Alerting: Declare incident from a firing alert (#61178)

This commit is contained in:
Gilles De Mey 2023-01-11 12:52:20 +01:00 committed by GitHub
parent db6d0464e9
commit 909ec67c56
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 230 additions and 35 deletions

View File

@ -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;

View File

@ -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<typeof useCombinedRule>;

View File

@ -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 };

View File

@ -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('<PluginBridge />', () => {
it('should render notInstalled component', async () => {
render(<PluginBridge plugin={NON_EXISTING_PLUGIN} notInstalledFallback={<div>plugin not installed</div>} />);
expect(await screen.findByText('plugin not installed')).toBeInTheDocument();
});
it('should render loading and installed component', async () => {
render(
<PluginBridge plugin={SupportedPlugin.Incident} loadingComponent={<>Loading...</>}>
Plugin installed!
</PluginBridge>
);
expect(await screen.findByText('Loading...')).toBeInTheDocument();
expect(await screen.findByText('Plugin installed!')).toBeInTheDocument();
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
});
});

View File

@ -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<PluginBridgeProps> = ({ 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<string, string>) {
const searchParams = new URLSearchParams(options).toString();
const pluginPath = `/a/${plugin}${path}`;
return pluginPath + (searchParams ? '?' + searchParams : '');
}

View File

@ -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<Props> = ({ title = '', severity = '' }) => {
const history = useHistory();
const bridgeURL = createBridgeURL(SupportedPlugin.Incident, '/incidents/declare', { title, severity });
const { loading, installed, settings } = usePluginBridge(SupportedPlugin.Incident);
return (
<>
{loading === true && (
<Button size="sm" type="button" disabled>
Declare Incident
</Button>
)}
{installed === false && (
<Tooltip content={'Grafana Incident is not installed or is not configured correctly'}>
<Button size="sm" type="button" disabled>
Declare Incident
</Button>
</Tooltip>
)}
{settings && (
<Button size="sm" type="button" onClick={() => history.push(bridgeURL)}>
Declare Incident
</Button>
)}
</>
);
};

View File

@ -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' }));
});
});
});

View File

@ -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<Props> = ({ 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<Props> = ({ rule, rulesSource, isViewM
if (isCloudRulesSource(rulesSource) && hasExplorePermission && !isFederated) {
buttons.push(
<LinkButton
className={style.button}
size="xs"
size="sm"
key="explore"
variant="primary"
icon="chart-line"
@ -97,8 +100,7 @@ export const RuleDetailsActionButtons: FC<Props> = ({ rule, rulesSource, isViewM
if (rule.annotations[Annotation.runbookURL]) {
buttons.push(
<LinkButton
className={style.button}
size="xs"
size="sm"
key="runbook"
variant="primary"
icon="book"
@ -114,8 +116,7 @@ export const RuleDetailsActionButtons: FC<Props> = ({ rule, rulesSource, isViewM
if (dashboardUID) {
buttons.push(
<LinkButton
className={style.button}
size="xs"
size="sm"
key="dashboard"
variant="primary"
icon="apps"
@ -129,8 +130,7 @@ export const RuleDetailsActionButtons: FC<Props> = ({ rule, rulesSource, isViewM
if (panelId) {
buttons.push(
<LinkButton
className={style.button}
size="xs"
size="sm"
key="panel"
variant="primary"
icon="apps"
@ -147,8 +147,7 @@ export const RuleDetailsActionButtons: FC<Props> = ({ rule, rulesSource, isViewM
if (alertmanagerSourceName && contextSrv.hasAccess(AccessControlAction.AlertingInstanceCreate, contextSrv.isEditor)) {
buttons.push(
<LinkButton
className={style.button}
size="xs"
size="sm"
key="silence"
icon="bell-slash"
target="__blank"
@ -162,7 +161,7 @@ export const RuleDetailsActionButtons: FC<Props> = ({ rule, rulesSource, isViewM
if (alertId) {
buttons.push(
<Fragment key="history">
<Button className={style.button} size="xs" icon="history" onClick={() => showStateHistoryModal()}>
<Button size="sm" icon="history" onClick={() => showStateHistoryModal()}>
Show state history
</Button>
{StateHistoryModal}
@ -170,6 +169,14 @@ export const RuleDetailsActionButtons: FC<Props> = ({ rule, rulesSource, isViewM
);
}
if (isFiringRule) {
buttons.push(
<Fragment key="declare-incident">
<DeclareIncident title={rule.name} />
</Fragment>
);
}
if (isViewMode) {
if (isEditable && rulerRule && !isFederated && !isProvisioned) {
const sourceName = getRulesSourceName(rulesSource);
@ -188,7 +195,6 @@ export const RuleDetailsActionButtons: FC<Props> = ({ 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<Props> = ({ rule, rulesSource, isViewM
);
rightButtons.push(
<LinkButton className={style.button} size="xs" key="edit" variant="secondary" icon="pen" href={editURL}>
<LinkButton size="sm" key="edit" variant="secondary" icon="pen" href={editURL}>
Edit
</LinkButton>
);
@ -206,8 +212,7 @@ export const RuleDetailsActionButtons: FC<Props> = ({ rule, rulesSource, isViewM
if (isRemovable && rulerRule && !isFederated && !isProvisioned) {
rightButtons.push(
<Button
className={style.button}
size="xs"
size="sm"
type="button"
key="delete"
variant="secondary"
@ -253,8 +258,4 @@ export const getStyles = (theme: GrafanaTheme2) => ({
flex-wrap: wrap;
border-bottom: solid 1px ${theme.colors.border.medium};
`,
button: css`
height: 24px;
font-size: ${theme.typography.size.sm};
`,
});

View File

@ -29,7 +29,7 @@ describe('PluginSettings', () => {
// assert
expect(response).toEqual(testPluginResponse);
expect(getRequestSpy).toHaveBeenCalledTimes(1);
expect(getRequestSpy).toHaveBeenCalledWith('/api/plugins/test/settings');
expect(getRequestSpy).toHaveBeenCalledWith('/api/plugins/test/settings', undefined, undefined, undefined);
});
it('should fetch settings from cache when it has a hit', async () => {

View File

@ -1,5 +1,5 @@
import { PluginMeta } from '@grafana/data';
import { getBackendSrv } from '@grafana/runtime';
import { BackendSrvRequest, getBackendSrv } from '@grafana/runtime';
type PluginCache = {
[key: string]: PluginMeta;
@ -7,13 +7,13 @@ type PluginCache = {
const pluginInfoCache: PluginCache = {};
export function getPluginSettings(pluginId: string): Promise<PluginMeta> {
export function getPluginSettings(pluginId: string, options?: Partial<BackendSrvRequest>): Promise<PluginMeta> {
const v = pluginInfoCache[pluginId];
if (v) {
return Promise.resolve(v);
}
return getBackendSrv()
.get(`/api/plugins/${pluginId}/settings`)
.get(`/api/plugins/${pluginId}/settings`, undefined, undefined, options)
.then((settings: any) => {
pluginInfoCache[pluginId] = settings;
return settings;