Merge pull request #6653 from firehydrant/firehydrant-plugin
Add FireHydrant plugin
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
extends: [require.resolve('@backstage/cli/config/eslint')],
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
# FireHydrant Plugin
|
||||
|
||||
## Overview
|
||||
|
||||
The [FireHydrant](https://firehydrant.io) plugin brings incident management to Backstage, and it displays service incidents information such as active incidents and incident analytics. There are also quick action links that let you create and view incidents in FireHydrant.
|
||||
|
||||
<img src="./doc/firehydrant_plugin_screenshot.png" alt="FireHydrant plugin screenshot">
|
||||
|
||||
## Features
|
||||
|
||||
- View total active incidents for a service declared in FireHydrant
|
||||
- Quick links to the top 5 most recent active incidents in FireHydrant
|
||||
- View incident metrics for a given service within the last 30 days, including: healthiness, total time impacted, total number of incidents, and MTT* (Mean Time To *) data such as MTTD (detect), MTTA (acknowledge), MTTM (mitigate) and MTTR (resolve).
|
||||
|
||||
## Setup
|
||||
|
||||
1. Install the plugin:
|
||||
|
||||
```bash
|
||||
# From your Backstage root directory
|
||||
cd packages/app
|
||||
yarn add @backstage/plugin-firehydrant
|
||||
```
|
||||
|
||||
2. Add the plugin to `EntityPage.tsx`, inside the `const overviewContent`'s parent `<Grid>` component:
|
||||
|
||||
```ts
|
||||
// In packages/app/src/components/catalog/EntityPage.tsx
|
||||
import { FirehydrantCard } from '@backstage/plugin-firehydrant';
|
||||
|
||||
// Add to code as a grid item
|
||||
<Grid item md={6}>
|
||||
<FirehydrantCard />
|
||||
</Grid>;
|
||||
```
|
||||
|
||||
3. Add proxy configuration to `app-config.yaml`:
|
||||
|
||||
```yaml
|
||||
proxy:
|
||||
'/firehydrant/api':
|
||||
target: 'https://api.firehydrant.io/v1/'
|
||||
changeOrigin: true
|
||||
headers:
|
||||
# Supply the token you generated from https://app.firehydrant.io/organizations/bots
|
||||
Authorization: Bearer ${FIREHYDRANT_BOT_TOKEN}
|
||||
```
|
||||
|
||||
Note: if you are not using environment variables, you can directly type the API Bot Token into `app-config.yaml`:
|
||||
|
||||
```yaml
|
||||
proxy:
|
||||
'/firehydrant/api':
|
||||
target: 'https://api.firehydrant.io/v1/'
|
||||
changeOrigin: true
|
||||
headers:
|
||||
# Supply the token you generated from https://app.firehydrant.io/organizations/bots
|
||||
Authorization: Bearer fhb-e4911b22bcd788c4a4afeb0c111ffbfa
|
||||
```
|
||||
@@ -0,0 +1,27 @@
|
||||
## API Report File for "@backstage/plugin-firehydrant"
|
||||
|
||||
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
|
||||
|
||||
```ts
|
||||
/// <reference types="react" />
|
||||
|
||||
import { BackstagePlugin } from '@backstage/core-plugin-api';
|
||||
import { RouteRef } from '@backstage/core-plugin-api';
|
||||
|
||||
// Warning: (ae-missing-release-tag) "FirehydrantCard" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
export const FirehydrantCard: () => JSX.Element;
|
||||
|
||||
// Warning: (ae-missing-release-tag) "firehydrantPlugin" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
export const firehydrantPlugin: BackstagePlugin<
|
||||
{
|
||||
root: RouteRef<undefined>;
|
||||
},
|
||||
{}
|
||||
>;
|
||||
|
||||
// (No @packageDocumentation comment for this package)
|
||||
```
|
||||
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* 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 { createDevApp } from '@backstage/dev-utils';
|
||||
import { firehydrantPlugin } from '../src/plugin';
|
||||
|
||||
createDevApp().registerPlugin(firehydrantPlugin).render();
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"name": "@backstage/plugin-firehydrant",
|
||||
"version": "0.1.1",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
"private": false,
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"main": "dist/index.esm.js",
|
||||
"types": "dist/index.d.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "backstage-cli plugin:build",
|
||||
"start": "backstage-cli plugin:serve",
|
||||
"lint": "backstage-cli lint",
|
||||
"test": "backstage-cli test",
|
||||
"diff": "backstage-cli plugin:diff",
|
||||
"prepack": "backstage-cli prepack",
|
||||
"postpack": "backstage-cli postpack",
|
||||
"clean": "backstage-cli clean"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/core-components": "^0.3.0",
|
||||
"@backstage/core-plugin-api": "^0.1.5",
|
||||
"@backstage/plugin-catalog-react": "^0.4.1",
|
||||
"@backstage/theme": "^0.2.9",
|
||||
"@material-ui/core": "^4.12.2",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"@material-ui/lab": "4.0.0-alpha.45",
|
||||
"luxon": "^1.27.0",
|
||||
"react": "^16.13.1",
|
||||
"react-dom": "^16.13.1",
|
||||
"react-use": "^17.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.7.7",
|
||||
"@backstage/core-app-api": "^0.1.7",
|
||||
"@backstage/dev-utils": "^0.2.5",
|
||||
"@backstage/test-utils": "^0.1.16",
|
||||
"@testing-library/jest-dom": "^5.10.1",
|
||||
"@testing-library/react": "^11.2.5",
|
||||
"@testing-library/user-event": "^13.1.8",
|
||||
"@types/jest": "^26.0.7",
|
||||
"@types/node": "^14.14.32",
|
||||
"cross-fetch": "^3.0.6",
|
||||
"msw": "^0.29.0"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"configSchema": {
|
||||
"$schema": "https://backstage.io/schema/config-v1",
|
||||
"title": "@backstage/plugin-firehydrant",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"firehydrant": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"baseUrl": {
|
||||
"type": "string",
|
||||
"visibility": "frontend"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* 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 {
|
||||
ServiceAnalyticsResponse,
|
||||
ServiceDetailsResponse,
|
||||
ServiceIncidentsResponse,
|
||||
} from './types';
|
||||
import { Incident, Service } from '../components/types';
|
||||
import { createApiRef, DiscoveryApi } from '@backstage/core-plugin-api';
|
||||
|
||||
export interface FireHydrantAPI {
|
||||
getServiceAnalytics(options: {
|
||||
serviceId: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
}): Promise<ServiceAnalyticsResponse>;
|
||||
|
||||
getServiceDetails(options: {
|
||||
serviceName: string;
|
||||
}): Promise<ServiceDetailsResponse>;
|
||||
|
||||
getServiceIncidents(options: {
|
||||
serviceId: string;
|
||||
}): Promise<ServiceIncidentsResponse>;
|
||||
}
|
||||
|
||||
export const fireHydrantApiRef = createApiRef<FireHydrantAPI>({
|
||||
id: 'plugin.firehydrant.service',
|
||||
description: 'Used by FireHydrant plugin for requests',
|
||||
});
|
||||
|
||||
export type Options = {
|
||||
discoveryApi: DiscoveryApi;
|
||||
proxyPath?: string;
|
||||
};
|
||||
|
||||
const DEFAULT_PROXY_PATH = '/firehydrant/api';
|
||||
|
||||
export class FireHydrantAPIClient implements FireHydrantAPI {
|
||||
private readonly discoveryApi: DiscoveryApi;
|
||||
private readonly proxyPath: string;
|
||||
|
||||
constructor(options: Options) {
|
||||
this.discoveryApi = options.discoveryApi;
|
||||
this.proxyPath = options.proxyPath ?? DEFAULT_PROXY_PATH;
|
||||
}
|
||||
|
||||
async getServiceAnalytics(options: {
|
||||
serviceId: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
}): Promise<ServiceAnalyticsResponse> {
|
||||
const proxyUrl = await this.getApiUrl();
|
||||
const response = await fetch(
|
||||
`${proxyUrl}/metrics/services/${options.serviceId}?start_date=${options.startDate}&end_date=${options.endDate}`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`There was a problem fetching FireHydrant analytics data: ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
const json = await response.json();
|
||||
return json;
|
||||
}
|
||||
|
||||
async getServiceDetails(options: {
|
||||
serviceName: string;
|
||||
}): Promise<ServiceDetailsResponse> {
|
||||
const proxyUrl = await this.getApiUrl();
|
||||
const response = await fetch(
|
||||
`${proxyUrl}/services?query=${options.serviceName}`,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`There was a problem fetching FireHydrant data: ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
|
||||
const servicesData: ServiceDetailsResponse = {
|
||||
service: {} as Service,
|
||||
incidents: [] as Incident[],
|
||||
};
|
||||
|
||||
if (json.data?.length === 0) {
|
||||
return servicesData;
|
||||
}
|
||||
|
||||
servicesData.service = json.data[0];
|
||||
|
||||
const incidentsJson = await this.getServiceIncidents({
|
||||
serviceId: json.data[0].id,
|
||||
});
|
||||
|
||||
servicesData.incidents = incidentsJson;
|
||||
|
||||
return servicesData;
|
||||
}
|
||||
|
||||
async getServiceIncidents(options: {
|
||||
serviceId: string;
|
||||
}): Promise<ServiceIncidentsResponse> {
|
||||
const proxyUrl = await this.getApiUrl();
|
||||
const response = await fetch(
|
||||
`${proxyUrl}/incidents?services=${options.serviceId}&active=true`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`There was a problem fetching FireHydrant incidents data: ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
return json.data;
|
||||
}
|
||||
|
||||
private async getApiUrl() {
|
||||
const proxyUrl = await this.discoveryApi.getBaseUrl('proxy');
|
||||
return proxyUrl + this.proxyPath;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* 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 { Incident, Service } from '../components/types';
|
||||
|
||||
export type ServiceDetailsResponse = {
|
||||
service?: Service;
|
||||
incidents?: ServiceIncidentsResponse;
|
||||
};
|
||||
|
||||
export type ServiceAnalyticsResponse = {
|
||||
id: string;
|
||||
mttd?: number;
|
||||
mtta?: number;
|
||||
mttm?: number;
|
||||
mttr?: number;
|
||||
count: number;
|
||||
total_time: number;
|
||||
};
|
||||
|
||||
export type ServiceIncidentsResponse = Incident[];
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* 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 { screen } from '@testing-library/react';
|
||||
import { ServiceAnalytics } from './ServiceAnalytics';
|
||||
import { renderInTestApp } from '@backstage/test-utils';
|
||||
|
||||
describe('ServiceAnalytics', () => {
|
||||
it('renders service analytics', async () => {
|
||||
await renderInTestApp(
|
||||
<ServiceAnalytics
|
||||
error={null}
|
||||
loading={false}
|
||||
value={{
|
||||
id: 'b20de448-7855-4fb0-8d98-d9eafe82fc2c',
|
||||
mttd: 32,
|
||||
mtta: 44,
|
||||
mttm: 33,
|
||||
mttr: 23,
|
||||
count: 0,
|
||||
total_time: 0,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText(/32/)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/44/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { makeStyles } from '@material-ui/core/styles';
|
||||
import { DateTime } from 'luxon';
|
||||
import {
|
||||
ResponseErrorPanel,
|
||||
Table,
|
||||
TableColumn,
|
||||
Progress,
|
||||
} from '@backstage/core-components';
|
||||
|
||||
const useStyles = makeStyles({
|
||||
container: {
|
||||
overflow: 'auto',
|
||||
width: '100%',
|
||||
'& td': {
|
||||
minWidth: '145px',
|
||||
},
|
||||
'& th': {
|
||||
minWidth: '145px',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const DenseTable = ({
|
||||
service,
|
||||
startDate,
|
||||
endDate,
|
||||
}: {
|
||||
service: object;
|
||||
startDate: DateTime;
|
||||
endDate: DateTime;
|
||||
}) => {
|
||||
const classes = useStyles();
|
||||
|
||||
const columns: TableColumn[] = [
|
||||
{ field: 'healthiness', title: 'Healthiness' },
|
||||
{ field: 'impacted', title: 'Impacted' },
|
||||
{ field: 'incidents', title: 'Incidents' },
|
||||
{ field: 'mttd', title: 'MTTD' },
|
||||
{ field: 'mtta', title: 'MTTA' },
|
||||
{ field: 'mttm', title: 'MTTM' },
|
||||
{ field: 'mttr', title: 'MTTR' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={classes.container}>
|
||||
<Table
|
||||
title="Incident Analytics"
|
||||
subtitle={`${startDate.toFormat('MMMM dd, yyyy')} - ${endDate.toFormat(
|
||||
'MMMM dd, yyyy',
|
||||
)}`}
|
||||
options={{ paging: false, search: false }}
|
||||
columns={columns}
|
||||
data={[service]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const secondsToDhms = (seconds: number) => {
|
||||
const secs = Number(seconds);
|
||||
const d = Math.floor(secs / (60 * 60 * 24));
|
||||
const h = Math.floor((secs / (60 * 60)) % 24);
|
||||
const m = Math.floor((secs / 60) % 60);
|
||||
const s = secs % 60;
|
||||
|
||||
const dDisplay = d > 0 ? `${d}d ` : '';
|
||||
const hDisplay = h > 0 ? `${h}h ` : '00h ';
|
||||
const mDisplay = m > 0 ? `${m}m ` : '00m ';
|
||||
const sDisplay = s > 0 ? `${s}s` : '00s';
|
||||
return dDisplay + hDisplay + mDisplay + sDisplay;
|
||||
};
|
||||
|
||||
export const truncateNum = (num: number) => {
|
||||
const matcher = `^-?\\d+(?:\\.\\d{0,3})?`;
|
||||
const re = new RegExp(matcher);
|
||||
const match = num.toString().match(re);
|
||||
const result = (match && match[0]) || '0'; // eslint-disable-line no-mixed-operators
|
||||
return result;
|
||||
};
|
||||
|
||||
export const calcHealthiness = ({
|
||||
mttm,
|
||||
incidents,
|
||||
range,
|
||||
}: {
|
||||
mttm: number;
|
||||
incidents: number;
|
||||
range: number;
|
||||
}) => {
|
||||
const num = (1 - (mttm * incidents) / range) * 100;
|
||||
return `${truncateNum(num)}%`;
|
||||
};
|
||||
|
||||
type AnalyticsDataType = {
|
||||
id?: string;
|
||||
count?: number;
|
||||
mttd?: number;
|
||||
mtta?: number;
|
||||
mttm?: number;
|
||||
mttr?: number;
|
||||
total_time?: number;
|
||||
};
|
||||
|
||||
export const ServiceAnalytics = ({
|
||||
value,
|
||||
loading,
|
||||
error,
|
||||
}: {
|
||||
value: AnalyticsDataType;
|
||||
loading: boolean;
|
||||
error: any;
|
||||
}) => {
|
||||
if (loading) {
|
||||
return <Progress />;
|
||||
} else if (error) {
|
||||
return <ResponseErrorPanel error={error} />;
|
||||
}
|
||||
const startDate = DateTime.now().minus({ days: 30 }).toUTC();
|
||||
const endDate = DateTime.now().toUTC();
|
||||
|
||||
// Transform and format the data to display in the table
|
||||
if (value.id) {
|
||||
const serviceData = {
|
||||
healthiness:
|
||||
value.mttm && value.count
|
||||
? calcHealthiness({
|
||||
mttm: value.mttm,
|
||||
incidents: value.count,
|
||||
range: endDate.diff(startDate, 'seconds').as('seconds'),
|
||||
})
|
||||
: '100%',
|
||||
impacted: value.total_time ? secondsToDhms(value.total_time) : '-',
|
||||
incidents: value.count,
|
||||
mttd: value.mttd ? secondsToDhms(value.mttd) : '-',
|
||||
mtta: value.mtta ? secondsToDhms(value.mtta) : '-',
|
||||
mttm: value.mttm ? secondsToDhms(value.mttm) : '-',
|
||||
mttr: value.mttr ? secondsToDhms(value.mttr) : '-',
|
||||
};
|
||||
|
||||
return (
|
||||
<DenseTable
|
||||
service={serviceData}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -0,0 +1,118 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* 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 { ApiProvider, ApiRegistry } from '@backstage/core-app-api';
|
||||
import { fireHydrantApiRef } from '../../api';
|
||||
import { screen } from '@testing-library/react';
|
||||
import { ServiceDetailsCard } from './ServiceDetailsCard';
|
||||
import { Service, Incident } from '../types';
|
||||
import { renderInTestApp } from '@backstage/test-utils';
|
||||
|
||||
const mockFireHydrantApi = {
|
||||
getServiceDetails: () => {},
|
||||
getServiceAnalytics: () => {},
|
||||
};
|
||||
|
||||
const apis = ApiRegistry.from([[fireHydrantApiRef, mockFireHydrantApi]]);
|
||||
|
||||
jest.mock('@backstage/plugin-catalog-react', () => ({
|
||||
useEntity: () => {
|
||||
return { entity: { metadata: { name: 'service-example' } } };
|
||||
},
|
||||
}));
|
||||
|
||||
describe('ServiceDetailsCard', () => {
|
||||
it('renders service incidents', async () => {
|
||||
mockFireHydrantApi.getServiceDetails = jest.fn().mockImplementationOnce(
|
||||
async () =>
|
||||
[
|
||||
{
|
||||
service: {
|
||||
id: '12345',
|
||||
name: 'service-example',
|
||||
description: 'This is a sample description',
|
||||
active_incidents: ['654321'],
|
||||
} as Service,
|
||||
incidents: [
|
||||
{
|
||||
id: '654321',
|
||||
active: true,
|
||||
description: 'test incident',
|
||||
name: 'incident name here',
|
||||
incident_url: 'http://example.com',
|
||||
} as Incident,
|
||||
],
|
||||
},
|
||||
][0],
|
||||
);
|
||||
|
||||
mockFireHydrantApi.getServiceAnalytics = jest.fn().mockImplementationOnce(
|
||||
async () =>
|
||||
[
|
||||
{
|
||||
buckets: [
|
||||
{
|
||||
metrics: {
|
||||
12345: {
|
||||
count: 0,
|
||||
mtta: null,
|
||||
mttd: null,
|
||||
mttm: null,
|
||||
mttr: null,
|
||||
total_time: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
][0],
|
||||
);
|
||||
|
||||
await renderInTestApp(
|
||||
<ApiProvider apis={apis}>
|
||||
<ServiceDetailsCard />
|
||||
</ApiProvider>,
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByText(/View service incidents/),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText(/There is 1 active incident/),
|
||||
).toBeInTheDocument();
|
||||
expect(await screen.findByText(/incident name here/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles empty response', async () => {
|
||||
mockFireHydrantApi.getServiceDetails = jest
|
||||
.fn()
|
||||
.mockImplementationOnce(async () => []);
|
||||
|
||||
mockFireHydrantApi.getServiceAnalytics = jest
|
||||
.fn()
|
||||
.mockImplementationOnce(async () => []);
|
||||
|
||||
await renderInTestApp(
|
||||
<ApiProvider apis={apis}>
|
||||
<ServiceDetailsCard />
|
||||
</ApiProvider>,
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByText(/This service does not exist in FireHydrant/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,305 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* 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, useState } from 'react';
|
||||
import { DateTime } from 'luxon';
|
||||
import { ServiceAnalytics } from '../ServiceAnalytics/ServiceAnalytics';
|
||||
import {
|
||||
Box,
|
||||
Button as MaterialButton,
|
||||
Typography,
|
||||
makeStyles,
|
||||
} from '@material-ui/core';
|
||||
import ExitToAppIcon from '@material-ui/icons/ExitToApp';
|
||||
import NotesIcon from '@material-ui/icons/Notes';
|
||||
import WhatshotIcon from '@material-ui/icons/Whatshot';
|
||||
import WarningIcon from '@material-ui/icons/Warning';
|
||||
import AddIcon from '@material-ui/icons/Add';
|
||||
import { useEntity } from '@backstage/plugin-catalog-react';
|
||||
import { Incident } from '../types';
|
||||
import { ServiceIncidentsResponse } from '../../api/types';
|
||||
import { useServiceDetails } from '../serviceDetails';
|
||||
import { useServiceAnalytics } from '../serviceAnalytics';
|
||||
import {
|
||||
InfoCard,
|
||||
Link,
|
||||
Progress,
|
||||
ResponseErrorPanel,
|
||||
} from '@backstage/core-components';
|
||||
import { configApiRef, useApi } from '@backstage/core-plugin-api';
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
button: {
|
||||
color: '#3b2492',
|
||||
display: 'grid',
|
||||
gridGap: '4px',
|
||||
textAlign: 'center',
|
||||
justifyItems: 'center',
|
||||
width: '105px',
|
||||
backgroundColor: theme.palette.type === 'dark' ? '#f1edff' : '',
|
||||
'&:hover, &:focus': {
|
||||
backgroundColor: '#f1edff',
|
||||
color: '#614ab6',
|
||||
},
|
||||
'&:active': {
|
||||
color: '#3b2492',
|
||||
backgroundColor: '#b2a6e3',
|
||||
boxShadow:
|
||||
'rgb(59, 36, 146) 0px 0px 0px 1px inset, rgb(141, 134, 188) 3px 3px 0px 0px inset;',
|
||||
},
|
||||
border: '1px solid #3b2492',
|
||||
borderRadius: '5px',
|
||||
padding: '8px 10px',
|
||||
textTransform: 'none',
|
||||
},
|
||||
buttonLink: {
|
||||
backgroundColor: '#3b2492',
|
||||
color: '#FFF',
|
||||
textTransform: 'none',
|
||||
'&:hover': {
|
||||
backgroundColor: '#614ab6',
|
||||
},
|
||||
},
|
||||
buttonContainer: {
|
||||
display: 'grid',
|
||||
gridGap: '24px',
|
||||
gridAutoFlow: 'column',
|
||||
gridAutoColumns: 'min-content',
|
||||
},
|
||||
icon: {
|
||||
color: '#f1642d',
|
||||
},
|
||||
link: {
|
||||
textDecoration: 'underline',
|
||||
fontSize: '16px',
|
||||
lineHeight: '27px',
|
||||
color: '#3b2492',
|
||||
'&:hover, &:focus': {
|
||||
fontWeight: '500',
|
||||
},
|
||||
},
|
||||
linksContainer: {
|
||||
borderBottom: '1px solid #d5d5d5',
|
||||
padding: '10px 0px 10px 20px',
|
||||
backgroundColor: '#f1edff',
|
||||
marginBottom: '20px',
|
||||
},
|
||||
table: {
|
||||
width: '100%',
|
||||
},
|
||||
warning: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '10px',
|
||||
background: '#f1edff',
|
||||
color: '#3b2492',
|
||||
},
|
||||
}));
|
||||
|
||||
const ServiceAnalyticsView = ({
|
||||
serviceId,
|
||||
startDate,
|
||||
endDate,
|
||||
}: {
|
||||
serviceId: string;
|
||||
startDate: DateTime;
|
||||
endDate: DateTime;
|
||||
}) => {
|
||||
const {
|
||||
loading: analyticsLoading,
|
||||
value: analyticsValue = {},
|
||||
error: analyticsError,
|
||||
} = useServiceAnalytics({
|
||||
serviceId,
|
||||
startDate: startDate.toFormat('YYYY-MM-DD'),
|
||||
endDate: endDate.toFormat('YYYY-MM-DD'),
|
||||
});
|
||||
|
||||
return (
|
||||
<ServiceAnalytics
|
||||
loading={analyticsLoading}
|
||||
value={analyticsValue}
|
||||
error={analyticsError}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const ServiceDetailsCard = () => {
|
||||
const { entity } = useEntity();
|
||||
const classes = useStyles();
|
||||
const [showServiceDetails, setShowServiceDetails] = useState(false);
|
||||
const configApi = useApi(configApiRef);
|
||||
|
||||
const BASE_URL =
|
||||
configApi.getOptionalString('firehydrant.baseUrl') ||
|
||||
'https://app.firehydrant.io';
|
||||
|
||||
const startDate = DateTime.now().minus({ days: 30 }).toUTC();
|
||||
const endDate = DateTime.now().toUTC();
|
||||
|
||||
// The Backstage service name in FireHydrant is a unique formatted string
|
||||
// that requires the entity's kind, name, and namespace.
|
||||
const fireHydrantServiceName = `${entity?.kind}:${
|
||||
entity?.metadata?.namespace ?? 'default'
|
||||
}/${entity?.metadata?.name}`;
|
||||
|
||||
const { loading, value, error } = useServiceDetails({
|
||||
serviceName: fireHydrantServiceName,
|
||||
});
|
||||
|
||||
const activeIncidents: string[] = value?.service?.active_incidents ?? [];
|
||||
const incidents: ServiceIncidentsResponse = value?.incidents ?? [];
|
||||
const serviceId: string = value?.service?.id!;
|
||||
|
||||
useEffect(() => {
|
||||
if (value?.service && Object.keys(value?.service).length > 0) {
|
||||
setShowServiceDetails(true);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
if (loading) {
|
||||
return <Progress />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <ResponseErrorPanel error={error} />;
|
||||
}
|
||||
|
||||
const headerText: string = showServiceDetails
|
||||
? `There ${activeIncidents?.length === 1 ? 'is' : 'are'} ${
|
||||
activeIncidents?.length
|
||||
} active incident${activeIncidents?.length === 1 ? '' : 's'}.`
|
||||
: '';
|
||||
|
||||
const serviceIncidentsLink: string = `${BASE_URL}/incidents?search={"services":[{"label":${JSON.stringify(
|
||||
value?.service?.name,
|
||||
)},"value":${JSON.stringify(value?.service?.id)}}]}`;
|
||||
|
||||
return (
|
||||
<InfoCard>
|
||||
{!showServiceDetails && !loading && (
|
||||
<div className={classes.warning}>
|
||||
<WarningIcon />
|
||||
<span>This service does not exist in FireHydrant.</span>
|
||||
</div>
|
||||
)}
|
||||
{showServiceDetails && (
|
||||
<Box
|
||||
alignItems="center"
|
||||
display="flex"
|
||||
justifyContent="space-between"
|
||||
borderBottom="1px solid #d5d5d5"
|
||||
>
|
||||
<Box>
|
||||
<h2>{headerText}</h2>
|
||||
</Box>
|
||||
<Box>
|
||||
<MaterialButton
|
||||
className={classes.buttonLink}
|
||||
color="default"
|
||||
href={serviceIncidentsLink}
|
||||
startIcon={<ExitToAppIcon />}
|
||||
target="_blank"
|
||||
variant="outlined"
|
||||
>
|
||||
View service incidents
|
||||
</MaterialButton>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
{activeIncidents && activeIncidents?.length > 0 && (
|
||||
<Box className={classes.linksContainer}>
|
||||
{incidents &&
|
||||
incidents?.slice(0, 5).map((incident: Incident, index: number) => (
|
||||
<div key={index}>
|
||||
<Link
|
||||
className={classes.link}
|
||||
to={incident.incident_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{incident.name}
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
<Box paddingLeft="16px" marginTop="10px">
|
||||
<Typography variant="subtitle1">View in FireHydrant </Typography>
|
||||
<Box className={classes.buttonContainer} marginTop="10px">
|
||||
<MaterialButton
|
||||
component={Link}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
className={classes.button}
|
||||
to={`${BASE_URL}/incidents/new`}
|
||||
>
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<AddIcon className={classes.icon} />
|
||||
</Box>
|
||||
<Box>
|
||||
<span>Declare an incident</span>
|
||||
</Box>
|
||||
</Box>
|
||||
</MaterialButton>
|
||||
<MaterialButton
|
||||
component={Link}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
className={classes.button}
|
||||
to={`${BASE_URL}/incidents`}
|
||||
>
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<WhatshotIcon className={classes.icon} />
|
||||
</Box>
|
||||
<Box>
|
||||
<span>View all incidents</span>
|
||||
</Box>
|
||||
</Box>
|
||||
</MaterialButton>
|
||||
{showServiceDetails && (
|
||||
<MaterialButton
|
||||
component={Link}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
className={classes.button}
|
||||
to={`${BASE_URL}/services/${value?.service?.id}`}
|
||||
>
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<NotesIcon className={classes.icon} />
|
||||
</Box>
|
||||
<Box>
|
||||
<span>View Service Details</span>
|
||||
</Box>
|
||||
</Box>
|
||||
</MaterialButton>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
{showServiceDetails && (
|
||||
<Box>
|
||||
<ServiceAnalyticsView
|
||||
serviceId={serviceId}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</InfoCard>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* 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 { ServiceDetailsCard } from './ServiceDetailsCard';
|
||||
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* 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 { useAsyncRetry } from 'react-use';
|
||||
import { fireHydrantApiRef } from '../api';
|
||||
import { errorApiRef, useApi } from '@backstage/core-plugin-api';
|
||||
|
||||
export const useServiceAnalytics = ({
|
||||
serviceId,
|
||||
startDate,
|
||||
endDate,
|
||||
}: {
|
||||
serviceId: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
}) => {
|
||||
const api = useApi(fireHydrantApiRef);
|
||||
const errorApi = useApi(errorApiRef);
|
||||
|
||||
const { loading, value, error, retry } = useAsyncRetry(async () => {
|
||||
try {
|
||||
return await api.getServiceAnalytics({
|
||||
serviceId: serviceId,
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
});
|
||||
} catch (e) {
|
||||
errorApi.post(e);
|
||||
return Promise.reject(e);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
loading,
|
||||
value,
|
||||
error,
|
||||
retry,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* 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 { useAsyncRetry } from 'react-use';
|
||||
import { fireHydrantApiRef } from '../api';
|
||||
import { errorApiRef, useApi } from '@backstage/core-plugin-api';
|
||||
|
||||
export const useServiceDetails = ({ serviceName }: { serviceName: string }) => {
|
||||
const api = useApi(fireHydrantApiRef);
|
||||
const errorApi = useApi(errorApiRef);
|
||||
|
||||
const { loading, value, error, retry } = useAsyncRetry(async () => {
|
||||
try {
|
||||
return await api.getServiceDetails({ serviceName: serviceName });
|
||||
} catch (e) {
|
||||
errorApi.post(e);
|
||||
return Promise.reject(e);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
loading,
|
||||
value,
|
||||
error,
|
||||
retry,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* 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 type Service = {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
active_incidents?: string[];
|
||||
};
|
||||
|
||||
export type Incident = {
|
||||
id: string;
|
||||
active: boolean;
|
||||
description?: string;
|
||||
name: string;
|
||||
incident_url: string;
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* 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 { firehydrantPlugin, FirehydrantCard } from './plugin';
|
||||
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* 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 { firehydrantPlugin } from './plugin';
|
||||
|
||||
describe('firehydrant', () => {
|
||||
it('should export plugin', () => {
|
||||
expect(firehydrantPlugin).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* 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 { fireHydrantApiRef, FireHydrantAPIClient } from './api';
|
||||
import {
|
||||
createApiFactory,
|
||||
createPlugin,
|
||||
discoveryApiRef,
|
||||
createComponentExtension,
|
||||
} from '@backstage/core-plugin-api';
|
||||
|
||||
import { rootRouteRef } from './routes';
|
||||
|
||||
export const firehydrantPlugin = createPlugin({
|
||||
id: 'firehydrant',
|
||||
apis: [
|
||||
createApiFactory({
|
||||
api: fireHydrantApiRef,
|
||||
deps: { discoveryApi: discoveryApiRef },
|
||||
factory: ({ discoveryApi }) => new FireHydrantAPIClient({ discoveryApi }),
|
||||
}),
|
||||
],
|
||||
routes: {
|
||||
root: rootRouteRef,
|
||||
},
|
||||
});
|
||||
|
||||
export const FirehydrantCard = firehydrantPlugin.provide(
|
||||
createComponentExtension({
|
||||
component: {
|
||||
lazy: () =>
|
||||
import('./components/ServiceDetailsCard').then(
|
||||
m => m.ServiceDetailsCard,
|
||||
),
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { createRouteRef } from '@backstage/core-plugin-api';
|
||||
|
||||
export const rootRouteRef = createRouteRef({
|
||||
title: 'firehydrant',
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import '@testing-library/jest-dom';
|
||||
import 'cross-fetch/polyfill';
|
||||
Reference in New Issue
Block a user