diff --git a/.changeset/two-elephants-arrive.md b/.changeset/two-elephants-arrive.md new file mode 100644 index 0000000000..6a1e713602 --- /dev/null +++ b/.changeset/two-elephants-arrive.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-airbrake': minor +--- + +API connectivity has added, but currently will only work by running it standalone on a browser with CORS disabled. diff --git a/docs/assets/getting-started/sidebar-with-catalog.png b/docs/assets/getting-started/sidebar-with-catalog.png new file mode 100644 index 0000000000..d80346c211 Binary files /dev/null and b/docs/assets/getting-started/sidebar-with-catalog.png differ diff --git a/docs/assets/getting-started/sidebar-without-catalog.png b/docs/assets/getting-started/sidebar-without-catalog.png new file mode 100644 index 0000000000..bfeab13235 Binary files /dev/null and b/docs/assets/getting-started/sidebar-without-catalog.png differ diff --git a/docs/assets/getting-started/simple-homepage.png b/docs/assets/getting-started/simple-homepage.png new file mode 100644 index 0000000000..128707e9ff Binary files /dev/null and b/docs/assets/getting-started/simple-homepage.png differ diff --git a/docs/getting-started/app-custom-theme.md b/docs/getting-started/app-custom-theme.md index fc68008ae8..34eca2429a 100644 --- a/docs/getting-started/app-custom-theme.md +++ b/docs/getting-started/app-custom-theme.md @@ -257,68 +257,4 @@ const LogoFull = () => { ## Custom Homepage In addition to a custom theme, a custom logo, you can also customize the -homepage of your app. To do that we need to go through a few steps. - -### Setting up the Home Page - -1. Create a Home Page Component that will be used for composition. - -`packages/app/src/components/home/HomePage.tsx` - -```tsx -import React from 'react'; - -export const HomePage = () => { - return { - /* TODO: Compose a Home Page here */ - }; -}; -``` - -2. Add a route where the homepage will live, presumably `/`. - -`packages/app/src/App.tsx` - -```tsx -import { HomepageCompositionRoot } from '@backstage/plugin-home'; -import { HomePage } from './components/home/HomePage'; - -// ... -}> - -; -// ... -``` - -### Composing your Home Page - -Composing a Home Page is no different from creating a regular React Component, -i.e. the App Integrator is free to include whatever content they like. However, -there are components developed with the Home Page in mind. If you are looking -for components to use when composing your homepage, you can take a look at the -[collection of Homepage components](https://backstage.io/?path=/story/plugins-home-components) -in storybook. If you don't find a component that suits your needs but want to -contribute, check the -[Contributing documentation](https://github.com/backstage/backstage/blob/master/plugins/home/README.md#contributing). - -> If you want to use one of the available homepage templates you can find the -> [templates](https://backstage.io/storybook/?path=/story/plugins-home-templates) -> in the storybook under the "Home" plugin. And if you would like to contribute -> a template, please see the -> [Contributing documentation](https://github.com/backstage/backstage/blob/master/plugins/home/README.md#contributing) - -```tsx -import React from 'react'; -import Grid from '@material-ui/core/Grid'; -import { HomePageCompanyLogo } from '@backstage/plugin-home'; - -export const HomePage = () => { - return ( - - - - - - ); -}; -``` +homepage of your app. Read the full guide on the [next page](homepage.md). diff --git a/docs/getting-started/homepage.md b/docs/getting-started/homepage.md new file mode 100644 index 0000000000..b7f7b88c6f --- /dev/null +++ b/docs/getting-started/homepage.md @@ -0,0 +1,166 @@ +--- +id: homepage +title: Backstage homepage - Setup and Customization +description: Documentation on setting up and customizing Backstage homepage +--- + +## Homepage + +Having a good Backstage homepage can significantly improve the discoverability of the platform. You want your users to find all the things they need right from the homepage and never have to remember direct URLs in Backstage. The [Home plugin](https://github.com/backstage/backstage/tree/master/plugins/home) introduces a system for composing a homepage for Backstage in order to surface relevant info and provide convenient shortcuts for common tasks. It's designed with composability in mind with an open ecosystem that allows anyone to contribute with any component, to be included in any homepage. + +For App Integrators, the system is designed to be composable to give total freedom in designing a Homepage that suits the needs of the organization. From the perspective of a Component Developer who wishes to contribute with building blocks to be included in Homepages, there's a convenient interface for bundling the different parts and exporting them with both error boundary and lazy loading handled under the surface. + +At the end of this tutorial, you can expect: + +- Your Backstage app to have a dedicated homepage instead of Software Catalog. +- Understand the composability of homepage and how to start customizing it for your own organization. + +### Prerequisites + +Before we begin, make sure + +- You have created your own standalone Backstage app using [`@backstage/create-app`](index.md#create-your-backstage-app) and not using a fork of the [backstage](https://github.com/backstage/backstage) repository. +- You do not have an existing homepage, and by default you are redirected to Software Catalog when you open Backstage. + +Now, let's get started by installing the home plugin and creating a simple homepage for your Backstage app. + +### Setup homepage + +#### 1. Install the plugin + +``` +# From your Backstage application directory +cd packages/app +yarn add @backstage/plugin-home +``` + +#### 2. Create a new HomePage component + +Inside your `packages/app` directory, create a new file where our new homepage component is going to live. Create `packages/app/src/components/home/HomePage.tsx` with the following initial code + +```tsx +import React from 'react'; + +export const HomePage = () => { + /* We will shortly compose a pretty homepage here. */ + return

Welcome to Backstage!

; +}; +``` + +#### 3. Update router for the root `/` route + +If you don't have a homepage already, most likely you have a redirect setup to use the Catalog homepage as a homepage. + +Inside your `packages/app/src/App.tsx`, look for + +```tsx +const routes = ( + + +``` + +Let's replace the `` line and use the new component we created in the previous step as the new homepage. + +```diff +// File: packages/app/src/App.tsx + ++ import { HomepageCompositionRoot } from '@backstage/plugin-home'; ++ import { HomePage } from './components/home/HomePage'; + +// ... +const routes = ( + +- ++ }> ++ ++ +// ... + +) +``` + +### 4. Update sidebar items + +Let's update the route for "Home" in the Backstage sidebar to point to the new homepage. We'll also add a Sidebar item to quickly open Catalog. + + + + + + + + +
Sidebar without Catalog + Sidebar with Catalog +
BeforeAfter
+ +The code for the Backstage sidebar is most likely inside your [`packages/app/src/components/Root/Root.tsx`](https://github.com/backstage/backstage/blob/master/packages/app/src/components/Root/Root.tsx). + +Let's make the following changes + +```diff +// Other imports ++ import CategoryIcon from '@material-ui/icons/Category'; + +export const Root = ({ children }: PropsWithChildren<{}>) => ( + + + + # ... + }> + {/* Global nav, not org-specific */} +- ++ ++ + + + + + {/* End global nav */} + +``` + +That's it! You should now have _(although slightly boring)_ a homepage! + +Screenshot of a blank homepage + +In the next steps, we will make it interesting and useful! + +### Use the default template + +There is a default homepage template ([storybook link](https://backstage.io/storybook/?path=/story/plugins-home-templates--default-template)) which we will use to set up our homepage. Checkout the [blog post announcement](https://backstage.io/blog/2022/01/25/backstage-homepage-templates) about the Backstage homepage templates for more information. + + + +### Composing your homepage + +Composing a homepage is no different from creating a regular React Component, +i.e. the App Integrator is free to include whatever content they like. However, +there are components developed with the homepage in mind. If you are looking +for components to use when composing your homepage, you can take a look at the +[collection of Homepage components](https://backstage.io/?path=/story/plugins-home-components) +in storybook. If you don't find a component that suits your needs but want to +contribute, check the +[Contributing documentation](https://github.com/backstage/backstage/blob/master/plugins/home/README.md#contributing). + +> If you want to use one of the available homepage templates you can find the +> [templates](https://backstage.io/storybook/?path=/story/plugins-home-templates) +> in the storybook under the "Home" plugin. And if you would like to contribute +> a template, please see the +> [Contributing documentation](https://github.com/backstage/backstage/blob/master/plugins/home/README.md#contributing) + +```tsx +import React from 'react'; +import Grid from '@material-ui/core/Grid'; +import { HomePageCompanyLogo } from '@backstage/plugin-home'; + +export const HomePage = () => { + return ( + + + + + + ); +}; +``` diff --git a/microsite/blog/2022-01-25-backstage-homepage-templates.md b/microsite/blog/2022-01-25-backstage-homepage-templates.md index 7ed5ee92e3..8ca8b7373e 100644 --- a/microsite/blog/2022-01-25-backstage-homepage-templates.md +++ b/microsite/blog/2022-01-25-backstage-homepage-templates.md @@ -33,7 +33,7 @@ _Example of the default homepage template in Backstage Storybook_ ## How to get started -To get started, you first need to set up your homepage plugin in the app if you have not already, then you can move on to composing your homepage. You can find our step-by-step documentation of how to do this under the [App Configuration - Customize the look-and-feel of your App documentation](https://backstage.io/docs/getting-started/app-custom-theme#custom-homepage). You can also access the [Backstage UI Kit in Figma](https://www.figma.com/file/nUaAw56hTgC0RIOYkuLSrB/Backstage-Design-System?node-id=2185%3A2978) to duplicate your own version and play around with a fitting homepage for your own organization. +To get started, you first need to set up your homepage plugin in the app if you have not already, then you can move on to composing your homepage. You can find our step-by-step documentation of how to do this under the [App Configuration - Customize the look-and-feel of your App documentation](https://backstage.io/docs/getting-started/homepage). You can also access the [Backstage UI Kit in Figma](https://www.figma.com/file/nUaAw56hTgC0RIOYkuLSrB/Backstage-Design-System?node-id=2185%3A2978) to duplicate your own version and play around with a fitting homepage for your own organization. ![Example of homepage components in the Backstage UI Kit in Figma](assets/22-01-25/homepage-components-figma.png) _Example of the homepage components in the Backstage UI Kit in Figma_ diff --git a/microsite/data/plugins/airbrake.yaml b/microsite/data/plugins/airbrake.yaml index 96de75c002..29c1f394d7 100644 --- a/microsite/data/plugins/airbrake.yaml +++ b/microsite/data/plugins/airbrake.yaml @@ -4,6 +4,6 @@ author: Simply Business authorUrl: https://github.com/simplybusiness/ category: Monitoring description: Access Airbrake error monitoring and other integrations from within Backstage -documentation: https://github.com/backstage/backstage/blob/master/plugins/airbrake/README.md +documentation: https://github.com/backstage/backstage/blob/master/plugins/airbrake iconUrl: https://wp-assets.airbrake.io/wp-content/uploads/2020/10/05222904/Square-white-A-on-Orange.png npmPackageName: '@backstage/plugin-airbrake' diff --git a/microsite/sidebars.json b/microsite/sidebars.json index cb7c0d5036..5369182013 100644 --- a/microsite/sidebars.json +++ b/microsite/sidebars.json @@ -22,7 +22,8 @@ "label": "App configuration", "ids": [ "getting-started/configure-app-with-plugins", - "getting-started/app-custom-theme" + "getting-started/app-custom-theme", + "getting-started/homepage" ] }, "getting-started/keeping-backstage-updated", diff --git a/plugins/airbrake/dev/components/ApiBar/ApiBar.tsx b/plugins/airbrake/dev/components/ApiBar/ApiBar.tsx new file mode 100644 index 0000000000..eae00c2d32 --- /dev/null +++ b/plugins/airbrake/dev/components/ApiBar/ApiBar.tsx @@ -0,0 +1,85 @@ +/* + * Copyright 2022 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 { + createTheme, + makeStyles, + MuiThemeProvider, + TextField, +} from '@material-ui/core'; +import { Context } from '../ContextProvider'; + +const useStyles = makeStyles({ + root: { + display: 'flex', + gap: '1em', + flexWrap: 'wrap', + }, +}); + +const textFieldTheme = createTheme({ + palette: { + type: 'dark', + primary: { + light: '#fff', + main: '#fff', + dark: '#fff', + contrastText: '#fff', + }, + secondary: { + light: '#fff', + main: '#fff', + dark: '#fff', + contrastText: '#fff', + }, + action: { + disabled: '#fff', + }, + text: { + primary: '#fff', + secondary: '#fff', + }, + }, +}); + +export const ApiBar = () => { + const classes = useStyles(); + + return ( + + {value => ( +
+ + + value.setProjectId?.(parseInt(e.target.value, 10) || undefined) + } + /> + value.setApiKey?.(e.target.value)} + /> + +
+ )} +
+ ); +}; diff --git a/plugins/airbrake/dev/components/ApiBar/index.ts b/plugins/airbrake/dev/components/ApiBar/index.ts new file mode 100644 index 0000000000..ff6e80d3d8 --- /dev/null +++ b/plugins/airbrake/dev/components/ApiBar/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 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 { ApiBar } from './ApiBar'; diff --git a/plugins/airbrake/dev/components/ContextProvider/ContextProvider.tsx b/plugins/airbrake/dev/components/ContextProvider/ContextProvider.tsx new file mode 100644 index 0000000000..6d7fe0f7f2 --- /dev/null +++ b/plugins/airbrake/dev/components/ContextProvider/ContextProvider.tsx @@ -0,0 +1,48 @@ +/* + * Copyright 2022 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, { + Dispatch, + PropsWithChildren, + SetStateAction, + useState, +} from 'react'; + +interface ContextInterface { + projectId?: number; + setProjectId?: Dispatch>; + apiKey?: string; + setApiKey?: Dispatch>; +} + +export const Context = React.createContext({}); + +export const ContextProvider = ({ children }: PropsWithChildren<{}>) => { + const [projectId, setProjectId] = useState(); + const [apiKey, setApiKey] = useState(''); + + return ( + + {children} + + ); +}; diff --git a/plugins/airbrake/dev/components/ContextProvider/index.ts b/plugins/airbrake/dev/components/ContextProvider/index.ts new file mode 100644 index 0000000000..f1e350868a --- /dev/null +++ b/plugins/airbrake/dev/components/ContextProvider/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 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 { ContextProvider, Context } from './ContextProvider'; diff --git a/plugins/airbrake/dev/index.tsx b/plugins/airbrake/dev/index.tsx index e74ce68ee5..cd0d29301f 100644 --- a/plugins/airbrake/dev/index.tsx +++ b/plugins/airbrake/dev/index.tsx @@ -15,39 +15,67 @@ */ import React from 'react'; import { createDevApp } from '@backstage/dev-utils'; -import { EntityAirbrakeContent, airbrakePlugin } from '../src/plugin'; +import { TestApiProvider } from '@backstage/test-utils'; +import { airbrakePlugin, EntityAirbrakeContent } from '../src'; import { - Content, - ContentHeader, - Header, - HeaderLabel, - Page, - SupportButton, -} from '@backstage/core-components'; + airbrakeApiRef, + MockAirbrakeApi, + ProductionAirbrakeApi, +} from '../src/api'; +import { ApiBar } from './components/ApiBar'; +import { Content, Header, Page } from '@backstage/core-components'; +import { EntityProvider } from '@backstage/plugin-catalog-react'; +import { createEntity } from '../src/api/mock/MockEntity'; +import CloudOffIcon from '@material-ui/icons/CloudOff'; +import CloudIcon from '@material-ui/icons/Cloud'; +import { Context, ContextProvider } from './components/ContextProvider'; createDevApp() .registerPlugin(airbrakePlugin) .addPage({ element: ( -
- - -
+
- - - A description of your plugin goes here. - - - + + + + + ), - title: 'Root Page', - path: '/airbrake', + title: 'Mock API', + path: '/airbrake-mock-api', + icon: CloudOffIcon, + }) + .addPage({ + element: ( + + +
+ +
+ + + {value => ( + + + + + + )} + + +
+
+ ), + title: 'Real API', + path: '/airbrake-real-api', + icon: CloudIcon, }) .render(); diff --git a/plugins/airbrake/package.json b/plugins/airbrake/package.json index 40b4787dd5..a914013d77 100644 --- a/plugins/airbrake/package.json +++ b/plugins/airbrake/package.json @@ -20,20 +20,23 @@ "clean": "backstage-cli clean" }, "dependencies": { + "@backstage/catalog-model": "^0.9.10", "@backstage/core-components": "^0.8.8", "@backstage/core-plugin-api": "^0.6.0", + "@backstage/dev-utils": "^0.2.21", + "@backstage/plugin-catalog-react": "^0.6.14", + "@backstage/test-utils": "^0.2.4", "@backstage/theme": "^0.2.14", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", - "react-use": "^17.2.4", - "object-hash": "^2.2.0" + "object-hash": "^2.2.0", + "react-use": "^17.2.4" }, "peerDependencies": { "react": "^16.13.1 || ^17.0.0" }, "devDependencies": { - "@types/object-hash": "^2.2.1", "@backstage/app-defaults": "^0.1.7", "@backstage/cli": "^0.13.2", "@backstage/core-app-api": "^0.5.2", @@ -44,8 +47,9 @@ "@testing-library/user-event": "^13.1.8", "@types/jest": "^26.0.7", "@types/node": "^14.14.32", - "msw": "^0.35.0", + "@types/object-hash": "^2.2.1", "cross-fetch": "^3.1.5", + "msw": "^0.35.0", "react-router": "6.0.0-beta.0" }, "files": [ diff --git a/plugins/airbrake/src/EntityAirbrakeContent.test.tsx b/plugins/airbrake/src/api/AirbrakeApi.ts similarity index 57% rename from plugins/airbrake/src/EntityAirbrakeContent.test.tsx rename to plugins/airbrake/src/api/AirbrakeApi.ts index f0afef425f..a29aa3eca7 100644 --- a/plugins/airbrake/src/EntityAirbrakeContent.test.tsx +++ b/plugins/airbrake/src/api/AirbrakeApi.ts @@ -1,5 +1,5 @@ /* - * Copyright 2021 The Backstage Authors + * Copyright 2020 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. @@ -13,13 +13,14 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React from 'react'; -import { EntityAirbrakeContent } from './plugin'; -import { renderInTestApp } from '@backstage/test-utils'; -describe('The Airbrake entity', () => { - it('should render the content properly', async () => { - const rendered = await renderInTestApp(); - expect(rendered.getByText('ChunkLoadError')).toBeInTheDocument(); - }); +import { Groups } from './airbrakeGroups'; +import { createApiRef } from '@backstage/core-plugin-api'; + +export const airbrakeApiRef = createApiRef({ + id: 'plugin.airbrake.service', }); + +export interface AirbrakeApi { + fetchGroups(projectId: string): Promise; +} diff --git a/plugins/airbrake/src/api/ProductionApi.test.ts b/plugins/airbrake/src/api/ProductionApi.test.ts new file mode 100644 index 0000000000..04bb73caf7 --- /dev/null +++ b/plugins/airbrake/src/api/ProductionApi.test.ts @@ -0,0 +1,58 @@ +/* + * Copyright 2022 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 { ProductionAirbrakeApi } from './ProductionApi'; +import { rest } from 'msw'; +import mockGroupsData from './mock/airbrakeGroupsApiMock.json'; +import { setupServer } from 'msw/node'; +import { setupRequestMockHandlers } from '@backstage/test-utils'; + +describe('The production Airbrake API', () => { + const productionApi = new ProductionAirbrakeApi('fakeApiKey'); + const worker = setupServer(); + setupRequestMockHandlers(worker); + + it('fetches groups using the provided project ID', async () => { + worker.use( + rest.get( + 'https://api.airbrake.io/api/v4/projects/123456/groups', + (req, res, ctx) => { + if (req.url.searchParams.get('key') === 'fakeApiKey') { + return res(ctx.status(200), ctx.json(mockGroupsData)); + } + return res(ctx.status(401)); + }, + ), + ); + + const groups = await productionApi.fetchGroups('123456'); + + expect(groups).toStrictEqual(mockGroupsData); + }); + + it('throws if fetching groups was unsuccessful', async () => { + worker.use( + rest.get( + 'https://api.airbrake.io/api/v4/projects/123456/groups', + (_, res, ctx) => { + return res(ctx.status(500)); + }, + ), + ); + + await expect(productionApi.fetchGroups('123456')).rejects.toThrow(); + }); +}); diff --git a/plugins/airbrake/src/components/EntityAirbrakeContent/EntityAirbrakeContent.test.tsx b/plugins/airbrake/src/api/ProductionApi.ts similarity index 51% rename from plugins/airbrake/src/components/EntityAirbrakeContent/EntityAirbrakeContent.test.tsx rename to plugins/airbrake/src/api/ProductionApi.ts index a621eb281c..76416bc173 100644 --- a/plugins/airbrake/src/components/EntityAirbrakeContent/EntityAirbrakeContent.test.tsx +++ b/plugins/airbrake/src/api/ProductionApi.ts @@ -14,19 +14,21 @@ * limitations under the License. */ -import React from 'react'; -import { EntityAirbrakeContent } from './EntityAirbrakeContent'; -import exampleData from './example-data.json'; -import { renderInTestApp } from '@backstage/test-utils'; +import { Groups } from './airbrakeGroups'; +import { AirbrakeApi } from './AirbrakeApi'; -describe('EntityAirbrakeContent', () => { - it('renders all errors sent from Airbrake', async () => { - const table = await renderInTestApp(); - expect(exampleData.groups.length).toBeGreaterThan(0); - for (const group of exampleData.groups) { - expect( - await table.getByText(group.errors[0].message), - ).toBeInTheDocument(); +export class ProductionAirbrakeApi implements AirbrakeApi { + constructor(private readonly apiKey?: string) {} + + async fetchGroups(projectId: string): Promise { + const apiUrl = `https://api.airbrake.io/api/v4/projects/${projectId}/groups?key=${this.apiKey}`; + + const response = await fetch(apiUrl); + + if (response.status >= 400 && response.status < 600) { + throw new Error('Failed fetching Airbrake groups'); } - }); -}); + + return (await response.json()) as Groups; + } +} diff --git a/plugins/airbrake/src/api/airbrakeGroups.d.ts b/plugins/airbrake/src/api/airbrakeGroups.d.ts new file mode 100644 index 0000000000..e1887eca9b --- /dev/null +++ b/plugins/airbrake/src/api/airbrakeGroups.d.ts @@ -0,0 +1,65 @@ +/* + * Copyright 2022 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 interface Groups { + count: number; + end: string; + groups?: Group[] | null; + page: number; + resolvedCount: number; + unresolvedCount: number; +} + +export interface Group { + id: string; + projectId: number; + resolved: boolean; + muted: boolean; + mutedBy: number; + mutedAt?: string | null; + errors?: Error[]; + attributes?: string | null; + context: Context; + lastDeployId: string; + lastDeployAt?: string | null; + lastNoticeId: string; + lastNoticeAt: string; + noticeCount: number; + noticeTotalCount: number; + commentCount: number; + createdAt: string; +} + +export interface Error { + type: string; + message: string; + backtrace?: Backtrace[]; +} + +export interface Backtrace { + file: string; + function: string; + line: number; + column: number; + code?: string | null; +} + +export interface Context { + action: string; + component: string; + environment: string; + severity: string; +} diff --git a/plugins/airbrake/src/api/index.ts b/plugins/airbrake/src/api/index.ts new file mode 100644 index 0000000000..2d92789653 --- /dev/null +++ b/plugins/airbrake/src/api/index.ts @@ -0,0 +1,21 @@ +/* + * Copyright 2020 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 * from './mock'; +export type { AirbrakeApi } from './AirbrakeApi'; +export { airbrakeApiRef } from './AirbrakeApi'; +export type { Groups } from './airbrakeGroups'; +export { ProductionAirbrakeApi } from './ProductionApi'; diff --git a/plugins/airbrake/src/api/mock/MockApi.ts b/plugins/airbrake/src/api/mock/MockApi.ts new file mode 100644 index 0000000000..dbb1d5c049 --- /dev/null +++ b/plugins/airbrake/src/api/mock/MockApi.ts @@ -0,0 +1,33 @@ +/* + * Copyright 2020 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 { Groups } from '../airbrakeGroups'; +import { AirbrakeApi } from '../AirbrakeApi'; +import mockGroupsData from './airbrakeGroupsApiMock.json'; + +export class MockAirbrakeApi implements AirbrakeApi { + waitTimeInMillis: number; + + constructor(waitTimeInMillis = 10) { + this.waitTimeInMillis = waitTimeInMillis; + } + + fetchGroups(): Promise { + return new Promise(resolve => { + setTimeout(() => resolve(mockGroupsData), this.waitTimeInMillis); + }); + } +} diff --git a/plugins/airbrake/src/api/mock/MockEntity.ts b/plugins/airbrake/src/api/mock/MockEntity.ts new file mode 100644 index 0000000000..13534f2458 --- /dev/null +++ b/plugins/airbrake/src/api/mock/MockEntity.ts @@ -0,0 +1,32 @@ +/* + * Copyright 2022 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 { AIRBRAKE_PROJECT_ID_ANNOTATION } from '../../components/useProjectId'; +import { Entity } from '@backstage/catalog-model'; + +export const createEntity = (projectId?: number) => { + const projectIdString = projectId?.toString(); + + return { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + annotations: { + [AIRBRAKE_PROJECT_ID_ANNOTATION]: projectIdString, + }, + name: projectIdString, + }, + } as Entity; +}; diff --git a/plugins/airbrake/src/components/EntityAirbrakeContent/example-data.json b/plugins/airbrake/src/api/mock/airbrakeGroupsApiMock.json similarity index 100% rename from plugins/airbrake/src/components/EntityAirbrakeContent/example-data.json rename to plugins/airbrake/src/api/mock/airbrakeGroupsApiMock.json diff --git a/plugins/airbrake/src/api/mock/index.ts b/plugins/airbrake/src/api/mock/index.ts new file mode 100644 index 0000000000..0588d45fa6 --- /dev/null +++ b/plugins/airbrake/src/api/mock/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 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 { MockAirbrakeApi } from './MockApi'; diff --git a/plugins/airbrake/src/components/EntityAirbrakeContent/EntityAirbrakeContent.tsx b/plugins/airbrake/src/components/EntityAirbrakeContent/EntityAirbrakeContent.tsx deleted file mode 100644 index 062d9e898a..0000000000 --- a/plugins/airbrake/src/components/EntityAirbrakeContent/EntityAirbrakeContent.tsx +++ /dev/null @@ -1,48 +0,0 @@ -/* - * 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 { Grid, Typography } from '@material-ui/core'; -import { InfoCard } from '@backstage/core-components'; -import exampleData from './example-data.json'; -import hash from 'object-hash'; -import { makeStyles } from '@material-ui/core/styles'; -import { BackstageTheme } from '@backstage/theme'; - -const useStyles = makeStyles(() => ({ - multilineText: { - whiteSpace: 'pre-wrap', - }, -})); - -export const EntityAirbrakeContent = () => { - const classes = useStyles(); - - return ( - - {exampleData.groups.map(group => ( - - {group.errors.map(error => ( - - - {error.message} - - - ))} - - ))} - - ); -}; diff --git a/plugins/airbrake/src/components/EntityAirbrakeWidget/EntityAirbrakeWidget.test.tsx b/plugins/airbrake/src/components/EntityAirbrakeWidget/EntityAirbrakeWidget.test.tsx new file mode 100644 index 0000000000..ef1ac229c9 --- /dev/null +++ b/plugins/airbrake/src/components/EntityAirbrakeWidget/EntityAirbrakeWidget.test.tsx @@ -0,0 +1,95 @@ +/* + * Copyright 2020 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 { EntityAirbrakeWidget } from './EntityAirbrakeWidget'; +import exampleData from '../../api/mock/airbrakeGroupsApiMock.json'; +import { + MockErrorApi, + renderInTestApp, + setupRequestMockHandlers, + TestApiProvider, +} from '@backstage/test-utils'; +import { createEntity } from '../../api/mock/MockEntity'; +import { + airbrakeApiRef, + MockAirbrakeApi, + ProductionAirbrakeApi, +} from '../../api'; +import { errorApiRef } from '@backstage/core-plugin-api'; +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; + +describe('EntityAirbrakeContent', () => { + const worker = setupServer(); + setupRequestMockHandlers(worker); + + it('renders all errors sent from Airbrake', async () => { + const widget = await renderInTestApp( + + + , + ); + expect(exampleData.groups.length).toBeGreaterThan(0); + for (const group of exampleData.groups) { + expect( + await widget.getByText(group.errors[0].message), + ).toBeInTheDocument(); + } + }); + + it('states that the annotation is missing if no project ID annotation is provided', async () => { + const widget = await renderInTestApp( + + + , + ); + await expect( + widget.findByText('Missing Annotation'), + ).resolves.toBeInTheDocument(); + }); + + it('states that an error occurred if the API call fails', async () => { + worker.use( + rest.get( + 'https://api.airbrake.io/api/v4/projects/123/groups', + (_, res, ctx) => { + return res(ctx.status(500)); + }, + ), + ); + const mockErrorApi = new MockErrorApi({ collect: true }); + + const widget = await renderInTestApp( + + + , + ); + + await expect( + widget.findByText(/.*there was an issue communicating with Airbrake.*/), + ).resolves.toBeInTheDocument(); + expect(mockErrorApi.getErrors().length).toBe(1); + expect(mockErrorApi.getErrors()[0].error.message).toStrictEqual( + 'Failed fetching Airbrake groups', + ); + }); +}); diff --git a/plugins/airbrake/src/components/EntityAirbrakeWidget/EntityAirbrakeWidget.tsx b/plugins/airbrake/src/components/EntityAirbrakeWidget/EntityAirbrakeWidget.tsx new file mode 100644 index 0000000000..b11116aeef --- /dev/null +++ b/plugins/airbrake/src/components/EntityAirbrakeWidget/EntityAirbrakeWidget.tsx @@ -0,0 +1,94 @@ +/* + * 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 { Entity } from '@backstage/catalog-model'; +import React, { useEffect } from 'react'; +import { Grid, Typography } from '@material-ui/core'; +import { + EmptyState, + InfoCard, + MissingAnnotationEmptyState, + Progress, +} from '@backstage/core-components'; +import hash from 'object-hash'; +import { makeStyles } from '@material-ui/core/styles'; +import { BackstageTheme } from '@backstage/theme'; +import { ErrorApi, errorApiRef, useApi } from '@backstage/core-plugin-api'; +import { airbrakeApiRef } from '../../api'; +import useAsync from 'react-use/lib/useAsync'; +import { AIRBRAKE_PROJECT_ID_ANNOTATION, useProjectId } from '../useProjectId'; + +const useStyles = makeStyles(() => ({ + multilineText: { + whiteSpace: 'pre-wrap', + }, +})); + +export const EntityAirbrakeWidget = ({ entity }: { entity: Entity }) => { + const classes = useStyles(); + + const projectId = useProjectId(entity); + const errorApi = useApi(errorApiRef); + const airbrakeApi = useApi(airbrakeApiRef); + + const { loading, value, error } = useAsync( + () => airbrakeApi.fetchGroups(projectId), + [airbrakeApi, projectId], + ); + + useEffect(() => { + if (error) { + errorApi.post(error); + } + }, [error, errorApi]); + + if (loading || !projectId || error) { + return ( + + {loading && } + + {!loading && !projectId && ( + + )} + + {!loading && error && ( + + )} + + ); + } + + return ( + + {value?.groups?.map(group => ( + + {group.errors?.map(groupError => ( + + + {groupError.message} + + + ))} + + ))} + + ); +}; diff --git a/plugins/airbrake/src/components/EntityAirbrakeWidget/index.ts b/plugins/airbrake/src/components/EntityAirbrakeWidget/index.ts new file mode 100644 index 0000000000..0a85263c5f --- /dev/null +++ b/plugins/airbrake/src/components/EntityAirbrakeWidget/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 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 { EntityAirbrakeWidget } from './EntityAirbrakeWidget'; diff --git a/plugins/airbrake/src/components/useProjectId.ts b/plugins/airbrake/src/components/useProjectId.ts new file mode 100644 index 0000000000..3402778b7b --- /dev/null +++ b/plugins/airbrake/src/components/useProjectId.ts @@ -0,0 +1,23 @@ +/* + * Copyright 2020 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 { Entity } from '@backstage/catalog-model'; + +export const AIRBRAKE_PROJECT_ID_ANNOTATION = 'airbrake.io/project-id'; + +export const useProjectId = (entity: Entity) => { + return entity?.metadata.annotations?.[AIRBRAKE_PROJECT_ID_ANNOTATION] ?? ''; +}; diff --git a/plugins/airbrake/src/extensions.test.tsx b/plugins/airbrake/src/extensions.test.tsx new file mode 100644 index 0000000000..d677d939a4 --- /dev/null +++ b/plugins/airbrake/src/extensions.test.tsx @@ -0,0 +1,37 @@ +/* + * 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 { EntityAirbrakeContent } from './extensions'; +import { Route } from 'react-router'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; +import { airbrakeApiRef, MockAirbrakeApi } from './api'; +import { createEntity } from './api/mock/MockEntity'; +import { EntityProvider } from '@backstage/plugin-catalog-react'; + +describe('The Airbrake entity', () => { + it('should render the content properly', async () => { + const rendered = await renderInTestApp( + + + } /> + + , + ); + await expect( + rendered.findByText('ChunkLoadError'), + ).resolves.toBeInTheDocument(); + }); +}); diff --git a/plugins/airbrake/src/extensions.tsx b/plugins/airbrake/src/extensions.tsx new file mode 100644 index 0000000000..6fbea68409 --- /dev/null +++ b/plugins/airbrake/src/extensions.tsx @@ -0,0 +1,37 @@ +/* + * Copyright 2020 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 { useEntity } from '@backstage/plugin-catalog-react'; +import React from 'react'; +import { airbrakePlugin } from './plugin'; +import { createRoutableExtension } from '@backstage/core-plugin-api'; +import { rootRouteRef } from './routes'; + +export const EntityAirbrakeContent = airbrakePlugin.provide( + createRoutableExtension({ + name: 'EntityAirbrakeContent', + mountPoint: rootRouteRef, + component: () => + import('./components/EntityAirbrakeWidget').then( + ({ EntityAirbrakeWidget }) => { + return () => { + const { entity } = useEntity(); + return ; + }; + }, + ), + }), +); diff --git a/plugins/airbrake/src/index.ts b/plugins/airbrake/src/index.ts index 8f319e02fd..d5aedd9ba6 100644 --- a/plugins/airbrake/src/index.ts +++ b/plugins/airbrake/src/index.ts @@ -13,4 +13,5 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export { airbrakePlugin, EntityAirbrakeContent } from './plugin'; +export { airbrakePlugin } from './plugin'; +export { EntityAirbrakeContent } from './extensions'; diff --git a/plugins/airbrake/src/plugin.test.ts b/plugins/airbrake/src/plugin.test.ts index c8e9c30e83..ad7865713f 100644 --- a/plugins/airbrake/src/plugin.test.ts +++ b/plugins/airbrake/src/plugin.test.ts @@ -15,9 +15,16 @@ */ import { airbrakePlugin } from './plugin'; +import { ProductionAirbrakeApi } from './api'; describe('catalog', () => { it('should export plugin', () => { expect(airbrakePlugin).toBeDefined(); }); + + it('should have at least one API, the production API', () => { + const apiFactories = Array.from(airbrakePlugin.getApis()); + expect(apiFactories.length).toBe(1); + expect(apiFactories[0].factory({})).toBeInstanceOf(ProductionAirbrakeApi); + }); }); diff --git a/plugins/airbrake/src/plugin.ts b/plugins/airbrake/src/plugin.ts index 0a3679baa4..f008b48ad4 100644 --- a/plugins/airbrake/src/plugin.ts +++ b/plugins/airbrake/src/plugin.ts @@ -13,27 +13,21 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { - createPlugin, - createRoutableExtension, -} from '@backstage/core-plugin-api'; +import { createApiFactory, createPlugin } from '@backstage/core-plugin-api'; import { rootRouteRef } from './routes'; +import { airbrakeApiRef, ProductionAirbrakeApi } from './api'; export const airbrakePlugin = createPlugin({ id: 'airbrake', + apis: [ + createApiFactory({ + api: airbrakeApiRef, + deps: {}, + factory: () => new ProductionAirbrakeApi(), + }), + ], routes: { root: rootRouteRef, }, }); - -export const EntityAirbrakeContent = airbrakePlugin.provide( - createRoutableExtension({ - name: 'EntityAirbrakeContent', - component: () => - import('./components/EntityAirbrakeContent/EntityAirbrakeContent').then( - m => m.EntityAirbrakeContent, - ), - mountPoint: rootRouteRef, - }), -);