Create Backstage Frontend Plugin¶
Let's start with creating Backstage Frontend Plugin.
I'm going to write how I created my plugin. This is based on the official guide and a Medium blog post (series). So, this is not something I invented and there likely isn't anything new here.
The idea here is that I write down my experience to serve as my long term memory.
The prior art are these two resources:
Steps¶
The steps we'll take, are the following:
- Initialize a new plugin with Yarn
- Verify the plugin works within Backstage
- Change the code to work as a TAB for Catalog Entities
- Abstract the Data provider for the Frontend via an API and Interface
- Change the code to call the Backend
- Build and publish it as a standalone NPMJS package
Init Frontend Plugin¶
To get started, get to your Backstage code copy. Make sure you're at the root of the code base.
Example
The root directly should be like this:
.
├── README.md
├── app-config.local.yaml
├── app-config.production.yaml
├── app-config.yaml
├── backstage.json
├── catalog-info.yaml
├── dist-types
├── environment.sh
├── examples
├── lerna.json
├── node_modules
├── package.json
├── packages
├── playwright.config.ts
├── plugins
├── tsconfig.json
└── yarn.lock
To create the plugin, we run the yarn new
command:
This generates your plugin, which is now located in the folder ./plugins/<nameOfPlugin>
.
The initial response should be something like this:
? Enter the ID of the plugin [required] hello
Creating frontend plugin @internal/plugin-hello
Checking Prerequisites:
availability plugins/hello ✔
creating temp dir ✔
Executing Template:
templating package.json.hbs ✔
templating README.md.hbs ✔
copying .eslintrc.js ✔
templating index.tsx.hbs ✔
templating index.ts.hbs ✔
templating plugin.test.ts.hbs ✔
templating routes.ts.hbs ✔
templating plugin.ts.hbs ✔
copying setupTests.ts ✔
templating ExampleComponent.test.tsx.hbs ✔
templating ExampleComponent.tsx.hbs ✔
copying index.ts ✔
templating ExampleFetchComponent.test.tsx.hbs ✔
templating ExampleFetchComponent.tsx.hbs ✔
copying index.ts ✔
Installing:
moving plugins/hello ✔
app adding dependency ✔
app adding import ✔
executing yarn install ✔
executing yarn lint --fix ✔
🎉 Successfully created plugin
Verify The Plugin Works¶
Unlike the backend plugin, we do not need to manually add the frontend plugin to backstage.
In the root of the Backstage project, start up a Dev instance:
This should startup the Backstage Frontend and Backend servers, and open the frontend in a browser.
Navigate to your newly created plugin via: http://localhost:3000/<myPluginName>
.
For example, localhost:3000/hello.
There is some placeholder content with a User List.
You can also run the plugin in isolation via the yarn workspace
command:
Change Plugin to Software Catalog Entry Tab¶
We'll make the following changes, as derived from John Tucker's blog posts, Part 1 and Part II.
- Strip unused code
- Remove the plugin from Backstage frontend's Routing configuration
- Import the plugin in the Catalog's Entity page
- Adhere to naming conventions
- Change our Plugin from a Page to a content Tab
All the changes we'll make below, are assumed to be in the folder of the plugin we created. For the sake of keeping to the code I know works, I'll assume the plugin is named Hello
.
Strip Unused Code¶
The first thing we're going to do, is strip the plugin to the minimal we can.
Which of course mean we'll print Hello World!
and nothing else.
We do so by changing the contents of the ExampleComponent that is autogenerated.
ExampleComponent/ExampleComponent.tsx
There's a test file for the original contents. We can remove this, src/components/ExampleComponent/ExampleComponent.test.tsx
, as creating the tests for our plugin's Components is out of scope.
If you run yarn dev
and navigate to our plugin, you'll see it now prints Hello World!
and nothing else.
Remove From Routing¶
To be clear, this is in the Backstage source code itself.
Remove the import
and the Route
entry related to our plugin from the App.tsx
:
App.tsx
Import Into Entity Page¶
We essentially do a reverse move here.
Update EntityPage.tsx
by adding the import line we just removed, and add a EntityLayout.Route
entry.
EntityPage.tsx
To be able to view our page now, we need at least a single Software Catalog Entity registered. If you do not have one on hand, John Tucker has one on GitHub.
If we start our Backstage again via yarn dev
, navigate to a Software Catalog Entity (via the Home page), you should see your Tab!
Adhere to Naming Conventions¶
Now that we are building a UI for a tab within a Backstage Component (Entity), it appears that the convention is that we need to rename our React Component from MyPluginPage to EntityMyPluginContent. - John Tucker
Fair enough. Let's update our plugin's naming.
And just as I am copying from [John Tucker]'s blog posts, he was inspired by the Kubernetes plugin. It's always good to read more than one reference.
So, what do we do? We need to rename some components in the following files in our plugin:
src/routes.ts
src/plugin.ts
src/index.ts
dev/index.ts
And then update the import and usage in Backstage's "EntityPage.tsx"
plugin.ts
import { rootCatalogHelloRouteRef } from './routes';
export const EntityHelloContent = helloPlugin.provide(
createRoutableExtension({
name: 'EntityHelloContent',
component: () =>
import('./components/ExampleComponent').then(m => m.ExampleComponent),
mountPoint: rootCatalogHelloRouteRef,
}),
);
dev/index.ts
EntityPage.tsx
Change Into A TAB¶
We've changed our plugin to be a Entity content Tab, rather than a Page.
However, when we run the plugin in Dev mode it registering itself as a Page.
Let's update the src/dev/index.ts
to register itself as a Entity Content Tab instead:
dev/index.ts
We need to import Entity and EntityProvider:
import { Entity } from '@backstage/catalog-model';
import { EntityProvider } from '@backstage/plugin-catalog-react';
As we don't have the full Backstage available in this mode, we also have no content. In order to view our plugin as it should be, we create a Mock entity:
const mockEntity: Entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'backstage',
description: 'backstage.io',
annotations: {
'backstage.io/kubernetes-id': 'dice-roller',
},
},
spec: {
lifecycle: 'production',
type: 'service',
owner: 'user:guest',
},
};
And then wrap our plugin's Content in an EntityProvider
element and feed it our Mock:
Full Example
import React from 'react';
import { Entity } from '@backstage/catalog-model';
import { EntityProvider } from '@backstage/plugin-catalog-react';
import { createDevApp } from '@backstage/dev-utils';
import { helloPlugin, EntityHelloContent } from '../src/plugin';
const mockEntity: Entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'backstage',
description: 'backstage.io',
annotations: {
'backstage.io/kubernetes-id': 'dice-roller',
},
},
spec: {
lifecycle: 'production',
type: 'service',
owner: 'user:guest',
},
};
createDevApp()
.registerPlugin(helloPlugin)
.addPage({
path: '/fixture-1',
title: 'Fixture 1',
element: (
<EntityProvider entity={mockEntity}>
<EntityHelloContent />
</EntityProvider>
),
})
.render();
Use Catalog Entity Data¶
It is great that our plugin is now a proper Tab in the Catalog Entity screen.
But, we're not using any information of this entity.
Let's change that, so that we can say "Hello
Enter the folder of the plugin, and add the plugin-catalog-react
package via yarn:
We then update our Component, ExampleComponent.tsx
, to use the name of the Entity (and the appropriate imports):
ExampleComponent.tsx
```typescript title="src/components/ExampleComponent/ExampleComponent.tsx import React from 'react'; import { useEntity } from '@backstage/plugin-catalog-react';
export const ExampleComponent = () => { const { entity } = useEntity(); return
Our plugin now says Hello to the Entity your are visiting.
Create API/Interface¶
As noted by John Tucker in is blog post, the Kubernetes plugin has created an Interface (which he calls an API) for the Frontend plugin to talk.
This means we can supply a Mock Client as alternative implementation during local development.
This way we do not depend on the Backend (plugin) to be available and returning data.
We start by defining the interface
that represents our API:
api/types.ts
To ensure we can use these types elsewhere, we import and export them via the index.ts
in the same (sub) folder:
api/index.ts
We do the same from the root of the plugin, again in an index.ts
:
index.ts
To ensure we can still run the plugin in isolation, we implement a Mock version we use when we run the plugin in Dev mode.
We do so via the dev
folder.
As this implementation also contains JSX code, the extension is now .tsx
instead of .ts
.
There are two notable changes:
- we create a
MockHelloClient
that implments theHelloApi
interface - We wrap our
EntityProvider
in aTestApiProvider
so it retrieve data from our Mock client
dev/index.tsx
import React from 'react';
import { Entity } from '@backstage/catalog-model';
import { EntityProvider } from '@backstage/plugin-catalog-react';
import { createDevApp } from '@backstage/dev-utils';
import { helloPlugin, EntityHelloContent } from '../src/plugin';
import { HelloApi, helloApiRef } from '../src';
import { TestApiProvider } from '@backstage/test-utils';
const mockEntity: Entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'backstage',
description: 'backstage.io',
annotations: {
'backstage.io/kubernetes-id': 'dice-roller',
},
},
spec: {
lifecycle: 'production',
type: 'service',
owner: 'user:guest',
},
};
class MockHelloClient implements HelloApi {
async getHealth(): Promise<{ status: string; }> {
return { status: 'ok'};
}
}
createDevApp()
.registerPlugin(helloPlugin)
.addPage({
path: '/fixture-1',
title: 'Fixture 1',
element: (
<TestApiProvider apis={[[helloApiRef, new MockHelloClient()]]}>
<EntityProvider entity={mockEntity}>
<EntityHelloContent />
</EntityProvider>
</TestApiProvider>
),
})
.render();
In order to make the Mock implementation available, we need to make two more changes.
- Create a React Hook
- Use the exported object of this hook in our React Component
hooks/useHelloPluginObjects.ts
import { useEffect, useState } from 'react';
import { useApi } from '@backstage/core-plugin-api';
import { helloApiRef } from '../api/types';
export const useHelloObjects = () => {
const [loading, setLoading] = useState<boolean>(true);
const [status, setStatus] = useState<string>('');
const [error, setError] = useState<boolean>(false);
const helloApi = useApi(helloApiRef);
const getObjects = async () => {
try {
const health = await helloApi.getHealth();
setStatus(health.status);
} catch (e) {
setError(true);
} finally {
setLoading(false);
}
}
useEffect(() => {
getObjects();
});
return {
error,
loading,
status,
}
}
Next, we update our Component.
Our Component now has data injected from the React Hook, which is taking care of the Client for us.
ExampleComponent.tsx
import React from 'react';
import { useEntity } from '@backstage/plugin-catalog-react';
import { useHelloObjects } from '../../hooks/useHelloPluginObjects'
export const ExampleComponent = () => {
const { entity } = useEntity();
const { error, loading, status } = useHelloObjects();
if (loading) {
return <div>Loading</div>;
}
if (error) {
return <div>Error</div>;
}
return (<>
<div>Hello {entity.metadata.name}</div>
<div>Status: {status}</div>
</>);
}
Great work so far, we now have Frontend plugin that can display data from both an (Catalog) Entity and a Backend.
We're not calling the Backend so far, only in Dev mode do we have data.
Next up, let's call our Backend plugin.
Call Actual Backend¶
We now add a Backend Client, and then update our Plugin to create this client so it retrieves the data from the Backend.
We do this by creating a new file, the client, and updating our plugin.ts
.
BackendClient
import { HelloApi } from './types';
import { DiscoveryApi } from '@backstage/core-plugin-api';
export class HelloBackendClient implements HelloApi {
private readonly discoveryApi: DiscoveryApi;
constructor( options: { discoveryApi: DiscoveryApi; } ) {
this.discoveryApi = options.discoveryApi;
}
private async handleResponse(response: Response): Promise<any> {
if (!response.ok) {
throw new Error();
}
return await response.json();
}
async getHealth(): Promise<{ status: string; }> {
const url = `${await this.discoveryApi.getBaseUrl('hello')}/health`;
const response = await fetch(url, {
method: 'GET',
});
return await this.handleResponse(response);
}
}
And the final piece of code, the updated plugin.ts
:
Plugin.ts
import {
createPlugin,
createRoutableExtension,
createApiFactory,
discoveryApiRef
} from '@backstage/core-plugin-api';
import { rootCatalogHelloRouteRef } from './routes';
import { HelloBackendClient } from './api/HelloBackendClient';
import { helloApiRef } from './api';
export const helloPlugin = createPlugin({
id: 'hello',
apis: [
createApiFactory({
api: helloApiRef,
deps: { discoveryApi: discoveryApiRef },
factory: ({ discoveryApi }) => new HelloBackendClient({ discoveryApi }),
}),
],
routes: {
root: rootCatalogHelloRouteRef,
},
});
export const EntityHelloContent = helloPlugin.provide(
createRoutableExtension({
name: 'EntityHelloContent',
component: () =>
import('./components/ExampleComponent').then(m => m.ExampleComponent),
mountPoint: rootCatalogHelloRouteRef,
}),
);
Build and Publish to NPMSJS¶
Before we can wrap it up entirely, we need to publish our frontend plugin.
To do so, we need to compile the TypeScript and build a NPM package we can publish.
Build Package¶
To compile the type script, we go to the root of the Backstage project, and run the following:
If there are any errors, which there shouldn't, resolve them before continuing.
We can then build the package. First, go back to the plugin folder:
Then run the build:
Once this is done, your plugin should be build and contain a dist
folder:
Which gives this result:
.
├── README.md
├── dev
│ └── index.tsx
├── dist
│ ├── esm
│ │ ├── index-39424543.esm.js
│ │ └── index-39424543.esm.js.map
│ ├── index.d.ts
│ ├── index.esm.js
│ └── index.esm.js.map
├── node_modules
├── package.json
└── src
├── api
│ ├── HelloBackendClient.ts
│ ├── index.ts
│ └── types.ts
├── components
│ └── ExampleComponent
│ ├── ExampleComponent.tsx
│ └── index.ts
├── hooks
│ └── useHelloPluginObjects.ts
├── index.ts
├── plugin.test.ts
├── plugin.ts
├── routes.ts
└── setupTests.ts
10 directories, 19 files
Publish Package¶
Ensure you have an NPMJS.org account and your NPM CLI is logged in:
Then, from the root of your plugin, run the NPM Publish command:
Which should ask you to login to your NPM account:
npm notice
npm notice 📦 @kearos/plugin-hello@0.2.1
npm notice === Tarball Contents ===
npm notice 631B README.md
npm notice 1.3kB dist/esm/index-39424543.esm.js
npm notice 2.7kB dist/esm/index-39424543.esm.js.map
npm notice 563B dist/index.d.ts
npm notice 1.8kB dist/index.esm.js
npm notice 3.5kB dist/index.esm.js.map
npm notice 1.5kB package.json
npm notice === Tarball Details ===
npm notice name: @kearos/plugin-hello
npm notice version: 0.2.1
npm notice filename: kearos-plugin-hello-0.2.1.tgz
npm notice package size: 4.0 kB
npm notice unpacked size: 12.0 kB
npm notice shasum: 7283d136a2b0....................df0cead7
npm notice integrity: sha512-8ka4QMDReqI7d[...]zKwxrmmRrY5wg==
npm notice total files: 7
npm notice
npm notice Publishing to https://registry.npmjs.org/ with tag latest and public access
Authenticate your account at:
https://www.npmjs.com/auth/cli/c41ec7fb-659f-4ec8-a1a0-e4cae8d74af0
Press ENTER to open in the browser...
Once you do, it should return with the following:
We've completed our work on the frontend plugin.