diff --git a/public/app/features/alerting/unified/RuleViewer.test.tsx b/public/app/features/alerting/unified/RuleViewer.test.tsx index d5d4d328cfb..a131e4a7d26 100644 --- a/public/app/features/alerting/unified/RuleViewer.test.tsx +++ b/public/app/features/alerting/unified/RuleViewer.test.tsx @@ -2,17 +2,33 @@ import { act, render, screen } from '@testing-library/react'; import React from 'react'; import { Provider } from 'react-redux'; import { Router } from 'react-router-dom'; +import { byRole } from 'testing-library-selector'; -import { DataSourceJsonData, PluginMeta } from '@grafana/data'; import { locationService } from '@grafana/runtime'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; +import { contextSrv } from 'app/core/services/context_srv'; import { configureStore } from 'app/store/configureStore'; +import { AccessControlAction } from 'app/types'; import { CombinedRule } from 'app/types/unified-alerting'; -import { GrafanaAlertStateDecision } from 'app/types/unified-alerting-dto'; import { RuleViewer } from './RuleViewer'; import { useCombinedRule } from './hooks/useCombinedRule'; -import { GRAFANA_RULES_SOURCE_NAME } from './utils/datasource'; +import { useIsRuleEditable } from './hooks/useIsRuleEditable'; +import { getCloudRule, getGrafanaRule } from './mocks'; + +const mockGrafanaRule = getGrafanaRule({ name: 'Test alert' }); +const mockCloudRule = getCloudRule({ name: 'cloud test alert' }); +const mockRoute: GrafanaRouteComponentProps<{ id?: string; sourceName?: string }> = { + route: { + path: '/', + component: RuleViewer, + }, + queryParams: { returnTo: '/alerting/list' }, + match: { params: { id: 'test1', sourceName: 'grafana' }, isExact: false, url: 'asdf', path: '' }, + history: locationService.getHistory(), + location: { pathname: '', hash: '', search: '', state: '' }, + staticContext: {}, +}; jest.mock('./hooks/useCombinedRule'); jest.mock('@grafana/runtime', () => ({ @@ -40,6 +56,20 @@ const renderRuleViewer = () => { ); }); }; + +const ui = { + actionButtons: { + edit: byRole('link', { name: /edit/i }), + delete: byRole('button', { name: /delete/i }), + silence: byRole('link', { name: 'Silence' }), + }, +}; +jest.mock('./hooks/useIsRuleEditable'); + +const mocks = { + useIsRuleEditable: jest.mocked(useIsRuleEditable), +}; + describe('RuleViewer', () => { let mockCombinedRule: jest.MockedFn; @@ -59,6 +89,7 @@ describe('RuleViewer', () => { requestId: 'A', error: undefined, }); + mocks.useIsRuleEditable.mockReturnValue({ loading: false, isEditable: false }); await renderRuleViewer(); expect(screen.getByText(/view rule/i)).toBeInTheDocument(); @@ -73,82 +104,142 @@ describe('RuleViewer', () => { requestId: 'A', error: undefined, }); + mocks.useIsRuleEditable.mockReturnValue({ loading: false, isEditable: false }); await renderRuleViewer(); expect(screen.getByText(/view rule/i)).toBeInTheDocument(); expect(screen.getByText(/cloud test alert/i)).toBeInTheDocument(); }); }); -const mockGrafanaRule = { - name: 'Test alert', - query: 'up', - labels: {}, - annotations: {}, - group: { - name: 'Prom up alert', - rules: [], - }, - namespace: { - rulesSource: GRAFANA_RULES_SOURCE_NAME, - name: 'Alerts', - groups: [], - }, - rulerRule: { - for: '', - annotations: {}, - labels: {}, - grafana_alert: { - condition: 'B', - exec_err_state: GrafanaAlertStateDecision.Alerting, - namespace_id: 11, - namespace_uid: 'namespaceuid123', - no_data_state: GrafanaAlertStateDecision.NoData, - title: 'Test alert', - uid: 'asdf23', - data: [], - }, - }, -}; +describe('RuleDetails RBAC', () => { + describe('Grafana rules action buttons in details', () => { + let mockCombinedRule: jest.MockedFn; -const mockCloudRule = { - name: 'Cloud test alert', - labels: {}, - query: 'up == 0', - annotations: {}, - group: { - name: 'test', - rules: [], - }, - promRule: { - health: 'ok', - name: 'cloud up alert', - query: 'up == 0', - type: 'alerting', - }, - namespace: { - name: 'prom test alerts', - groups: [], - rulesSource: { - name: 'prom test', - type: 'prometheus', - uid: 'asdf23', - id: 1, - meta: {} as PluginMeta, - jsonData: {} as DataSourceJsonData, - access: 'proxy', - readOnly: false, - }, - }, -}; + beforeEach(() => { + mockCombinedRule = jest.mocked(useCombinedRule); + }); -const mockRoute: GrafanaRouteComponentProps<{ id?: string; sourceName?: string }> = { - route: { - path: '/', - component: RuleViewer, - }, - queryParams: { returnTo: '/alerting/list' }, - match: { params: { id: 'test1', sourceName: 'grafana' }, isExact: false, url: 'asdf', path: '' }, - history: locationService.getHistory(), - location: { pathname: '', hash: '', search: '', state: '' }, - staticContext: {}, -}; + afterEach(() => { + mockCombinedRule.mockReset(); + }); + it('Should render Edit button for users with the update permission', async () => { + // Arrange + mocks.useIsRuleEditable.mockReturnValue({ loading: false, isEditable: true }); + mockCombinedRule.mockReturnValue({ + result: mockGrafanaRule as CombinedRule, + loading: false, + dispatched: true, + requestId: 'A', + error: undefined, + }); + + // Act + await renderRuleViewer(); + + // Assert + expect(ui.actionButtons.edit.get()).toBeInTheDocument(); + }); + + it('Should render Delete button for users with the delete permission', async () => { + // Arrange + mockCombinedRule.mockReturnValue({ + result: mockGrafanaRule as CombinedRule, + loading: false, + dispatched: true, + requestId: 'A', + error: undefined, + }); + mocks.useIsRuleEditable.mockReturnValue({ loading: false, isRemovable: true }); + + // Act + await renderRuleViewer(); + + // Assert + expect(ui.actionButtons.delete.get()).toBeInTheDocument(); + }); + + it('Should not render Silence button for users wihout the instance create permission', async () => { + // Arrange + mockCombinedRule.mockReturnValue({ + result: mockGrafanaRule as CombinedRule, + loading: false, + dispatched: true, + requestId: 'A', + error: undefined, + }); + jest.spyOn(contextSrv, 'hasPermission').mockReturnValue(false); + + // Act + await renderRuleViewer(); + + // Assert + expect(ui.actionButtons.silence.query()).not.toBeInTheDocument(); + }); + + it('Should render Silence button for users with the instance create permissions', async () => { + // Arrange + mockCombinedRule.mockReturnValue({ + result: mockGrafanaRule as CombinedRule, + loading: false, + dispatched: true, + requestId: 'A', + error: undefined, + }); + jest + .spyOn(contextSrv, 'hasPermission') + .mockImplementation((action) => action === AccessControlAction.AlertingInstanceCreate); + + // Act + await renderRuleViewer(); + + // Assert + expect(ui.actionButtons.silence.query()).toBeInTheDocument(); + }); + }); + describe('Cloud rules action buttons', () => { + let mockCombinedRule: jest.MockedFn; + + beforeEach(() => { + mockCombinedRule = jest.mocked(useCombinedRule); + }); + + afterEach(() => { + mockCombinedRule.mockReset(); + }); + it('Should render edit button for users with the update permission', async () => { + // Arrange + mocks.useIsRuleEditable.mockReturnValue({ loading: false, isEditable: true }); + mockCombinedRule.mockReturnValue({ + result: mockCloudRule as CombinedRule, + loading: false, + dispatched: true, + requestId: 'A', + error: undefined, + }); + + // Act + await renderRuleViewer(); + + // Assert + expect(ui.actionButtons.edit.query()).toBeInTheDocument(); + }); + + it('Should render Delete button for users with the delete permission', async () => { + // Arrange + mockCombinedRule.mockReturnValue({ + result: mockCloudRule as CombinedRule, + loading: false, + dispatched: true, + requestId: 'A', + error: undefined, + }); + mocks.useIsRuleEditable.mockReturnValue({ loading: false, isRemovable: true }); + + // Act + await renderRuleViewer(); + + // Assert + expect(ui.actionButtons.delete.query()).toBeInTheDocument(); + }); + }); +}); diff --git a/public/app/features/alerting/unified/RuleViewer.tsx b/public/app/features/alerting/unified/RuleViewer.tsx index 800f1db9a45..3b493ed5f45 100644 --- a/public/app/features/alerting/unified/RuleViewer.tsx +++ b/public/app/features/alerting/unified/RuleViewer.tsx @@ -170,7 +170,7 @@ export function RuleViewer({ match }: RuleViewerProps) { {rule.name} - +
diff --git a/public/app/features/alerting/unified/components/rules/RuleDetails.test.tsx b/public/app/features/alerting/unified/components/rules/RuleDetails.test.tsx index f0267f90617..6bdc835d8ea 100644 --- a/public/app/features/alerting/unified/components/rules/RuleDetails.test.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleDetails.test.tsx @@ -9,24 +9,57 @@ import { configureStore } from 'app/store/configureStore'; import { AccessControlAction } from 'app/types'; import { CombinedRule } from 'app/types/unified-alerting'; -import { mockCombinedRule } from '../../mocks'; +import { useIsRuleEditable } from '../../hooks/useIsRuleEditable'; +import { getCloudRule, getGrafanaRule } from '../../mocks'; import { RuleDetails } from './RuleDetails'; +jest.mock('../../hooks/useIsRuleEditable'); + +const mocks = { + useIsRuleEditable: jest.mocked(useIsRuleEditable), +}; + const ui = { actionButtons: { - edit: byRole('link', { name: 'Edit' }), - delete: byRole('button', { name: 'Delete' }), + edit: byRole('link', { name: /edit/i }), + delete: byRole('button', { name: /delete/i }), silence: byRole('link', { name: 'Silence' }), }, }; jest.spyOn(contextSrv, 'accessControlEnabled').mockReturnValue(true); +beforeEach(() => { + jest.clearAllMocks(); +}); + describe('RuleDetails RBAC', () => { describe('Grafana rules action buttons in details', () => { const grafanaRule = getGrafanaRule({ name: 'Grafana' }); + it('Should not render Edit button for users with the update permission', () => { + // Arrange + mocks.useIsRuleEditable.mockReturnValue({ loading: false, isEditable: true }); + + // Act + renderRuleDetails(grafanaRule); + + // Assert + expect(ui.actionButtons.edit.query()).not.toBeInTheDocument(); + }); + + it('Should not render Delete button for users with the delete permission', () => { + // Arrange + mocks.useIsRuleEditable.mockReturnValue({ loading: false, isRemovable: true }); + + // Act + renderRuleDetails(grafanaRule); + + // Assert + expect(ui.actionButtons.delete.query()).not.toBeInTheDocument(); + }); + it('Should not render Silence button for users wihout the instance create permission', () => { // Arrange jest.spyOn(contextSrv, 'hasPermission').mockReturnValue(false); @@ -51,6 +84,31 @@ describe('RuleDetails RBAC', () => { expect(ui.actionButtons.silence.query()).toBeInTheDocument(); }); }); + describe('Cloud rules action buttons', () => { + const cloudRule = getCloudRule({ name: 'Cloud' }); + + it('Should not render Edit button for users with the update permission', () => { + // Arrange + mocks.useIsRuleEditable.mockReturnValue({ loading: false, isEditable: true }); + + // Act + renderRuleDetails(cloudRule); + + // Assert + expect(ui.actionButtons.edit.query()).not.toBeInTheDocument(); + }); + + it('Should not render Delete button for users with the delete permission', () => { + // Arrange + mocks.useIsRuleEditable.mockReturnValue({ loading: false, isRemovable: true }); + + // Act + renderRuleDetails(cloudRule); + + // Assert + expect(ui.actionButtons.delete.query()).not.toBeInTheDocument(); + }); + }); }); function renderRuleDetails(rule: CombinedRule) { @@ -64,14 +122,3 @@ function renderRuleDetails(rule: CombinedRule) { ); } - -function getGrafanaRule(override?: Partial) { - return mockCombinedRule({ - namespace: { - groups: [], - name: 'Grafana', - rulesSource: 'grafana', - }, - ...override, - }); -} diff --git a/public/app/features/alerting/unified/components/rules/RuleDetails.tsx b/public/app/features/alerting/unified/components/rules/RuleDetails.tsx index 6c102205073..5d078a0f6a6 100644 --- a/public/app/features/alerting/unified/components/rules/RuleDetails.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleDetails.tsx @@ -34,7 +34,7 @@ export const RuleDetails: FC = ({ rule }) => { return (
- +
{} diff --git a/public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx b/public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx index cd66f5547fd..9913bbbbbba 100644 --- a/public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx @@ -1,29 +1,41 @@ import { css } from '@emotion/css'; -import React, { FC, Fragment } from 'react'; +import React, { FC, Fragment, useState } from 'react'; +import { useLocation } from 'react-router-dom'; -import { GrafanaTheme2, textUtil } from '@grafana/data'; -import { Button, HorizontalGroup, LinkButton, useStyles2 } from '@grafana/ui'; +import { GrafanaTheme2, textUtil, urlUtil } from '@grafana/data'; +import { config } from '@grafana/runtime'; +import { Button, ClipboardButton, ConfirmModal, HorizontalGroup, LinkButton, useStyles2 } from '@grafana/ui'; +import { useAppNotification } from 'app/core/copy/appNotification'; import { contextSrv } from 'app/core/services/context_srv'; -import { AccessControlAction } from 'app/types'; +import { AccessControlAction, useDispatch } from 'app/types'; import { CombinedRule, RulesSource } from 'app/types/unified-alerting'; +import { useIsRuleEditable } from '../../hooks/useIsRuleEditable'; import { useStateHistoryModal } from '../../hooks/useStateHistoryModal'; +import { deleteRuleAction } from '../../state/actions'; import { getAlertmanagerByUid } from '../../utils/alertmanager'; import { Annotation } from '../../utils/constants'; -import { isCloudRulesSource, isGrafanaRulesSource } from '../../utils/datasource'; +import { getRulesSourceName, isCloudRulesSource, isGrafanaRulesSource } from '../../utils/datasource'; import { createExploreLink, makeRuleBasedSilenceLink } from '../../utils/misc'; +import * as ruleId from '../../utils/rule-id'; import { isFederatedRuleGroup, isGrafanaRulerRule } from '../../utils/rules'; interface Props { rule: CombinedRule; rulesSource: RulesSource; + isViewMode: boolean; } -export const RuleDetailsActionButtons: FC = ({ rule, rulesSource }) => { +export const RuleDetailsActionButtons: FC = ({ rule, rulesSource, isViewMode }) => { const style = useStyles2(getStyles); - const { group } = rule; + const { namespace, group, rulerRule } = rule; const alertId = isGrafanaRulerRule(rule.rulerRule) ? rule.rulerRule.grafana_alert.id ?? '' : ''; const { StateHistoryModal, showStateHistoryModal } = useStateHistoryModal(alertId); + const dispatch = useDispatch(); + const location = useLocation(); + const notifyApp = useAppNotification(); + + const [ruleToDelete, setRuleToDelete] = useState(); const alertmanagerSourceName = isGrafanaRulesSource(rulesSource) ? rulesSource @@ -32,9 +44,39 @@ export const RuleDetailsActionButtons: FC = ({ rule, rulesSource }) => { const hasExplorePermission = contextSrv.hasPermission(AccessControlAction.DataSourcesExplore); const buttons: JSX.Element[] = []; + const rightButtons: JSX.Element[] = []; + + const deleteRule = () => { + if (ruleToDelete && ruleToDelete.rulerRule) { + const identifier = ruleId.fromRulerRule( + getRulesSourceName(ruleToDelete.namespace.rulesSource), + ruleToDelete.namespace.name, + ruleToDelete.group.name, + ruleToDelete.rulerRule + ); + + dispatch(deleteRuleAction(identifier, { navigateTo: isViewMode ? '/alerting/list' : undefined })); + setRuleToDelete(undefined); + } + }; + const buildShareUrl = () => { + if (isCloudRulesSource(rulesSource)) { + const { appUrl, appSubUrl } = config; + const baseUrl = appSubUrl !== '' ? `${appUrl}${appSubUrl}/` : config.appUrl; + const ruleUrl = `${encodeURIComponent(rulesSource.name)}/${encodeURIComponent(rule.name)}`; + return `${baseUrl}alerting/${ruleUrl}/find`; + } + + return window.location.href.split('?')[0]; + }; const isFederated = isFederatedRuleGroup(group); + const rulesSourceName = getRulesSourceName(rulesSource); + const isProvisioned = isGrafanaRulerRule(rule.rulerRule) && Boolean(rule.rulerRule.grafana_alert.provenance); + const { isEditable, isRemovable } = useIsRuleEditable(rulesSourceName, rulerRule); + + const returnTo = location.pathname + location.search; // explore does not support grafana rule queries atm // neither do "federated rules" if (isCloudRulesSource(rulesSource) && hasExplorePermission && !isFederated) { @@ -128,14 +170,77 @@ export const RuleDetailsActionButtons: FC = ({ rule, rulesSource }) => { ); } - if (buttons.length) { + if (isViewMode) { + if (isEditable && rulerRule && !isFederated && !isProvisioned) { + const sourceName = getRulesSourceName(rulesSource); + const identifier = ruleId.fromRulerRule(sourceName, namespace.name, group.name, rulerRule); + + const editURL = urlUtil.renderUrl( + `${config.appSubUrl}/alerting/${encodeURIComponent(ruleId.stringifyIdentifier(identifier))}/edit`, + { + returnTo, + } + ); + rightButtons.push( + { + notifyApp.error('Error while copying URL', copiedText); + }} + className={style.button} + size="sm" + getText={buildShareUrl} + > + Copy link to rule + + ); + + rightButtons.push( + + Edit + + ); + } + + if (isRemovable && rulerRule && !isFederated && !isProvisioned) { + rightButtons.push( + + ); + } + } + + if (buttons.length || rightButtons.length) { return ( -
- {buttons.length ? buttons :
} -
+ <> +
+ {buttons.length ? buttons :
} + {rightButtons.length ? rightButtons :
} +
+ {!!ruleToDelete && ( + setRuleToDelete(undefined)} + /> + )} + ); } - return null; }; @@ -150,7 +255,6 @@ export const getStyles = (theme: GrafanaTheme2) => ({ `, button: css` height: 24px; - margin-top: ${theme.spacing(1)}; font-size: ${theme.typography.size.sm}; `, }); diff --git a/public/app/features/alerting/unified/components/rules/RulesTable.test.tsx b/public/app/features/alerting/unified/components/rules/RulesTable.test.tsx index e53ad1f831c..c11484efd18 100644 --- a/public/app/features/alerting/unified/components/rules/RulesTable.test.tsx +++ b/public/app/features/alerting/unified/components/rules/RulesTable.test.tsx @@ -9,7 +9,7 @@ import { configureStore } from 'app/store/configureStore'; import { CombinedRule } from 'app/types/unified-alerting'; import { useIsRuleEditable } from '../../hooks/useIsRuleEditable'; -import { mockCombinedRule, mockDataSource, mockPromAlertingRule, mockRulerAlertingRule } from '../../mocks'; +import { getCloudRule, getGrafanaRule } from '../../mocks'; import { RulesTable } from './RulesTable'; @@ -41,30 +41,6 @@ function renderRulesTable(rule: CombinedRule) { ); } -function getGrafanaRule(override?: Partial) { - return mockCombinedRule({ - namespace: { - groups: [], - name: 'Grafana', - rulesSource: 'grafana', - }, - ...override, - }); -} - -function getCloudRule(override?: Partial) { - return mockCombinedRule({ - namespace: { - groups: [], - name: 'Cortex', - rulesSource: mockDataSource(), - }, - promRule: mockPromAlertingRule(), - rulerRule: mockRulerAlertingRule(), - ...override, - }); -} - describe('RulesTable RBAC', () => { describe('Grafana rules action buttons', () => { const grafanaRule = getGrafanaRule({ name: 'Grafana' }); diff --git a/public/app/features/alerting/unified/mocks.ts b/public/app/features/alerting/unified/mocks.ts index 89b8742000c..e0f524eb91e 100644 --- a/public/app/features/alerting/unified/mocks.ts +++ b/public/app/features/alerting/unified/mocks.ts @@ -516,3 +516,26 @@ export function mockStore(recipe: (state: StoreState) => void) { return configureStore(produce(defaultState, recipe)); } + +export function getGrafanaRule(override?: Partial) { + return mockCombinedRule({ + namespace: { + groups: [], + name: 'Grafana', + rulesSource: 'grafana', + }, + ...override, + }); +} +export function getCloudRule(override?: Partial) { + return mockCombinedRule({ + namespace: { + groups: [], + name: 'Cortex', + rulesSource: mockDataSource(), + }, + promRule: mockPromAlertingRule(), + rulerRule: mockRulerAlertingRule(), + ...override, + }); +}