mirror of
https://github.com/grafana/grafana.git
synced 2024-11-26 19:00:54 -06:00
Alerting: Declare incident from a firing alert (#61178)
This commit is contained in:
parent
db6d0464e9
commit
909ec67c56
@ -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;
|
||||
|
@ -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>;
|
||||
|
||||
|
@ -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 };
|
@ -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();
|
||||
});
|
||||
});
|
@ -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 : '');
|
||||
}
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
@ -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' }));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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};
|
||||
`,
|
||||
});
|
||||
|
@ -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 () => {
|
||||
|
@ -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;
|
||||
|
Loading…
Reference in New Issue
Block a user