From e6a53d79f0c8b0369c82613d27c73c27283af8eb Mon Sep 17 00:00:00 2001 From: Christine Yi Date: Thu, 29 Jul 2021 12:05:37 -0400 Subject: [PATCH 1/6] add FireHydrant plugin Signed-off-by: Christine Yi --- plugins/firehydrant/.eslintrc.js | 3 + plugins/firehydrant/README.md | 66 +++++ plugins/firehydrant/api-report.md | 23 ++ plugins/firehydrant/dev/index.tsx | 19 ++ plugins/firehydrant/doc/firehydrant_logo.png | Bin 0 -> 5387 bytes .../doc/firehydrant_plugin_screenshot.png | Bin 0 -> 57062 bytes plugins/firehydrant/package.json | 67 +++++ plugins/firehydrant/src/api/index.ts | 137 +++++++++ plugins/firehydrant/src/api/types.ts | 33 +++ .../ServiceAnalytics.test.tsx | 42 +++ .../ServiceAnalytics/ServiceAnalytics.tsx | 164 ++++++++++ .../ServiceDetailsCard.test.tsx | 118 ++++++++ .../ServiceDetailsCard/ServiceDetailsCard.tsx | 279 ++++++++++++++++++ .../components/ServiceDetailsCard/index.ts | 16 + .../src/components/serviceAnalytics.ts | 51 ++++ .../src/components/serviceDetails.ts | 39 +++ plugins/firehydrant/src/components/types.ts | 29 ++ plugins/firehydrant/src/index.ts | 16 + plugins/firehydrant/src/plugin.test.ts | 22 ++ plugins/firehydrant/src/plugin.ts | 46 +++ plugins/firehydrant/src/routes.ts | 20 ++ plugins/firehydrant/src/setupTests.ts | 17 ++ 22 files changed, 1207 insertions(+) create mode 100644 plugins/firehydrant/.eslintrc.js create mode 100644 plugins/firehydrant/README.md create mode 100644 plugins/firehydrant/api-report.md create mode 100644 plugins/firehydrant/dev/index.tsx create mode 100644 plugins/firehydrant/doc/firehydrant_logo.png create mode 100644 plugins/firehydrant/doc/firehydrant_plugin_screenshot.png create mode 100644 plugins/firehydrant/package.json create mode 100644 plugins/firehydrant/src/api/index.ts create mode 100644 plugins/firehydrant/src/api/types.ts create mode 100644 plugins/firehydrant/src/components/ServiceAnalytics/ServiceAnalytics.test.tsx create mode 100644 plugins/firehydrant/src/components/ServiceAnalytics/ServiceAnalytics.tsx create mode 100644 plugins/firehydrant/src/components/ServiceDetailsCard/ServiceDetailsCard.test.tsx create mode 100644 plugins/firehydrant/src/components/ServiceDetailsCard/ServiceDetailsCard.tsx create mode 100644 plugins/firehydrant/src/components/ServiceDetailsCard/index.ts create mode 100644 plugins/firehydrant/src/components/serviceAnalytics.ts create mode 100644 plugins/firehydrant/src/components/serviceDetails.ts create mode 100644 plugins/firehydrant/src/components/types.ts create mode 100644 plugins/firehydrant/src/index.ts create mode 100644 plugins/firehydrant/src/plugin.test.ts create mode 100644 plugins/firehydrant/src/plugin.ts create mode 100644 plugins/firehydrant/src/routes.ts create mode 100644 plugins/firehydrant/src/setupTests.ts diff --git a/plugins/firehydrant/.eslintrc.js b/plugins/firehydrant/.eslintrc.js new file mode 100644 index 0000000000..13573efa9c --- /dev/null +++ b/plugins/firehydrant/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], +}; diff --git a/plugins/firehydrant/README.md b/plugins/firehydrant/README.md new file mode 100644 index 0000000000..98f36eb4c3 --- /dev/null +++ b/plugins/firehydrant/README.md @@ -0,0 +1,66 @@ +# 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. + +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 `` component: + +```ts +// In packages/app/src/components/catalog/EntityPage.tsx +import { FirehydrantPage } from '@backstage/plugin-firehydrant'; + +// Add to code as a grid item + + +; +``` + +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} +``` + +as well as the default url configuration to `app-config.yaml`: + +```yaml +firehydrant: + baseUrl: https://api.firehydrant.io +``` + +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 +``` diff --git a/plugins/firehydrant/api-report.md b/plugins/firehydrant/api-report.md new file mode 100644 index 0000000000..d12c2499c9 --- /dev/null +++ b/plugins/firehydrant/api-report.md @@ -0,0 +1,23 @@ +## 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 +/// + +import { BackstagePlugin } from '@backstage/core-plugin-api'; +import { RouteRef } from '@backstage/core-plugin-api'; + +// @public (undocumented) +export const FirehydrantPage: () => JSX.Element; + +// @public (undocumented) +export const firehydrantPlugin: BackstagePlugin< + { + root: RouteRef; + }, + {} +>; + +// (No @packageDocumentation comment for this package) +``` diff --git a/plugins/firehydrant/dev/index.tsx b/plugins/firehydrant/dev/index.tsx new file mode 100644 index 0000000000..237cb2737e --- /dev/null +++ b/plugins/firehydrant/dev/index.tsx @@ -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(); diff --git a/plugins/firehydrant/doc/firehydrant_logo.png b/plugins/firehydrant/doc/firehydrant_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..3db13826348372b0847b9a891ea855c15c1a57c4 GIT binary patch literal 5387 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 literal 0 HcmV?d00001 diff --git a/plugins/firehydrant/doc/firehydrant_plugin_screenshot.png b/plugins/firehydrant/doc/firehydrant_plugin_screenshot.png new file mode 100644 index 0000000000000000000000000000000000000000..d3e79bb27339f22a0e199f0809dd1d1d53ff5d2d GIT binary patch literal 57062 zcmb@tWl)ga_XUciw35;wNOyNgcY}0ycc%i%wq{y*Gt z81cz-&U5zOYpuNuRge=$M!-dYfPg@jln_yZfOvBWesbQzf+O$Wu@0HW^= zekCWA8YHoDJYDGNjp5&J3PyWj2sqX^Z$3bhhtObFcXoY!^O5C3Z|jKwLUx3Mj>6~Z z;pK&znzt<;5hDJ(J+u74ut)fF%a*cePWdh?>5LN z*i{Z4^FqV4~iNBz$li8sDM_|Q

gH2c{@qG!3Q5{r*IOL2 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

7^9j0^lG5-`K>j@po`bNMRLvBJ+a3 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 + + getServiceDetails(options: { + serviceName: string; + }): Promise; + + getServiceIncidents(options: { + serviceId: string; + }): Promise; +} + +export const fireHydrantApiRef = createApiRef({ + 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 { + 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 { + 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 (response.ok) { + 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 { + 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; + } +} diff --git a/plugins/firehydrant/src/api/types.ts b/plugins/firehydrant/src/api/types.ts new file mode 100644 index 0000000000..089c850c97 --- /dev/null +++ b/plugins/firehydrant/src/api/types.ts @@ -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[]; diff --git a/plugins/firehydrant/src/components/ServiceAnalytics/ServiceAnalytics.test.tsx b/plugins/firehydrant/src/components/ServiceAnalytics/ServiceAnalytics.test.tsx new file mode 100644 index 0000000000..82bbac57c1 --- /dev/null +++ b/plugins/firehydrant/src/components/ServiceAnalytics/ServiceAnalytics.test.tsx @@ -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( + , + ); + + expect(await screen.findByText(/32/)).toBeInTheDocument(); + expect(await screen.findByText(/44/)).toBeInTheDocument(); + }); +}); diff --git a/plugins/firehydrant/src/components/ServiceAnalytics/ServiceAnalytics.tsx b/plugins/firehydrant/src/components/ServiceAnalytics/ServiceAnalytics.tsx new file mode 100644 index 0000000000..996d705a9a --- /dev/null +++ b/plugins/firehydrant/src/components/ServiceAnalytics/ServiceAnalytics.tsx @@ -0,0 +1,164 @@ +/* + * 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 moment, { Moment } from 'moment'; +import Alert from '@material-ui/lab/Alert'; +import { Table, TableColumn, Progress } from '@backstage/core-components'; + +const useStyles = makeStyles({ + container: { + overflow: 'auto', + width: '100%', + '& h5': { + fontSize: '18px !important', + }, + '& td': { + minWidth: '145px', + }, + '& th': { + minWidth: '145px', + }, + }, +}); + +export const DenseTable = ({ + service, + startDate, + endDate, +}: { + service: object; + startDate: Moment; + endDate: Moment; +}) => { + 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 ( +

+ + + ); +}; + +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 ; + } else if (error) { + return {error.message}; + } + + const startDate = moment().subtract(30, 'days').utc(); + const endDate = moment().utc(); + + // 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'), + }) + : '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 ( + + ); + } + + return null; +}; diff --git a/plugins/firehydrant/src/components/ServiceDetailsCard/ServiceDetailsCard.test.tsx b/plugins/firehydrant/src/components/ServiceDetailsCard/ServiceDetailsCard.test.tsx new file mode 100644 index 0000000000..1123f014da --- /dev/null +++ b/plugins/firehydrant/src/components/ServiceDetailsCard/ServiceDetailsCard.test.tsx @@ -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( + + + , + ); + + 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( + + + , + ); + + expect( + await screen.findByText(/This service does not exist in FireHydrant/), + ).toBeInTheDocument(); + }); +}); diff --git a/plugins/firehydrant/src/components/ServiceDetailsCard/ServiceDetailsCard.tsx b/plugins/firehydrant/src/components/ServiceDetailsCard/ServiceDetailsCard.tsx new file mode 100644 index 0000000000..7ebbcd8351 --- /dev/null +++ b/plugins/firehydrant/src/components/ServiceDetailsCard/ServiceDetailsCard.tsx @@ -0,0 +1,279 @@ +/* + * 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 moment from 'moment'; +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, Progress } from '@backstage/core-components'; +import { configApiRef, useApi } from '@backstage/core-plugin-api'; + +const useStyles = makeStyles(theme => ({ + infoCard: { + height: '342px', + }, + button: { + color: '#3b2492', + display: 'grid', + gridGap: '4px', + textAlign: 'center', + justifyItems: 'center', + width: '102px', + 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: '10px', + }, + 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: theme.palette.type === 'dark' ? '#3b2492' : '#fff', + }, +})); + +const ServiceWarning = ({ text }: { text: string }) => { + const classes = useStyles(); + return ( +
+ +   {text} +
+ ); +}; + +const ServiceAnalyticsView = ({ + serviceId, + startDate, + endDate, +}: { + serviceId: string; + startDate: moment.Moment; + endDate: moment.Moment; +}) => { + const { + loading: analyticsLoading, + value: analyticsValue = {}, + error: analyticsError, + } = useServiceAnalytics({ + serviceId, + startDate: startDate.format('YYYY-MM-DD'), + endDate: endDate.format('YYYY-MM-DD'), + }); + + return ( + + ); +}; + +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 = moment().subtract(30, 'days').utc(); + const endDate = moment().utc(); + + const { loading, value, error } = useServiceDetails({ + serviceName: entity?.metadata?.name, + }); + + 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 ; + } + + if (error) { + return ( + + ); + } + + 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 ( + + {!showServiceDetails && !loading && ( + + )} + {showServiceDetails && ( + + +

{headerText}

+
+ + + View service incidents   + + + +
+ )} + {activeIncidents && activeIncidents?.length > 0 && ( + + {incidents && + incidents?.slice(0, 5).map((incident: Incident, index: number) => ( + + ))} + + )} + + View in FireHydrant + + + + Declare an incident + + + + View all incidents + + {showServiceDetails && ( + + + View Service Details + + )} + + + {showServiceDetails && ( + + + + )} +
+ ); +}; diff --git a/plugins/firehydrant/src/components/ServiceDetailsCard/index.ts b/plugins/firehydrant/src/components/ServiceDetailsCard/index.ts new file mode 100644 index 0000000000..33e68ecf3b --- /dev/null +++ b/plugins/firehydrant/src/components/ServiceDetailsCard/index.ts @@ -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'; diff --git a/plugins/firehydrant/src/components/serviceAnalytics.ts b/plugins/firehydrant/src/components/serviceAnalytics.ts new file mode 100644 index 0000000000..ba88af86da --- /dev/null +++ b/plugins/firehydrant/src/components/serviceAnalytics.ts @@ -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, + }; +}; diff --git a/plugins/firehydrant/src/components/serviceDetails.ts b/plugins/firehydrant/src/components/serviceDetails.ts new file mode 100644 index 0000000000..b8cac2736a --- /dev/null +++ b/plugins/firehydrant/src/components/serviceDetails.ts @@ -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, + }; +}; diff --git a/plugins/firehydrant/src/components/types.ts b/plugins/firehydrant/src/components/types.ts new file mode 100644 index 0000000000..e607b2357d --- /dev/null +++ b/plugins/firehydrant/src/components/types.ts @@ -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; +}; diff --git a/plugins/firehydrant/src/index.ts b/plugins/firehydrant/src/index.ts new file mode 100644 index 0000000000..3b9b584fb8 --- /dev/null +++ b/plugins/firehydrant/src/index.ts @@ -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, FirehydrantPage } from './plugin'; diff --git a/plugins/firehydrant/src/plugin.test.ts b/plugins/firehydrant/src/plugin.test.ts new file mode 100644 index 0000000000..ffde182e64 --- /dev/null +++ b/plugins/firehydrant/src/plugin.test.ts @@ -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(); + }); +}); diff --git a/plugins/firehydrant/src/plugin.ts b/plugins/firehydrant/src/plugin.ts new file mode 100644 index 0000000000..e4794ab4ce --- /dev/null +++ b/plugins/firehydrant/src/plugin.ts @@ -0,0 +1,46 @@ +/* + * 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, + createRoutableExtension, +} 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 FirehydrantPage = firehydrantPlugin.provide( + createRoutableExtension({ + component: () => + import('./components/ServiceDetailsCard').then(m => m.ServiceDetailsCard), + mountPoint: rootRouteRef, + }), +); diff --git a/plugins/firehydrant/src/routes.ts b/plugins/firehydrant/src/routes.ts new file mode 100644 index 0000000000..eb2f8b6375 --- /dev/null +++ b/plugins/firehydrant/src/routes.ts @@ -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', +}); diff --git a/plugins/firehydrant/src/setupTests.ts b/plugins/firehydrant/src/setupTests.ts new file mode 100644 index 0000000000..fc6dbd98f8 --- /dev/null +++ b/plugins/firehydrant/src/setupTests.ts @@ -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'; From d5a69640bca9495304751b5ea8d0ef54a9508fa6 Mon Sep 17 00:00:00 2001 From: Christine Yi Date: Mon, 2 Aug 2021 12:07:31 -0400 Subject: [PATCH 2/6] update api-report, add FireHydrant to vocab.txt Signed-off-by: Christine Yi --- .github/styles/vocab.txt | 2 ++ plugins/firehydrant/README.md | 2 +- plugins/firehydrant/api-report.md | 4 ++++ 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/styles/vocab.txt b/.github/styles/vocab.txt index 48740ef367..c3ad830034 100644 --- a/.github/styles/vocab.txt +++ b/.github/styles/vocab.txt @@ -83,6 +83,8 @@ facto failover Fargate Figma +firehydrant +FireHydrant Firekube Fiverr gitbeaker diff --git a/plugins/firehydrant/README.md b/plugins/firehydrant/README.md index 98f36eb4c3..572e873c83 100644 --- a/plugins/firehydrant/README.md +++ b/plugins/firehydrant/README.md @@ -4,7 +4,7 @@ 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. -firehydrant plugin screenshot +FireHydrant plugin screenshot ## Features diff --git a/plugins/firehydrant/api-report.md b/plugins/firehydrant/api-report.md index d12c2499c9..32b21b9906 100644 --- a/plugins/firehydrant/api-report.md +++ b/plugins/firehydrant/api-report.md @@ -8,9 +8,13 @@ import { BackstagePlugin } from '@backstage/core-plugin-api'; import { RouteRef } from '@backstage/core-plugin-api'; +// Warning: (ae-missing-release-tag) "FirehydrantPage" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// // @public (undocumented) export const FirehydrantPage: () => 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< { From cfe71d2d877acbf08a3f473195cfccb1492067d3 Mon Sep 17 00:00:00 2001 From: Christine Yi Date: Thu, 5 Aug 2021 18:51:18 -0400 Subject: [PATCH 3/6] fetch firehydrant service by unique name Signed-off-by: Christine Yi --- .../components/ServiceDetailsCard/ServiceDetailsCard.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/plugins/firehydrant/src/components/ServiceDetailsCard/ServiceDetailsCard.tsx b/plugins/firehydrant/src/components/ServiceDetailsCard/ServiceDetailsCard.tsx index 7ebbcd8351..dba81fa88f 100644 --- a/plugins/firehydrant/src/components/ServiceDetailsCard/ServiceDetailsCard.tsx +++ b/plugins/firehydrant/src/components/ServiceDetailsCard/ServiceDetailsCard.tsx @@ -156,8 +156,14 @@ export const ServiceDetailsCard = () => { const startDate = moment().subtract(30, 'days').utc(); const endDate = moment().utc(); + // 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: entity?.metadata?.name, + serviceName: fireHydrantServiceName, }); const activeIncidents: string[] = value?.service?.active_incidents ?? []; From 7e16ee33de7f55ef99010ffcc42668ea8a196439 Mon Sep 17 00:00:00 2001 From: Christine Yi Date: Mon, 9 Aug 2021 10:21:00 -0400 Subject: [PATCH 4/6] bump dependencies Signed-off-by: Christine Yi --- plugins/firehydrant/package.json | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/plugins/firehydrant/package.json b/plugins/firehydrant/package.json index f4d2a3a8a3..08773dc972 100644 --- a/plugins/firehydrant/package.json +++ b/plugins/firehydrant/package.json @@ -20,11 +20,11 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core-plugin-api": "^0.1.3", - "@backstage/core-components": "^0.1.5", + "@backstage/core-plugin-api": "^0.1.4", + "@backstage/core-components": "^0.2.0", "@backstage/plugin-catalog-react": "^0.2.6", - "@backstage/theme": "^0.2.8", - "@material-ui/core": "^4.11.0", + "@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", "moment": "^2.29.1", @@ -33,17 +33,17 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.7.3", - "@backstage/core-app-api": "^0.1.4", - "@backstage/dev-utils": "^0.2.1", - "@backstage/test-utils": "^0.1.14", + "@backstage/cli": "^0.7.6", + "@backstage/core-app-api": "^0.1.6", + "@backstage/dev-utils": "^0.2.4", + "@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.21.2" + "msw": "^0.29.0" }, "files": [ "dist" From 055f8791b5c1311a3a948d00a4ca82cfb3d6f899 Mon Sep 17 00:00:00 2001 From: Christine Yi Date: Mon, 9 Aug 2021 16:06:35 -0400 Subject: [PATCH 5/6] bump dependencies Signed-off-by: Christine Yi --- plugins/firehydrant/package.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/plugins/firehydrant/package.json b/plugins/firehydrant/package.json index 08773dc972..f2f2dfe84b 100644 --- a/plugins/firehydrant/package.json +++ b/plugins/firehydrant/package.json @@ -20,9 +20,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core-plugin-api": "^0.1.4", - "@backstage/core-components": "^0.2.0", - "@backstage/plugin-catalog-react": "^0.2.6", + "@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", @@ -33,9 +33,9 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.7.6", - "@backstage/core-app-api": "^0.1.6", - "@backstage/dev-utils": "^0.2.4", + "@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", From 40bb7e3b24274f53eae4e36c0b805c3781662b2a Mon Sep 17 00:00:00 2001 From: Christine Yi Date: Tue, 10 Aug 2021 15:25:42 -0400 Subject: [PATCH 6/6] address PR feedback: use luxon, remove unused route, update styling Signed-off-by: Christine Yi --- plugins/firehydrant/README.md | 23 ++-- plugins/firehydrant/api-report.md | 4 +- plugins/firehydrant/package.json | 3 +- plugins/firehydrant/src/api/index.ts | 23 ++-- .../ServiceAnalytics/ServiceAnalytics.tsx | 30 ++--- .../ServiceDetailsCard/ServiceDetailsCard.tsx | 116 ++++++++++-------- plugins/firehydrant/src/index.ts | 2 +- plugins/firehydrant/src/plugin.ts | 15 ++- 8 files changed, 117 insertions(+), 99 deletions(-) diff --git a/plugins/firehydrant/README.md b/plugins/firehydrant/README.md index 572e873c83..aaab8b7d0e 100644 --- a/plugins/firehydrant/README.md +++ b/plugins/firehydrant/README.md @@ -26,11 +26,11 @@ yarn add @backstage/plugin-firehydrant ```ts // In packages/app/src/components/catalog/EntityPage.tsx -import { FirehydrantPage } from '@backstage/plugin-firehydrant'; +import { FirehydrantCard } from '@backstage/plugin-firehydrant'; // Add to code as a grid item - + ; ``` @@ -38,19 +38,12 @@ import { FirehydrantPage } from '@backstage/plugin-firehydrant'; ```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} -``` - -as well as the default url configuration to `app-config.yaml`: - -```yaml -firehydrant: - baseUrl: https://api.firehydrant.io + '/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`: diff --git a/plugins/firehydrant/api-report.md b/plugins/firehydrant/api-report.md index 32b21b9906..6cd8270178 100644 --- a/plugins/firehydrant/api-report.md +++ b/plugins/firehydrant/api-report.md @@ -8,10 +8,10 @@ import { BackstagePlugin } from '@backstage/core-plugin-api'; import { RouteRef } from '@backstage/core-plugin-api'; -// Warning: (ae-missing-release-tag) "FirehydrantPage" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// 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 FirehydrantPage: () => JSX.Element; +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) // diff --git a/plugins/firehydrant/package.json b/plugins/firehydrant/package.json index f2f2dfe84b..a5531ac0bc 100644 --- a/plugins/firehydrant/package.json +++ b/plugins/firehydrant/package.json @@ -4,6 +4,7 @@ "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", + "private": false, "publishConfig": { "access": "public", "main": "dist/index.esm.js", @@ -27,7 +28,7 @@ "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", - "moment": "^2.29.1", + "luxon": "^1.27.0", "react": "^16.13.1", "react-dom": "^16.13.1", "react-use": "^17.2.4" diff --git a/plugins/firehydrant/src/api/index.ts b/plugins/firehydrant/src/api/index.ts index e5e664aed8..b78e81a024 100644 --- a/plugins/firehydrant/src/api/index.ts +++ b/plugins/firehydrant/src/api/index.ts @@ -97,19 +97,18 @@ export class FireHydrantAPIClient implements FireHydrantAPI { incidents: [] as Incident[], }; - if (response.ok) { - 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; + 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; } diff --git a/plugins/firehydrant/src/components/ServiceAnalytics/ServiceAnalytics.tsx b/plugins/firehydrant/src/components/ServiceAnalytics/ServiceAnalytics.tsx index 996d705a9a..e5818ef484 100644 --- a/plugins/firehydrant/src/components/ServiceAnalytics/ServiceAnalytics.tsx +++ b/plugins/firehydrant/src/components/ServiceAnalytics/ServiceAnalytics.tsx @@ -15,17 +15,18 @@ */ import React from 'react'; import { makeStyles } from '@material-ui/core/styles'; -import moment, { Moment } from 'moment'; -import Alert from '@material-ui/lab/Alert'; -import { Table, TableColumn, Progress } from '@backstage/core-components'; +import { DateTime } from 'luxon'; +import { + ResponseErrorPanel, + Table, + TableColumn, + Progress, +} from '@backstage/core-components'; const useStyles = makeStyles({ container: { overflow: 'auto', width: '100%', - '& h5': { - fontSize: '18px !important', - }, '& td': { minWidth: '145px', }, @@ -41,8 +42,8 @@ export const DenseTable = ({ endDate, }: { service: object; - startDate: Moment; - endDate: Moment; + startDate: DateTime; + endDate: DateTime; }) => { const classes = useStyles(); @@ -60,7 +61,9 @@ export const DenseTable = ({
; } else if (error) { - return {error.message}; + return ; } - - const startDate = moment().subtract(30, 'days').utc(); - const endDate = moment().utc(); + 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) { @@ -140,7 +142,7 @@ export const ServiceAnalytics = ({ ? calcHealthiness({ mttm: value.mttm, incidents: value.count, - range: endDate.diff(startDate, 'seconds'), + range: endDate.diff(startDate, 'seconds').as('seconds'), }) : '100%', impacted: value.total_time ? secondsToDhms(value.total_time) : '-', diff --git a/plugins/firehydrant/src/components/ServiceDetailsCard/ServiceDetailsCard.tsx b/plugins/firehydrant/src/components/ServiceDetailsCard/ServiceDetailsCard.tsx index dba81fa88f..b70c578e11 100644 --- a/plugins/firehydrant/src/components/ServiceDetailsCard/ServiceDetailsCard.tsx +++ b/plugins/firehydrant/src/components/ServiceDetailsCard/ServiceDetailsCard.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ import React, { useEffect, useState } from 'react'; -import moment from 'moment'; +import { DateTime } from 'luxon'; import { ServiceAnalytics } from '../ServiceAnalytics/ServiceAnalytics'; import { Box, @@ -32,20 +32,22 @@ import { Incident } from '../types'; import { ServiceIncidentsResponse } from '../../api/types'; import { useServiceDetails } from '../serviceDetails'; import { useServiceAnalytics } from '../serviceAnalytics'; -import { InfoCard, Progress } from '@backstage/core-components'; +import { + InfoCard, + Link, + Progress, + ResponseErrorPanel, +} from '@backstage/core-components'; import { configApiRef, useApi } from '@backstage/core-plugin-api'; const useStyles = makeStyles(theme => ({ - infoCard: { - height: '342px', - }, button: { color: '#3b2492', display: 'grid', gridGap: '4px', textAlign: 'center', justifyItems: 'center', - width: '102px', + width: '105px', backgroundColor: theme.palette.type === 'dark' ? '#f1edff' : '', '&:hover, &:focus': { backgroundColor: '#f1edff', @@ -59,7 +61,8 @@ const useStyles = makeStyles(theme => ({ }, border: '1px solid #3b2492', borderRadius: '5px', - padding: '10px', + padding: '8px 10px', + textTransform: 'none', }, buttonLink: { backgroundColor: '#3b2492', @@ -101,28 +104,18 @@ const useStyles = makeStyles(theme => ({ alignItems: 'center', padding: '10px', background: '#f1edff', - color: theme.palette.type === 'dark' ? '#3b2492' : '#fff', + color: '#3b2492', }, })); -const ServiceWarning = ({ text }: { text: string }) => { - const classes = useStyles(); - return ( -
- -   {text} -
- ); -}; - const ServiceAnalyticsView = ({ serviceId, startDate, endDate, }: { serviceId: string; - startDate: moment.Moment; - endDate: moment.Moment; + startDate: DateTime; + endDate: DateTime; }) => { const { loading: analyticsLoading, @@ -130,8 +123,8 @@ const ServiceAnalyticsView = ({ error: analyticsError, } = useServiceAnalytics({ serviceId, - startDate: startDate.format('YYYY-MM-DD'), - endDate: endDate.format('YYYY-MM-DD'), + startDate: startDate.toFormat('YYYY-MM-DD'), + endDate: endDate.toFormat('YYYY-MM-DD'), }); return ( @@ -153,8 +146,8 @@ export const ServiceDetailsCard = () => { configApi.getOptionalString('firehydrant.baseUrl') || 'https://app.firehydrant.io'; - const startDate = moment().subtract(30, 'days').utc(); - const endDate = moment().utc(); + 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. @@ -181,9 +174,7 @@ export const ServiceDetailsCard = () => { } if (error) { - return ( - - ); + return ; } const headerText: string = showServiceDetails @@ -197,9 +188,12 @@ export const ServiceDetailsCard = () => { )},"value":${JSON.stringify(value?.service?.id)}}]}`; return ( - + {!showServiceDetails && !loading && ( - +
+ +   This service does not exist in FireHydrant. +
)} {showServiceDetails && ( { className={classes.buttonLink} color="default" href={serviceIncidentsLink} + startIcon={} target="_blank" variant="outlined" > - View service incidents   - + View service incidents @@ -230,9 +224,14 @@ export const ServiceDetailsCard = () => { {incidents && incidents?.slice(0, 5).map((incident: Incident, index: number) => ( ))} @@ -240,34 +239,55 @@ export const ServiceDetailsCard = () => { View in FireHydrant - - - Declare an incident - - + + + + + Declare an incident + + + + - - View all incidents - + + + + + + View all incidents + + + {showServiceDetails && ( - - - View Service Details - + + + + + + View Service Details + + + )} diff --git a/plugins/firehydrant/src/index.ts b/plugins/firehydrant/src/index.ts index 3b9b584fb8..465e42f1ae 100644 --- a/plugins/firehydrant/src/index.ts +++ b/plugins/firehydrant/src/index.ts @@ -13,4 +13,4 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export { firehydrantPlugin, FirehydrantPage } from './plugin'; +export { firehydrantPlugin, FirehydrantCard } from './plugin'; diff --git a/plugins/firehydrant/src/plugin.ts b/plugins/firehydrant/src/plugin.ts index e4794ab4ce..4277c1833f 100644 --- a/plugins/firehydrant/src/plugin.ts +++ b/plugins/firehydrant/src/plugin.ts @@ -18,7 +18,7 @@ import { createApiFactory, createPlugin, discoveryApiRef, - createRoutableExtension, + createComponentExtension, } from '@backstage/core-plugin-api'; import { rootRouteRef } from './routes'; @@ -37,10 +37,13 @@ export const firehydrantPlugin = createPlugin({ }, }); -export const FirehydrantPage = firehydrantPlugin.provide( - createRoutableExtension({ - component: () => - import('./components/ServiceDetailsCard').then(m => m.ServiceDetailsCard), - mountPoint: rootRouteRef, +export const FirehydrantCard = firehydrantPlugin.provide( + createComponentExtension({ + component: { + lazy: () => + import('./components/ServiceDetailsCard').then( + m => m.ServiceDetailsCard, + ), + }, }), );