Skip to main content

Creating a custom No-Code UI for a Third-Party Integration

Introduction

Soundcheck plugin offers a No-Code UI configuration option for every integration developed by Spotify. To build a custom No-Code UI to override the default Soundcheck No-Code UI, or to create a No-Code UI for your custom Third-Party integration, you can leverage a frontend extensions feature available in the new frontend system.

Integration extensions are the pages that appear when you click "Configure" on any tile within the Soundcheck Integrations tab. Note that it's currently not possible to change the content of the integration tiles. The "Configure" button will display on the integration tile only when an integration extension is installed for a Third-Party Integration.

The following integration extensions are provided by Soundcheck and enabled by default:

app-config.yaml
app:
extensions:
- integration:soundcheck/azure # Azure DevOps Integration
- integration:soundcheck/bigquery # BigQuery Integration
- integration:soundcheck/catalog # Software Catalog Integration
- integration:soundcheck/datadog # Datadog Integration
- integration:soundcheck/data-registry # Data Registry Integration
- integration:soundcheck/github # GitHub Integration
- integration:soundcheck/gitlab # GitLab Integration
- integration:soundcheck/http # HTTP Integration
- integration:soundcheck/jira # Jira Integration
- integration:soundcheck/kubernetes # Kubernetes Integration
- integration:soundcheck/newrelic # NewRelic Integration
- integration:soundcheck/pagerduty # PagerDuty Integration
- integration:soundcheck/scm # Source Code Management Integration
- integration:soundcheck/sonarqube # SonarQube Integration

Prerequisites

  1. Backstage is migrated to the new frontend system (Portal is using the new frontend system exclusively).

  2. Third-Party Integration is configurable (implements ConfigurableFactCollector interface). Most integrations developed by Spotify are configurable. Exceptions: Tech Insights and Soundcheck (they will be configurable in the future).

  3. Soundcheck plugin modules and the corresponding Third-Party Integration backend module are installed in your Backstage instance.

Implementation

1. Create a new frontend plugin

Example ID: soundcheck-integrations

yarn backstage-cli new

? What do you want to create?
> frontend-plugin - A new frontend plugin

? Enter the ID of the plugin [required] soundcheck-integrations

Note: The new frontend system is still in alpha and therefore not yet the default when creating frontend plugins. Frontend plugins should follow the migration guide to create an alpha export (this will be covered in the next steps).

You may remove the ExampleComponent & ExampleFetchComponent and their exports / imports that were created by the CLI.

2. Create alpha.tsx file

Create alpha.tsx file that will contain your plugin declaration compatible with the new frontend system:

plugins/soundcheck-integrations/src/alpha.tsx
import { convertLegacyRouteRefs } from '@backstage/core-compat-api';
import { createFrontendPlugin } from '@backstage/frontend-plugin-api';
import { rootRouteRef } from './routes';

export default createFrontendPlugin({
id: 'soundcheck-integrations',
routes: convertLegacyRouteRefs({
root: rootRouteRef,
}),
});

3. Update your package.json file

Update your package.json file:

  • Include alpha.tsx file in exports and typesVersions;
  • Include @spotify/backstage-plugin-soundcheck, @backstage/core-compat-api and @backstage/frontend-plugin-api modules in dependencies;
plugins/soundcheck-integrations/package.json
{
"name": "backstage-plugin-soundcheck-integrations",
"description": "Custom Soundcheck Integrations.",
"version": "0.0.0",
"backstage": {
"role": "frontend-plugin"
},
"exports": {
".": "./src/index.ts",
"./alpha": "./src/alpha.tsx",
"./package.json": "./package.json"
},
"typesVersions": {
"*": {
"alpha": [
"src/alpha.tsx"
],
"package.json": [
"package.json"
]
}
},
...
"dependencies": {
"@backstage/core-compat-api": "backstage:^",
"@backstage/frontend-plugin-api": "backstage:^",
// minimum version that supports custom No-Code UI is 0.17.0
"@spotify/backstage-plugin-soundcheck": "^0.17.0",
...
}
}

Soundcheck frontend module exports the following constants and functions that will be used to create a custom No-Code UI:

4. Create Integration React Component

Implement a React component that will display the configuration options for the Third-Party Integration. Here's a very simple example of such React component:

plugins/soundcheck-integrations/src/components/IntegrationComponent.tsx
import { Content } from '@backstage/core-components';
import { Button, Grid, TextField, Typography } from '@material-ui/core';
import {
useGetCollectors,
useUpdateCollectorConfig,
} from '@spotify/backstage-plugin-soundcheck';
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';

export const IntegrationComponent = ({
collectorId,
}: {
collectorId: string;
}) => {
const navigate = useNavigate();
const navigateBack = () => navigate('/soundcheck/integrations');

// fetching integration data from the database and/or YAML
const { data } = useGetCollectors([collectorId]);
const { mutateAsync: updateCollectorConfig } = useUpdateCollectorConfig();

const collector = data?.[0];
const [config, setConfig] = useState<string>();

useEffect(() => {
if (collector?.config) {
setConfig(JSON.stringify(collector.config, null, '\t'));
}
}, [collector]);

if (!collector)
return (
<Typography variant="h1">{collectorId} Integration Not Found</Typography>
);

const disabled = !collector.isEditable || !collector.isConfigurable;

const onSave = async () => {
// saving integration data to the database
const result = await updateCollectorConfig({
collectorId: collectorId,
config: config ? JSON.parse(config) : {},
});
if (result) navigateBack();
};

return (
<Content>
<Grid container direction="column" spacing={2}>
<Grid item>
<Typography variant="h1">
{collector.name ?? collector.id} Integration
</Typography>
<Typography variant="subtitle2">{collector.description}</Typography>
</Grid>
<Grid item>
<TextField
label="JSON Configuration"
placeholder="Enter JSON configuration"
variant="outlined"
disabled={disabled}
fullWidth
multiline
minRows={5}
maxRows={25}
value={config}
onChange={(event) => setConfig(event.target.value)}
/>
</Grid>
<Grid item>
<Grid container spacing={1}>
<Grid item>
<Button
variant="contained"
color="primary"
disabled={disabled}
onClick={onSave}
>
Save
</Button>
</Grid>
<Grid item>
<Button
variant="text"
color="primary"
disabled={disabled}
onClick={navigateBack}
>
Cancel
</Button>
</Grid>
</Grid>
</Grid>
</Grid>
</Content>
);
};

Note: This is a demo component that doesn't implement any validation and it's not meant to be used as-is in production.

5. Create Integration Extension

Create an integration extension for the custom No-Code UI using IntegrationPageBlueprint extension blueprint in your alpha.tsx file. Example:

plugins/soundcheck-integrations/src/alpha.tsx
import { IntegrationPageBlueprint } from '@spotify/backstage-plugin-soundcheck';

// extension reference: integration:soundcheck-integrations/pagerduty
export const pagerDutyIntegration = IntegrationPageBlueprint.make({
name: 'pagerduty', // extension name
params: {
integrationId: 'pagerduty', // IMPORTANT: The value must match the ID of the corresponding fact collector
loader: async () =>
import('./components/IntegrationComponent').then(
m => <m.IntegrationComponent collectorId="pagerduty" />, // React component created in the previous step
),
},
});

Extension configuration options:

  • name [optional] - Extension name, should be unique for all integration extensions. Must be provided if your plugin implements more than one integration extension.
  • disabled [optional] - A boolean flag, if set to true it disables the extension by default.
  • params.integrationId [required] - ID of the corresponding fact collector.
  • params.loader [required] - A function that returns the custom implementation of the No-Code UI (React component).
  • params.path [optional] - A path to the integration page. Default value is /soundcheck/integrations/:integrationId.
  • params.routeRef [optional] - A routing reference to expose a path in Backstage's routing system.

The extension reference follows the pattern <kind>:<namespace>/<name> where:

  • kind - Defaulted to integration for extensions created from IntegrationPageBlueprint extension blueprint.
  • namespace - ID of your frontend plugin that implements the extension (example: soundcheck-integrations).
  • name - Extension name (example: pagerduty).

6. Export Integration Extension

List the integration extension created above under your plugin declaration in alpha.tsx file:

plugins/soundcheck-integrations/src/alpha.tsx
import { convertLegacyRouteRefs } from '@backstage/core-compat-api';
import { createFrontendPlugin } from '@backstage/frontend-plugin-api';
import { rootRouteRef } from './routes';

export default createFrontendPlugin({
id: 'soundcheck-integrations',
routes: convertLegacyRouteRefs({
root: rootRouteRef,
}),
extensions: [pagerDutyIntegration],
});

7. Install the frontend plugin

Install the new soundcheck-integrations frontend plugin to your Backstage instance:

packages/app/package.json
{
"name": "app",
"description": "Backstage Frontend.",
"version": "0.0.0",
"backstage": {
"role": "frontend"
},
...
"dependencies": {
"backstage-plugin-soundcheck-integrations": "workspace:^",
...
}
}

8. Configure Integration Extension

While extensions derived from the IntegrationPageBlueprint blueprint do not offer custom configuration options, they do universally support the disabled option (note that integration extensions are enabled by default).

To override the default No-Code UI of any Soundcheck integration, simply add the following configuration:

app-config.yaml
app:
extensions:
# Extension reference of the PagerDuty integration page provided by Soundcheck
- integration:soundcheck/pagerduty:
disabled: true
# Extension reference of the custom PagerDuty integration page
- integration:soundcheck-integrations/pagerduty
- ...

React Hooks

useGetCollectors

React hook that fetches collector configurations from the database (collector table) and/or YAML by their IDs. If the collector's configuration is found in both database and YAML and the YAML configuration is a plain object (not an array), the configurations will be merged (it's recommended to configure secrets in YAML only and don't expose secret configuration in No-Code UI for security reasons). Otherwise, the database configuration takes precedence. Collector's YAML configuration can be found under soundcheck.collectors.<integrationId> in app-config.yaml file.

Usage example:

import { useGetCollectors } from '@spotify/backstage-plugin-soundcheck';

const { data } = useGetCollectors(['pagerduty']);
const collector = data?.[0];

The returned data is an array of the Collector type:

import { JsonObject } from '@backstage/types';

export type Collector = {
// Integration ID provided in the backend implementation of the fact collector.
// Implemented in `FactCollector.id`
id: string;
// Integration name provided in the backend implementation of the fact collector.
// Implemented in `FactCollector.name`
name?: string;
// Integration description provided in the backend implementation of the fact collector.
// Implemented in `FactCollector.description`
description?: string;
// A flag that's set to true if the collector's backend implements `ConfigurableFactCollector` interface.
isConfigurable: boolean;
// A flag that's set to true if the collector is configurable and the configuration in the YAML file is a plain object (not an array).
isEditable: boolean;
// The names of the facts that this collector can collect.
// Implemented in `FactCollector.getFactNames()`
factNames: string[];
// Current collector configuration as JSON.
// Implemented in `ConfigurableFactCollector.getConfig()`
config?: JsonObject;
// JSON schema for the collectors configuration (can be used for validation).
// Implemented in `ConfigurableFactCollector.getConfigSchema()`
configSchema?: JsonObject;
// Current collector configuration as `CollectionConfig[]` array.
// Implemented in `FactCollector.getCollectionConfigs()`
collectionConfigs: {
// The facts to which this collection configuration applies.
factRefs: string[];
// A filter specifying which entities to collect the specified facts for.
filter?: JsonObject;
// The frequency at which the specified facts should be collected.
frequency?: JsonObject;
// If the collected facts should be cached, and if so for how long.
cache?: JsonObject;
// A filter specifying which entities to exclude from collection.
exclude?: JsonObject;
}[];
};

Example:

{
"id": "pagerduty",
"name": "PagerDuty",
"description": "Collects facts from your PagerDuty instance.",
"isConfigurable": true,
"isEditable": true,
"factNames": [
"pagerduty:default/service",
"pagerduty:default/standards",
"pagerduty:default/incidents"
],
"config": {
"collects": [
{
"type": "Service",
"frequency": {
"hours": 1
},
"filter": [
{
"kind": ["Component"]
}
],
"cache": false
},
{
"type": "Standards",
"frequency": {
"hours": 1
},
"filter": [
{
"kind": ["Component"]
}
],
"cache": false
},
{
"factName": "incidents",
"type": "Incidents",
"statuses": ["triggered", "acknowledged", "resolved"],
"frequency": {
"hours": 1
},
"filter": [
{
"kind": ["Component"]
}
],
"cache": false
}
]
},
"collectionConfigs": [
{
"factRefs": ["pagerduty:default/service"],
"filter": [
{
"kind": ["Component"]
}
],
"frequency": {
"hours": 1
},
"cache": false,
"exclude": null
},
{
"factRefs": ["pagerduty:default/standards"],
"filter": [
{
"kind": ["Component"]
}
],
"frequency": {
"hours": 1
},
"cache": false,
"exclude": null
},
{
"factRefs": ["pagerduty:default/incidents"],
"filter": [
{
"kind": ["Component"]
}
],
"frequency": {
"hours": 1
},
"cache": false,
"exclude": null
}
]
}

useUpdateCollectorConfig

React hook that saves collector configuration to the database (collector table). No-Code UI configuration is always stored in the database as a raw JSON object. The configuration JSON object should only include the actual collector's configuration (aka what you'd configure under soundcheck.collectors.<integrationId> in app-config.yaml file as YAML config). The configuration should be a valid JSON object, otherwise an error will be thrown by the backend.

On top of persisting the config to the database the hook also calls ConfigurableFactCollector.setConfig() method implemented by the fact collector, so the fact collector will be aware of the updated config right away.

Usage example:

import { useUpdateCollectorConfig } from '@spotify/backstage-plugin-soundcheck';

const { mutateAsync: updateCollectorConfig } = useUpdateCollectorConfig();

const onSave = async () => {
const result = await updateCollectorConfig({
collectorId: 'pagerduty', // must be a valid integrationId
// must be a valid JSON configuration object
config: {
collects: [
{
type: 'Service',
cache: false,
filer: [
{
kind: ['Component'],
},
],
frequency: {
hours: 1,
},
},
],
},
});
if (result) console.log('Success!');
};