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