diff --git a/public/app/features/alerting/unified/RuleList.test.tsx b/public/app/features/alerting/unified/RuleList.test.tsx index 81ea10fcbf9..534aa43628f 100644 --- a/public/app/features/alerting/unified/RuleList.test.tsx +++ b/public/app/features/alerting/unified/RuleList.test.tsx @@ -6,7 +6,8 @@ import { Provider } from 'react-redux'; import { Router } from 'react-router-dom'; import { byRole, byTestId, byText } from 'testing-library-selector'; -import { locationService, setDataSourceSrv, logInfo } from '@grafana/runtime'; +import { locationService, setDataSourceSrv, logInfo, setBackendSrv } from '@grafana/runtime'; +import { backendSrv } from 'app/core/services/backend_srv'; import { contextSrv } from 'app/core/services/context_srv'; import * as ruleActionButtons from 'app/features/alerting/unified/components/rules/RuleActionsButtons'; import * as actions from 'app/features/alerting/unified/state/actions'; @@ -133,6 +134,10 @@ const ui = { }, }; +beforeAll(() => { + setBackendSrv(backendSrv); +}); + describe('RuleList', () => { beforeEach(() => { contextSrv.isEditor = true; diff --git a/public/app/features/alerting/unified/RuleViewer.test.tsx b/public/app/features/alerting/unified/RuleViewer.test.tsx index a131e4a7d26..c57279d70ef 100644 --- a/public/app/features/alerting/unified/RuleViewer.test.tsx +++ b/public/app/features/alerting/unified/RuleViewer.test.tsx @@ -4,8 +4,9 @@ import { Provider } from 'react-redux'; import { Router } from 'react-router-dom'; import { byRole } from 'testing-library-selector'; -import { locationService } from '@grafana/runtime'; +import { locationService, setBackendSrv } from '@grafana/runtime'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; +import { backendSrv } from 'app/core/services/backend_srv'; import { contextSrv } from 'app/core/services/context_srv'; import { configureStore } from 'app/store/configureStore'; import { AccessControlAction } from 'app/types'; @@ -70,6 +71,10 @@ const mocks = { useIsRuleEditable: jest.mocked(useIsRuleEditable), }; +beforeAll(() => { + setBackendSrv(backendSrv); +}); + describe('RuleViewer', () => { let mockCombinedRule: jest.MockedFn; diff --git a/public/app/features/alerting/unified/components/PluginBridge.mock.ts b/public/app/features/alerting/unified/components/PluginBridge.mock.ts new file mode 100644 index 00000000000..92a461cdd10 --- /dev/null +++ b/public/app/features/alerting/unified/components/PluginBridge.mock.ts @@ -0,0 +1,21 @@ +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; + +// bit of setup to mock HTTP request responses +import 'whatwg-fetch'; +import { SupportedPlugin } from './PluginBridge'; + +export const NON_EXISTING_PLUGIN = '__does_not_exist__'; + +const server = setupServer( + rest.get(`/api/plugins/${NON_EXISTING_PLUGIN}/settings`, async (_req, res, ctx) => res(ctx.status(404))), + rest.get(`/api/plugins/${SupportedPlugin.Incident}/settings`, async (_req, res, ctx) => { + return res( + ctx.json({ + enabled: true, + }) + ); + }) +); + +export { server }; diff --git a/public/app/features/alerting/unified/components/PluginBridge.test.tsx b/public/app/features/alerting/unified/components/PluginBridge.test.tsx new file mode 100644 index 00000000000..70e83713a48 --- /dev/null +++ b/public/app/features/alerting/unified/components/PluginBridge.test.tsx @@ -0,0 +1,47 @@ +import { screen, render } from '@testing-library/react'; +import React from 'react'; + +import { setBackendSrv } from '@grafana/runtime'; +import { backendSrv } from 'app/core/services/backend_srv'; + +import { createBridgeURL, PluginBridge, SupportedPlugin } from './PluginBridge'; +import { server, NON_EXISTING_PLUGIN } from './PluginBridge.mock'; + +beforeAll(() => { + setBackendSrv(backendSrv); + server.listen({ onUnhandledRequest: 'error' }); +}); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +describe('createBridgeURL', () => { + it('should work with path', () => { + expect(createBridgeURL(SupportedPlugin.Incident, '/incidents/declare')).toBe( + '/a/grafana-incident-app/incidents/declare' + ); + }); + + it('should work with path and options', () => { + expect(createBridgeURL(SupportedPlugin.Incident, '/incidents/declare', { title: 'My Incident' })).toBe( + '/a/grafana-incident-app/incidents/declare?title=My+Incident' + ); + }); +}); + +describe('', () => { + it('should render notInstalled component', async () => { + render(plugin not installed} />); + expect(await screen.findByText('plugin not installed')).toBeInTheDocument(); + }); + + it('should render loading and installed component', async () => { + render( + Loading...}> + Plugin installed! + + ); + expect(await screen.findByText('Loading...')).toBeInTheDocument(); + expect(await screen.findByText('Plugin installed!')).toBeInTheDocument(); + expect(screen.queryByText('Loading...')).not.toBeInTheDocument(); + }); +}); diff --git a/public/app/features/alerting/unified/components/PluginBridge.tsx b/public/app/features/alerting/unified/components/PluginBridge.tsx new file mode 100644 index 00000000000..7e0bee0f956 --- /dev/null +++ b/public/app/features/alerting/unified/components/PluginBridge.tsx @@ -0,0 +1,67 @@ +import React, { FC, ReactElement } from 'react'; +import { useAsync } from 'react-use'; + +import { PluginMeta } from '@grafana/data'; +import { getPluginSettings } from 'app/features/plugins/pluginSettings'; + +export enum SupportedPlugin { + Incident = 'grafana-incident-app', + OnCall = 'grafana-oncall-app', + MachineLearning = 'grafana-ml-app', +} + +export type PluginID = SupportedPlugin | string; + +export interface PluginBridgeProps { + plugin: PluginID; + // shows an optional component when the plugin is not installed + notInstalledFallback?: ReactElement; + // shows an optional component when we're checking if the plugin is installed + loadingComponent?: ReactElement; +} + +interface PluginBridgeHookResponse { + loading: boolean; + installed?: boolean; + error?: Error; + settings?: PluginMeta<{}>; +} + +export const PluginBridge: FC = ({ children, plugin, loadingComponent, notInstalledFallback }) => { + const { loading, installed } = usePluginBridge(plugin); + + if (loading) { + return loadingComponent ?? null; + } + + if (!installed) { + return notInstalledFallback ?? null; + } + + return <>{children}; +}; + +export function usePluginBridge(plugin: PluginID): PluginBridgeHookResponse { + const { loading, error, value } = useAsync(() => getPluginSettings(plugin, { showErrorAlert: false })); + + const installed = value && !error && !loading; + const enabled = value?.enabled; + const isLoading = loading && !value; + + if (isLoading) { + return { loading: true }; + } + + if (!installed || !enabled) { + return { loading: false, installed: false }; + } + + return { loading, installed: true, settings: value }; +} + +export function createBridgeURL(plugin: PluginID, path?: string, options?: Record) { + const searchParams = new URLSearchParams(options).toString(); + const pluginPath = `/a/${plugin}${path}`; + + return pluginPath + (searchParams ? '?' + searchParams : ''); +} diff --git a/public/app/features/alerting/unified/components/bridges/DeclareIncidentButton.tsx b/public/app/features/alerting/unified/components/bridges/DeclareIncidentButton.tsx new file mode 100644 index 00000000000..3c15e0f81a4 --- /dev/null +++ b/public/app/features/alerting/unified/components/bridges/DeclareIncidentButton.tsx @@ -0,0 +1,40 @@ +import React, { FC } from 'react'; +import { useHistory } from 'react-router-dom'; + +import { Button, Tooltip } from '@grafana/ui'; + +import { createBridgeURL, usePluginBridge, SupportedPlugin } from '../PluginBridge'; + +interface Props { + title?: string; + severity?: 'minor' | 'major' | 'critical'; +} + +export const DeclareIncident: FC = ({ title = '', severity = '' }) => { + const history = useHistory(); + const bridgeURL = createBridgeURL(SupportedPlugin.Incident, '/incidents/declare', { title, severity }); + + const { loading, installed, settings } = usePluginBridge(SupportedPlugin.Incident); + + return ( + <> + {loading === true && ( + + )} + {installed === false && ( + + + + )} + {settings && ( + + )} + + ); +}; diff --git a/public/app/features/alerting/unified/components/rules/RuleDetails.test.tsx b/public/app/features/alerting/unified/components/rules/RuleDetails.test.tsx index 6bdc835d8ea..5a3d86547d7 100644 --- a/public/app/features/alerting/unified/components/rules/RuleDetails.test.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleDetails.test.tsx @@ -1,9 +1,11 @@ -import { render } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import React from 'react'; import { Provider } from 'react-redux'; import { MemoryRouter } from 'react-router-dom'; import { byRole } from 'testing-library-selector'; +import { setBackendSrv } from '@grafana/runtime'; +import { backendSrv } from 'app/core/services/backend_srv'; import { contextSrv } from 'app/core/services/context_srv'; import { configureStore } from 'app/store/configureStore'; import { AccessControlAction } from 'app/types'; @@ -30,7 +32,8 @@ const ui = { jest.spyOn(contextSrv, 'accessControlEnabled').mockReturnValue(true); -beforeEach(() => { +beforeAll(() => { + setBackendSrv(backendSrv); jest.clearAllMocks(); }); @@ -38,7 +41,7 @@ describe('RuleDetails RBAC', () => { describe('Grafana rules action buttons in details', () => { const grafanaRule = getGrafanaRule({ name: 'Grafana' }); - it('Should not render Edit button for users with the update permission', () => { + it('Should not render Edit button for users with the update permission', async () => { // Arrange mocks.useIsRuleEditable.mockReturnValue({ loading: false, isEditable: true }); @@ -47,9 +50,10 @@ describe('RuleDetails RBAC', () => { // Assert expect(ui.actionButtons.edit.query()).not.toBeInTheDocument(); + await waitFor(() => screen.queryByRole('button', { name: 'Declare incident' })); }); - it('Should not render Delete button for users with the delete permission', () => { + it('Should not render Delete button for users with the delete permission', async () => { // Arrange mocks.useIsRuleEditable.mockReturnValue({ loading: false, isRemovable: true }); @@ -58,9 +62,10 @@ describe('RuleDetails RBAC', () => { // Assert expect(ui.actionButtons.delete.query()).not.toBeInTheDocument(); + await waitFor(() => screen.queryByRole('button', { name: 'Declare incident' })); }); - it('Should not render Silence button for users wihout the instance create permission', () => { + it('Should not render Silence button for users wihout the instance create permission', async () => { // Arrange jest.spyOn(contextSrv, 'hasPermission').mockReturnValue(false); @@ -69,9 +74,10 @@ describe('RuleDetails RBAC', () => { // Assert expect(ui.actionButtons.silence.query()).not.toBeInTheDocument(); + await waitFor(() => screen.queryByRole('button', { name: 'Declare incident' })); }); - it('Should render Silence button for users with the instance create permissions', () => { + it('Should render Silence button for users with the instance create permissions', async () => { // Arrange jest .spyOn(contextSrv, 'hasPermission') @@ -81,13 +87,14 @@ describe('RuleDetails RBAC', () => { renderRuleDetails(grafanaRule); // Assert - expect(ui.actionButtons.silence.query()).toBeInTheDocument(); + expect(await ui.actionButtons.silence.find()).toBeInTheDocument(); + await waitFor(() => screen.queryByRole('button', { name: 'Declare incident' })); }); }); describe('Cloud rules action buttons', () => { const cloudRule = getCloudRule({ name: 'Cloud' }); - it('Should not render Edit button for users with the update permission', () => { + it('Should not render Edit button for users with the update permission', async () => { // Arrange mocks.useIsRuleEditable.mockReturnValue({ loading: false, isEditable: true }); @@ -96,9 +103,10 @@ describe('RuleDetails RBAC', () => { // Assert expect(ui.actionButtons.edit.query()).not.toBeInTheDocument(); + await waitFor(() => screen.queryByRole('button', { name: 'Declare incident' })); }); - it('Should not render Delete button for users with the delete permission', () => { + it('Should not render Delete button for users with the delete permission', async () => { // Arrange mocks.useIsRuleEditable.mockReturnValue({ loading: false, isRemovable: true }); @@ -107,6 +115,7 @@ describe('RuleDetails RBAC', () => { // Assert expect(ui.actionButtons.delete.query()).not.toBeInTheDocument(); + await waitFor(() => screen.queryByRole('button', { name: 'Declare incident' })); }); }); }); diff --git a/public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx b/public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx index 9913bbbbbba..1d6aa31ca0a 100644 --- a/public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx @@ -9,6 +9,7 @@ import { useAppNotification } from 'app/core/copy/appNotification'; import { contextSrv } from 'app/core/services/context_srv'; import { AccessControlAction, useDispatch } from 'app/types'; import { CombinedRule, RulesSource } from 'app/types/unified-alerting'; +import { PromAlertingRuleState } from 'app/types/unified-alerting-dto'; import { useIsRuleEditable } from '../../hooks/useIsRuleEditable'; import { useStateHistoryModal } from '../../hooks/useStateHistoryModal'; @@ -18,7 +19,8 @@ import { Annotation } from '../../utils/constants'; import { getRulesSourceName, isCloudRulesSource, isGrafanaRulesSource } from '../../utils/datasource'; import { createExploreLink, makeRuleBasedSilenceLink } from '../../utils/misc'; import * as ruleId from '../../utils/rule-id'; -import { isFederatedRuleGroup, isGrafanaRulerRule } from '../../utils/rules'; +import { isAlertingRule, isFederatedRuleGroup, isGrafanaRulerRule } from '../../utils/rules'; +import { DeclareIncident } from '../bridges/DeclareIncidentButton'; interface Props { rule: CombinedRule; @@ -74,6 +76,8 @@ export const RuleDetailsActionButtons: FC = ({ rule, rulesSource, isViewM const rulesSourceName = getRulesSourceName(rulesSource); const isProvisioned = isGrafanaRulerRule(rule.rulerRule) && Boolean(rule.rulerRule.grafana_alert.provenance); + const isFiringRule = isAlertingRule(rule.promRule) && rule.promRule.state === PromAlertingRuleState.Firing; + const { isEditable, isRemovable } = useIsRuleEditable(rulesSourceName, rulerRule); const returnTo = location.pathname + location.search; @@ -82,8 +86,7 @@ export const RuleDetailsActionButtons: FC = ({ rule, rulesSource, isViewM if (isCloudRulesSource(rulesSource) && hasExplorePermission && !isFederated) { buttons.push( = ({ rule, rulesSource, isViewM if (rule.annotations[Annotation.runbookURL]) { buttons.push( = ({ rule, rulesSource, isViewM if (dashboardUID) { buttons.push( = ({ rule, rulesSource, isViewM if (panelId) { buttons.push( = ({ rule, rulesSource, isViewM if (alertmanagerSourceName && contextSrv.hasAccess(AccessControlAction.AlertingInstanceCreate, contextSrv.isEditor)) { buttons.push( = ({ rule, rulesSource, isViewM if (alertId) { buttons.push( - {StateHistoryModal} @@ -170,6 +169,14 @@ export const RuleDetailsActionButtons: FC = ({ rule, rulesSource, isViewM ); } + if (isFiringRule) { + buttons.push( + + + + ); + } + if (isViewMode) { if (isEditable && rulerRule && !isFederated && !isProvisioned) { const sourceName = getRulesSourceName(rulesSource); @@ -188,7 +195,6 @@ export const RuleDetailsActionButtons: FC = ({ rule, rulesSource, isViewM onClipboardError={(copiedText) => { notifyApp.error('Error while copying URL', copiedText); }} - className={style.button} size="sm" getText={buildShareUrl} > @@ -197,7 +203,7 @@ export const RuleDetailsActionButtons: FC = ({ rule, rulesSource, isViewM ); rightButtons.push( - + Edit ); @@ -206,8 +212,7 @@ export const RuleDetailsActionButtons: FC = ({ rule, rulesSource, isViewM if (isRemovable && rulerRule && !isFederated && !isProvisioned) { rightButtons.push(