Alerting: Alertmanager datasource support for upstream Prometheus AM implementation (#39775)

This commit is contained in:
Domas
2021-10-01 16:24:56 +03:00
committed by GitHub
parent cc7f7e30e9
commit a1d4be0700
36 changed files with 767 additions and 342 deletions

View File

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

View File

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

View File

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

View File

@@ -67,6 +67,7 @@ const AlertGroups = () => {
</React.Fragment> </React.Fragment>
); );
})} })}
{results && !filteredAlertGroups.length && <p>No results.</p>}
</AlertingPageWrapper> </AlertingPageWrapper>
); );
}; };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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