diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index b07519824b..206c1a7883 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -16,6 +16,7 @@ /plugins/search-* @backstage/techdocs-core /plugins/techdocs @backstage/techdocs-core /plugins/techdocs-backend @backstage/techdocs-core +/plugins/ilert @yacut /packages/search-common @backstage/techdocs-core /packages/techdocs-common @backstage/techdocs-core /.changeset/cost-insights-* @backstage/silver-lining diff --git a/app-config.yaml b/app-config.yaml index 3c4f2d7ea5..b1c915f85a 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -81,6 +81,13 @@ proxy: headers: Authorization: ${SENTRY_TOKEN} + '/ilert': + target: https://api.ilert.com + allowedMethods: ['GET', 'POST', 'PUT'] + allowedHeaders: ['Authorization'] + headers: + Authorization: ${ILERT_AUTH_HEADER} + organization: name: My Company diff --git a/microsite/data/plugins/ilert.yaml b/microsite/data/plugins/ilert.yaml new file mode 100644 index 0000000000..29cc4f6aa5 --- /dev/null +++ b/microsite/data/plugins/ilert.yaml @@ -0,0 +1,15 @@ +--- +title: iLert +author: iLert +authorUrl: https://www.ilert.com +category: Monitoring +description: iLert is a platform for alerting, on-call management and uptime monitoring targeted at DevOps and IT teams. +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 + - uptime + - on-call diff --git a/plugins/ilert/.eslintrc.js b/plugins/ilert/.eslintrc.js new file mode 100644 index 0000000000..4d51616e98 --- /dev/null +++ b/plugins/ilert/.eslintrc.js @@ -0,0 +1,6 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], + rules: { + quotes: ['error', 'single'], + }, +}; diff --git a/plugins/ilert/README.md b/plugins/ilert/README.md new file mode 100644 index 0000000000..da019d6bb6 --- /dev/null +++ b/plugins/ilert/README.md @@ -0,0 +1,128 @@ +# @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 or creating tickets in external incident management tools. + +## Overview + +This plugin gives an overview about ongoing iLert incidents, on-call and uptime monitor status. +See who is on-call, which incidents are active and trigger incidents directly from backstage for the configured alert sources. + +In detail this plugin provides: + +- Information details about the person on-call (all escalation levels of the current time) +- A way to override the current on-call person +- A list of active incidents +- A way to trigger a new incident +- A way to reassign/acknowledge/resolve an incident +- A way to trigger an incident action +- A way to trigger an immediate maintenance +- A way to disable/enable an alert source +- A list of uptime monitors + +## Setup instructions + +Install the plugin: + +```bash +# From the Backstage repository root +cd packages/app +yarn add @backstage/plugin-ilert +``` + +Add it to the `EntityPage.tsx`: + +```ts +import { + isPluginApplicableToEntity as isILertAvailable, + EntityILertCard, +} from '@backstage/plugin-ilert'; + +// ... + + + + + + +; +// ... +``` + +> To force an iLert card for each entity just add the `` component. An instruction card will appear 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 by the plugin - for example: + +```tsx +import { ILertPage } from '@backstage/plugin-ilert'; + + // ... + } /> + // ... +; +``` + +Modify your sidebar in [`Root.tsx`](https://github.com/backstage/backstage/blob/master/packages/app/src/components/Root/Root.tsx) to include the icon component exported by the plugin - for example: + +```tsx +import { ILertIcon } from '@backstage/plugin-ilert'; + + // ... + + // ... +; +``` + +## 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: + baseUrl: 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: ${ILERT_AUTH_HEADER} +``` + +Then start the backend, passing the authorization header (bearer token or basic auth) as environment variable: + +```bash +$ ILERT_AUTH_HEADER='' yarn start +``` + +## Integration Key + +The information displayed for each entity is based on the alert source integration key. + +### 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 +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: example + annotations: + ilert.com/integration-key: [INTEGRATION_KEY] +``` diff --git a/plugins/ilert/config.d.ts b/plugins/ilert/config.d.ts new file mode 100644 index 0000000000..6bf3f569ab --- /dev/null +++ b/plugins/ilert/config.d.ts @@ -0,0 +1,31 @@ +/* + * 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 Config { + ilert?: { + /** + * Domain used by users to access iLert web UI. + * Example: https://my-app.ilert.com/ + * @visibility frontend + */ + baseUrl: string; + + /** + * Path to use for requests via the proxy, defaults to /ilert/api + * @visibility frontend + */ + proxyPath?: string; + }; +} diff --git a/plugins/ilert/dev/index.tsx b/plugins/ilert/dev/index.tsx new file mode 100644 index 0000000000..737666e650 --- /dev/null +++ b/plugins/ilert/dev/index.tsx @@ -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: , + title: 'Root Page', + }) + .render(); diff --git a/plugins/ilert/package.json b/plugins/ilert/package.json new file mode 100644 index 0000000000..24376e03a8 --- /dev/null +++ b/plugins/ilert/package.json @@ -0,0 +1,56 @@ +{ + "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.9", + "@backstage/errors": "^0.1.1", + "@backstage/plugin-catalog-react": "^0.1.4", + "@backstage/theme": "^0.2.7", + "@date-io/luxon": "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", + "humanize-duration": "^3.26.0", + "luxon": "^1.26.0", + "react": "^16.13.1", + "react-dom": "^16.13.1", + "react-use": "^17.2.4" + }, + "devDependencies": { + "@backstage/cli": "^0.6.11", + "@backstage/dev-utils": "^0.1.14", + "@backstage/test-utils": "^0.1.11", + "@testing-library/jest-dom": "^5.10.1", + "@testing-library/react": "^11.2.5", + "@testing-library/user-event": "^13.1.8", + "@types/jest": "^26.0.7", + "@types/node": "^14.14.32", + "cross-fetch": "^3.0.6", + "msw": "^0.21.2" + }, + "files": [ + "dist", + "config.d.ts" + ], + "configSchema": "config.d.ts" +} diff --git a/plugins/ilert/src/api/client.ts b/plugins/ilert/src/api/client.ts new file mode 100644 index 0000000000..ed1e671bc0 --- /dev/null +++ b/plugins/ilert/src/api/client.ts @@ -0,0 +1,535 @@ +/* + * 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 { AuthenticationError, ResponseError } from '@backstage/errors'; +import { + AlertSource, + EscalationPolicy, + Incident, + IncidentAction, + IncidentResponder, + OnCall, + Schedule, + UptimeMonitor, + User, +} from '../types'; +import { + ILertApi, + GetIncidentsOpts, + GetIncidentsCountOpts, + Options, + EventRequest, +} from './types'; +import { DateTime as dt } from 'luxon'; + +export const ilertApiRef = createApiRef({ + id: 'plugin.ilert.service', + description: 'Used to make requests towards iLert API', +}); + +const DEFAULT_PROXY_PATH = '/ilert'; +const JSON_HEADERS = { + 'Content-Type': 'application/json', + Accept: 'application/json', +}; + +export class ILertClient implements ILertApi { + private readonly discoveryApi: DiscoveryApi; + private readonly proxyPath: string; + private readonly baseUrl: string; + + static fromConfig(configApi: ConfigApi, discoveryApi: DiscoveryApi) { + const baseUrl: string = + configApi.getOptionalString('ilert.baseUrl') ?? 'https://app.ilert.com'; + + return new ILertClient({ + discoveryApi: discoveryApi, + baseUrl, + proxyPath: + configApi.getOptionalString('ilert.proxyPath') ?? DEFAULT_PROXY_PATH, + }); + } + + constructor(opts: Options) { + this.discoveryApi = opts.discoveryApi; + this.baseUrl = opts.baseUrl; + this.proxyPath = opts.proxyPath; + } + + private async fetch(input: string, init?: RequestInit): Promise { + const apiUrl = await this.apiUrl(); + + const response = await fetch(`${apiUrl}${input}`, init); + if (response.status === 401) { + throw new AuthenticationError( + 'This request requires HTTP authentication.', + ); + } + if (!response.ok || response.status >= 400) { + throw await ResponseError.fromResponse(response); + } + + return await response.json(); + } + + async fetchIncidents(opts?: GetIncidentsOpts): Promise { + const init = { + headers: JSON_HEADERS, + }; + const query = new URLSearchParams(); + if (opts?.maxResults !== undefined) { + query.append('max-results', String(opts.maxResults)); + } + if (opts?.startIndex !== undefined) { + query.append('start-index', String(opts.startIndex)); + } + if (opts?.alertSources !== undefined && Array.isArray(opts.alertSources)) { + opts.alertSources.forEach((a: number) => { + if (a) { + query.append('alert-source', String(a)); + } + }); + } + + if (opts?.states !== undefined && 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 { + const init = { + headers: JSON_HEADERS, + }; + 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 fetchIncident(id: number): Promise { + const init = { + headers: JSON_HEADERS, + }; + + const response = await this.fetch( + `/api/v1/incidents/${encodeURIComponent(id)}`, + init, + ); + + return response; + } + + async fetchIncidentResponders( + incident: Incident, + ): Promise { + const init = { + headers: JSON_HEADERS, + }; + + const response = await this.fetch( + `/api/v1/incidents/${encodeURIComponent(incident.id)}/responders`, + init, + ); + + return response; + } + + async fetchIncidentActions(incident: Incident): Promise { + const init = { + headers: JSON_HEADERS, + }; + + const response = await this.fetch( + `/api/v1/incidents/${encodeURIComponent(incident.id)}/actions`, + init, + ); + + return response; + } + + async acceptIncident( + incident: Incident, + userName: string, + ): Promise { + const init = { + method: 'POST', + headers: JSON_HEADERS, + body: JSON.stringify({ + apiKey: incident.alertSource?.integrationKey || '', + incidentKey: incident.incidentKey, + summary: `from ${userName} via Backstage plugin`, + eventType: 'ACCEPT', + }), + }; + + await this.fetch('/api/v1/events', init); + return this.fetchIncident(incident.id); + } + + async resolveIncident( + incident: Incident, + userName: string, + ): Promise { + const init = { + method: 'POST', + headers: JSON_HEADERS, + body: JSON.stringify({ + apiKey: incident.alertSource?.integrationKey || '', + incidentKey: incident.incidentKey, + summary: `from ${userName} via Backstage plugin`, + eventType: 'RESOLVE', + }), + }; + + await this.fetch('/api/v1/events', init); + return this.fetchIncident(incident.id); + } + + async assignIncident( + incident: Incident, + responder: IncidentResponder, + ): Promise { + const init = { + method: 'PUT', + headers: JSON_HEADERS, + }; + + const query = new URLSearchParams(); + switch (responder.group) { + case 'ESCALATION_POLICY': + query.append('policy-id', String(responder.id)); + break; + case 'ON_CALL_SCHEDULE': + query.append('schedule-id', String(responder.id)); + break; + default: + query.append('user-id', String(responder.id)); + break; + } + + const response = await this.fetch( + `/api/v1/incidents/${encodeURIComponent( + incident.id, + )}/assign?${query.toString()}`, + init, + ); + + return response; + } + + async triggerIncidentAction( + incident: Incident, + action: IncidentAction, + ): Promise { + const init = { + method: 'POST', + headers: JSON_HEADERS, + body: JSON.stringify({ + webhookId: action.webhookId, + extensionId: action.extensionId, + type: action.type, + name: action.name, + }), + }; + + await this.fetch( + `/api/v1/incidents/${encodeURIComponent(incident.id)}/actions`, + init, + ); + } + + async createIncident(eventRequest: EventRequest): Promise { + const init = { + method: 'POST', + headers: JSON_HEADERS, + 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; + } + + async fetchUptimeMonitors(): Promise { + const init = { + headers: JSON_HEADERS, + }; + + const response = await this.fetch('/api/v1/uptime-monitors', init); + + return response; + } + + async fetchUptimeMonitor(id: number): Promise { + const init = { + headers: JSON_HEADERS, + }; + + const response: UptimeMonitor = await this.fetch( + `/api/v1/uptime-monitors/${encodeURIComponent(id)}`, + init, + ); + + return response; + } + + async pauseUptimeMonitor( + uptimeMonitor: UptimeMonitor, + ): Promise { + const init = { + method: 'PUT', + headers: JSON_HEADERS, + body: JSON.stringify({ ...uptimeMonitor, paused: true }), + }; + + const response = await this.fetch( + `/api/v1/uptime-monitors/${encodeURIComponent(uptimeMonitor.id)}`, + init, + ); + + return response; + } + + async resumeUptimeMonitor( + uptimeMonitor: UptimeMonitor, + ): Promise { + const init = { + method: 'PUT', + headers: JSON_HEADERS, + body: JSON.stringify({ ...uptimeMonitor, paused: false }), + }; + + const response = await this.fetch( + `/api/v1/uptime-monitors/${encodeURIComponent(uptimeMonitor.id)}`, + init, + ); + + return response; + } + + async fetchAlertSources(): Promise { + const init = { + headers: JSON_HEADERS, + }; + + const response = await this.fetch('/api/v1/alert-sources', init); + + return response; + } + + async fetchAlertSource( + idOrIntegrationKey: number | string, + ): Promise { + const init = { + headers: JSON_HEADERS, + }; + + const response = await this.fetch( + `/api/v1/alert-sources/${encodeURIComponent(idOrIntegrationKey)}`, + init, + ); + + return response; + } + + async fetchAlertSourceOnCalls(alertSource: AlertSource): Promise { + const init = { + headers: JSON_HEADERS, + }; + + const response = await this.fetch( + `/api/v1/on-calls?policies=${encodeURIComponent( + alertSource.escalationPolicy.id, + )}&expand=user&expand=escalationPolicy&timezone=${dt.local().zoneName}`, + init, + ); + + return response; + } + + async enableAlertSource(alertSource: AlertSource): Promise { + const init = { + method: 'PUT', + headers: JSON_HEADERS, + body: JSON.stringify({ ...alertSource, active: true }), + }; + + const response = await this.fetch( + `/api/v1/alert-sources/${encodeURIComponent(alertSource.id)}`, + init, + ); + + return response; + } + + async disableAlertSource(alertSource: AlertSource): Promise { + const init = { + method: 'PUT', + headers: JSON_HEADERS, + body: JSON.stringify({ ...alertSource, active: false }), + }; + + const response = await this.fetch( + `/api/v1/alert-sources/${encodeURIComponent(alertSource.id)}`, + init, + ); + + return response; + } + + async addImmediateMaintenance( + alertSourceId: number, + minutes: number, + ): Promise { + const init = { + method: 'POST', + headers: JSON_HEADERS, + body: JSON.stringify({ + start: dt.utc().toISO(), + end: dt.utc().plus({ minutes }).toISO(), + description: `Immediate maintenance window for ${minutes} minutes. Backstage β€” iLert plugin.`, + createdBy: 'Backstage', + timezone: dt.local().zoneName, + alertSources: [{ id: alertSourceId }], + }), + }; + + const response = await this.fetch('/api/v1/maintenance-windows', init); + + return response; + } + + async fetchOnCallSchedules(): Promise { + const init = { + headers: JSON_HEADERS, + }; + + const response = await this.fetch('/api/v1/schedules', init); + + return response; + } + + async fetchUsers(): Promise { + const init = { + headers: JSON_HEADERS, + }; + + const response = await this.fetch('/api/v1/users', init); + + return response; + } + + async overrideShift( + scheduleId: number, + userId: number, + start: string, + end: string, + ): Promise { + const init = { + method: 'PUT', + headers: JSON_HEADERS, + body: JSON.stringify({ user: { id: userId }, start, end }), + }; + + const response = await this.fetch( + `/api/v1/schedules/${encodeURIComponent(scheduleId)}/overrides`, + init, + ); + + return response; + } + + getIncidentDetailsURL(incident: Incident): string { + return `${this.baseUrl}/incident/view.jsf?id=${encodeURIComponent( + incident.id, + )}`; + } + + getAlertSourceDetailsURL(alertSource: AlertSource | null): string { + if (!alertSource) { + return ''; + } + return `${this.baseUrl}/source/view.jsf?id=${encodeURIComponent( + alertSource.id, + )}`; + } + + getEscalationPolicyDetailsURL(escalationPolicy: EscalationPolicy): string { + return `${this.baseUrl}/policy/view.jsf?id=${encodeURIComponent( + escalationPolicy.id, + )}`; + } + + getUptimeMonitorDetailsURL(uptimeMonitor: UptimeMonitor): string { + return `${this.baseUrl}/uptime/view.jsf?id=${encodeURIComponent( + uptimeMonitor.id, + )}`; + } + + getScheduleDetailsURL(schedule: Schedule): string { + return `${this.baseUrl}/schedule/view.jsf?id=${encodeURIComponent( + schedule.id, + )}`; + } + + getUserPhoneNumber(user: User | null) { + return user?.mobile?.number || user?.landline?.number || ''; + } + + getUserInitials(user: User | null) { + if (!user) { + return ''; + } + if (!user.firstName && !user.lastName) { + return user.username; + } + return `${user.firstName} ${user.lastName} (${user.username})`; + } + + private async apiUrl() { + const proxyUrl = await this.discoveryApi.getBaseUrl('proxy'); + return proxyUrl + this.proxyPath; + } +} diff --git a/plugins/ilert/src/api/index.ts b/plugins/ilert/src/api/index.ts new file mode 100644 index 0000000000..65c3571bff --- /dev/null +++ b/plugins/ilert/src/api/index.ts @@ -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 } from './client'; +export type { + ILertApi, + GetIncidentsCountOpts, + GetIncidentsOpts, + TableState, +} from './types'; diff --git a/plugins/ilert/src/api/types.ts b/plugins/ilert/src/api/types.ts new file mode 100644 index 0000000000..ab9cca3dc6 --- /dev/null +++ b/plugins/ilert/src/api/types.ts @@ -0,0 +1,120 @@ +/* + * 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, + IncidentAction, + OnCall, +} from '../types'; + +export type TableState = { + page: number; + pageSize: number; +}; + +export type GetIncidentsOpts = { + maxResults?: number; + startIndex?: number; + states?: IncidentStatus[]; + alertSources?: number[]; +}; + +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; + fetchIncidentsCount(opts?: GetIncidentsCountOpts): Promise; + fetchIncident(id: number): Promise; + fetchIncidentResponders(incident: Incident): Promise; + fetchIncidentActions(incident: Incident): Promise; + acceptIncident(incident: Incident, userName: string): Promise; + resolveIncident(incident: Incident, userName: string): Promise; + assignIncident( + incident: Incident, + responder: IncidentResponder, + ): Promise; + createIncident(eventRequest: EventRequest): Promise; + triggerIncidentAction( + incident: Incident, + action: IncidentAction, + ): Promise; + + fetchUptimeMonitors(): Promise; + pauseUptimeMonitor(uptimeMonitor: UptimeMonitor): Promise; + resumeUptimeMonitor(uptimeMonitor: UptimeMonitor): Promise; + fetchUptimeMonitor(id: number): Promise; + + fetchAlertSources(): Promise; + fetchAlertSource(idOrIntegrationKey: number | string): Promise; + fetchAlertSourceOnCalls(alertSource: AlertSource): Promise; + enableAlertSource(alertSource: AlertSource): Promise; + disableAlertSource(alertSource: AlertSource): Promise; + + addImmediateMaintenance( + alertSourceId: number, + minutes: number, + ): Promise; + + fetchOnCallSchedules(): Promise; + fetchUsers(): Promise; + + overrideShift( + scheduleId: number, + userId: number, + start: string, + end: string, + ): Promise; + + getIncidentDetailsURL(incident: Incident): string; + getAlertSourceDetailsURL(alertSource: AlertSource | null): string; + getEscalationPolicyDetailsURL(escalationPolicy: EscalationPolicy): string; + getUptimeMonitorDetailsURL(uptimeMonitor: UptimeMonitor): string; + getScheduleDetailsURL(schedule: Schedule): string; + getUserPhoneNumber(user: User | null): string; + getUserInitials(user: User | null): string; +} + +export type Options = { + discoveryApi: DiscoveryApi; + + /** + * URL used by users to access iLert web UI. + * Example: https://my-org.ilert.com/ + */ + baseUrl: string; + + /** + * Path to use for requests via the proxy, defaults to /ilert/api + */ + proxyPath: string; +}; diff --git a/plugins/ilert/src/assets/ilert.icon.svg b/plugins/ilert/src/assets/ilert.icon.svg new file mode 100644 index 0000000000..e93f1e80ca --- /dev/null +++ b/plugins/ilert/src/assets/ilert.icon.svg @@ -0,0 +1,11 @@ + + + + diff --git a/plugins/ilert/src/components/AlertSource/AlertSourceLink.tsx b/plugins/ilert/src/components/AlertSource/AlertSourceLink.tsx new file mode 100644 index 0000000000..f7f6dbb78c --- /dev/null +++ b/plugins/ilert/src/components/AlertSource/AlertSourceLink.tsx @@ -0,0 +1,70 @@ +/* + * 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, Link } from '@backstage/core'; +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 ( + + + {alertSource.name} + + + + {alertSource.name} + + + + ); +}; diff --git a/plugins/ilert/src/components/Errors/MissingAuthorizationHeaderError.tsx b/plugins/ilert/src/components/Errors/MissingAuthorizationHeaderError.tsx new file mode 100644 index 0000000000..6b3d463d2a --- /dev/null +++ b/plugins/ilert/src/components/Errors/MissingAuthorizationHeaderError.tsx @@ -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 = () => ( + + Read More + + } + /> +); diff --git a/plugins/ilert/src/components/Errors/index.ts b/plugins/ilert/src/components/Errors/index.ts new file mode 100644 index 0000000000..f20de48b90 --- /dev/null +++ b/plugins/ilert/src/components/Errors/index.ts @@ -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'; diff --git a/plugins/ilert/src/components/EscalationPolicy/EscalationPolicyLink.tsx b/plugins/ilert/src/components/EscalationPolicy/EscalationPolicyLink.tsx new file mode 100644 index 0000000000..9e7ff102f8 --- /dev/null +++ b/plugins/ilert/src/components/EscalationPolicy/EscalationPolicyLink.tsx @@ -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 { useApi, Link } from '@backstage/core'; +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 ( + + {escalationPolicy.name} + + ); +}; diff --git a/plugins/ilert/src/components/ILertCard/ILertCard.tsx b/plugins/ilert/src/components/ILertCard/ILertCard.tsx new file mode 100644 index 0000000000..bbc82c2b2d --- /dev/null +++ b/plugins/ilert/src/components/ILertCard/ILertCard.tsx @@ -0,0 +1,142 @@ +/* + * 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 { AuthenticationError } from '@backstage/errors'; +import { ResponseErrorPanel } from '@backstage/core'; +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_ANNOTATION } 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'; +import { ILertCardOnCall } from './ILertCardOnCall'; + +export const isPluginApplicableToEntity = (entity: Entity) => + Boolean(entity.metadata.annotations?.[ILERT_INTEGRATION_KEY_ANNOTATION]); + +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 [ + { tableState, states, incidents, incidentsCount, isLoading, error }, + { + onIncidentStatesChange, + onChangePage, + onChangeRowsPerPage, + onIncidentChanged, + refetchIncidents, + setIsLoading, + }, + ] = useIncidents(false, true, alertSource); + + const [ + isNewIncidentModalOpened, + setIsNewIncidentModalOpened, + ] = React.useState(false); + const [ + isMaintenanceModalOpened, + setIsMaintenanceModalOpened, + ] = React.useState(false); + + if (error) { + if (error instanceof AuthenticationError) { + return ; + } + + return ; + } + + if (!integrationKey) { + return ; + } + + return ( + <> + + + } + action={} + /> + + + + + + + + + + ); +}; diff --git a/plugins/ilert/src/components/ILertCard/ILertCardActionsHeader.tsx b/plugins/ilert/src/components/ILertCard/ILertCardActionsHeader.tsx new file mode 100644 index 0000000000..c19c31839a --- /dev/null +++ b/plugins/ilert/src/components/ILertCard/ILertCardActionsHeader.tsx @@ -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: , + }; + + const createIncidentLink: IconLinkVerticalProps = { + label: 'Create Incident', + onClick: handleCreateNewIncident, + icon: , + color: 'secondary', + disabled: + !alertSource || + alertSource.status === 'DISABLED' || + alertSource.status === 'IN_MAINTENANCE', + }; + + const enableAlertSourceLink: IconLinkVerticalProps = { + label: 'Enable', + onClick: handleEnableAlertSource, + icon: , + disabled: !alertSource || isLoading, + }; + + const disableAlertSourceLink: IconLinkVerticalProps = { + label: 'Disable', + onClick: handleDisableAlertSourceWarningOpen, + icon: , + disabled: !alertSource || isLoading, + }; + + const maintenanceAlertSourceLink: IconLinkVerticalProps = { + label: 'Immediate maintenance', + onClick: handleMaintenanceAlertSource, + icon: , + disabled: !alertSource || isLoading, + }; + + const uptimeMonitorReportLink: IconLinkVerticalProps = { + label: 'Uptime Report', + href: uptimeMonitor ? uptimeMonitor.shareUrl : '', + icon: , + 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 ( + <> + + + + Disable alert source + + + + + Do you really want to disable this alert source? A disabled alert + source cannot create new incidents. + + + + + + + + + + ); +}; diff --git a/plugins/ilert/src/components/ILertCard/ILertCardEmptyState.tsx b/plugins/ilert/src/components/ILertCard/ILertCardEmptyState.tsx new file mode 100644 index 0000000000..955d514564 --- /dev/null +++ b/plugins/ilert/src/components/ILertCard/ILertCardEmptyState.tsx @@ -0,0 +1,84 @@ +/* + * 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(theme => ({ + code: { + borderRadius: 6, + margin: theme.spacing(2, 0), + background: theme.palette.type === 'dark' ? '#444' : '#fff', + }, + header: { + display: 'inline-block', + padding: theme.spacing(2, 2, 2, 2.5), + }, +})); + +export const ILertCardEmptyState = () => { + const classes = useStyles(); + + return ( + + + + + + No integration key defined for this entity. You can add integration + key to your entity YAML as shown in the highlighted example below: + +
+ +
+ +
+
+ ); +}; diff --git a/plugins/ilert/src/components/ILertCard/ILertCardHeaderStatus.tsx b/plugins/ilert/src/components/ILertCard/ILertCardHeaderStatus.tsx new file mode 100644 index 0000000000..2c1b44551e --- /dev/null +++ b/plugins/ilert/src/components/ILertCard/ILertCardHeaderStatus.tsx @@ -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 ; + case 'DISABLED': + return ; + default: + return null; + } +}; diff --git a/plugins/ilert/src/components/ILertCard/ILertCardMaintenanceModal.tsx b/plugins/ilert/src/components/ILertCard/ILertCardMaintenanceModal.tsx new file mode 100644 index 0000000000..54d465a83c --- /dev/null +++ b/plugins/ilert/src/components/ILertCard/ILertCardMaintenanceModal.tsx @@ -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 ( + + + New maintenance window + + + + Keep your alert sources quiet, when your systems are under + maintenance. + + + {minuteOptions.map(option => ( + + {option.label} + + ))} + + + + + + + + ); +}; diff --git a/plugins/ilert/src/components/ILertCard/ILertCardOnCall.tsx b/plugins/ilert/src/components/ILertCard/ILertCardOnCall.tsx new file mode 100644 index 0000000000..8f28790375 --- /dev/null +++ b/plugins/ilert/src/components/ILertCard/ILertCardOnCall.tsx @@ -0,0 +1,117 @@ +/* + * 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 List from '@material-ui/core/List'; +import ListItem from '@material-ui/core/ListItem'; +import ListItemIcon from '@material-ui/core/ListItemIcon'; +import ListItemText from '@material-ui/core/ListItemText'; +import ListSubheader from '@material-ui/core/ListSubheader'; +import Typography from '@material-ui/core/Typography'; +import ReplayIcon from '@material-ui/icons/Replay'; +import { makeStyles } from '@material-ui/core/styles'; +import { AlertSource } from '../../types'; +import { useAlertSourceOnCalls } from '../../hooks/useAlertSourceOnCalls'; +import { Progress } from '@backstage/core'; +import { ILertCardOnCallEmptyState } from './ILertCardOnCallEmptyState'; +import { ILertCardOnCallItem } from './ILertCardOnCallItem'; + +const useStyles = makeStyles(theme => ({ + repeatText: { + fontStyle: 'italic', + }, + repeatIcon: { + marginLeft: theme.spacing(1), + }, +})); + +export const ILertCardOnCall = ({ + alertSource, +}: { + alertSource: AlertSource | null; +}) => { + const classes = useStyles(); + const [{ onCalls, isLoading }, {}] = useAlertSourceOnCalls(alertSource); + + if (isLoading) { + return ; + } + + if (!alertSource || !onCalls) { + return null; + } + + const repeatInfo = () => { + if ( + !alertSource || + !alertSource.escalationPolicy || + !alertSource.escalationPolicy.repeating || + !alertSource.escalationPolicy.frequency + ) { + return null; + } + + return ( + + + + + + {`Repeat ${alertSource.escalationPolicy.frequency} times`} + + } + /> + + ); + }; + + if (!onCalls.length) { + return ( + ON CALL}> + + + ); + } + + if (onCalls.length === 1) { + ON CALL}> + + ; + } + + return ( + ON CALL}> + {onCalls.map((onCall, index) => ( + + ))} + {repeatInfo()} + + ); +}; diff --git a/plugins/ilert/src/components/ILertCard/ILertCardOnCallEmptyState.tsx b/plugins/ilert/src/components/ILertCard/ILertCardOnCallEmptyState.tsx new file mode 100644 index 0000000000..2d43aeccb8 --- /dev/null +++ b/plugins/ilert/src/components/ILertCard/ILertCardOnCallEmptyState.tsx @@ -0,0 +1,45 @@ +/* + * Copyright 2020 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 ListItem from '@material-ui/core/ListItem'; +import ListItemText from '@material-ui/core/ListItemText'; +import Typography from '@material-ui/core/Typography'; + +const useStyles = makeStyles({ + text: { + fontStyle: 'italic', + }, +}); + +export const ILertCardOnCallEmptyState = () => { + const classes = useStyles(); + + return ( + + + + Nobody + + + + ); +}; diff --git a/plugins/ilert/src/components/ILertCard/ILertCardOnCallItem.tsx b/plugins/ilert/src/components/ILertCard/ILertCardOnCallItem.tsx new file mode 100644 index 0000000000..994666084b --- /dev/null +++ b/plugins/ilert/src/components/ILertCard/ILertCardOnCallItem.tsx @@ -0,0 +1,169 @@ +/* + * 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 Divider from '@material-ui/core/Divider'; +import ListItem from '@material-ui/core/ListItem'; +import ListItemIcon from '@material-ui/core/ListItemIcon'; +import Avatar from '@material-ui/core/Avatar'; +import ListItemText from '@material-ui/core/ListItemText'; +import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction'; +import Tooltip from '@material-ui/core/Tooltip'; +import IconButton from '@material-ui/core/IconButton'; +import Typography from '@material-ui/core/Typography'; +import EmailIcon from '@material-ui/icons/Email'; +import PhoneIcon from '@material-ui/icons/Phone'; +import { makeStyles } from '@material-ui/core/styles'; +import { OnCall } from '../../types'; +import { useApi } from '@backstage/core'; +import { ilertApiRef } from '../../api'; +import { DateTime as dt } from 'luxon'; + +const useStyles = makeStyles({ + listItemPrimary: { + fontWeight: 'bold', + }, + fistItemLine: { + position: 'absolute', + bottom: 0, + height: '50%', + left: 36, + }, + lastItemLine: { + position: 'absolute', + top: 0, + height: '50%', + left: 36, + }, + itemLine: { + position: 'absolute', + top: 0, + bottom: 0, + height: '100%', + left: 36, + }, +}); + +export const ILertCardOnCallItem = ({ + onCall, + fistItem = false, + lastItem = false, +}: { + onCall: OnCall; + fistItem?: boolean; + lastItem?: boolean; +}) => { + const ilertApi = useApi(ilertApiRef); + const classes = useStyles(); + + if (!onCall || !onCall.user) { + return null; + } + + const phoneNumber = ilertApi.getUserPhoneNumber(onCall.user); + const escalationRepeating = onCall.escalationPolicy.repeating; + const escalationSeconds = + onCall.escalationPolicy.escalationRules[onCall.escalationLevel - 1] + .escalationTimeout; + const escalationHoursOnly = Math.floor(escalationSeconds / 60); + const escalationMinutesOnly = escalationSeconds % 60; + + let escalationText = ''; + if (!lastItem || (lastItem && escalationRepeating)) { + escalationText = 'escalate'; + if (escalationSeconds === 0) { + escalationText += ' immediately'; + } else { + escalationText += ' after'; + if (escalationHoursOnly > 0) { + escalationText += ` ${escalationHoursOnly} ${ + escalationHoursOnly === 1 ? 'hour' : 'hours' + }`; + } + if (escalationMinutesOnly > 0 || escalationSeconds === 0) { + escalationText += ` ${escalationMinutesOnly} ${ + escalationMinutesOnly === 1 ? 'minute' : 'minutes' + }`; + } + } + } + + return ( + + {fistItem ? ( + + ) : null} + {lastItem ? ( + + ) : null} + {!fistItem && !lastItem ? ( + + ) : null} + + + {onCall.escalationLevel} + + + {onCall.schedule ? ( + + + {ilertApi.getUserInitials(onCall.user)} + + } + secondary={escalationText} + /> + + ) : ( + + {ilertApi.getUserInitials(onCall.user)} + + } + secondary={escalationText} + /> + )} + + {phoneNumber ? ( + + + + + + ) : null} + + + + + + + + ); +}; diff --git a/plugins/ilert/src/components/ILertCard/index.ts b/plugins/ilert/src/components/ILertCard/index.ts new file mode 100644 index 0000000000..4ae259bfc5 --- /dev/null +++ b/plugins/ilert/src/components/ILertCard/index.ts @@ -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'; diff --git a/plugins/ilert/src/components/ILertPage/ILertPage.tsx b/plugins/ilert/src/components/ILertPage/ILertPage.tsx new file mode 100644 index 0000000000..40934bfdfb --- /dev/null +++ b/plugins/ilert/src/components/ILertPage/ILertPage.tsx @@ -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(0); + const tabs = [ + { label: 'Who is on call?' }, + { label: 'Incidents' }, + { label: 'Uptime Monitors' }, + ]; + const renderTab = () => { + switch (selectedTab) { + case 0: + return ; + case 1: + return ; + case 2: + return ; + default: + return null; + } + }; + + return ( + +
+ + +
+ setSelectedTab(index)} + tabs={tabs.map(({ label }, index) => ({ + id: index.toString(), + label, + }))} + /> + + {renderTab()} +
+ ); +}; + +export default ILertPage; diff --git a/plugins/ilert/src/components/ILertPage/index.ts b/plugins/ilert/src/components/ILertPage/index.ts new file mode 100644 index 0000000000..5de46b63d4 --- /dev/null +++ b/plugins/ilert/src/components/ILertPage/index.ts @@ -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'; diff --git a/plugins/ilert/src/components/Incident/IncidentActionsMenu.tsx b/plugins/ilert/src/components/Incident/IncidentActionsMenu.tsx new file mode 100644 index 0000000000..5b06cc4eb6 --- /dev/null +++ b/plugins/ilert/src/components/Incident/IncidentActionsMenu.tsx @@ -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 { + alertApiRef, + Progress, + useApi, + identityApiRef, + Link, +} from '@backstage/core'; +import { IconButton, Menu, MenuItem, Typography } from '@material-ui/core'; +import MoreVertIcon from '@material-ui/icons/MoreVert'; +import { ilertApiRef } from '../../api'; +import { Incident, IncidentAction } from '../../types'; +import { IncidentAssignModal } from './IncidentAssignModal'; +import { useIncidentActions } from '../../hooks/useIncidentActions'; + +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 identityApi = useApi(identityApiRef); + const userName = identityApi.getUserId(); + const [anchorEl, setAnchorEl] = React.useState(null); + const callback = onIncidentChanged || ((_: Incident): void => {}); + const setProcessing = setIsLoading || ((_: boolean): void => {}); + const [ + isAssignIncidentModalOpened, + setIsAssignIncidentModalOpened, + ] = React.useState(false); + + const [{ incidentActions, isLoading }] = useIncidentActions( + incident, + Boolean(anchorEl), + ); + + const handleClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + + const handleCloseMenu = () => { + setAnchorEl(null); + }; + + const handleAccept = async (): Promise => { + try { + handleCloseMenu(); + setProcessing(true); + const newIncident = await ilertApi.acceptIncident(incident, userName); + alertApi.post({ message: 'Incident accepted.' }); + + callback(newIncident); + setProcessing(false); + } catch (err) { + setProcessing(false); + alertApi.post({ message: err, severity: 'error' }); + } + }; + + const handleResolve = async (): Promise => { + try { + handleCloseMenu(); + setProcessing(true); + const newIncident = await ilertApi.resolveIncident(incident, userName); + alertApi.post({ message: 'Incident resolved.' }); + + callback(newIncident); + setProcessing(false); + } catch (err) { + setProcessing(false); + alertApi.post({ message: err, severity: 'error' }); + } + }; + + const handleAssign = () => { + handleCloseMenu(); + setIsAssignIncidentModalOpened(true); + }; + + const handleTriggerAction = (action: IncidentAction) => async () => { + try { + handleCloseMenu(); + setProcessing(true); + await ilertApi.triggerIncidentAction(incident, action); + alertApi.post({ message: 'Incident action triggered.' }); + setProcessing(false); + } catch (err) { + setProcessing(false); + alertApi.post({ message: err, severity: 'error' }); + } + }; + + const actions: React.ReactNode[] = incidentActions.map(a => { + const successTrigger = a.history + ? a.history.find(h => h.success) + : undefined; + const triggeredBy = + successTrigger && successTrigger.actor + ? `${successTrigger.actor.firstName} ${successTrigger.actor.lastName}` + : ''; + return ( + + + {triggeredBy ? `${a.name} (by ${triggeredBy})` : a.name} + + + ); + }); + + return ( + <> + + + + + {incident.status === 'PENDING' ? ( + + + Accept + + + ) : null} + + {incident.status !== 'RESOLVED' ? ( + + + Resolve + + + ) : null} + + {incident.status !== 'RESOLVED' ? ( + + + Assign + + + ) : null} + + {isLoading ? ( + + + + ) : ( + actions + )} + + + + + View in iLert + + + + + + + ); +}; diff --git a/plugins/ilert/src/components/Incident/IncidentAssignModal.tsx b/plugins/ilert/src/components/Incident/IncidentAssignModal.tsx new file mode 100644 index 0000000000..edac5e4e0e --- /dev/null +++ b/plugins/ilert/src/components/Incident/IncidentAssignModal.tsx @@ -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 ( + + + Select responder to assign + + + + + This action will assign the incident to the selected responder. + + + { + 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 => ( +
+ {a.name} +
+ )} + renderInput={params => ( + + )} + /> +
+ + + + +
+ ); +}; diff --git a/plugins/ilert/src/components/Incident/IncidentLink.tsx b/plugins/ilert/src/components/Incident/IncidentLink.tsx new file mode 100644 index 0000000000..7bfe08b7a9 --- /dev/null +++ b/plugins/ilert/src/components/Incident/IncidentLink.tsx @@ -0,0 +1,44 @@ +/* + * 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, Link } from '@backstage/core'; +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 ( + + #{incident.id} + + ); +}; diff --git a/plugins/ilert/src/components/Incident/IncidentNewModal.tsx b/plugins/ilert/src/components/Incident/IncidentNewModal.tsx new file mode 100644 index 0000000000..8ff33e6319 --- /dev/null +++ b/plugins/ilert/src/components/Incident/IncidentNewModal.tsx @@ -0,0 +1,228 @@ +/* + * 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 { + await ilertApi.createIncident({ + integrationKey, + summary, + details, + userName, + source, + }); + alertApi.post({ message: 'Incident created.' }); + refetchIncidents(); + } catch (err) { + alertApi.post({ message: err, severity: 'error' }); + } + setIsModalOpened(false); + }, 250); + }; + + const canCreate = !!integrationKey && !!summary; + + return ( + + + {entityName ? ( +
+ This action will trigger an incident for{' '} + "{entityName}". +
+ ) : ( + 'New incident' + )} +
+ + + + 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. + + + {!initialAlertSource ? ( + { + setAlertSource(newValue); + }} + autoHighlight + getOptionLabel={a => a.name} + renderOption={a => ( +
+ {a.name} + {a.name} +
+ )} + renderInput={params => ( + + )} + /> + ) : null} + { + setSummary(event.target.value); + }} + /> + { + setDetails(event.target.value); + }} + /> +
+ + + + +
+ ); +}; diff --git a/plugins/ilert/src/components/Incident/IncidentStatus.tsx b/plugins/ilert/src/components/Incident/IncidentStatus.tsx new file mode 100644 index 0000000000..931a472ec6 --- /dev/null +++ b/plugins/ilert/src/components/Incident/IncidentStatus.tsx @@ -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; + +export const IncidentStatus = ({ incident }: { incident: Incident }) => { + const classes = useStyles(); + + return ( + +
+ {incident.status === 'PENDING' ? : } +
+
+ ); +}; diff --git a/plugins/ilert/src/components/Incident/index.ts b/plugins/ilert/src/components/Incident/index.ts new file mode 100644 index 0000000000..df8bab5d95 --- /dev/null +++ b/plugins/ilert/src/components/Incident/index.ts @@ -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'; diff --git a/plugins/ilert/src/components/IncidentsPage/IncidentsPage.tsx b/plugins/ilert/src/components/IncidentsPage/IncidentsPage.tsx new file mode 100644 index 0000000000..782848645e --- /dev/null +++ b/plugins/ilert/src/components/IncidentsPage/IncidentsPage.tsx @@ -0,0 +1,101 @@ +/* + * 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, + ResponseErrorPanel, +} from '@backstage/core'; +import { AuthenticationError } from '@backstage/errors'; +import Button from '@material-ui/core/Button'; +import AddIcon from '@material-ui/icons/Add'; +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 AuthenticationError) { + return ( + + + + ); + } + + return ( + + + + ); + } + + return ( + + + + + + This helps you to bring iLert into your developer portal. + + + + + ); +}; diff --git a/plugins/ilert/src/components/IncidentsPage/IncidentsTable.tsx b/plugins/ilert/src/components/IncidentsPage/IncidentsTable.tsx new file mode 100644 index 0000000000..087b2bfd40 --- /dev/null +++ b/plugins/ilert/src/components/IncidentsPage/IncidentsTable.tsx @@ -0,0 +1,262 @@ +/* + * 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 { DateTime as dt, Interval } from 'luxon'; +import humanizeDuration from 'humanize-duration'; +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 => , + }; + const summaryColumn: TableColumn = { + title: 'Summary', + field: 'summary', + cellStyle: !compact ? xlColumnStyle : undefined, + headerStyle: !compact ? xlColumnStyle : undefined, + render: rowData => {(rowData as Incident).summary}, + }; + const sourceColumn: TableColumn = { + title: 'Source', + field: 'source', + cellStyle: mdColumnStyle, + headerStyle: mdColumnStyle, + render: rowData => ( + + ), + }; + const durationColumn: TableColumn = { + title: 'Duration', + field: 'reportTime', + type: 'datetime', + cellStyle: smColumnStyle, + headerStyle: smColumnStyle, + render: rowData => ( + + {(rowData as Incident).status !== 'RESOLVED' + ? humanizeDuration( + Interval.fromDateTimes( + dt.fromISO((rowData as Incident).reportTime), + dt.now(), + ) + .toDuration() + .valueOf(), + { units: ['h', 'm', 's'], largest: 2, round: true }, + ) + : humanizeDuration( + Interval.fromDateTimes( + dt.fromISO((rowData as Incident).reportTime), + dt.fromISO((rowData as Incident).resolvedOn), + ) + .toDuration() + .valueOf(), + { units: ['h', 'm', 's'], largest: 2, round: true }, + )} + + ), + }; + const assignedToColumn: TableColumn = { + title: 'Assigned to', + field: 'assignedTo', + cellStyle: !compact ? mdColumnStyle : lgColumnStyle, + headerStyle: !compact ? mdColumnStyle : lgColumnStyle, + render: rowData => ( + + {ilertApi.getUserInitials((rowData as Incident).assignedTo)} + + ), + }; + const priorityColumn: TableColumn = { + title: 'Priority', + field: 'priority', + cellStyle: smColumnStyle, + headerStyle: smColumnStyle, + render: rowData => ( + + {(rowData as Incident).priority === 'HIGH' ? 'High' : 'Low'} + + ), + }; + const statusColumn: TableColumn = { + title: 'Status', + field: 'status', + cellStyle: xsColumnStyle, + headerStyle: xsColumnStyle, + render: rowData => , + }; + const actionsColumn: TableColumn = { + title: '', + field: '', + cellStyle: xsColumnStyle, + headerStyle: xsColumnStyle, + render: rowData => ( + + ), + }; + + 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 ( + + No incidents right now + + } + title={ + !compact ? ( + + ) : ( + + INCIDENTS + + ) + } + page={tableState.page} + totalCount={incidentsCount} + onChangePage={onChangePage} + onChangeRowsPerPage={onChangeRowsPerPage} + // localization={{ header: { actions: undefined } }} + columns={columns} + data={incidents} + isLoading={isLoading} + /> + ); +}; diff --git a/plugins/ilert/src/components/IncidentsPage/StatusChip.tsx b/plugins/ilert/src/components/IncidentsPage/StatusChip.tsx new file mode 100644 index 0000000000..9713b16a55 --- /dev/null +++ b/plugins/ilert/src/components/IncidentsPage/StatusChip.tsx @@ -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 ; + case ACCEPTED: + return ; + case PENDING: + return ; + default: + return ; + } +}; diff --git a/plugins/ilert/src/components/IncidentsPage/TableTitle.tsx b/plugins/ilert/src/components/IncidentsPage/TableTitle.tsx new file mode 100644 index 0000000000..f35981dec2 --- /dev/null +++ b/plugins/ilert/src/components/IncidentsPage/TableTitle.tsx @@ -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 ( +
+ + Status: + + + + +
+ ); +}; diff --git a/plugins/ilert/src/components/IncidentsPage/index.ts b/plugins/ilert/src/components/IncidentsPage/index.ts new file mode 100644 index 0000000000..7159247c68 --- /dev/null +++ b/plugins/ilert/src/components/IncidentsPage/index.ts @@ -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'; diff --git a/plugins/ilert/src/components/OnCallSchedulesPage/OnCallSchedulesGrid.tsx b/plugins/ilert/src/components/OnCallSchedulesPage/OnCallSchedulesGrid.tsx new file mode 100644 index 0000000000..31da9c1097 --- /dev/null +++ b/plugins/ilert/src/components/OnCallSchedulesPage/OnCallSchedulesGrid.tsx @@ -0,0 +1,171 @@ +/* + * 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, Link } 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 { 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: -6, + width: 12, + height: 12, + background: '#92949c !important', + borderRadius: '50%', + }, + + indicatorCurrent: { + position: 'absolute', + top: 'calc(40% - 10px)', + left: -6, + width: 12, + height: 12, + background: '#ffb74d !important', + color: '#ffb74d !important', + borderRadius: '50%', + '&::after': { + position: 'absolute', + top: 0, + left: 0, + width: '100%', + height: '100%', + borderRadius: '50%', + animation: '$ripple 1.2s infinite ease-in-out', + border: '1px solid currentColor', + content: '""', + }, + }, + '@keyframes ripple': { + '0%': { + transform: 'scale(.8)', + opacity: 1, + }, + '100%': { + transform: 'scale(2.4)', + opacity: 0, + }, + }, + + 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 ; + } + return ( + + {!onCallSchedules?.length + ? null + : onCallSchedules.map((schedule, index) => ( + + + {schedule.name} + + } + /> + + +
+ + + On call now + + + + +
+ + + Next on call + + + + ))} + + ); +}; diff --git a/plugins/ilert/src/components/OnCallSchedulesPage/OnCallSchedulesPage.tsx b/plugins/ilert/src/components/OnCallSchedulesPage/OnCallSchedulesPage.tsx new file mode 100644 index 0000000000..bb0cd0218e --- /dev/null +++ b/plugins/ilert/src/components/OnCallSchedulesPage/OnCallSchedulesPage.tsx @@ -0,0 +1,64 @@ +/* + * 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, + ResponseErrorPanel, +} from '@backstage/core'; +import { AuthenticationError } from '@backstage/errors'; +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 AuthenticationError) { + return ( + + + + ); + } + + return ( + + + + ); + } + + return ( + + + + This helps you to bring iLert into your developer portal. + + + + + ); +}; diff --git a/plugins/ilert/src/components/OnCallSchedulesPage/OnCallShiftItem.tsx b/plugins/ilert/src/components/OnCallSchedulesPage/OnCallShiftItem.tsx new file mode 100644 index 0000000000..46f6963445 --- /dev/null +++ b/plugins/ilert/src/components/OnCallSchedulesPage/OnCallShiftItem.tsx @@ -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 { DateTime as dt } from 'luxon'; +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 ( + + + + Nobody + + + + ); + } + + return ( + + {shift && shift.user ? ( + + + {`${shift.user.firstName} ${shift.user.lastName} (${shift.user.username})`} + + + ) : null} + + + {`${dt.fromISO(shift.start).toFormat('D MMM, HH:mm')} - ${dt + .fromISO(shift.end) + .toFormat('D MMM, HH:mm')}`} + + + + + + + + ); +}; diff --git a/plugins/ilert/src/components/OnCallSchedulesPage/index.ts b/plugins/ilert/src/components/OnCallSchedulesPage/index.ts new file mode 100644 index 0000000000..a52accc2bb --- /dev/null +++ b/plugins/ilert/src/components/OnCallSchedulesPage/index.ts @@ -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'; diff --git a/plugins/ilert/src/components/Shift/ShiftOverrideModal.tsx b/plugins/ilert/src/components/Shift/ShiftOverrideModal.tsx new file mode 100644 index 0000000000..47e13f9b7e --- /dev/null +++ b/plugins/ilert/src/components/Shift/ShiftOverrideModal.tsx @@ -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 LuxonUtils from '@date-io/luxon'; + +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 ( + + Shift override + + + { + setUser(newValue); + }} + autoHighlight + getOptionLabel={a => ilertApi.getUserInitials(a)} + renderOption={a => ( +
+ {ilertApi.getUserInitials(a)} +
+ )} + renderInput={params => ( + + )} + /> + { + setStart(date ? date.toISO() : ''); + }} + /> + { + setEnd(date ? date.toISO() : ''); + }} + /> +
+
+ + + + +
+ ); +}; diff --git a/plugins/ilert/src/components/UptimeMonitor/UptimeMonitorActionsMenu.tsx b/plugins/ilert/src/components/UptimeMonitor/UptimeMonitorActionsMenu.tsx new file mode 100644 index 0000000000..36671bff90 --- /dev/null +++ b/plugins/ilert/src/components/UptimeMonitor/UptimeMonitorActionsMenu.tsx @@ -0,0 +1,135 @@ +/* + * 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, Link } from '@backstage/core'; +import { IconButton, Menu, MenuItem, Typography } from '@material-ui/core'; +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); + const callback = onUptimeMonitorChanged || ((_: UptimeMonitor): void => {}); + + const handleClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + + const handleCloseMenu = () => { + setAnchorEl(null); + }; + + const handlePause = async (): Promise => { + 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 => { + 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 => { + try { + const um = await ilertApi.fetchUptimeMonitor(uptimeMonitor.id); + handleCloseMenu(); + window.open(um.shareUrl, '_blank'); + } catch (err) { + alertApi.post({ message: err, severity: 'error' }); + } + }; + + return ( + <> + + + + + {uptimeMonitor.paused ? ( + + + Resume + + + ) : null} + + {!uptimeMonitor.paused ? ( + + + Pause + + + ) : null} + + + + + View Report + + + + + + + + View in iLert + + + + + + ); +}; diff --git a/plugins/ilert/src/components/UptimeMonitor/UptimeMonitorLink.tsx b/plugins/ilert/src/components/UptimeMonitor/UptimeMonitorLink.tsx new file mode 100644 index 0000000000..507e0208e2 --- /dev/null +++ b/plugins/ilert/src/components/UptimeMonitor/UptimeMonitorLink.tsx @@ -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 { useApi, Link } from '@backstage/core'; +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 ( + + #{uptimeMonitor.id} + + ); +}; diff --git a/plugins/ilert/src/components/UptimeMonitor/index.ts b/plugins/ilert/src/components/UptimeMonitor/index.ts new file mode 100644 index 0000000000..f30d1ef15a --- /dev/null +++ b/plugins/ilert/src/components/UptimeMonitor/index.ts @@ -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'; diff --git a/plugins/ilert/src/components/UptimeMonitorsPage/StatusChip.tsx b/plugins/ilert/src/components/UptimeMonitorsPage/StatusChip.tsx new file mode 100644 index 0000000000..dd17b1758b --- /dev/null +++ b/plugins/ilert/src/components/UptimeMonitorsPage/StatusChip.tsx @@ -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; + +export const StatusChip = ({ + uptimeMonitor, +}: { + uptimeMonitor: UptimeMonitor; +}) => { + let label = `${uptimeMonitorStatusLabels[uptimeMonitor.status]}`; + + if (uptimeMonitor.paused) { + label = 'Paused'; + return ; + } + + switch (uptimeMonitor.status) { + case 'up': + return ; + case 'down': + return ; + case 'unknown': + return ; + default: + return ; + } +}; diff --git a/plugins/ilert/src/components/UptimeMonitorsPage/UptimeMonitorCheckType.tsx b/plugins/ilert/src/components/UptimeMonitorsPage/UptimeMonitorCheckType.tsx new file mode 100644 index 0000000000..3fdecd65e9 --- /dev/null +++ b/plugins/ilert/src/components/UptimeMonitorsPage/UptimeMonitorCheckType.tsx @@ -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 ( + {`${uptimeMonitor.checkType.toUpperCase()} πŸ‡©πŸ‡ͺ`} + ); + default: + return ( + {`${uptimeMonitor.checkType.toUpperCase()} πŸ‡ΊπŸ‡Έ`} + ); + } +}; diff --git a/plugins/ilert/src/components/UptimeMonitorsPage/UptimeMonitorsPage.tsx b/plugins/ilert/src/components/UptimeMonitorsPage/UptimeMonitorsPage.tsx new file mode 100644 index 0000000000..9769fa408d --- /dev/null +++ b/plugins/ilert/src/components/UptimeMonitorsPage/UptimeMonitorsPage.tsx @@ -0,0 +1,67 @@ +/* + * 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, + ResponseErrorPanel, +} from '@backstage/core'; +import { AuthenticationError } from '@backstage/errors'; +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 AuthenticationError) { + return ( + + + + ); + } + + return ( + + + + ); + } + + return ( + + + + This helps you to bring iLert into your developer portal. + + + + + ); +}; diff --git a/plugins/ilert/src/components/UptimeMonitorsPage/UptimeMonitorsTable.tsx b/plugins/ilert/src/components/UptimeMonitorsPage/UptimeMonitorsTable.tsx new file mode 100644 index 0000000000..2173f8e1b7 --- /dev/null +++ b/plugins/ilert/src/components/UptimeMonitorsPage/UptimeMonitorsTable.tsx @@ -0,0 +1,176 @@ +/* + * 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 { DateTime as dt, Interval } from 'luxon'; +import humanizeDuration from 'humanize-duration'; +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 => ( + + ), + }, + { + title: 'Name', + field: 'name', + render: rowData => ( + {(rowData as UptimeMonitor).name} + ), + }, + { + title: 'Check Type', + field: 'checkType', + cellStyle: lgColumnStyle, + headerStyle: lgColumnStyle, + render: rowData => ( + + ), + }, + { + title: 'Last state change', + field: 'lastStatusChange', + type: 'datetime', + cellStyle: mdColumnStyle, + headerStyle: mdColumnStyle, + render: rowData => ( + + {humanizeDuration( + Interval.fromDateTimes( + dt.fromISO((rowData as UptimeMonitor).lastStatusChange), + dt.now(), + ) + .toDuration() + .valueOf(), + { units: ['h', 'm', 's'], largest: 2, round: true }, + )} + + ), + }, + { + title: 'Escalation policy', + field: 'assignedTo', + cellStyle: lgColumnStyle, + headerStyle: lgColumnStyle, + render: rowData => ( + + ), + }, + { + title: 'Status', + field: 'status', + cellStyle: smColumnStyle, + headerStyle: smColumnStyle, + render: rowData => ( + + ), + }, + { + title: '', + field: '', + cellStyle: smColumnStyle, + headerStyle: smColumnStyle, + render: rowData => ( + + ), + }, + ]; + + return ( +
+ No uptime monitor + + } + page={tableState.page} + onChangePage={onChangePage} + onChangeRowsPerPage={onChangeRowsPerPage} + localization={{ header: { actions: undefined } }} + isLoading={isLoading} + columns={columns} + data={uptimeMonitors} + /> + ); +}; diff --git a/plugins/ilert/src/components/UptimeMonitorsPage/index.ts b/plugins/ilert/src/components/UptimeMonitorsPage/index.ts new file mode 100644 index 0000000000..d1a8809a4e --- /dev/null +++ b/plugins/ilert/src/components/UptimeMonitorsPage/index.ts @@ -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'; diff --git a/plugins/ilert/src/components/index.ts b/plugins/ilert/src/components/index.ts new file mode 100644 index 0000000000..81f7e02e23 --- /dev/null +++ b/plugins/ilert/src/components/index.ts @@ -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'; diff --git a/plugins/ilert/src/constants.ts b/plugins/ilert/src/constants.ts new file mode 100644 index 0000000000..bba644785f --- /dev/null +++ b/plugins/ilert/src/constants.ts @@ -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_ANNOTATION = 'ilert.com/integration-key'; diff --git a/plugins/ilert/src/hooks/index.ts b/plugins/ilert/src/hooks/index.ts new file mode 100644 index 0000000000..6e7e207112 --- /dev/null +++ b/plugins/ilert/src/hooks/index.ts @@ -0,0 +1,31 @@ +/* + * 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_ANNOTATION } from '../constants'; + +export function useILertEntity() { + const { entity } = useEntity(); + const integrationKey = + entity.metadata.annotations?.[ILERT_INTEGRATION_KEY_ANNOTATION] || ''; + const name = entity.metadata.name; + const identifier = `${entity.kind}:${ + entity.metadata.namespace || 'default' + }/${entity.metadata.name}`; + + return { integrationKey, name, identifier }; +} diff --git a/plugins/ilert/src/hooks/useAlertSource.ts b/plugins/ilert/src/hooks/useAlertSource.ts new file mode 100644 index 0000000000..15cc213b48 --- /dev/null +++ b/plugins/ilert/src/hooks/useAlertSource.ts @@ -0,0 +1,104 @@ +/* + * 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 } from '../api'; +import { useApi, errorApiRef } from '@backstage/core'; +import { AuthenticationError } from '@backstage/errors'; +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( + null, + ); + const [isAlertSourceLoading, setIsAlertSourceLoading] = React.useState(false); + const [ + uptimeMonitor, + setUptimeMonitor, + ] = React.useState(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 AuthenticationError)) { + 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 AuthenticationError)) { + 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; +}; diff --git a/plugins/ilert/src/hooks/useAlertSourceOnCalls.ts b/plugins/ilert/src/hooks/useAlertSourceOnCalls.ts new file mode 100644 index 0000000000..0fbb190a02 --- /dev/null +++ b/plugins/ilert/src/hooks/useAlertSourceOnCalls.ts @@ -0,0 +1,64 @@ +/* + * 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 } from '../api'; +import { useApi, errorApiRef } from '@backstage/core'; +import { AuthenticationError } from '@backstage/errors'; +import { useAsyncRetry } from 'react-use'; +import { AlertSource, OnCall } from '../types'; + +export const useAlertSourceOnCalls = (alertSource?: AlertSource | null) => { + const ilertApi = useApi(ilertApiRef); + const errorApi = useApi(errorApiRef); + + const [onCallsList, setOnCallsList] = React.useState([]); + const [isLoading, setIsLoading] = React.useState(false); + + const fetchAlertSourceOnCallsCall = async () => { + try { + if (!alertSource) { + return; + } + setIsLoading(true); + const data = await ilertApi.fetchAlertSourceOnCalls(alertSource); + setOnCallsList(data || []); + setIsLoading(false); + } catch (e) { + setIsLoading(false); + if (!(e instanceof AuthenticationError)) { + errorApi.post(e); + } + throw e; + } + }; + + const { error, retry } = useAsyncRetry(fetchAlertSourceOnCallsCall, [ + alertSource, + ]); + + return [ + { + onCalls: onCallsList, + error, + isLoading, + }, + { + retry, + setIsLoading, + refetchAlertSourceOnCalls: fetchAlertSourceOnCallsCall, + }, + ] as const; +}; diff --git a/plugins/ilert/src/hooks/useAssignIncident.ts b/plugins/ilert/src/hooks/useAssignIncident.ts new file mode 100644 index 0000000000..8f0f663d2b --- /dev/null +++ b/plugins/ilert/src/hooks/useAssignIncident.ts @@ -0,0 +1,74 @@ +/* + * 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 } from '../api'; +import { useApi, errorApiRef } from '@backstage/core'; +import { AuthenticationError } from '@backstage/errors'; +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(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 AuthenticationError)) { + errorApi.post(e); + } + throw e; + } + }, [incident, open]); + + return [ + { + incidentRespondersList, + incidentResponder, + error, + isLoading, + }, + { + setIncidentRespondersList, + setIncidentResponder, + setIsLoading, + retry, + }, + ] as const; +}; diff --git a/plugins/ilert/src/hooks/useIncidentActions.ts b/plugins/ilert/src/hooks/useIncidentActions.ts new file mode 100644 index 0000000000..e23341ed9e --- /dev/null +++ b/plugins/ilert/src/hooks/useIncidentActions.ts @@ -0,0 +1,62 @@ +/* + * 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 } from '../api'; +import { useApi, errorApiRef } from '@backstage/core'; +import { AuthenticationError } from '@backstage/errors'; +import { useAsyncRetry } from 'react-use'; +import { Incident, IncidentAction } from '../types'; + +export const useIncidentActions = ( + incident: Incident | null, + open: boolean, +) => { + const ilertApi = useApi(ilertApiRef); + const errorApi = useApi(errorApiRef); + + const [incidentActionsList, setIncidentActionsList] = React.useState< + IncidentAction[] + >([]); + const [isLoading, setIsLoading] = React.useState(false); + + const { error, retry } = useAsyncRetry(async () => { + try { + if (!incident || !open) { + return; + } + const data = await ilertApi.fetchIncidentActions(incident); + setIncidentActionsList(data); + } catch (e) { + if (!(e instanceof AuthenticationError)) { + errorApi.post(e); + } + throw e; + } + }, [incident, open]); + + return [ + { + incidentActions: incidentActionsList, + error, + isLoading, + }, + { + setIncidentActionsList, + setIsLoading, + retry, + }, + ] as const; +}; diff --git a/plugins/ilert/src/hooks/useIncidents.ts b/plugins/ilert/src/hooks/useIncidents.ts new file mode 100644 index 0000000000..5f130fb20c --- /dev/null +++ b/plugins/ilert/src/hooks/useIncidents.ts @@ -0,0 +1,159 @@ +/* + * 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 } from '../api'; +import { useApi, errorApiRef } from '@backstage/core'; +import { AuthenticationError } from '@backstage/errors'; +import { useAsyncRetry } from 'react-use'; +import { + ACCEPTED, + PENDING, + Incident, + IncidentStatus, + AlertSource, +} from '../types'; + +export const useIncidents = ( + paging: boolean, + singleSource?: boolean, + alertSource?: AlertSource | null, +) => { + const ilertApi = useApi(ilertApiRef); + const errorApi = useApi(errorApiRef); + + const [tableState, setTableState] = React.useState({ + page: 0, + pageSize: 10, + }); + const [states, setStates] = React.useState([ + ACCEPTED, + PENDING, + ]); + const [incidentsList, setIncidentsList] = React.useState([]); + const [incidentsCount, setIncidentsCount] = React.useState(0); + const [isLoading, setIsLoading] = React.useState(false); + + const fetchIncidentsCall = async () => { + try { + if (singleSource && !alertSource) { + return; + } + setIsLoading(true); + const opts: GetIncidentsOpts = { + states, + alertSources: alertSource ? [alertSource.id] : [], + }; + 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 AuthenticationError)) { + 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 AuthenticationError)) { + errorApi.post(e); + } + throw e; + } + }; + const fetchIncidents = useAsyncRetry(fetchIncidentsCall, [ + tableState, + states, + singleSource, + alertSource, + ]); + + 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; +}; diff --git a/plugins/ilert/src/hooks/useNewIncident.ts b/plugins/ilert/src/hooks/useNewIncident.ts new file mode 100644 index 0000000000..6fbcfbb5a5 --- /dev/null +++ b/plugins/ilert/src/hooks/useNewIncident.ts @@ -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 } from '../api'; +import { useApi, errorApiRef } from '@backstage/core'; +import { AuthenticationError } from '@backstage/errors'; +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( + [], + ); + const [alertSource, setAlertSource] = React.useState( + 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 AuthenticationError)) { + 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; +}; diff --git a/plugins/ilert/src/hooks/useOnCallSchedules.ts b/plugins/ilert/src/hooks/useOnCallSchedules.ts new file mode 100644 index 0000000000..ce76a0770c --- /dev/null +++ b/plugins/ilert/src/hooks/useOnCallSchedules.ts @@ -0,0 +1,61 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { ilertApiRef } from '../api'; +import { useApi, errorApiRef } from '@backstage/core'; +import { AuthenticationError } from '@backstage/errors'; +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 AuthenticationError)) { + errorApi.post(e); + } + throw e; + } + }; + + const { error, retry } = useAsyncRetry(fetchOnCallSchedulesCall, []); + + return [ + { + onCallSchedules: onCallSchedulesList, + error, + isLoading, + }, + { + retry, + setIsLoading, + refetchOnCallSchedules: fetchOnCallSchedulesCall, + }, + ] as const; +}; diff --git a/plugins/ilert/src/hooks/useShiftOverride.ts b/plugins/ilert/src/hooks/useShiftOverride.ts new file mode 100644 index 0000000000..136d20d286 --- /dev/null +++ b/plugins/ilert/src/hooks/useShiftOverride.ts @@ -0,0 +1,79 @@ +/* + * 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 } from '../api'; +import { useApi, errorApiRef } from '@backstage/core'; +import { AuthenticationError } from '@backstage/errors'; +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(s); + const [usersList, setUsersList] = React.useState([]); + 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 AuthenticationError)) { + 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; +}; diff --git a/plugins/ilert/src/hooks/useUptimeMonitors.ts b/plugins/ilert/src/hooks/useUptimeMonitors.ts new file mode 100644 index 0000000000..fa1576642a --- /dev/null +++ b/plugins/ilert/src/hooks/useUptimeMonitors.ts @@ -0,0 +1,89 @@ +/* + * 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 } from '../api'; +import { useApi, errorApiRef } from '@backstage/core'; +import { AuthenticationError } from '@backstage/errors'; +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({ + 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 AuthenticationError)) { + 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; +}; diff --git a/plugins/ilert/src/index.ts b/plugins/ilert/src/index.ts new file mode 100644 index 0000000000..2e5301f151 --- /dev/null +++ b/plugins/ilert/src/index.ts @@ -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; diff --git a/plugins/ilert/src/plugin.test.ts b/plugins/ilert/src/plugin.test.ts new file mode 100644 index 0000000000..89a0cb231f --- /dev/null +++ b/plugins/ilert/src/plugin.test.ts @@ -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(); + }); +}); diff --git a/plugins/ilert/src/plugin.ts b/plugins/ilert/src/plugin.ts new file mode 100644 index 0000000000..546b77911d --- /dev/null +++ b/plugins/ilert/src/plugin.ts @@ -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), + }, + }), +); diff --git a/plugins/ilert/src/route-refs.tsx b/plugins/ilert/src/route-refs.tsx new file mode 100644 index 0000000000..b63ba15315 --- /dev/null +++ b/plugins/ilert/src/route-refs.tsx @@ -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', +}); diff --git a/plugins/ilert/src/setupTests.ts b/plugins/ilert/src/setupTests.ts new file mode 100644 index 0000000000..0cec5b395d --- /dev/null +++ b/plugins/ilert/src/setupTests.ts @@ -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'; diff --git a/plugins/ilert/src/types.ts b/plugins/ilert/src/types.ts new file mode 100644 index 0000000000..d7746c9d9b --- /dev/null +++ b/plugins/ilert/src/types.ts @@ -0,0 +1,337 @@ +/* + * 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; +} + +export interface IncidentAction { + name: string; + type: string; + webhookId: string; + extensionId?: string; + history?: IncidentActionHistory[]; +} + +export interface IncidentActionHistory { + id: string; + webhookId: string; + incidentId: number; + actor: User; + success: boolean; +} + +export interface OnCall { + user: User; + escalationPolicy: EscalationPolicy; + schedule?: Schedule; + start: string; + end: string; + escalationLevel: number; +} diff --git a/yarn.lock b/yarn.lock index 330463a73a..76856b26b9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1951,6 +1951,13 @@ dependencies: "@date-io/core" "^1.3.13" +"@date-io/luxon@1.x": + version "1.3.13" + resolved "https://registry.npmjs.org/@date-io/luxon/-/luxon-1.3.13.tgz#68f0134bb38ef486b2ed6df01981f814c633e28a" + integrity sha512-9wUrJCNSMZJeYAiH+dbb45oGpnHeFP7TOH/Lt26If47gjFCkjvyINzWx+K5AGsnlP0Qosxc7hkF1yLi6ecutxw== + dependencies: + "@date-io/core" "^1.3.13" + "@emotion/cache@^10.0.27": version "10.0.29" resolved "https://registry.npmjs.org/@emotion/cache/-/cache-10.0.29.tgz#87e7e64f412c060102d589fe7c6dc042e6f9d1e0" @@ -3729,6 +3736,18 @@ react-transition-group "^4.0.0" rifm "^0.7.0" +"@material-ui/pickers@^3.3.10": + version "3.3.10" + resolved "https://registry.npmjs.org/@material-ui/pickers/-/pickers-3.3.10.tgz#f1b0f963348cc191645ef0bdeff7a67c6aa25485" + integrity sha512-hS4pxwn1ZGXVkmgD4tpFpaumUaAg2ZzbTrxltfC5yPw4BJV+mGkfnQOB4VpWEYZw2jv65Z0wLwDE/piQiPPZ3w== + dependencies: + "@babel/runtime" "^7.6.0" + "@date-io/core" "1.x" + "@types/styled-jsx" "^2.2.8" + clsx "^1.0.2" + react-transition-group "^4.0.0" + rifm "^0.7.0" + "@material-ui/styles@^4.10.0", "@material-ui/styles@^4.11.0", "@material-ui/styles@^4.11.3", "@material-ui/styles@^4.9.6": version "4.11.3" resolved "https://registry.npmjs.org/@material-ui/styles/-/styles-4.11.3.tgz#1b8d97775a4a643b53478c895e3f2a464e8916f2" @@ -15119,6 +15138,11 @@ humanize-duration@^3.25.1: resolved "https://registry.npmjs.org/humanize-duration/-/humanize-duration-3.25.1.tgz#50e12bf4b3f515ec91106107ee981e8cfe955d6f" integrity sha512-P+dRo48gpLgc2R9tMRgiDRNULPKCmqFYgguwqOO2C0fjO35TgdURDQDANSR1Nt92iHlbHGMxOTnsB8H8xnMa2Q== +humanize-duration@^3.26.0: + version "3.26.0" + resolved "https://registry.npmjs.org/humanize-duration/-/humanize-duration-3.26.0.tgz#4d77f6b3d2fe0ca1ff14623ccc2b2f8b48ab1aaf" + integrity sha512-SddekX3p5ApvPY6bbAYppGKe874jP6iFZXYtrQToDV4R0j2UpTYPqwTFM2QpXpuw9DhS/eXTUnKYTF9TbXAJ6A== + humanize-ms@^1.2.1: version "1.2.1" resolved "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz#c46e3159a293f6b896da29316d8b6fe8bb79bbed"