Alerting: Fix missing edit/delete/copy link in alert view (#60874)

Fix missing edit/delete/copy link in alert view
pull/60618/head
Sonia Aguilar 3 years ago committed by GitHub
parent 44afad2ce4
commit f22286df3f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 239
      public/app/features/alerting/unified/RuleViewer.test.tsx
  2. 2
      public/app/features/alerting/unified/RuleViewer.tsx
  3. 75
      public/app/features/alerting/unified/components/rules/RuleDetails.test.tsx
  4. 2
      public/app/features/alerting/unified/components/rules/RuleDetails.tsx
  5. 124
      public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx
  6. 26
      public/app/features/alerting/unified/components/rules/RulesTable.test.tsx
  7. 23
      public/app/features/alerting/unified/mocks.ts

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

@ -170,7 +170,7 @@ export function RuleViewer({ match }: RuleViewerProps) {
<Icon name="bell" size="lg" /> {rule.name}
</h4>
<RuleState rule={rule} isCreating={false} isDeleting={false} />
<RuleDetailsActionButtons rule={rule} rulesSource={rulesSource} />
<RuleDetailsActionButtons rule={rule} rulesSource={rulesSource} isViewMode={true} />
</div>
<div className={styles.details}>
<div className={styles.leftSide}>

@ -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) {
</Provider>
);
}
function getGrafanaRule(override?: Partial<CombinedRule>) {
return mockCombinedRule({
namespace: {
groups: [],
name: 'Grafana',
rulesSource: 'grafana',
},
...override,
});
}

@ -34,7 +34,7 @@ export const RuleDetails: FC<Props> = ({ rule }) => {
return (
<div>
<RuleDetailsActionButtons rule={rule} rulesSource={rulesSource} />
<RuleDetailsActionButtons rule={rule} rulesSource={rulesSource} isViewMode={false} />
<div className={styles.wrapper}>
<div className={styles.leftSide}>
{<EvaluationBehaviorSummary rule={rule} />}

@ -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<Props> = ({ rule, rulesSource }) => {
export const RuleDetailsActionButtons: FC<Props> = ({ 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<CombinedRule>();
const alertmanagerSourceName = isGrafanaRulesSource(rulesSource)
? rulesSource
@ -32,9 +44,39 @@ export const RuleDetailsActionButtons: FC<Props> = ({ 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<Props> = ({ 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(
<ClipboardButton
key="copy"
icon="copy"
onClipboardError={(copiedText) => {
notifyApp.error('Error while copying URL', copiedText);
}}
className={style.button}
size="sm"
getText={buildShareUrl}
>
Copy link to rule
</ClipboardButton>
);
rightButtons.push(
<LinkButton className={style.button} size="xs" key="edit" variant="secondary" icon="pen" href={editURL}>
Edit
</LinkButton>
);
}
if (isRemovable && rulerRule && !isFederated && !isProvisioned) {
rightButtons.push(
<Button
className={style.button}
size="xs"
type="button"
key="delete"
variant="secondary"
icon="trash-alt"
onClick={() => setRuleToDelete(rule)}
>
Delete
</Button>
);
}
}
if (buttons.length || rightButtons.length) {
return (
<>
<div className={style.wrapper}>
<HorizontalGroup width="auto">{buttons.length ? buttons : <div />}</HorizontalGroup>
<HorizontalGroup width="auto">{rightButtons.length ? rightButtons : <div />}</HorizontalGroup>
</div>
{!!ruleToDelete && (
<ConfirmModal
isOpen={true}
title="Delete rule"
body="Deleting this rule will permanently remove it from your alert rule list. Are you sure you want to delete this rule?"
confirmText="Yes, delete"
icon="exclamation-triangle"
onConfirm={deleteRule}
onDismiss={() => 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};
`,
});

@ -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<CombinedRule>) {
return mockCombinedRule({
namespace: {
groups: [],
name: 'Grafana',
rulesSource: 'grafana',
},
...override,
});
}
function getCloudRule(override?: Partial<CombinedRule>) {
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' });

@ -516,3 +516,26 @@ export function mockStore(recipe: (state: StoreState) => void) {
return configureStore(produce(defaultState, recipe));
}
export function getGrafanaRule(override?: Partial<CombinedRule>) {
return mockCombinedRule({
namespace: {
groups: [],
name: 'Grafana',
rulesSource: 'grafana',
},
...override,
});
}
export function getCloudRule(override?: Partial<CombinedRule>) {
return mockCombinedRule({
namespace: {
groups: [],
name: 'Cortex',
rulesSource: mockDataSource(),
},
promRule: mockPromAlertingRule(),
rulerRule: mockRulerAlertingRule(),
...override,
});
}

Loading…
Cancel
Save