Merge pull request #4416 from ayshiff/feature/splunk-on-call-plugin
Feature: Splunk On-Call Plugin
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-splunk-on-call': patch
|
||||
---
|
||||
|
||||
Added splunk-on-call plugin.
|
||||
@@ -213,6 +213,7 @@ Sneha
|
||||
Snyk
|
||||
sourcemaps
|
||||
sparklines
|
||||
Splunk
|
||||
Spotifiers
|
||||
spotify
|
||||
Spotify
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
title: Splunk On-Call
|
||||
author: Spotify
|
||||
authorUrl: https://github.com/spotify
|
||||
category: Monitoring
|
||||
description: Splunk On-Call offers a simple way to identify incidents and escalation policies.
|
||||
documentation: https://github.com/backstage/backstage/tree/master/plugins/splunk-on-call
|
||||
iconUrl: data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMiIgaGVpZ2h0PSIzMiIgdmlld0JveD0iMCAwIDMyIDMyIiBjbGFzcz0idm8tbGVhZi1ibGFjayI+PHBhdGggZD0iTTE1Ljk5OTcgMzJDMjQuODM2MSAzMiAzMiAyNC44MzY4IDMyIDE2LjAwMDJDMzIgNy4xNjM1NiAyNC44MzYxIDAgMTUuOTk5NyAwQzcuMTYzNCAwIDAgNy4xNjM1NiAwIDE2LjAwMDJDMCAyNC44MzY4IDcuMTYzNCAzMiAxNS45OTk3IDMyWk0yMi41Njc1IDE0LjY5MDRMMjMuNjI1OSAxNi41MTU4TDE4Ljg4NTEgMTguOTE0NUwxOC44MSAxOS43MTQxTDIyLjQwMzMgMTguNTc2MUwyMC41OTcyIDIwLjg0MzJMMjAuNDQ2MiAyMS41NjI4TDE4LjI0MzkgMjIuNTUyNUwxOC4wMTc2IDIzLjIzMjJMMTkuMDA0NSAyMi44MTU3TDE2LjM0MjIgMjUuOTEwNFYyOS42MDA0SDE1LjY1NzdWMjUuOTEwNEwxMi45OTU5IDIyLjgxNTdMMTMuOTgyOCAyMy4yMzIyTDEzLjc1NjYgMjIuNTUyNUwxMS41NTQ1IDIxLjU2MjhMMTEuNDAzNiAyMC44NDMyTDkuNTk2OTIgMTguNTc2MUwxMy4xOTA2IDE5LjcxNDFMMTMuMTE0OSAxOC45MTQ1TDguMzc0NDkgMTYuNTE1OEw5LjQzMzM5IDE0LjY5MDRMNy4yMDAyIDExLjUxODlMMTMuMzcxOCAxNC41MDA3TDEzLjQ5NyAxMy42MDM3TDExLjY5MzYgMTAuNjYyTDExLjk1MiAxMC4xNzc0TDExLjExOTUgNi43MTg2N0wxNC4zNTUzIDEwLjEyNjdMMTQuNTQ5MyA4LjI0NDU0TDEzLjU4MTggNS40MjI1M0wxNC44NzMgNS44NTYyNEwxNiAyLjQwMDM5TDE3LjEyNzMgNS44NTYyNEwxOC40MTg2IDUuNDIyNTNMMTcuNDUxMiA4LjI0NDU0TDE3LjY0NTQgMTAuMTI2N0wyMC44ODA3IDYuNzE4NjdMMjAuMDQ4MiAxMC4xNzc0TDIwLjMwNjggMTAuNjYyTDE4LjUwMzQgMTMuNjAzN0wxOC42Mjg4IDE0LjUwMDdMMjQuODAwMiAxMS41MTg5TDIyLjU2NzUgMTQuNjkwNFoiIGZpbGw9JyMyQzJDMkMnLz48L3N2Zz4K
|
||||
npmPackageName: '@backstage/plugin-splunk-on-call'
|
||||
tags:
|
||||
- monitoring
|
||||
- errors
|
||||
- alerting
|
||||
- splunk
|
||||
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
extends: [require.resolve('@backstage/cli/config/eslint')],
|
||||
};
|
||||
@@ -0,0 +1,120 @@
|
||||
# Splunk On-Call
|
||||
|
||||
## Overview
|
||||
|
||||
This plugin displays Splunk On-Call, formerly VictorOps, information about an entity.
|
||||
|
||||
There is a way to trigger an new incident directly to specific users or/and specific teams.
|
||||
|
||||
This plugin requires that entities are annotated with a team name. See more further down in this document.
|
||||
|
||||
This plugin provides:
|
||||
|
||||
- A list of incidents
|
||||
- A way to trigger a new incident to specific users or/and teams
|
||||
- A way to acknowledge/resolve an incident
|
||||
- Information details about the persons on-call
|
||||
|
||||
## Setup instructions
|
||||
|
||||
Install the plugin:
|
||||
|
||||
```bash
|
||||
yarn add @backstage/plugin-splunk-on-call
|
||||
```
|
||||
|
||||
Add it to the app in `plugins.ts`:
|
||||
|
||||
```ts
|
||||
export { plugin as SplunkOnCall } from '@backstage/plugin-splunk-on-call';
|
||||
```
|
||||
|
||||
Add it to the `EntityPage.tsx`:
|
||||
|
||||
```ts
|
||||
import {
|
||||
isPluginApplicableToEntity as isSplunkOnCallAvailable,
|
||||
SplunkOnCallCard,
|
||||
} from '@backstage/plugin-splunk-on-call';
|
||||
// ...
|
||||
{
|
||||
isSplunkOnCallAvailable(entity) && (
|
||||
<Grid item md={6}>
|
||||
<SplunkOnCallCard entity={entity} />
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Client configuration
|
||||
|
||||
In order to be able to perform certain action (create-acknowledge-resolve an action), you need to provide the username of the user making the action.
|
||||
The user supplied must be a valid Splunk On-Call user and a member of your organization.
|
||||
|
||||
In `app-config.yaml`:
|
||||
|
||||
```yaml
|
||||
splunkOnCall:
|
||||
username: <SPLUNK_ON_CALL_USERNAME>
|
||||
```
|
||||
|
||||
The user supplied must be a valid Splunk On-Call user and a member of your organization.
|
||||
|
||||
In order to make the API calls, you need to provide a new proxy config which will redirect to the Splunk On-Call API endpoint and add authentication information in the headers:
|
||||
|
||||
```yaml
|
||||
# app-config.yaml
|
||||
proxy:
|
||||
# ...
|
||||
'/splunk-on-call':
|
||||
target: https://api.victorops.com/api-public
|
||||
headers:
|
||||
X-VO-Api-Id:
|
||||
$env: SPLUNK_ON_CALL_API_ID
|
||||
X-VO-Api-Key:
|
||||
$env: SPLUNK_ON_CALL_API_KEY
|
||||
```
|
||||
|
||||
In addition, to make certain API calls (trigger-resolve-acknowledge an incident) you need to add the `PATCH` method to the backend `cors` methods list: `[GET, POST, PUT, DELETE, PATCH]`.
|
||||
|
||||
### Adding your team name to the entity annotation
|
||||
|
||||
The information displayed for each entity is based on the team name.
|
||||
If you want to use this plugin for an entity, you need to label it with the below annotation:
|
||||
|
||||
```yaml
|
||||
annotations:
|
||||
splunk.com/on-call-team': <SPLUNK_ON_CALL_TEAM_NAME>
|
||||
```
|
||||
|
||||
## Providing the API key and API id
|
||||
|
||||
In order for the client to make requests to the [Splunk On-Call API](https://portal.victorops.com/public/api-docs.html#/) it needs an [API ID and an API Key](https://help.victorops.com/knowledge-base/api/).
|
||||
|
||||
Then start the backend passing the values as an environment variable:
|
||||
|
||||
```bash
|
||||
$ SPLUNK_ON_CALL_API_KEY='' SPLUNK_ON_CALL_API_ID='' yarn start
|
||||
```
|
||||
|
||||
This will proxy the request by adding `X-VO-Api-Id` and `X-VO-Api-Key` headers with the provided values.
|
||||
|
||||
You can also add the values in your helm template:
|
||||
|
||||
```yaml
|
||||
# backend-secret.yaml
|
||||
stringData:
|
||||
# ...
|
||||
SPLUNK_ON_CALL_API_ID: { { .Values.auth.splunkOnCallApiId } }
|
||||
SPLUNK_ON_CALL_API_KEY: { { .Values.auth.splunkOnCallApiKey } }
|
||||
```
|
||||
|
||||
To enable it you need to provide them in the chart's values:
|
||||
|
||||
```yaml
|
||||
# values.yaml
|
||||
auth:
|
||||
# ...
|
||||
splunkOnCallApiId: h
|
||||
splunkOnCallApiKey: h
|
||||
```
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* 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 { splunkOnCallPlugin, SplunkOnCallPage } from '../src/plugin';
|
||||
|
||||
createDevApp()
|
||||
.registerPlugin(splunkOnCallPlugin)
|
||||
.addPage({
|
||||
title: 'Splunk On-Call',
|
||||
element: <SplunkOnCallPage />,
|
||||
})
|
||||
.render();
|
||||
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"name": "@backstage/plugin-splunk-on-call",
|
||||
"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"
|
||||
},
|
||||
"homepage": "https://backstage.io",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/backstage/backstage",
|
||||
"directory": "plugins/splunk-on-call"
|
||||
},
|
||||
"keywords": [
|
||||
"backstage",
|
||||
"splunk-on-call"
|
||||
],
|
||||
"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.1",
|
||||
"@backstage/core": "^0.6.1",
|
||||
"@backstage/plugin-catalog-react": "^0.0.2",
|
||||
"@backstage/theme": "^0.2.3",
|
||||
"@material-ui/core": "^4.11.0",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"@material-ui/lab": "4.0.0-alpha.45",
|
||||
"classnames": "^2.2.6",
|
||||
"luxon": "^1.25.0",
|
||||
"react": "^16.13.1",
|
||||
"react-dom": "^16.13.1",
|
||||
"react-router-dom": "6.0.0-beta.0",
|
||||
"react-use": "^15.3.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.6.0",
|
||||
"@backstage/dev-utils": "^0.1.10",
|
||||
"@backstage/test-utils": "^0.1.7",
|
||||
"@testing-library/jest-dom": "^5.10.1",
|
||||
"@testing-library/react": "^10.4.1",
|
||||
"@testing-library/user-event": "^12.0.7",
|
||||
"@types/jest": "^26.0.7",
|
||||
"@types/luxon": "^1.25.0",
|
||||
"@types/node": "^12.0.0",
|
||||
"cross-fetch": "^3.0.6",
|
||||
"msw": "^0.21.2",
|
||||
"node-fetch": "^2.6.1"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
/*
|
||||
* 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 { createApiRef, DiscoveryApi, ConfigApi } from '@backstage/core';
|
||||
import {
|
||||
Incident,
|
||||
OnCall,
|
||||
User,
|
||||
EscalationPolicyInfo,
|
||||
Team,
|
||||
} from '../components/types';
|
||||
import {
|
||||
SplunkOnCallApi,
|
||||
TriggerAlarmRequest,
|
||||
IncidentsResponse,
|
||||
OnCallsResponse,
|
||||
ClientApiConfig,
|
||||
RequestOptions,
|
||||
ListUserResponse,
|
||||
EscalationPolicyResponse,
|
||||
PatchIncidentRequest,
|
||||
} from './types';
|
||||
|
||||
export class UnauthorizedError extends Error {}
|
||||
|
||||
export const splunkOnCallApiRef = createApiRef<SplunkOnCallApi>({
|
||||
id: 'plugin.splunk-on-call.api',
|
||||
description: 'Used to fetch data from Splunk On-Call API',
|
||||
});
|
||||
|
||||
export class SplunkOnCallClient implements SplunkOnCallApi {
|
||||
static fromConfig(configApi: ConfigApi, discoveryApi: DiscoveryApi) {
|
||||
const usernameFromConfig: string | null =
|
||||
configApi.getOptionalString('splunkOnCall.username') || null;
|
||||
return new SplunkOnCallClient({
|
||||
username: usernameFromConfig,
|
||||
discoveryApi,
|
||||
});
|
||||
}
|
||||
constructor(private readonly config: ClientApiConfig) {}
|
||||
|
||||
async getIncidents(): Promise<Incident[]> {
|
||||
const url = `${await this.config.discoveryApi.getBaseUrl(
|
||||
'proxy',
|
||||
)}/splunk-on-call/v1/incidents`;
|
||||
|
||||
const { incidents } = await this.getByUrl<IncidentsResponse>(url);
|
||||
|
||||
return incidents;
|
||||
}
|
||||
|
||||
async getOnCallUsers(): Promise<OnCall[]> {
|
||||
const url = `${await this.config.discoveryApi.getBaseUrl(
|
||||
'proxy',
|
||||
)}/splunk-on-call/v1/oncall/current`;
|
||||
const { teamsOnCall } = await this.getByUrl<OnCallsResponse>(url);
|
||||
|
||||
return teamsOnCall;
|
||||
}
|
||||
|
||||
async getTeams(): Promise<Team[]> {
|
||||
const url = `${await this.config.discoveryApi.getBaseUrl(
|
||||
'proxy',
|
||||
)}/splunk-on-call/v1/team`;
|
||||
const teams = await this.getByUrl<Team[]>(url);
|
||||
|
||||
return teams;
|
||||
}
|
||||
|
||||
async acknowledgeIncident({
|
||||
incidentNames,
|
||||
}: PatchIncidentRequest): Promise<Response> {
|
||||
const options = {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
userName: this.config.username,
|
||||
incidentNames,
|
||||
}),
|
||||
};
|
||||
|
||||
const url = `${await this.config.discoveryApi.getBaseUrl(
|
||||
'proxy',
|
||||
)}/splunk-on-call/v1/incidents/ack`;
|
||||
|
||||
return this.request(url, options);
|
||||
}
|
||||
|
||||
async resolveIncident({
|
||||
incidentNames,
|
||||
}: PatchIncidentRequest): Promise<Response> {
|
||||
const options = {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
userName: this.config.username,
|
||||
incidentNames,
|
||||
}),
|
||||
};
|
||||
|
||||
const url = `${await this.config.discoveryApi.getBaseUrl(
|
||||
'proxy',
|
||||
)}/splunk-on-call/v1/incidents/resolve`;
|
||||
|
||||
return this.request(url, options);
|
||||
}
|
||||
|
||||
async getUsers(): Promise<User[]> {
|
||||
const url = `${await this.config.discoveryApi.getBaseUrl(
|
||||
'proxy',
|
||||
)}/splunk-on-call/v2/user`;
|
||||
const { users } = await this.getByUrl<ListUserResponse>(url);
|
||||
|
||||
return users;
|
||||
}
|
||||
|
||||
async getEscalationPolicies(): Promise<EscalationPolicyInfo[]> {
|
||||
const url = `${await this.config.discoveryApi.getBaseUrl(
|
||||
'proxy',
|
||||
)}/splunk-on-call/v1/policies`;
|
||||
const { policies } = await this.getByUrl<EscalationPolicyResponse>(url);
|
||||
|
||||
return policies;
|
||||
}
|
||||
|
||||
async triggerAlarm({
|
||||
summary,
|
||||
details,
|
||||
userName,
|
||||
targets,
|
||||
isMultiResponder,
|
||||
}: TriggerAlarmRequest): Promise<Response> {
|
||||
const body = JSON.stringify({
|
||||
summary,
|
||||
details,
|
||||
userName: this.config.username || userName,
|
||||
targets,
|
||||
isMultiResponder,
|
||||
});
|
||||
|
||||
const options = {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body,
|
||||
};
|
||||
|
||||
const url = `${await this.config.discoveryApi.getBaseUrl(
|
||||
'proxy',
|
||||
)}/splunk-on-call/v1/incidents`;
|
||||
|
||||
return this.request(url, options);
|
||||
}
|
||||
|
||||
private async getByUrl<T>(url: string): Promise<T> {
|
||||
const options = {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
};
|
||||
const response = await this.request(url, options);
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
private async request(
|
||||
url: string,
|
||||
options: RequestOptions,
|
||||
): Promise<Response> {
|
||||
const response = await fetch(url, options);
|
||||
if (response.status === 403) {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
if (!response.ok) {
|
||||
const payload = await response.json();
|
||||
const errors = payload.errors.map((error: string) => error).join(' ');
|
||||
const message = `Request failed with ${response.status}, ${errors}`;
|
||||
throw new Error(message);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export {
|
||||
SplunkOnCallClient,
|
||||
splunkOnCallApiRef,
|
||||
UnauthorizedError,
|
||||
} from './client';
|
||||
export type { SplunkOnCallApi } from './types';
|
||||
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
* 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 {
|
||||
EscalationPolicyInfo,
|
||||
Incident,
|
||||
Team,
|
||||
User,
|
||||
} from '../components/types';
|
||||
|
||||
export const MOCKED_USER: User = {
|
||||
createdAt: '2021-02-01T23:38:38Z',
|
||||
displayName: 'Test User',
|
||||
email: 'test@example.com',
|
||||
firstName: 'FirstNameTest',
|
||||
lastName: 'LastNameTest',
|
||||
passwordLastUpdated: '2021-02-01T23:38:38Z',
|
||||
username: 'test_user',
|
||||
verified: true,
|
||||
_selfUrl: '/api-public/v1/user/test_user',
|
||||
};
|
||||
|
||||
export const MOCKED_ON_CALL = [
|
||||
{
|
||||
team: { name: 'team_example', slug: 'team-zEalMCgwYSA0Lt40' },
|
||||
oncallNow: [
|
||||
{
|
||||
escalationPolicy: { name: 'Example', slug: 'team-zEalMCgwYSA0Lt40' },
|
||||
users: [{ onCalluser: { username: 'test_user' } }],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const MOCK_INCIDENT: Incident = {
|
||||
alertCount: 1,
|
||||
currentPhase: 'ACKED',
|
||||
entityDisplayName: 'test-incident',
|
||||
entityId: 'entityId',
|
||||
entityState: 'CRITICAL',
|
||||
entityType: 'SERVICE',
|
||||
incidentNumber: '1',
|
||||
lastAlertId: 'lastAlertId',
|
||||
lastAlertTime: '2021-02-03T00:13:11Z',
|
||||
routingKey: 'routingdefault',
|
||||
service: 'test',
|
||||
startTime: '2021-02-03T00:13:11Z',
|
||||
pagedTeams: ['team-O9SqT13fsnCstjMi'],
|
||||
pagedUsers: [],
|
||||
pagedPolicies: [
|
||||
{
|
||||
policy: {
|
||||
name: 'Generated Direct User Policy for test_user',
|
||||
slug: 'directUserPolicySlug-test',
|
||||
_selfUrl: '/test',
|
||||
},
|
||||
},
|
||||
],
|
||||
transitions: [{ name: 'ACKED', at: '2021-02-03T01:20:00Z', by: 'test' }],
|
||||
monitorName: 'vouser-user',
|
||||
monitorType: 'Manual',
|
||||
firstAlertUuid: 'firstAlertUuid',
|
||||
incidentLink: 'https://portal.victorops.com/example',
|
||||
};
|
||||
|
||||
export const MOCK_TEAM: Team = {
|
||||
_selfUrl: '/api-public/v1/team/team-O9SqT13fsnCstjMi',
|
||||
_membersUrl: '/api-public/v1/team/team-O9SqT13fsnCstjMi/members',
|
||||
_policiesUrl: '/api-public/v1/team/team-O9SqT13fsnCstjMi/policies',
|
||||
_adminsUrl: '/api-public/v1/team/team-O9SqT13fsnCstjMi/admins',
|
||||
name: 'test',
|
||||
slug: 'team-O9SqT13fsnCstjMi',
|
||||
memberCount: 1,
|
||||
version: 1,
|
||||
isDefaultTeam: false,
|
||||
};
|
||||
|
||||
export const ESCALATION_POLICIES: EscalationPolicyInfo[] = [
|
||||
{
|
||||
policy: {
|
||||
name: 'Example',
|
||||
slug: 'team-zEalMCgwYSA0Lt40',
|
||||
_selfUrl: '/api-public/v1/policies/team-zEalMCgwYSA0Lt40',
|
||||
},
|
||||
team: { name: 'Example', slug: 'team-zEalMCgwYSA0Lt40' },
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* 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 {
|
||||
EscalationPolicyInfo,
|
||||
Incident,
|
||||
OnCall,
|
||||
Team,
|
||||
User,
|
||||
} from '../components/types';
|
||||
import { DiscoveryApi } from '@backstage/core';
|
||||
|
||||
export enum TargetType {
|
||||
UserValue = 'User',
|
||||
EscalationPolicyValue = 'EscalationPolicy',
|
||||
}
|
||||
|
||||
export type IncidentTarget = {
|
||||
type: TargetType;
|
||||
slug: string;
|
||||
};
|
||||
|
||||
export type TriggerAlarmRequest = {
|
||||
targets: IncidentTarget[];
|
||||
details: string;
|
||||
summary: string;
|
||||
userName: string;
|
||||
isMultiResponder?: boolean;
|
||||
};
|
||||
|
||||
export interface SplunkOnCallApi {
|
||||
/**
|
||||
* Fetches a list of incidents
|
||||
*/
|
||||
getIncidents(): Promise<Incident[]>;
|
||||
|
||||
/**
|
||||
* Fetches the list of users in an escalation policy.
|
||||
*/
|
||||
getOnCallUsers(): Promise<OnCall[]>;
|
||||
|
||||
/**
|
||||
* Triggers an incident to specific users and/or specific teams.
|
||||
*/
|
||||
triggerAlarm(request: TriggerAlarmRequest): Promise<Response>;
|
||||
|
||||
/**
|
||||
* Resolves an incident.
|
||||
*/
|
||||
resolveIncident(request: PatchIncidentRequest): Promise<Response>;
|
||||
|
||||
/**
|
||||
* Acknowledge an incident.
|
||||
*/
|
||||
acknowledgeIncident(request: PatchIncidentRequest): Promise<Response>;
|
||||
|
||||
/**
|
||||
* Get a list of users for your organization.
|
||||
*/
|
||||
getUsers(): Promise<User[]>;
|
||||
|
||||
/**
|
||||
* Get a list of teams for your organization.
|
||||
*/
|
||||
getTeams(): Promise<Team[]>;
|
||||
|
||||
/**
|
||||
* Get a list of escalation policies for your organization.
|
||||
*/
|
||||
getEscalationPolicies(): Promise<EscalationPolicyInfo[]>;
|
||||
}
|
||||
|
||||
export type PatchIncidentRequest = {
|
||||
incidentNames: string[];
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export type EscalationPolicyResponse = {
|
||||
policies: EscalationPolicyInfo[];
|
||||
};
|
||||
|
||||
export type ListUserResponse = {
|
||||
users: User[];
|
||||
_selfUrl?: string;
|
||||
};
|
||||
|
||||
export type IncidentsResponse = {
|
||||
incidents: Incident[];
|
||||
};
|
||||
|
||||
export type OnCallsResponse = {
|
||||
teamsOnCall: OnCall[];
|
||||
};
|
||||
|
||||
export type ClientApiConfig = {
|
||||
username: string | null;
|
||||
discoveryApi: DiscoveryApi;
|
||||
};
|
||||
|
||||
export type RequestOptions = {
|
||||
method: string;
|
||||
headers: HeadersInit;
|
||||
body?: BodyInit;
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="282" height="173" fill="none" viewBox="0 0 282 173"><path fill="#000" fill-opacity=".05" fill-rule="evenodd" d="M16.4571 45.1637C11.0514 46.1711 7.48574 51.3699 8.49306 56.7756C9.50039 62.1814 14.6992 65.747 20.105 64.7397L27.5528 63.3518C25.4791 65.5835 24.4525 68.7347 25.0535 71.9596C26.0608 77.3653 31.2596 80.931 36.6654 79.9236L89.691 70.0427C89.7016 70.1067 89.7129 70.1708 89.7249 70.2349C90.3258 73.4598 92.4185 76.0298 95.1569 77.3647L91.9031 77.971C86.4974 78.9784 82.9318 84.1772 83.9391 89.583C84.9464 94.9887 90.1452 98.5543 95.551 97.547L250.098 68.7482C255.504 67.7409 259.069 62.5421 258.062 57.1363C257.461 53.9114 255.368 51.3414 252.63 50.0065L257.835 49.0366C263.241 48.0292 266.807 42.8304 265.799 37.4247C264.792 32.0189 259.593 28.4533 254.187 29.4606L161.492 46.7338C161.481 46.6697 161.47 46.6056 161.458 46.5415C160.857 43.3166 158.764 40.7466 156.026 39.4117L165.025 37.7347C170.431 36.7274 173.997 31.5286 172.989 26.1228C171.982 20.7171 166.783 17.1514 161.378 18.1588L16.4571 45.1637ZM24.3031 122.54C23.2958 117.134 26.8614 111.936 32.2672 110.928L190.856 81.3762C196.262 80.3688 201.461 83.9345 202.468 89.3402C203.476 94.746 199.91 99.9448 194.504 100.952L189.963 101.798C190.493 102.057 190.999 102.362 191.474 102.708L246.43 92.4677C251.835 91.4604 257.034 95.026 258.041 100.432C258.642 103.657 257.616 106.808 255.542 109.04L256.649 108.833C262.055 107.826 267.253 111.392 268.261 116.797C269.268 122.203 265.702 127.402 260.297 128.409L95.5591 159.107C90.1534 160.114 84.9545 156.549 83.9472 151.143C82.9399 145.737 86.5055 140.538 91.9113 139.531L103.94 137.29C103.41 137.031 102.904 136.726 102.429 136.38L29.1002 150.044C23.6944 151.051 18.4956 147.486 17.4882 142.08C16.4809 136.674 20.0465 131.475 25.4523 130.468L29.7352 129.67C26.9967 128.335 24.904 125.765 24.3031 122.54Z" clip-rule="evenodd"/><g filter="url(#filter0_d)"><path fill="#EEE" d="M232.896 31.2403H51.2975C49.1452 31.2403 47.4005 32.983 47.4005 35.1327V46.8101C47.4005 48.9598 49.1452 50.7025 51.2975 50.7025H232.896C235.048 50.7025 236.793 48.9598 236.793 46.8101V35.1327C236.793 32.983 235.048 31.2403 232.896 31.2403Z"/><mask id="mask0" width="190" height="114" x="47" y="31" mask-type="alpha" maskUnits="userSpaceOnUse"><path fill="#404040" d="M232.896 31.2403H51.2975C49.1452 31.2403 47.4005 32.983 47.4005 35.1327V141.007C47.4005 143.157 49.1452 144.9 51.2975 144.9H232.896C235.048 144.9 236.793 143.157 236.793 141.007V35.1327C236.793 32.983 235.048 31.2403 232.896 31.2403Z"/></mask><g mask="url(#mask0)"><path fill="#EEE" d="M239.91 42.1391H47.4005V150.349H239.91V42.1391Z"/></g></g><circle cx="188" cy="55" r="6" fill="#69DDC7"/><circle cx="91" cy="92" r="6" fill="#69DDC7"/><path fill="#69DDC7" d="M121 114L95.5 88L86.5 96L121 130L192.5 59L183.5 51L121 114Z"/><defs><filter id="filter0_d" width="229.392" height="153.66" x="29.401" y="15.24" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset dx="2" dy="4"/><feGaussianBlur stdDeviation="10"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/><feBlend in2="BackgroundImageFix" mode="normal" result="effect1_dropShadow"/><feBlend in="SourceGraphic" in2="effect1_dropShadow" mode="normal" result="shape"/></filter></defs></svg>
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* 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 { EmptyState } from '@backstage/core';
|
||||
import { Button } from '@material-ui/core';
|
||||
|
||||
export const MissingApiKeyOrApiIdError = () => (
|
||||
<EmptyState
|
||||
missing="info"
|
||||
title="Missing or invalid Splunk On-Call API key and/or API id"
|
||||
description="The request to fetch data needs a valid api id and a valid api key. See README for more details."
|
||||
action={
|
||||
<Button
|
||||
color="primary"
|
||||
variant="contained"
|
||||
href="https://github.com/backstage/backstage/blob/master/plugins/splunk-on-call/README.md"
|
||||
>
|
||||
Read More
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export { MissingApiKeyOrApiIdError } from './MissingApiKeyOrApiIdError';
|
||||
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
* 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 { render, waitFor } from '@testing-library/react';
|
||||
import { EscalationPolicy } from './EscalationPolicy';
|
||||
import { wrapInTestApp } from '@backstage/test-utils';
|
||||
import { ApiProvider, ApiRegistry } from '@backstage/core';
|
||||
import { splunkOnCallApiRef } from '../../api';
|
||||
import { MOCKED_ON_CALL, MOCKED_USER } from '../../api/mocks';
|
||||
|
||||
const mockSplunkOnCallApi = {
|
||||
getOnCallUsers: () => [],
|
||||
};
|
||||
const apis = ApiRegistry.from([[splunkOnCallApiRef, mockSplunkOnCallApi]]);
|
||||
|
||||
describe('Escalation', () => {
|
||||
it('Handles an empty response', async () => {
|
||||
mockSplunkOnCallApi.getOnCallUsers = jest
|
||||
.fn()
|
||||
.mockImplementationOnce(async () => []);
|
||||
|
||||
const { getByText, queryByTestId } = render(
|
||||
wrapInTestApp(
|
||||
<ApiProvider apis={apis}>
|
||||
<EscalationPolicy
|
||||
users={{
|
||||
[MOCKED_USER.username!]: MOCKED_USER,
|
||||
}}
|
||||
team="team_example"
|
||||
/>
|
||||
</ApiProvider>,
|
||||
),
|
||||
);
|
||||
await waitFor(() => !queryByTestId('progress'));
|
||||
|
||||
expect(getByText('Empty escalation policy')).toBeInTheDocument();
|
||||
expect(mockSplunkOnCallApi.getOnCallUsers).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Render a list of users', async () => {
|
||||
mockSplunkOnCallApi.getOnCallUsers = jest
|
||||
.fn()
|
||||
.mockImplementationOnce(async () => MOCKED_ON_CALL);
|
||||
|
||||
const { getByText, queryByTestId } = render(
|
||||
wrapInTestApp(
|
||||
<ApiProvider apis={apis}>
|
||||
<EscalationPolicy
|
||||
users={{
|
||||
[MOCKED_USER.username!]: MOCKED_USER,
|
||||
}}
|
||||
team="team_example"
|
||||
/>
|
||||
</ApiProvider>,
|
||||
),
|
||||
);
|
||||
await waitFor(() => !queryByTestId('progress'));
|
||||
|
||||
expect(getByText('FirstNameTest LastNameTest')).toBeInTheDocument();
|
||||
expect(getByText('test@example.com')).toBeInTheDocument();
|
||||
expect(mockSplunkOnCallApi.getOnCallUsers).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Handles errors', async () => {
|
||||
mockSplunkOnCallApi.getOnCallUsers = jest
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('Error message'));
|
||||
|
||||
const { getByText, queryByTestId } = render(
|
||||
wrapInTestApp(
|
||||
<ApiProvider apis={apis}>
|
||||
<EscalationPolicy
|
||||
users={{
|
||||
[MOCKED_USER.username!]: MOCKED_USER,
|
||||
}}
|
||||
team="team_example"
|
||||
/>
|
||||
</ApiProvider>,
|
||||
),
|
||||
);
|
||||
await waitFor(() => !queryByTestId('progress'));
|
||||
|
||||
expect(
|
||||
getByText('Error encountered while fetching information. Error message'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
* 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 { List, ListSubheader } from '@material-ui/core';
|
||||
import { EscalationUsersEmptyState } from './EscalationUsersEmptyState';
|
||||
import { EscalationUser } from './EscalationUser';
|
||||
import { useAsync } from 'react-use';
|
||||
import { splunkOnCallApiRef } from '../../api';
|
||||
import { useApi, Progress } from '@backstage/core';
|
||||
import { Alert } from '@material-ui/lab';
|
||||
import { User } from '../types';
|
||||
|
||||
type Props = {
|
||||
users: { [key: string]: User };
|
||||
team: string;
|
||||
};
|
||||
|
||||
export const EscalationPolicy = ({ users, team }: Props) => {
|
||||
const api = useApi(splunkOnCallApiRef);
|
||||
|
||||
const { value: userNames, loading, error } = useAsync(async () => {
|
||||
const oncalls = await api.getOnCallUsers();
|
||||
const teamUsernames = oncalls
|
||||
.filter(oncall => oncall.team?.name === team)
|
||||
.flatMap(oncall => {
|
||||
return oncall.oncallNow?.flatMap(oncallNow => {
|
||||
return oncallNow.users?.flatMap(user => {
|
||||
return user?.onCalluser?.username;
|
||||
});
|
||||
});
|
||||
});
|
||||
return teamUsernames;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert severity="error">
|
||||
Error encountered while fetching information. {error.message}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <Progress />;
|
||||
}
|
||||
|
||||
if (!userNames?.length) {
|
||||
return <EscalationUsersEmptyState />;
|
||||
}
|
||||
|
||||
return (
|
||||
<List dense subheader={<ListSubheader>ON CALL</ListSubheader>}>
|
||||
{userNames &&
|
||||
userNames.map(
|
||||
(userName, index) =>
|
||||
userName &&
|
||||
userName in users && (
|
||||
<EscalationUser key={index} user={users[userName]} />
|
||||
),
|
||||
)}
|
||||
</List>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* 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 {
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemSecondaryAction,
|
||||
Tooltip,
|
||||
ListItemText,
|
||||
makeStyles,
|
||||
IconButton,
|
||||
Typography,
|
||||
} from '@material-ui/core';
|
||||
import Avatar from '@material-ui/core/Avatar';
|
||||
import EmailIcon from '@material-ui/icons/Email';
|
||||
import { User } from '../types';
|
||||
|
||||
const useStyles = makeStyles({
|
||||
listItemPrimary: {
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
});
|
||||
|
||||
type Props = {
|
||||
user: User;
|
||||
};
|
||||
|
||||
export const EscalationUser = ({ user }: Props) => {
|
||||
const classes = useStyles();
|
||||
|
||||
return (
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
<Avatar alt="User" />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Typography className={classes.listItemPrimary}>
|
||||
{user.firstName} {user.lastName}
|
||||
</Typography>
|
||||
}
|
||||
secondary={user.email}
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<Tooltip title="Send e-mail to user" placement="top">
|
||||
<IconButton href={`mailto:${user.email}`}>
|
||||
<EmailIcon color="primary" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* 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 {
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
makeStyles,
|
||||
} from '@material-ui/core';
|
||||
import { StatusWarning } from '@backstage/core';
|
||||
|
||||
const useStyles = makeStyles({
|
||||
denseListIcon: {
|
||||
marginRight: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
export const EscalationUsersEmptyState = () => {
|
||||
const classes = useStyles();
|
||||
return (
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
<div className={classes.denseListIcon}>
|
||||
<StatusWarning />
|
||||
</div>
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Empty escalation policy" />
|
||||
</ListItem>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export { EscalationPolicy } from './EscalationPolicy';
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* 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 { Grid, Typography } from '@material-ui/core';
|
||||
import EmptyStateImage from '../../assets/emptystate.svg';
|
||||
|
||||
export const IncidentsEmptyState = () => {
|
||||
return (
|
||||
<Grid container justify="center" direction="column" alignItems="center">
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="h5">Nice! No incidents found!</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<img
|
||||
src={EmptyStateImage}
|
||||
alt="EmptyState"
|
||||
data-testid="emptyStateImg"
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,241 @@
|
||||
/*
|
||||
* 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, { useEffect } from 'react';
|
||||
import {
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemSecondaryAction,
|
||||
Tooltip,
|
||||
ListItemText,
|
||||
makeStyles,
|
||||
IconButton,
|
||||
Typography,
|
||||
} from '@material-ui/core';
|
||||
import DoneIcon from '@material-ui/icons/Done';
|
||||
import DoneAllIcon from '@material-ui/icons/DoneAll';
|
||||
import {
|
||||
StatusError,
|
||||
StatusWarning,
|
||||
StatusOK,
|
||||
useApi,
|
||||
alertApiRef,
|
||||
} from '@backstage/core';
|
||||
import { DateTime, Duration } from 'luxon';
|
||||
import { Incident, IncidentPhase } from '../types';
|
||||
import OpenInBrowserIcon from '@material-ui/icons/OpenInBrowser';
|
||||
import { splunkOnCallApiRef } from '../../api/client';
|
||||
import { useAsyncFn } from 'react-use';
|
||||
import { PatchIncidentRequest } from '../../api/types';
|
||||
|
||||
const useStyles = makeStyles({
|
||||
denseListIcon: {
|
||||
marginRight: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
listItemPrimary: {
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
listItemIcon: {
|
||||
minWidth: '1em',
|
||||
},
|
||||
secondaryAction: {
|
||||
paddingRight: 48,
|
||||
},
|
||||
});
|
||||
|
||||
type Props = {
|
||||
incident: Incident;
|
||||
onIncidentAction: () => void;
|
||||
};
|
||||
|
||||
const IncidentPhaseStatus = ({
|
||||
currentPhase,
|
||||
}: {
|
||||
currentPhase: IncidentPhase;
|
||||
}) => {
|
||||
switch (currentPhase) {
|
||||
case 'UNACKED':
|
||||
return <StatusError />;
|
||||
case 'ACKED':
|
||||
return <StatusWarning />;
|
||||
default:
|
||||
return <StatusOK />;
|
||||
}
|
||||
};
|
||||
|
||||
const incidentPhaseTooltip = (currentPhase: IncidentPhase) => {
|
||||
switch (currentPhase) {
|
||||
case 'UNACKED':
|
||||
return 'Triggered';
|
||||
case 'ACKED':
|
||||
return 'Acknowledged';
|
||||
default:
|
||||
return 'Resolved';
|
||||
}
|
||||
};
|
||||
|
||||
const IncidentAction = ({
|
||||
currentPhase,
|
||||
incidentNames,
|
||||
resolveAction,
|
||||
acknowledgeAction,
|
||||
}: {
|
||||
currentPhase: string;
|
||||
incidentNames: string[];
|
||||
resolveAction: (args: PatchIncidentRequest) => void;
|
||||
acknowledgeAction: (args: PatchIncidentRequest) => void;
|
||||
}) => {
|
||||
switch (currentPhase) {
|
||||
case 'UNACKED':
|
||||
return (
|
||||
<Tooltip title="Aknowledge" placement="top">
|
||||
<IconButton onClick={() => acknowledgeAction({ incidentNames })}>
|
||||
<DoneIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
case 'ACKED':
|
||||
return (
|
||||
<Tooltip title="Resolve" placement="top">
|
||||
<IconButton onClick={() => resolveAction({ incidentNames })}>
|
||||
<DoneAllIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
default:
|
||||
return <></>;
|
||||
}
|
||||
};
|
||||
|
||||
export const IncidentListItem = ({ incident, onIncidentAction }: Props) => {
|
||||
const classes = useStyles();
|
||||
const duration =
|
||||
new Date().getTime() - new Date(incident.startTime!).getTime();
|
||||
const createdAt = DateTime.local()
|
||||
.minus(Duration.fromMillis(duration))
|
||||
.toRelative({ locale: 'en' });
|
||||
const alertApi = useApi(alertApiRef);
|
||||
const api = useApi(splunkOnCallApiRef);
|
||||
|
||||
const hasBeenManuallyTriggered = incident.monitorName?.includes('vouser-');
|
||||
|
||||
const user = hasBeenManuallyTriggered
|
||||
? incident.monitorName?.replace('vouser-', '')
|
||||
: incident.monitorName;
|
||||
|
||||
const [
|
||||
{ value: resolveValue, error: resolveError },
|
||||
handleResolveIncident,
|
||||
] = useAsyncFn(
|
||||
async ({ incidentNames }: PatchIncidentRequest) =>
|
||||
await api.resolveIncident({
|
||||
incidentNames,
|
||||
}),
|
||||
);
|
||||
|
||||
const [
|
||||
{ value: acknowledgeValue, error: acknowledgeError },
|
||||
handleAcknowledgeIncident,
|
||||
] = useAsyncFn(
|
||||
async ({ incidentNames }: PatchIncidentRequest) =>
|
||||
await api.acknowledgeIncident({
|
||||
incidentNames,
|
||||
}),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (acknowledgeValue) {
|
||||
alertApi.post({
|
||||
message: `Incident successfully acknowledged`,
|
||||
});
|
||||
}
|
||||
|
||||
if (resolveValue) {
|
||||
alertApi.post({
|
||||
message: `Incident successfully resolved`,
|
||||
});
|
||||
}
|
||||
if (resolveValue || acknowledgeValue) {
|
||||
onIncidentAction();
|
||||
}
|
||||
}, [acknowledgeValue, resolveValue, alertApi, onIncidentAction]);
|
||||
|
||||
if (acknowledgeError) {
|
||||
alertApi.post({
|
||||
message: `Failed to acknowledge incident. ${acknowledgeError.message}`,
|
||||
severity: 'error',
|
||||
});
|
||||
}
|
||||
|
||||
if (resolveError) {
|
||||
alertApi.post({
|
||||
message: `Failed to resolve incident. ${resolveError.message}`,
|
||||
severity: 'error',
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<ListItem dense key={incident.entityId}>
|
||||
<ListItemIcon className={classes.listItemIcon}>
|
||||
<Tooltip
|
||||
title={incidentPhaseTooltip(incident.currentPhase)}
|
||||
placement="top"
|
||||
>
|
||||
<div className={classes.denseListIcon}>
|
||||
<IncidentPhaseStatus currentPhase={incident.currentPhase} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={incident.entityDisplayName}
|
||||
primaryTypographyProps={{
|
||||
variant: 'body1',
|
||||
className: classes.listItemPrimary,
|
||||
}}
|
||||
secondary={
|
||||
<Typography noWrap variant="body2" color="textSecondary">
|
||||
Created {createdAt} {user && `by ${user}`}
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
|
||||
{incident.incidentLink && incident.incidentNumber && (
|
||||
<ListItemSecondaryAction>
|
||||
<IncidentAction
|
||||
currentPhase={incident.currentPhase || ''}
|
||||
incidentNames={[incident.incidentNumber]}
|
||||
resolveAction={handleResolveIncident}
|
||||
acknowledgeAction={handleAcknowledgeIncident}
|
||||
/>
|
||||
<Tooltip title="View in Splunk On-Call" placement="top">
|
||||
<IconButton
|
||||
href={incident.incidentLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
color="primary"
|
||||
>
|
||||
<OpenInBrowserIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</ListItemSecondaryAction>
|
||||
)}
|
||||
</ListItem>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
* 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 { render, waitFor } from '@testing-library/react';
|
||||
import { Incidents } from './Incidents';
|
||||
import { wrapInTestApp } from '@backstage/test-utils';
|
||||
import {
|
||||
alertApiRef,
|
||||
ApiProvider,
|
||||
ApiRegistry,
|
||||
createApiRef,
|
||||
IdentityApi,
|
||||
identityApiRef,
|
||||
} from '@backstage/core';
|
||||
import { splunkOnCallApiRef } from '../../api';
|
||||
import { MOCK_TEAM, MOCK_INCIDENT } from '../../api/mocks';
|
||||
|
||||
const mockIdentityApi: Partial<IdentityApi> = {
|
||||
getUserId: () => 'test',
|
||||
};
|
||||
|
||||
const mockSplunkOnCallApi = {
|
||||
getIncidents: () => [],
|
||||
getTeams: () => [],
|
||||
};
|
||||
const apis = ApiRegistry.from([
|
||||
[
|
||||
alertApiRef,
|
||||
createApiRef({
|
||||
id: 'core.alert',
|
||||
description: 'Used to report alerts and forward them to the app',
|
||||
}),
|
||||
],
|
||||
[identityApiRef, mockIdentityApi],
|
||||
[splunkOnCallApiRef, mockSplunkOnCallApi],
|
||||
]);
|
||||
|
||||
describe('Incidents', () => {
|
||||
it('Renders an empty state when there are no incidents', async () => {
|
||||
mockSplunkOnCallApi.getTeams = jest
|
||||
.fn()
|
||||
.mockImplementationOnce(async () => [MOCK_TEAM]);
|
||||
|
||||
const { getByText, queryByTestId } = render(
|
||||
wrapInTestApp(
|
||||
<ApiProvider apis={apis}>
|
||||
<Incidents refreshIncidents={false} team="test" />
|
||||
</ApiProvider>,
|
||||
),
|
||||
);
|
||||
await waitFor(() => !queryByTestId('progress'));
|
||||
expect(getByText('Nice! No incidents found!')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Renders all incidents', async () => {
|
||||
mockSplunkOnCallApi.getIncidents = jest
|
||||
.fn()
|
||||
.mockImplementationOnce(async () => [MOCK_INCIDENT]);
|
||||
|
||||
mockSplunkOnCallApi.getTeams = jest
|
||||
.fn()
|
||||
.mockImplementationOnce(async () => [MOCK_TEAM]);
|
||||
const {
|
||||
getByText,
|
||||
getByTitle,
|
||||
getAllByTitle,
|
||||
getByLabelText,
|
||||
queryByTestId,
|
||||
} = render(
|
||||
wrapInTestApp(
|
||||
<ApiProvider apis={apis}>
|
||||
<Incidents team="test" refreshIncidents={false} />
|
||||
</ApiProvider>,
|
||||
),
|
||||
);
|
||||
await waitFor(() => !queryByTestId('progress'));
|
||||
expect(
|
||||
getByText('user', {
|
||||
exact: false,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
expect(getByText('test-incident')).toBeInTheDocument();
|
||||
expect(getByTitle('Acknowledged')).toBeInTheDocument();
|
||||
expect(getByLabelText('Status warning')).toBeInTheDocument();
|
||||
|
||||
// assert links, mailto and hrefs, date calculation
|
||||
expect(getAllByTitle('View in Splunk On-Call').length).toEqual(1);
|
||||
});
|
||||
|
||||
it('Handle errors', async () => {
|
||||
mockSplunkOnCallApi.getIncidents = jest
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('Error occurred'));
|
||||
|
||||
const { getByText, queryByTestId } = render(
|
||||
wrapInTestApp(
|
||||
<ApiProvider apis={apis}>
|
||||
<Incidents team="test" refreshIncidents={false} />
|
||||
</ApiProvider>,
|
||||
),
|
||||
);
|
||||
await waitFor(() => !queryByTestId('progress'));
|
||||
expect(
|
||||
getByText('Error encountered while fetching information. Error occurred'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* 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, { useEffect } from 'react';
|
||||
import { List, ListSubheader } from '@material-ui/core';
|
||||
import { IncidentListItem } from './IncidentListItem';
|
||||
import { IncidentsEmptyState } from './IncidentEmptyState';
|
||||
import { useAsyncFn } from 'react-use';
|
||||
import { splunkOnCallApiRef } from '../../api';
|
||||
import { useApi, Progress } from '@backstage/core';
|
||||
import { Alert } from '@material-ui/lab';
|
||||
|
||||
type Props = {
|
||||
refreshIncidents: boolean;
|
||||
team: string;
|
||||
};
|
||||
|
||||
export const Incidents = ({ refreshIncidents, team }: Props) => {
|
||||
const api = useApi(splunkOnCallApiRef);
|
||||
|
||||
const [{ value: incidents, loading, error }, getIncidents] = useAsyncFn(
|
||||
async () => {
|
||||
const allIncidents = await api.getIncidents();
|
||||
const teams = await api.getTeams();
|
||||
const teamSlug = teams.find(teamValue => teamValue.name === team)?.slug;
|
||||
const filteredIncidents = teamSlug
|
||||
? allIncidents.filter(incident =>
|
||||
incident.pagedTeams?.includes(teamSlug),
|
||||
)
|
||||
: [];
|
||||
return filteredIncidents;
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
getIncidents();
|
||||
}, [refreshIncidents, getIncidents]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert severity="error">
|
||||
Error encountered while fetching information. {error.message}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <Progress />;
|
||||
}
|
||||
|
||||
if (!incidents?.length) {
|
||||
return <IncidentsEmptyState />;
|
||||
}
|
||||
|
||||
return (
|
||||
<List dense subheader={<ListSubheader>INCIDENTS</ListSubheader>}>
|
||||
{incidents!.map((incident, index) => (
|
||||
<IncidentListItem
|
||||
onIncidentAction={() => getIncidents()}
|
||||
key={index}
|
||||
incident={incident}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export { Incidents } from './Incidents';
|
||||
@@ -0,0 +1,155 @@
|
||||
/*
|
||||
* 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 { render, waitFor, fireEvent, act } from '@testing-library/react';
|
||||
import { SplunkOnCallCard } from './SplunkOnCallCard';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { wrapInTestApp } from '@backstage/test-utils';
|
||||
import {
|
||||
alertApiRef,
|
||||
ApiProvider,
|
||||
ApiRegistry,
|
||||
ConfigApi,
|
||||
configApiRef,
|
||||
ConfigReader,
|
||||
createApiRef,
|
||||
} from '@backstage/core';
|
||||
import {
|
||||
splunkOnCallApiRef,
|
||||
UnauthorizedError,
|
||||
SplunkOnCallClient,
|
||||
} from '../api';
|
||||
import {
|
||||
ESCALATION_POLICIES,
|
||||
MOCKED_ON_CALL,
|
||||
MOCKED_USER,
|
||||
MOCK_INCIDENT,
|
||||
MOCK_TEAM,
|
||||
} from '../api/mocks';
|
||||
|
||||
const mockSplunkOnCallApi: Partial<SplunkOnCallClient> = {
|
||||
getUsers: async () => [],
|
||||
getIncidents: async () => [MOCK_INCIDENT],
|
||||
getOnCallUsers: async () => MOCKED_ON_CALL,
|
||||
getTeams: async () => [MOCK_TEAM],
|
||||
getEscalationPolicies: async () => ESCALATION_POLICIES,
|
||||
};
|
||||
|
||||
const configApi: ConfigApi = new ConfigReader({
|
||||
splunkOnCall: {
|
||||
username: MOCKED_USER.username,
|
||||
},
|
||||
});
|
||||
|
||||
const apis = ApiRegistry.from([
|
||||
[splunkOnCallApiRef, mockSplunkOnCallApi],
|
||||
[configApiRef, configApi],
|
||||
[
|
||||
alertApiRef,
|
||||
createApiRef({
|
||||
id: 'core.alert',
|
||||
description: 'Used to report alerts and forward them to the app',
|
||||
}),
|
||||
],
|
||||
]);
|
||||
const entity: Entity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
name: 'splunkoncall-test',
|
||||
annotations: {
|
||||
'splunk.com/on-call-team': 'Example',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe('SplunkOnCallCard', () => {
|
||||
it('Render splunkoncall', async () => {
|
||||
mockSplunkOnCallApi.getUsers = jest
|
||||
.fn()
|
||||
.mockImplementationOnce(async () => [MOCKED_USER]);
|
||||
|
||||
const { getByText, queryByTestId } = render(
|
||||
wrapInTestApp(
|
||||
<ApiProvider apis={apis}>
|
||||
<SplunkOnCallCard entity={entity} />
|
||||
</ApiProvider>,
|
||||
),
|
||||
);
|
||||
await waitFor(() => !queryByTestId('progress'));
|
||||
expect(getByText('Create Incident')).toBeInTheDocument();
|
||||
expect(getByText('Nice! No incidents found!')).toBeInTheDocument();
|
||||
expect(getByText('Empty escalation policy')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Handles custom error for missing token', async () => {
|
||||
mockSplunkOnCallApi.getUsers = jest
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new UnauthorizedError());
|
||||
|
||||
const { getByText, queryByTestId } = render(
|
||||
wrapInTestApp(
|
||||
<ApiProvider apis={apis}>
|
||||
<SplunkOnCallCard entity={entity} />
|
||||
</ApiProvider>,
|
||||
),
|
||||
);
|
||||
await waitFor(() => !queryByTestId('progress'));
|
||||
expect(
|
||||
getByText('Missing or invalid Splunk On-Call API key and/or API id'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles general error', async () => {
|
||||
mockSplunkOnCallApi.getUsers = jest
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('An error occurred'));
|
||||
const { getByText, queryByTestId } = render(
|
||||
wrapInTestApp(
|
||||
<ApiProvider apis={apis}>
|
||||
<SplunkOnCallCard entity={entity} />
|
||||
</ApiProvider>,
|
||||
),
|
||||
);
|
||||
await waitFor(() => !queryByTestId('progress'));
|
||||
|
||||
expect(
|
||||
getByText(
|
||||
'Error encountered while fetching information. An error occurred',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
it('opens the dialog when trigger button is clicked', async () => {
|
||||
mockSplunkOnCallApi.getUsers = jest
|
||||
.fn()
|
||||
.mockImplementationOnce(async () => [MOCKED_USER]);
|
||||
|
||||
const { getByText, queryByTestId, getByTestId, getByRole } = render(
|
||||
wrapInTestApp(
|
||||
<ApiProvider apis={apis}>
|
||||
<SplunkOnCallCard entity={entity} />
|
||||
</ApiProvider>,
|
||||
),
|
||||
);
|
||||
await waitFor(() => !queryByTestId('progress'));
|
||||
expect(getByText('Create Incident')).toBeInTheDocument();
|
||||
const triggerButton = getByTestId('trigger-button');
|
||||
await act(async () => {
|
||||
fireEvent.click(triggerButton);
|
||||
});
|
||||
expect(getByRole('dialog')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
/*
|
||||
* 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, { useState, useCallback } from 'react';
|
||||
import {
|
||||
useApi,
|
||||
Progress,
|
||||
HeaderIconLinkRow,
|
||||
MissingAnnotationEmptyState,
|
||||
configApiRef,
|
||||
EmptyState,
|
||||
} from '@backstage/core';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import {
|
||||
Button,
|
||||
makeStyles,
|
||||
Card,
|
||||
CardHeader,
|
||||
Divider,
|
||||
CardContent,
|
||||
Typography,
|
||||
} from '@material-ui/core';
|
||||
import { Incidents } from './Incident';
|
||||
import { EscalationPolicy } from './Escalation';
|
||||
import { useAsync } from 'react-use';
|
||||
import { Alert } from '@material-ui/lab';
|
||||
import { splunkOnCallApiRef, UnauthorizedError } from '../api';
|
||||
import AlarmAddIcon from '@material-ui/icons/AlarmAdd';
|
||||
import { TriggerDialog } from './TriggerDialog';
|
||||
import { MissingApiKeyOrApiIdError } from './Errors/MissingApiKeyOrApiIdError';
|
||||
import { User } from './types';
|
||||
|
||||
const useStyles = makeStyles({
|
||||
triggerAlarm: {
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
fontSize: '0.7rem',
|
||||
textTransform: 'uppercase',
|
||||
fontWeight: 600,
|
||||
letterSpacing: 1.2,
|
||||
lineHeight: 1.5,
|
||||
'&:hover, &:focus, &.focus': {
|
||||
backgroundColor: 'transparent',
|
||||
textDecoration: 'none',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const SPLUNK_ON_CALL_TEAM = 'splunk.com/on-call-team';
|
||||
|
||||
export const MissingTeamAnnotation = () => (
|
||||
<MissingAnnotationEmptyState annotation={SPLUNK_ON_CALL_TEAM} />
|
||||
);
|
||||
|
||||
export const MissingUsername = () => (
|
||||
<CardContent>
|
||||
<EmptyState
|
||||
title="No Splunk On-Call user available."
|
||||
missing="info"
|
||||
description="You need to add a valid username to your 'app-config.yaml' if you want to enable Splunk On-Call. Make sure that the user is a member of your organization."
|
||||
/>
|
||||
</CardContent>
|
||||
);
|
||||
|
||||
export const isPluginApplicableToEntity = (entity: Entity) =>
|
||||
Boolean(entity.metadata.annotations?.[SPLUNK_ON_CALL_TEAM]);
|
||||
|
||||
type Props = {
|
||||
entity: Entity;
|
||||
};
|
||||
|
||||
export const SplunkOnCallCard = ({ entity }: Props) => {
|
||||
const classes = useStyles();
|
||||
const config = useApi(configApiRef);
|
||||
const api = useApi(splunkOnCallApiRef);
|
||||
const [showDialog, setShowDialog] = useState<boolean>(false);
|
||||
const [refreshIncidents, setRefreshIncidents] = useState<boolean>(false);
|
||||
const team = entity.metadata.annotations![SPLUNK_ON_CALL_TEAM];
|
||||
|
||||
const username = config.getOptionalString('splunkOnCall.username');
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
setRefreshIncidents(x => !x);
|
||||
}, []);
|
||||
|
||||
const handleDialog = useCallback(() => {
|
||||
setShowDialog(x => !x);
|
||||
}, []);
|
||||
|
||||
const { value: users, loading, error } = useAsync(async () => {
|
||||
const allUsers = await api.getUsers();
|
||||
const usersHashMap = allUsers.reduce(
|
||||
(map: Record<string, User>, obj: User) => {
|
||||
if (obj.username) {
|
||||
map[obj.username] = obj;
|
||||
}
|
||||
return map;
|
||||
},
|
||||
{},
|
||||
);
|
||||
return { usersHashMap, userList: allUsers };
|
||||
});
|
||||
|
||||
const incidentCreator =
|
||||
username && users?.userList.find(user => user.username === username);
|
||||
|
||||
if (error instanceof UnauthorizedError) {
|
||||
return <MissingApiKeyOrApiIdError />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert severity="error">
|
||||
Error encountered while fetching information. {error.message}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <Progress />;
|
||||
}
|
||||
|
||||
const Content = () => {
|
||||
if (!team) {
|
||||
return <MissingTeamAnnotation />;
|
||||
}
|
||||
if (!username || !incidentCreator) {
|
||||
return <MissingUsername />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Incidents team={team} refreshIncidents={refreshIncidents} />
|
||||
{users?.usersHashMap && team && (
|
||||
<EscalationPolicy team={team} users={users.usersHashMap} />
|
||||
)}
|
||||
{users && incidentCreator && (
|
||||
<TriggerDialog
|
||||
users={users.userList}
|
||||
incidentCreator={incidentCreator}
|
||||
showDialog={showDialog}
|
||||
handleDialog={handleDialog}
|
||||
onIncidentCreated={handleRefresh}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const triggerLink = {
|
||||
label: 'Create Incident',
|
||||
action: (
|
||||
<Button
|
||||
data-testid="trigger-button"
|
||||
color="secondary"
|
||||
onClick={handleDialog}
|
||||
className={classes.triggerAlarm}
|
||||
>
|
||||
Create Incident
|
||||
</Button>
|
||||
),
|
||||
icon: <AlarmAddIcon onClick={handleDialog} />,
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Splunk On-Call"
|
||||
subheader={[
|
||||
<Typography key="team_name">Team: {team}</Typography>,
|
||||
username && (
|
||||
<HeaderIconLinkRow key="incident_trigger" links={[triggerLink]} />
|
||||
),
|
||||
]}
|
||||
/>
|
||||
<Divider />
|
||||
<CardContent>
|
||||
<Content />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* 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 { Grid, makeStyles } from '@material-ui/core';
|
||||
import {
|
||||
Content,
|
||||
ContentHeader,
|
||||
Page,
|
||||
Header,
|
||||
SupportButton,
|
||||
} from '@backstage/core';
|
||||
import { SplunkOnCallCard } from './SplunkOnCallCard';
|
||||
import { useEntity } from '@backstage/plugin-catalog-react';
|
||||
|
||||
const useStyles = makeStyles(() => ({
|
||||
overflowXScroll: {
|
||||
overflowX: 'scroll',
|
||||
},
|
||||
}));
|
||||
|
||||
export type SplunkOnCallPageProps = {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
pageTitle?: string;
|
||||
};
|
||||
|
||||
export const SplunkOnCallPage = ({
|
||||
title,
|
||||
subtitle,
|
||||
pageTitle,
|
||||
}: SplunkOnCallPageProps): JSX.Element => {
|
||||
const classes = useStyles();
|
||||
const { entity } = useEntity();
|
||||
|
||||
return (
|
||||
<Page themeId="tool">
|
||||
<Header title={title} subtitle={subtitle} />
|
||||
<Content className={classes.overflowXScroll}>
|
||||
<ContentHeader title={pageTitle}>
|
||||
<SupportButton>
|
||||
This is used to help you automate incident management.
|
||||
</SupportButton>
|
||||
</ContentHeader>
|
||||
<Grid container spacing={3} direction="row">
|
||||
<Grid item xs={12} sm={6} md={4}>
|
||||
<SplunkOnCallCard entity={entity} />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Content>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
SplunkOnCallPage.defaultProps = {
|
||||
title: 'Splunk On-Call',
|
||||
subtitle: 'Automate incident management',
|
||||
pageTitle: 'Dashboard',
|
||||
};
|
||||
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* 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 { render, fireEvent, act } from '@testing-library/react';
|
||||
import { wrapInTestApp } from '@backstage/test-utils';
|
||||
import {
|
||||
ApiRegistry,
|
||||
alertApiRef,
|
||||
createApiRef,
|
||||
ApiProvider,
|
||||
} from '@backstage/core';
|
||||
import { splunkOnCallApiRef } from '../../api';
|
||||
import { TriggerDialog } from './TriggerDialog';
|
||||
import { ESCALATION_POLICIES, MOCKED_USER } from '../../api/mocks';
|
||||
|
||||
describe('TriggerDialog', () => {
|
||||
const mockTriggerAlarmFn = jest.fn();
|
||||
const mockSplunkOnCallApi = {
|
||||
triggerAlarm: mockTriggerAlarmFn,
|
||||
getEscalationPolicies: async () => ESCALATION_POLICIES,
|
||||
};
|
||||
|
||||
const apis = ApiRegistry.from([
|
||||
[
|
||||
alertApiRef,
|
||||
createApiRef({
|
||||
id: 'core.alert',
|
||||
description: 'Used to report alerts and forward them to the app',
|
||||
}),
|
||||
],
|
||||
[splunkOnCallApiRef, mockSplunkOnCallApi],
|
||||
]);
|
||||
|
||||
it('open the dialog and trigger an alarm', async () => {
|
||||
const { getByText, getByRole, getAllByRole, getByTestId } = render(
|
||||
wrapInTestApp(
|
||||
<ApiProvider apis={apis}>
|
||||
<TriggerDialog
|
||||
showDialog
|
||||
incidentCreator={MOCKED_USER}
|
||||
handleDialog={() => {}}
|
||||
users={[MOCKED_USER]}
|
||||
onIncidentCreated={() => {}}
|
||||
/>
|
||||
</ApiProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(getByRole('dialog')).toBeInTheDocument();
|
||||
expect(
|
||||
getByText('This action will trigger an incident', {
|
||||
exact: false,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
const summary = getByTestId('trigger-summary-input');
|
||||
const body = getByTestId('trigger-body-input');
|
||||
const behavior = getByTestId('trigger-select-behavior');
|
||||
const description = 'Test Trigger Alarm';
|
||||
await act(async () => {
|
||||
fireEvent.change(summary, { target: { value: description } });
|
||||
fireEvent.change(body, { target: { value: description } });
|
||||
fireEvent.change(behavior, { target: { value: '0' } });
|
||||
fireEvent.mouseDown(getAllByRole('button')[0]);
|
||||
});
|
||||
|
||||
// Trigger user targets select
|
||||
const options = getAllByRole('option');
|
||||
await act(async () => {
|
||||
fireEvent.click(options[0]);
|
||||
fireEvent.keyDown(options[0], {
|
||||
key: 'Escape',
|
||||
code: 'Escape',
|
||||
keyCode: 27,
|
||||
charCode: 27,
|
||||
});
|
||||
});
|
||||
|
||||
// Trigger policy targets select
|
||||
await act(async () => {
|
||||
fireEvent.mouseDown(getAllByRole('button')[1]);
|
||||
});
|
||||
const policiesOptions = getAllByRole('option');
|
||||
await act(async () => {
|
||||
fireEvent.click(policiesOptions[0]);
|
||||
});
|
||||
|
||||
// Trigger incident creation button
|
||||
const triggerButton = getByTestId('trigger-button');
|
||||
await act(async () => {
|
||||
fireEvent.click(triggerButton);
|
||||
});
|
||||
expect(mockTriggerAlarmFn).toHaveBeenCalled();
|
||||
expect(mockTriggerAlarmFn).toHaveBeenCalledWith({
|
||||
summary: description,
|
||||
details: description,
|
||||
userName: 'test_user',
|
||||
targets: [
|
||||
{ slug: 'test_user', type: 'User' },
|
||||
{ slug: 'team-zEalMCgwYSA0Lt40', type: 'EscalationPolicy' },
|
||||
],
|
||||
isMultiResponder: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,365 @@
|
||||
/*
|
||||
* 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, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
TextField,
|
||||
DialogActions,
|
||||
Button,
|
||||
DialogContent,
|
||||
Typography,
|
||||
CircularProgress,
|
||||
Select,
|
||||
MenuItem,
|
||||
Input,
|
||||
Chip,
|
||||
createStyles,
|
||||
makeStyles,
|
||||
Theme,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
} from '@material-ui/core';
|
||||
import { useApi, alertApiRef } from '@backstage/core';
|
||||
import { useAsync, useAsyncFn } from 'react-use';
|
||||
import { splunkOnCallApiRef } from '../../api';
|
||||
import { Alert } from '@material-ui/lab';
|
||||
import { User } from '../types';
|
||||
import { IncidentTarget, TargetType } from '../../api/types';
|
||||
|
||||
const MenuProps = {
|
||||
PaperProps: {
|
||||
style: {
|
||||
width: 250,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
type Props = {
|
||||
users: User[];
|
||||
incidentCreator: User;
|
||||
showDialog: boolean;
|
||||
handleDialog: () => void;
|
||||
onIncidentCreated: () => void;
|
||||
};
|
||||
|
||||
const useStyles = makeStyles((theme: Theme) =>
|
||||
createStyles({
|
||||
chips: {
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
chip: {
|
||||
margin: 2,
|
||||
},
|
||||
formControl: {
|
||||
margin: theme.spacing(1),
|
||||
minWidth: `calc(100% - ${theme.spacing(2)}px)`,
|
||||
},
|
||||
targets: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
width: '100%',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
export const TriggerDialog = ({
|
||||
users,
|
||||
incidentCreator,
|
||||
showDialog,
|
||||
handleDialog,
|
||||
onIncidentCreated: onIncidentCreated,
|
||||
}: Props) => {
|
||||
const alertApi = useApi(alertApiRef);
|
||||
const api = useApi(splunkOnCallApiRef);
|
||||
const classes = useStyles();
|
||||
|
||||
const [userTargets, setUserTargets] = useState<string[]>([]);
|
||||
const [policyTargets, setPolicyTargets] = useState<string[]>([]);
|
||||
const [detailsValue, setDetails] = useState<string>('');
|
||||
const [summaryValue, setSummary] = useState<string>('');
|
||||
const [isMultiResponderValue, setIsMultiResponder] = useState<string>('1');
|
||||
|
||||
const [
|
||||
{ value, loading: triggerLoading, error: triggerError },
|
||||
handleTriggerAlarm,
|
||||
] = useAsyncFn(
|
||||
async (
|
||||
summary: string,
|
||||
details: string,
|
||||
userName: string,
|
||||
targets: IncidentTarget[],
|
||||
isMultiResponder: boolean,
|
||||
) =>
|
||||
await api.triggerAlarm({
|
||||
summary,
|
||||
details,
|
||||
userName,
|
||||
targets,
|
||||
isMultiResponder,
|
||||
}),
|
||||
);
|
||||
|
||||
const {
|
||||
value: policies,
|
||||
loading: policiesLoaading,
|
||||
error: policiesError,
|
||||
} = useAsync(async () => {
|
||||
const allPolicies = await api.getEscalationPolicies();
|
||||
return allPolicies;
|
||||
});
|
||||
|
||||
const handleUserTargets = (event: React.ChangeEvent<{ value: unknown }>) => {
|
||||
setUserTargets(event.target.value as string[]);
|
||||
};
|
||||
|
||||
const handlePolicyTargets = (
|
||||
event: React.ChangeEvent<{ value: unknown }>,
|
||||
) => {
|
||||
setPolicyTargets(event.target.value as string[]);
|
||||
};
|
||||
|
||||
const detailsChanged = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setDetails(event.target.value);
|
||||
};
|
||||
|
||||
const summaryChanged = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setSummary(event.target.value);
|
||||
};
|
||||
|
||||
const isMultiResponderChanged = (
|
||||
event: React.ChangeEvent<{ value: unknown }>,
|
||||
) => {
|
||||
setIsMultiResponder(event.target.value as string);
|
||||
};
|
||||
|
||||
const targets = (): IncidentTarget[] => [
|
||||
...userTargets.map(user => ({ slug: user, type: TargetType.UserValue })),
|
||||
...policyTargets.map(user => ({
|
||||
slug: user,
|
||||
type: TargetType.EscalationPolicyValue,
|
||||
})),
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
if (value) {
|
||||
alertApi.post({
|
||||
message: `Alarm successfully triggered`,
|
||||
});
|
||||
onIncidentCreated();
|
||||
handleDialog();
|
||||
}
|
||||
}, [value, alertApi, handleDialog, onIncidentCreated]);
|
||||
|
||||
if (triggerError) {
|
||||
alertApi.post({
|
||||
message: `Failed to trigger alarm. ${triggerError.message}`,
|
||||
severity: 'error',
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog maxWidth="md" open={showDialog} onClose={handleDialog} fullWidth>
|
||||
<DialogTitle>This action will trigger an incident</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="subtitle1" gutterBottom align="justify">
|
||||
Created by:{' '}
|
||||
<b>
|
||||
{incidentCreator?.firstName} {incidentCreator?.lastName}
|
||||
</b>
|
||||
</Typography>
|
||||
<Alert severity="info">
|
||||
<Typography variant="body1" align="justify">
|
||||
If the issue you are seeing does not need urgent attention, please
|
||||
get in touch with the responsible team using their preferred
|
||||
communications channel. You can find information about the owner of
|
||||
this entity in the "About" card. If the issue is urgent, please
|
||||
don't hesitate to trigger the alert.
|
||||
</Typography>
|
||||
</Alert>
|
||||
<Typography
|
||||
variant="body1"
|
||||
style={{ marginTop: '1em' }}
|
||||
gutterBottom
|
||||
align="justify"
|
||||
>
|
||||
Please describe the problem you want to report. Be as descriptive as
|
||||
possible. Your signed in user and a reference to the current page will
|
||||
automatically be amended to the alarm so that the receiver can reach
|
||||
out to you if necessary.
|
||||
</Typography>
|
||||
<div style={{ marginTop: '1em' }}>
|
||||
<Typography color="textSecondary" gutterBottom>
|
||||
Select the targets
|
||||
</Typography>
|
||||
<div className={classes.targets}>
|
||||
<FormControl className={classes.formControl}>
|
||||
<InputLabel>Select Users</InputLabel>
|
||||
<Select
|
||||
id="user-targets"
|
||||
multiple
|
||||
value={userTargets}
|
||||
onChange={handleUserTargets}
|
||||
input={<Input />}
|
||||
renderValue={selected => (
|
||||
<div className={classes.chips}>
|
||||
{(selected as string[]).map(selectedUser => {
|
||||
const element = users.find(
|
||||
user => user.username === selectedUser,
|
||||
);
|
||||
return (
|
||||
<Chip
|
||||
key={selectedUser}
|
||||
label={`${element?.firstName} ${element?.lastName}`}
|
||||
className={classes.chip}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
MenuProps={MenuProps}
|
||||
>
|
||||
{users.map(user => (
|
||||
<MenuItem key={user.email} value={user.username}>
|
||||
{user.firstName} {user.lastName}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl className={classes.formControl}>
|
||||
<InputLabel>Select Teams / Policies</InputLabel>
|
||||
<Select
|
||||
id="policy-targets"
|
||||
multiple
|
||||
value={policyTargets}
|
||||
onChange={handlePolicyTargets}
|
||||
input={<Input />}
|
||||
renderValue={selected => (
|
||||
<div className={classes.chips}>
|
||||
{(selected as string[]).map(selectedPolicy => {
|
||||
const element = policies?.find(
|
||||
policy => policy.policy.slug === selectedPolicy,
|
||||
);
|
||||
return (
|
||||
<Chip
|
||||
key={selectedPolicy}
|
||||
label={element?.policy.name}
|
||||
className={classes.chip}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
MenuProps={MenuProps}
|
||||
>
|
||||
{!policiesError &&
|
||||
!policiesLoaading &&
|
||||
policies &&
|
||||
policies.map(policy => (
|
||||
<MenuItem
|
||||
key={policy.policy.slug}
|
||||
value={policy.policy.slug}
|
||||
>
|
||||
{policy.policy.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</div>
|
||||
</div>
|
||||
<Typography
|
||||
style={{ marginTop: '1em' }}
|
||||
color="textSecondary"
|
||||
gutterBottom
|
||||
>
|
||||
Acknowledge Behavior
|
||||
</Typography>
|
||||
<FormControl className={classes.formControl}>
|
||||
<Select
|
||||
id="multi-responder"
|
||||
value={isMultiResponderValue}
|
||||
onChange={isMultiResponderChanged}
|
||||
inputProps={{ 'data-testid': 'trigger-select-behavior' }}
|
||||
>
|
||||
<MenuItem value="1">
|
||||
Stop paging after a single escalation policy or user has
|
||||
acknowledged
|
||||
</MenuItem>
|
||||
<MenuItem value="0">
|
||||
Continue paging until each escalation policy or user above has
|
||||
acknowledged
|
||||
</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField
|
||||
required
|
||||
inputProps={{ 'data-testid': 'trigger-summary-input' }}
|
||||
id="summary"
|
||||
multiline
|
||||
fullWidth
|
||||
rows="4"
|
||||
margin="normal"
|
||||
label="Incident summary"
|
||||
variant="outlined"
|
||||
onChange={summaryChanged}
|
||||
/>
|
||||
<TextField
|
||||
required
|
||||
inputProps={{ 'data-testid': 'trigger-body-input' }}
|
||||
id="details"
|
||||
multiline
|
||||
fullWidth
|
||||
rows="2"
|
||||
margin="normal"
|
||||
label="Incident body"
|
||||
variant="outlined"
|
||||
onChange={detailsChanged}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
data-testid="trigger-button"
|
||||
id="trigger"
|
||||
color="secondary"
|
||||
disabled={
|
||||
!detailsValue ||
|
||||
!summaryValue ||
|
||||
(!userTargets.length && !policyTargets.length) ||
|
||||
triggerLoading
|
||||
}
|
||||
variant="contained"
|
||||
onClick={() =>
|
||||
handleTriggerAlarm(
|
||||
summaryValue,
|
||||
detailsValue,
|
||||
incidentCreator.username!,
|
||||
targets(),
|
||||
!!Number(isMultiResponderValue),
|
||||
)
|
||||
}
|
||||
endIcon={triggerLoading && <CircularProgress size={16} />}
|
||||
>
|
||||
Trigger Incident
|
||||
</Button>
|
||||
<Button id="close" color="primary" onClick={handleDialog}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export { TriggerDialog } from './TriggerDialog';
|
||||
@@ -0,0 +1,129 @@
|
||||
/*
|
||||
* 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 { IncidentTarget } from '../api/types';
|
||||
|
||||
export type Team = {
|
||||
name?: string;
|
||||
slug?: string;
|
||||
memberCount?: number;
|
||||
version?: number;
|
||||
isDefaultTeam?: boolean;
|
||||
_selfUrl?: string;
|
||||
_policiesUrl?: string;
|
||||
_membersUrl?: string;
|
||||
_adminsUrl?: string;
|
||||
};
|
||||
|
||||
export type OnCall = {
|
||||
team?: OnCallTeamResource;
|
||||
oncallNow?: OnCallNowResource[];
|
||||
};
|
||||
|
||||
export type OnCallTeamResource = {
|
||||
name?: string;
|
||||
slug?: string;
|
||||
};
|
||||
|
||||
export type OnCallNowResource = {
|
||||
escalationPolicy?: OnCallEscalationPolicyResource;
|
||||
users?: OnCallUsersResource[];
|
||||
};
|
||||
|
||||
export type OnCallEscalationPolicyResource = {
|
||||
name?: string;
|
||||
slug?: string;
|
||||
};
|
||||
|
||||
export type OnCallUsersResource = {
|
||||
onCalluser?: OnCallUser;
|
||||
};
|
||||
|
||||
export type OnCallUser = {
|
||||
username?: string;
|
||||
};
|
||||
|
||||
export type User = {
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
displayName?: string;
|
||||
username?: string;
|
||||
email?: string;
|
||||
createdAt?: string;
|
||||
passwordLastUpdated?: string;
|
||||
verified?: boolean;
|
||||
_selfUrl?: string;
|
||||
};
|
||||
|
||||
export type CreateIncidentRequest = {
|
||||
summary: string;
|
||||
details: string;
|
||||
userName: string;
|
||||
targets: IncidentTarget;
|
||||
isMultiResponder: boolean;
|
||||
};
|
||||
|
||||
export type IncidentPhase = 'UNACKED' | 'ACKED' | 'RESOLVED';
|
||||
|
||||
export type Incident = {
|
||||
incidentNumber?: string;
|
||||
startTime?: string;
|
||||
currentPhase: IncidentPhase;
|
||||
entityState?: string;
|
||||
entityType?: string;
|
||||
routingKey?: string;
|
||||
alertCount?: number;
|
||||
lastAlertTime?: string;
|
||||
lastAlertId?: string;
|
||||
entityId?: string;
|
||||
host?: string;
|
||||
service?: string;
|
||||
pagedUsers?: string[];
|
||||
pagedTeams?: string[];
|
||||
entityDisplayName?: string;
|
||||
pagedPolicies?: EscalationPolicyInfo[];
|
||||
transitions?: IncidentTransition[];
|
||||
firstAlertUuid?: string;
|
||||
monitorName?: string;
|
||||
monitorType?: string;
|
||||
incidentLink?: string;
|
||||
};
|
||||
|
||||
export type EscalationPolicyInfo = {
|
||||
policy: EscalationPolicySummary;
|
||||
team?: EscalationPolicyTeam;
|
||||
};
|
||||
|
||||
export type IncidentTransition = {
|
||||
name?: string;
|
||||
at?: string;
|
||||
by?: string;
|
||||
message?: string;
|
||||
manually?: boolean;
|
||||
alertId?: string;
|
||||
alertUrl?: string;
|
||||
};
|
||||
|
||||
export type EscalationPolicySummary = {
|
||||
name: string;
|
||||
slug: string;
|
||||
_selfUrl: string;
|
||||
};
|
||||
|
||||
export type EscalationPolicyTeam = {
|
||||
name: string;
|
||||
slug: string;
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
export {
|
||||
splunkOnCallPlugin,
|
||||
splunkOnCallPlugin as plugin,
|
||||
SplunkOnCallPage,
|
||||
} from './plugin';
|
||||
export {
|
||||
isPluginApplicableToEntity,
|
||||
SplunkOnCallCard,
|
||||
} from './components/SplunkOnCallCard';
|
||||
export {
|
||||
SplunkOnCallClient,
|
||||
splunkOnCallApiRef,
|
||||
UnauthorizedError,
|
||||
} from './api/client';
|
||||
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* 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 { splunkOnCallPlugin } from './plugin';
|
||||
|
||||
describe('splunk-on-call', () => {
|
||||
it('should export plugin', () => {
|
||||
expect(splunkOnCallPlugin).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* 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 {
|
||||
createApiFactory,
|
||||
createPlugin,
|
||||
createRouteRef,
|
||||
discoveryApiRef,
|
||||
configApiRef,
|
||||
createRoutableExtension,
|
||||
} from '@backstage/core';
|
||||
import { splunkOnCallApiRef, SplunkOnCallClient } from './api';
|
||||
|
||||
export const rootRouteRef = createRouteRef({
|
||||
title: 'splunk-on-call',
|
||||
});
|
||||
|
||||
export const splunkOnCallPlugin = createPlugin({
|
||||
id: 'splunk-on-call',
|
||||
apis: [
|
||||
createApiFactory({
|
||||
api: splunkOnCallApiRef,
|
||||
deps: { discoveryApi: discoveryApiRef, configApi: configApiRef },
|
||||
factory: ({ configApi, discoveryApi }) =>
|
||||
SplunkOnCallClient.fromConfig(configApi, discoveryApi),
|
||||
}),
|
||||
],
|
||||
routes: {
|
||||
root: rootRouteRef,
|
||||
},
|
||||
});
|
||||
|
||||
export const SplunkOnCallPage = splunkOnCallPlugin.provide(
|
||||
createRoutableExtension({
|
||||
component: () =>
|
||||
import('./components/SplunkOnCallPage').then(m => m.SplunkOnCallPage),
|
||||
mountPoint: rootRouteRef,
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* 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 '@testing-library/jest-dom';
|
||||
@@ -1865,6 +1865,21 @@
|
||||
remark-gfm "^1.0.0"
|
||||
zen-observable "^0.8.15"
|
||||
|
||||
"@backstage/plugin-catalog-react@^0.0.2":
|
||||
version "0.0.2"
|
||||
resolved "https://registry.npmjs.org/@backstage/plugin-catalog-react/-/plugin-catalog-react-0.0.2.tgz#e50da2dac9fab3a0d5973f8d1083ee2c368e5e52"
|
||||
integrity sha512-O6aujFPRaEFTk4XlwOoswbnoHIOqMtj6ycUj6R1mNKOM4plUgGDKKhO3be69FHMJEMbiSvVe6AW+1kXaK+1LqA==
|
||||
dependencies:
|
||||
"@backstage/catalog-client" "^0.3.5"
|
||||
"@backstage/catalog-model" "^0.7.1"
|
||||
"@backstage/core" "^0.6.0"
|
||||
"@material-ui/core" "^4.11.0"
|
||||
"@types/react" "^16.9"
|
||||
react "^16.13.1"
|
||||
react-router "6.0.0-beta.0"
|
||||
react-router-dom "6.0.0-beta.0"
|
||||
react-use "^15.3.3"
|
||||
|
||||
"@backstage/plugin-catalog@^0.2.0":
|
||||
version "0.3.1"
|
||||
dependencies:
|
||||
|
||||
Reference in New Issue
Block a user