From 687d973c6be3c5511743a64d73cf19e3a61a867d Mon Sep 17 00:00:00 2001 From: yacut Date: Wed, 14 Apr 2021 20:31:18 +0200 Subject: [PATCH] add incident actions to ilert plugin Signed-off-by: yacut --- plugins/ilert/README.md | 10 ++- plugins/ilert/src/api/client.ts | 60 ++++++++++++++---- plugins/ilert/src/api/types.ts | 12 +++- .../src/components/ILertCard/ILertCard.tsx | 3 +- .../Incident/IncidentActionsMenu.tsx | 54 +++++++++++++++- plugins/ilert/src/hooks/useIncidentActions.ts | 61 +++++++++++++++++++ plugins/ilert/src/hooks/useIncidents.ts | 18 +++++- plugins/ilert/src/types.ts | 16 +++++ 8 files changed, 206 insertions(+), 28 deletions(-) create mode 100644 plugins/ilert/src/hooks/useIncidentActions.ts diff --git a/plugins/ilert/README.md b/plugins/ilert/README.md index 55e41ba5bd..0265f4172b 100644 --- a/plugins/ilert/README.md +++ b/plugins/ilert/README.md @@ -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) && ( @@ -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 // ... } /> @@ -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 // ... @@ -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 diff --git a/plugins/ilert/src/api/client.ts b/plugins/ilert/src/api/client.ts index 259502eff0..ed79681320 100644 --- a/plugins/ilert/src/api/client.ts +++ b/plugins/ilert/src/api/client.ts @@ -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 { + 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 { const init = { method: 'PUT', @@ -222,6 +239,27 @@ export class ILertClient implements ILertApi { return response; } + async triggerIncidentAction( + incident: Incident, + action: IncidentAction, + ): Promise { + 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 { 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) { diff --git a/plugins/ilert/src/api/types.ts b/plugins/ilert/src/api/types.ts index 4c33455d86..b37ae73c93 100644 --- a/plugins/ilert/src/api/types.ts +++ b/plugins/ilert/src/api/types.ts @@ -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; fetchIncidentsCount(opts?: GetIncidentsCountOpts): Promise; fetchIncidentResponders(incident: Incident): Promise; + fetchIncidentActions(incident: Incident): Promise; acceptIncident(incident: Incident): Promise; resolveIncident(incident: Incident): Promise; assignIncident( @@ -60,6 +62,10 @@ export interface ILertApi { responder: IncidentResponder, ): Promise; createIncident(eventRequest: EventRequest): Promise; + triggerIncidentAction( + incident: Incident, + action: IncidentAction, + ): Promise; fetchUptimeMonitors(): Promise; pauseUptimeMonitor(uptimeMonitor: UptimeMonitor): Promise; @@ -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 diff --git a/plugins/ilert/src/components/ILertCard/ILertCard.tsx b/plugins/ilert/src/components/ILertCard/ILertCard.tsx index b376b319b5..9e2c93d3da 100644 --- a/plugins/ilert/src/components/ILertCard/ILertCard.tsx +++ b/plugins/ilert/src/components/ILertCard/ILertCard.tsx @@ -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, diff --git a/plugins/ilert/src/components/Incident/IncidentActionsMenu.tsx b/plugins/ilert/src/components/Incident/IncidentActionsMenu.tsx index 576f037c8c..0a1ae95ded 100644 --- a/plugins/ilert/src/components/Incident/IncidentActionsMenu.tsx +++ b/plugins/ilert/src/components/Incident/IncidentActionsMenu.tsx @@ -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) => { 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 ( + + + {triggeredBy ? `${a.name} (by ${triggeredBy})` : a.name} + + + ); + }); + return ( <> {incident.status === 'PENDING' ? ( @@ -129,6 +169,14 @@ export const IncidentActionsMenu = ({ ) : null} + {isLoading ? ( + + + + ) : ( + actions + )} + diff --git a/plugins/ilert/src/hooks/useIncidentActions.ts b/plugins/ilert/src/hooks/useIncidentActions.ts new file mode 100644 index 0000000000..d8dd08e17a --- /dev/null +++ b/plugins/ilert/src/hooks/useIncidentActions.ts @@ -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; +}; diff --git a/plugins/ilert/src/hooks/useIncidents.ts b/plugins/ilert/src/hooks/useIncidents.ts index a252dfb14f..2f3d12cf17 100644 --- a/plugins/ilert/src/hooks/useIncidents.ts +++ b/plugins/ilert/src/hooks/useIncidents.ts @@ -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 = () => { diff --git a/plugins/ilert/src/types.ts b/plugins/ilert/src/types.ts index dd9aab42d7..a209b99533 100644 --- a/plugins/ilert/src/types.ts +++ b/plugins/ilert/src/types.ts @@ -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; +}