Alerting: unify alert rule actions between list and detail view (#86071)

* Add mock method for getting a plugin

* Update tests to find "more" button via label

* Remove test for Silence action in rule details

* Unify alert rule actions to pull from same place

* Restore behaviour of only showing incident button when firing

* Fix identifier and pause permission/logic

* Remove TODO comment related to refactor

* Update snapshot for useAbilities

* Undo optional param

* Rename alert rule menu hook to component

* Refactor hook to component

* Rename Rule action buttons component

* Chore: update style syntax for RuleDetails

* Add tests for refactored alert rule menu

* Only re-fetch Grafana managed alerts after pausing/resuming

* Remove console log and check for extensions

* Improve share rule generation of GMA rules

* Rename component

* Update action

* Refactor plugins and fix tests

* lint

---------

Co-authored-by: Konrad Lalik <konrad.lalik@grafana.com>
Co-authored-by: Gilles De Mey <gilles.de.mey@gmail.com>
This commit is contained in:
Tom Ratcliffe
2024-04-30 16:17:55 +01:00
committed by GitHub
co-authored by Konrad Lalik Gilles De Mey
parent 93519f70ca
commit b0f6913ef6
24 changed files with 608 additions and 729 deletions
-12
View File
@@ -2011,22 +2011,10 @@ exports[`better eslint`] = {
[0, 0, 0, "Styles should be written using objects.", "5"],
[0, 0, 0, "Styles should be written using objects.", "6"]
],
"public/app/features/alerting/unified/components/rules/RuleActionsButtons.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"]
],
"public/app/features/alerting/unified/components/rules/RuleConfigStatus.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"],
[0, 0, 0, "Styles should be written using objects.", "1"]
],
"public/app/features/alerting/unified/components/rules/RuleDetails.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"],
[0, 0, 0, "Styles should be written using objects.", "1"],
[0, 0, 0, "Styles should be written using objects.", "2"]
],
"public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx:5381": [
[0, 0, 0, "\'HorizontalGroup\' import from \'@grafana/ui\' is restricted from being used by a pattern. Use Stack component instead.", "0"],
[0, 0, 0, "Styles should be written using objects.", "1"]
],
"public/app/features/alerting/unified/components/rules/RuleDetailsAnnotations.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"]
],
@@ -155,7 +155,7 @@ const ui = {
paused: byText(/^Paused/),
},
actionButtons: {
more: byRole('button', { name: 'More' }),
more: byRole('button', { name: /more-actions/ }),
},
moreActionItems: {
pause: byRole('menuitem', { name: /pause evaluation/i }),
@@ -17,7 +17,6 @@ If the item needs more rationale and you feel like a single sentence is inedequa
## Refactoring
- Get rid of "+ Add new" in drop-downs : Let's see if is there a way we can make it work with `<Select allowCustomValue />`
- There is a lot of overlap between `RuleActionButtons` and `RuleDetailsActionButtons`. As these components contain a lot of logic it would be nice to extract that logic into hooks
- Create a shared timings form that can be used in both `EditDefaultPolicyForm.tsx` and `EditNotificationPolicyForm.tsx`
## Testing
@@ -1,131 +0,0 @@
import React from 'react';
import { AppEvents } from '@grafana/data';
import { Dropdown, LinkButton, Menu } from '@grafana/ui';
import appEvents from 'app/core/app_events';
import MenuItemPauseRule from 'app/features/alerting/unified/components/MenuItemPauseRule';
import { CombinedRule, RuleIdentifier } from 'app/types/unified-alerting';
import { AlertRuleAction, useAlertRuleAbility } from '../../hooks/useAbilities';
import { useRulePluginLinkExtension } from '../../plugins/useRulePluginLinkExtensions';
import { createShareLink, isLocalDevEnv, isOpenSourceEdition, makeRuleBasedSilenceLink } from '../../utils/misc';
import * as ruleId from '../../utils/rule-id';
import { createUrl } from '../../utils/url';
import MoreButton from '../MoreButton';
import { DeclareIncidentMenuItem } from '../bridges/DeclareIncidentButton';
import { useAlertRule } from './RuleContext';
interface Props {
handleDelete: (rule: CombinedRule) => void;
handleDuplicateRule: (identifier: RuleIdentifier) => void;
}
export const useAlertRulePageActions = ({ handleDelete, handleDuplicateRule }: Props) => {
const { rule, identifier } = useAlertRule();
const rulePluginLinkExtension = useRulePluginLinkExtension(rule);
// check all abilities and permissions
const [editSupported, editAllowed] = useAlertRuleAbility(rule, AlertRuleAction.Update);
const canEdit = editSupported && editAllowed;
const [deleteSupported, deleteAllowed] = useAlertRuleAbility(rule, AlertRuleAction.Delete);
const canDelete = deleteSupported && deleteAllowed;
const [duplicateSupported, duplicateAllowed] = useAlertRuleAbility(rule, AlertRuleAction.Duplicate);
const canDuplicate = duplicateSupported && duplicateAllowed;
const [silenceSupported, silenceAllowed] = useAlertRuleAbility(rule, AlertRuleAction.Silence);
const canSilence = silenceSupported && silenceAllowed;
const [exportSupported, exportAllowed] = useAlertRuleAbility(rule, AlertRuleAction.ModifyExport);
const canExport = exportSupported && exportAllowed;
/**
* Since Incident isn't available as an open-source product we shouldn't show it for Open-Source licenced editions of Grafana.
* We should show it in development mode
*/
const shouldShowDeclareIncidentButton = !isOpenSourceEdition() || isLocalDevEnv();
const shareUrl = createShareLink(rule.namespace.rulesSource, rule);
return [
canEdit && <EditButton key="edit-action" identifier={identifier} />,
<Dropdown
key="more-actions"
overlay={
<Menu>
{canEdit && <MenuItemPauseRule rule={rule} />}
{canSilence && (
<Menu.Item
label="Silence notifications"
icon="bell-slash"
url={makeRuleBasedSilenceLink(identifier.ruleSourceName, rule)}
/>
)}
{shouldShowDeclareIncidentButton && <DeclareIncidentMenuItem title={rule.name} url={''} />}
{canDuplicate && <Menu.Item label="Duplicate" icon="copy" onClick={() => handleDuplicateRule(identifier)} />}
<Menu.Divider />
<Menu.Item label="Copy link" icon="share-alt" onClick={() => copyToClipboard(shareUrl)} />
{canExport && (
<Menu.Item
label="Export"
icon="download-alt"
childItems={[<ExportMenuItem key="export-with-modifications" identifier={identifier} />]}
/>
)}
{rulePluginLinkExtension.length > 0 && (
<>
<Menu.Divider />
{rulePluginLinkExtension.map((extension) => (
<Menu.Item
key={extension.id}
label={extension.title}
icon={extension.icon}
onClick={extension.onClick}
url={extension.path}
/>
))}
</>
)}
{canDelete && (
<>
<Menu.Divider />
<Menu.Item label="Delete" icon="trash-alt" destructive onClick={() => handleDelete(rule)} />
</>
)}
</Menu>
}
>
<MoreButton size="md" />
</Dropdown>,
];
};
function copyToClipboard(text: string) {
navigator.clipboard?.writeText(text).then(() => {
appEvents.emit(AppEvents.alertSuccess, ['URL copied to clipboard']);
});
}
type PropsWithIdentifier = { identifier: RuleIdentifier };
const ExportMenuItem = ({ identifier }: PropsWithIdentifier) => {
const returnTo = location.pathname + location.search;
const url = createUrl(`/alerting/${encodeURIComponent(ruleId.stringifyIdentifier(identifier))}/modify-export`, {
returnTo,
});
return <Menu.Item key="with-modifications" label="With modifications" icon="file-edit-alt" url={url} />;
};
const EditButton = ({ identifier }: PropsWithIdentifier) => {
const returnTo = location.pathname + location.search;
const ruleIdentifier = ruleId.stringifyIdentifier(identifier);
const editURL = createUrl(`/alerting/${encodeURIComponent(ruleIdentifier)}/edit`, { returnTo });
return (
<LinkButton variant="secondary" icon="pen" href={editURL}>
Edit
</LinkButton>
);
};
@@ -0,0 +1,139 @@
import React from 'react';
import { AppEvents } from '@grafana/data';
import { ComponentSize, Dropdown, Menu } from '@grafana/ui';
import appEvents from 'app/core/app_events';
import MenuItemPauseRule from 'app/features/alerting/unified/components/MenuItemPauseRule';
import MoreButton from 'app/features/alerting/unified/components/MoreButton';
import { useRulePluginLinkExtension } from 'app/features/alerting/unified/plugins/useRulePluginLinkExtensions';
import { isAlertingRule } from 'app/features/alerting/unified/utils/rules';
import { CombinedRule, RuleIdentifier } from 'app/types/unified-alerting';
import { PromAlertingRuleState } from 'app/types/unified-alerting-dto';
import { AlertRuleAction, useAlertRuleAbility } from '../../hooks/useAbilities';
import { createShareLink, isLocalDevEnv, isOpenSourceEdition, makeRuleBasedSilenceLink } from '../../utils/misc';
import * as ruleId from '../../utils/rule-id';
import { createUrl } from '../../utils/url';
import { DeclareIncidentMenuItem } from '../bridges/DeclareIncidentButton';
interface Props {
rule: CombinedRule;
identifier: RuleIdentifier;
showCopyLinkButton?: boolean;
handleDelete: (rule: CombinedRule) => void;
handleDuplicateRule: (identifier: RuleIdentifier) => void;
onPauseChange?: () => void;
buttonSize?: ComponentSize;
hideLabels?: boolean;
}
/**
* Get a list of menu items + divider elements for rendering in an alert rule's
* dropdown menu
*/
const AlertRuleMenu = ({
rule,
identifier,
showCopyLinkButton,
handleDelete,
handleDuplicateRule,
onPauseChange,
buttonSize,
}: Props) => {
// check all abilities and permissions
const [pauseSupported, pauseAllowed] = useAlertRuleAbility(rule, AlertRuleAction.Pause);
const canPause = pauseSupported && pauseAllowed;
const [deleteSupported, deleteAllowed] = useAlertRuleAbility(rule, AlertRuleAction.Delete);
const canDelete = deleteSupported && deleteAllowed;
const [duplicateSupported, duplicateAllowed] = useAlertRuleAbility(rule, AlertRuleAction.Duplicate);
const canDuplicate = duplicateSupported && duplicateAllowed;
const [silenceSupported, silenceAllowed] = useAlertRuleAbility(rule, AlertRuleAction.Silence);
const canSilence = silenceSupported && silenceAllowed;
const [exportSupported, exportAllowed] = useAlertRuleAbility(rule, AlertRuleAction.ModifyExport);
const canExport = exportSupported && exportAllowed;
const ruleExtensionLinks = useRulePluginLinkExtension(rule);
const extensionsAvailable = ruleExtensionLinks.length > 0;
/**
* Since Incident isn't available as an open-source product we shouldn't show it for Open-Source licenced editions of Grafana.
* We should show it in development mode
*/
const shouldShowDeclareIncidentButton =
(!isOpenSourceEdition() || isLocalDevEnv()) &&
isAlertingRule(rule.promRule) &&
rule.promRule.state === PromAlertingRuleState.Firing;
const shareUrl = createShareLink(rule.namespace.rulesSource, rule);
const showDivider =
[canPause, canSilence, shouldShowDeclareIncidentButton, canDuplicate].some(Boolean) &&
[showCopyLinkButton, canExport].some(Boolean);
const menuItems = (
<>
{canPause && <MenuItemPauseRule rule={rule} onPauseChange={onPauseChange} />}
{canSilence && (
<Menu.Item
label="Silence notifications"
icon="bell-slash"
url={makeRuleBasedSilenceLink(identifier.ruleSourceName, rule)}
/>
)}
{shouldShowDeclareIncidentButton && <DeclareIncidentMenuItem title={rule.name} url={''} />}
{canDuplicate && <Menu.Item label="Duplicate" icon="copy" onClick={() => handleDuplicateRule(identifier)} />}
{showDivider && <Menu.Divider />}
{shareUrl && <Menu.Item label="Copy link" icon="share-alt" onClick={() => copyToClipboard(shareUrl)} />}
{canExport && (
<Menu.Item
label="Export"
icon="download-alt"
childItems={[<ExportMenuItem key="export-with-modifications" identifier={identifier} />]}
/>
)}
{extensionsAvailable && (
<>
<Menu.Divider />
{ruleExtensionLinks.map((extension) => (
<Menu.Item key={extension.id} label={extension.title} icon={extension.icon} onClick={extension.onClick} />
))}
</>
)}
{canDelete && (
<>
<Menu.Divider />
<Menu.Item label="Delete" icon="trash-alt" destructive onClick={() => handleDelete(rule)} />
</>
)}
</>
);
return (
<Dropdown overlay={<Menu>{menuItems}</Menu>}>
<MoreButton size={buttonSize} />
</Dropdown>
);
};
function copyToClipboard(text: string) {
navigator.clipboard?.writeText(text).then(() => {
appEvents.emit(AppEvents.alertSuccess, ['URL copied to clipboard']);
});
}
type PropsWithIdentifier = { identifier: RuleIdentifier };
const ExportMenuItem = ({ identifier }: PropsWithIdentifier) => {
const returnTo = location.pathname + location.search;
const url = createUrl(`/alerting/${encodeURIComponent(ruleId.stringifyIdentifier(identifier))}/modify-export`, {
returnTo,
});
return <Menu.Item key="with-modifications" label="With modifications" icon="file-edit-alt" url={url} />;
};
export default AlertRuleMenu;
@@ -46,15 +46,15 @@ const ELEMENTS = {
delete: byRole('menuitem', { name: /Delete/i }),
},
pluginActions: {
sloDashboard: byRole('link', { name: /SLO dashboard/i }),
sloDashboard: byRole('menuitem', { name: /SLO dashboard/i }),
declareIncident: byRole('link', { name: /Declare incident/i }),
assertsWorkbench: byRole('link', { name: /Open workbench/i }),
assertsWorkbench: byRole('menuitem', { name: /Open workbench/i }),
},
},
},
};
const { apiHandlers: pluginApiHandlers } = setupPlugins(plugins.slo, plugins.incident, plugins.asserts);
const { apiHandlers: pluginApiHandlers } = setupPlugins(plugins);
const server = createMockGrafanaServer(...pluginApiHandlers);
@@ -7,6 +7,7 @@ import { Alert, LinkButton, Stack, TabContent, Text, TextLink, useStyles2 } from
import { PageInfoItem } from 'app/core/components/Page/types';
import { useQueryParams } from 'app/core/hooks/useQueryParams';
import InfoPausedRule from 'app/features/alerting/unified/components/InfoPausedRule';
import { RuleActionsButtons } from 'app/features/alerting/unified/components/rules/RuleActionsButtons';
import { CombinedRule, RuleHealth, RuleIdentifier } from 'app/types/unified-alerting';
import { PromAlertingRuleState, PromRuleType } from 'app/types/unified-alerting-dto';
@@ -31,8 +32,6 @@ import { WithReturnButton } from '../WithReturnButton';
import { decodeGrafanaNamespace } from '../expressions/util';
import { RedirectToCloneRule } from '../rules/CloneRule';
import { useAlertRulePageActions } from './Actions';
import { useDeleteModal } from './DeleteModal';
import { FederatedRuleWarning } from './FederatedRuleWarning';
import PausedBadge from './PausedBadge';
import { useAlertRule } from './RuleContext';
@@ -60,12 +59,6 @@ const RuleViewer = () => {
// of duplicating provisioned alert rules
const [duplicateRuleIdentifier, setDuplicateRuleIdentifier] = useState<RuleIdentifier>();
const [deleteModal, showDeleteModal] = useDeleteModal();
const actions = useAlertRulePageActions({
handleDuplicateRule: setDuplicateRuleIdentifier,
handleDelete: showDeleteModal,
});
const { annotations, promRule } = rule;
const hasError = isErrorHealth(rule.promRule?.health);
@@ -95,7 +88,7 @@ const RuleViewer = () => {
ruleOrigin={ruleOrigin}
/>
)}
actions={actions}
actions={<RuleActionsButtons rule={rule} showCopyLinkButton rulesSource={rule.namespace.rulesSource} />}
info={createMetadata(rule)}
subTitle={
<Stack direction="column">
@@ -128,7 +121,6 @@ const RuleViewer = () => {
{activeTab === ActiveTab.Details && <Details rule={rule} />}
</TabContent>
</Stack>
{deleteModal}
{duplicateRuleIdentifier && (
<RedirectToCloneRule
redirectTo={true}
@@ -0,0 +1,126 @@
import { produce } from 'immer';
import React from 'react';
import { render, screen, userEvent } from 'test/test-utils';
import { byLabelText } from 'testing-library-selector';
import { setPluginExtensionsHook } from '@grafana/runtime';
import { contextSrv } from 'app/core/services/context_srv';
import { RuleActionsButtons } from 'app/features/alerting/unified/components/rules/RuleActionsButtons';
import { setupMswServer } from 'app/features/alerting/unified/mockApi';
import {
getCloudRule,
getGrafanaRule,
grantUserPermissions,
mockDataSource,
mockGrafanaRulerRule,
mockPromAlertingRule,
} from 'app/features/alerting/unified/mocks';
import { configureStore } from 'app/store/configureStore';
import { AccessControlAction } from 'app/types';
import { PromAlertingRuleState } from 'app/types/unified-alerting-dto';
setupMswServer();
jest.mock('app/core/services/context_srv');
const mockContextSrv = jest.mocked(contextSrv);
const ui = {
moreButton: byLabelText('more-actions'),
};
const grantAllPermissions = () => {
grantUserPermissions([
AccessControlAction.AlertingRuleCreate,
AccessControlAction.AlertingRuleRead,
AccessControlAction.AlertingRuleUpdate,
AccessControlAction.AlertingRuleDelete,
AccessControlAction.AlertingInstanceCreate,
]);
mockContextSrv.hasPermissionInMetadata.mockImplementation(() => true);
mockContextSrv.hasPermission.mockImplementation(() => true);
};
const grantNoPermissions = () => {
grantUserPermissions([]);
mockContextSrv.hasPermissionInMetadata.mockImplementation(() => false);
mockContextSrv.hasPermission.mockImplementation(() => false);
};
const getMenuContents = async () => {
await screen.findByRole('menu');
const allMenuItems = screen.queryAllByRole('menuitem').map((el) => el.textContent);
const allLinkItems = screen.queryAllByRole('link').map((el) => el.textContent);
return [...allMenuItems, ...allLinkItems];
};
setPluginExtensionsHook(() => ({
extensions: [],
isLoading: false,
}));
describe('RuleActionsButtons', () => {
it('renders correct options for grafana managed rule', async () => {
const user = userEvent.setup();
grantAllPermissions();
const mockRule = getGrafanaRule();
render(<RuleActionsButtons rule={mockRule} rulesSource="grafana" showCopyLinkButton />);
await user.click(await ui.moreButton.find());
expect(await getMenuContents()).toMatchSnapshot();
});
it('renders correct options for Cloud rule', async () => {
const user = userEvent.setup();
grantAllPermissions();
const mockRule = getCloudRule();
const dataSource = mockDataSource({ id: 1 });
const defaultState = configureStore().getState();
render(<RuleActionsButtons rule={mockRule} rulesSource={dataSource} />, {
preloadedState: produce(defaultState, (store) => {
store.unifiedAlerting.dataSources[dataSource.name] = {
loading: false,
dispatched: true,
result: {
id: 'test-ds',
name: dataSource.name,
rulerConfig: {
dataSourceName: dataSource.name,
apiVersion: 'config',
},
},
};
}),
});
await user.click(await ui.moreButton.find());
expect(await getMenuContents()).toMatchSnapshot();
});
it('renders minimal "More" menu when appropriate', async () => {
const user = userEvent.setup();
grantNoPermissions();
const mockRule = getGrafanaRule({ promRule: mockPromAlertingRule({ state: PromAlertingRuleState.Inactive }) });
render(<RuleActionsButtons rule={mockRule} rulesSource="grafana" />);
await user.click(await ui.moreButton.find());
expect(await getMenuContents()).toMatchSnapshot();
});
it('does not allow deletion when rule is provisioned', async () => {
const user = userEvent.setup();
grantAllPermissions();
const mockRule = getGrafanaRule({ rulerRule: mockGrafanaRulerRule({ provenance: 'file' }) });
render(<RuleActionsButtons rule={mockRule} rulesSource="grafana" />);
await user.click(await ui.moreButton.find());
expect(screen.queryByText(/delete/i)).not.toBeInTheDocument();
});
});
@@ -1,33 +1,20 @@
import { css } from '@emotion/css';
import { uniqueId } from 'lodash';
import { css, cx } from '@emotion/css';
import React, { useState } from 'react';
import { useLocation } from 'react-router-dom';
import { GrafanaTheme2 } from '@grafana/data';
import {
Button,
ClipboardButton,
ConfirmModal,
Dropdown,
Icon,
LinkButton,
Menu,
Tooltip,
useStyles2,
Stack,
} from '@grafana/ui';
import { useAppNotification } from 'app/core/copy/appNotification';
import MenuItemPauseRule from 'app/features/alerting/unified/components/MenuItemPauseRule';
import { LinkButton, useStyles2, Stack } from '@grafana/ui';
import AlertRuleMenu from 'app/features/alerting/unified/components/rule-viewer/AlertRuleMenu';
import { useDeleteModal } from 'app/features/alerting/unified/components/rule-viewer/DeleteModal';
import { INSTANCES_DISPLAY_LIMIT } from 'app/features/alerting/unified/components/rules/RuleDetails';
import { useRulesFilter } from 'app/features/alerting/unified/hooks/useFilteredRules';
import { useDispatch } from 'app/types';
import { CombinedRule, RuleIdentifier, RulesSource } from 'app/types/unified-alerting';
import { AlertRuleAction, useAlertRuleAbility } from '../../hooks/useAbilities';
import { useRulePluginLinkExtension } from '../../plugins/useRulePluginLinkExtensions';
import { deleteRuleAction, fetchAllPromAndRulerRulesAction } from '../../state/actions';
import { getRulesSourceName } from '../../utils/datasource';
import { createShareLink, createViewLink } from '../../utils/misc';
import { fetchPromAndRulerRulesAction } from '../../state/actions';
import { GRAFANA_RULES_SOURCE_NAME, getRulesSourceName } from '../../utils/datasource';
import { createViewLink } from '../../utils/misc';
import * as ruleId from '../../utils/rule-id';
import { isGrafanaRulerRule } from '../../utils/rules';
import { createUrl } from '../../utils/url';
@@ -39,230 +26,124 @@ export const matchesWidth = (width: number) => window.matchMedia(`(max-width: ${
interface Props {
rule: CombinedRule;
rulesSource: RulesSource;
/**
* Should we show the buttons in a "compact" state?
* i.e. without text and using smaller button sizes
*/
compact?: boolean;
showViewButton?: boolean;
showCopyLinkButton?: boolean;
}
export const RuleActionsButtons = ({ rule, rulesSource }: Props) => {
/**
* **Action** buttons to show for an alert rule - e.g. "View", "Edit", "More..."
*/
export const RuleActionsButtons = ({ compact, showViewButton, showCopyLinkButton, rule, rulesSource }: Props) => {
const dispatch = useDispatch();
const location = useLocation();
const notifyApp = useAppNotification();
const style = useStyles2(getStyles);
const [deleteModal, showDeleteModal] = useDeleteModal();
const [redirectToClone, setRedirectToClone] = useState<
{ identifier: RuleIdentifier; isProvisioned: boolean } | undefined
>(undefined);
const { namespace, group, rulerRule } = rule;
const [ruleToDelete, setRuleToDelete] = useState<CombinedRule>();
const { hasActiveFilters } = useRulesFilter();
const returnTo = location.pathname + location.search;
const isViewMode = inViewMode(location.pathname);
const isProvisioned = isGrafanaRulerRule(rule.rulerRule) && Boolean(rule.rulerRule.grafana_alert.provenance);
const ruleExtensionLinks = useRulePluginLinkExtension(rule);
const [editRuleSupported, editRuleAllowed] = useAlertRuleAbility(rule, AlertRuleAction.Update);
const [deleteRuleSupported, deleteRuleAllowed] = useAlertRuleAbility(rule, AlertRuleAction.Delete);
const [duplicateRuleSupported, duplicateRuleAllowed] = useAlertRuleAbility(rule, AlertRuleAction.Duplicate);
const [modifyExportSupported, modifyExportAllowed] = useAlertRuleAbility(rule, AlertRuleAction.ModifyExport);
const canEditRule = editRuleSupported && editRuleAllowed;
const canDeleteRule = deleteRuleSupported && deleteRuleAllowed;
const canDuplicateRule = duplicateRuleSupported && duplicateRuleAllowed;
const canModifyExport = modifyExportSupported && modifyExportAllowed;
const buttons: JSX.Element[] = [];
const moreActions: 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 = () => createShareLink(rulesSource, rule);
const buttonClasses = cx({ [style.compactButton]: compact });
const buttonSize = compact ? 'sm' : 'md';
const sourceName = getRulesSourceName(rulesSource);
if (!isViewMode) {
const identifier = ruleId.fromCombinedRule(sourceName, rule);
if (showViewButton) {
buttons.push(
<Tooltip placement="top" content={'View'}>
<LinkButton
className={style.button}
title="View"
size="sm"
key="view"
variant="secondary"
icon="eye"
href={createViewLink(rulesSource, rule, returnTo)}
/>
</Tooltip>
<LinkButton
tooltip={compact ? 'View' : undefined}
tooltipPlacement="top"
className={buttonClasses}
title={'View'}
size={buttonSize}
key="view"
variant="secondary"
icon="eye"
href={createViewLink(rulesSource, rule, returnTo)}
>
{!compact && 'View'}
</LinkButton>
);
}
if (rulerRule) {
if (rulerRule && canEditRule) {
const identifier = ruleId.fromRulerRule(sourceName, namespace.name, group.name, rulerRule);
if (canEditRule) {
const editURL = createUrl(`/alerting/${encodeURIComponent(ruleId.stringifyIdentifier(identifier))}/edit`, {
returnTo,
});
const editURL = createUrl(`/alerting/${encodeURIComponent(ruleId.stringifyIdentifier(identifier))}/edit`, {
returnTo,
});
buttons.push(
<Tooltip placement="top" content={'Edit'}>
<LinkButton
title="Edit"
className={style.button}
size="sm"
key="edit"
variant="secondary"
icon="pen"
href={editURL}
/>
</Tooltip>
);
buttons.push(
<LinkButton
tooltip={compact ? 'Edit' : undefined}
tooltipPlacement="top"
title={'Edit'}
className={buttonClasses}
size={buttonSize}
key="edit"
variant="secondary"
icon="pen"
href={editURL}
>
{!compact && 'Edit'}
</LinkButton>
);
}
moreActions.push(
<MenuItemPauseRule
rule={rule}
onPauseChange={() => {
// Uses INSTANCES_DISPLAY_LIMIT + 1 here as exporting LIMIT_ALERTS from RuleList has the side effect
// of breaking some unrelated tests in Policy.test.tsx due to mocking approach
const limitAlerts = hasActiveFilters ? undefined : INSTANCES_DISPLAY_LIMIT + 1;
// Trigger a re-fetch of the rules table
// TODO: Migrate rules table functionality to RTK Query, so we instead rely
// on tag invalidation (or optimistic cache updates) for this
dispatch(fetchAllPromAndRulerRulesAction(false, { limitAlerts }));
}}
return (
<Stack gap={1}>
{buttons}
<AlertRuleMenu
buttonSize={buttonSize}
rule={rule}
identifier={identifier}
showCopyLinkButton={showCopyLinkButton}
handleDelete={() => showDeleteModal(rule)}
handleDuplicateRule={() => setRedirectToClone({ identifier, isProvisioned })}
onPauseChange={() => {
// Uses INSTANCES_DISPLAY_LIMIT + 1 here as exporting LIMIT_ALERTS from RuleList has the side effect
// of breaking some unrelated tests in Policy.test.tsx due to mocking approach
const limitAlerts = hasActiveFilters ? undefined : INSTANCES_DISPLAY_LIMIT + 1;
// Trigger a re-fetch of the rules table
// TODO: Migrate rules table functionality to RTK Query, so we instead rely
// on tag invalidation (or optimistic cache updates) for this
dispatch(fetchPromAndRulerRulesAction({ rulesSourceName: GRAFANA_RULES_SOURCE_NAME, limitAlerts }));
}}
/>
{deleteModal}
{redirectToClone?.identifier && (
<RedirectToCloneRule
identifier={redirectToClone.identifier}
isProvisioned={redirectToClone.isProvisioned}
onDismiss={() => setRedirectToClone(undefined)}
/>
);
}
if (isViewMode) {
buttons.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>
);
}
if (canDuplicateRule) {
moreActions.push(
<Menu.Item label="Duplicate" icon="copy" onClick={() => setRedirectToClone({ identifier, isProvisioned })} />
);
}
if (canModifyExport) {
moreActions.push(
<Menu.Item
label="Modify export"
icon="edit"
url={createUrl(`/alerting/${encodeURIComponent(ruleId.stringifyIdentifier(identifier))}/modify-export`, {
returnTo: location.pathname + location.search,
})}
/>
);
}
}
if (ruleExtensionLinks.length > 0) {
moreActions.push(
<Menu.Divider />,
...ruleExtensionLinks.map((extension) => (
<Menu.Item key={extension.id} label={extension.title} icon={extension.icon} onClick={extension.onClick} />
))
);
}
if (rulerRule && canDeleteRule) {
moreActions.push(
<Menu.Divider />,
<Menu.Item label="Delete" icon="trash-alt" destructive onClick={() => setRuleToDelete(rule)} />
);
}
if (buttons.length || moreActions.length) {
return (
<>
<Stack gap={1}>
{buttons.map((button, index) => (
<React.Fragment key={index}>{button}</React.Fragment>
))}
{moreActions.length > 0 && (
<Dropdown
overlay={
<Menu>
{moreActions.map((action) => (
<React.Fragment key={uniqueId('action_')}>{action}</React.Fragment>
))}
</Menu>
}
>
<Button variant="secondary" size="sm">
More
<Icon name="angle-down" />
</Button>
</Dropdown>
)}
</Stack>
{!!ruleToDelete && (
<ConfirmModal
isOpen={true}
title="Delete rule"
body={
<div>
<p>
Deleting &quot;<strong>{ruleToDelete.name}</strong>&quot; will permanently remove it from your alert
rule list.
</p>
<p>Are you sure you want to delete this rule?</p>
</div>
}
confirmText="Yes, delete"
icon="exclamation-triangle"
onConfirm={deleteRule}
onDismiss={() => setRuleToDelete(undefined)}
/>
)}
{redirectToClone && (
<RedirectToCloneRule
identifier={redirectToClone.identifier}
isProvisioned={redirectToClone.isProvisioned}
onDismiss={() => setRedirectToClone(undefined)}
/>
)}
</>
);
}
return null;
)}
</Stack>
);
};
function inViewMode(pathname: string): boolean {
return pathname.endsWith('/view');
}
export const getStyles = (theme: GrafanaTheme2) => ({
button: css`
padding: 0 ${theme.spacing(2)};
`,
const getStyles = (theme: GrafanaTheme2) => ({
compactButton: css({
padding: `0 ${theme.spacing(2)}`,
}),
});
@@ -9,10 +9,8 @@ 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 { contextSrv } from 'app/core/services/context_srv';
import { AlertmanagerChoice } from 'app/plugins/datasource/alertmanager/types';
import { configureStore } from 'app/store/configureStore';
import { AccessControlAction } from 'app/types';
import { CombinedRule } from 'app/types/unified-alerting';
import { AlertmanagersChoiceResponse } from '../../api/alertmanagerApi';
@@ -113,32 +111,6 @@ describe('RuleDetails RBAC', () => {
expect(ui.actionButtons.delete.query()).not.toBeInTheDocument();
await waitFor(() => screen.queryByRole('button', { name: 'Declare incident' }));
});
it('Should not render Silence button for users wihout the instance create permission', async () => {
// Arrange
jest.spyOn(contextSrv, 'hasPermission').mockReturnValue(false);
// Act
renderRuleDetails(grafanaRule);
// Assert
expect(ui.actionButtons.silence.query()).not.toBeInTheDocument();
await waitFor(() => screen.queryByRole('button', { name: 'Declare incident' }));
});
it('Should render Silence button for users with the instance create permissions', async () => {
// Arrange
jest
.spyOn(contextSrv, 'hasPermission')
.mockImplementation((action) => action === AccessControlAction.AlertingInstanceCreate);
// Act
renderRuleDetails(grafanaRule);
// Assert
expect(await ui.actionButtons.silence.find()).toBeInTheDocument();
await waitFor(() => screen.queryByRole('button', { name: 'Declare incident' }));
});
});
describe('Cloud rules action buttons', () => {
@@ -12,8 +12,8 @@ import { isNullDate } from '../../utils/time';
import { AlertLabels } from '../AlertLabels';
import { DetailsField } from '../DetailsField';
import { RuleDetailsActionButtons } from './RuleDetailsActionButtons';
import { RuleDetailsAnnotations } from './RuleDetailsAnnotations';
import RuleDetailsButtons from './RuleDetailsButtons';
import { RuleDetailsDataSources } from './RuleDetailsDataSources';
import { RuleDetailsExpression } from './RuleDetailsExpression';
import { RuleDetailsMatchingInstances } from './RuleDetailsMatchingInstances';
@@ -37,7 +37,7 @@ export const RuleDetails = ({ rule }: Props) => {
return (
<div>
<RuleDetailsActionButtons rule={rule} rulesSource={rulesSource} />
<RuleDetailsButtons rule={rule} rulesSource={rulesSource} />
<div className={styles.wrapper}>
<div className={styles.leftSide}>
{<EvaluationBehaviorSummary rule={rule} />}
@@ -111,21 +111,21 @@ const EvaluationBehaviorSummary = ({ rule }: EvaluationBehaviorSummaryProps) =>
};
export const getStyles = (theme: GrafanaTheme2) => ({
wrapper: css`
display: flex;
flex-direction: row;
wrapper: css({
display: 'flex',
flexDirection: 'row',
${theme.breakpoints.down('md')} {
flex-direction: column;
}
`,
leftSide: css`
flex: 1;
`,
rightSide: css`
${theme.breakpoints.up('md')} {
padding-left: 90px;
width: 300px;
}
`,
[theme.breakpoints.down('md')]: {
flexDirection: 'column',
},
}),
leftSide: css({
flex: '1',
}),
rightSide: css({
[theme.breakpoints.up('md')]: {
paddingLeft: '90px',
width: '300px',
},
}),
});
@@ -1,257 +0,0 @@
import { css } from '@emotion/css';
import { uniqueId } from 'lodash';
import React, { Fragment, useState } from 'react';
import { GrafanaTheme2, textUtil } from '@grafana/data';
import { config, useReturnToPrevious } from '@grafana/runtime';
import { Button, ConfirmModal, Dropdown, HorizontalGroup, Icon, LinkButton, Menu, useStyles2 } from '@grafana/ui';
import { useDispatch } from 'app/types';
import { CombinedRule, RuleIdentifier, RulesSource } from 'app/types/unified-alerting';
import { PromAlertingRuleState } from 'app/types/unified-alerting-dto';
import { AlertRuleAction, useAlertRuleAbility } from '../../hooks/useAbilities';
import { useStateHistoryModal } from '../../hooks/useStateHistoryModal';
import { deleteRuleAction } from '../../state/actions';
import { getAlertmanagerByUid } from '../../utils/alertmanager';
import { Annotation } from '../../utils/constants';
import { getRulesSourceName, isCloudRulesSource, isGrafanaRulesSource } from '../../utils/datasource';
import {
createExploreLink,
createShareLink,
isLocalDevEnv,
isOpenSourceEdition,
makeRuleBasedSilenceLink,
} from '../../utils/misc';
import * as ruleId from '../../utils/rule-id';
import { isAlertingRule, isFederatedRuleGroup, isGrafanaRulerRule } from '../../utils/rules';
import { DeclareIncidentButton } from '../bridges/DeclareIncidentButton';
import { RedirectToCloneRule } from './CloneRule';
interface Props {
rule: CombinedRule;
rulesSource: RulesSource;
}
export const RuleDetailsActionButtons = ({ rule, rulesSource }: Props) => {
const style = useStyles2(getStyles);
const { group } = rule;
const { StateHistoryModal, showStateHistoryModal } = useStateHistoryModal();
const dispatch = useDispatch();
const setReturnToPrevious = useReturnToPrevious();
const [ruleToDelete, setRuleToDelete] = useState<CombinedRule>();
const [redirectToClone, setRedirectToClone] = useState<
{ identifier: RuleIdentifier; isProvisioned: boolean } | undefined
>(undefined);
const alertmanagerSourceName = isGrafanaRulesSource(rulesSource)
? rulesSource
: getAlertmanagerByUid(rulesSource.jsonData.alertmanagerUid)?.name;
const [silenceSupported, silenceAllowed] = useAlertRuleAbility(rule, AlertRuleAction.Silence);
const [exploreSupported, exploreAllowed] = useAlertRuleAbility(rule, AlertRuleAction.Explore);
const buttons: JSX.Element[] = [];
const rightButtons: JSX.Element[] = [];
const moreActionsButtons: React.ReactElement[] = [];
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: undefined }));
setRuleToDelete(undefined);
}
};
const isFederated = isFederatedRuleGroup(group);
const isFiringRule = isAlertingRule(rule.promRule) && rule.promRule.state === PromAlertingRuleState.Firing;
const canSilence = silenceSupported && silenceAllowed && alertmanagerSourceName;
const buildShareUrl = () => createShareLink(rulesSource, rule);
// explore does not support grafana rule queries atm
// neither do "federated rules"
if (isCloudRulesSource(rulesSource) && exploreSupported && exploreAllowed && !isFederated) {
buttons.push(
<LinkButton
size="sm"
key="explore"
variant="primary"
icon="chart-line"
target="_blank"
href={createExploreLink(rulesSource, rule.query)}
>
See graph
</LinkButton>
);
}
if (rule.annotations[Annotation.runbookURL]) {
buttons.push(
<LinkButton
size="sm"
key="runbook"
variant="primary"
icon="book"
target="_blank"
href={textUtil.sanitizeUrl(rule.annotations[Annotation.runbookURL])}
>
View runbook
</LinkButton>
);
}
if (rule.annotations[Annotation.dashboardUID]) {
const dashboardUID = rule.annotations[Annotation.dashboardUID];
const isReturnToPreviousEnabled = config.featureToggles.returnToPrevious;
if (dashboardUID) {
buttons.push(
<LinkButton
size="sm"
key="dashboard"
variant="primary"
icon="apps"
target={isReturnToPreviousEnabled ? undefined : '_blank'}
href={`d/${encodeURIComponent(dashboardUID)}`}
onClick={() => {
setReturnToPrevious(rule.name);
}}
>
Go to dashboard
</LinkButton>
);
const panelId = rule.annotations[Annotation.panelID];
if (panelId) {
buttons.push(
<LinkButton
size="sm"
key="panel"
variant="primary"
icon="apps"
target={isReturnToPreviousEnabled ? undefined : '_blank'}
href={`d/${encodeURIComponent(dashboardUID)}?viewPanel=${encodeURIComponent(panelId)}`}
onClick={() => {
setReturnToPrevious(rule.name);
}}
>
Go to panel
</LinkButton>
);
}
}
}
if (canSilence) {
buttons.push(
<LinkButton
size="sm"
key="silence"
icon="bell-slash"
target="_blank"
href={makeRuleBasedSilenceLink(alertmanagerSourceName, rule)}
>
Silence
</LinkButton>
);
}
if (isGrafanaRulerRule(rule.rulerRule)) {
buttons.push(
<Fragment key="history">
<Button
size="sm"
icon="history"
onClick={() => isGrafanaRulerRule(rule.rulerRule) && showStateHistoryModal(rule.rulerRule)}
>
Show state history
</Button>
{StateHistoryModal}
</Fragment>
);
}
if (isFiringRule && shouldShowDeclareIncidentButton()) {
buttons.push(
<Fragment key="declare-incident">
<DeclareIncidentButton title={rule.name} url={buildShareUrl()} />
</Fragment>
);
}
if (buttons.length || rightButtons.length || moreActionsButtons.length) {
return (
<>
<div className={style.wrapper}>
<HorizontalGroup width="auto">{buttons.length ? buttons : <div />}</HorizontalGroup>
<HorizontalGroup width="auto">
{rightButtons.length && rightButtons}
{moreActionsButtons.length && (
<Dropdown
overlay={
<Menu>
{moreActionsButtons.map((action) => (
<React.Fragment key={uniqueId('action_')}>{action}</React.Fragment>
))}
</Menu>
}
>
<Button variant="secondary" size="sm">
More
<Icon name="angle-down" />
</Button>
</Dropdown>
)}
</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)}
/>
)}
{redirectToClone && (
<RedirectToCloneRule
identifier={redirectToClone.identifier}
isProvisioned={redirectToClone.isProvisioned}
onDismiss={() => setRedirectToClone(undefined)}
/>
)}
</>
);
}
return null;
};
/**
* Since Incident isn't available as an open-source product we shouldn't show it for Open-Source licenced editions of Grafana.
* We should show it in development mode
*/
function shouldShowDeclareIncidentButton() {
return !isOpenSourceEdition() || isLocalDevEnv();
}
export const getStyles = (theme: GrafanaTheme2) => ({
wrapper: css`
padding: 0 0 ${theme.spacing(2)} 0;
gap: ${theme.spacing(1)};
display: flex;
flex-direction: row;
justify-content: space-between;
flex-wrap: wrap;
border-bottom: solid 1px ${theme.colors.border.medium};
`,
});
@@ -0,0 +1,127 @@
import React, { Fragment } from 'react';
import { textUtil } from '@grafana/data';
import { config, useReturnToPrevious } from '@grafana/runtime';
import { Button, LinkButton, Stack } from '@grafana/ui';
import { CombinedRule, RulesSource } from 'app/types/unified-alerting';
import { AlertRuleAction, useAlertRuleAbility } from '../../hooks/useAbilities';
import { useStateHistoryModal } from '../../hooks/useStateHistoryModal';
import { Annotation } from '../../utils/constants';
import { isCloudRulesSource } from '../../utils/datasource';
import { createExploreLink } from '../../utils/misc';
import { isFederatedRuleGroup, isGrafanaRulerRule } from '../../utils/rules';
interface Props {
rule: CombinedRule;
rulesSource: RulesSource;
}
/**
* Buttons to display on an expanded alert rule in the list view
*
* e.g. "Show state history", "Go to dashboard"
*
* Shouldn't include *actions* for the alert rule, just navigation items
*/
const RuleDetailsButtons = ({ rule, rulesSource }: Props) => {
const { group } = rule;
const { StateHistoryModal, showStateHistoryModal } = useStateHistoryModal();
const setReturnToPrevious = useReturnToPrevious();
const [exploreSupported, exploreAllowed] = useAlertRuleAbility(rule, AlertRuleAction.Explore);
const buttons: JSX.Element[] = [];
const isFederated = isFederatedRuleGroup(group);
// explore does not support grafana rule queries atm
// neither do "federated rules"
if (isCloudRulesSource(rulesSource) && exploreSupported && exploreAllowed && !isFederated) {
buttons.push(
<LinkButton
size="sm"
key="explore"
variant="primary"
icon="chart-line"
target="_blank"
href={createExploreLink(rulesSource, rule.query)}
>
See graph
</LinkButton>
);
}
if (rule.annotations[Annotation.runbookURL]) {
buttons.push(
<LinkButton
size="sm"
key="runbook"
variant="primary"
icon="book"
target="_blank"
href={textUtil.sanitizeUrl(rule.annotations[Annotation.runbookURL])}
>
View runbook
</LinkButton>
);
}
if (rule.annotations[Annotation.dashboardUID]) {
const dashboardUID = rule.annotations[Annotation.dashboardUID];
const isReturnToPreviousEnabled = config.featureToggles.returnToPrevious;
if (dashboardUID) {
buttons.push(
<LinkButton
size="sm"
key="dashboard"
variant="primary"
icon="apps"
target={isReturnToPreviousEnabled ? undefined : '_blank'}
href={`d/${encodeURIComponent(dashboardUID)}`}
onClick={() => {
setReturnToPrevious(rule.name);
}}
>
Go to dashboard
</LinkButton>
);
const panelId = rule.annotations[Annotation.panelID];
if (panelId) {
buttons.push(
<LinkButton
size="sm"
key="panel"
variant="primary"
icon="apps"
target={isReturnToPreviousEnabled ? undefined : '_blank'}
href={`d/${encodeURIComponent(dashboardUID)}?viewPanel=${encodeURIComponent(panelId)}`}
onClick={() => {
setReturnToPrevious(rule.name);
}}
>
Go to panel
</LinkButton>
);
}
}
}
if (isGrafanaRulerRule(rule.rulerRule)) {
buttons.push(
<Fragment key="history">
<Button
size="sm"
icon="history"
onClick={() => isGrafanaRulerRule(rule.rulerRule) && showStateHistoryModal(rule.rulerRule)}
>
Show state history
</Button>
{StateHistoryModal}
</Fragment>
);
}
return buttons.length ? <Stack>{buttons}</Stack> : null;
};
export default RuleDetailsButtons;
@@ -6,11 +6,12 @@ import { MemoryRouter } from 'react-router-dom';
import { byRole } from 'testing-library-selector';
import { setPluginExtensionsHook } from '@grafana/runtime';
import { mockApi, setupMswServer } from 'app/features/alerting/unified/mockApi';
import { configureStore } from 'app/store/configureStore';
import { CombinedRule } from 'app/types/unified-alerting';
import { AlertRuleAction, useAlertRuleAbility } from '../../hooks/useAbilities';
import { getCloudRule, getGrafanaRule } from '../../mocks';
import { getCloudRule, getGrafanaRule, getMockPluginMeta } from '../../mocks';
import { RulesTable } from './RulesTable';
@@ -29,7 +30,7 @@ const ui = {
actionButtons: {
edit: byRole('link', { name: 'Edit' }),
view: byRole('link', { name: 'View' }),
more: byRole('button', { name: 'More' }),
more: byRole('button', { name: /more-actions/i }),
},
moreActionItems: {
delete: byRole('menuitem', { name: 'Delete' }),
@@ -49,8 +50,14 @@ function renderRulesTable(rule: CombinedRule) {
}
const user = userEvent.setup();
const server = setupMswServer();
describe('RulesTable RBAC', () => {
beforeEach(() => {
mockApi(server).plugins.getPluginSettings({
...getMockPluginMeta('grafana-incident-app', 'Grafana Incident'),
});
});
describe('Grafana rules action buttons', () => {
const grafanaRule = getGrafanaRule({ name: 'Grafana' });
@@ -266,7 +266,7 @@ function useColumns(showSummaryColumn: boolean, showGroupColumn: boolean, showNe
label: 'Actions',
// eslint-disable-next-line react/display-name
renderCell: ({ data: rule }) => {
return <RuleActionsButtons rule={rule} rulesSource={rule.namespace.rulesSource} />;
return <RuleActionsButtons compact showViewButton rule={rule} rulesSource={rule.namespace.rulesSource} />;
},
size: '200px',
});
@@ -0,0 +1,28 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`RuleActionsButtons renders correct options for Cloud rule 1`] = `
[
"Duplicate",
"Copy link",
"Delete",
"Declare incident",
]
`;
exports[`RuleActionsButtons renders correct options for grafana managed rule 1`] = `
[
"Pause evaluation",
"Duplicate",
"Copy link",
"Export",
"Delete",
"Silence notifications",
"Declare incident",
]
`;
exports[`RuleActionsButtons renders minimal "More" menu when appropriate 1`] = `
[
"Copy link",
]
`;
@@ -18,6 +18,10 @@ exports[`AlertRule abilities should report no permissions while we are loading d
false,
false,
],
"pause-alert-rule": [
false,
false,
],
"silence-alert-rule": [
false,
false,
@@ -51,6 +55,10 @@ exports[`AlertRule abilities should report that all actions are supported for a
true,
false,
],
"pause-alert-rule": [
true,
false,
],
"silence-alert-rule": [
true,
false,
@@ -69,6 +69,7 @@ export enum AlertRuleAction {
Explore = 'explore-alert-rule',
Silence = 'silence-alert-rule',
ModifyExport = 'modify-export-rule',
Pause = 'pause-alert-rule',
}
// this enum lists all of the actions we can perform within alerting in general, not linked to a specific
@@ -178,6 +179,7 @@ export function useAllAlertRuleAbilities(rule: CombinedRule): Abilities<AlertRul
[AlertRuleAction.Explore]: toAbility(AlwaysSupported, AccessControlAction.DataSourcesExplore),
[AlertRuleAction.Silence]: canSilence,
[AlertRuleAction.ModifyExport]: [isGrafanaManagedAlertRule, exportAllowed],
[AlertRuleAction.Pause]: [MaybeSupportedUnlessImmutable && isGrafanaManagedAlertRule, isEditable ?? false],
};
return abilities;
+21 -36
View File
@@ -755,42 +755,27 @@ export function mockDashboardDto(
};
}
export const onCallPluginMetaMock: PluginMeta = {
name: 'Grafana OnCall',
id: 'grafana-oncall-app',
type: PluginType.app,
module: 'plugins/grafana-oncall-app/module',
baseUrl: 'public/plugins/grafana-oncall-app',
info: {
author: { name: 'Grafana Labs' },
description: 'Grafana OnCall',
updated: '',
version: '',
links: [],
logos: {
small: '',
large: '',
export const getMockPluginMeta: (id: string, name: string) => PluginMeta = (id, name) => {
return {
name,
id,
type: PluginType.app,
module: `plugins/${id}/module`,
baseUrl: `public/plugins/${id}`,
info: {
author: { name: 'Grafana Labs' },
description: name,
updated: '',
version: '',
links: [],
logos: {
small: '',
large: '',
},
screenshots: [],
},
screenshots: [],
},
};
};
export const labelsPluginMetaMock: PluginMeta = {
name: 'Grafana IRM Labels',
id: 'grafana-labels-app',
type: PluginType.app,
module: 'plugins/grafana-labels-app/module',
baseUrl: 'public/plugins/grafana-labels-app',
info: {
author: { name: 'Grafana Labs' },
description: '',
updated: '',
version: '',
links: [],
logos: {
small: '',
large: '',
},
screenshots: [],
},
};
export const labelsPluginMetaMock = getMockPluginMeta('grafana-labels-app', 'Grafana IRM Labels');
export const onCallPluginMetaMock = getMockPluginMeta('grafana-oncall-app', 'Grafana OnCall');
@@ -0,0 +1,6 @@
import { HttpResponse, http } from 'msw';
import { mockFolder } from 'app/features/alerting/unified/mocks';
export const folderHandler = (response = mockFolder()) =>
http.get(`/api/folders/:folderUid`, () => HttpResponse.json(response));
@@ -1,10 +1,12 @@
import { http, HttpResponse } from 'msw';
import { PluginMeta } from '@grafana/data';
import { plugins } from 'app/features/alerting/unified/testSetup/plugins';
export const pluginsHandler = (pluginsRegistry: Map<string, PluginMeta>) =>
http.get<{ pluginId: string }>(`/api/plugins/:pluginId/settings`, ({ params: { pluginId } }) =>
pluginsRegistry.has(pluginId)
? HttpResponse.json<PluginMeta>(pluginsRegistry.get(pluginId)!)
: HttpResponse.json({ message: 'Plugin not found, no installed plugin with that id' }, { status: 404 })
);
export const pluginsHandler = (pluginsArray: PluginMeta[] = plugins) =>
http.get<{ pluginId: string }>(`/api/plugins/:pluginId/settings`, ({ params: { pluginId } }) => {
const matchingPlugin = pluginsArray.find((plugin) => plugin.id === pluginId);
return matchingPlugin
? HttpResponse.json<PluginMeta>(matchingPlugin)
: HttpResponse.json({ message: 'Plugin not found, no installed plugin with that id' }, { status: 404 });
});
@@ -7,6 +7,8 @@ import {
alertmanagerChoiceHandler,
} from 'app/features/alerting/unified/mocks/alertmanagerApi';
import { datasourceBuildInfoHandler } from 'app/features/alerting/unified/mocks/datasources';
import { folderHandler } from 'app/features/alerting/unified/mocks/folders';
import { pluginsHandler } from 'app/features/alerting/unified/mocks/plugins';
import {
silenceCreateHandler,
silenceGetHandler,
@@ -20,6 +22,10 @@ const allHandlers = [
alertmanagerChoiceHandler(),
alertmanagerAlertsListHandler(),
folderHandler(),
pluginsHandler(),
silencesListHandler(),
silenceGetHandler(),
silenceCreateHandler(),
@@ -5,11 +5,8 @@ import { config } from '@grafana/runtime';
import { pluginsHandler } from '../mocks/plugins';
export function setupPlugins(...plugins: PluginMeta[]): { apiHandlers: RequestHandler[] } {
const pluginsRegistry = new Map<string, PluginMeta>();
plugins.forEach((plugin) => pluginsRegistry.set(plugin.id, plugin));
pluginsRegistry.forEach((plugin) => {
export function setupPlugins(plugins: PluginMeta[]): { apiHandlers: RequestHandler[] } {
plugins.forEach((plugin) => {
config.apps[plugin.id] = {
id: plugin.id,
path: plugin.baseUrl,
@@ -20,12 +17,12 @@ export function setupPlugins(...plugins: PluginMeta[]): { apiHandlers: RequestHa
});
return {
apiHandlers: [pluginsHandler(pluginsRegistry)],
apiHandlers: [pluginsHandler(plugins)],
};
}
export const plugins: Record<string, PluginMeta> = {
slo: {
export const plugins: PluginMeta[] = [
{
id: 'grafana-slo-app',
name: 'SLO dashboard',
type: PluginType.app,
@@ -48,7 +45,7 @@ export const plugins: Record<string, PluginMeta> = {
module: 'public/plugins/grafana-slo-app/module.js',
baseUrl: 'public/plugins/grafana-slo-app',
},
incident: {
{
id: 'grafana-incident-app',
name: 'Incident management',
type: PluginType.app,
@@ -71,7 +68,7 @@ export const plugins: Record<string, PluginMeta> = {
module: 'public/plugins/grafana-incident-app/module.js',
baseUrl: 'public/plugins/grafana-incident-app',
},
asserts: {
{
id: 'grafana-asserts-app',
name: 'Asserts',
type: PluginType.app,
@@ -94,4 +91,4 @@ export const plugins: Record<string, PluginMeta> = {
module: 'public/plugins/grafana-asserts-app/module.js',
baseUrl: 'public/plugins/grafana-asserts-app',
},
};
];
@@ -5,7 +5,7 @@ import { GrafanaEdition } from '@grafana/data/src/types/config';
import { config, isFetchError } from '@grafana/runtime';
import { DataSourceRef } from '@grafana/schema';
import { escapePathSeparators } from 'app/features/alerting/unified/utils/rule-id';
import { alertInstanceKey } from 'app/features/alerting/unified/utils/rules';
import { alertInstanceKey, isGrafanaRulerRule } from 'app/features/alerting/unified/utils/rules';
import { SortOrder } from 'app/plugins/panel/alertlist/types';
import { Alert, CombinedRule, FilterState, RulesSource, SilenceFilterState } from 'app/types/unified-alerting';
import {
@@ -54,14 +54,16 @@ export function createMuteTimingLink(muteTimingName: string, alertManagerSourceN
});
}
export function createShareLink(ruleSource: RulesSource, rule: CombinedRule): string {
export function createShareLink(ruleSource: RulesSource, rule: CombinedRule): string | undefined {
if (isCloudRulesSource(ruleSource)) {
return createAbsoluteUrl(
`/alerting/${encodeURIComponent(ruleSource.name)}/${encodeURIComponent(escapePathSeparators(rule.name))}/find`
);
} else if (isGrafanaRulerRule(rule.rulerRule)) {
return createUrl(`/alerting/grafana/${rule.rulerRule.grafana_alert.uid}/view`);
}
return window.location.href.split('?')[0];
return;
}
export function arrayToRecord(items: Array<{ key: string; value: string }>): Record<string, string> {