mirror of https://github.com/grafana/grafana
Alerting: Detail v2 part 2 (#80577)
parent
1a794e8822
commit
d84d0c8889
@ -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()); |
||||
}; |
||||
@ -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)); |
||||
}) |
||||
); |
||||
} |
||||
Loading…
Reference in new issue