mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Alerting: Alertmanager datasource support for upstream Prometheus AM implementation (#39775)
This commit is contained in:
@@ -3,7 +3,9 @@ package api
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/api/response"
|
"github.com/grafana/grafana/pkg/api/response"
|
||||||
@@ -14,13 +16,26 @@ import (
|
|||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var endpoints = map[string]map[string]string{
|
||||||
|
"cortex": {
|
||||||
|
"silences": "/alertmanager/api/v2/silences",
|
||||||
|
"silence": "/alertmanager/api/v2/silence/%s",
|
||||||
|
"status": "/alertmanager/api/v2/status",
|
||||||
|
"groups": "/alertmanager/api/v2/alerts/groups",
|
||||||
|
"alerts": "/alertmanager/api/v2/alerts",
|
||||||
|
"config": "/api/v1/alerts",
|
||||||
|
},
|
||||||
|
"prometheus": {
|
||||||
|
"silences": "/api/v2/silences",
|
||||||
|
"silence": "/api/v2/silence/%s",
|
||||||
|
"status": "/api/v2/status",
|
||||||
|
"groups": "/api/v2/alerts/groups",
|
||||||
|
"alerts": "/api/v2/alerts",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
amSilencesPath = "/alertmanager/api/v2/silences"
|
defaultImplementation = "cortex"
|
||||||
amSilencePath = "/alertmanager/api/v2/silence/%s"
|
|
||||||
amStatusPath = "/alertmanager/api/v2/status"
|
|
||||||
amAlertGroupsPath = "/alertmanager/api/v2/alerts/groups"
|
|
||||||
amAlertsPath = "/alertmanager/api/v2/alerts"
|
|
||||||
amConfigPath = "/api/v1/alerts"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type LotexAM struct {
|
type LotexAM struct {
|
||||||
@@ -35,14 +50,57 @@ func NewLotexAM(proxy *AlertingProxy, log log.Logger) *LotexAM {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (am *LotexAM) RouteGetAMStatus(ctx *models.ReqContext) response.Response {
|
func (am *LotexAM) withAMReq(
|
||||||
|
ctx *models.ReqContext,
|
||||||
|
method string,
|
||||||
|
endpoint string,
|
||||||
|
pathParams []string,
|
||||||
|
body io.Reader,
|
||||||
|
extractor func(*response.NormalResponse) (interface{}, error),
|
||||||
|
headers map[string]string,
|
||||||
|
) response.Response {
|
||||||
|
ds, err := am.DataProxy.DataSourceCache.GetDatasource(ctx.ParamsInt64(":Recipient"), ctx.SignedInUser, ctx.SkipCache)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, models.ErrDataSourceAccessDenied) {
|
||||||
|
return ErrResp(http.StatusForbidden, err, "Access denied to datasource")
|
||||||
|
}
|
||||||
|
if errors.Is(err, models.ErrDataSourceNotFound) {
|
||||||
|
return ErrResp(http.StatusNotFound, err, "Unable to find datasource")
|
||||||
|
}
|
||||||
|
return ErrResp(http.StatusInternalServerError, err, "Unable to load datasource meta data")
|
||||||
|
}
|
||||||
|
|
||||||
|
impl := ds.JsonData.Get("implementation").MustString(defaultImplementation)
|
||||||
|
implEndpoints, ok := endpoints[impl]
|
||||||
|
if !ok {
|
||||||
|
return ErrResp(http.StatusBadRequest, fmt.Errorf("unsupported Alert Manager implementation \"%s\"", impl), "")
|
||||||
|
}
|
||||||
|
endpointPath, ok := implEndpoints[endpoint]
|
||||||
|
if !ok {
|
||||||
|
return ErrResp(http.StatusBadRequest, fmt.Errorf("unsupported endpoint \"%s\" for Alert Manager implementation \"%s\"", endpoint, impl), "")
|
||||||
|
}
|
||||||
|
|
||||||
|
iPathParams := make([]interface{}, len(pathParams))
|
||||||
|
for idx, value := range pathParams {
|
||||||
|
iPathParams[idx] = value
|
||||||
|
}
|
||||||
|
|
||||||
return am.withReq(
|
return am.withReq(
|
||||||
|
ctx,
|
||||||
|
method,
|
||||||
|
withPath(*ctx.Req.URL, fmt.Sprintf(endpointPath, iPathParams...)),
|
||||||
|
body,
|
||||||
|
extractor,
|
||||||
|
headers,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (am *LotexAM) RouteGetAMStatus(ctx *models.ReqContext) response.Response {
|
||||||
|
return am.withAMReq(
|
||||||
ctx,
|
ctx,
|
||||||
http.MethodGet,
|
http.MethodGet,
|
||||||
withPath(
|
"status",
|
||||||
*ctx.Req.URL,
|
nil,
|
||||||
amStatusPath,
|
|
||||||
),
|
|
||||||
nil,
|
nil,
|
||||||
jsonExtractor(&apimodels.GettableStatus{}),
|
jsonExtractor(&apimodels.GettableStatus{}),
|
||||||
nil,
|
nil,
|
||||||
@@ -54,10 +112,11 @@ func (am *LotexAM) RouteCreateSilence(ctx *models.ReqContext, silenceBody apimod
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return ErrResp(500, err, "Failed marshal silence")
|
return ErrResp(500, err, "Failed marshal silence")
|
||||||
}
|
}
|
||||||
return am.withReq(
|
return am.withAMReq(
|
||||||
ctx,
|
ctx,
|
||||||
http.MethodPost,
|
http.MethodPost,
|
||||||
withPath(*ctx.Req.URL, amSilencesPath),
|
"silences",
|
||||||
|
nil,
|
||||||
bytes.NewBuffer(blob),
|
bytes.NewBuffer(blob),
|
||||||
jsonExtractor(&apimodels.GettableSilence{}),
|
jsonExtractor(&apimodels.GettableSilence{}),
|
||||||
map[string]string{"Content-Type": "application/json"},
|
map[string]string{"Content-Type": "application/json"},
|
||||||
@@ -65,13 +124,11 @@ func (am *LotexAM) RouteCreateSilence(ctx *models.ReqContext, silenceBody apimod
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (am *LotexAM) RouteDeleteAlertingConfig(ctx *models.ReqContext) response.Response {
|
func (am *LotexAM) RouteDeleteAlertingConfig(ctx *models.ReqContext) response.Response {
|
||||||
return am.withReq(
|
return am.withAMReq(
|
||||||
ctx,
|
ctx,
|
||||||
http.MethodDelete,
|
http.MethodDelete,
|
||||||
withPath(
|
"config",
|
||||||
*ctx.Req.URL,
|
nil,
|
||||||
amConfigPath,
|
|
||||||
),
|
|
||||||
nil,
|
nil,
|
||||||
messageExtractor,
|
messageExtractor,
|
||||||
nil,
|
nil,
|
||||||
@@ -79,13 +136,11 @@ func (am *LotexAM) RouteDeleteAlertingConfig(ctx *models.ReqContext) response.Re
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (am *LotexAM) RouteDeleteSilence(ctx *models.ReqContext) response.Response {
|
func (am *LotexAM) RouteDeleteSilence(ctx *models.ReqContext) response.Response {
|
||||||
return am.withReq(
|
return am.withAMReq(
|
||||||
ctx,
|
ctx,
|
||||||
http.MethodDelete,
|
http.MethodDelete,
|
||||||
withPath(
|
"silence",
|
||||||
*ctx.Req.URL,
|
[]string{macaron.Params(ctx.Req)[":SilenceId"]},
|
||||||
fmt.Sprintf(amSilencePath, macaron.Params(ctx.Req)[":SilenceId"]),
|
|
||||||
),
|
|
||||||
nil,
|
nil,
|
||||||
messageExtractor,
|
messageExtractor,
|
||||||
nil,
|
nil,
|
||||||
@@ -93,13 +148,11 @@ func (am *LotexAM) RouteDeleteSilence(ctx *models.ReqContext) response.Response
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (am *LotexAM) RouteGetAlertingConfig(ctx *models.ReqContext) response.Response {
|
func (am *LotexAM) RouteGetAlertingConfig(ctx *models.ReqContext) response.Response {
|
||||||
return am.withReq(
|
return am.withAMReq(
|
||||||
ctx,
|
ctx,
|
||||||
http.MethodGet,
|
http.MethodGet,
|
||||||
withPath(
|
"config",
|
||||||
*ctx.Req.URL,
|
nil,
|
||||||
amConfigPath,
|
|
||||||
),
|
|
||||||
nil,
|
nil,
|
||||||
yamlExtractor(&apimodels.GettableUserConfig{}),
|
yamlExtractor(&apimodels.GettableUserConfig{}),
|
||||||
nil,
|
nil,
|
||||||
@@ -107,13 +160,11 @@ func (am *LotexAM) RouteGetAlertingConfig(ctx *models.ReqContext) response.Respo
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (am *LotexAM) RouteGetAMAlertGroups(ctx *models.ReqContext) response.Response {
|
func (am *LotexAM) RouteGetAMAlertGroups(ctx *models.ReqContext) response.Response {
|
||||||
return am.withReq(
|
return am.withAMReq(
|
||||||
ctx,
|
ctx,
|
||||||
http.MethodGet,
|
http.MethodGet,
|
||||||
withPath(
|
"groups",
|
||||||
*ctx.Req.URL,
|
nil,
|
||||||
amAlertGroupsPath,
|
|
||||||
),
|
|
||||||
nil,
|
nil,
|
||||||
jsonExtractor(&apimodels.AlertGroups{}),
|
jsonExtractor(&apimodels.AlertGroups{}),
|
||||||
nil,
|
nil,
|
||||||
@@ -121,13 +172,11 @@ func (am *LotexAM) RouteGetAMAlertGroups(ctx *models.ReqContext) response.Respon
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (am *LotexAM) RouteGetAMAlerts(ctx *models.ReqContext) response.Response {
|
func (am *LotexAM) RouteGetAMAlerts(ctx *models.ReqContext) response.Response {
|
||||||
return am.withReq(
|
return am.withAMReq(
|
||||||
ctx,
|
ctx,
|
||||||
http.MethodGet,
|
http.MethodGet,
|
||||||
withPath(
|
"alerts",
|
||||||
*ctx.Req.URL,
|
nil,
|
||||||
amAlertsPath,
|
|
||||||
),
|
|
||||||
nil,
|
nil,
|
||||||
jsonExtractor(&apimodels.GettableAlerts{}),
|
jsonExtractor(&apimodels.GettableAlerts{}),
|
||||||
nil,
|
nil,
|
||||||
@@ -135,13 +184,11 @@ func (am *LotexAM) RouteGetAMAlerts(ctx *models.ReqContext) response.Response {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (am *LotexAM) RouteGetSilence(ctx *models.ReqContext) response.Response {
|
func (am *LotexAM) RouteGetSilence(ctx *models.ReqContext) response.Response {
|
||||||
return am.withReq(
|
return am.withAMReq(
|
||||||
ctx,
|
ctx,
|
||||||
http.MethodGet,
|
http.MethodGet,
|
||||||
withPath(
|
"silence",
|
||||||
*ctx.Req.URL,
|
[]string{macaron.Params(ctx.Req)[":SilenceId"]},
|
||||||
fmt.Sprintf(amSilencePath, macaron.Params(ctx.Req)[":SilenceId"]),
|
|
||||||
),
|
|
||||||
nil,
|
nil,
|
||||||
jsonExtractor(&apimodels.GettableSilence{}),
|
jsonExtractor(&apimodels.GettableSilence{}),
|
||||||
nil,
|
nil,
|
||||||
@@ -149,13 +196,11 @@ func (am *LotexAM) RouteGetSilence(ctx *models.ReqContext) response.Response {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (am *LotexAM) RouteGetSilences(ctx *models.ReqContext) response.Response {
|
func (am *LotexAM) RouteGetSilences(ctx *models.ReqContext) response.Response {
|
||||||
return am.withReq(
|
return am.withAMReq(
|
||||||
ctx,
|
ctx,
|
||||||
http.MethodGet,
|
http.MethodGet,
|
||||||
withPath(
|
"silences",
|
||||||
*ctx.Req.URL,
|
nil,
|
||||||
amSilencesPath,
|
|
||||||
),
|
|
||||||
nil,
|
nil,
|
||||||
jsonExtractor(&apimodels.GettableSilences{}),
|
jsonExtractor(&apimodels.GettableSilences{}),
|
||||||
nil,
|
nil,
|
||||||
@@ -168,10 +213,11 @@ func (am *LotexAM) RoutePostAlertingConfig(ctx *models.ReqContext, config apimod
|
|||||||
return ErrResp(500, err, "Failed marshal alert manager configuration ")
|
return ErrResp(500, err, "Failed marshal alert manager configuration ")
|
||||||
}
|
}
|
||||||
|
|
||||||
return am.withReq(
|
return am.withAMReq(
|
||||||
ctx,
|
ctx,
|
||||||
http.MethodPost,
|
http.MethodPost,
|
||||||
withPath(*ctx.Req.URL, amConfigPath),
|
"config",
|
||||||
|
nil,
|
||||||
bytes.NewBuffer(yml),
|
bytes.NewBuffer(yml),
|
||||||
messageExtractor,
|
messageExtractor,
|
||||||
nil,
|
nil,
|
||||||
@@ -184,10 +230,11 @@ func (am *LotexAM) RoutePostAMAlerts(ctx *models.ReqContext, alerts apimodels.Po
|
|||||||
return ErrResp(500, err, "Failed marshal postable alerts")
|
return ErrResp(500, err, "Failed marshal postable alerts")
|
||||||
}
|
}
|
||||||
|
|
||||||
return am.withReq(
|
return am.withAMReq(
|
||||||
ctx,
|
ctx,
|
||||||
http.MethodPost,
|
http.MethodPost,
|
||||||
withPath(*ctx.Req.URL, amAlertsPath),
|
"alerts",
|
||||||
|
nil,
|
||||||
bytes.NewBuffer(yml),
|
bytes.NewBuffer(yml),
|
||||||
messageExtractor,
|
messageExtractor,
|
||||||
nil,
|
nil,
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { typeAsJestMock } from 'test/helpers/typeAsJestMock';
|
import { typeAsJestMock } from 'test/helpers/typeAsJestMock';
|
||||||
import { getAllDataSources } from './utils/config';
|
import { getAllDataSources } from './utils/config';
|
||||||
import { fetchAlertManagerConfig, deleteAlertManagerConfig, updateAlertManagerConfig } from './api/alertmanager';
|
import {
|
||||||
|
fetchAlertManagerConfig,
|
||||||
|
deleteAlertManagerConfig,
|
||||||
|
updateAlertManagerConfig,
|
||||||
|
fetchStatus,
|
||||||
|
} from './api/alertmanager';
|
||||||
import { configureStore } from 'app/store/configureStore';
|
import { configureStore } from 'app/store/configureStore';
|
||||||
import { locationService, setDataSourceSrv } from '@grafana/runtime';
|
import { locationService, setDataSourceSrv } from '@grafana/runtime';
|
||||||
import Admin from './Admin';
|
import Admin from './Admin';
|
||||||
@@ -9,13 +14,17 @@ import { Provider } from 'react-redux';
|
|||||||
import { Router } from 'react-router-dom';
|
import { Router } from 'react-router-dom';
|
||||||
import { ALERTMANAGER_NAME_LOCAL_STORAGE_KEY, ALERTMANAGER_NAME_QUERY_KEY } from './utils/constants';
|
import { ALERTMANAGER_NAME_LOCAL_STORAGE_KEY, ALERTMANAGER_NAME_QUERY_KEY } from './utils/constants';
|
||||||
import { render, waitFor } from '@testing-library/react';
|
import { render, waitFor } from '@testing-library/react';
|
||||||
import { byLabelText, byRole } from 'testing-library-selector';
|
import { byLabelText, byRole, byTestId } from 'testing-library-selector';
|
||||||
import { mockDataSource, MockDataSourceSrv } from './mocks';
|
import { mockDataSource, MockDataSourceSrv, someCloudAlertManagerConfig, someCloudAlertManagerStatus } from './mocks';
|
||||||
import { DataSourceType } from './utils/datasource';
|
import { DataSourceType } from './utils/datasource';
|
||||||
import { contextSrv } from 'app/core/services/context_srv';
|
import { contextSrv } from 'app/core/services/context_srv';
|
||||||
import store from 'app/core/store';
|
import store from 'app/core/store';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types';
|
import {
|
||||||
|
AlertManagerCortexConfig,
|
||||||
|
AlertManagerDataSourceJsonData,
|
||||||
|
AlertManagerImplementation,
|
||||||
|
} from 'app/plugins/datasource/alertmanager/types';
|
||||||
|
|
||||||
jest.mock('./api/alertmanager');
|
jest.mock('./api/alertmanager');
|
||||||
jest.mock('./api/grafana');
|
jest.mock('./api/grafana');
|
||||||
@@ -28,6 +37,7 @@ const mocks = {
|
|||||||
fetchConfig: typeAsJestMock(fetchAlertManagerConfig),
|
fetchConfig: typeAsJestMock(fetchAlertManagerConfig),
|
||||||
deleteAlertManagerConfig: typeAsJestMock(deleteAlertManagerConfig),
|
deleteAlertManagerConfig: typeAsJestMock(deleteAlertManagerConfig),
|
||||||
updateAlertManagerConfig: typeAsJestMock(updateAlertManagerConfig),
|
updateAlertManagerConfig: typeAsJestMock(updateAlertManagerConfig),
|
||||||
|
fetchStatus: typeAsJestMock(fetchStatus),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -53,6 +63,13 @@ const dataSources = {
|
|||||||
name: 'CloudManager',
|
name: 'CloudManager',
|
||||||
type: DataSourceType.Alertmanager,
|
type: DataSourceType.Alertmanager,
|
||||||
}),
|
}),
|
||||||
|
promAlertManager: mockDataSource<AlertManagerDataSourceJsonData>({
|
||||||
|
name: 'PromManager',
|
||||||
|
type: DataSourceType.Alertmanager,
|
||||||
|
jsonData: {
|
||||||
|
implementation: AlertManagerImplementation.prometheus,
|
||||||
|
},
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const ui = {
|
const ui = {
|
||||||
@@ -60,6 +77,7 @@ const ui = {
|
|||||||
resetButton: byRole('button', { name: /Reset configuration/ }),
|
resetButton: byRole('button', { name: /Reset configuration/ }),
|
||||||
saveButton: byRole('button', { name: /Save/ }),
|
saveButton: byRole('button', { name: /Save/ }),
|
||||||
configInput: byLabelText<HTMLTextAreaElement>(/Configuration/),
|
configInput: byLabelText<HTMLTextAreaElement>(/Configuration/),
|
||||||
|
readOnlyConfig: byTestId('readonly-config'),
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('Alerting Admin', () => {
|
describe('Alerting Admin', () => {
|
||||||
@@ -117,4 +135,20 @@ describe('Alerting Admin', () => {
|
|||||||
await waitFor(() => expect(mocks.api.fetchConfig).toHaveBeenCalledTimes(3));
|
await waitFor(() => expect(mocks.api.fetchConfig).toHaveBeenCalledTimes(3));
|
||||||
expect(input.value).toEqual(JSON.stringify(newConfig, null, 2));
|
expect(input.value).toEqual(JSON.stringify(newConfig, null, 2));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('Read-only when using Prometheus Alertmanager', async () => {
|
||||||
|
mocks.api.fetchStatus.mockResolvedValue({
|
||||||
|
...someCloudAlertManagerStatus,
|
||||||
|
config: someCloudAlertManagerConfig.alertmanager_config,
|
||||||
|
});
|
||||||
|
await renderAdminPage(dataSources.promAlertManager.name);
|
||||||
|
|
||||||
|
await ui.readOnlyConfig.find();
|
||||||
|
expect(ui.configInput.query()).not.toBeInTheDocument();
|
||||||
|
expect(ui.resetButton.query()).not.toBeInTheDocument();
|
||||||
|
expect(ui.saveButton.query()).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
expect(mocks.api.fetchConfig).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.api.fetchStatus).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Alert, Button, ConfirmModal, TextArea, HorizontalGroup, Field, Form } f
|
|||||||
import { useAlertManagerSourceName } from './hooks/useAlertManagerSourceName';
|
import { useAlertManagerSourceName } from './hooks/useAlertManagerSourceName';
|
||||||
import { AlertingPageWrapper } from './components/AlertingPageWrapper';
|
import { AlertingPageWrapper } from './components/AlertingPageWrapper';
|
||||||
import { AlertManagerPicker } from './components/AlertManagerPicker';
|
import { AlertManagerPicker } from './components/AlertManagerPicker';
|
||||||
import { GRAFANA_RULES_SOURCE_NAME } from './utils/datasource';
|
import { GRAFANA_RULES_SOURCE_NAME, isVanillaPrometheusAlertManagerDataSource } from './utils/datasource';
|
||||||
import { useDispatch } from 'react-redux';
|
import { useDispatch } from 'react-redux';
|
||||||
import {
|
import {
|
||||||
deleteAlertManagerConfigAction,
|
deleteAlertManagerConfigAction,
|
||||||
@@ -23,6 +23,7 @@ export default function Admin(): JSX.Element {
|
|||||||
const [showConfirmDeleteAMConfig, setShowConfirmDeleteAMConfig] = useState(false);
|
const [showConfirmDeleteAMConfig, setShowConfirmDeleteAMConfig] = useState(false);
|
||||||
const { loading: isDeleting } = useUnifiedAlertingSelector((state) => state.deleteAMConfig);
|
const { loading: isDeleting } = useUnifiedAlertingSelector((state) => state.deleteAMConfig);
|
||||||
const { loading: isSaving } = useUnifiedAlertingSelector((state) => state.saveAMConfig);
|
const { loading: isSaving } = useUnifiedAlertingSelector((state) => state.saveAMConfig);
|
||||||
|
const readOnly = alertManagerSourceName ? isVanillaPrometheusAlertManagerDataSource(alertManagerSourceName) : false;
|
||||||
|
|
||||||
const configRequests = useUnifiedAlertingSelector((state) => state.amConfigs);
|
const configRequests = useUnifiedAlertingSelector((state) => state.amConfigs);
|
||||||
|
|
||||||
@@ -82,41 +83,50 @@ export default function Admin(): JSX.Element {
|
|||||||
<Form defaultValues={defaultValues} onSubmit={onSubmit} key={defaultValues.configJSON}>
|
<Form defaultValues={defaultValues} onSubmit={onSubmit} key={defaultValues.configJSON}>
|
||||||
{({ register, errors }) => (
|
{({ register, errors }) => (
|
||||||
<>
|
<>
|
||||||
<Field
|
{!readOnly && (
|
||||||
disabled={loading}
|
<Field
|
||||||
label="Configuration"
|
|
||||||
invalid={!!errors.configJSON}
|
|
||||||
error={errors.configJSON?.message}
|
|
||||||
>
|
|
||||||
<TextArea
|
|
||||||
{...register('configJSON', {
|
|
||||||
required: { value: true, message: 'Required.' },
|
|
||||||
validate: (v) => {
|
|
||||||
try {
|
|
||||||
JSON.parse(v);
|
|
||||||
return true;
|
|
||||||
} catch (e) {
|
|
||||||
return e.message;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})}
|
|
||||||
id="configuration"
|
|
||||||
rows={25}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<HorizontalGroup>
|
|
||||||
<Button type="submit" variant="primary" disabled={loading}>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
variant="destructive"
|
label="Configuration"
|
||||||
onClick={() => setShowConfirmDeleteAMConfig(true)}
|
invalid={!!errors.configJSON}
|
||||||
|
error={errors.configJSON?.message}
|
||||||
>
|
>
|
||||||
Reset configuration
|
<TextArea
|
||||||
</Button>
|
{...register('configJSON', {
|
||||||
</HorizontalGroup>
|
required: { value: true, message: 'Required.' },
|
||||||
|
validate: (v) => {
|
||||||
|
try {
|
||||||
|
JSON.parse(v);
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
return e.message;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
id="configuration"
|
||||||
|
rows={25}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
{readOnly && (
|
||||||
|
<Field label="Configuration">
|
||||||
|
<pre data-testid="readonly-config">{defaultValues.configJSON}</pre>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
{!readOnly && (
|
||||||
|
<HorizontalGroup>
|
||||||
|
<Button type="submit" variant="primary" disabled={loading}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
disabled={loading}
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => setShowConfirmDeleteAMConfig(true)}
|
||||||
|
>
|
||||||
|
Reset configuration
|
||||||
|
</Button>
|
||||||
|
</HorizontalGroup>
|
||||||
|
)}
|
||||||
{!!showConfirmDeleteAMConfig && (
|
{!!showConfirmDeleteAMConfig && (
|
||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
isOpen={true}
|
isOpen={true}
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ const AlertGroups = () => {
|
|||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{results && !filteredAlertGroups.length && <p>No results.</p>}
|
||||||
</AlertingPageWrapper>
|
</AlertingPageWrapper>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,17 +3,23 @@ import { locationService, setDataSourceSrv } from '@grafana/runtime';
|
|||||||
import { render, waitFor } from '@testing-library/react';
|
import { render, waitFor } from '@testing-library/react';
|
||||||
import { Provider } from 'react-redux';
|
import { Provider } from 'react-redux';
|
||||||
import { Router } from 'react-router-dom';
|
import { Router } from 'react-router-dom';
|
||||||
import { AlertManagerCortexConfig, Route } from 'app/plugins/datasource/alertmanager/types';
|
import {
|
||||||
|
AlertManagerCortexConfig,
|
||||||
|
AlertManagerDataSourceJsonData,
|
||||||
|
AlertManagerImplementation,
|
||||||
|
Route,
|
||||||
|
} from 'app/plugins/datasource/alertmanager/types';
|
||||||
import { configureStore } from 'app/store/configureStore';
|
import { configureStore } from 'app/store/configureStore';
|
||||||
import { typeAsJestMock } from 'test/helpers/typeAsJestMock';
|
import { typeAsJestMock } from 'test/helpers/typeAsJestMock';
|
||||||
import { byRole, byTestId, byText } from 'testing-library-selector';
|
import { byRole, byTestId, byText } from 'testing-library-selector';
|
||||||
import AmRoutes from './AmRoutes';
|
import AmRoutes from './AmRoutes';
|
||||||
import { fetchAlertManagerConfig, updateAlertManagerConfig } from './api/alertmanager';
|
import { fetchAlertManagerConfig, fetchStatus, updateAlertManagerConfig } from './api/alertmanager';
|
||||||
import { mockDataSource, MockDataSourceSrv } from './mocks';
|
import { mockDataSource, MockDataSourceSrv, someCloudAlertManagerConfig, someCloudAlertManagerStatus } from './mocks';
|
||||||
import { getAllDataSources } from './utils/config';
|
import { getAllDataSources } from './utils/config';
|
||||||
import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from './utils/datasource';
|
import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from './utils/datasource';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { selectOptionInTest } from '@grafana/ui';
|
import { selectOptionInTest } from '@grafana/ui';
|
||||||
|
import { ALERTMANAGER_NAME_QUERY_KEY } from './utils/constants';
|
||||||
|
|
||||||
jest.mock('./api/alertmanager');
|
jest.mock('./api/alertmanager');
|
||||||
jest.mock('./utils/config');
|
jest.mock('./utils/config');
|
||||||
@@ -24,12 +30,17 @@ const mocks = {
|
|||||||
api: {
|
api: {
|
||||||
fetchAlertManagerConfig: typeAsJestMock(fetchAlertManagerConfig),
|
fetchAlertManagerConfig: typeAsJestMock(fetchAlertManagerConfig),
|
||||||
updateAlertManagerConfig: typeAsJestMock(updateAlertManagerConfig),
|
updateAlertManagerConfig: typeAsJestMock(updateAlertManagerConfig),
|
||||||
|
fetchStatus: typeAsJestMock(fetchStatus),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderAmRoutes = () => {
|
const renderAmRoutes = (alertManagerSourceName?: string) => {
|
||||||
const store = configureStore();
|
const store = configureStore();
|
||||||
|
|
||||||
|
locationService.push(
|
||||||
|
'/alerting/routes' + (alertManagerSourceName ? `?${ALERTMANAGER_NAME_QUERY_KEY}=${alertManagerSourceName}` : '')
|
||||||
|
);
|
||||||
|
|
||||||
return render(
|
return render(
|
||||||
<Provider store={store}>
|
<Provider store={store}>
|
||||||
<Router history={locationService.getHistory()}>
|
<Router history={locationService.getHistory()}>
|
||||||
@@ -44,6 +55,13 @@ const dataSources = {
|
|||||||
name: 'Alertmanager',
|
name: 'Alertmanager',
|
||||||
type: DataSourceType.Alertmanager,
|
type: DataSourceType.Alertmanager,
|
||||||
}),
|
}),
|
||||||
|
promAlertManager: mockDataSource<AlertManagerDataSourceJsonData>({
|
||||||
|
name: 'PromManager',
|
||||||
|
type: DataSourceType.Alertmanager,
|
||||||
|
jsonData: {
|
||||||
|
implementation: AlertManagerImplementation.prometheus,
|
||||||
|
},
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const ui = {
|
const ui = {
|
||||||
@@ -57,6 +75,10 @@ const ui = {
|
|||||||
editButton: byRole('button', { name: 'Edit' }),
|
editButton: byRole('button', { name: 'Edit' }),
|
||||||
saveButton: byRole('button', { name: 'Save' }),
|
saveButton: byRole('button', { name: 'Save' }),
|
||||||
|
|
||||||
|
editRouteButton: byTestId('edit-route'),
|
||||||
|
deleteRouteButton: byTestId('delete-route'),
|
||||||
|
newPolicyButton: byRole('button', { name: /New policy/ }),
|
||||||
|
|
||||||
receiverSelect: byTestId('am-receiver-select'),
|
receiverSelect: byTestId('am-receiver-select'),
|
||||||
groupSelect: byTestId('am-group-select'),
|
groupSelect: byTestId('am-group-select'),
|
||||||
|
|
||||||
@@ -313,7 +335,7 @@ describe('AmRoutes', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Show error message if loading Alermanager config fails', async () => {
|
it('Show error message if loading Alertmanager config fails', async () => {
|
||||||
mocks.api.fetchAlertManagerConfig.mockRejectedValue({
|
mocks.api.fetchAlertManagerConfig.mockRejectedValue({
|
||||||
status: 500,
|
status: 500,
|
||||||
data: {
|
data: {
|
||||||
@@ -326,6 +348,24 @@ describe('AmRoutes', () => {
|
|||||||
expect(ui.rootReceiver.query()).not.toBeInTheDocument();
|
expect(ui.rootReceiver.query()).not.toBeInTheDocument();
|
||||||
expect(ui.editButton.query()).not.toBeInTheDocument();
|
expect(ui.editButton.query()).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('Prometheus Alertmanager routes cannot be edited', async () => {
|
||||||
|
mocks.api.fetchStatus.mockResolvedValue({
|
||||||
|
...someCloudAlertManagerStatus,
|
||||||
|
config: someCloudAlertManagerConfig.alertmanager_config,
|
||||||
|
});
|
||||||
|
await renderAmRoutes(dataSources.promAlertManager.name);
|
||||||
|
const rootRouteContainer = await ui.rootRouteContainer.find();
|
||||||
|
expect(ui.editButton.query(rootRouteContainer)).not.toBeInTheDocument();
|
||||||
|
const rows = await ui.row.findAll();
|
||||||
|
expect(rows).toHaveLength(2);
|
||||||
|
expect(ui.editRouteButton.query()).not.toBeInTheDocument();
|
||||||
|
expect(ui.deleteRouteButton.query()).not.toBeInTheDocument();
|
||||||
|
expect(ui.saveButton.query()).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
expect(mocks.api.fetchAlertManagerConfig).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.api.fetchStatus).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const clickSelectOption = async (selectElement: HTMLElement, optionText: string): Promise<void> => {
|
const clickSelectOption = async (selectElement: HTMLElement, optionText: string): Promise<void> => {
|
||||||
|
|||||||
@@ -16,13 +16,16 @@ import { fetchAlertManagerConfigAction, updateAlertManagerConfigAction } from '.
|
|||||||
import { AmRouteReceiver, FormAmRoute } from './types/amroutes';
|
import { AmRouteReceiver, FormAmRoute } from './types/amroutes';
|
||||||
import { amRouteToFormAmRoute, formAmRouteToAmRoute, stringsToSelectableValues } from './utils/amroutes';
|
import { amRouteToFormAmRoute, formAmRouteToAmRoute, stringsToSelectableValues } from './utils/amroutes';
|
||||||
import { initialAsyncRequestState } from './utils/redux';
|
import { initialAsyncRequestState } from './utils/redux';
|
||||||
|
import { isVanillaPrometheusAlertManagerDataSource } from './utils/datasource';
|
||||||
|
|
||||||
const AmRoutes: FC = () => {
|
const AmRoutes: FC = () => {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
const [isRootRouteEditMode, setIsRootRouteEditMode] = useState(false);
|
const [isRootRouteEditMode, setIsRootRouteEditMode] = useState(false);
|
||||||
|
|
||||||
const [alertManagerSourceName, setAlertManagerSourceName] = useAlertManagerSourceName();
|
const [alertManagerSourceName, setAlertManagerSourceName] = useAlertManagerSourceName();
|
||||||
|
|
||||||
|
const readOnly = alertManagerSourceName ? isVanillaPrometheusAlertManagerDataSource(alertManagerSourceName) : true;
|
||||||
|
|
||||||
const amConfigs = useUnifiedAlertingSelector((state) => state.amConfigs);
|
const amConfigs = useUnifiedAlertingSelector((state) => state.amConfigs);
|
||||||
|
|
||||||
const fetchConfig = useCallback(() => {
|
const fetchConfig = useCallback(() => {
|
||||||
@@ -111,6 +114,7 @@ const AmRoutes: FC = () => {
|
|||||||
<div className={styles.break} />
|
<div className={styles.break} />
|
||||||
<AmSpecificRouting
|
<AmSpecificRouting
|
||||||
onChange={handleSave}
|
onChange={handleSave}
|
||||||
|
readOnly={readOnly}
|
||||||
onRootRouteEdit={enterRootRouteEditMode}
|
onRootRouteEdit={enterRootRouteEditMode}
|
||||||
receivers={receivers}
|
receivers={receivers}
|
||||||
routes={rootRoute}
|
routes={rootRoute}
|
||||||
|
|||||||
@@ -24,13 +24,14 @@ import { byTestId } from 'testing-library-selector';
|
|||||||
import { PrometheusDatasource } from 'app/plugins/datasource/prometheus/datasource';
|
import { PrometheusDatasource } from 'app/plugins/datasource/prometheus/datasource';
|
||||||
import { DataSourceApi } from '@grafana/data';
|
import { DataSourceApi } from '@grafana/data';
|
||||||
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
|
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
|
||||||
|
import { PromOptions } from 'app/plugins/datasource/prometheus/types';
|
||||||
|
|
||||||
jest.mock('./api/prometheus');
|
jest.mock('./api/prometheus');
|
||||||
jest.mock('./api/ruler');
|
jest.mock('./api/ruler');
|
||||||
jest.mock('./utils/config');
|
jest.mock('./utils/config');
|
||||||
|
|
||||||
const dataSources = {
|
const dataSources = {
|
||||||
prometheus: mockDataSource({
|
prometheus: mockDataSource<PromOptions>({
|
||||||
name: 'Prometheus',
|
name: 'Prometheus',
|
||||||
type: DataSourceType.Prometheus,
|
type: DataSourceType.Prometheus,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { ALERTMANAGER_NAME_LOCAL_STORAGE_KEY, ALERTMANAGER_NAME_QUERY_KEY } from
|
|||||||
import store from 'app/core/store';
|
import store from 'app/core/store';
|
||||||
import { contextSrv } from 'app/core/services/context_srv';
|
import { contextSrv } from 'app/core/services/context_srv';
|
||||||
import { selectOptionInTest } from '@grafana/ui';
|
import { selectOptionInTest } from '@grafana/ui';
|
||||||
|
import { AlertManagerDataSourceJsonData, AlertManagerImplementation } from 'app/plugins/datasource/alertmanager/types';
|
||||||
|
|
||||||
jest.mock('./api/alertmanager');
|
jest.mock('./api/alertmanager');
|
||||||
jest.mock('./api/grafana');
|
jest.mock('./api/grafana');
|
||||||
@@ -63,6 +64,13 @@ const dataSources = {
|
|||||||
name: 'CloudManager',
|
name: 'CloudManager',
|
||||||
type: DataSourceType.Alertmanager,
|
type: DataSourceType.Alertmanager,
|
||||||
}),
|
}),
|
||||||
|
promAlertManager: mockDataSource<AlertManagerDataSourceJsonData>({
|
||||||
|
name: 'PromManager',
|
||||||
|
type: DataSourceType.Alertmanager,
|
||||||
|
jsonData: {
|
||||||
|
implementation: AlertManagerImplementation.prometheus,
|
||||||
|
},
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const ui = {
|
const ui = {
|
||||||
@@ -70,6 +78,7 @@ const ui = {
|
|||||||
saveContactButton: byRole('button', { name: /save contact point/i }),
|
saveContactButton: byRole('button', { name: /save contact point/i }),
|
||||||
newContactPointTypeButton: byRole('button', { name: /new contact point type/i }),
|
newContactPointTypeButton: byRole('button', { name: /new contact point type/i }),
|
||||||
testContactPointButton: byRole('button', { name: /Test/ }),
|
testContactPointButton: byRole('button', { name: /Test/ }),
|
||||||
|
cancelButton: byTestId('cancel-button'),
|
||||||
|
|
||||||
receiversTable: byTestId('receivers-table'),
|
receiversTable: byTestId('receivers-table'),
|
||||||
templatesTable: byTestId('templates-table'),
|
templatesTable: byTestId('templates-table'),
|
||||||
@@ -81,6 +90,7 @@ const ui = {
|
|||||||
name: byLabelText('Name'),
|
name: byLabelText('Name'),
|
||||||
email: {
|
email: {
|
||||||
addresses: byLabelText(/Addresses/),
|
addresses: byLabelText(/Addresses/),
|
||||||
|
toEmails: byLabelText(/To/),
|
||||||
},
|
},
|
||||||
hipchat: {
|
hipchat: {
|
||||||
url: byLabelText('Hip Chat Url'),
|
url: byLabelText('Hip Chat Url'),
|
||||||
@@ -353,6 +363,42 @@ describe('Receivers', () => {
|
|||||||
});
|
});
|
||||||
}, 10000);
|
}, 10000);
|
||||||
|
|
||||||
|
it('Prometheus Alertmanager receiver cannot be edited', async () => {
|
||||||
|
mocks.api.fetchStatus.mockResolvedValue({
|
||||||
|
...someCloudAlertManagerStatus,
|
||||||
|
config: someCloudAlertManagerConfig.alertmanager_config,
|
||||||
|
});
|
||||||
|
await renderReceivers(dataSources.promAlertManager.name);
|
||||||
|
|
||||||
|
const receiversTable = await ui.receiversTable.find();
|
||||||
|
// there's no templates table for vanilla prom, API does not return templates
|
||||||
|
expect(ui.templatesTable.query()).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
// click view button on the receiver
|
||||||
|
const receiverRows = receiversTable.querySelectorAll<HTMLTableRowElement>('tbody tr');
|
||||||
|
expect(receiverRows[0]).toHaveTextContent('cloud-receiver');
|
||||||
|
expect(byTestId('edit').query(receiverRows[0])).not.toBeInTheDocument();
|
||||||
|
await userEvent.click(byTestId('view').get(receiverRows[0]));
|
||||||
|
|
||||||
|
// check that form is open
|
||||||
|
await byRole('heading', { name: /contact point/i }).find();
|
||||||
|
expect(locationService.getLocation().pathname).toEqual('/alerting/notifications/receivers/cloud-receiver/edit');
|
||||||
|
const channelForms = ui.channelFormContainer.queryAll();
|
||||||
|
expect(channelForms).toHaveLength(2);
|
||||||
|
|
||||||
|
// check that inputs are disabled and there is no save button
|
||||||
|
expect(ui.inputs.name.queryAll()[0]).toHaveAttribute('readonly');
|
||||||
|
expect(ui.inputs.email.toEmails.get(channelForms[0])).toHaveAttribute('readonly');
|
||||||
|
expect(ui.inputs.slack.webhookURL.get(channelForms[1])).toHaveAttribute('readonly');
|
||||||
|
expect(ui.newContactPointButton.query()).not.toBeInTheDocument();
|
||||||
|
expect(ui.testContactPointButton.query()).not.toBeInTheDocument();
|
||||||
|
expect(ui.saveContactButton.query()).not.toBeInTheDocument();
|
||||||
|
expect(ui.cancelButton.query()).toBeInTheDocument();
|
||||||
|
|
||||||
|
expect(mocks.api.fetchConfig).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.api.fetchStatus).toHaveBeenCalledTimes(1);
|
||||||
|
}, 10000);
|
||||||
|
|
||||||
it('Loads config from status endpoint if there is no user config', async () => {
|
it('Loads config from status endpoint if there is no user config', async () => {
|
||||||
// loading an empty config with make it fetch config from status endpoint
|
// loading an empty config with make it fetch config from status endpoint
|
||||||
mocks.api.fetchConfig.mockResolvedValue({
|
mocks.api.fetchConfig.mockResolvedValue({
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ const Silences: FC = () => {
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
{alertsRequest?.error && !alertsRequest?.loading && (
|
{alertsRequest?.error && !alertsRequest?.loading && (
|
||||||
<Alert severity="error" title="Error loading alert manager alerts">
|
<Alert severity="error" title="Error loading Alertmanager alerts">
|
||||||
{alertsRequest.error?.message || 'Unknown error.'}
|
{alertsRequest.error?.message || 'Unknown error.'}
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -79,7 +79,9 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
|||||||
`,
|
`,
|
||||||
filterInput: css`
|
filterInput: css`
|
||||||
width: 340px;
|
width: 340px;
|
||||||
margin-left: ${theme.spacing(1)};
|
& + & {
|
||||||
|
margin-left: ${theme.spacing(1)};
|
||||||
|
}
|
||||||
`,
|
`,
|
||||||
clearButton: css`
|
clearButton: css`
|
||||||
margin-left: ${theme.spacing(1)};
|
margin-left: ${theme.spacing(1)};
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Button, useStyles2 } from '@grafana/ui';
|
|||||||
import { AmRouteReceiver, FormAmRoute } from '../../types/amroutes';
|
import { AmRouteReceiver, FormAmRoute } from '../../types/amroutes';
|
||||||
import { AmRootRouteForm } from './AmRootRouteForm';
|
import { AmRootRouteForm } from './AmRootRouteForm';
|
||||||
import { AmRootRouteRead } from './AmRootRouteRead';
|
import { AmRootRouteRead } from './AmRootRouteRead';
|
||||||
|
import { isVanillaPrometheusAlertManagerDataSource } from '../../utils/datasource';
|
||||||
|
|
||||||
export interface AmRootRouteProps {
|
export interface AmRootRouteProps {
|
||||||
isEditMode: boolean;
|
isEditMode: boolean;
|
||||||
@@ -27,13 +28,15 @@ export const AmRootRoute: FC<AmRootRouteProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
|
|
||||||
|
const isReadOnly = isVanillaPrometheusAlertManagerDataSource(alertManagerSourceName);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container} data-testid="am-root-route-container">
|
<div className={styles.container} data-testid="am-root-route-container">
|
||||||
<div className={styles.titleContainer}>
|
<div className={styles.titleContainer}>
|
||||||
<h5 className={styles.title}>
|
<h5 className={styles.title}>
|
||||||
Root policy - <i>default for all alerts</i>
|
Root policy - <i>default for all alerts</i>
|
||||||
</h5>
|
</h5>
|
||||||
{!isEditMode && (
|
{!isEditMode && !isReadOnly && (
|
||||||
<Button icon="pen" onClick={onEnterEditMode} size="sm" type="button" variant="secondary">
|
<Button icon="pen" onClick={onEnterEditMode} size="sm" type="button" variant="secondary">
|
||||||
Edit
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -11,9 +11,15 @@ export interface AmRoutesExpandedReadProps {
|
|||||||
onChange: (routes: FormAmRoute) => void;
|
onChange: (routes: FormAmRoute) => void;
|
||||||
receivers: AmRouteReceiver[];
|
receivers: AmRouteReceiver[];
|
||||||
routes: FormAmRoute;
|
routes: FormAmRoute;
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AmRoutesExpandedRead: FC<AmRoutesExpandedReadProps> = ({ onChange, receivers, routes }) => {
|
export const AmRoutesExpandedRead: FC<AmRoutesExpandedReadProps> = ({
|
||||||
|
onChange,
|
||||||
|
receivers,
|
||||||
|
routes,
|
||||||
|
readOnly = false,
|
||||||
|
}) => {
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
const gridStyles = useStyles2(getGridStyles);
|
const gridStyles = useStyles2(getGridStyles);
|
||||||
|
|
||||||
@@ -66,7 +72,7 @@ export const AmRoutesExpandedRead: FC<AmRoutesExpandedReadProps> = ({ onChange,
|
|||||||
) : (
|
) : (
|
||||||
<p>No nested policies configured.</p>
|
<p>No nested policies configured.</p>
|
||||||
)}
|
)}
|
||||||
{!isAddMode && (
|
{!isAddMode && !readOnly && (
|
||||||
<Button
|
<Button
|
||||||
className={styles.addNestedRoutingBtn}
|
className={styles.addNestedRoutingBtn}
|
||||||
icon="plus"
|
icon="plus"
|
||||||
|
|||||||
@@ -14,12 +14,20 @@ export interface AmRoutesTableProps {
|
|||||||
onCancelAdd: () => void;
|
onCancelAdd: () => void;
|
||||||
receivers: AmRouteReceiver[];
|
receivers: AmRouteReceiver[];
|
||||||
routes: FormAmRoute[];
|
routes: FormAmRoute[];
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type RouteTableColumnProps = DynamicTableColumnProps<FormAmRoute>;
|
type RouteTableColumnProps = DynamicTableColumnProps<FormAmRoute>;
|
||||||
type RouteTableItemProps = DynamicTableItemProps<FormAmRoute>;
|
type RouteTableItemProps = DynamicTableItemProps<FormAmRoute>;
|
||||||
|
|
||||||
export const AmRoutesTable: FC<AmRoutesTableProps> = ({ isAddMode, onCancelAdd, onChange, receivers, routes }) => {
|
export const AmRoutesTable: FC<AmRoutesTableProps> = ({
|
||||||
|
isAddMode,
|
||||||
|
onCancelAdd,
|
||||||
|
onChange,
|
||||||
|
receivers,
|
||||||
|
routes,
|
||||||
|
readOnly = false,
|
||||||
|
}) => {
|
||||||
const [editMode, setEditMode] = useState(false);
|
const [editMode, setEditMode] = useState(false);
|
||||||
|
|
||||||
const [expandedId, setExpandedId] = useState<string | number>();
|
const [expandedId, setExpandedId] = useState<string | number>();
|
||||||
@@ -48,41 +56,53 @@ export const AmRoutesTable: FC<AmRoutesTableProps> = ({ isAddMode, onCancelAdd,
|
|||||||
renderCell: (item) => item.data.receiver || '-',
|
renderCell: (item) => item.data.receiver || '-',
|
||||||
size: 5,
|
size: 5,
|
||||||
},
|
},
|
||||||
{
|
...(readOnly
|
||||||
id: 'actions',
|
? []
|
||||||
label: 'Actions',
|
: [
|
||||||
// eslint-disable-next-line react/display-name
|
{
|
||||||
renderCell: (item, index) => {
|
id: 'actions',
|
||||||
if (item.renderExpandedContent) {
|
label: 'Actions',
|
||||||
return null;
|
// eslint-disable-next-line react/display-name
|
||||||
}
|
renderCell: (item, index) => {
|
||||||
|
if (item.renderExpandedContent) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const expandWithCustomContent = () => {
|
const expandWithCustomContent = () => {
|
||||||
expandItem(item);
|
expandItem(item);
|
||||||
setEditMode(true);
|
setEditMode(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HorizontalGroup>
|
<HorizontalGroup>
|
||||||
<Button icon="pen" onClick={expandWithCustomContent} size="sm" type="button" variant="secondary">
|
<Button
|
||||||
Edit
|
data-testid="edit-route"
|
||||||
</Button>
|
icon="pen"
|
||||||
<IconButton
|
onClick={expandWithCustomContent}
|
||||||
name="trash-alt"
|
size="sm"
|
||||||
onClick={() => {
|
type="button"
|
||||||
const newRoutes = [...routes];
|
variant="secondary"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<IconButton
|
||||||
|
data-testid="delete-route"
|
||||||
|
name="trash-alt"
|
||||||
|
onClick={() => {
|
||||||
|
const newRoutes = [...routes];
|
||||||
|
|
||||||
newRoutes.splice(index, 1);
|
newRoutes.splice(index, 1);
|
||||||
|
|
||||||
onChange(newRoutes);
|
onChange(newRoutes);
|
||||||
}}
|
}}
|
||||||
type="button"
|
type="button"
|
||||||
/>
|
/>
|
||||||
</HorizontalGroup>
|
</HorizontalGroup>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
size: '100px',
|
size: '100px',
|
||||||
},
|
} as RouteTableColumnProps,
|
||||||
|
]),
|
||||||
];
|
];
|
||||||
|
|
||||||
const items = useMemo(() => prepareItems(routes), [routes]);
|
const items = useMemo(() => prepareItems(routes), [routes]);
|
||||||
@@ -139,6 +159,7 @@ export const AmRoutesTable: FC<AmRoutesTableProps> = ({ isAddMode, onCancelAdd,
|
|||||||
}}
|
}}
|
||||||
receivers={receivers}
|
receivers={receivers}
|
||||||
routes={item.data}
|
routes={item.data}
|
||||||
|
readOnly={readOnly}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,9 +12,16 @@ export interface AmSpecificRoutingProps {
|
|||||||
onRootRouteEdit: () => void;
|
onRootRouteEdit: () => void;
|
||||||
receivers: AmRouteReceiver[];
|
receivers: AmRouteReceiver[];
|
||||||
routes: FormAmRoute;
|
routes: FormAmRoute;
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AmSpecificRouting: FC<AmSpecificRoutingProps> = ({ onChange, onRootRouteEdit, receivers, routes }) => {
|
export const AmSpecificRouting: FC<AmSpecificRoutingProps> = ({
|
||||||
|
onChange,
|
||||||
|
onRootRouteEdit,
|
||||||
|
receivers,
|
||||||
|
routes,
|
||||||
|
readOnly = false,
|
||||||
|
}) => {
|
||||||
const [actualRoutes, setActualRoutes] = useState(routes.routes);
|
const [actualRoutes, setActualRoutes] = useState(routes.routes);
|
||||||
const [isAddMode, setIsAddMode] = useState(false);
|
const [isAddMode, setIsAddMode] = useState(false);
|
||||||
|
|
||||||
@@ -44,13 +51,14 @@ export const AmSpecificRouting: FC<AmSpecificRoutingProps> = ({ onChange, onRoot
|
|||||||
/>
|
/>
|
||||||
) : actualRoutes.length > 0 ? (
|
) : actualRoutes.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
{!isAddMode && (
|
{!isAddMode && !readOnly && (
|
||||||
<Button className={styles.addMatcherBtn} icon="plus" onClick={addNewRoute} type="button">
|
<Button className={styles.addMatcherBtn} icon="plus" onClick={addNewRoute} type="button">
|
||||||
New policy
|
New policy
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<AmRoutesTable
|
<AmRoutesTable
|
||||||
isAddMode={isAddMode}
|
isAddMode={isAddMode}
|
||||||
|
readOnly={readOnly}
|
||||||
onCancelAdd={() => {
|
onCancelAdd={() => {
|
||||||
setIsAddMode(false);
|
setIsAddMode(false);
|
||||||
setActualRoutes((actualRoutes) => {
|
setActualRoutes((actualRoutes) => {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { updateAlertManagerConfigAction } from '../../state/actions';
|
|||||||
import { omitEmptyValues } from '../../utils/receiver-form';
|
import { omitEmptyValues } from '../../utils/receiver-form';
|
||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
import { GrafanaTheme2 } from '@grafana/data';
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
|
import { isVanillaPrometheusAlertManagerDataSource } from '../../utils/datasource';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
config: AlertManagerCortexConfig;
|
config: AlertManagerCortexConfig;
|
||||||
@@ -28,7 +29,7 @@ export const GlobalConfigForm: FC<Props> = ({ config, alertManagerSourceName })
|
|||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
useCleanup((state) => state.unifiedAlerting.saveAMConfig);
|
useCleanup((state) => state.unifiedAlerting.saveAMConfig);
|
||||||
const { loading, error } = useUnifiedAlertingSelector((state) => state.saveAMConfig);
|
const { loading, error } = useUnifiedAlertingSelector((state) => state.saveAMConfig);
|
||||||
|
const readOnly = isVanillaPrometheusAlertManagerDataSource(alertManagerSourceName);
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
|
|
||||||
const formAPI = useForm<FormValues>({
|
const formAPI = useForm<FormValues>({
|
||||||
@@ -75,6 +76,7 @@ export const GlobalConfigForm: FC<Props> = ({ config, alertManagerSourceName })
|
|||||||
)}
|
)}
|
||||||
{globalConfigOptions.map((option) => (
|
{globalConfigOptions.map((option) => (
|
||||||
<OptionField
|
<OptionField
|
||||||
|
readOnly={readOnly}
|
||||||
defaultValue={defaultValues[option.propertyName]}
|
defaultValue={defaultValues[option.propertyName]}
|
||||||
key={option.propertyName}
|
key={option.propertyName}
|
||||||
option={option}
|
option={option}
|
||||||
@@ -84,12 +86,16 @@ export const GlobalConfigForm: FC<Props> = ({ config, alertManagerSourceName })
|
|||||||
))}
|
))}
|
||||||
<div>
|
<div>
|
||||||
<HorizontalGroup>
|
<HorizontalGroup>
|
||||||
{loading && (
|
{!readOnly && (
|
||||||
<Button disabled={true} icon="fa fa-spinner" variant="primary">
|
<>
|
||||||
Saving...
|
{loading && (
|
||||||
</Button>
|
<Button disabled={true} icon="fa fa-spinner" variant="primary">
|
||||||
|
Saving...
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{!loading && <Button type="submit">Save global config</Button>}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{!loading && <Button type="submit">Save global config</Button>}
|
|
||||||
<LinkButton
|
<LinkButton
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
fill="outline"
|
fill="outline"
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { GrafanaTheme2 } from '@grafana/data';
|
|||||||
import { Alert, LinkButton, useStyles2 } from '@grafana/ui';
|
import { Alert, LinkButton, useStyles2 } from '@grafana/ui';
|
||||||
import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types';
|
import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types';
|
||||||
import React, { FC } from 'react';
|
import React, { FC } from 'react';
|
||||||
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
|
import { GRAFANA_RULES_SOURCE_NAME, isVanillaPrometheusAlertManagerDataSource } from '../../utils/datasource';
|
||||||
import { makeAMLink } from '../../utils/misc';
|
import { makeAMLink } from '../../utils/misc';
|
||||||
import { ReceiversTable } from './ReceiversTable';
|
import { ReceiversTable } from './ReceiversTable';
|
||||||
import { TemplatesTable } from './TemplatesTable';
|
import { TemplatesTable } from './TemplatesTable';
|
||||||
@@ -16,9 +16,10 @@ interface Props {
|
|||||||
export const ReceiversAndTemplatesView: FC<Props> = ({ config, alertManagerName }) => {
|
export const ReceiversAndTemplatesView: FC<Props> = ({ config, alertManagerName }) => {
|
||||||
const isCloud = alertManagerName !== GRAFANA_RULES_SOURCE_NAME;
|
const isCloud = alertManagerName !== GRAFANA_RULES_SOURCE_NAME;
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
|
const isVanillaAM = isVanillaPrometheusAlertManagerDataSource(alertManagerName);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TemplatesTable config={config} alertManagerName={alertManagerName} />
|
{!isVanillaAM && <TemplatesTable config={config} alertManagerName={alertManagerName} />}
|
||||||
<ReceiversTable config={config} alertManagerName={alertManagerName} />
|
<ReceiversTable config={config} alertManagerName={alertManagerName} />
|
||||||
{isCloud && (
|
{isCloud && (
|
||||||
<Alert className={styles.section} severity="info" title="Global config for contact points">
|
<Alert className={styles.section} severity="info" title="Global config for contact points">
|
||||||
@@ -27,7 +28,7 @@ export const ReceiversAndTemplatesView: FC<Props> = ({ config, alertManagerName
|
|||||||
password, for all the supported contact points.
|
password, for all the supported contact points.
|
||||||
</p>
|
</p>
|
||||||
<LinkButton href={makeAMLink('alerting/notifications/global-config', alertManagerName)} variant="secondary">
|
<LinkButton href={makeAMLink('alerting/notifications/global-config', alertManagerName)} variant="secondary">
|
||||||
Edit global config
|
{isVanillaAM ? 'View global config' : 'Edit global config'}
|
||||||
</LinkButton>
|
</LinkButton>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ interface Props {
|
|||||||
addButtonLabel: string;
|
addButtonLabel: string;
|
||||||
addButtonTo: string;
|
addButtonTo: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
showButton?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ReceiversSection: FC<Props> = ({
|
export const ReceiversSection: FC<Props> = ({
|
||||||
@@ -19,6 +20,7 @@ export const ReceiversSection: FC<Props> = ({
|
|||||||
addButtonLabel,
|
addButtonLabel,
|
||||||
addButtonTo,
|
addButtonTo,
|
||||||
children,
|
children,
|
||||||
|
showButton = true,
|
||||||
}) => {
|
}) => {
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
return (
|
return (
|
||||||
@@ -28,9 +30,11 @@ export const ReceiversSection: FC<Props> = ({
|
|||||||
<h4>{title}</h4>
|
<h4>{title}</h4>
|
||||||
<p className={styles.description}>{description}</p>
|
<p className={styles.description}>{description}</p>
|
||||||
</div>
|
</div>
|
||||||
<Link to={addButtonTo}>
|
{showButton && (
|
||||||
<Button icon="plus">{addButtonLabel}</Button>
|
<Link to={addButtonTo}>
|
||||||
</Link>
|
<Button icon="plus">{addButtonLabel}</Button>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{children}
|
{children}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { css } from '@emotion/css';
|
|||||||
import { isReceiverUsed } from '../../utils/alertmanager';
|
import { isReceiverUsed } from '../../utils/alertmanager';
|
||||||
import { useDispatch } from 'react-redux';
|
import { useDispatch } from 'react-redux';
|
||||||
import { deleteReceiverAction } from '../../state/actions';
|
import { deleteReceiverAction } from '../../state/actions';
|
||||||
|
import { isVanillaPrometheusAlertManagerDataSource } from '../../utils/datasource';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
config: AlertManagerCortexConfig;
|
config: AlertManagerCortexConfig;
|
||||||
@@ -22,7 +23,7 @@ export const ReceiversTable: FC<Props> = ({ config, alertManagerName }) => {
|
|||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const tableStyles = useStyles2(getAlertTableStyles);
|
const tableStyles = useStyles2(getAlertTableStyles);
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
|
const isVanillaAM = isVanillaPrometheusAlertManagerDataSource(alertManagerName);
|
||||||
const grafanaNotifiers = useUnifiedAlertingSelector((state) => state.grafanaNotifiers);
|
const grafanaNotifiers = useUnifiedAlertingSelector((state) => state.grafanaNotifiers);
|
||||||
|
|
||||||
// receiver name slated for deletion. If this is set, a confirmation modal is shown. If user approves, this receiver is deleted
|
// receiver name slated for deletion. If this is set, a confirmation modal is shown. If user approves, this receiver is deleted
|
||||||
@@ -65,6 +66,7 @@ export const ReceiversTable: FC<Props> = ({ config, alertManagerName }) => {
|
|||||||
className={styles.section}
|
className={styles.section}
|
||||||
title="Contact points"
|
title="Contact points"
|
||||||
description="Define where the notifications will be sent to, for example email or Slack."
|
description="Define where the notifications will be sent to, for example email or Slack."
|
||||||
|
showButton={!isVanillaAM}
|
||||||
addButtonLabel="New contact point"
|
addButtonLabel="New contact point"
|
||||||
addButtonTo={makeAMLink('/alerting/notifications/receivers/new', alertManagerName)}
|
addButtonTo={makeAMLink('/alerting/notifications/receivers/new', alertManagerName)}
|
||||||
>
|
>
|
||||||
@@ -92,20 +94,35 @@ export const ReceiversTable: FC<Props> = ({ config, alertManagerName }) => {
|
|||||||
<td>{receiver.name}</td>
|
<td>{receiver.name}</td>
|
||||||
<td>{receiver.types.join(', ')}</td>
|
<td>{receiver.types.join(', ')}</td>
|
||||||
<td className={tableStyles.actionsCell}>
|
<td className={tableStyles.actionsCell}>
|
||||||
<ActionIcon
|
{!isVanillaAM && (
|
||||||
data-testid="edit"
|
<>
|
||||||
to={makeAMLink(
|
<ActionIcon
|
||||||
`/alerting/notifications/receivers/${encodeURIComponent(receiver.name)}/edit`,
|
data-testid="edit"
|
||||||
alertManagerName
|
to={makeAMLink(
|
||||||
)}
|
`/alerting/notifications/receivers/${encodeURIComponent(receiver.name)}/edit`,
|
||||||
tooltip="Edit contact point"
|
alertManagerName
|
||||||
icon="pen"
|
)}
|
||||||
/>
|
tooltip="Edit contact point"
|
||||||
<ActionIcon
|
icon="pen"
|
||||||
onClick={() => onClickDeleteReceiver(receiver.name)}
|
/>
|
||||||
tooltip="Delete contact point"
|
<ActionIcon
|
||||||
icon="trash-alt"
|
onClick={() => onClickDeleteReceiver(receiver.name)}
|
||||||
/>
|
tooltip="Delete contact point"
|
||||||
|
icon="trash-alt"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{isVanillaAM && (
|
||||||
|
<ActionIcon
|
||||||
|
data-testid="view"
|
||||||
|
to={makeAMLink(
|
||||||
|
`/alerting/notifications/receivers/${encodeURIComponent(receiver.name)}/edit`,
|
||||||
|
alertManagerName
|
||||||
|
)}
|
||||||
|
tooltip="View contact point"
|
||||||
|
icon="file-alt"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export interface Props<R extends ChannelValues> {
|
|||||||
onResetSecureField: (key: string) => void;
|
onResetSecureField: (key: string) => void;
|
||||||
errors?: FieldErrors<R>;
|
errors?: FieldErrors<R>;
|
||||||
pathPrefix?: string;
|
pathPrefix?: string;
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChannelOptions<R extends ChannelValues>({
|
export function ChannelOptions<R extends ChannelValues>({
|
||||||
@@ -22,6 +23,7 @@ export function ChannelOptions<R extends ChannelValues>({
|
|||||||
secureFields,
|
secureFields,
|
||||||
errors,
|
errors,
|
||||||
pathPrefix = '',
|
pathPrefix = '',
|
||||||
|
readOnly = false,
|
||||||
}: Props<R>): JSX.Element {
|
}: Props<R>): JSX.Element {
|
||||||
const { watch } = useFormContext<ReceiverFormValues<R>>();
|
const { watch } = useFormContext<ReceiverFormValues<R>>();
|
||||||
const currentFormValues = watch() as Record<string, any>; // react hook form types ARE LYING!
|
const currentFormValues = watch() as Record<string, any>; // react hook form types ARE LYING!
|
||||||
@@ -44,14 +46,16 @@ export function ChannelOptions<R extends ChannelValues>({
|
|||||||
readOnly={true}
|
readOnly={true}
|
||||||
value="Configured"
|
value="Configured"
|
||||||
suffix={
|
suffix={
|
||||||
<Button
|
readOnly ? null : (
|
||||||
onClick={() => onResetSecureField(option.propertyName)}
|
<Button
|
||||||
variant="link"
|
onClick={() => onResetSecureField(option.propertyName)}
|
||||||
type="button"
|
variant="link"
|
||||||
size="sm"
|
type="button"
|
||||||
>
|
size="sm"
|
||||||
Clear
|
>
|
||||||
</Button>
|
Clear
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
@@ -67,6 +71,7 @@ export function ChannelOptions<R extends ChannelValues>({
|
|||||||
return (
|
return (
|
||||||
<OptionField
|
<OptionField
|
||||||
defaultValue={defaultValue}
|
defaultValue={defaultValue}
|
||||||
|
readOnly={readOnly}
|
||||||
key={key}
|
key={key}
|
||||||
error={error}
|
error={error}
|
||||||
pathPrefix={option.secure ? `${pathPrefix}secureSettings.` : `${pathPrefix}settings.`}
|
pathPrefix={option.secure ? `${pathPrefix}secureSettings.` : `${pathPrefix}settings.`}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ interface Props<R> {
|
|||||||
secureFields?: Record<string, boolean>;
|
secureFields?: Record<string, boolean>;
|
||||||
errors?: FieldErrors<R>;
|
errors?: FieldErrors<R>;
|
||||||
onDelete?: () => void;
|
onDelete?: () => void;
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChannelSubForm<R extends ChannelValues>({
|
export function ChannelSubForm<R extends ChannelValues>({
|
||||||
@@ -32,6 +33,7 @@ export function ChannelSubForm<R extends ChannelValues>({
|
|||||||
errors,
|
errors,
|
||||||
secureFields,
|
secureFields,
|
||||||
commonSettingsComponent: CommonSettingsComponent,
|
commonSettingsComponent: CommonSettingsComponent,
|
||||||
|
readOnly = false,
|
||||||
}: Props<R>): JSX.Element {
|
}: Props<R>): JSX.Element {
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
const name = (fieldName: string) => `${pathPrefix}${fieldName}`;
|
const name = (fieldName: string) => `${pathPrefix}${fieldName}`;
|
||||||
@@ -80,6 +82,7 @@ export function ChannelSubForm<R extends ChannelValues>({
|
|||||||
defaultValue={defaultValues.type}
|
defaultValue={defaultValues.type}
|
||||||
render={({ field: { ref, onChange, ...field } }) => (
|
render={({ field: { ref, onChange, ...field } }) => (
|
||||||
<Select
|
<Select
|
||||||
|
disabled={readOnly}
|
||||||
menuShouldPortal
|
menuShouldPortal
|
||||||
{...field}
|
{...field}
|
||||||
width={37}
|
width={37}
|
||||||
@@ -92,35 +95,37 @@ export function ChannelSubForm<R extends ChannelValues>({
|
|||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.buttons}>
|
{!readOnly && (
|
||||||
{onTest && (
|
<div className={styles.buttons}>
|
||||||
<Button
|
{onTest && (
|
||||||
disabled={testingReceiver}
|
<Button
|
||||||
size="xs"
|
disabled={testingReceiver}
|
||||||
variant="secondary"
|
size="xs"
|
||||||
type="button"
|
variant="secondary"
|
||||||
onClick={() => onTest()}
|
type="button"
|
||||||
icon={testingReceiver ? 'fa fa-spinner' : 'message'}
|
onClick={() => onTest()}
|
||||||
>
|
icon={testingReceiver ? 'fa fa-spinner' : 'message'}
|
||||||
Test
|
>
|
||||||
|
Test
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button size="xs" variant="secondary" type="button" onClick={() => onDuplicate()} icon="copy">
|
||||||
|
Duplicate
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
{onDelete && (
|
||||||
<Button size="xs" variant="secondary" type="button" onClick={() => onDuplicate()} icon="copy">
|
<Button
|
||||||
Duplicate
|
data-testid={`${pathPrefix}delete-button`}
|
||||||
</Button>
|
size="xs"
|
||||||
{onDelete && (
|
variant="secondary"
|
||||||
<Button
|
type="button"
|
||||||
data-testid={`${pathPrefix}delete-button`}
|
onClick={() => onDelete()}
|
||||||
size="xs"
|
icon="trash-alt"
|
||||||
variant="secondary"
|
>
|
||||||
type="button"
|
Delete
|
||||||
onClick={() => onDelete()}
|
</Button>
|
||||||
icon="trash-alt"
|
)}
|
||||||
>
|
</div>
|
||||||
Delete
|
)}
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{notifier && (
|
{notifier && (
|
||||||
<div className={styles.innerContent}>
|
<div className={styles.innerContent}>
|
||||||
@@ -131,6 +136,7 @@ export function ChannelSubForm<R extends ChannelValues>({
|
|||||||
errors={errors}
|
errors={errors}
|
||||||
onResetSecureField={onResetSecureField}
|
onResetSecureField={onResetSecureField}
|
||||||
pathPrefix={pathPrefix}
|
pathPrefix={pathPrefix}
|
||||||
|
readOnly={readOnly}
|
||||||
/>
|
/>
|
||||||
{!!(mandatoryOptions?.length && optionalOptions?.length) && (
|
{!!(mandatoryOptions?.length && optionalOptions?.length) && (
|
||||||
<CollapsibleSection label={`Optional ${notifier.name} settings`}>
|
<CollapsibleSection label={`Optional ${notifier.name} settings`}>
|
||||||
@@ -146,11 +152,12 @@ export function ChannelSubForm<R extends ChannelValues>({
|
|||||||
onResetSecureField={onResetSecureField}
|
onResetSecureField={onResetSecureField}
|
||||||
errors={errors}
|
errors={errors}
|
||||||
pathPrefix={pathPrefix}
|
pathPrefix={pathPrefix}
|
||||||
|
readOnly={readOnly}
|
||||||
/>
|
/>
|
||||||
</CollapsibleSection>
|
</CollapsibleSection>
|
||||||
)}
|
)}
|
||||||
<CollapsibleSection label="Notification settings">
|
<CollapsibleSection label="Notification settings">
|
||||||
<CommonSettingsComponent pathPrefix={pathPrefix} />
|
<CommonSettingsComponent pathPrefix={pathPrefix} readOnly={readOnly} />
|
||||||
</CollapsibleSection>
|
</CollapsibleSection>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,14 +3,19 @@ import React, { FC } from 'react';
|
|||||||
import { CommonSettingsComponentProps } from '../../../types/receiver-form';
|
import { CommonSettingsComponentProps } from '../../../types/receiver-form';
|
||||||
import { useFormContext } from 'react-hook-form';
|
import { useFormContext } from 'react-hook-form';
|
||||||
|
|
||||||
export const CloudCommonChannelSettings: FC<CommonSettingsComponentProps> = ({ pathPrefix, className }) => {
|
export const CloudCommonChannelSettings: FC<CommonSettingsComponentProps> = ({
|
||||||
|
pathPrefix,
|
||||||
|
className,
|
||||||
|
readOnly = false,
|
||||||
|
}) => {
|
||||||
const { register } = useFormContext();
|
const { register } = useFormContext();
|
||||||
return (
|
return (
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
<Field>
|
<Field disabled={readOnly}>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
{...register(`${pathPrefix}sendResolved`)}
|
{...register(`${pathPrefix}sendResolved`)}
|
||||||
label="Send resolved"
|
label="Send resolved"
|
||||||
|
disabled={readOnly}
|
||||||
description="Whether or not to notify about resolved alerts."
|
description="Whether or not to notify about resolved alerts."
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useDispatch } from 'react-redux';
|
|||||||
import { updateAlertManagerConfigAction } from '../../../state/actions';
|
import { updateAlertManagerConfigAction } from '../../../state/actions';
|
||||||
import { CloudChannelValues, ReceiverFormValues, CloudChannelMap } from '../../../types/receiver-form';
|
import { CloudChannelValues, ReceiverFormValues, CloudChannelMap } from '../../../types/receiver-form';
|
||||||
import { cloudNotifierTypes } from '../../../utils/cloud-alertmanager-notifier-types';
|
import { cloudNotifierTypes } from '../../../utils/cloud-alertmanager-notifier-types';
|
||||||
|
import { isVanillaPrometheusAlertManagerDataSource } from '../../../utils/datasource';
|
||||||
import { makeAMLink } from '../../../utils/misc';
|
import { makeAMLink } from '../../../utils/misc';
|
||||||
import {
|
import {
|
||||||
cloudReceiverToFormValues,
|
cloudReceiverToFormValues,
|
||||||
@@ -31,6 +32,7 @@ const defaultChannelValues: CloudChannelValues = Object.freeze({
|
|||||||
|
|
||||||
export const CloudReceiverForm: FC<Props> = ({ existing, alertManagerSourceName, config }) => {
|
export const CloudReceiverForm: FC<Props> = ({ existing, alertManagerSourceName, config }) => {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
const isVanillaAM = isVanillaPrometheusAlertManagerDataSource(alertManagerSourceName);
|
||||||
|
|
||||||
// transform receiver DTO to form values
|
// transform receiver DTO to form values
|
||||||
const [existingValue] = useMemo((): [ReceiverFormValues<CloudChannelValues> | undefined, CloudChannelMap] => {
|
const [existingValue] = useMemo((): [ReceiverFormValues<CloudChannelValues> | undefined, CloudChannelMap] => {
|
||||||
@@ -60,9 +62,11 @@ export const CloudReceiverForm: FC<Props> = ({ existing, alertManagerSourceName,
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Alert title="Info" severity="info">
|
{!isVanillaAM && (
|
||||||
Note that empty string values will be replaced with global defaults were appropriate.
|
<Alert title="Info" severity="info">
|
||||||
</Alert>
|
Note that empty string values will be replaced with global defaults were appropriate.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
<ReceiverForm<CloudChannelValues>
|
<ReceiverForm<CloudChannelValues>
|
||||||
config={config}
|
config={config}
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { makeAMLink } from '../../../utils/misc';
|
|||||||
import { ChannelSubForm } from './ChannelSubForm';
|
import { ChannelSubForm } from './ChannelSubForm';
|
||||||
import { DeletedSubForm } from './fields/DeletedSubform';
|
import { DeletedSubForm } from './fields/DeletedSubform';
|
||||||
import { appEvents } from 'app/core/core';
|
import { appEvents } from 'app/core/core';
|
||||||
|
import { isVanillaPrometheusAlertManagerDataSource } from '../../../utils/datasource';
|
||||||
|
|
||||||
interface Props<R extends ChannelValues> {
|
interface Props<R extends ChannelValues> {
|
||||||
config: AlertManagerCortexConfig;
|
config: AlertManagerCortexConfig;
|
||||||
@@ -38,7 +39,7 @@ export function ReceiverForm<R extends ChannelValues>({
|
|||||||
commonSettingsComponent,
|
commonSettingsComponent,
|
||||||
}: Props<R>): JSX.Element {
|
}: Props<R>): JSX.Element {
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
|
const readOnly = isVanillaPrometheusAlertManagerDataSource(alertManagerSourceName);
|
||||||
const defaultValues = initialValues || {
|
const defaultValues = initialValues || {
|
||||||
name: '',
|
name: '',
|
||||||
items: [
|
items: [
|
||||||
@@ -94,9 +95,12 @@ export function ReceiverForm<R extends ChannelValues>({
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
<form onSubmit={handleSubmit(submitCallback, onInvalid)}>
|
<form onSubmit={handleSubmit(submitCallback, onInvalid)}>
|
||||||
<h4 className={styles.heading}>{initialValues ? 'Update contact point' : 'Create contact point'}</h4>
|
<h4 className={styles.heading}>
|
||||||
|
{readOnly ? 'Contact point' : initialValues ? 'Update contact point' : 'Create contact point'}
|
||||||
|
</h4>
|
||||||
<Field label="Name" invalid={!!errors.name} error={errors.name && errors.name.message}>
|
<Field label="Name" invalid={!!errors.name} error={errors.name && errors.name.message}>
|
||||||
<Input
|
<Input
|
||||||
|
readOnly={readOnly}
|
||||||
id="name"
|
id="name"
|
||||||
{...register('name', {
|
{...register('name', {
|
||||||
required: 'Name is required',
|
required: 'Name is required',
|
||||||
@@ -133,33 +137,43 @@ export function ReceiverForm<R extends ChannelValues>({
|
|||||||
secureFields={initialItem?.secureFields}
|
secureFields={initialItem?.secureFields}
|
||||||
errors={errors?.items?.[index] as FieldErrors<R>}
|
errors={errors?.items?.[index] as FieldErrors<R>}
|
||||||
commonSettingsComponent={commonSettingsComponent}
|
commonSettingsComponent={commonSettingsComponent}
|
||||||
|
readOnly={readOnly}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<Button
|
<>
|
||||||
type="button"
|
{!readOnly && (
|
||||||
icon="plus"
|
<Button
|
||||||
variant="secondary"
|
type="button"
|
||||||
onClick={() => append({ ...defaultItem, __id: String(Math.random()) } as R)}
|
icon="plus"
|
||||||
>
|
variant="secondary"
|
||||||
New contact point type
|
onClick={() => append({ ...defaultItem, __id: String(Math.random()) } as R)}
|
||||||
</Button>
|
>
|
||||||
<div className={styles.buttons}>
|
New contact point type
|
||||||
{loading && (
|
|
||||||
<Button disabled={true} icon="fa fa-spinner" variant="primary">
|
|
||||||
Saving...
|
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{!loading && <Button type="submit">Save contact point</Button>}
|
<div className={styles.buttons}>
|
||||||
<LinkButton
|
{!readOnly && (
|
||||||
disabled={loading}
|
<>
|
||||||
fill="outline"
|
{loading && (
|
||||||
variant="secondary"
|
<Button disabled={true} icon="fa fa-spinner" variant="primary">
|
||||||
href={makeAMLink('alerting/notifications', alertManagerSourceName)}
|
Saving...
|
||||||
>
|
</Button>
|
||||||
Cancel
|
)}
|
||||||
</LinkButton>
|
{!loading && <Button type="submit">Save contact point</Button>}
|
||||||
</div>
|
</>
|
||||||
|
)}
|
||||||
|
<LinkButton
|
||||||
|
disabled={loading}
|
||||||
|
fill="outline"
|
||||||
|
variant="secondary"
|
||||||
|
data-testid="cancel-button"
|
||||||
|
href={makeAMLink('alerting/notifications', alertManagerSourceName)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</LinkButton>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
</form>
|
</form>
|
||||||
</FormProvider>
|
</FormProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,10 +6,11 @@ import { ActionIcon } from '../../../rules/ActionIcon';
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
value?: Record<string, string>;
|
value?: Record<string, string>;
|
||||||
|
readOnly?: boolean;
|
||||||
onChange: (value: Record<string, string>) => void;
|
onChange: (value: Record<string, string>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const KeyValueMapInput: FC<Props> = ({ value, onChange }) => {
|
export const KeyValueMapInput: FC<Props> = ({ value, onChange, readOnly = false }) => {
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
const [pairs, setPairs] = useState(recordToPairs(value));
|
const [pairs, setPairs] = useState(recordToPairs(value));
|
||||||
useEffect(() => setPairs(recordToPairs(value)), [value]);
|
useEffect(() => setPairs(recordToPairs(value)), [value]);
|
||||||
@@ -44,36 +45,48 @@ export const KeyValueMapInput: FC<Props> = ({ value, onChange }) => {
|
|||||||
<tr>
|
<tr>
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
<th>Value</th>
|
<th>Value</th>
|
||||||
<th></th>
|
{!readOnly && <th></th>}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{pairs.map(([key, value], index) => (
|
{pairs.map(([key, value], index) => (
|
||||||
<tr key={index}>
|
<tr key={index}>
|
||||||
<td>
|
<td>
|
||||||
<Input value={key} onChange={(e) => updatePair([e.currentTarget.value, value], index)} />
|
<Input
|
||||||
|
readOnly={readOnly}
|
||||||
|
value={key}
|
||||||
|
onChange={(e) => updatePair([e.currentTarget.value, value], index)}
|
||||||
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<Input value={value} onChange={(e) => updatePair([key, e.currentTarget.value], index)} />
|
<Input
|
||||||
</td>
|
readOnly={readOnly}
|
||||||
<td>
|
value={value}
|
||||||
<ActionIcon icon="trash-alt" tooltip="delete" onClick={() => deleteItem(index)} />
|
onChange={(e) => updatePair([key, e.currentTarget.value], index)}
|
||||||
|
/>
|
||||||
</td>
|
</td>
|
||||||
|
{!readOnly && (
|
||||||
|
<td>
|
||||||
|
<ActionIcon icon="trash-alt" tooltip="delete" onClick={() => deleteItem(index)} />
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
)}
|
)}
|
||||||
<Button
|
{!readOnly && (
|
||||||
className={styles.addButton}
|
<Button
|
||||||
type="button"
|
className={styles.addButton}
|
||||||
variant="secondary"
|
type="button"
|
||||||
icon="plus"
|
variant="secondary"
|
||||||
size="sm"
|
icon="plus"
|
||||||
onClick={() => setPairs([...pairs, ['', '']])}
|
size="sm"
|
||||||
>
|
onClick={() => setPairs([...pairs, ['', '']])}
|
||||||
Add
|
>
|
||||||
</Button>
|
Add
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,12 +14,14 @@ interface Props {
|
|||||||
invalid?: boolean;
|
invalid?: boolean;
|
||||||
pathPrefix: string;
|
pathPrefix: string;
|
||||||
error?: FieldError | DeepMap<any, FieldError>;
|
error?: FieldError | DeepMap<any, FieldError>;
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const OptionField: FC<Props> = ({ option, invalid, pathPrefix, error, defaultValue }) => {
|
export const OptionField: FC<Props> = ({ option, invalid, pathPrefix, error, defaultValue, readOnly = false }) => {
|
||||||
if (option.element === 'subform') {
|
if (option.element === 'subform') {
|
||||||
return (
|
return (
|
||||||
<SubformField
|
<SubformField
|
||||||
|
readOnly={readOnly}
|
||||||
defaultValue={defaultValue}
|
defaultValue={defaultValue}
|
||||||
option={option}
|
option={option}
|
||||||
errors={error as DeepMap<any, FieldError> | undefined}
|
errors={error as DeepMap<any, FieldError> | undefined}
|
||||||
@@ -30,6 +32,7 @@ export const OptionField: FC<Props> = ({ option, invalid, pathPrefix, error, def
|
|||||||
if (option.element === 'subform_array') {
|
if (option.element === 'subform_array') {
|
||||||
return (
|
return (
|
||||||
<SubformArrayField
|
<SubformArrayField
|
||||||
|
readOnly={readOnly}
|
||||||
defaultValues={defaultValue}
|
defaultValues={defaultValue}
|
||||||
option={option}
|
option={option}
|
||||||
pathPrefix={pathPrefix}
|
pathPrefix={pathPrefix}
|
||||||
@@ -50,12 +53,13 @@ export const OptionField: FC<Props> = ({ option, invalid, pathPrefix, error, def
|
|||||||
option={option}
|
option={option}
|
||||||
invalid={invalid}
|
invalid={invalid}
|
||||||
pathPrefix={pathPrefix}
|
pathPrefix={pathPrefix}
|
||||||
|
readOnly={readOnly}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const OptionInput: FC<Props & { id: string }> = ({ option, invalid, id, pathPrefix = '' }) => {
|
const OptionInput: FC<Props & { id: string }> = ({ option, invalid, id, pathPrefix = '', readOnly = false }) => {
|
||||||
const { control, register, unregister } = useFormContext();
|
const { control, register, unregister } = useFormContext();
|
||||||
const name = `${pathPrefix}${option.propertyName}`;
|
const name = `${pathPrefix}${option.propertyName}`;
|
||||||
|
|
||||||
@@ -71,6 +75,8 @@ const OptionInput: FC<Props & { id: string }> = ({ option, invalid, id, pathPref
|
|||||||
return (
|
return (
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id={id}
|
id={id}
|
||||||
|
readOnly={readOnly}
|
||||||
|
disabled={readOnly}
|
||||||
className={styles.checkbox}
|
className={styles.checkbox}
|
||||||
{...register(name)}
|
{...register(name)}
|
||||||
label={option.label}
|
label={option.label}
|
||||||
@@ -81,6 +87,7 @@ const OptionInput: FC<Props & { id: string }> = ({ option, invalid, id, pathPref
|
|||||||
return (
|
return (
|
||||||
<Input
|
<Input
|
||||||
id={id}
|
id={id}
|
||||||
|
readOnly={readOnly}
|
||||||
invalid={invalid}
|
invalid={invalid}
|
||||||
type={option.inputType}
|
type={option.inputType}
|
||||||
{...register(name, {
|
{...register(name, {
|
||||||
@@ -96,6 +103,7 @@ const OptionInput: FC<Props & { id: string }> = ({ option, invalid, id, pathPref
|
|||||||
<InputControl
|
<InputControl
|
||||||
render={({ field: { onChange, ref, ...field } }) => (
|
render={({ field: { onChange, ref, ...field } }) => (
|
||||||
<Select
|
<Select
|
||||||
|
disabled={readOnly}
|
||||||
menuShouldPortal
|
menuShouldPortal
|
||||||
{...field}
|
{...field}
|
||||||
options={option.selectOptions ?? undefined}
|
options={option.selectOptions ?? undefined}
|
||||||
@@ -112,6 +120,7 @@ const OptionInput: FC<Props & { id: string }> = ({ option, invalid, id, pathPref
|
|||||||
return (
|
return (
|
||||||
<TextArea
|
<TextArea
|
||||||
id={id}
|
id={id}
|
||||||
|
readOnly={readOnly}
|
||||||
invalid={invalid}
|
invalid={invalid}
|
||||||
{...register(name, {
|
{...register(name, {
|
||||||
required: option.required ? 'Required' : false,
|
required: option.required ? 'Required' : false,
|
||||||
@@ -122,7 +131,9 @@ const OptionInput: FC<Props & { id: string }> = ({ option, invalid, id, pathPref
|
|||||||
case 'string_array':
|
case 'string_array':
|
||||||
return (
|
return (
|
||||||
<InputControl
|
<InputControl
|
||||||
render={({ field: { value, onChange } }) => <StringArrayInput value={value} onChange={onChange} />}
|
render={({ field: { value, onChange } }) => (
|
||||||
|
<StringArrayInput readOnly={readOnly} value={value} onChange={onChange} />
|
||||||
|
)}
|
||||||
control={control}
|
control={control}
|
||||||
name={name}
|
name={name}
|
||||||
/>
|
/>
|
||||||
@@ -130,7 +141,9 @@ const OptionInput: FC<Props & { id: string }> = ({ option, invalid, id, pathPref
|
|||||||
case 'key_value_map':
|
case 'key_value_map':
|
||||||
return (
|
return (
|
||||||
<InputControl
|
<InputControl
|
||||||
render={({ field: { value, onChange } }) => <KeyValueMapInput value={value} onChange={onChange} />}
|
render={({ field: { value, onChange } }) => (
|
||||||
|
<KeyValueMapInput readOnly={readOnly} value={value} onChange={onChange} />
|
||||||
|
)}
|
||||||
control={control}
|
control={control}
|
||||||
name={name}
|
name={name}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -6,10 +6,11 @@ import { ActionIcon } from '../../../rules/ActionIcon';
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
value?: string[];
|
value?: string[];
|
||||||
|
readOnly?: boolean;
|
||||||
onChange: (value: string[]) => void;
|
onChange: (value: string[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const StringArrayInput: FC<Props> = ({ value, onChange }) => {
|
export const StringArrayInput: FC<Props> = ({ value, onChange, readOnly = false }) => {
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
|
|
||||||
const deleteItem = (index: number) => {
|
const deleteItem = (index: number) => {
|
||||||
@@ -33,25 +34,29 @@ export const StringArrayInput: FC<Props> = ({ value, onChange }) => {
|
|||||||
{!!value?.length &&
|
{!!value?.length &&
|
||||||
value.map((v, index) => (
|
value.map((v, index) => (
|
||||||
<div key={index} className={styles.row}>
|
<div key={index} className={styles.row}>
|
||||||
<Input value={v} onChange={(e) => updateValue(e.currentTarget.value, index)} />
|
<Input readOnly={readOnly} value={v} onChange={(e) => updateValue(e.currentTarget.value, index)} />
|
||||||
<ActionIcon
|
{!readOnly && (
|
||||||
className={styles.deleteIcon}
|
<ActionIcon
|
||||||
icon="trash-alt"
|
className={styles.deleteIcon}
|
||||||
tooltip="delete"
|
icon="trash-alt"
|
||||||
onClick={() => deleteItem(index)}
|
tooltip="delete"
|
||||||
/>
|
onClick={() => deleteItem(index)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<Button
|
{!readOnly && (
|
||||||
className={styles.addButton}
|
<Button
|
||||||
type="button"
|
className={styles.addButton}
|
||||||
variant="secondary"
|
type="button"
|
||||||
icon="plus"
|
variant="secondary"
|
||||||
size="sm"
|
icon="plus"
|
||||||
onClick={() => onChange([...(value ?? []), ''])}
|
size="sm"
|
||||||
>
|
onClick={() => onChange([...(value ?? []), ''])}
|
||||||
Add
|
>
|
||||||
</Button>
|
Add
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,9 +13,10 @@ interface Props {
|
|||||||
option: NotificationChannelOption;
|
option: NotificationChannelOption;
|
||||||
pathPrefix: string;
|
pathPrefix: string;
|
||||||
errors?: Array<DeepMap<any, FieldError>>;
|
errors?: Array<DeepMap<any, FieldError>>;
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SubformArrayField: FC<Props> = ({ option, pathPrefix, errors, defaultValues }) => {
|
export const SubformArrayField: FC<Props> = ({ option, pathPrefix, errors, defaultValues, readOnly = false }) => {
|
||||||
const styles = useStyles2(getReceiverFormFieldStyles);
|
const styles = useStyles2(getReceiverFormFieldStyles);
|
||||||
const path = `${pathPrefix}${option.propertyName}`;
|
const path = `${pathPrefix}${option.propertyName}`;
|
||||||
const formAPI = useFormContext();
|
const formAPI = useFormContext();
|
||||||
@@ -31,15 +32,18 @@ export const SubformArrayField: FC<Props> = ({ option, pathPrefix, errors, defau
|
|||||||
{(fields ?? defaultValues ?? []).map((field, itemIndex) => {
|
{(fields ?? defaultValues ?? []).map((field, itemIndex) => {
|
||||||
return (
|
return (
|
||||||
<div key={itemIndex} className={styles.wrapper}>
|
<div key={itemIndex} className={styles.wrapper}>
|
||||||
<ActionIcon
|
{!readOnly && (
|
||||||
data-testid={`${path}.${itemIndex}.delete-button`}
|
<ActionIcon
|
||||||
icon="trash-alt"
|
data-testid={`${path}.${itemIndex}.delete-button`}
|
||||||
tooltip="delete"
|
icon="trash-alt"
|
||||||
onClick={() => remove(itemIndex)}
|
tooltip="delete"
|
||||||
className={styles.deleteIcon}
|
onClick={() => remove(itemIndex)}
|
||||||
/>
|
className={styles.deleteIcon}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{option.subformOptions?.map((option) => (
|
{option.subformOptions?.map((option) => (
|
||||||
<OptionField
|
<OptionField
|
||||||
|
readOnly={readOnly}
|
||||||
defaultValue={field?.[option.propertyName]}
|
defaultValue={field?.[option.propertyName]}
|
||||||
key={option.propertyName}
|
key={option.propertyName}
|
||||||
option={option}
|
option={option}
|
||||||
@@ -50,17 +54,19 @@ export const SubformArrayField: FC<Props> = ({ option, pathPrefix, errors, defau
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<Button
|
{!readOnly && (
|
||||||
data-testid={`${path}.add-button`}
|
<Button
|
||||||
className={styles.addButton}
|
data-testid={`${path}.add-button`}
|
||||||
type="button"
|
className={styles.addButton}
|
||||||
variant="secondary"
|
type="button"
|
||||||
icon="plus"
|
variant="secondary"
|
||||||
size="sm"
|
icon="plus"
|
||||||
onClick={() => append({ __id: String(Math.random()) })}
|
size="sm"
|
||||||
>
|
onClick={() => append({ __id: String(Math.random()) })}
|
||||||
Add
|
>
|
||||||
</Button>
|
Add
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</CollapsibleSection>
|
</CollapsibleSection>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,9 +11,10 @@ interface Props {
|
|||||||
option: NotificationChannelOption;
|
option: NotificationChannelOption;
|
||||||
pathPrefix: string;
|
pathPrefix: string;
|
||||||
errors?: DeepMap<any, FieldError>;
|
errors?: DeepMap<any, FieldError>;
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SubformField: FC<Props> = ({ option, pathPrefix, errors, defaultValue }) => {
|
export const SubformField: FC<Props> = ({ option, pathPrefix, errors, defaultValue, readOnly = false }) => {
|
||||||
const styles = useStyles2(getReceiverFormFieldStyles);
|
const styles = useStyles2(getReceiverFormFieldStyles);
|
||||||
const name = `${pathPrefix}${option.propertyName}`;
|
const name = `${pathPrefix}${option.propertyName}`;
|
||||||
const { watch } = useFormContext();
|
const { watch } = useFormContext();
|
||||||
@@ -28,16 +29,19 @@ export const SubformField: FC<Props> = ({ option, pathPrefix, errors, defaultVal
|
|||||||
{option.description && <p className={styles.description}>{option.description}</p>}
|
{option.description && <p className={styles.description}>{option.description}</p>}
|
||||||
{show && (
|
{show && (
|
||||||
<>
|
<>
|
||||||
<ActionIcon
|
{!readOnly && (
|
||||||
data-testid={`${name}.delete-button`}
|
<ActionIcon
|
||||||
icon="trash-alt"
|
data-testid={`${name}.delete-button`}
|
||||||
tooltip="delete"
|
icon="trash-alt"
|
||||||
onClick={() => setShow(false)}
|
tooltip="delete"
|
||||||
className={styles.deleteIcon}
|
onClick={() => setShow(false)}
|
||||||
/>
|
className={styles.deleteIcon}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{(option.subformOptions ?? []).map((subOption) => {
|
{(option.subformOptions ?? []).map((subOption) => {
|
||||||
return (
|
return (
|
||||||
<OptionField
|
<OptionField
|
||||||
|
readOnly={readOnly}
|
||||||
defaultValue={defaultValue?.[subOption.propertyName]}
|
defaultValue={defaultValue?.[subOption.propertyName]}
|
||||||
key={subOption.propertyName}
|
key={subOption.propertyName}
|
||||||
option={subOption}
|
option={subOption}
|
||||||
@@ -48,7 +52,7 @@ export const SubformField: FC<Props> = ({ option, pathPrefix, errors, defaultVal
|
|||||||
})}
|
})}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{!show && (
|
{!show && !readOnly && (
|
||||||
<Button
|
<Button
|
||||||
className={styles.addButton}
|
className={styles.addButton}
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import { DataSourceApi, DataSourceInstanceSettings, DataSourcePluginMeta, ScopedVars } from '@grafana/data';
|
import {
|
||||||
|
DataSourceApi,
|
||||||
|
DataSourceInstanceSettings,
|
||||||
|
DataSourceJsonData,
|
||||||
|
DataSourcePluginMeta,
|
||||||
|
ScopedVars,
|
||||||
|
} from '@grafana/data';
|
||||||
import {
|
import {
|
||||||
GrafanaAlertStateDecision,
|
GrafanaAlertStateDecision,
|
||||||
GrafanaRuleDefinition,
|
GrafanaRuleDefinition,
|
||||||
@@ -25,10 +31,10 @@ import {
|
|||||||
|
|
||||||
let nextDataSourceId = 1;
|
let nextDataSourceId = 1;
|
||||||
|
|
||||||
export const mockDataSource = (
|
export function mockDataSource<T extends DataSourceJsonData = DataSourceJsonData>(
|
||||||
partial: Partial<DataSourceInstanceSettings> = {},
|
partial: Partial<DataSourceInstanceSettings<T>> = {},
|
||||||
meta: Partial<DataSourcePluginMeta> = {}
|
meta: Partial<DataSourcePluginMeta> = {}
|
||||||
): DataSourceInstanceSettings<any> => {
|
): DataSourceInstanceSettings<T> {
|
||||||
const id = partial.id ?? nextDataSourceId++;
|
const id = partial.id ?? nextDataSourceId++;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -37,7 +43,7 @@ export const mockDataSource = (
|
|||||||
type: 'prometheus',
|
type: 'prometheus',
|
||||||
name: `Prometheus-${id}`,
|
name: `Prometheus-${id}`,
|
||||||
access: 'proxy',
|
access: 'proxy',
|
||||||
jsonData: {},
|
jsonData: {} as T,
|
||||||
meta: ({
|
meta: ({
|
||||||
info: {
|
info: {
|
||||||
logos: {
|
logos: {
|
||||||
@@ -49,7 +55,7 @@ export const mockDataSource = (
|
|||||||
} as any) as DataSourcePluginMeta,
|
} as any) as DataSourcePluginMeta,
|
||||||
...partial,
|
...partial,
|
||||||
};
|
};
|
||||||
};
|
}
|
||||||
|
|
||||||
export const mockPromAlert = (partial: Partial<Alert> = {}): Alert => ({
|
export const mockPromAlert = (partial: Partial<Alert> = {}): Alert => ({
|
||||||
activeAt: '2021-03-18T13:47:05.04938691Z',
|
activeAt: '2021-03-18T13:47:05.04938691Z',
|
||||||
@@ -351,6 +357,14 @@ export const someCloudAlertManagerConfig: AlertManagerCortexConfig = {
|
|||||||
alertmanager_config: {
|
alertmanager_config: {
|
||||||
route: {
|
route: {
|
||||||
receiver: 'cloud-receiver',
|
receiver: 'cloud-receiver',
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
receiver: 'foo-receiver',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
receiver: 'bar-receiver',
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
receivers: [
|
receivers: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -39,7 +39,12 @@ import {
|
|||||||
setRulerRuleGroup,
|
setRulerRuleGroup,
|
||||||
} from '../api/ruler';
|
} from '../api/ruler';
|
||||||
import { RuleFormType, RuleFormValues } from '../types/rule-form';
|
import { RuleFormType, RuleFormValues } from '../types/rule-form';
|
||||||
import { getAllRulesSourceNames, GRAFANA_RULES_SOURCE_NAME, isGrafanaRulesSource } from '../utils/datasource';
|
import {
|
||||||
|
getAllRulesSourceNames,
|
||||||
|
GRAFANA_RULES_SOURCE_NAME,
|
||||||
|
isGrafanaRulesSource,
|
||||||
|
isVanillaPrometheusAlertManagerDataSource,
|
||||||
|
} from '../utils/datasource';
|
||||||
import { makeAMLink, retryWhile } from '../utils/misc';
|
import { makeAMLink, retryWhile } from '../utils/misc';
|
||||||
import { isFetchError, withAppEvents, withSerializedError } from '../utils/redux';
|
import { isFetchError, withAppEvents, withSerializedError } from '../utils/redux';
|
||||||
import { formValuesToRulerRuleDTO, formValuesToRulerGrafanaRuleDTO } from '../utils/rule-form';
|
import { formValuesToRulerRuleDTO, formValuesToRulerGrafanaRuleDTO } from '../utils/rule-form';
|
||||||
@@ -66,26 +71,36 @@ export const fetchAlertManagerConfigAction = createAsyncThunk(
|
|||||||
'unifiedalerting/fetchAmConfig',
|
'unifiedalerting/fetchAmConfig',
|
||||||
(alertManagerSourceName: string): Promise<AlertManagerCortexConfig> =>
|
(alertManagerSourceName: string): Promise<AlertManagerCortexConfig> =>
|
||||||
withSerializedError(
|
withSerializedError(
|
||||||
retryWhile(
|
(async () => {
|
||||||
() => fetchAlertManagerConfig(alertManagerSourceName),
|
// for vanilla prometheus, there is no config endpoint. Only fetch config from status
|
||||||
// if config has been recently deleted, it takes a while for cortex start returning the default one.
|
if (isVanillaPrometheusAlertManagerDataSource(alertManagerSourceName)) {
|
||||||
// retry for a short while instead of failing
|
|
||||||
(e) => !!messageFromError(e)?.includes('alertmanager storage object not found'),
|
|
||||||
FETCH_CONFIG_RETRY_TIMEOUT
|
|
||||||
).then((result) => {
|
|
||||||
// if user config is empty for cortex alertmanager, try to get config from status endpoint
|
|
||||||
if (
|
|
||||||
isEmpty(result.alertmanager_config) &&
|
|
||||||
isEmpty(result.template_files) &&
|
|
||||||
alertManagerSourceName !== GRAFANA_RULES_SOURCE_NAME
|
|
||||||
) {
|
|
||||||
return fetchStatus(alertManagerSourceName).then((status) => ({
|
return fetchStatus(alertManagerSourceName).then((status) => ({
|
||||||
alertmanager_config: status.config,
|
alertmanager_config: status.config,
|
||||||
template_files: {},
|
template_files: {},
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
return result;
|
|
||||||
})
|
return retryWhile(
|
||||||
|
() => fetchAlertManagerConfig(alertManagerSourceName),
|
||||||
|
// if config has been recently deleted, it takes a while for cortex start returning the default one.
|
||||||
|
// retry for a short while instead of failing
|
||||||
|
(e) => !!messageFromError(e)?.includes('alertmanager storage object not found'),
|
||||||
|
FETCH_CONFIG_RETRY_TIMEOUT
|
||||||
|
).then((result) => {
|
||||||
|
// if user config is empty for cortex alertmanager, try to get config from status endpoint
|
||||||
|
if (
|
||||||
|
isEmpty(result.alertmanager_config) &&
|
||||||
|
isEmpty(result.template_files) &&
|
||||||
|
alertManagerSourceName !== GRAFANA_RULES_SOURCE_NAME
|
||||||
|
) {
|
||||||
|
return fetchStatus(alertManagerSourceName).then((status) => ({
|
||||||
|
alertmanager_config: status.config,
|
||||||
|
template_files: {},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
})()
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export interface GrafanaChannelValues extends ChannelValues {
|
|||||||
export interface CommonSettingsComponentProps {
|
export interface CommonSettingsComponentProps {
|
||||||
pathPrefix: string;
|
pathPrefix: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
export type CommonSettingsComponentType = React.ComponentType<CommonSettingsComponentProps>;
|
export type CommonSettingsComponentType = React.ComponentType<CommonSettingsComponentProps>;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { DataSourceInstanceSettings, DataSourceJsonData } from '@grafana/data';
|
import { DataSourceInstanceSettings, DataSourceJsonData } from '@grafana/data';
|
||||||
|
import { AlertManagerDataSourceJsonData, AlertManagerImplementation } from 'app/plugins/datasource/alertmanager/types';
|
||||||
import { RulesSource } from 'app/types/unified-alerting';
|
import { RulesSource } from 'app/types/unified-alerting';
|
||||||
import { getAllDataSources } from './config';
|
import { getAllDataSources } from './config';
|
||||||
|
|
||||||
@@ -51,6 +52,14 @@ export function isCloudRulesSource(rulesSource: RulesSource | string): rulesSour
|
|||||||
return rulesSource !== GRAFANA_RULES_SOURCE_NAME;
|
return rulesSource !== GRAFANA_RULES_SOURCE_NAME;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isVanillaPrometheusAlertManagerDataSource(name: string): boolean {
|
||||||
|
return (
|
||||||
|
name !== GRAFANA_RULES_SOURCE_NAME &&
|
||||||
|
(getDataSourceByName(name)?.jsonData as AlertManagerDataSourceJsonData)?.implementation ===
|
||||||
|
AlertManagerImplementation.prometheus
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function isGrafanaRulesSource(
|
export function isGrafanaRulesSource(
|
||||||
rulesSource: RulesSource | string
|
rulesSource: RulesSource | string
|
||||||
): rulesSource is typeof GRAFANA_RULES_SOURCE_NAME {
|
): rulesSource is typeof GRAFANA_RULES_SOURCE_NAME {
|
||||||
|
|||||||
@@ -1,15 +1,49 @@
|
|||||||
import { DataSourcePluginOptionsEditorProps } from '@grafana/data';
|
import { DataSourcePluginOptionsEditorProps, SelectableValue } from '@grafana/data';
|
||||||
import { Alert, DataSourceHttpSettings } from '@grafana/ui';
|
import { DataSourceHttpSettings, InlineFormLabel, Select } from '@grafana/ui';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { AlertManagerDataSourceJsonData, AlertManagerImplementation } from './types';
|
||||||
|
|
||||||
export type Props = DataSourcePluginOptionsEditorProps;
|
export type Props = DataSourcePluginOptionsEditorProps<AlertManagerDataSourceJsonData>;
|
||||||
|
|
||||||
|
const IMPL_OPTIONS: SelectableValue[] = [
|
||||||
|
{
|
||||||
|
value: AlertManagerImplementation.cortex,
|
||||||
|
label: 'Cortex',
|
||||||
|
description: `https://cortexmetrics.io/`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: AlertManagerImplementation.prometheus,
|
||||||
|
label: 'Prometheus',
|
||||||
|
description:
|
||||||
|
'https://prometheus.io/. Does not support editing configuration via API, so contact points and notification policies are read-only.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export const ConfigEditor: React.FC<Props> = ({ options, onOptionsChange }) => {
|
export const ConfigEditor: React.FC<Props> = ({ options, onOptionsChange }) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Alert severity="info" title="Only Cortex alertmanager is supported">
|
<h3 className="page-heading">Alertmanager</h3>
|
||||||
Note that only Cortex implementation of alert manager is supported at this time.
|
<div className="gf-form-group">
|
||||||
</Alert>
|
<div className="gf-form-inline">
|
||||||
|
<div className="gf-form">
|
||||||
|
<InlineFormLabel width={13}>Implementation</InlineFormLabel>
|
||||||
|
<Select
|
||||||
|
width={40}
|
||||||
|
options={IMPL_OPTIONS}
|
||||||
|
value={options.jsonData.implementation || AlertManagerImplementation.cortex}
|
||||||
|
onChange={(value) =>
|
||||||
|
onOptionsChange({
|
||||||
|
...options,
|
||||||
|
jsonData: {
|
||||||
|
...options.jsonData,
|
||||||
|
implementation: value.value as AlertManagerImplementation,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<DataSourceHttpSettings
|
<DataSourceHttpSettings
|
||||||
defaultUrl={''}
|
defaultUrl={''}
|
||||||
dataSourceConfig={options}
|
dataSourceConfig={options}
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { lastValueFrom, Observable, of } from 'rxjs';
|
import { lastValueFrom, Observable, of } from 'rxjs';
|
||||||
import { DataQuery, DataQueryResponse, DataSourceApi, DataSourceInstanceSettings } from '@grafana/data';
|
import { DataQuery, DataQueryResponse, DataSourceApi, DataSourceInstanceSettings } from '@grafana/data';
|
||||||
import { BackendSrvRequest, getBackendSrv } from '@grafana/runtime';
|
import { BackendSrvRequest, getBackendSrv } from '@grafana/runtime';
|
||||||
|
import { AlertManagerDataSourceJsonData, AlertManagerImplementation } from './types';
|
||||||
|
|
||||||
export type AlertManagerQuery = {
|
export type AlertManagerQuery = {
|
||||||
query: string;
|
query: string;
|
||||||
} & DataQuery;
|
} & DataQuery;
|
||||||
|
|
||||||
export class AlertManagerDatasource extends DataSourceApi<AlertManagerQuery> {
|
export class AlertManagerDatasource extends DataSourceApi<AlertManagerQuery, AlertManagerDataSourceJsonData> {
|
||||||
constructor(public instanceSettings: DataSourceInstanceSettings) {
|
constructor(public instanceSettings: DataSourceInstanceSettings<AlertManagerDataSourceJsonData>) {
|
||||||
super(instanceSettings);
|
super(instanceSettings);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,23 +41,38 @@ export class AlertManagerDatasource extends DataSourceApi<AlertManagerQuery> {
|
|||||||
|
|
||||||
async testDatasource() {
|
async testDatasource() {
|
||||||
let alertmanagerResponse;
|
let alertmanagerResponse;
|
||||||
let cortexAlertmanagerResponse;
|
|
||||||
|
|
||||||
try {
|
if (this.instanceSettings.jsonData.implementation === AlertManagerImplementation.prometheus) {
|
||||||
alertmanagerResponse = await this._request('/api/v2/status');
|
try {
|
||||||
if (alertmanagerResponse && alertmanagerResponse?.status === 200) {
|
alertmanagerResponse = await this._request('/alertmanager/api/v2/status');
|
||||||
return {
|
if (alertmanagerResponse && alertmanagerResponse?.status === 200) {
|
||||||
status: 'error',
|
return {
|
||||||
message:
|
status: 'error',
|
||||||
'Only Cortex alert manager implementation is supported. A URL to cortex instance should be provided.',
|
message:
|
||||||
};
|
'It looks like you have chosen Prometheus implementation, but detected a Cortex endpoint. Please update implementation selection and try again.',
|
||||||
}
|
};
|
||||||
} catch (e) {}
|
}
|
||||||
try {
|
} catch (e) {}
|
||||||
cortexAlertmanagerResponse = await this._request('/alertmanager/api/v2/status');
|
try {
|
||||||
} catch (e) {}
|
alertmanagerResponse = await this._request('/api/v2/status');
|
||||||
|
} catch (e) {}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
alertmanagerResponse = await this._request('/api/v2/status');
|
||||||
|
if (alertmanagerResponse && alertmanagerResponse?.status === 200) {
|
||||||
|
return {
|
||||||
|
status: 'error',
|
||||||
|
message:
|
||||||
|
'It looks like you have chosen Cortex implementation, but detected a Prometheus endpoint. Please update implementation selection and try again.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
try {
|
||||||
|
alertmanagerResponse = await this._request('/alertmanager/api/v2/status');
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
return cortexAlertmanagerResponse?.status === 200
|
return alertmanagerResponse?.status === 200
|
||||||
? {
|
? {
|
||||||
status: 'success',
|
status: 'success',
|
||||||
message: 'Health check passed.',
|
message: 'Health check passed.',
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"type": "datasource",
|
"type": "datasource",
|
||||||
"name": "Alert Manager",
|
"name": "Alertmanager",
|
||||||
"id": "alertmanager",
|
"id": "alertmanager",
|
||||||
"metrics": false,
|
"metrics": false,
|
||||||
"state": "alpha",
|
"state": "alpha",
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
//DOCS: https://prometheus.io/docs/alerting/latest/configuration/
|
//DOCS: https://prometheus.io/docs/alerting/latest/configuration/
|
||||||
|
|
||||||
|
import { DataSourceJsonData } from '@grafana/data';
|
||||||
|
|
||||||
export type AlertManagerCortexConfig = {
|
export type AlertManagerCortexConfig = {
|
||||||
template_files: Record<string, string>;
|
template_files: Record<string, string>;
|
||||||
alertmanager_config: AlertmanagerConfig;
|
alertmanager_config: AlertmanagerConfig;
|
||||||
@@ -246,3 +248,10 @@ export interface TestReceiversResult {
|
|||||||
notified_at: string;
|
notified_at: string;
|
||||||
receivers: TestReceiversResultReceiver[];
|
receivers: TestReceiversResultReceiver[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum AlertManagerImplementation {
|
||||||
|
cortex = 'cortex',
|
||||||
|
prometheus = 'prometheus',
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AlertManagerDataSourceJsonData = DataSourceJsonData & { implementation?: AlertManagerImplementation };
|
||||||
|
|||||||
Reference in New Issue
Block a user