Alerting: Add RBAC logic for silences creation (#87322)

* Remove role requirement for editing silence (instead handled by silence editor displaying error)

* Send query params for metadata/access control silence logic

* Add new access control types to enum

* Add folder-specific checks for silences

* Remove filtering in alert manager picker

* fix flakey rule viewer test and update permissions helper

* Use `returnTo` in rule viewer page

* Fix incorrect display of duration

* Clean up some mock server behaviour in rule details tests

* Tweak styles for silences alerts table

* Remove alertmanager picker from silences drawer

* Add error if user cannot edit a silence

* Show alert rule name in silences table and consume RBAC logic

* Update mocks to include RBAC access control logic

* Update silences tests

* Update silences type to include access control info

* Update comment for missing alertmanager

* Update mock handlers and query param logic

* Tweak query params again

* Update access control mock

* Revert AM picker fix as user may not have access to Grafana AM

* Swap ternary order

* Change text for no alert rule targeted

* Don't show error alert from RTKQ query and remove alert instance preview in case of error

* Add missing translations

* Fix test adding missing mock for getting alert rule

* Add missing translations in SilencesTable

* Add translations autogenerated files

* fix allowing edit silence in external alert manager

---------

Co-authored-by: Sonia Aguilar <soniaaguilarpeiron@gmail.com>
Co-authored-by: Yuri Tseretyan <yuriy.tseretyan@grafana.com>
This commit is contained in:
Tom Ratcliffe 2024-06-05 20:09:26 +01:00 committed by GitHub
parent 20c90ff60d
commit 170d476bdc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 446 additions and 274 deletions

View File

@ -2463,13 +2463,8 @@ exports[`better eslint`] = {
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "3"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "4"]
],
"public/app/features/alerting/unified/components/silences/SilencedAlertsTable.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"]
],
"public/app/features/alerting/unified/components/silences/SilencedAlertsTableRow.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"]
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
],
"public/app/features/alerting/unified/components/silences/SilencedInstancesPreview.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
@ -2490,14 +2485,6 @@ exports[`better eslint`] = {
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "3"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "4"]
],
"public/app/features/alerting/unified/components/silences/SilencesTable.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "3"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "4"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "5"]
],
"public/app/features/alerting/unified/home/GettingStarted.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],

View File

@ -79,10 +79,6 @@ export function getAlertingRoutes(cfg = config): RouteDescriptor[] {
},
{
path: '/alerting/silence/:id/edit',
roles: evaluateAccess([
AccessControlAction.AlertingInstanceUpdate,
AccessControlAction.AlertingInstancesExternalWrite,
]),
component: importAlertingComponent(
() => import(/* webpackChunkName: "AlertSilences" */ 'app/features/alerting/unified/Silences')
),

View File

@ -1,47 +1,49 @@
import React from 'react';
import { render, waitFor, userEvent, screen } from 'test/test-utils';
import { render, screen, userEvent, waitFor, within } from 'test/test-utils';
import { byLabelText, byPlaceholderText, byRole, byTestId, byText } from 'testing-library-selector';
import { dateTime } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { config, locationService, setDataSourceSrv } from '@grafana/runtime';
import { setupMswServer } from 'app/features/alerting/unified/mockApi';
import { config, locationService } from '@grafana/runtime';
import { mockAlertRuleApi, setupMswServer } from 'app/features/alerting/unified/mockApi';
import { waitForServerRequest } from 'app/features/alerting/unified/mocks/server/events';
import { MOCK_GRAFANA_ALERT_RULE_TITLE } from 'app/features/alerting/unified/mocks/server/handlers/alertRules';
import {
MOCK_DATASOURCE_UID_BROKEN_ALERTMANAGER,
MOCK_DATASOURCE_NAME_BROKEN_ALERTMANAGER,
MOCK_DATASOURCE_UID_BROKEN_ALERTMANAGER,
} from 'app/features/alerting/unified/mocks/server/handlers/datasources';
import { silenceCreateHandler } from 'app/features/alerting/unified/mocks/server/handlers/silences';
import { MatcherOperator } from 'app/plugins/datasource/alertmanager/types';
import { MatcherOperator, SilenceState } from 'app/plugins/datasource/alertmanager/types';
import { AccessControlAction } from 'app/types';
import Silences from './Silences';
import { grantUserPermissions, MOCK_SILENCE_ID_EXISTING, mockDataSource, MockDataSourceSrv } from './mocks';
import { AlertmanagerProvider } from './state/AlertmanagerContext';
import {
MOCK_SILENCE_ID_EXISTING,
MOCK_SILENCE_ID_EXISTING_ALERT_RULE_UID,
MOCK_SILENCE_ID_LACKING_PERMISSIONS,
grantUserPermissions,
mockDataSource,
mockSilences,
} from './mocks';
import { grafanaRulerRule } from './mocks/alertRuleApi';
import { setupDataSources } from './testSetup/datasources';
import { DataSourceType } from './utils/datasource';
import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from './utils/datasource';
jest.mock('app/core/services/context_srv');
const TEST_TIMEOUT = 60000;
const renderSilences = (location = '/alerting/silences/') => {
locationService.push(location);
return render(
<AlertmanagerProvider accessType="instance">
<Silences />
</AlertmanagerProvider>,
{
historyOptions: {
initialEntries: [location],
},
}
);
return render(<Silences />, {
historyOptions: {
initialEntries: [location],
},
});
};
const dataSources = {
am: mockDataSource({
name: 'Alertmanager',
name: GRAFANA_RULES_SOURCE_NAME,
type: DataSourceType.Alertmanager,
}),
[MOCK_DATASOURCE_NAME_BROKEN_ALERTMANAGER]: mockDataSource({
@ -61,6 +63,7 @@ const ui = {
addSilenceButton: byRole('link', { name: /add silence/i }),
queryBar: byPlaceholderText('Search'),
existingSilenceNotFound: byRole('alert', { name: /existing silence .* not found/i }),
noPermissionToEdit: byRole('alert', { name: /do not have permission/i }),
editor: {
timeRange: byTestId(selectors.components.TimePicker.openButton),
durationField: byLabelText('Duration'),
@ -74,6 +77,7 @@ const ui = {
addMatcherButton: byRole('button', { name: 'Add matcher' }),
submit: byText(/save silence/i),
createdBy: byText(/created by \*/i),
loadingIndicator: byTestId('Spinner'),
},
};
@ -107,7 +111,7 @@ const addAdditionalMatcher = async () => {
await user.click(ui.editor.addMatcherButton.get());
};
setupMswServer();
const server = setupMswServer();
beforeEach(() => {
setupDataSources(dataSources.am, dataSources[MOCK_DATASOURCE_NAME_BROKEN_ALERTMANAGER]);
@ -117,10 +121,6 @@ describe('Silences', () => {
beforeAll(resetMocks);
afterEach(resetMocks);
beforeEach(() => {
setDataSourceSrv(new MockDataSourceSrv(dataSources));
});
it(
'loads and shows silences',
async () => {
@ -133,10 +133,10 @@ describe('Silences', () => {
expect(ui.expiredTable.get()).toBeInTheDocument();
const allSilences = ui.silenceRow.queryAll();
expect(allSilences).toHaveLength(3);
expect(allSilences[0]).toHaveTextContent('foo=bar');
expect(allSilences[1]).toHaveTextContent('foo!=bar');
expect(allSilences[2]).toHaveTextContent('foo=bar');
expect(allSilences).toHaveLength(mockSilences.length);
expect(within(allSilences[0]).getByLabelText('Tags')).toHaveTextContent('foo=bar');
expect(within(allSilences[1]).getByLabelText('Tags')).toHaveTextContent('foo!=bar');
expect(allSilences[2]).toHaveTextContent(MOCK_GRAFANA_ALERT_RULE_TITLE);
await user.click(ui.expiredCaret.get());
@ -144,7 +144,10 @@ describe('Silences', () => {
expect(ui.expiredTable.query()).not.toBeInTheDocument();
const activeSilences = ui.silenceRow.queryAll();
expect(activeSilences).toHaveLength(2);
const expectedActiveSilences = mockSilences.filter(
(silence) => silence.status.state !== SilenceState.Expired
).length;
expect(activeSilences).toHaveLength(expectedActiveSilences);
expect(activeSilences[0]).toHaveTextContent('foo=bar');
expect(activeSilences[1]).toHaveTextContent('foo!=bar');
},
@ -161,6 +164,7 @@ describe('Silences', () => {
expect(notExpiredTable).toBeInTheDocument();
const silencedAlertRows = await ui.silencedAlertCell.findAll(notExpiredTable);
expect(silencedAlertRows[0]).toHaveTextContent('2');
expect(silencedAlertRows[1]).toHaveTextContent('0');
},
@ -175,8 +179,8 @@ describe('Silences', () => {
const queryBar = await ui.queryBar.find();
await user.type(queryBar, 'foo=bar');
await waitFor(() => expect(ui.silenceRow.getAll()).toHaveLength(2));
await screen.findByRole('button', { name: /clear filters/i });
expect(ui.silenceRow.getAll()).toHaveLength(1);
},
TEST_TIMEOUT
);
@ -211,6 +215,7 @@ describe('Silence create/edit', () => {
afterEach(resetMocks);
beforeEach(() => {
mockAlertRuleApi(server).getAlertRule(MOCK_SILENCE_ID_EXISTING_ALERT_RULE_UID, grafanaRulerRule);
setUserLogged(true);
});
@ -258,7 +263,7 @@ describe('Silence create/edit', () => {
'creates a new silence',
async () => {
const user = userEvent.setup();
renderSilences(`${baseUrlPath}?alertmanager=Alertmanager`);
renderSilences(`${baseUrlPath}?alertmanager=${GRAFANA_RULES_SOURCE_NAME}`);
expect(await ui.editor.durationField.find()).toBeInTheDocument();
const postRequest = waitForServerRequest(silenceCreateHandler());
@ -314,6 +319,11 @@ describe('Silence create/edit', () => {
expect(await ui.existingSilenceNotFound.find()).toBeInTheDocument();
});
it('shows an error when user cannot edit/recreate silence', async () => {
renderSilences(`/alerting/silence/${MOCK_SILENCE_ID_LACKING_PERMISSIONS}/edit`);
expect(await ui.noPermissionToEdit.find()).toBeInTheDocument();
});
it('populates form with existing silence information', async () => {
renderSilences(`/alerting/silence/${MOCK_SILENCE_ID_EXISTING}/edit`);
@ -321,7 +331,13 @@ describe('Silence create/edit', () => {
// existing fields have been filled out as well
await waitFor(() => expect(ui.editor.matcherName.get()).toHaveValue('foo'));
expect(ui.editor.matcherValue.get()).toHaveValue('bar');
expect(ui.editor.comment.get()).toHaveValue('Silence noisy alerts');
expect(ui.editor.comment.get()).toHaveValue('Happy path silence');
});
it('populates form with existing silence information that has __alert_rule_uid__', async () => {
mockAlertRuleApi(server).getAlertRule(MOCK_SILENCE_ID_EXISTING_ALERT_RULE_UID, grafanaRulerRule);
renderSilences(`/alerting/silence/${MOCK_SILENCE_ID_EXISTING_ALERT_RULE_UID}/edit`);
expect(await screen.findByLabelText(/alert rule/i)).toHaveValue(grafanaRulerRule.grafana_alert.title);
});
it(
@ -331,7 +347,7 @@ describe('Silence create/edit', () => {
const postRequest = waitForServerRequest(silenceCreateHandler());
renderSilences(`${baseUrlPath}?alertmanager=Alertmanager`);
renderSilences(`${baseUrlPath}?alertmanager=${GRAFANA_RULES_SOURCE_NAME}`);
await waitFor(() => expect(ui.editor.durationField.query()).not.toBeNull());
await enterSilenceLabel(0, 'foo', MatcherOperator.equal, 'bar');
@ -340,7 +356,7 @@ describe('Silence create/edit', () => {
expect(await ui.notExpiredTable.find()).toBeInTheDocument();
expect(locationService.getSearch().get('alertmanager')).toBe('Alertmanager');
expect(locationService.getSearch().get('alertmanager')).toBe(GRAFANA_RULES_SOURCE_NAME);
const createSilenceRequest = await postRequest;
const requestBody = await createSilenceRequest.clone().json();

View File

@ -14,10 +14,17 @@ export const alertSilencesApi = alertingApi.injectEndpoints({
Silence[],
{
datasourceUid: string;
ruleMetadata?: boolean;
accessControl?: boolean;
}
>({
query: ({ datasourceUid }) => ({
query: ({ datasourceUid, ruleMetadata, accessControl }) => ({
url: `/api/alertmanager/${datasourceUid}/api/v2/silences`,
params: {
ruleMetadata,
// query param is lowercased on backend for consistency with folder endpoint
accesscontrol: accessControl,
},
}),
providesTags: (result) =>
result ? result.map(({ id }) => ({ type: 'AlertmanagerSilences', id })) : ['AlertmanagerSilences'],
@ -28,11 +35,18 @@ export const alertSilencesApi = alertingApi.injectEndpoints({
{
datasourceUid: string;
id: string;
ruleMetadata?: boolean;
accessControl?: boolean;
}
>({
query: ({ datasourceUid, id }) => ({
query: ({ datasourceUid, id, ruleMetadata, accessControl }) => ({
url: `/api/alertmanager/${datasourceUid}/api/v2/silence/${id}`,
showErrorAlert: false,
params: {
ruleMetadata,
// query param is lowercased on backend for consistency with folder endpoint
accesscontrol: accessControl,
},
}),
providesTags: (result, error, { id }) =>
result ? [{ type: 'AlertmanagerSilences', id }] : ['AlertmanagerSilences'],

View File

@ -4,13 +4,13 @@ import { dispatch } from 'app/store/store';
import { ReceiversStateDTO } from 'app/types/alerting';
import {
AlertManagerCortexConfig,
AlertmanagerAlert,
AlertmanagerChoice,
AlertManagerCortexConfig,
AlertmanagerGroup,
GrafanaAlertingConfiguration,
ExternalAlertmanagersConnectionStatus,
ExternalAlertmanagersStatusResponse,
GrafanaAlertingConfiguration,
GrafanaManagedContactPoint,
Matcher,
MuteTimeInterval,
@ -19,8 +19,8 @@ import { NotifierDTO } from '../../../../types';
import { withPerformanceLogging } from '../Analytics';
import { matcherToOperator } from '../utils/alertmanager';
import {
getDatasourceAPIUid,
GRAFANA_RULES_SOURCE_NAME,
getDatasourceAPIUid,
isVanillaPrometheusAlertManagerDataSource,
} from '../utils/datasource';
import { retryWhile, wrapWithQuotes } from '../utils/misc';
@ -52,9 +52,9 @@ export const alertmanagerApi = alertingApi.injectEndpoints({
endpoints: (build) => ({
getAlertmanagerAlerts: build.query<
AlertmanagerAlert[],
{ amSourceName: string; filter?: AlertmanagerAlertsFilter }
{ amSourceName: string; filter?: AlertmanagerAlertsFilter; showErrorAlert?: boolean }
>({
query: ({ amSourceName, filter }) => {
query: ({ amSourceName, filter, showErrorAlert = true }) => {
// TODO Add support for active, silenced, inhibited, unprocessed filters
const filterMatchers = filter?.matchers
?.filter((matcher) => matcher.name && matcher.value)
@ -77,6 +77,7 @@ export const alertmanagerApi = alertingApi.injectEndpoints({
return {
url: `/api/alertmanager/${getDatasourceAPIUid(amSourceName)}/api/v2/alerts`,
params,
showErrorAlert,
};
},
providesTags: ['AlertmanagerAlerts'],

View File

@ -9,43 +9,32 @@ import { AlertManagerDataSource, GRAFANA_RULES_SOURCE_NAME } from '../utils/data
interface Props {
disabled?: boolean;
/**
* If true, only show alertmanagers that are receiving alerts from Grafana
*/
showOnlyReceivingGrafanaAlerts?: boolean;
}
function getAlertManagerLabel(alertManager: AlertManagerDataSource) {
return alertManager.name === GRAFANA_RULES_SOURCE_NAME ? 'Grafana' : alertManager.name.slice(0, 37);
}
export const AlertManagerPicker = ({ disabled = false, showOnlyReceivingGrafanaAlerts }: Props) => {
export const AlertManagerPicker = ({ disabled = false }: Props) => {
const styles = useStyles2(getStyles);
const { selectedAlertmanager, availableAlertManagers, setSelectedAlertmanager } = useAlertmanager();
const options: Array<SelectableValue<string>> = useMemo(() => {
return availableAlertManagers
.filter(({ name, handleGrafanaManagedAlerts }) => {
const isReceivingGrafanaAlerts = name === GRAFANA_RULES_SOURCE_NAME || handleGrafanaManagedAlerts;
return showOnlyReceivingGrafanaAlerts ? isReceivingGrafanaAlerts : true;
})
.map((ds) => ({
label: getAlertManagerLabel(ds),
value: ds.name,
imgUrl: ds.imgUrl,
meta: ds.meta,
}));
}, [availableAlertManagers, showOnlyReceivingGrafanaAlerts]);
return availableAlertManagers.map((ds) => ({
label: getAlertManagerLabel(ds),
value: ds.name,
imgUrl: ds.imgUrl,
meta: ds.meta,
}));
}, [availableAlertManagers]);
const isDisabled = disabled || options.length === 1;
const label = isDisabled ? 'Alertmanager' : 'Choose Alertmanager';
return (
<InlineField
className={styles.field}
label={disabled ? 'Alertmanager' : 'Choose Alertmanager'}
disabled={disabled || options.length === 1}
data-testid="alertmanager-picker"
>
<InlineField className={styles.field} label={label} disabled={isDisabled} data-testid="alertmanager-picker">
<Select
aria-label={disabled ? 'Alertmanager' : 'Choose Alertmanager'}
aria-label={label}
width={29}
className="ds-picker select-container"
backspaceRemovesValue={false}

View File

@ -21,7 +21,7 @@ import {
import { grafanaRulerRule } from '../../mocks/alertRuleApi';
import { setupDataSources } from '../../testSetup/datasources';
import { Annotation } from '../../utils/constants';
import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
import { DataSourceType } from '../../utils/datasource';
import * as ruleId from '../../utils/rule-id';
import { AlertRuleProvider } from './RuleContext';
@ -71,8 +71,25 @@ setPluginExtensionsHook(() => ({
isLoading: false,
}));
/**
* "Grants" permissions via contextSrv mock, and additionally sets folder access control
* API response to match
*/
const grantPermissionsHelper = (permissions: AccessControlAction[]) => {
const permissionsHash = permissions.reduce((hash, permission) => ({ ...hash, [permission]: true }), {});
grantUserPermissions(permissions);
setFolderAccessControl(permissionsHash);
};
const openSilenceDrawer = async () => {
const user = userEvent.setup();
await user.click(ELEMENTS.actions.more.button.get());
await user.click(ELEMENTS.actions.more.actions.silence.get());
await screen.findByText(/Configure silences/i);
};
beforeAll(() => {
grantUserPermissions([
grantPermissionsHelper([
AccessControlAction.AlertingRuleCreate,
AccessControlAction.AlertingRuleRead,
AccessControlAction.AlertingRuleUpdate,
@ -109,23 +126,28 @@ describe('RuleViewer', () => {
const mockRuleIdentifier = ruleId.fromCombinedRule('grafana', mockRule);
beforeAll(() => {
grantUserPermissions([
grantPermissionsHelper([
AccessControlAction.AlertingRuleCreate,
AccessControlAction.AlertingRuleRead,
AccessControlAction.AlertingRuleUpdate,
AccessControlAction.AlertingRuleDelete,
AccessControlAction.AlertingInstanceRead,
AccessControlAction.AlertingInstanceCreate,
AccessControlAction.AlertingInstanceRead,
AccessControlAction.AlertingInstancesExternalRead,
AccessControlAction.AlertingInstancesExternalWrite,
]);
setBackendSrv(backendSrv);
setFolderAccessControl({
[AccessControlAction.AlertingRuleCreate]: true,
[AccessControlAction.AlertingRuleRead]: true,
[AccessControlAction.AlertingRuleUpdate]: true,
[AccessControlAction.AlertingRuleDelete]: true,
[AccessControlAction.AlertingInstanceCreate]: true,
});
const dataSources = {
am: mockDataSource<AlertManagerDataSourceJsonData>({
name: 'Alertmanager',
type: DataSourceType.Alertmanager,
jsonData: {
handleGrafanaManagedAlerts: true,
},
}),
};
setupDataSources(dataSources.am);
});
it('should render a Grafana managed alert rule', async () => {
@ -164,30 +186,9 @@ describe('RuleViewer', () => {
}
});
it.skip('renders silencing form correctly and shows alert rule name', async () => {
const dataSources = {
grafana: mockDataSource<AlertManagerDataSourceJsonData>({
name: GRAFANA_RULES_SOURCE_NAME,
type: DataSourceType.Alertmanager,
jsonData: {
handleGrafanaManagedAlerts: true,
},
}),
am: mockDataSource<AlertManagerDataSourceJsonData>({
name: 'Alertmanager',
type: DataSourceType.Alertmanager,
jsonData: {
handleGrafanaManagedAlerts: true,
},
}),
};
setupDataSources(dataSources.grafana, dataSources.am);
it('renders silencing form correctly and shows alert rule name', async () => {
await renderRuleViewer(mockRule, mockRuleIdentifier);
const user = userEvent.setup();
await user.click(ELEMENTS.actions.more.button.get());
await user.click(ELEMENTS.actions.more.actions.silence.get());
await openSilenceDrawer();
const silenceDrawer = await screen.findByRole('dialog', { name: 'Drawer title Silence alert rule' });
expect(await within(silenceDrawer).findByLabelText(/^alert rule/i)).toHaveValue(

View File

@ -225,11 +225,13 @@ interface TitleProps {
export const Title = ({ name, paused = false, state, health, ruleType, ruleOrigin }: TitleProps) => {
const styles = useStyles2(getStyles);
const [queryParams] = useQueryParams();
const isRecordingRule = ruleType === PromRuleType.Recording;
const returnTo = queryParams.returnTo ? String(queryParams.returnTo) : '/alerting/list';
return (
<div className={styles.title}>
<LinkButton variant="secondary" icon="angle-left" href="/alerting/list" />
<LinkButton variant="secondary" icon="angle-left" href={returnTo} />
{ruleOrigin && <PluginOriginBadge pluginId={ruleOrigin.pluginId} />}
<Text variant="h1" truncate>
{name}

View File

@ -1,23 +1,14 @@
import { render, screen, waitFor } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
import React from 'react';
import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import { render } from 'test/test-utils';
import { byRole } from 'testing-library-selector';
import { PluginExtensionTypes } from '@grafana/data';
import { usePluginLinkExtensions, setBackendSrv } from '@grafana/runtime';
import { backendSrv } from 'app/core/services/backend_srv';
import { AlertmanagerChoice } from 'app/plugins/datasource/alertmanager/types';
import { configureStore } from 'app/store/configureStore';
import { CombinedRule } from 'app/types/unified-alerting';
import { setupMswServer } from 'app/features/alerting/unified/mockApi';
import { GrafanaAlertingConfigurationStatusResponse } from '../../api/alertmanagerApi';
import { useIsRuleEditable } from '../../hooks/useIsRuleEditable';
import { getCloudRule, getGrafanaRule } from '../../mocks';
import { mockAlertmanagerChoiceResponse } from '../../mocks/alertmanagerApi';
import { SupportedPlugin } from '../../types/pluginBridges';
import { RuleDetails } from './RuleDetails';
@ -38,33 +29,16 @@ const ui = {
actionButtons: {
edit: byRole('link', { name: /edit/i }),
delete: byRole('button', { name: /delete/i }),
silence: byRole('link', { name: 'Silence' }),
},
};
const server = setupServer(
http.get(`/api/plugins/${SupportedPlugin.Incident}/settings`, async () => {
return HttpResponse.json({
enabled: false,
});
})
);
const alertmanagerChoiceMockedResponse: GrafanaAlertingConfigurationStatusResponse = {
alertmanagersChoice: AlertmanagerChoice.Internal,
numExternalAlertmanagers: 0,
};
setupMswServer();
beforeAll(() => {
setBackendSrv(backendSrv);
server.listen({ onUnhandledRequest: 'error' });
jest.clearAllMocks();
});
afterAll(() => {
server.close();
});
beforeEach(() => {
mocks.usePluginLinkExtensionsMock.mockReturnValue({
extensions: [
@ -80,8 +54,6 @@ beforeEach(() => {
],
isLoading: false,
});
server.resetHandlers();
mockAlertmanagerChoiceResponse(server, alertmanagerChoiceMockedResponse);
});
describe('RuleDetails RBAC', () => {
@ -93,11 +65,10 @@ describe('RuleDetails RBAC', () => {
mocks.useIsRuleEditable.mockReturnValue({ loading: false, isEditable: true });
// Act
renderRuleDetails(grafanaRule);
render(<RuleDetails rule={grafanaRule} />);
// Assert
expect(ui.actionButtons.edit.query()).not.toBeInTheDocument();
await waitFor(() => screen.queryByRole('button', { name: 'Declare incident' }));
});
it('Should not render Delete button for users with the delete permission', async () => {
@ -105,11 +76,10 @@ describe('RuleDetails RBAC', () => {
mocks.useIsRuleEditable.mockReturnValue({ loading: false, isRemovable: true });
// Act
renderRuleDetails(grafanaRule);
render(<RuleDetails rule={grafanaRule} />);
// Assert
expect(ui.actionButtons.delete.query()).not.toBeInTheDocument();
await waitFor(() => screen.queryByRole('button', { name: 'Declare incident' }));
});
});
@ -121,11 +91,10 @@ describe('RuleDetails RBAC', () => {
mocks.useIsRuleEditable.mockReturnValue({ loading: false, isEditable: true });
// Act
renderRuleDetails(cloudRule);
render(<RuleDetails rule={cloudRule} />);
// Assert
expect(ui.actionButtons.edit.query()).not.toBeInTheDocument();
await waitFor(() => screen.queryByRole('button', { name: 'Declare incident' }));
});
it('Should not render Delete button for users with the delete permission', async () => {
@ -133,23 +102,10 @@ describe('RuleDetails RBAC', () => {
mocks.useIsRuleEditable.mockReturnValue({ loading: false, isRemovable: true });
// Act
renderRuleDetails(cloudRule);
render(<RuleDetails rule={cloudRule} />);
// Assert
expect(ui.actionButtons.delete.query()).not.toBeInTheDocument();
await waitFor(() => screen.queryByRole('button', { name: 'Declare incident' }));
});
});
});
function renderRuleDetails(rule: CombinedRule) {
const store = configureStore();
render(
<Provider store={store}>
<MemoryRouter>
<RuleDetails rule={rule} />
</MemoryRouter>
</Provider>
);
}

View File

@ -26,11 +26,15 @@ export const SilenceDetails = ({ silence }: Props) => {
<div className={styles.title}>Schedule</div>
<div>{`${startsAtDate?.format(dateDisplayFormat)} - ${endsAtDate?.format(dateDisplayFormat)}`}</div>
<div className={styles.title}>Duration</div>
<div> {duration}</div>
<div>{duration}</div>
<div className={styles.title}>Created by</div>
<div> {createdBy}</div>
<div className={styles.title}>Affected alerts</div>
<SilencedAlertsTable silencedAlerts={silencedAlerts} />
<div>{createdBy}</div>
{silencedAlerts.length > 0 && (
<>
<div className={styles.title}>Affected alerts</div>
<SilencedAlertsTable silencedAlerts={silencedAlerts} />
</>
)}
</div>
);
};
@ -40,6 +44,7 @@ const getStyles = (theme: GrafanaTheme2) => ({
display: 'grid',
gridTemplateColumns: '1fr 9fr',
gridRowGap: '1rem',
paddingBottom: theme.spacing(2),
}),
title: css({
color: theme.colors.text.primary,

View File

@ -1,11 +1,9 @@
import React from 'react';
import { Divider, Drawer, Stack } from '@grafana/ui';
import { AlertManagerPicker } from 'app/features/alerting/unified/components/AlertManagerPicker';
import { GrafanaAlertmanagerDeliveryWarning } from 'app/features/alerting/unified/components/GrafanaAlertmanagerDeliveryWarning';
import { Drawer, Stack } from '@grafana/ui';
import { SilencesEditor } from 'app/features/alerting/unified/components/silences/SilencesEditor';
import { getDefaultSilenceFormValues } from 'app/features/alerting/unified/components/silences/utils';
import { useAlertmanager } from 'app/features/alerting/unified/state/AlertmanagerContext';
import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource';
import { RulerGrafanaRuleDTO } from 'app/types/unified-alerting-dto';
type Props = {
@ -20,7 +18,6 @@ const SilenceGrafanaRuleDrawer = ({ rulerRule, onClose }: Props) => {
const { uid } = rulerRule.grafana_alert;
const formValues = getDefaultSilenceFormValues();
const { selectedAlertmanager } = useAlertmanager();
return (
<Drawer
@ -30,17 +27,10 @@ const SilenceGrafanaRuleDrawer = ({ rulerRule, onClose }: Props) => {
size="md"
>
<Stack direction={'column'}>
<GrafanaAlertmanagerDeliveryWarning currentAlertmanager={selectedAlertmanager!} />
<div>
<AlertManagerPicker showOnlyReceivingGrafanaAlerts />
<Divider />
</div>
<SilencesEditor
ruleUid={uid}
formValues={formValues}
alertManagerSourceName={selectedAlertmanager!}
alertManagerSourceName={GRAFANA_RULES_SOURCE_NAME}
onSilenceCreated={onClose}
onCancel={onClose}
/>

View File

@ -3,6 +3,7 @@ import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '@grafana/ui';
import { Trans } from 'app/core/internationalization';
import { AlertmanagerAlert } from 'app/plugins/datasource/alertmanager/types';
import { getAlertTableStyles } from '../../styles/table';
@ -29,9 +30,13 @@ const SilencedAlertsTable = ({ silencedAlerts }: Props) => {
<thead>
<tr>
<th></th>
<th>State</th>
<th>
<Trans i18nKey="silences-table.header.state">State</Trans>
</th>
<th></th>
<th>Alert name</th>
<th>
<Trans i18nKey="silences-table.header.alert-name">Alert name</Trans>
</th>
</tr>
</thead>
<tbody>

View File

@ -35,7 +35,7 @@ export const SilencedAlertsTableRow = ({ alert, className }: Props) => {
<td>
<AmAlertStateTag state={alert.status.state} />
</td>
<td>for {duration} seconds</td>
<td>for {duration}</td>
<td>{alertName}</td>
</tr>
{!isCollapsed && (

View File

@ -63,6 +63,14 @@ export const SilencedInstancesPreview = ({ amSourceName, matchers: inputMatchers
[amSourceName, matchers]
);
if (isError) {
return (
<Alert title="Preview not available" severity="error">
Error occured when generating preview of affected alerts. Are your matchers valid?
</Alert>
);
}
const tableItemAlerts = alerts.map<DynamicTableItemProps<AlertmanagerAlert>>((alert) => ({
id: alert.fingerprint,
data: alert,
@ -77,11 +85,7 @@ export const SilencedInstancesPreview = ({ amSourceName, matchers: inputMatchers
) : null}
</h4>
{!hasValidMatchers && <span>Add a valid matcher to see affected alerts</span>}
{isError && (
<Alert title="Preview not available" severity="error">
Error occured when generating preview of affected alerts. Are your matchers valid?
</Alert>
)}
{isFetching && <LoadingPlaceholder text="Loading affected alert rule instances..." />}
{!isFetching && !isError && hasValidMatchers && (
<div className={styles.table}>

View File

@ -27,7 +27,7 @@ import {
} from '@grafana/ui';
import { alertSilencesApi, SilenceCreatedResponse } from 'app/features/alerting/unified/api/alertSilencesApi';
import { MATCHER_ALERT_RULE_UID } from 'app/features/alerting/unified/utils/constants';
import { getDatasourceAPIUid } from 'app/features/alerting/unified/utils/datasource';
import { getDatasourceAPIUid, GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource';
import { MatcherOperator, SilenceCreatePayload } from 'app/plugins/datasource/alertmanager/types';
import { SilenceFormFields } from '../../types/silence-form';
@ -49,7 +49,7 @@ interface Props {
*
* Fetches silence details from API, based on `silenceId`
*/
export const ExistingSilenceEditor = ({ silenceId, alertManagerSourceName }: Props) => {
const ExistingSilenceEditor = ({ silenceId, alertManagerSourceName }: Props) => {
const {
data: silence,
isLoading: getSilenceIsLoading,
@ -57,9 +57,12 @@ export const ExistingSilenceEditor = ({ silenceId, alertManagerSourceName }: Pro
} = alertSilencesApi.endpoints.getSilence.useQuery({
id: silenceId,
datasourceUid: getDatasourceAPIUid(alertManagerSourceName),
ruleMetadata: true,
accessControl: true,
});
const ruleUid = silence?.matchers?.find((m) => m.name === MATCHER_ALERT_RULE_UID)?.value;
const isGrafanaAlertManager = alertManagerSourceName === GRAFANA_RULES_SOURCE_NAME;
const defaultValues = useMemo(() => {
if (!silence) {
@ -80,6 +83,12 @@ export const ExistingSilenceEditor = ({ silenceId, alertManagerSourceName }: Pro
return <Alert title={`Existing silence "${silenceId}" not found`} severity="warning" />;
}
const canEditSilence = isGrafanaAlertManager ? silence?.accessControl?.write : true;
if (!canEditSilence) {
return <Alert title={`You do not have permission to edit/recreate this silence`} severity="error" />;
}
return (
<SilencesEditor ruleUid={ruleUid} formValues={defaultValues} alertManagerSourceName={alertManagerSourceName} />
);
@ -258,6 +267,7 @@ const getStyles = (theme: GrafanaTheme2) => ({
justifyContent: 'flex-start',
gap: theme.spacing(1),
maxWidth: theme.breakpoints.values.sm,
paddingTop: theme.spacing(2),
}),
});

View File

@ -1,14 +1,25 @@
import { css } from '@emotion/css';
import React, { useMemo } from 'react';
import { dateMath, GrafanaTheme2 } from '@grafana/data';
import { CollapsableSection, Icon, Link, LinkButton, useStyles2, Stack, Alert, LoadingPlaceholder } from '@grafana/ui';
import { GrafanaTheme2, dateMath } from '@grafana/data';
import {
Alert,
CollapsableSection,
Divider,
Icon,
Link,
LinkButton,
LoadingPlaceholder,
Stack,
useStyles2,
} from '@grafana/ui';
import { useQueryParams } from 'app/core/hooks/useQueryParams';
import { Trans } from 'app/core/internationalization';
import { alertSilencesApi } from 'app/features/alerting/unified/api/alertSilencesApi';
import { alertmanagerApi } from 'app/features/alerting/unified/api/alertmanagerApi';
import { featureDiscoveryApi } from 'app/features/alerting/unified/api/featureDiscoveryApi';
import { SILENCES_POLL_INTERVAL_MS } from 'app/features/alerting/unified/utils/constants';
import { getDatasourceAPIUid } from 'app/features/alerting/unified/utils/datasource';
import { MATCHER_ALERT_RULE_UID, SILENCES_POLL_INTERVAL_MS } from 'app/features/alerting/unified/utils/constants';
import { GRAFANA_RULES_SOURCE_NAME, getDatasourceAPIUid } from 'app/features/alerting/unified/utils/datasource';
import { AlertmanagerAlert, Silence, SilenceState } from 'app/plugins/datasource/alertmanager/types';
import { AlertmanagerAction, useAlertmanagerAbility } from '../../hooks/useAbilities';
@ -16,8 +27,6 @@ import { parseMatchers } from '../../utils/alertmanager';
import { getSilenceFiltersFromUrlParams, makeAMLink, stringifyErrorLike } from '../../utils/misc';
import { Authorize } from '../Authorize';
import { DynamicTable, DynamicTableColumnProps, DynamicTableItemProps } from '../DynamicTable';
import { ActionButton } from '../rules/ActionButton';
import { ActionIcon } from '../rules/ActionIcon';
import { Matchers } from './Matchers';
import { NoSilencesSplash } from './NoSilencesCTA';
@ -49,7 +58,7 @@ const SilencesTable = ({ alertManagerSourceName }: Props) => {
isLoading,
error,
} = alertSilencesApi.endpoints.getSilences.useQuery(
{ datasourceUid: getDatasourceAPIUid(alertManagerSourceName) },
{ datasourceUid: getDatasourceAPIUid(alertManagerSourceName), ruleMetadata: true, accessControl: true },
API_QUERY_OPTIONS
);
@ -102,8 +111,10 @@ const SilencesTable = ({ alertManagerSourceName }: Props) => {
if (mimirLazyInitError) {
return (
<Alert title="The selected Alertmanager has no configuration" severity="warning">
Create a new contact point to create a configuration using the default values or contact your administrator to
set up the Alertmanager.
<Trans i18nKey="silences.table.noConfig">
Create a new contact point to create a configuration using the default values or contact your administrator to
set up the Alertmanager.
</Trans>
</Alert>
);
}
@ -125,7 +136,7 @@ const SilencesTable = ({ alertManagerSourceName }: Props) => {
<Authorize actions={[AlertmanagerAction.CreateSilence]}>
<Stack justifyContent="end">
<LinkButton href={makeAMLink('/alerting/silence/new', alertManagerSourceName)} icon="plus">
Add Silence
<Trans i18nKey="silences.table.add-silence-button">Add Silence</Trans>
</LinkButton>
</Stack>
</Authorize>
@ -138,7 +149,11 @@ const SilencesTable = ({ alertManagerSourceName }: Props) => {
<CollapsableSection label={`Expired silences (${itemsExpired.length})`} isOpen={showExpiredFromUrl}>
<div className={styles.callout}>
<Icon className={styles.calloutIcon} name="info-circle" />
<span>Expired silences are automatically deleted after 5 days.</span>
<span>
<Trans i18nKey="silences.table.expired-silences">
Expired silences are automatically deleted after 5 days.
</Trans>
</span>
</div>
<SilenceList
items={itemsExpired}
@ -172,11 +187,18 @@ function SilenceList({
cols={columns}
isExpandable
dataTestId={dataTestId}
renderExpandedContent={({ data }) => <SilenceDetails silence={data} />}
renderExpandedContent={({ data }) => {
return (
<>
<Divider />
<SilenceDetails silence={data} />
</>
);
}}
/>
);
} else {
return <>No matching silences found</>;
return <Trans i18nKey="silences.table.no-matching-silences">No matching silences found;</Trans>;
}
}
@ -238,6 +260,7 @@ const getStyles = (theme: GrafanaTheme2) => ({
function useColumns(alertManagerSourceName: string) {
const [updateSupported, updateAllowed] = useAlertmanagerAbility(AlertmanagerAction.UpdateSilence);
const [expireSilence] = alertSilencesApi.endpoints.expireSilence.useMutation();
const isGrafanaFlavoredAlertmanager = alertManagerSourceName === GRAFANA_RULES_SOURCE_NAME;
return useMemo((): SilenceTableColumnProps[] => {
const handleExpireSilenceClick = (silenceId: string) => {
@ -250,23 +273,40 @@ function useColumns(alertManagerSourceName: string) {
renderCell: function renderStateTag({ data: { status } }) {
return <SilenceStateTag state={status.state} />;
},
size: 4,
size: 3,
},
{
id: 'alert-rule',
label: 'Alert rule targeted',
renderCell: function renderAlertRuleLink({ data: { metadata } }) {
return metadata?.rule_title ? (
<Link
href={`/alerting/grafana/${metadata?.rule_uid}/view?returnTo=${encodeURIComponent('/alerting/silences')}`}
>
{metadata.rule_title}
</Link>
) : (
'None'
);
},
size: 8,
},
{
id: 'matchers',
label: 'Matching labels',
renderCell: function renderMatchers({ data: { matchers } }) {
return <Matchers matchers={matchers || []} />;
const filteredMatchers = matchers?.filter((matcher) => matcher.name !== MATCHER_ALERT_RULE_UID) || [];
return <Matchers matchers={filteredMatchers} />;
},
size: 10,
size: 7,
},
{
id: 'alerts',
label: 'Alerts',
label: 'Alerts silenced',
renderCell: function renderSilencedAlerts({ data: { silencedAlerts } }) {
return <span data-testid="alerts">{silencedAlerts.length}</span>;
},
size: 4,
size: 2,
},
{
id: 'schedule',
@ -275,39 +315,58 @@ function useColumns(alertManagerSourceName: string) {
const startsAtDate = dateMath.parse(startsAt);
const endsAtDate = dateMath.parse(endsAt);
const dateDisplayFormat = 'YYYY-MM-DD HH:mm';
return (
<>
{' '}
{startsAtDate?.format(dateDisplayFormat)} {'-'}
{endsAtDate?.format(dateDisplayFormat)}
</>
);
return `${startsAtDate?.format(dateDisplayFormat)} - ${endsAtDate?.format(dateDisplayFormat)}`;
},
size: 7,
},
];
if (updateSupported && updateAllowed) {
if (updateSupported) {
columns.push({
id: 'actions',
label: 'Actions',
renderCell: function renderActions({ data: silence }) {
const isExpired = silence.status.state === SilenceState.Expired;
const canCreate = silence?.accessControl?.create;
const canWrite = silence?.accessControl?.write;
const canRecreate = isExpired && (isGrafanaFlavoredAlertmanager ? canCreate : updateAllowed);
const canEdit = !isExpired && (isGrafanaFlavoredAlertmanager ? canWrite : updateAllowed);
return (
<Stack gap={0.5}>
{silence.status.state === 'expired' ? (
<Link href={makeAMLink(`/alerting/silence/${silence.id}/edit`, alertManagerSourceName)}>
<ActionButton icon="sync">Recreate</ActionButton>
</Link>
) : (
<ActionButton icon="bell" onClick={() => handleExpireSilenceClick(silence.id)}>
Unsilence
</ActionButton>
<Stack gap={0.5} wrap="wrap">
{canRecreate && (
<LinkButton
title="Recreate"
size="sm"
variant="secondary"
icon="sync"
href={makeAMLink(`/alerting/silence/${silence.id}/edit`, alertManagerSourceName)}
>
<Trans i18nKey="silences.table.recreate-button">Recreate</Trans>
</LinkButton>
)}
{silence.status.state !== 'expired' && (
<ActionIcon
to={makeAMLink(`/alerting/silence/${silence.id}/edit`, alertManagerSourceName)}
icon="pen"
tooltip="Edit"
/>
{canEdit && (
<>
<LinkButton
title="Unsilence"
size="sm"
variant="secondary"
icon="bell"
onClick={() => handleExpireSilenceClick(silence.id)}
>
<Trans i18nKey="silences.table.unsilence-button">Unsilence</Trans>
</LinkButton>
<LinkButton
title="Edit"
size="sm"
variant="secondary"
icon="pen"
href={makeAMLink(`/alerting/silence/${silence.id}/edit`, alertManagerSourceName)}
>
<Trans i18nKey="silences.table.edit-button">Edit</Trans>
</LinkButton>
</>
)}
</Stack>
);
@ -316,6 +375,6 @@ function useColumns(alertManagerSourceName: string) {
});
}
return columns;
}, [alertManagerSourceName, expireSilence, updateAllowed, updateSupported]);
}, [alertManagerSourceName, expireSilence, isGrafanaFlavoredAlertmanager, updateAllowed, updateSupported]);
}
export default SilencesTable;

View File

@ -3,22 +3,21 @@ import { createBrowserHistory } from 'history';
import React, { PropsWithChildren } from 'react';
import { Router } from 'react-router-dom';
import { TestProvider } from 'test/helpers/TestProvider';
import { render, screen } from 'test/test-utils';
import { mockFolderApi, setupMswServer } from 'app/features/alerting/unified/mockApi';
import {
defaultGrafanaAlertingConfigurationStatusResponse,
mockAlertmanagerChoiceResponse,
} from 'app/features/alerting/unified/mocks/alertmanagerApi';
import { setupMswServer } from 'app/features/alerting/unified/mockApi';
import { setFolderAccessControl } from 'app/features/alerting/unified/mocks/server/configure';
import { AlertManagerDataSourceJsonData, AlertManagerImplementation } from 'app/plugins/datasource/alertmanager/types';
import { AccessControlAction } from 'app/types';
import { RulerGrafanaRuleDTO } from 'app/types/unified-alerting-dto';
import { CombinedRule } from 'app/types/unified-alerting';
import { getCloudRule, getGrafanaRule, grantUserPermissions, mockDataSource, mockFolder } from '../mocks';
import { getCloudRule, getGrafanaRule, grantUserPermissions, mockDataSource } from '../mocks';
import { AlertmanagerProvider } from '../state/AlertmanagerContext';
import { setupDataSources } from '../testSetup/datasources';
import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource';
import {
AlertRuleAction,
AlertmanagerAction,
useAlertmanagerAbilities,
useAlertmanagerAbility,
@ -142,20 +141,28 @@ describe('alertmanager abilities', () => {
});
});
setupMswServer();
/**
* Render the hook result in a component so we can more reliably check that the result has settled
* after API requests. Without this approach, the hook might return `[false, false]` whilst
* API requests are still loading
*/
const RenderActionPermissions = ({ rule, action }: { rule: CombinedRule; action: AlertRuleAction }) => {
const result = useAllAlertRuleAbilities(rule);
const [isSupported, isAllowed] = result[action];
return (
<>
{isSupported && 'supported'}
{isAllowed && 'allowed'}
</>
);
};
describe('AlertRule abilities', () => {
const server = setupMswServer();
it('should report that all actions are supported for a Grafana Managed alert rule', async () => {
const rule = getGrafanaRule();
// TODO: Remove server mocking within test once server is run before all tests
mockFolderApi(server).folder(
(rule.rulerRule as RulerGrafanaRuleDTO).grafana_alert.namespace_uid,
mockFolder({
accessControl: { [AccessControlAction.AlertingRuleUpdate]: false },
})
);
mockAlertmanagerChoiceResponse(server, defaultGrafanaAlertingConfigurationStatusResponse);
const abilities = renderHook(() => useAllAlertRuleAbilities(rule), { wrapper: TestProvider });
await waitFor(() => {
@ -169,6 +176,28 @@ describe('AlertRule abilities', () => {
expect(abilities.result.current).toMatchSnapshot();
});
it('grants correct silence permissions for folder with silence create permission', async () => {
setFolderAccessControl({ [AccessControlAction.AlertingSilenceCreate]: true });
const rule = getGrafanaRule();
render(<RenderActionPermissions rule={rule} action={AlertRuleAction.Silence} />);
expect(await screen.findByText(/supported/)).toBeInTheDocument();
expect(await screen.findByText(/allowed/)).toBeInTheDocument();
});
it('does not grant silence permissions for folder without silence create permission', async () => {
setFolderAccessControl({ [AccessControlAction.AlertingSilenceCreate]: false });
const rule = getGrafanaRule();
render(<RenderActionPermissions rule={rule} action={AlertRuleAction.Silence} />);
expect(await screen.findByText(/supported/)).toBeInTheDocument();
expect(screen.queryByText(/allowed/)).not.toBeInTheDocument();
});
it('should report no permissions while we are loading data for cloud rule', async () => {
const rule = getCloudRule();

View File

@ -1,9 +1,10 @@
import { useMemo } from 'react';
import { contextSrv as ctx } from 'app/core/services/context_srv';
import { useFolder } from 'app/features/alerting/unified/hooks/useFolder';
import { AlertmanagerChoice } from 'app/plugins/datasource/alertmanager/types';
import { AccessControlAction } from 'app/types';
import { CombinedRule, RulesSource } from 'app/types/unified-alerting';
import { CombinedRule } from 'app/types/unified-alerting';
import { alertmanagerApi } from '../api/alertmanagerApi';
import { useAlertmanager } from '../state/AlertmanagerContext';
@ -170,7 +171,7 @@ export function useAllAlertRuleAbilities(rule: CombinedRule): Abilities<AlertRul
const duplicateSupported = isPluginProvided ? NotSupported : MaybeSupported;
const rulesPermissions = getRulesPermissions(rulesSourceName);
const canSilence = useCanSilence(rulesSource);
const canSilence = useCanSilence(rule);
const abilities: Abilities<AlertRuleAction> = {
[AlertRuleAction.Duplicate]: toAbility(duplicateSupported, rulesPermissions.create),
@ -272,7 +273,8 @@ export function useAlertmanagerAbilities(actions: AlertmanagerAction[]): Ability
* 1. the user has no permissions to create silences
* 2. the admin has configured to only send instances to external AMs
*/
function useCanSilence(rulesSource: RulesSource): [boolean, boolean] {
function useCanSilence(rule: CombinedRule): [boolean, boolean] {
const rulesSource = rule.namespace.rulesSource;
const isGrafanaManagedRule = rulesSource === GRAFANA_RULES_SOURCE_NAME;
const { currentData: amConfigStatus, isLoading } =
@ -280,9 +282,12 @@ function useCanSilence(rulesSource: RulesSource): [boolean, boolean] {
skip: !isGrafanaManagedRule,
});
const folderUID = isGrafanaRulerRule(rule.rulerRule) ? rule.rulerRule.grafana_alert.namespace_uid : undefined;
const { loading: folderIsLoading, folder } = useFolder(folderUID);
// we don't support silencing when the rule is not a Grafana managed rule
// we simply don't know what Alertmanager the ruler is sending alerts to
if (!isGrafanaManagedRule || isLoading) {
if (!isGrafanaManagedRule || isLoading || folderIsLoading || !folder) {
return [false, false];
}
@ -290,7 +295,16 @@ function useCanSilence(rulesSource: RulesSource): [boolean, boolean] {
const interactsWithAll = amConfigStatus?.alertmanagersChoice === AlertmanagerChoice.All;
const silenceSupported = !interactsOnlyWithExternalAMs || interactsWithAll;
return toAbility(silenceSupported, AccessControlAction.AlertingInstanceCreate);
const { accessControl = {} } = folder;
// User is permitted to silence if they either have the "global" permissions of "AlertingInstanceCreate",
// or the folder specific access control of "AlertingSilenceCreate"
const allowedToSilence = Boolean(
ctx.hasPermission(AccessControlAction.AlertingInstanceCreate) ||
accessControl[AccessControlAction.AlertingSilenceCreate]
);
return [silenceSupported, allowedToSilence];
}
// just a convenient function

View File

@ -10,6 +10,7 @@ import { DashboardDTO, FolderDTO, NotifierDTO, OrgUser } from 'app/types';
import {
PromBuildInfoResponse,
PromRulesResponse,
RulerGrafanaRuleDTO,
RulerRuleGroupDTO,
RulerRulesConfigDTO,
} from 'app/types/unified-alerting-dto';
@ -283,6 +284,9 @@ export function mockAlertRuleApi(server: SetupServer) {
http.get(`/api/ruler/${dsName}/api/v1/rules/${namespace}/${group}`, () => HttpResponse.json(response))
);
},
getAlertRule: (uid: string, response: RulerGrafanaRuleDTO) => {
server.use(http.get(`/api/ruler/grafana/api/v1/rule/${uid}`, () => HttpResponse.json(response)));
},
};
}

View File

@ -20,6 +20,7 @@ import {
import { DataSourceSrv, GetDataSourceListFilters, config } from '@grafana/runtime';
import { defaultDashboard } from '@grafana/schema';
import { contextSrv } from 'app/core/services/context_srv';
import { MOCK_GRAFANA_ALERT_RULE_TITLE } from 'app/features/alerting/unified/mocks/server/handlers/alertRules';
import { parseMatchers } from 'app/features/alerting/unified/utils/alertmanager';
import { DatasourceSrv } from 'app/features/plugins/datasource_srv';
import {
@ -307,20 +308,46 @@ export const mockSilence = (partial: Partial<Silence> = {}): Silence => {
status: {
state: SilenceState.Active,
},
accessControl: {
create: true,
read: true,
write: true,
},
...partial,
};
};
export const MOCK_SILENCE_ID_EXISTING = 'f209e273-0e4e-434f-9f66-e72f092025a2';
export const MOCK_SILENCE_ID_EXISTING_ALERT_RULE_UID = '5f7d08cd-ac62-432e-8449-8c20c95c19b6';
export const MOCK_SILENCE_ID_EXPIRED = '145884a8-ee20-4864-9f84-661305fb7d82';
export const MOCK_SILENCE_ID_LACKING_PERMISSIONS = '31063317-f0d2-4d98-baf3-ec9febc1fa83';
export const mockSilences = [
mockSilence({ id: MOCK_SILENCE_ID_EXISTING }),
mockSilence({ id: MOCK_SILENCE_ID_EXISTING, comment: 'Happy path silence' }),
mockSilence({
id: 'ce031625-61c7-47cd-9beb-8760bccf0ed7',
matchers: parseMatchers('foo!=bar'),
comment: 'Catch all',
comment: 'Silence with negated matcher',
}),
mockSilence({
id: MOCK_SILENCE_ID_EXISTING_ALERT_RULE_UID,
matchers: parseMatchers(`__alert_rule_uid__=${MOCK_SILENCE_ID_EXISTING_ALERT_RULE_UID}`),
comment: 'Silence with alert rule UID matcher',
metadata: {
rule_title: MOCK_GRAFANA_ALERT_RULE_TITLE,
},
}),
mockSilence({
id: MOCK_SILENCE_ID_LACKING_PERMISSIONS,
matchers: parseMatchers('something=else'),
comment: 'Silence without permissions to edit',
accessControl: {},
}),
mockSilence({
id: MOCK_SILENCE_ID_EXPIRED,
status: { state: SilenceState.Expired },
comment: 'Silence which is expired',
}),
mockSilence({ id: '145884a8-ee20-4864-9f84-661305fb7d82', status: { state: SilenceState.Expired } }),
];
export const mockNotifiersState = (partial: Partial<NotifiersState> = {}): NotifiersState => {

View File

@ -4,22 +4,45 @@ import { mockSilences } from 'app/features/alerting/unified/mocks';
import { MOCK_DATASOURCE_UID_BROKEN_ALERTMANAGER } from 'app/features/alerting/unified/mocks/server/handlers/datasources';
const silencesListHandler = (silences = mockSilences) =>
http.get<{ datasourceUid: string }>('/api/alertmanager/:datasourceUid/api/v2/silences', ({ params }) => {
http.get<{ datasourceUid: string }>('/api/alertmanager/:datasourceUid/api/v2/silences', ({ params, request }) => {
if (params.datasourceUid === MOCK_DATASOURCE_UID_BROKEN_ALERTMANAGER) {
return HttpResponse.json({ traceId: '' }, { status: 502 });
}
return HttpResponse.json(silences);
// Server only responds with ACL/rule metadata if query param is sent
const accessControlQueryParam = new URL(request.url).searchParams.get('accesscontrol');
const ruleMetadataQueryParam = new URL(request.url).searchParams.get('ruleMetadata');
const mappedSilences = silences.map(({ accessControl, metadata, ...silence }) => {
return {
...silence,
...(accessControlQueryParam && { accessControl }),
...(ruleMetadataQueryParam && { metadata }),
};
});
return HttpResponse.json(mappedSilences);
});
const silenceGetHandler = () =>
http.get<{ uuid: string }>('/api/alertmanager/:datasourceUid/api/v2/silence/:uuid', ({ params }) => {
http.get<{ uuid: string }>('/api/alertmanager/:datasourceUid/api/v2/silence/:uuid', ({ params, request }) => {
const { uuid } = params;
const matchingMockSilence = mockSilences.find((silence) => silence.id === uuid);
if (matchingMockSilence) {
return HttpResponse.json(matchingMockSilence);
if (!matchingMockSilence) {
return HttpResponse.json({ message: 'silence not found' }, { status: 404 });
}
return HttpResponse.json({ message: 'silence not found' }, { status: 404 });
// Server only responds with ACL/rule metadata if query param is sent
const accessControlQueryParam = new URL(request.url).searchParams.get('accesscontrol');
const ruleMetadataQueryParam = new URL(request.url).searchParams.get('ruleMetadata');
const { accessControl, metadata, ...silence } = matchingMockSilence;
return HttpResponse.json({
...silence,
...(accessControlQueryParam && { accessControl }),
...(ruleMetadataQueryParam && { metadata }),
});
});
export const silenceCreateHandler = () =>

View File

@ -1,5 +1,5 @@
//DOCS: https://prometheus.io/docs/alerting/latest/configuration/
import { DataSourceJsonData } from '@grafana/data';
import { DataSourceJsonData, WithAccessControlMetadata } from '@grafana/data';
export type AlertManagerCortexConfig = {
template_files: Record<string, string>;
@ -188,7 +188,7 @@ export enum MatcherOperator {
notRegex = '!~',
}
export type Silence = {
export interface Silence extends WithAccessControlMetadata {
id: string;
matchers?: Matcher[];
startsAt: string;
@ -199,7 +199,12 @@ export type Silence = {
status: {
state: SilenceState;
};
};
metadata?: {
rule_uid?: string;
rule_title?: string;
folder_uid?: string;
};
}
export type SilenceCreatePayload = {
id?: string;

View File

@ -101,6 +101,11 @@ export enum AccessControlAction {
AlertingInstanceUpdate = 'alert.instances:write',
AlertingInstanceRead = 'alert.instances:read',
// Alerting silences
AlertingSilenceCreate = 'alert.silences:create',
AlertingSilenceUpdate = 'alert.silences:write',
AlertingSilenceRead = 'alert.silences:read',
// Alerting Notification policies
AlertingNotificationsRead = 'alert.notifications:read',
AlertingNotificationsWrite = 'alert.notifications:write',

View File

@ -1669,6 +1669,21 @@
"empty-state": {
"button-title": "Create silence",
"title": "You haven't created any silences yet"
},
"table": {
"add-silence-button": "Add Silence",
"edit-button": "Edit",
"expired-silences": "Expired silences are automatically deleted after 5 days.",
"no-matching-silences": "No matching silences found;",
"noConfig": "Create a new contact point to create a configuration using the default values or contact your administrator to set up the Alertmanager.",
"recreate-button": "Recreate",
"unsilence-button": "Unsilence"
}
},
"silences-table": {
"header": {
"alert-name": "Alert name",
"state": "State"
}
},
"snapshot": {

View File

@ -1669,6 +1669,21 @@
"empty-state": {
"button-title": "Cřęäŧę şįľęʼnčę",
"title": "Ÿőū ĥävęʼn'ŧ čřęäŧęđ äʼny şįľęʼnčęş yęŧ"
},
"table": {
"add-silence-button": "Åđđ Ŝįľęʼnčę",
"edit-button": "Ēđįŧ",
"expired-silences": "Ēχpįřęđ şįľęʼnčęş äřę äūŧőmäŧįčäľľy đęľęŧęđ äƒŧęř 5 đäyş.",
"no-matching-silences": "Ńő mäŧčĥįʼnģ şįľęʼnčęş ƒőūʼnđ;",
"noConfig": "Cřęäŧę ä ʼnęŵ čőʼnŧäčŧ pőįʼnŧ ŧő čřęäŧę ä čőʼnƒįģūřäŧįőʼn ūşįʼnģ ŧĥę đęƒäūľŧ väľūęş őř čőʼnŧäčŧ yőūř äđmįʼnįşŧřäŧőř ŧő şęŧ ūp ŧĥę Åľęřŧmäʼnäģęř.",
"recreate-button": "Ŗęčřęäŧę",
"unsilence-button": "Ůʼnşįľęʼnčę"
}
},
"silences-table": {
"header": {
"alert-name": "Åľęřŧ ʼnämę",
"state": "Ŝŧäŧę"
}
},
"snapshot": {