From d84d0c888922fd21954efc0adfa5271e4cd488cb Mon Sep 17 00:00:00 2001 From: Gilles De Mey Date: Tue, 23 Jan 2024 15:04:12 +0100 Subject: [PATCH] Alerting: Detail v2 part 2 (#80577) --- .betterer.results | 3 - packages/grafana-data/src/types/icon.ts | 1 + .../features/alerting/unified/RuleViewer.tsx | 15 +- .../alerting/unified/components/Label.tsx | 3 +- .../components/rule-viewer/tabs/Details.tsx | 4 +- .../components/rule-viewer/v2/Actions.tsx | 113 ++++++++++++ .../components/rule-viewer/v2/RuleContext.tsx | 33 ++++ .../rule-viewer/v2/RuleViewer.v2.test.tsx | 169 ++++++++++++++++++ .../rule-viewer/v2/RuleViewer.v2.tsx | 134 +++----------- .../rule-viewer/v2/__mocks__/server.ts | 58 ++++++ .../unified/components/rules/CloneRule.tsx | 36 ++-- 11 files changed, 439 insertions(+), 130 deletions(-) create mode 100644 public/app/features/alerting/unified/components/rule-viewer/v2/Actions.tsx create mode 100644 public/app/features/alerting/unified/components/rule-viewer/v2/RuleContext.tsx create mode 100644 public/app/features/alerting/unified/components/rule-viewer/v2/RuleViewer.v2.test.tsx create mode 100644 public/app/features/alerting/unified/components/rule-viewer/v2/__mocks__/server.ts diff --git a/.betterer.results b/.betterer.results index 0e057228ff1..4bb4a77a5b9 100644 --- a/.betterer.results +++ b/.betterer.results @@ -1998,9 +1998,6 @@ exports[`better eslint`] = { "public/app/features/alerting/unified/components/rules/AlertInstanceStateFilter.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"] ], - "public/app/features/alerting/unified/components/rules/CloneRule.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"] - ], "public/app/features/alerting/unified/components/rules/CloudRules.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"], [0, 0, 0, "Styles should be written using objects.", "1"], diff --git a/packages/grafana-data/src/types/icon.ts b/packages/grafana-data/src/types/icon.ts index c1889cf1b6f..9aad23aea6e 100644 --- a/packages/grafana-data/src/types/icon.ts +++ b/packages/grafana-data/src/types/icon.ts @@ -101,6 +101,7 @@ export const availableIconsIndex = { 'file-blank': true, 'file-copy-alt': true, 'file-download': true, + 'file-edit-alt': true, 'file-landscape-alt': true, filter: true, flip: true, diff --git a/public/app/features/alerting/unified/RuleViewer.tsx b/public/app/features/alerting/unified/RuleViewer.tsx index d81bcd3fedf..772c0936dff 100644 --- a/public/app/features/alerting/unified/RuleViewer.tsx +++ b/public/app/features/alerting/unified/RuleViewer.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from 'react'; +import React from 'react'; import { NavModelItem } from '@grafana/data'; import { config } from '@grafana/runtime'; @@ -7,6 +7,7 @@ import { SafeDynamicImport } from 'app/core/components/DynamicImports/SafeDynami import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; import { AlertingPageWrapper } from './components/AlertingPageWrapper'; +import { AlertRuleProvider } from './components/rule-viewer/v2/RuleContext'; import { useCombinedRule } from './hooks/useCombinedRule'; import { getRuleIdFromPathname, parse as parseRuleId } from './utils/rule-id'; @@ -33,7 +34,10 @@ const RuleViewerV1Wrapper = (props: RuleViewerProps) => { const id = getRuleIdFromPathname(props.match.params); - const identifier = useMemo(() => { + + // we convert the stringified ID to a rule identifier object which contains additional + // type and source information + const identifier = React.useMemo(() => { if (!id) { throw new Error('Rule ID is required'); } @@ -41,6 +45,7 @@ const RuleViewerV2Wrapper = (props: RuleViewerProps) => { return parseRuleId(id, true); }, [id]); + // we then fetch the rule from the correct API endpoint(s) const { loading, error, result: rule } = useCombinedRule({ ruleIdentifier: identifier }); // TODO improve error handling here @@ -61,7 +66,11 @@ const RuleViewerV2Wrapper = (props: RuleViewerProps) => { } if (rule) { - return ; + return ( + + + + ); } return null; diff --git a/public/app/features/alerting/unified/components/Label.tsx b/public/app/features/alerting/unified/components/Label.tsx index 6c3788d77ab..37177f3801c 100644 --- a/public/app/features/alerting/unified/components/Label.tsx +++ b/public/app/features/alerting/unified/components/Label.tsx @@ -18,9 +18,10 @@ interface Props { // TODO allow customization with color prop const Label = ({ label, value, icon, color, size = 'md' }: Props) => { const styles = useStyles2(getStyles, color, size); + const ariaLabel = `${label}: ${value}`; return ( -
+
diff --git a/public/app/features/alerting/unified/components/rule-viewer/tabs/Details.tsx b/public/app/features/alerting/unified/components/rule-viewer/tabs/Details.tsx index 1c0da4966ad..6ff99b02903 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/tabs/Details.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/tabs/Details.tsx @@ -50,6 +50,8 @@ const Details = ({ rule }: DetailsProps) => { ? rule.annotations ?? [] : undefined; + const hasEvaluationDuration = Number.isFinite(evaluationDuration); + return (
@@ -74,7 +76,7 @@ const Details = ({ rule }: DetailsProps) => { {/* evaluation duration and pending period */} - {evaluationDuration && ( + {hasEvaluationDuration && ( <> Last evaluation {evaluationTimestamp && evaluationDuration && ( diff --git a/public/app/features/alerting/unified/components/rule-viewer/v2/Actions.tsx b/public/app/features/alerting/unified/components/rule-viewer/v2/Actions.tsx new file mode 100644 index 00000000000..dad6cc9c6b6 --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-viewer/v2/Actions.tsx @@ -0,0 +1,113 @@ +import React from 'react'; + +import { AppEvents } from '@grafana/data'; +import { Dropdown, LinkButton, Menu } from '@grafana/ui'; +import appEvents from 'app/core/app_events'; +import { CombinedRule, RuleIdentifier } from 'app/types/unified-alerting'; + +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 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(); + + // 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 && , + + {canSilence && ( + + )} + {shouldShowDeclareIncidentButton && } + {canDuplicate && handleDuplicateRule(identifier)} />} + + copyToClipboard(shareUrl)} /> + {canExport && ( + ]} + /> + )} + {canDelete && ( + <> + + handleDelete(rule)} /> + + )} + + } + > + + , + ]; +}; + +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 ; +}; + +const EditButton = ({ identifier }: PropsWithIdentifier) => { + const returnTo = location.pathname + location.search; + const ruleIdentifier = ruleId.stringifyIdentifier(identifier); + const editURL = createUrl(`/alerting/${encodeURIComponent(ruleIdentifier)}/edit`, { returnTo }); + + return ( + + Edit + + ); +}; diff --git a/public/app/features/alerting/unified/components/rule-viewer/v2/RuleContext.tsx b/public/app/features/alerting/unified/components/rule-viewer/v2/RuleContext.tsx new file mode 100644 index 00000000000..7a191a0f721 --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-viewer/v2/RuleContext.tsx @@ -0,0 +1,33 @@ +import * as React from 'react'; + +import { CombinedRule, RuleIdentifier } from 'app/types/unified-alerting'; + +interface Context { + rule: CombinedRule; + identifier: RuleIdentifier; +} + +const AlertRuleContext = React.createContext(undefined); + +type Props = Context & React.PropsWithChildren & {}; + +const AlertRuleProvider = ({ children, rule, identifier }: Props) => { + const value: Context = { + rule, + identifier, + }; + + return {children}; +}; + +const useAlertRule = () => { + const context = React.useContext(AlertRuleContext); + + if (context === undefined) { + throw new Error('useAlertRule must be used within a AlertRuleContext'); + } + + return context; +}; + +export { AlertRuleProvider, useAlertRule }; diff --git a/public/app/features/alerting/unified/components/rule-viewer/v2/RuleViewer.v2.test.tsx b/public/app/features/alerting/unified/components/rule-viewer/v2/RuleViewer.v2.test.tsx new file mode 100644 index 00000000000..2787c4ae2ee --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-viewer/v2/RuleViewer.v2.test.tsx @@ -0,0 +1,169 @@ +import { render, waitFor, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { TestProvider } from 'test/helpers/TestProvider'; +import { byText, byRole } from 'testing-library-selector'; + +import { setBackendSrv } from '@grafana/runtime'; +import { backendSrv } from 'app/core/services/backend_srv'; +import { AccessControlAction } from 'app/types'; +import { CombinedRule, RuleIdentifier } from 'app/types/unified-alerting'; + +import { getCloudRule, getGrafanaRule, grantUserPermissions } from '../../../mocks'; +import { Annotation } from '../../../utils/constants'; +import * as ruleId from '../../../utils/rule-id'; + +import { AlertRuleProvider } from './RuleContext'; +import RuleViewer from './RuleViewer.v2'; +import { createMockGrafanaServer } from './__mocks__/server'; + +// metadata and interactive elements +const ELEMENTS = { + loading: byText(/Loading rule/i), + metadata: { + summary: (text: string) => byText(text), + runbook: (url: string) => byRole('link', { name: url }), + dashboardAndPanel: byRole('link', { name: 'View panel' }), + evaluationInterval: (interval: string) => byText(`Every ${interval}`), + label: ([key, value]: [string, string]) => byRole('listitem', { name: `${key}: ${value}` }), + }, + actions: { + edit: byRole('link', { name: 'Edit' }), + more: { + button: byRole('button', { name: /More/i }), + actions: { + silence: byRole('link', { name: /Silence/i }), + declareIncident: byRole('menuitem', { name: /Declare incident/i }), + duplicate: byRole('menuitem', { name: /Duplicate/i }), + copyLink: byRole('menuitem', { name: /Copy link/i }), + export: byRole('menuitem', { name: /Export/i }), + delete: byRole('menuitem', { name: /Delete/i }), + }, + }, + }, +}; + +describe('RuleViewer', () => { + describe('Grafana managed alert rule', () => { + const server = createMockGrafanaServer(); + + const mockRule = getGrafanaRule( + { + name: 'Test alert', + annotations: { + [Annotation.dashboardUID]: 'dashboard-1', + [Annotation.panelID]: 'panel-1', + [Annotation.summary]: 'This is the summary for the rule', + [Annotation.runbookURL]: 'https://runbook.site/', + }, + labels: { + team: 'operations', + severity: 'low', + }, + group: { + name: 'my-group', + interval: '15m', + rules: [], + totals: { alerting: 1 }, + }, + }, + { uid: 'test1' } + ); + const mockRuleIdentifier = ruleId.fromCombinedRule('grafana', mockRule); + + beforeAll(() => { + grantUserPermissions([ + AccessControlAction.AlertingRuleCreate, + AccessControlAction.AlertingRuleRead, + AccessControlAction.AlertingRuleUpdate, + AccessControlAction.AlertingRuleDelete, + AccessControlAction.AlertingInstanceCreate, + ]); + setBackendSrv(backendSrv); + }); + + beforeEach(() => { + server.listen(); + }); + + afterAll(() => { + server.close(); + }); + + afterEach(() => { + server.resetHandlers(); + }); + + it('should render a Grafana managed alert rule', async () => { + await renderRuleViewer(mockRule, mockRuleIdentifier); + + // assert on basic info to be visible + expect(screen.getByText('Test alert')).toBeInTheDocument(); + expect(screen.getByText('Firing')).toBeInTheDocument(); + + // alert rule metadata + const ruleSummary = mockRule.annotations[Annotation.summary]; + const runBookURL = mockRule.annotations[Annotation.runbookURL]; + const groupInterval = mockRule.group.interval; + const labels = mockRule.labels; + + expect(ELEMENTS.metadata.summary(ruleSummary).get()).toBeInTheDocument(); + expect(ELEMENTS.metadata.dashboardAndPanel.get()).toBeInTheDocument(); + expect(ELEMENTS.metadata.runbook(runBookURL).get()).toBeInTheDocument(); + expect(ELEMENTS.metadata.evaluationInterval(groupInterval!).get()).toBeInTheDocument(); + + for (const label in labels) { + expect(ELEMENTS.metadata.label([label, labels[label]]).get()).toBeInTheDocument(); + } + + // actions + await waitFor(() => { + expect(ELEMENTS.actions.edit.get()).toBeInTheDocument(); + expect(ELEMENTS.actions.more.button.get()).toBeInTheDocument(); + }); + + // check the "more actions" button + await userEvent.click(ELEMENTS.actions.more.button.get()); + const menuItems = Object.values(ELEMENTS.actions.more.actions); + for (const menuItem of menuItems) { + expect(menuItem.get()).toBeInTheDocument(); + } + }); + }); + + describe.skip('Data source managed alert rule', () => { + const mockRule = getCloudRule({ name: 'cloud test alert' }); + const mockRuleIdentifier = ruleId.fromCombinedRule('mimir-1', mockRule); + + beforeAll(() => { + grantUserPermissions([ + AccessControlAction.AlertingRuleExternalRead, + AccessControlAction.AlertingRuleExternalWrite, + ]); + }); + + it('should render a data source managed alert rule', () => { + renderRuleViewer(mockRule, mockRuleIdentifier); + + // assert on basic info to be vissible + expect(screen.getByText('Test alert')).toBeInTheDocument(); + expect(screen.getByText('Firing')).toBeInTheDocument(); + + expect(screen.getByText(mockRule.annotations[Annotation.summary])).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'View panel' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: mockRule.annotations[Annotation.runbookURL] })).toBeInTheDocument(); + expect(screen.getByText(`Every ${mockRule.group.interval}`)).toBeInTheDocument(); + }); + }); +}); + +const renderRuleViewer = async (rule: CombinedRule, identifier: RuleIdentifier) => { + render( + + + , + { wrapper: TestProvider } + ); + + await waitFor(() => expect(ELEMENTS.loading.query()).not.toBeInTheDocument()); +}; diff --git a/public/app/features/alerting/unified/components/rule-viewer/v2/RuleViewer.v2.tsx b/public/app/features/alerting/unified/components/rule-viewer/v2/RuleViewer.v2.tsx index f2006d2e19e..55d956e7b97 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/v2/RuleViewer.v2.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/v2/RuleViewer.v2.tsx @@ -1,47 +1,33 @@ import { isEmpty, truncate } from 'lodash'; -import React from 'react'; +import React, { useState } from 'react'; -import { AppEvents, NavModelItem, UrlQueryValue } from '@grafana/data'; -import { Alert, Button, Dropdown, LinkButton, Menu, Stack, TabContent, Text, TextLink } from '@grafana/ui'; +import { NavModelItem, UrlQueryValue } from '@grafana/data'; +import { Alert, Button, LinkButton, Stack, TabContent, Text, TextLink } from '@grafana/ui'; import { PageInfoItem } from 'app/core/components/Page/types'; -import { appEvents } from 'app/core/core'; import { useQueryParams } from 'app/core/hooks/useQueryParams'; import { CombinedRule, RuleIdentifier } from 'app/types/unified-alerting'; import { PromAlertingRuleState } from 'app/types/unified-alerting-dto'; import { defaultPageNav } from '../../../RuleViewer'; -import { AlertRuleAction, useAlertRuleAbility } from '../../../hooks/useAbilities'; import { Annotation } from '../../../utils/constants'; -import { - createShareLink, - isLocalDevEnv, - isOpenSourceEdition, - makeDashboardLink, - makePanelLink, - makeRuleBasedSilenceLink, -} from '../../../utils/misc'; -import * as ruleId from '../../../utils/rule-id'; +import { makeDashboardLink, makePanelLink } from '../../../utils/misc'; import { isAlertingRule, isFederatedRuleGroup, isGrafanaRulerRule } from '../../../utils/rules'; import { createUrl } from '../../../utils/url'; import { AlertLabels } from '../../AlertLabels'; import { AlertStateDot } from '../../AlertStateDot'; import { AlertingPageWrapper } from '../../AlertingPageWrapper'; -import MoreButton from '../../MoreButton'; import { ProvisionedResource, ProvisioningAlert } from '../../Provisioning'; -import { DeclareIncidentMenuItem } from '../../bridges/DeclareIncidentButton'; import { decodeGrafanaNamespace } from '../../expressions/util'; +import { RedirectToCloneRule } from '../../rules/CloneRule'; import { Details } from '../tabs/Details'; import { History } from '../tabs/History'; import { InstancesList } from '../tabs/Instances'; import { QueryResults } from '../tabs/Query'; import { Routing } from '../tabs/Routing'; +import { useAlertRulePageActions } from './Actions'; import { useDeleteModal } from './DeleteModal'; - -type RuleViewerProps = { - rule: CombinedRule; - identifier: RuleIdentifier; -}; +import { useAlertRule } from './RuleContext'; enum ActiveTab { Query = 'query', @@ -51,24 +37,20 @@ enum ActiveTab { Details = 'details', } -const RuleViewer = ({ rule, identifier }: RuleViewerProps) => { +const RuleViewer = () => { + const { rule } = useAlertRule(); const { pageNav, activeTab } = usePageNav(rule); + + // this will be used to track if we are in the process of cloning a rule + // we want to be able to show a modal if the rule has been provisioned explain the limitations + // of duplicating provisioned alert rules + const [duplicateRuleIdentifier, setDuplicateRuleIdentifier] = useState(); + const [deleteModal, showDeleteModal] = useDeleteModal(); - - 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; + const actions = useAlertRulePageActions({ + handleDuplicateRule: setDuplicateRuleIdentifier, + handleDelete: showDeleteModal, + }); const promRule = rule.promRule; @@ -77,20 +59,6 @@ const RuleViewer = ({ rule, identifier }: RuleViewerProps) => { const isFederatedRule = isFederatedRuleGroup(rule.group); const isProvisioned = isGrafanaRulerRule(rule.rulerRule) && Boolean(rule.rulerRule.grafana_alert.provenance); - /** - * 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); - - const copyShareUrl = () => { - if (navigator.clipboard) { - navigator.clipboard.writeText(shareUrl); - appEvents.emit(AppEvents.alertSuccess, ['URL copied to clipboard']); - } - }; - return ( { renderTitle={(title) => { return ; }} - actions={[ - canEdit && <EditButton key="edit-action" identifier={identifier} />, - <Dropdown - key="more-actions" - overlay={ - <Menu> - {canSilence && ( - <Menu.Item - label="Silence" - icon="bell-slash" - url={makeRuleBasedSilenceLink(identifier.ruleSourceName, rule)} - /> - )} - {shouldShowDeclareIncidentButton && <DeclareIncidentMenuItem title={rule.name} url={''} />} - {canDuplicate && <Menu.Item label="Duplicate" icon="copy" />} - <Menu.Divider /> - <Menu.Item label="Copy link" icon="share-alt" onClick={copyShareUrl} /> - {canExport && ( - <Menu.Item - label="Export" - icon="download-alt" - childItems={[ - <Menu.Item key="no-modifications" label="Without modifications" icon="file-blank" />, - <Menu.Item key="with-modifications" label="With modifications" icon="file-alt" />, - ]} - /> - )} - {canDelete && ( - <> - <Menu.Divider /> - <Menu.Item label="Delete" icon="trash-alt" destructive onClick={() => showDeleteModal(rule)} /> - </> - )} - </Menu> - } - > - <MoreButton size="md" /> - </Dropdown>, - ]} + actions={actions} info={createMetadata(rule)} > <Stack direction="column" gap={2}> @@ -168,26 +98,18 @@ const RuleViewer = ({ rule, identifier }: RuleViewerProps) => { </Stack> </Stack> {deleteModal} + {duplicateRuleIdentifier && ( + <RedirectToCloneRule + redirectTo={true} + identifier={duplicateRuleIdentifier} + isProvisioned={isProvisioned} + onDismiss={() => setDuplicateRuleIdentifier(undefined)} + /> + )} </AlertingPageWrapper> ); }; -interface EditButtonProps { - identifier: RuleIdentifier; -} - -export const EditButton = ({ identifier }: EditButtonProps) => { - 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> - ); -}; - const createMetadata = (rule: CombinedRule): PageInfoItem[] => { const { labels, annotations, group } = rule; const metadata: PageInfoItem[] = []; diff --git a/public/app/features/alerting/unified/components/rule-viewer/v2/__mocks__/server.ts b/public/app/features/alerting/unified/components/rule-viewer/v2/__mocks__/server.ts new file mode 100644 index 00000000000..a9ec31748fe --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-viewer/v2/__mocks__/server.ts @@ -0,0 +1,58 @@ +import { rest } from 'msw'; +import { SetupServer, setupServer } from 'msw/node'; + +import 'whatwg-fetch'; +import { AlertmanagersChoiceResponse } from 'app/features/alerting/unified/api/alertmanagerApi'; +import { mockAlertmanagerChoiceResponse } from 'app/features/alerting/unified/mocks/alertmanagerApi'; +import { AlertmanagerChoice } from 'app/plugins/datasource/alertmanager/types'; +import { AccessControlAction } from 'app/types'; + +const alertmanagerChoiceMockedResponse: AlertmanagersChoiceResponse = { + alertmanagersChoice: AlertmanagerChoice.Internal, + numExternalAlertmanagers: 0, +}; + +const folderAccess = { + [AccessControlAction.AlertingRuleCreate]: true, + [AccessControlAction.AlertingRuleRead]: true, + [AccessControlAction.AlertingRuleUpdate]: true, + [AccessControlAction.AlertingRuleDelete]: true, +}; + +export function createMockGrafanaServer() { + const server = setupServer(); + + mockFolderAccess(server, folderAccess); + mockAlertmanagerChoiceResponse(server, alertmanagerChoiceMockedResponse); + mockGrafanaIncidentPluginSettings(server); + + return server; +} + +// this endpoint is used to determine of we have edit / delete permissions for the Grafana managed alert rule +// a user must alsso have permissions for the folder (namespace) in which the alert rule is stored +function mockFolderAccess(server: SetupServer, accessControl: Partial<Record<AccessControlAction, boolean>>) { + server.use( + rest.get('/api/folders/:uid', (req, res, ctx) => { + const uid = req.params.uid; + + return res( + ctx.json({ + title: 'My Folder', + uid, + accessControl, + }) + ); + }) + ); + + return server; +} + +function mockGrafanaIncidentPluginSettings(server: SetupServer) { + server.use( + rest.get('/api/plugins/grafana-incident-app/settings', (_, res, ctx) => { + return res(ctx.status(200)); + }) + ); +} diff --git a/public/app/features/alerting/unified/components/rules/CloneRule.tsx b/public/app/features/alerting/unified/components/rules/CloneRule.tsx index c422796823c..d8cd6af4e88 100644 --- a/public/app/features/alerting/unified/components/rules/CloneRule.tsx +++ b/public/app/features/alerting/unified/components/rules/CloneRule.tsx @@ -1,9 +1,7 @@ -import { css } from '@emotion/css'; import React, { useState } from 'react'; -import { Redirect } from 'react-router-dom'; +import { Redirect, useLocation } from 'react-router-dom'; -import { GrafanaTheme2 } from '@grafana/data'; -import { Button, ConfirmModal, useStyles2 } from '@grafana/ui'; +import { Button, ConfirmModal } from '@grafana/ui'; import { RuleIdentifier } from 'app/types/unified-alerting'; import * as ruleId from '../../utils/rule-id'; @@ -11,19 +9,31 @@ import * as ruleId from '../../utils/rule-id'; interface ConfirmCloneRuleModalProps { identifier: RuleIdentifier; isProvisioned: boolean; + redirectTo?: boolean; onDismiss: () => void; } -export function RedirectToCloneRule({ identifier, isProvisioned, onDismiss }: ConfirmCloneRuleModalProps) { - const styles = useStyles2(getStyles); - +export function RedirectToCloneRule({ + identifier, + isProvisioned, + redirectTo = false, + onDismiss, +}: ConfirmCloneRuleModalProps) { // For provisioned rules an additional confirmation step is required // Users have to be aware that the cloned rule will NOT be marked as provisioned + const location = useLocation(); const [stage, setStage] = useState<'redirect' | 'confirm'>(isProvisioned ? 'confirm' : 'redirect'); if (stage === 'redirect') { - const cloneUrl = `/alerting/new?copyFrom=${encodeURIComponent(ruleId.stringifyIdentifier(identifier))}`; - return <Redirect to={cloneUrl} push />; + const copyFrom = ruleId.stringifyIdentifier(identifier); + const returnTo = location.pathname + location.search; + + const queryParams = new URLSearchParams({ + copyFrom, + returnTo: redirectTo ? returnTo : '', + }); + + return <Redirect to={`/alerting/new?` + queryParams.toString()} push />; } return ( @@ -33,7 +43,7 @@ export function RedirectToCloneRule({ identifier, isProvisioned, onDismiss }: Co body={ <div> <p> - The new rule will <span className={styles.bold}>NOT</span> be marked as a provisioned rule. + The new rule will <strong>not</strong> be marked as a provisioned rule. </p> <p> You will need to set a new evaluation group for the copied rule because the original one has been @@ -87,9 +97,3 @@ export const CloneRuleButton = React.forwardRef<HTMLButtonElement, CloneRuleButt ); CloneRuleButton.displayName = 'CloneRuleButton'; - -const getStyles = (theme: GrafanaTheme2) => ({ - bold: css` - font-weight: ${theme.typography.fontWeightBold}; - `, -});