add incident actions to ilert plugin

Signed-off-by: yacut <roman.rogozhnikov@gmail.com>
This commit is contained in:
yacut
2021-04-14 20:31:18 +02:00
parent 2d3ee7572e
commit 687d973c6b
8 changed files with 206 additions and 28 deletions
+4 -6
View File
@@ -17,6 +17,9 @@ This plugin provides:
- A list of incidents
- A way to trigger a new incident
- A way to reassign/acknowledge/resolve an incident
- A way to trigger an incident action
- A way to trigger an immediate maintenance
- A way to disable/enable an alert source
- A list of uptime monitors
## Setup instructions
@@ -41,7 +44,6 @@ import {
isPluginApplicableToEntity as isILertAvailable,
EntityILertCard,
} from '@backstage/plugin-ilert';
// add to code
{
isILertAvailable(entity) && (
<Grid item md={6}>
@@ -58,10 +60,8 @@ import {
Modify your app routes in [`App.tsx`](https://github.com/backstage/backstage/blob/master/packages/app/src/App.tsx) to include the Router component exported from the plugin, for example:
```tsx
// At the top imports
import { ILertPage } from '@backstage/plugin-ilert';
// Inside App component
<Routes>
// ...
<Route path="/ilert" element={<ILertPage />} />
@@ -72,10 +72,8 @@ import { ILertPage } from '@backstage/plugin-ilert';
Modify your sidebar in [`Root.tsx`](https://github.com/backstage/backstage/blob/master/packages/app/src/components/Root/Root.tsx) to include the icon component exported from the plugin, for example:
```tsx
// At the top imports
import { ILertIcon } from '@backstage/plugin-ilert';
// Inside Sidebar component
<Sidebar>
// ...
<SidebarItem icon={ILertIcon} to="ilert" text="iLert" />
@@ -91,7 +89,7 @@ In `app-config.yaml`:
```yaml
ilert:
domain: https://my-org.ilert.com/
baseUrl: https://my-org.ilert.com/
```
## Providing the Authorization Header
+49 -11
View File
@@ -18,6 +18,7 @@ import {
AlertSource,
EscalationPolicy,
Incident,
IncidentAction,
IncidentResponder,
Schedule,
UptimeMonitor,
@@ -44,22 +45,22 @@ export class UnauthorizedError extends Error {}
export class ILertClient implements ILertApi {
private readonly discoveryApi: DiscoveryApi;
private readonly proxyPath: string;
private readonly domain: string;
private readonly baseUrl: string;
static fromConfig(configApi: ConfigApi, discoveryApi: DiscoveryApi) {
const domainUrl: string =
configApi.getOptionalString('domainUrl') ?? 'https://api.ilert.com';
const baseUrl: string =
configApi.getOptionalString('ilert.baseUrl') ?? 'https://app.ilert.com';
return new ILertClient({
discoveryApi: discoveryApi,
domain: domainUrl,
baseUrl,
proxyPath: configApi.getOptionalString('ilert.proxyPath'),
});
}
constructor(opts: Options) {
this.discoveryApi = opts.discoveryApi;
this.domain = opts.domain;
this.baseUrl = opts.baseUrl;
this.proxyPath = opts.proxyPath ?? DEFAULT_PROXY_PATH;
}
@@ -94,7 +95,7 @@ export class ILertClient implements ILertApi {
query.append('start-index', `${opts.startIndex}`);
}
if (opts && opts.alertSources) {
opts.alertSources.forEach((a: string | number) => {
opts.alertSources.forEach((a: number) => {
if (a) {
query.append('alert-source', `${a}`);
}
@@ -153,6 +154,22 @@ export class ILertClient implements ILertApi {
return response;
}
async fetchIncidentActions(incident: Incident): Promise<IncidentAction[]> {
const init = {
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
};
const response = await this.fetch(
`/api/v1/incidents/${incident.id}/actions`,
init,
);
return response;
}
async acceptIncident(incident: Incident): Promise<Incident> {
const init = {
method: 'PUT',
@@ -222,6 +239,27 @@ export class ILertClient implements ILertApi {
return response;
}
async triggerIncidentAction(
incident: Incident,
action: IncidentAction,
): Promise<void> {
const init = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({
webhookId: action.webhookId,
extensionId: action.extensionId,
type: action.type,
name: action.name,
}),
};
await this.fetch(`/api/v1/incidents/${incident.id}/actions`, init);
}
async createIncident(eventRequest: EventRequest): Promise<boolean> {
const init = {
method: 'POST',
@@ -461,26 +499,26 @@ export class ILertClient implements ILertApi {
}
getIncidentDetailsURL(incident: Incident): string {
return `${this.domain}/incident/view.jsf?id=${incident.id}`;
return `${this.baseUrl}/incident/view.jsf?id=${incident.id}`;
}
getAlertSourceDetailsURL(alertSource: AlertSource | null): string {
if (!alertSource) {
return '';
}
return `${this.domain}/source/view.jsf?id=${alertSource.id}`;
return `${this.baseUrl}/source/view.jsf?id=${alertSource.id}`;
}
getEscalationPolicyDetailsURL(escalationPolicy: EscalationPolicy): string {
return `${this.domain}/policy/view.jsf?id=${escalationPolicy.id}`;
return `${this.baseUrl}/policy/view.jsf?id=${escalationPolicy.id}`;
}
getUptimeMonitorDetailsURL(uptimeMonitor: UptimeMonitor): string {
return `${this.domain}/uptime/view.jsf?id=${uptimeMonitor.id}`;
return `${this.baseUrl}/uptime/view.jsf?id=${uptimeMonitor.id}`;
}
getScheduleDetailsURL(schedule: Schedule): string {
return `${this.domain}/schedule/view.jsf?id=${schedule.id}`;
return `${this.baseUrl}/schedule/view.jsf?id=${schedule.id}`;
}
getUserInitials(assignedTo: User | null) {
+9 -3
View File
@@ -23,6 +23,7 @@ import {
EscalationPolicy,
Schedule,
IncidentResponder,
IncidentAction,
} from '../types';
export type TableState = {
@@ -34,7 +35,7 @@ export type GetIncidentsOpts = {
maxResults?: number;
startIndex?: number;
states?: IncidentStatus[];
alertSources?: number[] | string[];
alertSources?: number[];
};
export type GetIncidentsCountOpts = {
@@ -53,6 +54,7 @@ export interface ILertApi {
fetchIncidents(opts?: GetIncidentsOpts): Promise<Incident[]>;
fetchIncidentsCount(opts?: GetIncidentsCountOpts): Promise<number>;
fetchIncidentResponders(incident: Incident): Promise<IncidentResponder[]>;
fetchIncidentActions(incident: Incident): Promise<IncidentAction[]>;
acceptIncident(incident: Incident): Promise<Incident>;
resolveIncident(incident: Incident): Promise<Incident>;
assignIncident(
@@ -60,6 +62,10 @@ export interface ILertApi {
responder: IncidentResponder,
): Promise<Incident>;
createIncident(eventRequest: EventRequest): Promise<boolean>;
triggerIncidentAction(
incident: Incident,
action: IncidentAction,
): Promise<void>;
fetchUptimeMonitors(): Promise<UptimeMonitor[]>;
pauseUptimeMonitor(uptimeMonitor: UptimeMonitor): Promise<UptimeMonitor>;
@@ -98,10 +104,10 @@ export type Options = {
discoveryApi: DiscoveryApi;
/**
* Domain used by users to access iLert web UI.
* URL used by users to access iLert web UI.
* Example: https://my-org.ilert.com/
*/
domain: string;
baseUrl: string;
/**
* Path to use for requests via the proxy, defaults to /ilert/api
@@ -56,7 +56,6 @@ export const ILertCard = () => {
{ alertSource, uptimeMonitor },
{ setAlertSource, refetchAlertSource },
] = useAlertSource(integrationKey);
const alertSourcesFilter = alertSource ? [alertSource.id] : [integrationKey];
const [
{ tableState, states, incidents, incidentsCount, isLoading, error },
{
@@ -67,7 +66,7 @@ export const ILertCard = () => {
refetchIncidents,
setIsLoading,
},
] = useIncidents(false, alertSourcesFilter);
] = useIncidents(false, true, alertSource);
const [
isNewIncidentModalOpened,
@@ -14,13 +14,14 @@
* limitations under the License.
*/
import React from 'react';
import { alertApiRef, useApi } from '@backstage/core';
import { alertApiRef, Progress, useApi } from '@backstage/core';
import { IconButton, Menu, MenuItem, Typography } from '@material-ui/core';
import Link from '@material-ui/core/Link';
import MoreVertIcon from '@material-ui/icons/MoreVert';
import { ilertApiRef } from '../../api';
import { Incident } from '../../types';
import { Incident, IncidentAction } from '../../types';
import { IncidentAssignModal } from './IncidentAssignModal';
import { useIncidentActions } from '../../hooks/useIncidentActions';
export const IncidentActionsMenu = ({
incident,
@@ -41,6 +42,11 @@ export const IncidentActionsMenu = ({
setIsAssignIncidentModalOpened,
] = React.useState(false);
const [{ incidentActions, isLoading }] = useIncidentActions(
incident,
Boolean(anchorEl),
);
const handleClick = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
};
@@ -84,6 +90,40 @@ export const IncidentActionsMenu = ({
setIsAssignIncidentModalOpened(true);
};
const handleTriggerAction = (action: IncidentAction) => async () => {
try {
handleCloseMenu();
setProcessing(true);
await ilertApi.triggerIncidentAction(incident, action);
alertApi.post({ message: 'Incident action triggered.' });
setProcessing(false);
} catch (err) {
setProcessing(false);
alertApi.post({ message: err, severity: 'error' });
}
};
const actions: React.ReactNode[] = incidentActions.map(a => {
const successTrigger = a.history
? a.history.find(h => h.success)
: undefined;
const triggeredBy =
successTrigger && successTrigger.actor
? `${successTrigger.actor.firstName} ${successTrigger.actor.lastName}`
: '';
return (
<MenuItem
key={a.webhookId}
onClick={handleTriggerAction(a)}
disabled={!!successTrigger}
>
<Typography variant="inherit" noWrap>
{triggeredBy ? `${a.name} (by ${triggeredBy})` : a.name}
</Typography>
</MenuItem>
);
});
return (
<>
<IconButton
@@ -102,7 +142,7 @@ export const IncidentActionsMenu = ({
open={Boolean(anchorEl)}
onClose={handleCloseMenu}
PaperProps={{
style: { maxHeight: 48 * 4.5 },
style: { maxHeight: 48 * 5.5 },
}}
>
{incident.status === 'PENDING' ? (
@@ -129,6 +169,14 @@ export const IncidentActionsMenu = ({
</MenuItem>
) : null}
{isLoading ? (
<MenuItem key="loading">
<Progress style={{ width: '100%' }} />
</MenuItem>
) : (
actions
)}
<MenuItem key="details" onClick={handleCloseMenu}>
<Typography variant="inherit" noWrap>
<Link href={ilertApi.getIncidentDetailsURL(incident)}>
@@ -0,0 +1,61 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { ilertApiRef, UnauthorizedError } from '../api';
import { useApi, errorApiRef } from '@backstage/core';
import { useAsyncRetry } from 'react-use';
import { Incident, IncidentAction } from '../types';
export const useIncidentActions = (
incident: Incident | null,
open: boolean,
) => {
const ilertApi = useApi(ilertApiRef);
const errorApi = useApi(errorApiRef);
const [incidentActionsList, setIncidentActionsList] = React.useState<
IncidentAction[]
>([]);
const [isLoading, setIsLoading] = React.useState(false);
const { error, retry } = useAsyncRetry(async () => {
try {
if (!incident || !open) {
return;
}
const data = await ilertApi.fetchIncidentActions(incident);
setIncidentActionsList(data);
} catch (e) {
if (!(e instanceof UnauthorizedError)) {
errorApi.post(e);
}
throw e;
}
}, [incident, open]);
return [
{
incidentActions: incidentActionsList,
error,
isLoading,
},
{
setIncidentActionsList,
setIsLoading,
retry,
},
] as const;
};
+15 -3
View File
@@ -22,11 +22,18 @@ import {
} from '../api';
import { useApi, errorApiRef } from '@backstage/core';
import { useAsyncRetry } from 'react-use';
import { ACCEPTED, PENDING, Incident, IncidentStatus } from '../types';
import {
ACCEPTED,
PENDING,
Incident,
IncidentStatus,
AlertSource,
} from '../types';
export const useIncidents = (
paging: boolean,
alertSources?: number[] | string[],
singleSource?: boolean,
alertSource?: AlertSource | null,
) => {
const ilertApi = useApi(ilertApiRef);
const errorApi = useApi(errorApiRef);
@@ -45,10 +52,13 @@ export const useIncidents = (
const fetchIncidentsCall = async () => {
try {
if (singleSource && !alertSource) {
return;
}
setIsLoading(true);
const opts: GetIncidentsOpts = {
states,
alertSources,
alertSources: alertSource ? [alertSource.id] : [],
};
if (paging) {
opts.maxResults = tableState.pageSize;
@@ -80,6 +90,8 @@ export const useIncidents = (
const fetchIncidents = useAsyncRetry(fetchIncidentsCall, [
tableState,
states,
singleSource,
alertSource,
]);
const refetchIncidents = () => {
+16
View File
@@ -310,3 +310,19 @@ export interface IncidentResponder {
name: string;
disabled: boolean;
}
export interface IncidentAction {
name: string;
type: string;
webhookId: string;
extensionId?: string;
history?: IncidentActionHistory[];
}
export interface IncidentActionHistory {
id: string;
webhookId: string;
incidentId: number;
actor: User;
success: boolean;
}