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": [ | ||||
|       [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"], | ||||
|  |  | |||
|  | @ -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, | ||||
|  |  | |||
|  | @ -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) => <DetailViewV1 {...props} | |||
| 
 | ||||
| const RuleViewerV2Wrapper = (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 <DetailViewV2 rule={rule} identifier={identifier} />; | ||||
|     return ( | ||||
|       <AlertRuleProvider identifier={identifier} rule={rule}> | ||||
|         <DetailViewV2 /> | ||||
|       </AlertRuleProvider> | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   return null; | ||||
|  |  | |||
|  | @ -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 ( | ||||
|     <div className={styles.wrapper} role="listitem"> | ||||
|     <div className={styles.wrapper} role="listitem" aria-label={ariaLabel}> | ||||
|       <Stack direction="row" gap={0} alignItems="stretch"> | ||||
|         <div className={styles.label}> | ||||
|           <Stack direction="row" gap={0.5} alignItems="center"> | ||||
|  |  | |||
|  | @ -50,6 +50,8 @@ const Details = ({ rule }: DetailsProps) => { | |||
|     ? rule.annotations ?? [] | ||||
|     : undefined; | ||||
| 
 | ||||
|   const hasEvaluationDuration = Number.isFinite(evaluationDuration); | ||||
| 
 | ||||
|   return ( | ||||
|     <Stack direction="column" gap={3}> | ||||
|       <div className={styles.metadataWrapper}> | ||||
|  | @ -74,7 +76,7 @@ const Details = ({ rule }: DetailsProps) => { | |||
| 
 | ||||
|         {/* evaluation duration and pending period */} | ||||
|         <MetaText direction="column"> | ||||
|           {evaluationDuration && ( | ||||
|           {hasEvaluationDuration && ( | ||||
|             <> | ||||
|               Last evaluation | ||||
|               {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 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<RuleIdentifier>(); | ||||
| 
 | ||||
|   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 ( | ||||
|     <AlertingPageWrapper | ||||
|       pageNav={pageNav} | ||||
|  | @ -99,45 +67,7 @@ const RuleViewer = ({ rule, identifier }: RuleViewerProps) => { | |||
|       renderTitle={(title) => { | ||||
|         return <Title name={title} state={isAlertType ? promRule.state : undefined} />; | ||||
|       }} | ||||
|       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[] = []; | ||||
|  |  | |||
|  | @ -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 { 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}; | ||||
|   `,
 | ||||
| }); | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue