mirror of https://github.com/grafana/grafana.git
				
				
				
			Alerting: Detail v2 part 2 (#80577)
This commit is contained in:
		
							parent
							
								
									1a794e8822
								
							
						
					
					
						commit
						d84d0c8889
					
				|  | @ -1998,9 +1998,6 @@ exports[`better eslint`] = { | ||||||
|     "public/app/features/alerting/unified/components/rules/AlertInstanceStateFilter.tsx:5381": [ |     "public/app/features/alerting/unified/components/rules/AlertInstanceStateFilter.tsx:5381": [ | ||||||
|       [0, 0, 0, "Styles should be written using objects.", "0"] |       [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": [ |     "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.", "0"], | ||||||
|       [0, 0, 0, "Styles should be written using objects.", "1"], |       [0, 0, 0, "Styles should be written using objects.", "1"], | ||||||
|  |  | ||||||
|  | @ -101,6 +101,7 @@ export const availableIconsIndex = { | ||||||
|   'file-blank': true, |   'file-blank': true, | ||||||
|   'file-copy-alt': true, |   'file-copy-alt': true, | ||||||
|   'file-download': true, |   'file-download': true, | ||||||
|  |   'file-edit-alt': true, | ||||||
|   'file-landscape-alt': true, |   'file-landscape-alt': true, | ||||||
|   filter: true, |   filter: true, | ||||||
|   flip: true, |   flip: true, | ||||||
|  |  | ||||||
|  | @ -1,4 +1,4 @@ | ||||||
| import React, { useMemo } from 'react'; | import React from 'react'; | ||||||
| 
 | 
 | ||||||
| import { NavModelItem } from '@grafana/data'; | import { NavModelItem } from '@grafana/data'; | ||||||
| import { config } from '@grafana/runtime'; | 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 { GrafanaRouteComponentProps } from 'app/core/navigation/types'; | ||||||
| 
 | 
 | ||||||
| import { AlertingPageWrapper } from './components/AlertingPageWrapper'; | import { AlertingPageWrapper } from './components/AlertingPageWrapper'; | ||||||
|  | import { AlertRuleProvider } from './components/rule-viewer/v2/RuleContext'; | ||||||
| import { useCombinedRule } from './hooks/useCombinedRule'; | import { useCombinedRule } from './hooks/useCombinedRule'; | ||||||
| import { getRuleIdFromPathname, parse as parseRuleId } from './utils/rule-id'; | import { getRuleIdFromPathname, parse as parseRuleId } from './utils/rule-id'; | ||||||
| 
 | 
 | ||||||
|  | @ -33,7 +34,10 @@ const RuleViewerV1Wrapper = (props: RuleViewerProps) => <DetailViewV1 {...props} | ||||||
| 
 | 
 | ||||||
| const RuleViewerV2Wrapper = (props: RuleViewerProps) => { | const RuleViewerV2Wrapper = (props: RuleViewerProps) => { | ||||||
|   const id = getRuleIdFromPathname(props.match.params); |   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) { |     if (!id) { | ||||||
|       throw new Error('Rule ID is required'); |       throw new Error('Rule ID is required'); | ||||||
|     } |     } | ||||||
|  | @ -41,6 +45,7 @@ const RuleViewerV2Wrapper = (props: RuleViewerProps) => { | ||||||
|     return parseRuleId(id, true); |     return parseRuleId(id, true); | ||||||
|   }, [id]); |   }, [id]); | ||||||
| 
 | 
 | ||||||
|  |   // we then fetch the rule from the correct API endpoint(s)
 | ||||||
|   const { loading, error, result: rule } = useCombinedRule({ ruleIdentifier: identifier }); |   const { loading, error, result: rule } = useCombinedRule({ ruleIdentifier: identifier }); | ||||||
| 
 | 
 | ||||||
|   // TODO improve error handling here
 |   // TODO improve error handling here
 | ||||||
|  | @ -61,7 +66,11 @@ const RuleViewerV2Wrapper = (props: RuleViewerProps) => { | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   if (rule) { |   if (rule) { | ||||||
|     return <DetailViewV2 rule={rule} identifier={identifier} />; |     return ( | ||||||
|  |       <AlertRuleProvider identifier={identifier} rule={rule}> | ||||||
|  |         <DetailViewV2 /> | ||||||
|  |       </AlertRuleProvider> | ||||||
|  |     ); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   return null; |   return null; | ||||||
|  |  | ||||||
|  | @ -18,9 +18,10 @@ interface Props { | ||||||
| // TODO allow customization with color prop
 | // TODO allow customization with color prop
 | ||||||
| const Label = ({ label, value, icon, color, size = 'md' }: Props) => { | const Label = ({ label, value, icon, color, size = 'md' }: Props) => { | ||||||
|   const styles = useStyles2(getStyles, color, size); |   const styles = useStyles2(getStyles, color, size); | ||||||
|  |   const ariaLabel = `${label}: ${value}`; | ||||||
| 
 | 
 | ||||||
|   return ( |   return ( | ||||||
|     <div className={styles.wrapper} role="listitem"> |     <div className={styles.wrapper} role="listitem" aria-label={ariaLabel}> | ||||||
|       <Stack direction="row" gap={0} alignItems="stretch"> |       <Stack direction="row" gap={0} alignItems="stretch"> | ||||||
|         <div className={styles.label}> |         <div className={styles.label}> | ||||||
|           <Stack direction="row" gap={0.5} alignItems="center"> |           <Stack direction="row" gap={0.5} alignItems="center"> | ||||||
|  |  | ||||||
|  | @ -50,6 +50,8 @@ const Details = ({ rule }: DetailsProps) => { | ||||||
|     ? rule.annotations ?? [] |     ? rule.annotations ?? [] | ||||||
|     : undefined; |     : undefined; | ||||||
| 
 | 
 | ||||||
|  |   const hasEvaluationDuration = Number.isFinite(evaluationDuration); | ||||||
|  | 
 | ||||||
|   return ( |   return ( | ||||||
|     <Stack direction="column" gap={3}> |     <Stack direction="column" gap={3}> | ||||||
|       <div className={styles.metadataWrapper}> |       <div className={styles.metadataWrapper}> | ||||||
|  | @ -74,7 +76,7 @@ const Details = ({ rule }: DetailsProps) => { | ||||||
| 
 | 
 | ||||||
|         {/* evaluation duration and pending period */} |         {/* evaluation duration and pending period */} | ||||||
|         <MetaText direction="column"> |         <MetaText direction="column"> | ||||||
|           {evaluationDuration && ( |           {hasEvaluationDuration && ( | ||||||
|             <> |             <> | ||||||
|               Last evaluation |               Last evaluation | ||||||
|               {evaluationTimestamp && evaluationDuration && ( |               {evaluationTimestamp && evaluationDuration && ( | ||||||
|  |  | ||||||
|  | @ -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 && <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" 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} />]} | ||||||
|  |             /> | ||||||
|  |           )} | ||||||
|  |           {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,33 @@ | ||||||
|  | import * as React from 'react'; | ||||||
|  | 
 | ||||||
|  | import { CombinedRule, RuleIdentifier } from 'app/types/unified-alerting'; | ||||||
|  | 
 | ||||||
|  | interface Context { | ||||||
|  |   rule: CombinedRule; | ||||||
|  |   identifier: RuleIdentifier; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | const AlertRuleContext = React.createContext<Context | undefined>(undefined); | ||||||
|  | 
 | ||||||
|  | type Props = Context & React.PropsWithChildren & {}; | ||||||
|  | 
 | ||||||
|  | const AlertRuleProvider = ({ children, rule, identifier }: Props) => { | ||||||
|  |   const value: Context = { | ||||||
|  |     rule, | ||||||
|  |     identifier, | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   return <AlertRuleContext.Provider value={value}>{children}</AlertRuleContext.Provider>; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | 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 }; | ||||||
|  | @ -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( | ||||||
|  |     <AlertRuleProvider identifier={identifier} rule={rule}> | ||||||
|  |       <RuleViewer /> | ||||||
|  |     </AlertRuleProvider>, | ||||||
|  |     { wrapper: TestProvider } | ||||||
|  |   ); | ||||||
|  | 
 | ||||||
|  |   await waitFor(() => expect(ELEMENTS.loading.query()).not.toBeInTheDocument()); | ||||||
|  | }; | ||||||
|  | @ -1,47 +1,33 @@ | ||||||
| import { isEmpty, truncate } from 'lodash'; | import { isEmpty, truncate } from 'lodash'; | ||||||
| import React from 'react'; | import React, { useState } from 'react'; | ||||||
| 
 | 
 | ||||||
| import { AppEvents, NavModelItem, UrlQueryValue } from '@grafana/data'; | import { NavModelItem, UrlQueryValue } from '@grafana/data'; | ||||||
| import { Alert, Button, Dropdown, LinkButton, Menu, Stack, TabContent, Text, TextLink } from '@grafana/ui'; | import { Alert, Button, LinkButton, Stack, TabContent, Text, TextLink } from '@grafana/ui'; | ||||||
| import { PageInfoItem } from 'app/core/components/Page/types'; | import { PageInfoItem } from 'app/core/components/Page/types'; | ||||||
| import { appEvents } from 'app/core/core'; |  | ||||||
| import { useQueryParams } from 'app/core/hooks/useQueryParams'; | import { useQueryParams } from 'app/core/hooks/useQueryParams'; | ||||||
| import { CombinedRule, RuleIdentifier } from 'app/types/unified-alerting'; | import { CombinedRule, RuleIdentifier } from 'app/types/unified-alerting'; | ||||||
| import { PromAlertingRuleState } from 'app/types/unified-alerting-dto'; | import { PromAlertingRuleState } from 'app/types/unified-alerting-dto'; | ||||||
| 
 | 
 | ||||||
| import { defaultPageNav } from '../../../RuleViewer'; | import { defaultPageNav } from '../../../RuleViewer'; | ||||||
| import { AlertRuleAction, useAlertRuleAbility } from '../../../hooks/useAbilities'; |  | ||||||
| import { Annotation } from '../../../utils/constants'; | import { Annotation } from '../../../utils/constants'; | ||||||
| import { | import { makeDashboardLink, makePanelLink } from '../../../utils/misc'; | ||||||
|   createShareLink, |  | ||||||
|   isLocalDevEnv, |  | ||||||
|   isOpenSourceEdition, |  | ||||||
|   makeDashboardLink, |  | ||||||
|   makePanelLink, |  | ||||||
|   makeRuleBasedSilenceLink, |  | ||||||
| } from '../../../utils/misc'; |  | ||||||
| import * as ruleId from '../../../utils/rule-id'; |  | ||||||
| import { isAlertingRule, isFederatedRuleGroup, isGrafanaRulerRule } from '../../../utils/rules'; | import { isAlertingRule, isFederatedRuleGroup, isGrafanaRulerRule } from '../../../utils/rules'; | ||||||
| import { createUrl } from '../../../utils/url'; | import { createUrl } from '../../../utils/url'; | ||||||
| import { AlertLabels } from '../../AlertLabels'; | import { AlertLabels } from '../../AlertLabels'; | ||||||
| import { AlertStateDot } from '../../AlertStateDot'; | import { AlertStateDot } from '../../AlertStateDot'; | ||||||
| import { AlertingPageWrapper } from '../../AlertingPageWrapper'; | import { AlertingPageWrapper } from '../../AlertingPageWrapper'; | ||||||
| import MoreButton from '../../MoreButton'; |  | ||||||
| import { ProvisionedResource, ProvisioningAlert } from '../../Provisioning'; | import { ProvisionedResource, ProvisioningAlert } from '../../Provisioning'; | ||||||
| import { DeclareIncidentMenuItem } from '../../bridges/DeclareIncidentButton'; |  | ||||||
| import { decodeGrafanaNamespace } from '../../expressions/util'; | import { decodeGrafanaNamespace } from '../../expressions/util'; | ||||||
|  | import { RedirectToCloneRule } from '../../rules/CloneRule'; | ||||||
| import { Details } from '../tabs/Details'; | import { Details } from '../tabs/Details'; | ||||||
| import { History } from '../tabs/History'; | import { History } from '../tabs/History'; | ||||||
| import { InstancesList } from '../tabs/Instances'; | import { InstancesList } from '../tabs/Instances'; | ||||||
| import { QueryResults } from '../tabs/Query'; | import { QueryResults } from '../tabs/Query'; | ||||||
| import { Routing } from '../tabs/Routing'; | import { Routing } from '../tabs/Routing'; | ||||||
| 
 | 
 | ||||||
|  | import { useAlertRulePageActions } from './Actions'; | ||||||
| import { useDeleteModal } from './DeleteModal'; | import { useDeleteModal } from './DeleteModal'; | ||||||
| 
 | import { useAlertRule } from './RuleContext'; | ||||||
| type RuleViewerProps = { |  | ||||||
|   rule: CombinedRule; |  | ||||||
|   identifier: RuleIdentifier; |  | ||||||
| }; |  | ||||||
| 
 | 
 | ||||||
| enum ActiveTab { | enum ActiveTab { | ||||||
|   Query = 'query', |   Query = 'query', | ||||||
|  | @ -51,24 +37,20 @@ enum ActiveTab { | ||||||
|   Details = 'details', |   Details = 'details', | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| const RuleViewer = ({ rule, identifier }: RuleViewerProps) => { | const RuleViewer = () => { | ||||||
|  |   const { rule } = useAlertRule(); | ||||||
|   const { pageNav, activeTab } = usePageNav(rule); |   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<RuleIdentifier>(); | ||||||
|  | 
 | ||||||
|   const [deleteModal, showDeleteModal] = useDeleteModal(); |   const [deleteModal, showDeleteModal] = useDeleteModal(); | ||||||
| 
 |   const actions = useAlertRulePageActions({ | ||||||
|   const [editSupported, editAllowed] = useAlertRuleAbility(rule, AlertRuleAction.Update); |     handleDuplicateRule: setDuplicateRuleIdentifier, | ||||||
|   const canEdit = editSupported && editAllowed; |     handleDelete: showDeleteModal, | ||||||
| 
 |   }); | ||||||
|   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 promRule = rule.promRule; |   const promRule = rule.promRule; | ||||||
| 
 | 
 | ||||||
|  | @ -77,20 +59,6 @@ const RuleViewer = ({ rule, identifier }: RuleViewerProps) => { | ||||||
|   const isFederatedRule = isFederatedRuleGroup(rule.group); |   const isFederatedRule = isFederatedRuleGroup(rule.group); | ||||||
|   const isProvisioned = isGrafanaRulerRule(rule.rulerRule) && Boolean(rule.rulerRule.grafana_alert.provenance); |   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 ( |   return ( | ||||||
|     <AlertingPageWrapper |     <AlertingPageWrapper | ||||||
|       pageNav={pageNav} |       pageNav={pageNav} | ||||||
|  | @ -99,45 +67,7 @@ const RuleViewer = ({ rule, identifier }: RuleViewerProps) => { | ||||||
|       renderTitle={(title) => { |       renderTitle={(title) => { | ||||||
|         return <Title name={title} state={isAlertType ? promRule.state : undefined} />; |         return <Title name={title} state={isAlertType ? promRule.state : undefined} />; | ||||||
|       }} |       }} | ||||||
|       actions={[ |       actions={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>, |  | ||||||
|       ]} |  | ||||||
|       info={createMetadata(rule)} |       info={createMetadata(rule)} | ||||||
|     > |     > | ||||||
|       <Stack direction="column" gap={2}> |       <Stack direction="column" gap={2}> | ||||||
|  | @ -168,26 +98,18 @@ const RuleViewer = ({ rule, identifier }: RuleViewerProps) => { | ||||||
|         </Stack> |         </Stack> | ||||||
|       </Stack> |       </Stack> | ||||||
|       {deleteModal} |       {deleteModal} | ||||||
|  |       {duplicateRuleIdentifier && ( | ||||||
|  |         <RedirectToCloneRule | ||||||
|  |           redirectTo={true} | ||||||
|  |           identifier={duplicateRuleIdentifier} | ||||||
|  |           isProvisioned={isProvisioned} | ||||||
|  |           onDismiss={() => setDuplicateRuleIdentifier(undefined)} | ||||||
|  |         /> | ||||||
|  |       )} | ||||||
|     </AlertingPageWrapper> |     </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 createMetadata = (rule: CombinedRule): PageInfoItem[] => { | ||||||
|   const { labels, annotations, group } = rule; |   const { labels, annotations, group } = rule; | ||||||
|   const metadata: PageInfoItem[] = []; |   const metadata: PageInfoItem[] = []; | ||||||
|  |  | ||||||
|  | @ -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)); | ||||||
|  |     }) | ||||||
|  |   ); | ||||||
|  | } | ||||||
|  | @ -1,9 +1,7 @@ | ||||||
| import { css } from '@emotion/css'; |  | ||||||
| import React, { useState } from 'react'; | 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 } from '@grafana/ui'; | ||||||
| import { Button, ConfirmModal, useStyles2 } from '@grafana/ui'; |  | ||||||
| import { RuleIdentifier } from 'app/types/unified-alerting'; | import { RuleIdentifier } from 'app/types/unified-alerting'; | ||||||
| 
 | 
 | ||||||
| import * as ruleId from '../../utils/rule-id'; | import * as ruleId from '../../utils/rule-id'; | ||||||
|  | @ -11,19 +9,31 @@ import * as ruleId from '../../utils/rule-id'; | ||||||
| interface ConfirmCloneRuleModalProps { | interface ConfirmCloneRuleModalProps { | ||||||
|   identifier: RuleIdentifier; |   identifier: RuleIdentifier; | ||||||
|   isProvisioned: boolean; |   isProvisioned: boolean; | ||||||
|  |   redirectTo?: boolean; | ||||||
|   onDismiss: () => void; |   onDismiss: () => void; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export function RedirectToCloneRule({ identifier, isProvisioned, onDismiss }: ConfirmCloneRuleModalProps) { | export function RedirectToCloneRule({ | ||||||
|   const styles = useStyles2(getStyles); |   identifier, | ||||||
| 
 |   isProvisioned, | ||||||
|  |   redirectTo = false, | ||||||
|  |   onDismiss, | ||||||
|  | }: ConfirmCloneRuleModalProps) { | ||||||
|   // For provisioned rules an additional confirmation step is required
 |   // For provisioned rules an additional confirmation step is required
 | ||||||
|   // Users have to be aware that the cloned rule will NOT be marked as provisioned
 |   // 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'); |   const [stage, setStage] = useState<'redirect' | 'confirm'>(isProvisioned ? 'confirm' : 'redirect'); | ||||||
| 
 | 
 | ||||||
|   if (stage === 'redirect') { |   if (stage === 'redirect') { | ||||||
|     const cloneUrl = `/alerting/new?copyFrom=${encodeURIComponent(ruleId.stringifyIdentifier(identifier))}`; |     const copyFrom = ruleId.stringifyIdentifier(identifier); | ||||||
|     return <Redirect to={cloneUrl} push />; |     const returnTo = location.pathname + location.search; | ||||||
|  | 
 | ||||||
|  |     const queryParams = new URLSearchParams({ | ||||||
|  |       copyFrom, | ||||||
|  |       returnTo: redirectTo ? returnTo : '', | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     return <Redirect to={`/alerting/new?` + queryParams.toString()} push />; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   return ( |   return ( | ||||||
|  | @ -33,7 +43,7 @@ export function RedirectToCloneRule({ identifier, isProvisioned, onDismiss }: Co | ||||||
|       body={ |       body={ | ||||||
|         <div> |         <div> | ||||||
|           <p> |           <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> | ||||||
|           <p> |           <p> | ||||||
|             You will need to set a new evaluation group for the copied rule because the original one has been |             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'; | CloneRuleButton.displayName = 'CloneRuleButton'; | ||||||
| 
 |  | ||||||
| const getStyles = (theme: GrafanaTheme2) => ({ |  | ||||||
|   bold: css` |  | ||||||
|     font-weight: ${theme.typography.fontWeightBold}; |  | ||||||
|   `,
 |  | ||||||
| }); |  | ||||||
|  |  | ||||||
		Loading…
	
		Reference in New Issue