add iLert plugin

Signed-off-by: yacut <roman.rogozhnikov@gmail.com>
This commit is contained in:
yacut
2021-04-14 17:29:23 +02:00
parent 93dc706eea
commit 2d3ee7572e
62 changed files with 5199 additions and 0 deletions
+1
View File
@@ -14,6 +14,7 @@
/plugins/search-* @backstage/techdocs-core
/plugins/techdocs @backstage/techdocs-core
/plugins/techdocs-backend @backstage/techdocs-core
/plugins/ilert @yacut
/packages/techdocs-common @backstage/techdocs-core
/.changeset/cost-insights-* @backstage/silver-lining
/.changeset/techdocs-* @backstage/techdocs-core
+13
View File
@@ -0,0 +1,13 @@
---
title: iLert
author: iLert
authorUrl: https://github.com/iLert
category: Monitoring
description: iLert is a platform for alerting, on-call management and uptime monitoring.
documentation: https://github.com/backstage/backstage/tree/master/plugins/ilert
iconUrl: https://avatars.githubusercontent.com/u/13230510?s=200&v=4
npmPackageName: '@backstage/plugin-ilert'
tags:
- monitoring
- errors
- alerting
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint')],
rules: {
quotes: ['error', 'single'],
},
};
+132
View File
@@ -0,0 +1,132 @@
# @backstage/plugin-ilert
## Introduction
[iLert](https://www.ilert.com) is a platform for alerting, on-call management and uptime monitoring. It helps teams to reduce response times to critical incidents by extending monitoring tools with reliable alerting, automatic escalations, on-call schedules and other features to support the incident response process, such as [informing stakeholders](https://docs.ilert.com/getting-started/stakeholder-engagement) or creating tickets in external incident management tools.
## Overview
This plugin displays iLert information about an entity such as if there are any active incidents, wo is on-call now and uptime monitor status.
There is also an easy way to trigger an incident directly to the person who is currently on-call.
This plugin provides:
- Information details about the persons on-call
- A way to override current person on-call
- A list of incidents
- A way to trigger a new incident
- A way to reassign/acknowledge/resolve an incident
- A list of uptime monitors
## Setup instructions
Install the plugin:
```bash
yarn add @backstage/plugin-ilert
```
Then make sure to export the plugin in your app's [`plugins.ts`](https://github.com/backstage/backstage/blob/master/packages/app/src/plugins.ts)
to enable the plugin:
```js
export { plugin as ILert } from '@backstage/plugin-ilert';
```
Add it to the `EntityPage.tsx`:
```ts
import {
isPluginApplicableToEntity as isILertAvailable,
EntityILertCard,
} from '@backstage/plugin-ilert';
// add to code
{
isILertAvailable(entity) && (
<Grid item md={6}>
<EntityILertCard />
</Grid>
);
}
```
> To force iLert card for each entity just add the `<EntityILertCard />` component, so an instruction card will appears if no integration key is set.
## Add iLert explorer to the App sidebar
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 />} />
// ...
</Routes>;
```
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" />
// ...
</Sidebar>;
```
## Client configuration
If you want to override the default URL for api calls and detail pages, you can add it to `app-config.yaml`.
In `app-config.yaml`:
```yaml
ilert:
domain: https://my-org.ilert.com/
```
## Providing the Authorization Header
In order to make the API calls, you need to provide a new proxy config which will redirect to the [iLert API](https://api.ilert.com/api-docs/) endpoint it needs an [Authorization Header](https://api.ilert.com/api-docs/#section/Authentication).
Add the proxy configuration in `app-config.yaml`
```yaml
proxy:
...
'/ilert':
target: https://api.ilert.com
allowedMethods: ['GET', 'POST', 'PUT']
allowedHeaders: ['Authorization']
headers:
Authorization:
$env: ILERT_AUTH_HEADER
```
Then start the backend passing the token as an environment variable:
```bash
$ ILERT_AUTH_HEADER='Basic <TOKEN>' yarn start
```
## Integration Key
The information displayed for each entity is based on the [alert source integration key](https://docs.ilert.com/integrations/backstage).
### Adding the integration key to the entity annotation
If you want to use this plugin for an entity, you need to label it with the below annotation:
```yml
annotations:
ilert.com/integration-key: [INTEGRATION_KEY]
```
+27
View File
@@ -0,0 +1,27 @@
/*
* 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 { createDevApp } from '@backstage/dev-utils';
import { ilertPlugin } from '../src/plugin';
import { IlertPage } from '../src';
createDevApp()
.registerPlugin(ilertPlugin)
.addPage({
element: <IlertPage />,
title: 'Root Page',
})
.render();
+54
View File
@@ -0,0 +1,54 @@
{
"name": "@backstage/plugin-ilert",
"version": "0.1.1",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"publishConfig": {
"access": "public",
"main": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"scripts": {
"build": "backstage-cli plugin:build",
"start": "backstage-cli plugin:serve",
"lint": "backstage-cli lint",
"test": "backstage-cli test",
"diff": "backstage-cli plugin:diff",
"prepack": "backstage-cli prepack",
"postpack": "backstage-cli postpack",
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/catalog-model": "^0.7.6",
"@backstage/core": "^0.7.4",
"@backstage/plugin-catalog-react": "^0.1.4",
"@backstage/theme": "^0.2.5",
"@date-io/date-fns": "1.x",
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"@material-ui/pickers": "^3.3.10",
"date-fns": "^2.20.2",
"moment": "^2.29.1",
"moment-timezone": "^0.5.33",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-use": "^15.3.3"
},
"devDependencies": {
"@backstage/cli": "^0.6.7",
"@backstage/dev-utils": "^0.1.13",
"@backstage/test-utils": "^0.1.10",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^11.2.5",
"@testing-library/user-event": "^12.0.7",
"@types/jest": "^26.0.7",
"@types/node": "^14.14.32",
"cross-fetch": "^3.0.6",
"msw": "^0.21.2"
},
"files": [
"dist"
]
}
+500
View File
@@ -0,0 +1,500 @@
/*
* 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 { ConfigApi, createApiRef, DiscoveryApi } from '@backstage/core';
import {
AlertSource,
EscalationPolicy,
Incident,
IncidentResponder,
Schedule,
UptimeMonitor,
User,
} from '../types';
import {
ILertApi,
GetIncidentsOpts,
GetIncidentsCountOpts,
Options,
EventRequest,
} from './types';
import moment from 'moment';
import momentTimezone from 'moment-timezone';
export const ilertApiRef = createApiRef<ILertApi>({
id: 'plugin.ilert.service',
description: 'Used to make requests towards iLert API',
});
const DEFAULT_PROXY_PATH = '/ilert';
export class UnauthorizedError extends Error {}
export class ILertClient implements ILertApi {
private readonly discoveryApi: DiscoveryApi;
private readonly proxyPath: string;
private readonly domain: string;
static fromConfig(configApi: ConfigApi, discoveryApi: DiscoveryApi) {
const domainUrl: string =
configApi.getOptionalString('domainUrl') ?? 'https://api.ilert.com';
return new ILertClient({
discoveryApi: discoveryApi,
domain: domainUrl,
proxyPath: configApi.getOptionalString('ilert.proxyPath'),
});
}
constructor(opts: Options) {
this.discoveryApi = opts.discoveryApi;
this.domain = opts.domain;
this.proxyPath = opts.proxyPath ?? DEFAULT_PROXY_PATH;
}
private async fetch<T = any>(input: string, init?: RequestInit): Promise<T> {
const apiUrl = await this.apiUrl();
const response = await fetch(`${apiUrl}${input}`, init);
if (response.status === 401) {
throw new UnauthorizedError('');
}
if (!response.ok) {
throw new Error(
`Request failed with ${response.status} ${response.statusText}`,
);
}
return await response.json();
}
async fetchIncidents(opts?: GetIncidentsOpts): Promise<Incident[]> {
const init = {
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
};
const query = new URLSearchParams();
if (opts && opts.maxResults) {
query.append('max-results', `${opts.maxResults}`);
}
if (opts && opts.startIndex) {
query.append('start-index', `${opts.startIndex}`);
}
if (opts && opts.alertSources) {
opts.alertSources.forEach((a: string | number) => {
if (a) {
query.append('alert-source', `${a}`);
}
});
}
if (opts && opts.states && Array.isArray(opts.states)) {
opts.states.forEach(state => {
query.append('state', state);
});
}
const response = await this.fetch(
`/api/v1/incidents?${query.toString()}`,
init,
);
return response;
}
async fetchIncidentsCount(opts?: GetIncidentsCountOpts): Promise<number> {
const init = {
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
};
const query = new URLSearchParams();
if (opts && opts.states && Array.isArray(opts.states)) {
opts.states.forEach(state => {
query.append('state', state);
});
}
const response = await this.fetch(
`/api/v1/incidents/count?${query.toString()}`,
init,
);
return response && response.count ? response.count : 0;
}
async fetchIncidentResponders(
incident: Incident,
): Promise<IncidentResponder[]> {
const init = {
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
};
const response = await this.fetch(
`/api/v1/incidents/${incident.id}/responders`,
init,
);
return response;
}
async acceptIncident(incident: Incident): Promise<Incident> {
const init = {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({ summary: 'Backstage — iLert plugin' }),
};
const response = await this.fetch(
`/api/v1/incidents/${incident.id}/accept`,
init,
);
return response;
}
async resolveIncident(incident: Incident): Promise<Incident> {
const init = {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({ summary: 'Backstage — iLert plugin' }),
};
const response = await this.fetch(
`/api/v1/incidents/${incident.id}/resolve`,
init,
);
return response;
}
async assignIncident(
incident: Incident,
responder: IncidentResponder,
): Promise<Incident> {
const init = {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
};
const query = new URLSearchParams();
switch (responder.group) {
case 'ESCALATION_POLICY':
query.append('policy-id', `${responder.id}`);
break;
case 'ON_CALL_SCHEDULE':
query.append('schedule-id', `${responder.id}`);
break;
default:
query.append('user-id', `${responder.id}`);
break;
}
const response = await this.fetch(
`/api/v1/incidents/${incident.id}/assign?${query.toString()}`,
init,
);
return response;
}
async createIncident(eventRequest: EventRequest): Promise<boolean> {
const init = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({
apiKey: eventRequest.integrationKey,
summary: eventRequest.summary,
details: eventRequest.details,
eventType: 'ALERT',
links: [
{
href: eventRequest.source,
text: 'Backstage Url',
},
],
customDetails: {
userName: eventRequest.userName,
},
}),
};
const response = await this.fetch('/api/v1/events', init);
return response.responseCode === 'NEW_INCIDENT_CREATED';
}
async fetchUptimeMonitors(): Promise<UptimeMonitor[]> {
const init = {
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
};
const response = await this.fetch('/api/v1/uptime-monitors', init);
return response;
}
async fetchUptimeMonitor(id: number): Promise<UptimeMonitor> {
const init = {
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
};
const response: UptimeMonitor = await this.fetch(
`/api/v1/uptime-monitors/${id}`,
init,
);
return response;
}
async pauseUptimeMonitor(
uptimeMonitor: UptimeMonitor,
): Promise<UptimeMonitor> {
const init = {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({ ...uptimeMonitor, paused: true }),
};
const response = await this.fetch(
`/api/v1/uptime-monitors/${uptimeMonitor.id}`,
init,
);
return response;
}
async resumeUptimeMonitor(
uptimeMonitor: UptimeMonitor,
): Promise<UptimeMonitor> {
const init = {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({ ...uptimeMonitor, paused: false }),
};
const response = await this.fetch(
`/api/v1/uptime-monitors/${uptimeMonitor.id}`,
init,
);
return response;
}
async fetchAlertSources(): Promise<AlertSource[]> {
const init = {
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
};
const response = await this.fetch('/api/v1/alert-sources', init);
return response;
}
async fetchAlertSource(
idOrIntegrationKey: number | string,
): Promise<AlertSource> {
const init = {
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
};
const response = await this.fetch(
`/api/v1/alert-sources/${idOrIntegrationKey}`,
init,
);
return response;
}
async enableAlertSource(alertSource: AlertSource): Promise<AlertSource> {
const init = {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({ ...alertSource, active: true }),
};
const response = await this.fetch(
`/api/v1/alert-sources/${alertSource.id}`,
init,
);
return response;
}
async disableAlertSource(alertSource: AlertSource): Promise<AlertSource> {
const init = {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({ ...alertSource, active: false }),
};
const response = await this.fetch(
`/api/v1/alert-sources/${alertSource.id}`,
init,
);
return response;
}
async addImmediateMaintenance(
alertSourceId: number,
minutes: number,
): Promise<void> {
const init = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({
start: moment().utc().toISOString(),
end: moment().add(minutes, 'minutes').utc().toISOString(),
description: `Immediate maintenance window for ${minutes} minutes. Backstage — iLert plugin.`,
createdBy: 'Backstage',
timezone: momentTimezone.tz.guess(),
alertSources: [{ id: alertSourceId }],
}),
};
const response = await this.fetch('/api/v1/maintenance-windows', init);
return response;
}
async fetchOnCallSchedules(): Promise<Schedule[]> {
const init = {
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
};
const response = await this.fetch('/api/v1/schedules', init);
return response;
}
async fetchUsers(): Promise<User[]> {
const init = {
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
};
const response = await this.fetch('/api/v1/users', init);
return response;
}
async overrideShift(
scheduleId: number,
userId: number,
start: string,
end: string,
): Promise<Schedule> {
const init = {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({ user: { id: userId }, start, end }),
};
const response = await this.fetch(
`/api/v1/schedules/${scheduleId}/overrides`,
init,
);
return response;
}
getIncidentDetailsURL(incident: Incident): string {
return `${this.domain}/incident/view.jsf?id=${incident.id}`;
}
getAlertSourceDetailsURL(alertSource: AlertSource | null): string {
if (!alertSource) {
return '';
}
return `${this.domain}/source/view.jsf?id=${alertSource.id}`;
}
getEscalationPolicyDetailsURL(escalationPolicy: EscalationPolicy): string {
return `${this.domain}/policy/view.jsf?id=${escalationPolicy.id}`;
}
getUptimeMonitorDetailsURL(uptimeMonitor: UptimeMonitor): string {
return `${this.domain}/uptime/view.jsf?id=${uptimeMonitor.id}`;
}
getScheduleDetailsURL(schedule: Schedule): string {
return `${this.domain}/schedule/view.jsf?id=${schedule.id}`;
}
getUserInitials(assignedTo: User | null) {
if (!assignedTo) {
return '';
}
if (!assignedTo.firstName && !assignedTo.lastName) {
return assignedTo.username;
}
return `${assignedTo.firstName} ${assignedTo.lastName} (${assignedTo.username})`;
}
private async apiUrl() {
const proxyUrl = await this.discoveryApi.getBaseUrl('proxy');
return proxyUrl + this.proxyPath;
}
}
+23
View File
@@ -0,0 +1,23 @@
/*
* 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.
*/
export { ILertClient, ilertApiRef, UnauthorizedError } from './client';
export type {
ILertApi,
GetIncidentsCountOpts,
GetIncidentsOpts,
TableState,
} from './types';
+110
View File
@@ -0,0 +1,110 @@
/*
* 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 { DiscoveryApi } from '@backstage/core';
import {
AlertSource,
Incident,
User,
IncidentStatus,
UptimeMonitor,
EscalationPolicy,
Schedule,
IncidentResponder,
} from '../types';
export type TableState = {
page: number;
pageSize: number;
};
export type GetIncidentsOpts = {
maxResults?: number;
startIndex?: number;
states?: IncidentStatus[];
alertSources?: number[] | string[];
};
export type GetIncidentsCountOpts = {
states?: IncidentStatus[];
};
export type EventRequest = {
integrationKey: string;
summary: string;
details: string;
userName: string;
source: string;
};
export interface ILertApi {
fetchIncidents(opts?: GetIncidentsOpts): Promise<Incident[]>;
fetchIncidentsCount(opts?: GetIncidentsCountOpts): Promise<number>;
fetchIncidentResponders(incident: Incident): Promise<IncidentResponder[]>;
acceptIncident(incident: Incident): Promise<Incident>;
resolveIncident(incident: Incident): Promise<Incident>;
assignIncident(
incident: Incident,
responder: IncidentResponder,
): Promise<Incident>;
createIncident(eventRequest: EventRequest): Promise<boolean>;
fetchUptimeMonitors(): Promise<UptimeMonitor[]>;
pauseUptimeMonitor(uptimeMonitor: UptimeMonitor): Promise<UptimeMonitor>;
resumeUptimeMonitor(uptimeMonitor: UptimeMonitor): Promise<UptimeMonitor>;
fetchUptimeMonitor(id: number): Promise<UptimeMonitor>;
fetchAlertSources(): Promise<AlertSource[]>;
fetchAlertSource(idOrIntegrationKey: number | string): Promise<AlertSource>;
enableAlertSource(alertSource: AlertSource): Promise<AlertSource>;
disableAlertSource(alertSource: AlertSource): Promise<AlertSource>;
addImmediateMaintenance(
alertSourceId: number,
minutes: number,
): Promise<void>;
fetchOnCallSchedules(): Promise<Schedule[]>;
fetchUsers(): Promise<User[]>;
overrideShift(
scheduleId: number,
userId: number,
start: string,
end: string,
): Promise<Schedule>;
getIncidentDetailsURL(incident: Incident): string;
getAlertSourceDetailsURL(alertSource: AlertSource | null): string;
getEscalationPolicyDetailsURL(escalationPolicy: EscalationPolicy): string;
getUptimeMonitorDetailsURL(uptimeMonitor: UptimeMonitor): string;
getScheduleDetailsURL(schedule: Schedule): string;
getUserInitials(assignedTo: User | null): string;
}
export type Options = {
discoveryApi: DiscoveryApi;
/**
* Domain used by users to access iLert web UI.
* Example: https://my-org.ilert.com/
*/
domain: string;
/**
* Path to use for requests via the proxy, defaults to /ilert/api
*/
proxyPath?: string;
};
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24pt" height="24pt" viewBox="0 0 24 24" version="1.1">
<g id="Logo">
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(100%,100%,100%);fill-opacity:1;" d="M 0 9.277344 L 0 18.574219 C 0 20.832031 1.847656 22.679688 4.105469 22.679688 L 12.582031 22.679688 C 6.21875 21.007812 1.277344 15.792969 0 9.277344 Z M 0 9.277344 "/>
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(100%,100%,100%);fill-opacity:1;" d="M 19.527344 22.5625 C 21.328125 22.128906 22.679688 20.503906 22.679688 18.574219 L 22.679688 16.273438 C 21.691406 16.820312 20.632812 17.222656 19.527344 17.46875 Z M 19.527344 22.5625 "/>
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(100%,100%,100%);fill-opacity:1;" d="M 14.417969 17.46875 C 9.136719 16.296875 5.171875 11.578125 5.171875 5.945312 C 5.167969 3.855469 5.726562 1.804688 6.785156 0 L 4.105469 0 C 1.847656 0 0 1.847656 0 4.105469 L 0 6.882812 C 0.433594 14.816406 6.335938 21.316406 13.992188 22.679688 L 14.421875 22.679688 Z M 14.417969 17.46875 "/>
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(100%,100%,100%);fill-opacity:1;" d="M 19.527344 12.375 L 19.527344 17.160156 C 20.632812 16.910156 21.695312 16.496094 22.679688 15.929688 L 22.679688 9.855469 C 21.902344 10.988281 20.804688 11.863281 19.527344 12.375 Z M 19.527344 12.375 "/>
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(100%,100%,100%);fill-opacity:1;" d="M 14.417969 17.160156 L 14.417969 12.375 C 11.863281 11.355469 10.054688 8.859375 10.054688 5.945312 C 10.054688 3.503906 11.34375 1.246094 13.441406 0 L 7.128906 0 C 6.039062 1.792969 5.464844 3.847656 5.46875 5.945312 C 5.46875 11.410156 9.300781 15.996094 14.417969 17.160156 Z M 14.417969 17.160156 "/>
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(100%,100%,100%);fill-opacity:1;" d="M 10.355469 5.945312 C 10.355469 8.613281 11.957031 11.019531 14.417969 12.050781 L 14.417969 9.917969 C 15.972656 10.925781 17.972656 10.925781 19.527344 9.917969 L 19.527344 12.050781 C 20.847656 11.496094 21.953125 10.53125 22.679688 9.300781 L 22.679688 4.105469 C 22.679688 1.847656 20.832031 0 18.574219 0 L 14.066406 0 C 11.796875 1.113281 10.355469 3.417969 10.355469 5.945312 Z M 16.972656 2.5625 C 18.84375 2.5625 20.355469 4.078125 20.355469 5.945312 C 20.355469 7.8125 18.84375 9.328125 16.972656 9.328125 C 15.105469 9.328125 13.589844 7.8125 13.589844 5.945312 C 13.59375 4.078125 15.105469 2.566406 16.972656 2.5625 Z M 16.972656 2.5625 "/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

@@ -0,0 +1,71 @@
/*
* 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 { useApi } from '@backstage/core';
import Link from '@material-ui/core/Link';
import Grid from '@material-ui/core/Grid';
import { AlertSource } from '../../types';
import { ilertApiRef } from '../../api';
import { makeStyles } from '@material-ui/core/styles';
import useMediaQuery from '@material-ui/core/useMediaQuery';
const useStyles = makeStyles({
root: {
display: 'flex',
maxWidth: '100%',
},
image: {
height: 22,
paddingRight: 4,
},
link: {
lineHeight: '22px',
},
});
export const AlertSourceLink = ({
alertSource,
}: {
alertSource: AlertSource | null;
}) => {
const ilertApi = useApi(ilertApiRef);
const classes = useStyles();
const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)');
if (!alertSource) {
return null;
}
return (
<Grid container spacing={0}>
<Grid item xs={2}>
<img
src={prefersDarkMode ? alertSource.lightIconUrl : alertSource.iconUrl}
alt={alertSource.name}
className={classes.image}
/>
</Grid>
<Grid item xs={10}>
<Link
className={classes.link}
href={ilertApi.getAlertSourceDetailsURL(alertSource)}
>
{alertSource.name}
</Link>
</Grid>
</Grid>
);
};
@@ -0,0 +1,35 @@
/*
* 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 { EmptyState } from '@backstage/core';
import { Button } from '@material-ui/core';
export const MissingAuthorizationHeaderError = () => (
<EmptyState
missing="info"
title="Missing or invalid iLert authorization header"
description="The request to fetch data needs a valid authorization header. See README for more details."
action={
<Button
color="primary"
variant="contained"
href="https://github.com/backstage/backstage/blob/master/plugins/ilert/README.md"
>
Read More
</Button>
}
/>
);
@@ -0,0 +1,17 @@
/*
* 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.
*/
export { MissingAuthorizationHeaderError } from './MissingAuthorizationHeaderError';
@@ -0,0 +1,49 @@
/*
* 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 { useApi } from '@backstage/core';
import Link from '@material-ui/core/Link';
import { makeStyles } from '@material-ui/core/styles';
import { EscalationPolicy } from '../../types';
import { ilertApiRef } from '../../api';
const useStyles = makeStyles({
link: {
lineHeight: '22px',
},
});
export const EscalationPolicyLink = ({
escalationPolicy,
}: {
escalationPolicy: EscalationPolicy | null;
}) => {
const ilertApi = useApi(ilertApiRef);
const classes = useStyles();
if (!escalationPolicy) {
return null;
}
return (
<Link
className={classes.link}
href={ilertApi.getEscalationPolicyDetailsURL(escalationPolicy)}
>
{escalationPolicy.name}
</Link>
);
};
@@ -0,0 +1,145 @@
/*
* 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 { Entity } from '@backstage/catalog-model';
import { UnauthorizedError } from '../../api';
import Alert from '@material-ui/lab/Alert';
import Card from '@material-ui/core/Card';
import CardContent from '@material-ui/core/CardContent';
import CardHeader from '@material-ui/core/CardHeader';
import Divider from '@material-ui/core/Divider';
import { makeStyles } from '@material-ui/core/styles';
import { ILERT_INTEGRATION_KEY } from '../../constants';
import { MissingAuthorizationHeaderError } from '../Errors';
import { useIncidents } from '../../hooks/useIncidents';
import { IncidentsTable } from '../IncidentsPage';
import { IncidentNewModal } from '../Incident/IncidentNewModal';
import { ILertCardActionsHeader } from './ILertCardActionsHeader';
import { useAlertSource } from '../../hooks/useAlertSource';
import { useILertEntity } from '../../hooks';
import { ILertCardHeaderStatus } from './ILertCardHeaderStatus';
import { ILertCardMaintenanceModal } from './ILertCardMaintenanceModal';
import { ILertCardEmptyState } from './ILertCardEmptyState';
export const isPluginApplicableToEntity = (entity: Entity) =>
Boolean(entity.metadata.annotations?.[ILERT_INTEGRATION_KEY]);
const useStyles = makeStyles({
content: {
paddingLeft: '0 !important',
paddingRight: '0 !important',
paddingBottom: '0 !important',
paddingTop: '0 !important',
'& div div': {
boxShadow: 'none !important',
},
},
});
export const ILertCard = () => {
const classes = useStyles();
const { integrationKey, name } = useILertEntity();
const [
{ alertSource, uptimeMonitor },
{ setAlertSource, refetchAlertSource },
] = useAlertSource(integrationKey);
const alertSourcesFilter = alertSource ? [alertSource.id] : [integrationKey];
const [
{ tableState, states, incidents, incidentsCount, isLoading, error },
{
onIncidentStatesChange,
onChangePage,
onChangeRowsPerPage,
onIncidentChanged,
refetchIncidents,
setIsLoading,
},
] = useIncidents(false, alertSourcesFilter);
const [
isNewIncidentModalOpened,
setIsNewIncidentModalOpened,
] = React.useState(false);
const [
isMaintenanceModalOpened,
setIsMaintenanceModalOpened,
] = React.useState(false);
if (error) {
if (error instanceof UnauthorizedError) {
return <MissingAuthorizationHeaderError />;
}
return (
<Alert data-testid="error-message" severity="error">
{error.message}
</Alert>
);
}
if (!integrationKey) {
return <ILertCardEmptyState />;
}
return (
<>
<Card data-testid="ilert-card">
<CardHeader
title="iLert"
subheader={
<ILertCardActionsHeader
alertSource={alertSource}
setAlertSource={setAlertSource}
setIsNewIncidentModalOpened={setIsNewIncidentModalOpened}
setIsMaintenanceModalOpened={setIsMaintenanceModalOpened}
uptimeMonitor={uptimeMonitor}
/>
}
action={<ILertCardHeaderStatus alertSource={alertSource} />}
/>
<Divider />
<CardContent className={classes.content}>
<IncidentsTable
incidents={incidents}
incidentsCount={incidentsCount}
tableState={tableState}
states={states}
onIncidentChanged={onIncidentChanged}
onIncidentStatesChange={onIncidentStatesChange}
onChangePage={onChangePage}
onChangeRowsPerPage={onChangeRowsPerPage}
isLoading={isLoading}
setIsLoading={setIsLoading}
compact
/>
</CardContent>
</Card>
<IncidentNewModal
isModalOpened={isNewIncidentModalOpened}
setIsModalOpened={setIsNewIncidentModalOpened}
refetchIncidents={refetchIncidents}
initialAlertSource={alertSource}
entityName={name}
/>
<ILertCardMaintenanceModal
alertSource={alertSource}
refetchAlertSource={refetchAlertSource}
isModalOpened={isMaintenanceModalOpened}
setIsModalOpened={setIsMaintenanceModalOpened}
/>
</>
);
};
@@ -0,0 +1,203 @@
/*
* 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 {
HeaderIconLinkRow,
IconLinkVerticalProps,
useApi,
alertApiRef,
} from '@backstage/core';
import Alert from '@material-ui/lab/Alert';
import Button from '@material-ui/core/Button';
import Dialog from '@material-ui/core/Dialog';
import DialogActions from '@material-ui/core/DialogActions';
import DialogContent from '@material-ui/core/DialogContent';
import DialogTitle from '@material-ui/core/DialogTitle';
import AlarmAddIcon from '@material-ui/icons/AlarmAdd';
import BuildIcon from '@material-ui/icons/Build';
import PauseIcon from '@material-ui/icons/Pause';
import PlayArrowIcon from '@material-ui/icons/PlayArrow';
import TimelineIcon from '@material-ui/icons/Timeline';
import WebIcon from '@material-ui/icons/Web';
import Typography from '@material-ui/core/Typography';
import { ilertApiRef } from '../../api';
import { AlertSource, UptimeMonitor } from '../../types';
export const ILertCardActionsHeader = ({
alertSource,
setAlertSource,
setIsNewIncidentModalOpened,
setIsMaintenanceModalOpened,
uptimeMonitor,
}: {
alertSource: AlertSource | null;
setAlertSource: (alertSource: AlertSource) => void;
setIsNewIncidentModalOpened: (isOpen: boolean) => void;
setIsMaintenanceModalOpened: (isOpen: boolean) => void;
uptimeMonitor: UptimeMonitor | null;
}) => {
const ilertApi = useApi(ilertApiRef);
const alertApi = useApi(alertApiRef);
const [isLoading, setIsLoading] = React.useState(false);
const [isDisableModalOpened, setIsDisableModalOpened] = React.useState(false);
const handleCreateNewIncident = () => {
setIsNewIncidentModalOpened(true);
};
const handleEnableAlertSource = async () => {
try {
if (!alertSource) {
return;
}
setIsLoading(true);
const newAlertSource = await ilertApi.enableAlertSource(alertSource);
alertApi.post({ message: 'Alert source enabled.' });
setIsLoading(false);
setAlertSource(newAlertSource);
} catch (err) {
setIsLoading(false);
alertApi.post({ message: err, severity: 'error' });
}
};
const handleDisableAlertSource = async () => {
try {
if (!alertSource) {
return;
}
setIsDisableModalOpened(false);
setIsLoading(true);
const newAlertSource = await ilertApi.disableAlertSource(alertSource);
alertApi.post({ message: 'Alert source disabled.' });
setIsLoading(false);
setAlertSource(newAlertSource);
} catch (err) {
setIsLoading(false);
alertApi.post({ message: err, severity: 'error' });
}
};
const handleDisableAlertSourceWarningOpen = () => {
setIsDisableModalOpened(true);
};
const handleDisableAlertSourceWarningClose = () => {
setIsDisableModalOpened(false);
};
const handleMaintenanceAlertSource = () => {
setIsMaintenanceModalOpened(true);
};
const alertSourceLink: IconLinkVerticalProps = {
label: 'Alert Source',
href: ilertApi.getAlertSourceDetailsURL(alertSource),
icon: <WebIcon />,
};
const createIncidentLink: IconLinkVerticalProps = {
label: 'Create Incident',
onClick: handleCreateNewIncident,
icon: <AlarmAddIcon />,
color: 'secondary',
disabled:
!alertSource ||
alertSource.status === 'DISABLED' ||
alertSource.status === 'IN_MAINTENANCE',
};
const enableAlertSourceLink: IconLinkVerticalProps = {
label: 'Enable',
onClick: handleEnableAlertSource,
icon: <PlayArrowIcon />,
disabled: !alertSource || isLoading,
};
const disableAlertSourceLink: IconLinkVerticalProps = {
label: 'Disable',
onClick: handleDisableAlertSourceWarningOpen,
icon: <PauseIcon />,
disabled: !alertSource || isLoading,
};
const maintenanceAlertSourceLink: IconLinkVerticalProps = {
label: 'Immediate maintenance',
onClick: handleMaintenanceAlertSource,
icon: <BuildIcon />,
disabled: !alertSource || isLoading,
};
const uptimeMonitorReportLink: IconLinkVerticalProps = {
label: 'Uptime Report',
href: uptimeMonitor ? uptimeMonitor.shareUrl : '',
icon: <TimelineIcon />,
disabled: !alertSource || !uptimeMonitor || isLoading,
};
const links: IconLinkVerticalProps[] = [
alertSourceLink,
createIncidentLink,
alertSource && alertSource.active
? disableAlertSourceLink
: enableAlertSourceLink,
];
if (alertSource && alertSource.integrationType === 'MONITOR') {
links.push(uptimeMonitorReportLink);
}
if (alertSource && alertSource.status !== 'IN_MAINTENANCE') {
links.push(maintenanceAlertSourceLink);
}
return (
<>
<HeaderIconLinkRow links={links} />
<Dialog
open={isDisableModalOpened}
onClose={handleDisableAlertSourceWarningClose}
aria-labelledby="alert-source-disable-form-title"
>
<DialogTitle id="alert-source-disable-form-title">
Disable alert source
</DialogTitle>
<DialogContent>
<Alert severity="info">
<Typography variant="body1" align="justify">
Do you really want to disable this alert source? A disabled alert
source cannot create new incidents.
</Typography>
</Alert>
</DialogContent>
<DialogActions>
<Button
onClick={handleDisableAlertSource}
color="secondary"
variant="contained"
>
Disable
</Button>
<Button
onClick={handleDisableAlertSourceWarningClose}
color="primary"
>
Cancel
</Button>
</DialogActions>
</Dialog>
</>
);
};
@@ -0,0 +1,86 @@
/*
* 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 { CodeSnippet } from '@backstage/core';
import { BackstageTheme } from '@backstage/theme';
import Card from '@material-ui/core/Card';
import CardContent from '@material-ui/core/CardContent';
import CardHeader from '@material-ui/core/CardHeader';
import Button from '@material-ui/core/Button';
import Divider from '@material-ui/core/Divider';
import Typography from '@material-ui/core/Typography';
import { makeStyles } from '@material-ui/core/styles';
import React from 'react';
const ENTITY_YAML = `apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: example
description: example.com
annotations:
ilert.com/integration-key: [INTEGRATION_KEY]
spec:
type: website
lifecycle: production
owner: guest`;
const useStyles = makeStyles<BackstageTheme>(theme => ({
code: {
borderRadius: 6,
margin: `${theme.spacing(2)}px 0px`,
background: theme.palette.type === 'dark' ? '#444' : '#fff',
},
header: {
display: 'inline-block',
padding: `${theme.spacing(2)}px ${theme.spacing(2)}px ${theme.spacing(
2,
)}px ${theme.spacing(2.5)}px`,
},
}));
export const ILertCardEmptyState = () => {
const classes = useStyles();
return (
<Card data-testid="ilert-empty-card">
<CardHeader title="iLert" className={classes.header} />
<Divider />
<CardContent>
<Typography variant="body1">
No integration key defined for this entity. You can add integration
key to your entity YAML as shown in the highlighted example below:
</Typography>
<div className={classes.code}>
<CodeSnippet
text={ENTITY_YAML}
language="yaml"
showLineNumbers
highlightedNumbers={[6, 7]}
customStyle={{ background: 'inherit', fontSize: '115%' }}
/>
</div>
<Button
variant="contained"
color="primary"
target="_blank"
href="https://github.com/backstage/backstage/blob/master/plugins/ilert/README.md"
>
Read more
</Button>
</CardContent>
</Card>
);
};
@@ -0,0 +1,46 @@
/*
* 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 Chip from '@material-ui/core/Chip';
import { withStyles } from '@material-ui/core/styles';
import { AlertSource } from '../../types';
const MaintenanceChip = withStyles({
root: {
backgroundColor: '#92949c',
color: 'white',
marginTop: 8,
},
})(Chip);
export const ILertCardHeaderStatus = ({
alertSource,
}: {
alertSource: AlertSource | null;
}) => {
if (!alertSource) {
return null;
}
switch (alertSource.status) {
case 'IN_MAINTENANCE':
return <MaintenanceChip label="MAINTENANCE" size="small" />;
case 'DISABLED':
return <MaintenanceChip label="INACTIVE" size="small" />;
default:
return null;
}
};
@@ -0,0 +1,138 @@
/*
* 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 { alertApiRef, useApi } from '@backstage/core';
import Button from '@material-ui/core/Button';
import Dialog from '@material-ui/core/Dialog';
import DialogActions from '@material-ui/core/DialogActions';
import DialogContent from '@material-ui/core/DialogContent';
import DialogContentText from '@material-ui/core/DialogContentText';
import DialogTitle from '@material-ui/core/DialogTitle';
import MenuItem from '@material-ui/core/MenuItem';
import TextField from '@material-ui/core/TextField';
import { ilertApiRef } from '../../api';
import { AlertSource } from '../../types';
export const ILertCardMaintenanceModal = ({
alertSource,
refetchAlertSource,
isModalOpened,
setIsModalOpened,
}: {
alertSource: AlertSource | null;
refetchAlertSource: () => void;
isModalOpened: boolean;
setIsModalOpened: (isModalOpened: boolean) => void;
}) => {
const ilertApi = useApi(ilertApiRef);
const alertApi = useApi(alertApiRef);
const [minutes, setMinutes] = React.useState(5);
const handleClose = () => {
setIsModalOpened(false);
};
const handleImmediateMaintenance = () => {
if (!alertSource) {
return;
}
setIsModalOpened(false);
setTimeout(async () => {
try {
await ilertApi.addImmediateMaintenance(alertSource.id, minutes);
alertApi.post({ message: 'Maintenance started.' });
refetchAlertSource();
} catch (err) {
alertApi.post({ message: err, severity: 'error' });
}
}, 250);
};
const handleMinutesChange = (event: any) => {
setMinutes(event.target.value);
};
const minuteOptions = [
{
value: 5,
label: '5 minutes',
},
{
value: 10,
label: '10 minutes',
},
{
value: 15,
label: '15 minutes',
},
{
value: 30,
label: '30 minutes',
},
{
value: 60,
label: '60 minutes',
},
];
if (!alertSource) {
return null;
}
return (
<Dialog
open={isModalOpened}
onClose={handleClose}
aria-labelledby="maintenance-form-title"
>
<DialogTitle id="maintenance-form-title">
New maintenance window
</DialogTitle>
<DialogContent>
<DialogContentText>
Keep your alert sources quiet, when your systems are under
maintenance.
</DialogContentText>
<TextField
select
label="Duration"
value={minutes}
onChange={handleMinutesChange}
variant="outlined"
fullWidth
>
{minuteOptions.map(option => (
<MenuItem key={option.value} value={option.value}>
{option.label}
</MenuItem>
))}
</TextField>
</DialogContent>
<DialogActions>
<Button
onClick={handleImmediateMaintenance}
color="primary"
variant="contained"
>
Create
</Button>
<Button onClick={handleClose} color="primary">
Cancel
</Button>
</DialogActions>
</Dialog>
);
};
@@ -0,0 +1,16 @@
/*
* 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.
*/
export * from './ILertCard';
@@ -0,0 +1,68 @@
/*
* 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 {
Page,
Header,
HeaderTabs,
HeaderLabel,
Content,
} from '@backstage/core';
import { IncidentsPage } from '../IncidentsPage';
import { UptimeMonitorsPage } from '../UptimeMonitorsPage';
import { OnCallSchedulesPage } from '../OnCallSchedulesPage';
export const ILertPage = () => {
const [selectedTab, setSelectedTab] = React.useState<number>(0);
const tabs = [
{ label: 'Who is on call?' },
{ label: 'Incidents' },
{ label: 'Uptime Monitors' },
];
const renderTab = () => {
switch (selectedTab) {
case 0:
return <OnCallSchedulesPage />;
case 1:
return <IncidentsPage />;
case 2:
return <UptimeMonitorsPage />;
default:
return null;
}
};
return (
<Page themeId="website">
<Header title="iLert" type="tool">
<HeaderLabel label="Owner" value="iLert" />
<HeaderLabel label="Lifecycle" value="Alpha" />
</Header>
<HeaderTabs
selectedIndex={selectedTab}
onChange={index => setSelectedTab(index)}
tabs={tabs.map(({ label }, index) => ({
id: index.toString(),
label,
}))}
/>
<Content noPadding>{renderTab()}</Content>
</Page>
);
};
export default ILertPage;
@@ -0,0 +1,16 @@
/*
* 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.
*/
export * from './ILertPage';
@@ -0,0 +1,148 @@
/*
* 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 { alertApiRef, 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 { IncidentAssignModal } from './IncidentAssignModal';
export const IncidentActionsMenu = ({
incident,
onIncidentChanged,
setIsLoading,
}: {
incident: Incident;
onIncidentChanged?: (incident: Incident) => void;
setIsLoading?: (isLoading: boolean) => void;
}) => {
const ilertApi = useApi(ilertApiRef);
const alertApi = useApi(alertApiRef);
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
const callback = onIncidentChanged || ((_: Incident): void => {});
const setProcessing = setIsLoading || ((_: boolean): void => {});
const [
isAssignIncidentModalOpened,
setIsAssignIncidentModalOpened,
] = React.useState(false);
const handleClick = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
};
const handleCloseMenu = () => {
setAnchorEl(null);
};
const handleAccept = async (): Promise<void> => {
try {
handleCloseMenu();
setProcessing(true);
const newIncident = await ilertApi.acceptIncident(incident);
alertApi.post({ message: 'Incident accepted.' });
callback(newIncident);
setProcessing(false);
} catch (err) {
setProcessing(false);
alertApi.post({ message: err, severity: 'error' });
}
};
const handleResolve = async (): Promise<void> => {
try {
handleCloseMenu();
setProcessing(true);
const newIncident = await ilertApi.resolveIncident(incident);
alertApi.post({ message: 'Incident resolved.' });
callback(newIncident);
setProcessing(false);
} catch (err) {
setProcessing(false);
alertApi.post({ message: err, severity: 'error' });
}
};
const handleAssign = () => {
handleCloseMenu();
setIsAssignIncidentModalOpened(true);
};
return (
<>
<IconButton
aria-label="more"
aria-controls="long-menu"
aria-haspopup="true"
onClick={handleClick}
size="small"
>
<MoreVertIcon />
</IconButton>
<Menu
id={`incident-actions-menu-${incident.id}`}
anchorEl={anchorEl}
keepMounted
open={Boolean(anchorEl)}
onClose={handleCloseMenu}
PaperProps={{
style: { maxHeight: 48 * 4.5 },
}}
>
{incident.status === 'PENDING' ? (
<MenuItem key="ack" onClick={handleAccept}>
<Typography variant="inherit" noWrap>
Accept
</Typography>
</MenuItem>
) : null}
{incident.status !== 'RESOLVED' ? (
<MenuItem key="close" onClick={handleResolve}>
<Typography variant="inherit" noWrap>
Resolve
</Typography>
</MenuItem>
) : null}
{incident.status !== 'RESOLVED' ? (
<MenuItem key="assign" onClick={handleAssign}>
<Typography variant="inherit" noWrap>
Assign
</Typography>
</MenuItem>
) : null}
<MenuItem key="details" onClick={handleCloseMenu}>
<Typography variant="inherit" noWrap>
<Link href={ilertApi.getIncidentDetailsURL(incident)}>
View in iLert
</Link>
</Typography>
</MenuItem>
</Menu>
<IncidentAssignModal
incident={incident}
setIsModalOpened={setIsAssignIncidentModalOpened}
isModalOpened={isAssignIncidentModalOpened}
onIncidentChanged={onIncidentChanged}
/>
</>
);
};
@@ -0,0 +1,183 @@
/*
* 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 { alertApiRef, useApi } from '@backstage/core';
import { makeStyles } from '@material-ui/core/styles';
import Alert from '@material-ui/lab/Alert';
import Button from '@material-ui/core/Button';
import TextField from '@material-ui/core/TextField';
import Dialog from '@material-ui/core/Dialog';
import DialogActions from '@material-ui/core/DialogActions';
import DialogContent from '@material-ui/core/DialogContent';
import DialogTitle from '@material-ui/core/DialogTitle';
import Autocomplete from '@material-ui/lab/Autocomplete';
import { useAssignIncident } from '../../hooks/useAssignIncident';
import { Typography } from '@material-ui/core';
import { ilertApiRef } from '../../api';
import { Incident } from '../../types';
const useStyles = makeStyles(() => ({
container: {
display: 'flex',
flexWrap: 'wrap',
},
formControl: {
minWidth: 120,
width: '100%',
},
option: {
fontSize: 15,
'& > span': {
marginRight: 10,
fontSize: 18,
},
},
optionWrapper: {
display: 'flex',
width: '100%',
},
sourceImage: {
height: 22,
paddingRight: 4,
},
}));
export const IncidentAssignModal = ({
incident,
isModalOpened,
setIsModalOpened,
onIncidentChanged,
}: {
incident: Incident | null;
isModalOpened: boolean;
setIsModalOpened: (open: boolean) => void;
onIncidentChanged?: (incident: Incident) => void;
}) => {
const [
{ incidentRespondersList, incidentResponder, isLoading },
{ setIsLoading, setIncidentResponder, setIncidentRespondersList },
] = useAssignIncident(incident, isModalOpened);
const callback = onIncidentChanged || ((_: Incident): void => {});
const ilertApi = useApi(ilertApiRef);
const alertApi = useApi(alertApiRef);
const classes = useStyles();
const handleClose = () => {
setIncidentRespondersList([]);
setIsModalOpened(false);
};
const handleAssign = () => {
if (!incident || !incidentResponder) {
return;
}
setIsLoading(true);
setIncidentRespondersList([]);
setTimeout(async () => {
try {
const newIncident = await ilertApi.assignIncident(
incident,
incidentResponder,
);
callback(newIncident);
alertApi.post({ message: 'Incident assigned.' });
} catch (err) {
alertApi.post({ message: err, severity: 'error' });
}
setIsLoading(false);
setIsModalOpened(false);
}, 250);
};
const canAssign = !!incidentResponder;
return (
<Dialog
open={isModalOpened}
onClose={handleClose}
aria-labelledby="assign-incident-form-title"
>
<DialogTitle id="assign-incident-form-title">
Select responder to assign
</DialogTitle>
<DialogContent>
<Alert severity="info">
<Typography variant="body1" gutterBottom align="justify">
This action will assign the incident to the selected responder.
</Typography>
</Alert>
<Autocomplete
disabled={isLoading}
options={incidentRespondersList}
value={incidentResponder}
classes={{
root: classes.formControl,
option: classes.option,
}}
onChange={(_event: any, newValue: any) => {
setIncidentResponder(newValue);
}}
autoHighlight
groupBy={option => {
switch (option.group) {
case 'SUGGESTED':
return 'Suggested responders';
case 'USER':
return 'Users';
case 'ESCALATION_POLICY':
return 'Escalation policies';
case 'ON_CALL_SCHEDULE':
return 'Schedules';
default:
return '';
}
}}
getOptionLabel={a => a.name}
renderOption={a => (
<div className={classes.optionWrapper}>
<Typography noWrap>{a.name}</Typography>
</div>
)}
renderInput={params => (
<TextField
{...params}
label="Responder"
variant="outlined"
margin="normal"
inputProps={{
...params.inputProps,
autoComplete: 'new-password', // disable autocomplete and autofill
}}
/>
)}
/>
</DialogContent>
<DialogActions>
<Button
disabled={!canAssign}
onClick={handleAssign}
color="primary"
variant="contained"
>
Assign
</Button>
<Button onClick={handleClose} color="primary">
Cancel
</Button>
</DialogActions>
</Dialog>
);
};
@@ -0,0 +1,45 @@
/*
* 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 { useApi } from '@backstage/core';
import Link from '@material-ui/core/Link';
import { makeStyles } from '@material-ui/core/styles';
import { Incident } from '../../types';
import { ilertApiRef } from '../../api';
const useStyles = makeStyles({
link: {
lineHeight: '22px',
},
});
export const IncidentLink = ({ incident }: { incident: Incident | null }) => {
const ilertApi = useApi(ilertApiRef);
const classes = useStyles();
if (!incident) {
return null;
}
return (
<Link
className={classes.link}
href={ilertApi.getIncidentDetailsURL(incident)}
>
#{incident.id}
</Link>
);
};
@@ -0,0 +1,230 @@
/*
* 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 { alertApiRef, identityApiRef, useApi } from '@backstage/core';
import { makeStyles } from '@material-ui/core/styles';
import Alert from '@material-ui/lab/Alert';
import Button from '@material-ui/core/Button';
import TextField from '@material-ui/core/TextField';
import Dialog from '@material-ui/core/Dialog';
import DialogActions from '@material-ui/core/DialogActions';
import DialogContent from '@material-ui/core/DialogContent';
import DialogTitle from '@material-ui/core/DialogTitle';
import Autocomplete from '@material-ui/lab/Autocomplete';
import { useNewIncident } from '../../hooks/useNewIncident';
import { Typography } from '@material-ui/core';
import useMediaQuery from '@material-ui/core/useMediaQuery';
import { ilertApiRef } from '../../api';
import { AlertSource } from '../../types';
const useStyles = makeStyles(() => ({
container: {
display: 'flex',
flexWrap: 'wrap',
},
formControl: {
minWidth: 120,
width: '100%',
},
option: {
fontSize: 15,
'& > span': {
marginRight: 10,
fontSize: 18,
},
},
optionWrapper: {
display: 'flex',
width: '100%',
},
sourceImage: {
height: 22,
paddingRight: 4,
},
}));
export const IncidentNewModal = ({
isModalOpened,
setIsModalOpened,
refetchIncidents,
initialAlertSource,
entityName,
}: {
isModalOpened: boolean;
setIsModalOpened: (open: boolean) => void;
refetchIncidents: () => void;
initialAlertSource?: AlertSource | null;
entityName?: string;
}) => {
const [
{ alertSources, alertSource, summary, details, isLoading },
{ setAlertSource, setSummary, setDetails, setIsLoading },
] = useNewIncident(isModalOpened, initialAlertSource);
const ilertApi = useApi(ilertApiRef);
const alertApi = useApi(alertApiRef);
const identityApi = useApi(identityApiRef);
const userName = identityApi.getUserId();
const source = window.location.toString();
const classes = useStyles();
const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)');
const handleClose = () => {
setIsModalOpened(false);
};
let integrationKey = '';
if (initialAlertSource && initialAlertSource.integrationKey) {
integrationKey = initialAlertSource.integrationKey;
} else if (alertSource && alertSource.integrationKey) {
integrationKey = alertSource.integrationKey;
}
const handleCreate = () => {
if (!integrationKey) {
return;
}
setIsLoading(true);
setTimeout(async () => {
try {
const success = await ilertApi.createIncident({
integrationKey,
summary,
details,
userName,
source,
});
if (success) {
alertApi.post({ message: 'Incident created.' });
refetchIncidents();
}
} catch (err) {
alertApi.post({ message: err, severity: 'error' });
}
setIsModalOpened(false);
}, 250);
};
const canCreate = !!integrationKey && !!summary;
return (
<Dialog
open={isModalOpened}
onClose={handleClose}
aria-labelledby="create-incident-form-title"
>
<DialogTitle id="create-incident-form-title">
{entityName ? (
<div>
This action will trigger an incident for{' '}
<strong>"{entityName}"</strong>.
</div>
) : (
'New incident'
)}
</DialogTitle>
<DialogContent>
<Alert severity="info">
<Typography variant="body1" gutterBottom align="justify">
Please describe the problem you want to report. Be as descriptive as
possible. Your signed in user and a reference to the current page
will automatically be amended to the alarm so that the receiver can
reach out to you if necessary.
</Typography>
</Alert>
{!initialAlertSource ? (
<Autocomplete
disabled={isLoading}
options={alertSources}
value={alertSource}
classes={{
root: classes.formControl,
option: classes.option,
}}
onChange={(_event: any, newValue: any) => {
setAlertSource(newValue);
}}
autoHighlight
getOptionLabel={a => a.name}
renderOption={a => (
<div className={classes.optionWrapper}>
<img
src={prefersDarkMode ? a.lightIconUrl : a.iconUrl}
alt={a.name}
className={classes.sourceImage}
/>
<Typography noWrap>{a.name}</Typography>
</div>
)}
renderInput={params => (
<TextField
{...params}
label="Alert Source"
variant="outlined"
margin="normal"
inputProps={{
...params.inputProps,
autoComplete: 'new-password', // disable autocomplete and autofill
}}
/>
)}
/>
) : null}
<TextField
disabled={isLoading}
label="Summary"
fullWidth
margin="normal"
variant="outlined"
classes={{
root: classes.formControl,
}}
value={summary}
onChange={event => {
setSummary(event.target.value);
}}
/>
<TextField
disabled={isLoading}
label="Details"
fullWidth
multiline
rows={4}
margin="normal"
variant="outlined"
classes={{
root: classes.formControl,
}}
value={details}
onChange={event => {
setDetails(event.target.value);
}}
/>
</DialogContent>
<DialogActions>
<Button
disabled={!canCreate}
onClick={handleCreate}
color="secondary"
variant="contained"
>
Create
</Button>
<Button onClick={handleClose} color="primary">
Cancel
</Button>
</DialogActions>
</Dialog>
);
};
@@ -0,0 +1,48 @@
/*
* 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 { StatusError, StatusOK } from '@backstage/core';
import { makeStyles } from '@material-ui/core/styles';
import Tooltip from '@material-ui/core/Tooltip';
import { ACCEPTED, Incident, PENDING, RESOLVED } from '../../types';
const useStyles = makeStyles({
denseListIcon: {
marginRight: 0,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
},
});
export const incidentStatusLabels = {
[RESOLVED]: 'Resolved',
[ACCEPTED]: 'Accepted',
[PENDING]: 'Pending',
} as Record<string, string>;
export const IncidentStatus = ({ incident }: { incident: Incident }) => {
const classes = useStyles();
return (
<Tooltip title={incident.status} placement="top">
<div className={classes.denseListIcon}>
{incident.status === 'PENDING' ? <StatusError /> : <StatusOK />}
</div>
</Tooltip>
);
};
@@ -0,0 +1,17 @@
/*
* 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.
*/
export * from './IncidentActionsMenu';
export * from './IncidentStatus';
@@ -0,0 +1,93 @@
/*
* 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 { Content, ContentHeader, SupportButton } from '@backstage/core';
import Button from '@material-ui/core/Button';
import AddIcon from '@material-ui/icons/Add';
import { UnauthorizedError } from '../../api';
import Alert from '@material-ui/lab/Alert';
import { IncidentsTable } from './IncidentsTable';
import { MissingAuthorizationHeaderError } from '../Errors';
import { useIncidents } from '../../hooks/useIncidents';
import { IncidentNewModal } from '../Incident/IncidentNewModal';
export const IncidentsPage = () => {
const [
{ tableState, states, incidents, incidentsCount, isLoading, error },
{
onIncidentStatesChange,
onChangePage,
onChangeRowsPerPage,
onIncidentChanged,
refetchIncidents,
setIsLoading,
},
] = useIncidents(true);
const [isModalOpened, setIsModalOpened] = React.useState(false);
const handleCreateNewIncidentClick = () => {
setIsModalOpened(true);
};
if (error) {
if (error instanceof UnauthorizedError) {
return <MissingAuthorizationHeaderError />;
}
return (
<Alert data-testid="error-message" severity="error">
{error.message}
</Alert>
);
}
return (
<Content>
<ContentHeader title="Incidents">
<Button
variant="contained"
color="primary"
size="small"
startIcon={<AddIcon />}
onClick={handleCreateNewIncidentClick}
>
Create Incident
</Button>
<IncidentNewModal
isModalOpened={isModalOpened}
setIsModalOpened={setIsModalOpened}
refetchIncidents={refetchIncidents}
/>
<SupportButton>
This helps you to bring iLert into your developer portal.
</SupportButton>
</ContentHeader>
<IncidentsTable
incidents={incidents}
incidentsCount={incidentsCount}
tableState={tableState}
states={states}
onIncidentChanged={onIncidentChanged}
onIncidentStatesChange={onIncidentStatesChange}
onChangePage={onChangePage}
onChangeRowsPerPage={onChangeRowsPerPage}
isLoading={isLoading}
setIsLoading={setIsLoading}
/>
</Content>
);
};
@@ -0,0 +1,253 @@
/*
* 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 { Table, TableColumn, useApi } from '@backstage/core';
import { makeStyles } from '@material-ui/core/styles';
import { ilertApiRef, TableState } from '../../api';
import { Incident, IncidentStatus } from '../../types';
import { StatusChip } from './StatusChip';
import { AlertSourceLink } from '../AlertSource/AlertSourceLink';
import { TableTitle } from './TableTitle';
import Typography from '@material-ui/core/Typography';
import moment from 'moment';
import { IncidentActionsMenu } from '../Incident/IncidentActionsMenu';
import { IncidentLink } from '../Incident/IncidentLink';
const useStyles = makeStyles(theme => ({
empty: {
padding: theme.spacing(2),
display: 'flex',
justifyContent: 'center',
},
}));
export const IncidentsTable = ({
incidents,
incidentsCount,
tableState,
states,
isLoading,
onIncidentChanged,
setIsLoading,
onIncidentStatesChange,
onChangePage,
onChangeRowsPerPage,
compact,
}: {
incidents: Incident[];
incidentsCount: number;
tableState: TableState;
states: IncidentStatus[];
isLoading: boolean;
onIncidentChanged: (incident: Incident) => void;
setIsLoading: (isLoading: boolean) => void;
onIncidentStatesChange: (states: IncidentStatus[]) => void;
onChangePage: (page: number) => void;
onChangeRowsPerPage: (pageSize: number) => void;
compact?: boolean;
}) => {
const ilertApi = useApi(ilertApiRef);
const classes = useStyles();
const xsColumnStyle = {
width: '5%',
maxWidth: '5%',
};
const smColumnStyle = {
width: '10%',
maxWidth: '10%',
};
const mdColumnStyle = {
width: '15%',
maxWidth: '15%',
};
const lgColumnStyle = {
width: '20%',
maxWidth: '20%',
};
const xlColumnStyle = {
width: '30%',
maxWidth: '30%',
};
const idColumn: TableColumn = {
title: 'ID',
field: 'id',
highlight: true,
cellStyle: smColumnStyle,
headerStyle: smColumnStyle,
render: rowData => <IncidentLink incident={rowData as Incident} />,
};
const summaryColumn: TableColumn = {
title: 'Summary',
field: 'summary',
cellStyle: !compact ? xlColumnStyle : undefined,
headerStyle: !compact ? xlColumnStyle : undefined,
render: rowData => <Typography>{(rowData as Incident).summary}</Typography>,
};
const sourceColumn: TableColumn = {
title: 'Source',
field: 'source',
cellStyle: mdColumnStyle,
headerStyle: mdColumnStyle,
render: rowData => (
<AlertSourceLink alertSource={(rowData as Incident).alertSource} />
),
};
const durationColumn: TableColumn = {
title: 'Duration',
field: 'reportTime',
type: 'datetime',
cellStyle: smColumnStyle,
headerStyle: smColumnStyle,
render: rowData => (
<Typography noWrap>
{(rowData as Incident).status !== 'RESOLVED'
? moment
.duration(moment((rowData as Incident).reportTime).diff(moment()))
.humanize()
: moment
.duration(
moment((rowData as Incident).reportTime).diff(
moment((rowData as Incident).resolvedOn),
),
)
.humanize()}
</Typography>
),
};
const assignedToColumn: TableColumn = {
title: 'Assigned to',
field: 'assignedTo',
cellStyle: !compact ? mdColumnStyle : lgColumnStyle,
headerStyle: !compact ? mdColumnStyle : lgColumnStyle,
render: rowData => (
<Typography noWrap>
{ilertApi.getUserInitials((rowData as Incident).assignedTo)}
</Typography>
),
};
const priorityColumn: TableColumn = {
title: 'Priority',
field: 'priority',
cellStyle: smColumnStyle,
headerStyle: smColumnStyle,
render: rowData => (
<Typography noWrap>
{(rowData as Incident).priority === 'HIGH' ? 'High' : 'Low'}
</Typography>
),
};
const statusColumn: TableColumn = {
title: 'Status',
field: 'status',
cellStyle: xsColumnStyle,
headerStyle: xsColumnStyle,
render: rowData => <StatusChip incident={rowData as Incident} />,
};
const actionsColumn: TableColumn = {
title: '',
field: '',
cellStyle: xsColumnStyle,
headerStyle: xsColumnStyle,
render: rowData => (
<IncidentActionsMenu
incident={rowData as Incident}
onIncidentChanged={onIncidentChanged}
setIsLoading={setIsLoading}
/>
),
};
const columns: TableColumn[] = compact
? [
summaryColumn,
durationColumn,
assignedToColumn,
statusColumn,
actionsColumn,
]
: [
idColumn,
summaryColumn,
sourceColumn,
durationColumn,
assignedToColumn,
priorityColumn,
statusColumn,
actionsColumn,
];
let tableStyle: React.CSSProperties = {};
if (compact) {
tableStyle = {
width: '100%',
maxWidth: '100%',
minWidth: '0',
height: 'calc(100% - 10px)',
boxShadow: 'none !important',
borderRadius: 'none !important',
};
} else {
tableStyle = {
width: '100%',
maxWidth: '100%',
};
}
return (
<Table
style={tableStyle}
options={{
sorting: false,
search: !compact,
paging: !compact,
actionsColumnIndex: -1,
pageSize: tableState.pageSize,
pageSizeOptions: !compact ? [10, 20, 50, 100] : [3, 10, 20, 50, 100],
padding: 'dense',
loadingType: 'overlay',
showEmptyDataSourceMessage: !isLoading,
showTitle: true,
toolbar: true,
}}
emptyContent={
<Typography color="textSecondary" className={classes.empty}>
No incidents right now
</Typography>
}
title={
!compact ? (
<TableTitle
incidentStates={states}
onIncidentStatesChange={onIncidentStatesChange}
/>
) : (
<Typography variant="button" color="textSecondary">
INCIDENTS
</Typography>
)
}
page={tableState.page}
totalCount={incidentsCount}
onChangePage={onChangePage}
onChangeRowsPerPage={onChangeRowsPerPage}
// localization={{ header: { actions: undefined } }}
columns={columns}
data={incidents}
isLoading={isLoading}
/>
);
};
@@ -0,0 +1,57 @@
/*
* 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 { Chip, withStyles } from '@material-ui/core';
import { Incident, PENDING, ACCEPTED, RESOLVED } from '../../types';
import { incidentStatusLabels } from '../Incident/IncidentStatus';
const ResolvedChip = withStyles({
root: {
backgroundColor: '#4caf50',
color: 'white',
margin: 0,
},
})(Chip);
const AcceptedChip = withStyles({
root: {
backgroundColor: '#ffb74d',
color: 'white',
margin: 0,
},
})(Chip);
const PendingChip = withStyles({
root: {
backgroundColor: '#d32f2f',
color: 'white',
margin: 0,
},
})(Chip);
export const StatusChip = ({ incident }: { incident: Incident }) => {
const label = `${incidentStatusLabels[incident.status]}`;
switch (incident.status) {
case RESOLVED:
return <ResolvedChip label={label} size="small" />;
case ACCEPTED:
return <AcceptedChip label={label} size="small" />;
case PENDING:
return <PendingChip label={label} size="small" />;
default:
return <Chip label={label} size="small" />;
}
};
@@ -0,0 +1,97 @@
/*
* 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 { PENDING, ACCEPTED, RESOLVED, IncidentStatus } from '../../types';
import { incidentStatusLabels } from '../Incident/IncidentStatus';
import FormControl from '@material-ui/core/FormControl';
import ListItemText from '@material-ui/core/ListItemText';
import Select from '@material-ui/core/Select';
import Typography from '@material-ui/core/Typography';
import MenuItem from '@material-ui/core/MenuItem';
import Checkbox from '@material-ui/core/Checkbox';
import { makeStyles } from '@material-ui/core/styles';
const ITEM_HEIGHT = 48;
const ITEM_PADDING_TOP = 8;
const MenuProps = {
PaperProps: {
style: {
maxHeight: ITEM_HEIGHT * 4.5 + ITEM_PADDING_TOP,
width: 250,
},
},
};
const useStyles = makeStyles({
root: {
display: 'flex',
},
label: {
marginTop: 8,
marginRight: 4,
},
formControl: {
minWidth: 120,
maxWidth: 300,
},
grow: {
flexGrow: 1,
},
});
export const TableTitle = ({
incidentStates,
onIncidentStatesChange,
}: {
incidentStates: IncidentStatus[];
onIncidentStatesChange: (states: IncidentStatus[]) => void;
}) => {
const classes = useStyles();
const handleIncidentStatusSelectChange = (event: any) => {
onIncidentStatesChange(event.target.value);
};
return (
<div className={classes.root}>
<Typography noWrap className={classes.label}>
Status:
</Typography>
<FormControl
className={classes.formControl}
variant="outlined"
size="small"
>
<Select
id="incidents-status-select"
multiple
value={incidentStates}
onChange={handleIncidentStatusSelectChange}
renderValue={(selected: any) => selected.join(', ')}
MenuProps={MenuProps}
>
{[PENDING, ACCEPTED, RESOLVED].map(state => (
<MenuItem key={state} value={state}>
<Checkbox
checked={incidentStates.indexOf(state as IncidentStatus) > -1}
/>
<ListItemText primary={incidentStatusLabels[state]} />
</MenuItem>
))}
</Select>
</FormControl>
</div>
);
};
@@ -0,0 +1,17 @@
/*
* 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.
*/
export * from './IncidentsPage';
export * from './IncidentsTable';
@@ -0,0 +1,150 @@
/*
* 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 { useApi, ItemCardGrid, Progress } from '@backstage/core';
import { makeStyles } from '@material-ui/core/styles';
import Card from '@material-ui/core/Card';
import CardContent from '@material-ui/core/CardContent';
import CardHeader from '@material-ui/core/CardHeader';
import Typography from '@material-ui/core/Typography';
import Link from '@material-ui/core/Link';
import { Schedule } from '../../types';
import { ilertApiRef } from '../../api';
import { OnCallShiftItem } from './OnCallShiftItem';
const useStyles = makeStyles(() => ({
card: {
margin: 16,
width: 'calc(100% - 32px)',
},
cardHeader: {
maxWidth: '100%',
},
cardContent: {
marginLeft: 80,
borderLeft: '1px #808289 solid',
position: 'relative',
},
indicatorNext: {
position: 'absolute',
top: 'calc(40% - 10px)',
left: -8,
width: 16,
height: 16,
background: '#92949c !important',
borderRadius: '50%',
},
indicatorCurrent: {
position: 'absolute',
top: 'calc(40% - 10px)',
left: -8,
width: 16,
height: 16,
background: '#ffb74d !important',
borderRadius: '50%',
},
beforeText: {
position: 'absolute',
top: 'calc(31% - 10px)',
left: -78,
width: 65,
height: 20,
textAlign: 'center',
color: '#808289',
},
marginBottom: {
marginBottom: 16,
},
link: {
fontSize: '1.5rem',
fontWeight: 700,
overflow: 'hidden',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
display: 'block',
},
}));
export const OnCallSchedulesGrid = ({
onCallSchedules,
isLoading,
refetchOnCallSchedules,
}: {
onCallSchedules: Schedule[];
isLoading: boolean;
refetchOnCallSchedules: () => void;
}) => {
const ilertApi = useApi(ilertApiRef);
const classes = useStyles();
if (isLoading) {
return <Progress />;
}
return (
<ItemCardGrid data-testid="docs-explore">
{!onCallSchedules?.length
? null
: onCallSchedules.map((schedule, index) => (
<Card key={index}>
<CardHeader
classes={{ content: classes.cardHeader }}
title={
<Link
href={ilertApi.getScheduleDetailsURL(schedule)}
className={classes.link}
>
{schedule.name}
</Link>
}
/>
<CardContent className={classes.cardContent}>
<div className={classes.indicatorCurrent} />
<OnCallShiftItem
shift={schedule.currentShift}
scheduleId={schedule.id}
refetchOnCallSchedules={refetchOnCallSchedules}
/>
<Typography className={classes.beforeText} variant="body2">
On call now
</Typography>
</CardContent>
<CardContent
className={`${classes.cardContent} ${classes.marginBottom}`}
>
<div className={classes.indicatorNext} />
<OnCallShiftItem
shift={schedule.nextShift}
scheduleId={schedule.id}
refetchOnCallSchedules={refetchOnCallSchedules}
/>
<Typography className={classes.beforeText} variant="body2">
Next on call
</Typography>
</CardContent>
</Card>
))}
</ItemCardGrid>
);
};
@@ -0,0 +1,56 @@
/*
* 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 { Content, ContentHeader, SupportButton } from '@backstage/core';
import { UnauthorizedError } from '../../api';
import Alert from '@material-ui/lab/Alert';
import { OnCallSchedulesGrid } from './OnCallSchedulesGrid';
import { MissingAuthorizationHeaderError } from '../Errors';
import { useOnCallSchedules } from '../../hooks/useOnCallSchedules';
export const OnCallSchedulesPage = () => {
const [
{ onCallSchedules, isLoading, error },
{ refetchOnCallSchedules },
] = useOnCallSchedules();
if (error) {
if (error instanceof UnauthorizedError) {
return <MissingAuthorizationHeaderError />;
}
return (
<Alert data-testid="error-message" severity="error">
{error.message}
</Alert>
);
}
return (
<Content>
<ContentHeader title="Who is on call?">
<SupportButton>
This helps you to bring iLert into your developer portal.
</SupportButton>
</ContentHeader>
<OnCallSchedulesGrid
onCallSchedules={onCallSchedules}
isLoading={isLoading}
refetchOnCallSchedules={refetchOnCallSchedules}
/>
</Content>
);
};
@@ -0,0 +1,105 @@
/*
* 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 Button from '@material-ui/core/Button';
import Grid from '@material-ui/core/Grid';
import Typography from '@material-ui/core/Typography';
import RepeatIcon from '@material-ui/icons/Repeat';
import { Shift } from '../../types';
import moment from 'moment';
import { makeStyles } from '@material-ui/core/styles';
import { ShiftOverrideModal } from '../Shift/ShiftOverrideModal';
const useStyles = makeStyles({
button: {
marginTop: 4,
padding: 0,
lineHeight: 1.8,
'& span': {
lineHeight: 1.8,
fontSize: '0.65rem',
},
'& svg': {
fontSize: '0.85rem !important',
},
},
});
export const OnCallShiftItem = ({
scheduleId,
shift,
refetchOnCallSchedules,
}: {
scheduleId: number;
shift: Shift;
refetchOnCallSchedules: () => void;
}) => {
const classes = useStyles();
const [isModalOpened, setIsModalOpened] = React.useState(false);
const handleOverride = () => {
setIsModalOpened(true);
};
if (!shift || !shift.start) {
return (
<Grid container spacing={0}>
<Grid item sm={12}>
<Typography variant="subtitle1" color="textSecondary">
Nobody
</Typography>
</Grid>
</Grid>
);
}
return (
<Grid container spacing={0}>
{shift && shift.user ? (
<Grid item sm={12}>
<Typography variant="subtitle1" noWrap>
{`${shift.user.firstName} ${shift.user.lastName} (${shift.user.username})`}
</Typography>
</Grid>
) : null}
<Grid item sm={12}>
<Typography variant="subtitle2" color="textSecondary">
{`${moment(shift.start).format('D MMM, HH:mm')} - ${moment(
shift.end,
).format('D MMM, HH:mm')}`}
</Typography>
</Grid>
<Grid item sm={12}>
<Button
color="primary"
size="small"
className={classes.button}
startIcon={<RepeatIcon />}
onClick={handleOverride}
>
<Typography variant="overline">Override shift</Typography>
</Button>
<ShiftOverrideModal
scheduleId={scheduleId}
shift={shift}
refetchOnCallSchedules={refetchOnCallSchedules}
isModalOpened={isModalOpened}
setIsModalOpened={setIsModalOpened}
/>
</Grid>
</Grid>
);
};
@@ -0,0 +1,17 @@
/*
* 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.
*/
export * from './OnCallSchedulesPage';
export * from './OnCallSchedulesGrid';
@@ -0,0 +1,196 @@
/*
* 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 { alertApiRef, useApi } from '@backstage/core';
import { makeStyles } from '@material-ui/core/styles';
import Button from '@material-ui/core/Button';
import TextField from '@material-ui/core/TextField';
import Dialog from '@material-ui/core/Dialog';
import DialogActions from '@material-ui/core/DialogActions';
import DialogContent from '@material-ui/core/DialogContent';
import DialogTitle from '@material-ui/core/DialogTitle';
import Autocomplete from '@material-ui/lab/Autocomplete';
import { Typography } from '@material-ui/core';
import { ilertApiRef } from '../../api';
import { useShiftOverride } from '../../hooks/useShiftOverride';
import { Shift } from '../../types';
import { DateTimePicker, MuiPickersUtilsProvider } from '@material-ui/pickers';
import DateFnsUtils from '@date-io/date-fns';
const useStyles = makeStyles(() => ({
container: {
display: 'flex',
flexWrap: 'wrap',
},
formControl: {
minWidth: 120,
width: '100%',
},
option: {
fontSize: 15,
'& > span': {
marginRight: 10,
fontSize: 18,
},
},
optionWrapper: {
display: 'flex',
width: '100%',
},
sourceImage: {
height: 22,
paddingRight: 4,
},
grow: {
flexGrow: 1,
},
}));
export const ShiftOverrideModal = ({
scheduleId,
shift,
refetchOnCallSchedules,
isModalOpened,
setIsModalOpened,
}: {
scheduleId: number;
shift: Shift;
refetchOnCallSchedules: () => void;
isModalOpened: boolean;
setIsModalOpened: (isModalOpened: boolean) => void;
}) => {
const [
{ isLoading, users, user, start, end },
{ setUser, setStart, setEnd, setIsLoading },
] = useShiftOverride(shift, isModalOpened);
const ilertApi = useApi(ilertApiRef);
const alertApi = useApi(alertApiRef);
const classes = useStyles();
const handleClose = () => {
setIsModalOpened(false);
};
const handleOverride = () => {
if (!shift || !shift.user) {
return;
}
setIsLoading(true);
setTimeout(async () => {
try {
const success = await ilertApi.overrideShift(
scheduleId,
user.id,
start,
end,
);
if (success) {
alertApi.post({ message: 'Shift overridden.' });
refetchOnCallSchedules();
}
} catch (err) {
alertApi.post({ message: err, severity: 'error' });
}
setIsModalOpened(false);
}, 250);
};
if (!shift) {
return null;
}
return (
<Dialog
open={isModalOpened}
onClose={handleClose}
aria-labelledby="override-shift-form-title"
>
<DialogTitle id="override-shift-form-title">Shift override</DialogTitle>
<DialogContent>
<MuiPickersUtilsProvider utils={DateFnsUtils}>
<Autocomplete
disabled={isLoading}
options={users}
value={user}
classes={{
root: classes.formControl,
option: classes.option,
}}
onChange={(_event: any, newValue: any) => {
setUser(newValue);
}}
autoHighlight
getOptionLabel={a => ilertApi.getUserInitials(a)}
renderOption={a => (
<div className={classes.optionWrapper}>
<Typography noWrap>{ilertApi.getUserInitials(a)}</Typography>
</div>
)}
renderInput={params => (
<TextField
{...params}
label="User"
variant="outlined"
fullWidth
inputProps={{
...params.inputProps,
autoComplete: 'new-password', // disable autocomplete and autofill
}}
/>
)}
/>
<DateTimePicker
label="Start"
inputVariant="outlined"
fullWidth
margin="normal"
ampm={false}
value={start}
className={classes.formControl}
onChange={date => {
setStart(date ? date.toISOString() : '');
}}
/>
<DateTimePicker
label="End"
inputVariant="outlined"
fullWidth
margin="normal"
ampm={false}
value={end}
className={classes.formControl}
onChange={date => {
setEnd(date ? date.toISOString() : '');
}}
/>
</MuiPickersUtilsProvider>
</DialogContent>
<DialogActions>
<Button
disabled={isLoading}
onClick={handleOverride}
color="primary"
variant="contained"
>
Override
</Button>
<Button disabled={isLoading} onClick={handleClose} color="primary">
Cancel
</Button>
</DialogActions>
</Dialog>
);
};
@@ -0,0 +1,134 @@
/*
* 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 { alertApiRef, 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 { UptimeMonitor } from '../../types';
export const UptimeMonitorActionsMenu = ({
uptimeMonitor,
onUptimeMonitorChanged,
}: {
uptimeMonitor: UptimeMonitor;
onUptimeMonitorChanged?: (uptimeMonitor: UptimeMonitor) => void;
}) => {
const ilertApi = useApi(ilertApiRef);
const alertApi = useApi(alertApiRef);
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
const callback = onUptimeMonitorChanged || ((_: UptimeMonitor): void => {});
const handleClick = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
};
const handleCloseMenu = () => {
setAnchorEl(null);
};
const handlePause = async (): Promise<void> => {
try {
const newUptimeMonitor = await ilertApi.pauseUptimeMonitor(uptimeMonitor);
handleCloseMenu();
alertApi.post({ message: 'Uptime monitor paused.' });
callback(newUptimeMonitor);
} catch (err) {
alertApi.post({ message: err, severity: 'error' });
}
};
const handleResume = async (): Promise<void> => {
try {
const newUptimeMonitor = await ilertApi.resumeUptimeMonitor(
uptimeMonitor,
);
handleCloseMenu();
alertApi.post({ message: 'Uptime monitor resumed.' });
callback(newUptimeMonitor);
} catch (err) {
alertApi.post({ message: err, severity: 'error' });
}
};
const handleOpenReport = async (): Promise<void> => {
try {
const um = await ilertApi.fetchUptimeMonitor(uptimeMonitor.id);
handleCloseMenu();
window.open(um.shareUrl, '_blank');
} catch (err) {
alertApi.post({ message: err, severity: 'error' });
}
};
return (
<>
<IconButton
aria-label="more"
aria-controls="long-menu"
aria-haspopup="true"
onClick={handleClick}
size="small"
>
<MoreVertIcon />
</IconButton>
<Menu
id={`uptime-monitor-actions-menu-${uptimeMonitor.id}`}
anchorEl={anchorEl}
keepMounted
open={Boolean(anchorEl)}
onClose={handleCloseMenu}
PaperProps={{
style: { maxHeight: 48 * 4.5 },
}}
>
{uptimeMonitor.paused ? (
<MenuItem key="ack" onClick={handleResume}>
<Typography variant="inherit" noWrap>
Resume
</Typography>
</MenuItem>
) : null}
{!uptimeMonitor.paused ? (
<MenuItem key="close" onClick={handlePause}>
<Typography variant="inherit" noWrap>
Pause
</Typography>
</MenuItem>
) : null}
<MenuItem key="report" onClick={handleCloseMenu}>
<Typography variant="inherit" noWrap>
<Link onClick={handleOpenReport}>View Report</Link>
</Typography>
</MenuItem>
<MenuItem key="details" onClick={handleCloseMenu}>
<Typography variant="inherit" noWrap>
<Link href={ilertApi.getUptimeMonitorDetailsURL(uptimeMonitor)}>
View in iLert
</Link>
</Typography>
</MenuItem>
</Menu>
</>
);
};
@@ -0,0 +1,49 @@
/*
* 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 { useApi } from '@backstage/core';
import Link from '@material-ui/core/Link';
import { makeStyles } from '@material-ui/core/styles';
import { UptimeMonitor } from '../../types';
import { ilertApiRef } from '../../api';
const useStyles = makeStyles({
link: {
lineHeight: '22px',
},
});
export const UptimeMonitorLink = ({
uptimeMonitor,
}: {
uptimeMonitor: UptimeMonitor | null;
}) => {
const ilertApi = useApi(ilertApiRef);
const classes = useStyles();
if (!uptimeMonitor) {
return null;
}
return (
<Link
className={classes.link}
href={ilertApi.getUptimeMonitorDetailsURL(uptimeMonitor)}
>
#{uptimeMonitor.id}
</Link>
);
};
@@ -0,0 +1,16 @@
/*
* 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.
*/
export * from './UptimeMonitorActionsMenu';
@@ -0,0 +1,73 @@
/*
* 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 Chip from '@material-ui/core/Chip';
import { withStyles } from '@material-ui/core/styles';
import { UptimeMonitor } from '../../types';
const UpChip = withStyles({
root: {
backgroundColor: '#4caf50',
color: 'white',
margin: 0,
},
})(Chip);
const DownChip = withStyles({
root: {
backgroundColor: '#d32f2f',
color: 'white',
margin: 0,
},
})(Chip);
const UnknownChip = withStyles({
root: {
backgroundColor: '#92949c',
color: 'white',
margin: 0,
},
})(Chip);
export const uptimeMonitorStatusLabels = {
['up']: 'Up',
['down']: 'Down',
['unknown']: 'Unknown',
} as Record<string, string>;
export const StatusChip = ({
uptimeMonitor,
}: {
uptimeMonitor: UptimeMonitor;
}) => {
let label = `${uptimeMonitorStatusLabels[uptimeMonitor.status]}`;
if (uptimeMonitor.paused) {
label = 'Paused';
return <UnknownChip label={label} size="small" />;
}
switch (uptimeMonitor.status) {
case 'up':
return <UpChip label={label} size="small" />;
case 'down':
return <DownChip label={label} size="small" />;
case 'unknown':
return <UnknownChip label={label} size="small" />;
default:
return <Chip label={label} size="small" />;
}
};
@@ -0,0 +1,39 @@
/*
* 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 { UptimeMonitor } from '../../types';
import Typography from '@material-ui/core/Typography';
export const UptimeMonitorCheckType = ({
uptimeMonitor,
}: {
uptimeMonitor: UptimeMonitor;
}) => {
switch (uptimeMonitor.region) {
case 'EU':
return (
<Typography
noWrap
>{`${uptimeMonitor.checkType.toUpperCase()} 🇩🇪`}</Typography>
);
default:
return (
<Typography
noWrap
>{`${uptimeMonitor.checkType.toUpperCase()} 🇺🇸`}</Typography>
);
}
};
@@ -0,0 +1,59 @@
/*
* 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 { Content, ContentHeader, SupportButton } from '@backstage/core';
import { UnauthorizedError } from '../../api';
import Alert from '@material-ui/lab/Alert';
import { UptimeMonitorsTable } from './UptimeMonitorsTable';
import { MissingAuthorizationHeaderError } from '../Errors';
import { useUptimeMonitors } from '../../hooks/useUptimeMonitors';
export const UptimeMonitorsPage = () => {
const [
{ tableState, uptimeMonitors, isLoading, error },
{ onChangePage, onChangeRowsPerPage, onUptimeMonitorChanged },
] = useUptimeMonitors();
if (error) {
if (error instanceof UnauthorizedError) {
return <MissingAuthorizationHeaderError />;
}
return (
<Alert data-testid="error-message" severity="error">
{error.message}
</Alert>
);
}
return (
<Content>
<ContentHeader title="Uptime Monitors">
<SupportButton>
This helps you to bring iLert into your developer portal.
</SupportButton>
</ContentHeader>
<UptimeMonitorsTable
uptimeMonitors={uptimeMonitors}
tableState={tableState}
isLoading={isLoading}
onChangePage={onChangePage}
onChangeRowsPerPage={onChangeRowsPerPage}
onUptimeMonitorChanged={onUptimeMonitorChanged}
/>
</Content>
);
};
@@ -0,0 +1,173 @@
/*
* 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 { makeStyles } from '@material-ui/core/styles';
import { Table, TableColumn } from '@backstage/core';
import { TableState } from '../../api';
import { UptimeMonitor } from '../../types';
import { StatusChip } from './StatusChip';
import Typography from '@material-ui/core/Typography';
import moment from 'moment';
import { EscalationPolicyLink } from '../EscalationPolicy/EscalationPolicyLink';
import { UptimeMonitorCheckType } from './UptimeMonitorCheckType';
import { UptimeMonitorActionsMenu } from '../UptimeMonitor/UptimeMonitorActionsMenu';
import { UptimeMonitorLink } from '../UptimeMonitor/UptimeMonitorLink';
const useStyles = makeStyles(theme => ({
empty: {
padding: theme.spacing(2),
display: 'flex',
justifyContent: 'center',
},
}));
export const UptimeMonitorsTable = ({
uptimeMonitors,
tableState,
isLoading,
onChangePage,
onChangeRowsPerPage,
onUptimeMonitorChanged,
}: {
uptimeMonitors: UptimeMonitor[];
tableState: TableState;
isLoading: boolean;
onChangePage: (page: number) => void;
onChangeRowsPerPage: (pageSize: number) => void;
onUptimeMonitorChanged: (uptimeMonitor: UptimeMonitor) => void;
}) => {
const classes = useStyles();
const smColumnStyle = {
width: '5%',
maxWidth: '5%',
};
const mdColumnStyle = {
width: '10%',
maxWidth: '10%',
};
const lgColumnStyle = {
width: '15%',
maxWidth: '15%',
};
const columns: TableColumn[] = [
{
title: 'ID',
field: 'id',
highlight: true,
cellStyle: mdColumnStyle,
headerStyle: mdColumnStyle,
render: rowData => (
<UptimeMonitorLink uptimeMonitor={rowData as UptimeMonitor} />
),
},
{
title: 'Name',
field: 'name',
render: rowData => (
<Typography>{(rowData as UptimeMonitor).name}</Typography>
),
},
{
title: 'Check Type',
field: 'checkType',
cellStyle: lgColumnStyle,
headerStyle: lgColumnStyle,
render: rowData => (
<UptimeMonitorCheckType uptimeMonitor={rowData as UptimeMonitor} />
),
},
{
title: 'Last state change',
field: 'lastStatusChange',
type: 'datetime',
cellStyle: mdColumnStyle,
headerStyle: mdColumnStyle,
render: rowData => (
<Typography noWrap>
{moment
.duration(
moment((rowData as UptimeMonitor).lastStatusChange).diff(
moment(),
),
)
.humanize()}
</Typography>
),
},
{
title: 'Escalation policy',
field: 'assignedTo',
cellStyle: lgColumnStyle,
headerStyle: lgColumnStyle,
render: rowData => (
<EscalationPolicyLink
escalationPolicy={(rowData as UptimeMonitor).escalationPolicy}
/>
),
},
{
title: 'Status',
field: 'status',
cellStyle: smColumnStyle,
headerStyle: smColumnStyle,
render: rowData => (
<StatusChip uptimeMonitor={rowData as UptimeMonitor} />
),
},
{
title: '',
field: '',
cellStyle: smColumnStyle,
headerStyle: smColumnStyle,
render: rowData => (
<UptimeMonitorActionsMenu
uptimeMonitor={rowData as UptimeMonitor}
onUptimeMonitorChanged={onUptimeMonitorChanged}
/>
),
},
];
return (
<Table
options={{
sorting: true,
search: true,
paging: true,
actionsColumnIndex: -1,
pageSize: tableState.pageSize,
pageSizeOptions: [10, 20, 50, 100],
padding: 'dense',
loadingType: 'overlay',
showEmptyDataSourceMessage: !isLoading,
}}
emptyContent={
<Typography color="textSecondary" className={classes.empty}>
No uptime monitor
</Typography>
}
page={tableState.page}
onChangePage={onChangePage}
onChangeRowsPerPage={onChangeRowsPerPage}
localization={{ header: { actions: undefined } }}
isLoading={isLoading}
columns={columns}
data={uptimeMonitors}
/>
);
};
@@ -0,0 +1,17 @@
/*
* 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.
*/
export * from './UptimeMonitorsPage';
export * from './UptimeMonitorsTable';
+18
View File
@@ -0,0 +1,18 @@
/*
* 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.
*/
export * from './ILertPage';
export * from './ILertCard';
+16
View File
@@ -0,0 +1,16 @@
/*
* 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.
*/
export const ILERT_INTEGRATION_KEY = 'ilert.com/integration-key';
+28
View File
@@ -0,0 +1,28 @@
/*
* 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 { useEntity } from '@backstage/plugin-catalog-react';
import { ILERT_INTEGRATION_KEY } from '../constants';
export function useILertEntity() {
const { entity } = useEntity();
const integrationKey =
entity.metadata.annotations?.[ILERT_INTEGRATION_KEY] || '';
const name = entity.metadata.name;
return { integrationKey, name };
}
+103
View File
@@ -0,0 +1,103 @@
/*
* 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 { AlertSource, UptimeMonitor } from '../types';
export const useAlertSource = (integrationKey: string) => {
const ilertApi = useApi(ilertApiRef);
const errorApi = useApi(errorApiRef);
const [alertSource, setAlertSource] = React.useState<AlertSource | null>(
null,
);
const [isAlertSourceLoading, setIsAlertSourceLoading] = React.useState(false);
const [
uptimeMonitor,
setUptimeMonitor,
] = React.useState<UptimeMonitor | null>(null);
const [isUptimeMonitorLoading, setIsUptimeMonitorLoading] = React.useState(
false,
);
const fetchAlertSourceCall = async () => {
try {
if (!integrationKey) {
return;
}
setIsAlertSourceLoading(true);
const data = await ilertApi.fetchAlertSource(integrationKey);
setAlertSource(data || null);
setIsAlertSourceLoading(false);
} catch (e) {
setIsAlertSourceLoading(false);
if (!(e instanceof UnauthorizedError)) {
errorApi.post(e);
}
throw e;
}
};
const {
error: alertSourceError,
retry: alertSourceRetry,
} = useAsyncRetry(fetchAlertSourceCall, [integrationKey]);
const fetchUptimeMonitorCall = async () => {
try {
if (!alertSource || alertSource.integrationType !== 'MONITOR') {
return;
}
setIsUptimeMonitorLoading(true);
const data = await ilertApi.fetchUptimeMonitor(alertSource.id);
setUptimeMonitor(data || null);
setIsUptimeMonitorLoading(false);
} catch (e) {
setIsUptimeMonitorLoading(false);
if (!(e instanceof UnauthorizedError)) {
errorApi.post(e);
}
throw e;
}
};
const {
error: uptimeMonitorError,
retry: uptimeMonitorRetry,
} = useAsyncRetry(fetchUptimeMonitorCall, [alertSource]);
const retry = () => {
alertSourceRetry();
uptimeMonitorRetry();
};
return [
{
alertSource,
uptimeMonitor,
error: alertSourceError || uptimeMonitorError,
isLoading: isAlertSourceLoading || isUptimeMonitorLoading,
},
{
retry,
setIsLoading: setIsAlertSourceLoading,
refetchAlertSource: fetchAlertSourceCall,
setAlertSource,
},
] as const;
};
@@ -0,0 +1,73 @@
/*
* 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, IncidentResponder } from '../types';
export const useAssignIncident = (incident: Incident | null, open: boolean) => {
const ilertApi = useApi(ilertApiRef);
const errorApi = useApi(errorApiRef);
const [incidentRespondersList, setIncidentRespondersList] = React.useState<
IncidentResponder[]
>([]);
const [
incidentResponder,
setIncidentResponder,
] = React.useState<IncidentResponder | null>(null);
const [isLoading, setIsLoading] = React.useState(false);
const { error, retry } = useAsyncRetry(async () => {
try {
if (!incident || !open) {
return;
}
const data = await ilertApi.fetchIncidentResponders(incident);
if (data && Array.isArray(data)) {
const groups = [
'SUGGESTED',
'USER',
'ESCALATION_POLICY',
'ON_CALL_SCHEDULE',
];
data.sort((a, b) => groups.indexOf(a.group) - groups.indexOf(b.group));
setIncidentRespondersList(data);
}
} catch (e) {
if (!(e instanceof UnauthorizedError)) {
errorApi.post(e);
}
throw e;
}
}, [incident, open]);
return [
{
incidentRespondersList,
incidentResponder,
error,
isLoading,
},
{
setIncidentRespondersList,
setIncidentResponder,
setIsLoading,
retry,
},
] as const;
};
+151
View File
@@ -0,0 +1,151 @@
/*
* 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 {
GetIncidentsOpts,
ilertApiRef,
TableState,
UnauthorizedError,
} from '../api';
import { useApi, errorApiRef } from '@backstage/core';
import { useAsyncRetry } from 'react-use';
import { ACCEPTED, PENDING, Incident, IncidentStatus } from '../types';
export const useIncidents = (
paging: boolean,
alertSources?: number[] | string[],
) => {
const ilertApi = useApi(ilertApiRef);
const errorApi = useApi(errorApiRef);
const [tableState, setTableState] = React.useState<TableState>({
page: 0,
pageSize: 10,
});
const [states, setStates] = React.useState<IncidentStatus[]>([
ACCEPTED,
PENDING,
]);
const [incidentsList, setIncidentsList] = React.useState<Incident[]>([]);
const [incidentsCount, setIncidentsCount] = React.useState(0);
const [isLoading, setIsLoading] = React.useState(false);
const fetchIncidentsCall = async () => {
try {
setIsLoading(true);
const opts: GetIncidentsOpts = {
states,
alertSources,
};
if (paging) {
opts.maxResults = tableState.pageSize;
opts.startIndex = tableState.page * tableState.pageSize;
}
const data = await ilertApi.fetchIncidents(opts);
setIncidentsList(data || []);
setIsLoading(false);
} catch (e) {
if (!(e instanceof UnauthorizedError)) {
errorApi.post(e);
}
setIsLoading(false);
throw e;
}
};
const fetchIncidentsCountCall = async () => {
try {
const count = await ilertApi.fetchIncidentsCount({ states });
setIncidentsCount(count || 0);
} catch (e) {
if (!(e instanceof UnauthorizedError)) {
errorApi.post(e);
}
throw e;
}
};
const fetchIncidents = useAsyncRetry(fetchIncidentsCall, [
tableState,
states,
]);
const refetchIncidents = () => {
setTableState({ ...tableState, page: 0 });
Promise.all([fetchIncidentsCall(), fetchIncidentsCountCall()]);
};
const fetchIncidentsCount = useAsyncRetry(fetchIncidentsCountCall, [states]);
const error = fetchIncidents.error || fetchIncidentsCount.error;
const retry = () => {
fetchIncidents.retry();
fetchIncidentsCount.retry();
};
const onIncidentChanged = (newIncident: Incident) => {
let shouldRefetchIncidents = false;
setIncidentsList(
incidentsList.reduce((acc: Incident[], incident: Incident) => {
if (newIncident.id === incident.id) {
if (states.includes(newIncident.status)) {
acc.push(newIncident);
} else {
shouldRefetchIncidents = true;
}
return acc;
}
acc.push(incident);
return acc;
}, []),
);
if (shouldRefetchIncidents) {
refetchIncidents();
}
};
const onChangePage = (page: number) => {
setTableState({ ...tableState, page });
};
const onChangeRowsPerPage = (p: number) => {
setTableState({ ...tableState, pageSize: p });
};
const onIncidentStatesChange = (s: IncidentStatus[]) => {
setStates(s);
};
return [
{
tableState,
states,
incidents: incidentsList,
incidentsCount,
error,
isLoading,
},
{
setTableState,
setStates,
setIncidentsList,
setIsLoading,
retry,
onIncidentChanged,
refetchIncidents,
onChangePage,
onChangeRowsPerPage,
onIncidentStatesChange,
},
] as const;
};
+77
View File
@@ -0,0 +1,77 @@
/*
* 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 { AlertSource } from '../types';
export const useNewIncident = (
open: boolean,
initialAlertSource?: AlertSource | null,
) => {
const ilertApi = useApi(ilertApiRef);
const errorApi = useApi(errorApiRef);
const [alertSourcesList, setAlertSourcesList] = React.useState<AlertSource[]>(
[],
);
const [alertSource, setAlertSource] = React.useState<AlertSource | null>(
null,
);
const [summary, setSummary] = React.useState('');
const [details, setDetails] = React.useState('');
const [isLoading, setIsLoading] = React.useState(false);
const fetchAlertSources = useAsyncRetry(async () => {
try {
if (!open || initialAlertSource) {
return;
}
const count = await ilertApi.fetchAlertSources();
setAlertSourcesList(count || 0);
} catch (e) {
if (!(e instanceof UnauthorizedError)) {
errorApi.post(e);
}
throw e;
}
}, [open]);
const error = fetchAlertSources.error;
const retry = () => {
fetchAlertSources.retry();
};
return [
{
alertSources: alertSourcesList,
alertSource: initialAlertSource ? initialAlertSource : alertSource,
summary,
details,
error,
isLoading,
},
{
setAlertSourcesList,
setAlertSource,
setSummary,
setDetails,
setIsLoading,
retry,
},
] as const;
};
@@ -0,0 +1,60 @@
/*
* 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 { Schedule } from '../types';
export const useOnCallSchedules = () => {
const ilertApi = useApi(ilertApiRef);
const errorApi = useApi(errorApiRef);
const [onCallSchedulesList, setOnCallSchedulesList] = React.useState<
Schedule[]
>([]);
const [isLoading, setIsLoading] = React.useState(false);
const fetchOnCallSchedulesCall = async () => {
try {
setIsLoading(true);
const data = await ilertApi.fetchOnCallSchedules();
setOnCallSchedulesList(data || []);
setIsLoading(false);
} catch (e) {
setIsLoading(false);
if (!(e instanceof UnauthorizedError)) {
errorApi.post(e);
}
throw e;
}
};
const { error, retry } = useAsyncRetry(fetchOnCallSchedulesCall, []);
return [
{
onCallSchedules: onCallSchedulesList,
error,
isLoading,
},
{
retry,
setIsLoading,
refetchOnCallSchedules: fetchOnCallSchedulesCall,
},
] as const;
};
@@ -0,0 +1,78 @@
/*
* 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 { User, Shift } from '../types';
export const useShiftOverride = (s: Shift, isModalOpened: boolean) => {
const ilertApi = useApi(ilertApiRef);
const errorApi = useApi(errorApiRef);
const [shift, setShift] = React.useState<Shift>(s);
const [usersList, setUsersList] = React.useState<User[]>([]);
const [isLoading, setIsLoading] = React.useState(false);
const { error, retry } = useAsyncRetry(async () => {
try {
if (!isModalOpened) {
return;
}
setIsLoading(true);
const data = await ilertApi.fetchUsers();
setUsersList(data || []);
setIsLoading(false);
} catch (e) {
setIsLoading(false);
if (!(e instanceof UnauthorizedError)) {
errorApi.post(e);
}
throw e;
}
}, [isModalOpened]);
const setUser = (user: User) => {
setShift({ ...shift, user });
};
const setStart = (start: string) => {
setShift({ ...shift, start });
};
const setEnd = (end: string) => {
setShift({ ...shift, end });
};
return [
{
shift,
users: usersList,
user: shift.user,
start: shift.start,
end: shift.end,
error,
isLoading,
},
{
retry,
setIsLoading,
setUser,
setStart,
setEnd,
},
] as const;
};
@@ -0,0 +1,88 @@
/*
* 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, TableState, UnauthorizedError } from '../api';
import { useApi, errorApiRef } from '@backstage/core';
import { useAsyncRetry } from 'react-use';
import { UptimeMonitor } from '../types';
export const useUptimeMonitors = () => {
const ilertApi = useApi(ilertApiRef);
const errorApi = useApi(errorApiRef);
const [tableState, setTableState] = React.useState<TableState>({
page: 0,
pageSize: 10,
});
const [uptimeMonitorsList, setUptimeMonitorsList] = React.useState<
UptimeMonitor[]
>([]);
const [isLoading, setIsLoading] = React.useState(false);
const { error, retry } = useAsyncRetry(async () => {
try {
setIsLoading(true);
const data = await ilertApi.fetchUptimeMonitors();
setUptimeMonitorsList(data || []);
setIsLoading(false);
} catch (e) {
setIsLoading(false);
if (!(e instanceof UnauthorizedError)) {
errorApi.post(e);
}
throw e;
}
}, [tableState]);
const onUptimeMonitorChanged = (newUptimeMonitor: UptimeMonitor) => {
setUptimeMonitorsList(
uptimeMonitorsList.map(
(uptimeMonitor: UptimeMonitor): UptimeMonitor => {
if (newUptimeMonitor.id === uptimeMonitor.id) {
return newUptimeMonitor;
}
return uptimeMonitor;
},
),
);
};
const onChangePage = (page: number) => {
setTableState({ ...tableState, page });
};
const onChangeRowsPerPage = (pageSize: number) => {
setTableState({ ...tableState, pageSize });
};
return [
{
tableState,
uptimeMonitors: uptimeMonitorsList,
error,
isLoading,
},
{
setTableState,
setUptimeMonitorsList,
retry,
onUptimeMonitorChanged,
onChangePage,
onChangeRowsPerPage,
setIsLoading,
},
] as const;
};
+33
View File
@@ -0,0 +1,33 @@
/*
* 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 { IconComponent } from '@backstage/core';
import ILertIconComponent from './assets/ilert.icon.svg';
export {
ilertPlugin,
ilertPlugin as plugin,
ILertPage,
EntityILertCard,
} from './plugin';
export {
ILertPage as Router,
isPluginApplicableToEntity,
isPluginApplicableToEntity as isILertAvailable,
ILertCard,
} from './components';
export * from './api';
export * from './route-refs';
export const ILertIcon: IconComponent = ILertIconComponent;
+22
View File
@@ -0,0 +1,22 @@
/*
* 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 { ilertPlugin } from './plugin';
describe('plugin-ilert', () => {
it('should export plugin', () => {
expect(ilertPlugin).toBeDefined();
});
});
+60
View File
@@ -0,0 +1,60 @@
/*
* 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 {
configApiRef,
createApiFactory,
createPlugin,
discoveryApiRef,
identityApiRef,
createRoutableExtension,
createComponentExtension,
} from '@backstage/core';
import { ILertClient, ilertApiRef } from './api';
import { iLertRouteRef } from './route-refs';
export const ilertPlugin = createPlugin({
id: 'ilert',
apis: [
createApiFactory({
api: ilertApiRef,
deps: {
discoveryApi: discoveryApiRef,
identityApi: identityApiRef,
configApi: configApiRef,
},
factory: ({ discoveryApi, configApi }) =>
ILertClient.fromConfig(configApi, discoveryApi),
}),
],
routes: {
root: iLertRouteRef,
},
});
export const ILertPage = ilertPlugin.provide(
createRoutableExtension({
component: () => import('./components').then(m => m.ILertPage),
mountPoint: iLertRouteRef,
}),
);
export const EntityILertCard = ilertPlugin.provide(
createComponentExtension({
component: {
lazy: () => import('./components/ILertCard').then(m => m.ILertCard),
},
}),
);
+24
View File
@@ -0,0 +1,24 @@
/*
* 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 { createRouteRef } from '@backstage/core';
import ILertIcon from './assets/ilert.icon.svg';
export const iLertRouteRef = createRouteRef({
icon: ILertIcon,
path: '/ilert',
title: 'iLert',
});
+17
View File
@@ -0,0 +1,17 @@
/*
* 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 '@testing-library/jest-dom';
import 'cross-fetch/polyfill';
+312
View File
@@ -0,0 +1,312 @@
/*
* 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.
*/
export interface Incident {
id: number;
summary: string;
details: string;
reportTime: string;
resolvedOn: string;
status: IncidentStatus;
priority: IncidentPriority;
incidentKey: string;
alertSource: AlertSource | null;
assignedTo: User | null;
logEntries: LogEntry[];
links: Link[];
images: Image[];
subscribers: Subscriber[];
commentText: string;
commentPublishToSubscribers: boolean;
}
export const PENDING = 'PENDING';
export const ACCEPTED = 'ACCEPTED';
export const RESOLVED = 'RESOLVED';
export type IncidentStatus = typeof PENDING | typeof ACCEPTED | typeof RESOLVED;
export type IncidentPriority = 'HIGH' | 'LOW';
export interface Link {
href: string;
text: string;
}
export interface Image {
src: string;
href: string;
alt: string;
}
export type SubscriberType = 'TEAM' | 'USER';
export interface Subscriber {
id: number;
name: string;
type: SubscriberType;
}
export interface LogEntry {
id: number;
timestamp: string;
logEntryType: string;
text: string;
incidentId?: number;
iconName?: string;
iconClass?: string;
filterTypes?: string[];
}
export interface User {
id: number;
username: string;
firstName: string;
lastName: string;
email: string;
mobile: Phone;
landline: Phone;
timezone?: string;
language?: Language;
role?: UserRole;
notificationPreferences?: any[];
position: string;
department: string;
}
export type UserRole =
| 'USER'
| 'ADMIN'
| 'STAKEHOLDER'
| 'ACCOUNT_OWNER'
| 'RESPONDER';
export type Language = 'de' | 'en';
export interface Phone {
regionCode: string;
number: string;
}
export interface AlertSource {
id: number;
name: string;
status: AlertSourceStatus;
escalationPolicy: EscalationPolicy;
integrationType: AlertSourceIntegrationType;
integrationKey?: string;
iconUrl?: string;
lightIconUrl?: string;
darkIconUrl?: string;
incidentCreation?: AlertSourceIncidentCreation;
incidentPriorityRule?: AlertSourceIncidentPriorityRule;
emailFiltered?: boolean;
emailResolveFiltered?: boolean;
active?: boolean;
emailPredicates?: AlertSourceEmailPredicate[];
emailResolvePredicates?: AlertSourceEmailPredicate[];
filterOperator?: AlertSourceFilterOperator;
resolveFilterOperator?: AlertSourceFilterOperator;
supportHours?: AlertSourceSupportHours;
heartbeat?: AlertSourceHeartbeat;
autotaskMetadata?: AlertSourceAutotaskMetadata;
autoResolutionTimeout?: string;
teams: TeamShort[];
}
export interface TeamShort {
id: number;
name: string;
}
export interface TeamMember {
user: User;
role: 'STAKEHOLDER' | 'RESPONDER' | 'USER' | 'ADMIN';
}
export type AlertSourceStatus =
| 'PENDING'
| 'ALL_ACCEPTED'
| 'ALL_RESOLVED'
| 'IN_MAINTENANCE'
| 'DISABLED';
export type AlertSourceIntegrationType =
| 'NAGIOS'
| 'ICINGA'
| 'EMAIL'
| 'SMS'
| 'API'
| 'CRN'
| 'HEARTBEAT'
| 'PRTG'
| 'PINGDOM'
| 'CLOUDWATCH'
| 'AWSPHD'
| 'STACKDRIVER'
| 'INSTANA'
| 'ZABBIX'
| 'SOLARWINDS'
| 'PROMETHEUS'
| 'NEWRELIC'
| 'GRAFANA'
| 'GITHUB'
| 'DATADOG'
| 'UPTIMEROBOT'
| 'APPDYNAMICS'
| 'DYNATRACE'
| 'TOPDESK'
| 'STATUSCAKE'
| 'MONITOR'
| 'TOOL'
| 'CHECKMK'
| 'AUTOTASK'
| 'AWSBUDGET'
| 'KENTIXAM'
| 'CONSUL'
| 'ZAMMAD'
| 'SIGNALFX'
| 'SPLUNK'
| 'KUBERNETES'
| 'SEMATEXT'
| 'SENTRY'
| 'SUMOLOGIC'
| 'RAYGUN'
| 'MXTOOLBOX'
| 'ESWATCHER'
| 'AMAZONSNS'
| 'KAPACITOR'
| 'CORTEXXSOAR'
| string;
export type AlertSourceIncidentCreation =
| 'ONE_INCIDENT_PER_EMAIL'
| 'ONE_INCIDENT_PER_EMAIL_SUBJECT'
| 'ONE_PENDING_INCIDENT_ALLOWED'
| 'ONE_OPEN_INCIDENT_ALLOWED'
| 'OPEN_RESOLVE_ON_EXTRACTION';
export type AlertSourceFilterOperator = 'AND' | 'OR';
export type AlertSourceIncidentPriorityRule =
| 'HIGH'
| 'LOW'
| 'HIGH_DURING_SUPPORT_HOURS'
| 'LOW_DURING_SUPPORT_HOURS';
export interface AlertSourceEmailPredicate {
field: 'EMAIL_FROM' | 'EMAIL_SUBJECT' | 'EMAIL_BODY';
criteria:
| 'CONTAINS_ANY_WORDS'
| 'CONTAINS_NOT_WORDS'
| 'CONTAINS_STRING'
| 'CONTAINS_NOT_STRING'
| 'IS_STRING'
| 'IS_NOT_STRING'
| 'MATCHES_REGEX'
| 'MATCHES_NOT_REGEX';
value: string;
}
export type AlertSourceTimeZone =
| 'Europe/Berlin'
| 'America/New_York'
| 'America/Los_Angeles'
| 'Asia/Istanbul';
export interface AlertSourceSupportDay {
start: string;
end: string;
}
export interface AlertSourceSupportHours {
timezone: AlertSourceTimeZone;
autoRaiseIncidents: boolean;
supportDays: {
MONDAY: AlertSourceSupportDay;
TUESDAY: AlertSourceSupportDay;
WEDNESDAY: AlertSourceSupportDay;
THURSDAY: AlertSourceSupportDay;
FRIDAY: AlertSourceSupportDay;
SATURDAY: AlertSourceSupportDay;
SUNDAY: AlertSourceSupportDay;
};
}
export interface AlertSourceHeartbeat {
summary: string;
intervalSec: number;
status: 'OVERDUE' | 'ON_TIME' | 'NEVER_RECEIVED';
}
export interface AlertSourceAutotaskMetadata {
userName: string;
secret: string;
apiIntegrationCode: string;
webServer: string;
}
export interface EscalationPolicy {
id: number;
name: string;
escalationRules: EscalationRule[];
newEscalationRule: EscalationRule;
repeating?: boolean;
frequency?: number;
teams: TeamShort[];
}
export interface EscalationRule {
user: User | null;
schedule: Schedule | null;
escalationTimeout: number;
}
export interface Schedule {
id: number;
name: string;
timezone: string;
startsOn: string;
currentShift: Shift;
nextShift: Shift;
shifts: Shift[];
overrides: Shift[];
teams: TeamShort[];
}
export interface Shift {
user: User;
start: string;
end: string;
}
export interface UptimeMonitor {
id: number;
name: string;
region: 'EU' | 'US';
checkType: 'http' | 'tcp' | 'udp' | 'ping';
checkParams: UptimeMonitorCheckParams;
intervalSec: number;
timeoutMs: number;
createIncidentAfterFailedChecks: number;
paused: boolean;
embedUrl: string;
shareUrl: string;
status: string;
lastStatusChange: string;
escalationPolicy: EscalationPolicy;
teams: TeamShort[];
}
export interface UptimeMonitorCheckParams {
host?: string;
port?: number;
url?: string;
}
export interface IncidentResponder {
group: 'SUGGESTED' | 'USER' | 'ESCALATION_POLICY' | 'ON_CALL_SCHEDULE';
id: number;
name: string;
disabled: boolean;
}