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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
36 changed files with 767 additions and 342 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -29,6 +29,7 @@ export interface GrafanaChannelValues extends ChannelValues {
export interface CommonSettingsComponentProps {
pathPrefix: string;
className?: string;
readOnly?: boolean;
}
export type CommonSettingsComponentType = React.ComponentType<CommonSettingsComponentProps>;

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
{
"type": "datasource",
"name": "Alert Manager",
"name": "Alertmanager",
"id": "alertmanager",
"metrics": false,
"state": "alpha",

View File

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