Merge branch 'backstage:master' into configuration_token_expiry

This commit is contained in:
Lavanya Sainik
2024-01-16 11:31:59 +00:00
committed by GitHub
90 changed files with 2095 additions and 418 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-auth-backend-module-pinniped-provider': patch
---
Deprecated the `authModulePinnipedProvider` export. A default export is now available and should be used like this in your backend: `backend.add(import('@backstage/plugin-auth-backend-module-pinniped-provider'));`
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-dynamic-feature-service': minor
---
New `backend-dynamic-feature-service` package, for the discovery of dynamic frontend and backend plugins (and modules) and the loading of the backend ones inside the backend application.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-common': patch
---
Do not call fetch directly but rather use `fetchResponse` facility
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-backend-module-github': patch
---
Ensure `teamReviewers` list is unique before calling API
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-common': patch
---
Updated Template.v1beta3.schema.json, added a missing "presentation" field
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-auth-backend-module-microsoft-provider': patch
---
Deprecated the `authModuleMicrosoftProvider` export. A default export is now available and should be used like this in your backend: `backend.add(import('@backstage/plugin-auth-backend-module-microsoft-provider'));`
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-events-backend': patch
---
Update `README.md`
+1 -1
View File
@@ -1,5 +1,5 @@
{
"mode": "pre",
"mode": "exit",
"tag": "next",
"initialVersions": {
"example-app": "0.2.90",
+8
View File
@@ -0,0 +1,8 @@
---
'@backstage/plugin-events-backend-module-aws-sqs': patch
---
Fix errors when deleting SQS messages:
- If zero messages were received, skip deletion to avoid `EmptyBatchRequest` error from the SQS client.
- If zero failures were returned from the SQS client during deletion, skip error logging.
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/frontend-plugin-api': patch
'@backstage/frontend-test-utils': patch
'@backstage/frontend-app-api': patch
---
Added `elements`, `wrappers`, and `router` inputs to `app/root`, that let you add things to the root of the React tree above the layout. You can use the `createAppRootElementExtension`, `createAppRootWrapperExtension`, and `createRouterExtension` extension creator, respectively, to conveniently create such extensions. These are all optional, and if you do not supply a router a default one will be used (`BrowserRouter` in regular runs, `MemoryRouter` in tests/CI).
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/frontend-app-api': minor
---
Attaching extensions to an input that does not exist is now a warning rather than an error.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-react': patch
---
Show first scaffolder output text by default
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-backend': patch
---
Parse the URL using a different method rather than `git-url-parse` to support wildcards for URLs which are not VCS providers
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-app-visualizer': minor
---
Initial release of the app visualizer plugin.
+31 -34
View File
@@ -12,16 +12,23 @@ jobs:
name: Cache Manifests File
runs-on: ubuntu-latest
if: ${{ github.event.workflow_run.conclusion == 'success' }}
permissions:
# "If you specify the access for any of these scopes, all of those that are not specified are set to none."
# https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#permissions
actions: read # Access cache
outputs:
manifests-cache-key: ${{ env.MANIFESTS_FILE_HASH }}
git-ref: ${{ env.GIT_REF }}
pr-number: ${{ env.PR_NUMBER }}
action: ${{ env.ACTION }}
manifests-cache-key: ${{ steps.hash.outputs.MANIFESTS_FILE_HASH }}
git-ref: ${{ steps.event.outputs.GIT_REF }}
pr-number: ${{ steps.event.outputs.PR_NUMBER }}
action: ${{ steps.event.outputs.ACTION }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@eb238b55efaa70779f274895e782ed17c84f2895 # v2.6.1
with:
egress-policy: audit
disable-sudo: true
egress-policy: block
allowed-endpoints: >
api.github.com:443
- name: 'Download artifacts'
# Fetch output (zip archive) from the workflow run that triggered this workflow.
@@ -48,59 +55,50 @@ jobs:
let fs = require('fs');
fs.writeFileSync(`${process.env.GITHUB_WORKSPACE}/preview-spec.zip`, Buffer.from(download.data));
- name: 'Unzip artifact'
run: unzip preview-spec.zip
- name: 'Accept event from first stage'
run: unzip preview-spec.zip event.json
- name: Read Event into ENV
id: event
run: |
echo 'EVENT_JSON<<EOF' >> $GITHUB_ENV
cat event.json >> $GITHUB_ENV
echo -e '\nEOF' >> $GITHUB_ENV
echo PR_NUMBER=$(jq '.number | tonumber' < event.json) >> $GITHUB_OUTPUT
echo ACTION=$(jq --raw-output '.action | tostring | [scan("\\w+")][0]' < event.json) >> $GITHUB_OUTPUT
echo GIT_REF=$(jq --raw-output '.pull_request.head.sha | tostring | [scan("\\w+")][0]' < event.json) >> $GITHUB_OUTPUT
- name: Hash Rendered Manifests File
id: hash
# If the previous workflow was triggered by a PR close event, we will not have a manifests file artifact.
if: ${{ fromJSON(env.EVENT_JSON).action != 'closed' }}
if: ${{ steps.event.outputs.ACTION != 'closed' }}
run: |
unzip preview-spec.zip manifests.rendered.yml
ls
echo "MANIFESTS_FILE_HASH=$(md5sum manifests.rendered.yml | awk '{ print $1 }')" >> $GITHUB_ENV
echo "MANIFESTS_FILE_HASH=$(md5sum manifests.rendered.yml | awk '{ print $1 }')" >> $GITHUB_OUTPUT
- name: Cache Manifests File
if: ${{ fromJSON(env.EVENT_JSON).action != 'closed' }}
if: ${{ steps.event.outputs.ACTION != 'closed' }}
uses: actions/cache@v3.3.2
with:
path: manifests.rendered.yml
key: ${{ env.MANIFESTS_FILE_HASH }}
- name: Read PR Number From Event Object
id: pr
run: echo "PR_NUMBER=${{ fromJSON(env.EVENT_JSON).number }}" >> $GITHUB_ENV
- name: Read Event Type from Event Object
id: action
run: echo "ACTION=${{ fromJSON(env.EVENT_JSON).action }}" >> $GITHUB_ENV
- name: Read Git Ref From Event Object
id: ref
run: echo "GIT_REF=${{ fromJSON(env.EVENT_JSON).pull_request.head.sha }}" >> $GITHUB_ENV
key: ${{ steps.hash.outputs.MANIFESTS_FILE_HASH }}
- name: DEBUG - Print Job Outputs
if: ${{ runner.debug }}
run: |
echo "PR number: ${{ env.PR_NUMBER }}"
echo "Git Ref: ${{ env.GIT_REF }}"
echo "Manifests file hash: ${{ env.MANIFESTS_FILE_HASH }}"
echo "PR number: ${{ steps.event.outputs.PR_NUMBER }}"
echo "Git Ref: ${{ steps.event.outputs.GIT_REF }}"
echo "Action: ${{ steps.event.outputs.ACTION }}"
echo "Manifests file hash: ${{ steps.hash.outputs.MANIFESTS_FILE_HASH }}"
cat event.json
deploy-uffizzi-preview:
permissions:
contents: read
pull-requests: write
id-token: write
name: Deploy to Uffizzi Virtual Cluster
needs:
- cache-manifests-file
if: ${{ github.event.workflow_run.conclusion == 'success' && needs.cache-manifests-file.outputs.action != 'closed' }}
permissions:
contents: read
pull-requests: write
id-token: write
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -142,7 +140,6 @@ jobs:
- name: Fetch cached Manifests File
id: cache
# if: ${{ contains(fromJSON('["create", "update"]'), env.UFFIZZI_ACTION) }}
uses: actions/cache@v3
with:
path: manifests.rendered.yml
+255
View File
@@ -0,0 +1,255 @@
---
title: Backstage Notifications System
status: provisional
authors:
- Rugvip
project-areas:
- core
creation-date: 2023-01-12
---
# BEP: Backstage Notifications System
[**Discussion Issue**](https://github.com/backstage/backstage/issues/22213)
- [Summary](#summary)
- [Motivation](#motivation)
- [Goals](#goals)
- [Non-Goals](#non-goals)
- [Proposal](#proposal)
- [Design Details](#design-details)
- [Release Plan](#release-plan)
- [Dependencies](#dependencies)
- [Alternatives](#alternatives)
## Summary
The Backstage Notifications System provides a way for Backstage plugins to send notifications to users. The notifications are displayed in the Backstage frontend UI, but future extensions can allow them to be sent via external channels such as email. The system includes a push mechanism to make sure that users receive notifications immediately, as well as persistence of notification history and read status.
## Motivation
Support for notifications is an old and common features request. See [#639](https://github.com/backstage/backstage/issues/639) and [#10652](https://github.com/backstage/backstage/issues/10652). There are also initiatives to implement notifications in [#20312](https://github.com/backstage/backstage/issues/20312) as well as the underlying infrastructure pieces, for example [#17997](https://github.com/backstage/backstage/issues/17997).
This proposal assumes that further motivation for a notifications system as a whole is not needed. The focus is instead on the specific goals of an initial notifications system, and generally aiming to address the need to coordinate this work.
### Goals
We aim to build a system that would provide support for the following type of notifications, as listed in [#10652](https://github.com/backstage/backstage/issues/10652):
- System-wide announcements or alerts
- Notifications for component owners (e.g. build failure, successful deployment, new vulnerabilities)
- Notifications for individuals (e.g. updates you have subscribed to, new required training courses)
- Notifications pertaining to a particular entity in the catalog (a given notification might apply to an entity and the owning team)
Initially we only target a single way to send notifications: via a REST API call to the notifications backend, which in turn also provides an accompanying [backend service](https://backstage.io/docs/backend-system/architecture/services).
The notifications system will need to support scaled deployments, where multiple backend instances may be deployed.
In the future we aim to provide an extensible system that can be customized to send notifications to users via other channels. Notifications can be captured and processed in-band in order to trigger custom behavior such as sending an email. The goal is for this to be possible to build, but not necessarily part of the initial implementation.
### Non-Goals
The following features are out of scope for this BEP:
- Building out specific support for channels such as email, Slack, and other external channels.
- Providing a fallback mechanisms when a notification cannot be delivered to a user. For example, if a user has not logged in for a long time, we will not attempt to send an email instead.
## Proposal
The notifications system is implemented through two net-new components, the `notifications` plugin, and the `signal` plugin.
The `notifications` plugin in its simplest form provides the following:
- A backend API for sending, reading, and interacting with notifications.
- Persistent storage of notification state.
- A frontend UI for viewing notifications.
The `signal` plugin provides the following:
- A backend API for pushing lightweight signals to online users.
- A connection between the frontend and backend that can be used to push messages to the client.
- A frontend API that lets plugins to subscribe to specific signals.
### Signals Plugin
In the backend the signal plugin implements a general purpose message bus for sending signals from backend plugins to connected users. It relies on the `EventBroker` from the [events plugin](https://github.com/backstage/backstage/blob/master/plugins/events-backend/README.md) for the actual message passing in the backend. In order to support scaled deployments, each signal backend instance has a separate subscription to the event broker so that each instance receives all events. It is then up to each backend instance to filter out events that are not relevant to it. For this reason, signals should be kept lightweight and not contain unnecessary data.
In the frontend the signal plugin has a persistent connection to the signal backend. This is initially implemented as a WebSocket connection, but could in the future also receive fallback mechanisms such as Server Sent Events or long polling. It is important that this connection is authenticated as we will be routing signals to specific users. The exact implementation of the authentication is not part of this proposal, but it should use whatever the outcome of the discussion in issue [#19581](https://github.com/backstage/backstage/issues/19581) is.
In order to route signals from the sender to the intended receiver the signal plugin uses the concept of signaling channels. They are just like the topics on the message bus, but we use separate terminology in order to avoid confusion. They also sit one layer beneath the even topic in the protocol stack, where the signal channel is communicated as part of the event payload. In the frontend, the signal plugin exposes an API to subscribe to a signal channel. Each time the user receives a signal on the specified channel, a listener is called with the signal payload.
### Notifications Plugin
The role of the notifications plugin is to manage the lifecycle of notifications. The backend plugin provides an API for other backends to send notifications, as well as an accompanying [backend service](https://backstage.io/docs/backend-system/architecture/services). It also provides a separate API for the frontend plugin to read notifications for an individual user and manage the read status of notifications.
The notification backend stores notification using the [database service](https://backstage.io/docs/backend-system/core-services/index#database). In particular it needs to store the following information for each notification:
- ID
- Payload
- Receivers
- Read status by user
The receivers is **not** a list of users, but rather a filter that describes who should receive the notification. It must be possible to evaluate this filter in a database query, so that we can efficiently fetch all notifications for a given user. The same filter will also be used by the signal backend to determine which users should receive a signal.
The notification backend does not provide any new permissions, since creating notifications can only be done by other backend plugins, while reading notifications can only be done by the authenticated user. It is possible that we want to add a permissions for reading notifications, in particular for admin and impersonation use cases, but that is not part of this proposal or the initial implementation.
The notification frontend plugin provides a UI for viewing notifications, which in the initial implementation can be as simple as needed. The only requirement is that a user is able to view recent notifications and distinguish between read and unread notifications. A notification as marked as read once it has been interacted with. The frontend plugin also subscribes to the notifications signal channel and alerts the user when a new notification is received.
### Architecture Overview
The diagram below show an overview of the notifications system architecture and the interaction between the different components.
![notifications system architecture diagram](./notifications-architecture.drawio.svg)
The components that are presented with dashed lines are not directly part of this proposal, but included to be considered as future extensions.
Let's walk through the process of sending of a notification to a user:
1. A backend plugin that wants to send a notification to a user uses the `NotificationService`, which in turn makes a POST request to the notification backend. The request contains a filter to select the target users, as well as the notification payload.
1b. As the notification backend handles the request it could optionally invoke a series of notification processors. These processors can be used to transform the notification payload, send the notification through other channels, and decide whether the regular notification flow should continue or not. This step is not in scope for this proposal.
2. The notification backend stores the received notification in its database. At this point the notification is available from the notification backend's read API.
3. The notification backend published an event to the event bus on the `'signal'` topic. This event payload contains the signaling channel, in this case `'notifications'`, as well as the target user ID and the notification ID, but not the notification payload.
4. Each instance of the signal backend plugin subscribes to the `'signal'` topic and receives the event.
5. Each signal backend instance has a set of push channels set up to online users. The incoming event is filtered based on the target user ID and the signaling is pushed through all connections with a matching user ID.
6. The signal frontend plugin receives the signal and forwards it to all subscribers of the target channel. In this case, the notifications frontend plugin, who is subscribed to the `'notifications'` channel.
7. The notifications frontend plugin receives the signal with the notification ID. It then makes a request to the notification backend to fetch the notification payload and then displays the notification to the user.
7b. Rather than rendering the notification directly, the notifications frontend plugin leaves that to an extension installed by the frontend part of the backend plugin that sent the notification. This allows for a more customized notification experience which can also contain custom actions and data display. This step is not in scope for this proposal.
## Design Details
### Backend Services
The following backend service interfaces are added as part of this proposal.
#### `NotificationService`
```ts
// TODO - We may want to add an additional wrapping here with interfaces for Notification and NotificationParameters
interface SendNotificationRequest {
receivers: SignalReceivers;
/* TODO - please contribute :) */
}
interface SendNotificationResponse {
id: string;
receivers: SignalReceivers;
/* TODO - please contribute :) */
}
interface NotificationService {
sendNotification(
request: SendNotificationRequest,
): Promise<SendNotificationResponse>;
}
```
#### `SignalService`
```ts
type SignalReceivers =
| {
type: 'entity';
entityRef: string; // typically a user or group entity
}
| {
type: 'broadcast'; // all users
};
interface Signal {
channel: string;
receivers: SignalReceivers;
payload: JsonObject;
}
interface SignalService {
signal(signal: Signal): Promise<void>;
}
```
### Frontend API
The following frontend API is added as part of this proposal.
#### `SignalApi`
> TODO - we likely need a slightly different approach here, since APIs are lazy loaded.
```ts
interface SignalSubscriber {
unsubscribe(): void;
}
interface SignalApi {
subscribe(
channel: string,
listener: (payload: JsonObject) => void,
): SignalSubscriber;
}
```
> TODO: Add payload formats and API details
<!--
- A notification can be sent to any entity
- If the entity is an user, the notification is sent to that user
- If the entity is a group, the notification is sent to all users that are members of that group
- In other cases, the notification is sent to to the user or group in the `spec.owner` field
- Notification frontend shows users their own notifications in its own page and the number of unread notifications
in the main menu item
- Notifications can be set to `read` by the user and they are filtered out of the main view
- Notifications can be saved for later use by the user
- Each notification must have title, description and link
- Each notification can also have icon or image as an URL
- Notification frontend utilizes [Web Notification API](https://developer.mozilla.org/en-US/docs/Web/API/Notification)
to notify user for new notifications
-->
## Release Plan
The notification and signal plugins are released as two new plugins in the Backstage ecosystem. They will both start out in an experimental state.
For the notification plugin to reach a stable release we much reach the following:
- A stable notifications payload format.
- A stable notifications receiver filter format.
- The event broker must have at least one implementation that supports scaled deployments.
For the signal plugin to reach a stable release we much reach the following:
- A stable signal receiver filter format.
- A stable signal channel API in the frontend.
If any changes are required to the frontend framework to facilitate the implementation of notifications or signals, these will be released as experimental alpha features. They will stay in alpha until they are deemed stable enough, which must happen before a stable release of the notifications system.
## Dependencies
Since the signal plugin relies on the event broker for communication, it is a dependency for the notifications system as a whole. The event broker does not currently implement any transport for scaled deployments, which is a requirement for scaled deployments of the notification system.
## Alternatives
### Signal Plugin Separation
One primary consideration for the notifications system was whether the notification plugin should set up its own frontend push connection rather than relying on the signal plugin. The signal plugin separation is done for three primary reasons:
1. The use-case of sending signals from backend plugins to frontend plugins is not limited to only notifications, since not all signals should necessarily be displayed directly to the user. For example, the catalog backend may want to send a signal to the frontend when an entity is updated so that users that are currently viewing the entity in the frontend can trigger an update of the view.
2. We want to limit the number of WebSocket connections that the frontend needs to maintain. By using a separated signal abstraction it is easier to extend the implementation to support other messaging patterns in the future. For example, we may want to move the Scaffolder logs to be sent via the signal plugin instead. That is much more straightforward with a separate signal plugin compared to adding it to the notifications plugin.
3. The technical implementation of sending signals is sufficiently complex that it makes sense to separate out to its own solution in order to reduce the complexity of the notifications plugin. In particular when it comes to handling scaled deployments.
### User-to-User Notifications
The notification backend API for sending notifications is only available to other backend plugins, and is not accessible to end users in the frontend. If this were to be added it would likely be a separate backend module that you can install alongside the notification backend. That is unless the permission framework can be extended to support default policy handlers so that we can reject the permission to send user notifications by default.
There are a few reasons we don't want to allow user-to-user notifications out of the box:
- To limit the possibility of spam. Even if well-intended, a notification overload can quickly lead to a bad user experience and users disabling and ignoring notifications.
- From a security perspective it's beneficial to avoid user sent notifications since they could be used to trick users into clicking on malicious links, or other similar attacks.
- Our assumption is that this is a feature that most adopters will not want to use, and so by having it be disabled by default we can avoid additional configuration steps.
@@ -0,0 +1,537 @@
<svg host="65bd71144e" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="671px" height="311px" viewBox="-0.5 -0.5 671 311" content="&lt;mxfile scale=&quot;1&quot; border=&quot;20&quot;&gt;&lt;diagram id=&quot;2fh7CcgDxUdRYRy3FD-Z&quot; name=&quot;Page-1&quot;&gt;7Vxbk6I4FP41Vu0+tEVAUB9bu+192N3pWqdqZx4jREk1Agto6/76TSDckqhIA97W6RrJIdy+79xyEulp0/XuLYC+/YdnIaenKtaup730VHU40Mn/VLBPBCPDSASrAFuJCOSCOf4XMaHCpBtsobDUMfI8J8J+WWh6rovMqCSDQeB9lrstPad8VR+ukCCYm9ARpX9jK7ITqTZWcvlvCK9sdmXNYDsW0PxYBd7GZZfrqdos/iS71zA9Fesf2tDyPgsi7bWnTQPPi5Kt9W6KHIpsilpy3OzA3uy2A+RGVQ5IeQqjffroyCJIsKbrueRrYkdrh7QA2bRgaCOLNZaeGzHuCHHaJIxgED1T+PNjY9kMO/QEtA9yLa4HkRT373D0g273VZ01fxZ2veyKjT1riI/NkAi9TWCyB1OZHsFghVivwTCR0WcuHMewekPeGkXBnnQIkAMjvC1rB2RKtsr6ZYe+e5jciaowe3gCKgN6n+q5Uj5HclfssJwwslG4j1wU0yin1FDPpDRHfFgdcQJ0sE+O0tPmz/SEtJEfFrdKx72jAJMnQgETflGPFo5nfny3sVtSplhByebUc7wgxkFb6vRffLLA+0CFPUb8+YImpQ6gYU0ijwj3hQ4+VZDwiKKlHirVM97++f7G0f5kI7mDusrIkNpCZ8Oe+E8vwktsEgw8l+yZEIdJOBNUNvahsaOhZH/aOEJzH8YEfJKIU9bgQzRTvSrIk089+rcoiNDuKLOZrY84TAes/ZlHkhRnxS5EkYFyWBtKDuEI4Ok5zrf+oun39aPG35q9lqNLO8aricY7atp2K7M1qu2rq7vqW/OuEoLUSxGknfJfs4Cge+cOLAtunTgwAfE5XrnQuctYoVeIFEpLQKdG1arvOZXtl3xR0dMUHVKLvkYXfc3gUr4mdTYtE1I3bz9OZHf8AL1pgmpl2pldpk7yRKYNxkf7fz3THvyvPVW0x7hJ7eEjRePaM66rPWcMGzLtAUXdKajSAe259oRVombapYKIfjh9usNUlbcKWaLaVv5U12JAHYtRzrOY8xW9kv1UsgZDtIaLDd8MwRrenc0K04Hbj7scUDxpfEGvy+LT6Djc1+aAStWmNsDvdOAMzh7PXZdzqeMfKzkkSbUeNB6fD8378BoBOKaTGxXmfcQzqVzGmJ35xAzSuZmpKr/MwcS03H3UbFoKhjWDbHlQA44q0aFBjXLCDi6ZcgJJkfRihQvQTBX7MVi60vLFqYlCvnzR9EQhqD0AfUQdus4ixikdEsrdTeuQOFvzukXxHUwoB8G1JJ4N5JpcXiFJNEdt5ZliieGOcAXD0+OnQVvIisPVO0JW1S6HbJqlFpDt9/sCuORJojKCZURYFCrCykTQwSsSEl5Mgg9d1jShuGATOs9sxxpbFr2MlLIyqU1ocYWJxbbGodndPgjW2ZDuAlhrqoC1v1k4OLRpdpDEvqvEnV+T0gAPwlI2CQ+ymKg2wYO4amEReNAyYfhADGSI84tcu2BAkpVoM/b3OAxw5RhVwoDeFgNi9hKiYEuzbsXfUI/0MCzwk7uayMKwLRbGAgvv3+bfHwf8J5X3QpI4YLSE/uDcRRhN/pyi89JE+uuJUhGSZSTtV9a5gtSIo+9AYb1GWSF9pGJZYUdUP5lVn+/DCK0F2u9mSosbSXS6FFSs56A1xKTLLHSg+UG+IwTXYU+dUo2LzGsdZbTh5nhiqs7zNuLmxFD/F/E4kgLb/eLPhxmZZbQWZoYC/j3VcCjSoQ/dEgXGPxv6Y8KJmcD7TF3vavGLDhKryb9/jYFRKFZPYQwW7QtUfxfvSM9Dtlb0+z3wTBSG5JzsytQq44sn+8VA6DjYDw8RVjUmXlVJSwhCHZZhB2Ki9/b6SHmeMNYBIvhaSwaY+t4C+ECAvrLCw9BPfsO8xDsK2UmlXy5V05Qpt2UsDL0p5RYKtkMR4LYijC4WESWr428cYL5I2yW+Yk4LFncHsPBLTImLaA1hsSQoGRzeNsD8XFmX8Ir1PsmrDG4bXsDPLIw7xFdM8Y17w5dPILqEV8zgh/fnf/mpsU4RFvNjyWLCGweYn+eVVZrbAjgd6xxbrRDa0Keb5t7BtD6gnUZ6kQwcfl9kguyVOt82ETkNYvJkjPoy6I8NNf8bXP+gkUtKdJEy2TIIvQnKxIKayNnN2wTgXrXTYVZiiEnf1IauG78cawodZxGXLW9ojC4AL6Gn8pS8tHoMmuGCNPPXZiVV/vzNZNrrfw==&lt;/diagram&gt;&lt;/mxfile&gt;" style="background-color: rgb(255, 255, 255);">
<defs/>
<g>
<path d="M 230 120 L 230 80" fill="none" stroke="rgb(0, 0, 0)" stroke-miterlimit="10" stroke-dasharray="3 3" pointer-events="stroke"/>
<path d="M 290 120 L 290 110 Q 290 100 300 100 L 310 100 Q 320 100 320 94.06 L 320 88.12" fill="none" stroke="#666666" stroke-miterlimit="10" pointer-events="stroke"/>
<path d="M 320 81.12 L 322.33 88.12 L 317.67 88.12 Z" fill="#666666" stroke="#666666" stroke-miterlimit="10" pointer-events="all"/>
<rect x="200" y="120" width="120" height="40" fill="#f5f5f5" stroke="#666666" pointer-events="all"/>
<g transform="translate(-0.5 -0.5)">
<switch>
<foreignObject pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow: visible; text-align: left;">
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 118px; height: 1px; padding-top: 140px; margin-left: 201px;">
<div data-drawio-colors="color: #333333; " style="box-sizing: border-box; font-size: 0px; text-align: center;">
<div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(51, 51, 51); line-height: 1.2; pointer-events: all; white-space: normal; overflow-wrap: normal;">
Notification Backend
</div>
</div>
</div>
</foreignObject>
<text x="260" y="144" fill="#333333" font-family="Helvetica" font-size="12px" text-anchor="middle">
Notification Backend
</text>
</switch>
</g>
<path d="M 200 260 L 148.12 260" fill="none" stroke="#666666" stroke-miterlimit="10" stroke-dasharray="3 3" pointer-events="stroke"/>
<path d="M 141.12 260 L 148.12 257.67 L 148.12 262.33 Z" fill="#666666" stroke="#666666" stroke-miterlimit="10" pointer-events="all"/>
<path d="M 260 240 L 260 168.12" fill="none" stroke="#666666" stroke-miterlimit="10" pointer-events="stroke"/>
<path d="M 260 161.12 L 262.33 168.12 L 257.67 168.12 Z" fill="#666666" stroke="#666666" stroke-miterlimit="10" pointer-events="all"/>
<rect x="200" y="240" width="120" height="40" fill="#f5f5f5" stroke="#666666" pointer-events="all"/>
<g transform="translate(-0.5 -0.5)">
<switch>
<foreignObject pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow: visible; text-align: left;">
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 118px; height: 1px; padding-top: 260px; margin-left: 201px;">
<div data-drawio-colors="color: #333333; " style="box-sizing: border-box; font-size: 0px; text-align: center;">
<div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(51, 51, 51); line-height: 1.2; pointer-events: all; white-space: normal; overflow-wrap: normal;">
Notification Frontend
</div>
</div>
</div>
</foreignObject>
<text x="260" y="264" fill="#333333" font-family="Helvetica" font-size="12px" text-anchor="middle">
Notification Frontend
</text>
</switch>
</g>
<rect x="430" y="120" width="100" height="40" fill="#f5f5f5" stroke="#666666" pointer-events="all"/>
<g transform="translate(-0.5 -0.5)">
<switch>
<foreignObject pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow: visible; text-align: left;">
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 98px; height: 1px; padding-top: 140px; margin-left: 431px;">
<div data-drawio-colors="color: #333333; " style="box-sizing: border-box; font-size: 0px; text-align: center;">
<div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(51, 51, 51); line-height: 1.2; pointer-events: all; white-space: normal; overflow-wrap: normal;">
Signal Backend
</div>
</div>
</div>
</foreignObject>
<text x="480" y="144" fill="#333333" font-family="Helvetica" font-size="12px" text-anchor="middle">
Signal Backend
</text>
</switch>
</g>
<path d="M 480 231.88 L 480 160" fill="none" stroke="#666666" stroke-miterlimit="10" pointer-events="stroke"/>
<path d="M 480 238.88 L 477.67 231.88 L 482.33 231.88 Z" fill="#666666" stroke="#666666" stroke-miterlimit="10" pointer-events="all"/>
<path d="M 480 240 L 480 210 Q 480 200 490 200 L 560 200 Q 570 200 570 190 L 570 160" fill="none" stroke="#666666" stroke-miterlimit="10" pointer-events="stroke"/>
<path d="M 480 240 L 480 210 Q 480 200 490 200 L 620 200 Q 630 200 630 190 L 630 160" fill="none" stroke="#666666" stroke-miterlimit="10" pointer-events="stroke"/>
<path d="M 430 260 L 328.12 260" fill="none" stroke="#666666" stroke-miterlimit="10" pointer-events="stroke"/>
<path d="M 321.12 260 L 328.12 257.67 L 328.12 262.33 Z" fill="#666666" stroke="#666666" stroke-miterlimit="10" pointer-events="all"/>
<rect x="430" y="240" width="100" height="40" fill="#f5f5f5" stroke="#666666" pointer-events="all"/>
<g transform="translate(-0.5 -0.5)">
<switch>
<foreignObject pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow: visible; text-align: left;">
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 98px; height: 1px; padding-top: 260px; margin-left: 431px;">
<div data-drawio-colors="color: #333333; " style="box-sizing: border-box; font-size: 0px; text-align: center;">
<div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(51, 51, 51); line-height: 1.2; pointer-events: all; white-space: normal; overflow-wrap: normal;">
Signal Frontend
</div>
</div>
</div>
</foreignObject>
<text x="480" y="264" fill="#333333" font-family="Helvetica" font-size="12px" text-anchor="middle">
Signal Frontend
</text>
</switch>
</g>
<path d="M 140 140 L 191.88 140" fill="none" stroke="#666666" stroke-miterlimit="10" pointer-events="stroke"/>
<path d="M 198.88 140 L 191.88 142.33 L 191.88 137.67 Z" fill="#666666" stroke="#666666" stroke-miterlimit="10" pointer-events="all"/>
<rect x="20" y="120" width="120" height="40" fill="#f5f5f5" stroke="#666666" pointer-events="all"/>
<g transform="translate(-0.5 -0.5)">
<switch>
<foreignObject pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow: visible; text-align: left;">
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 118px; height: 1px; padding-top: 140px; margin-left: 21px;">
<div data-drawio-colors="color: #333333; " style="box-sizing: border-box; font-size: 0px; text-align: center;">
<div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(51, 51, 51); line-height: 1.2; pointer-events: all; white-space: normal; overflow-wrap: normal;">
Plugin X Backend
</div>
</div>
</div>
</foreignObject>
<text x="80" y="144" fill="#333333" font-family="Helvetica" font-size="12px" text-anchor="middle">
Plugin X Backend
</text>
</switch>
</g>
<rect x="20" y="240" width="120" height="40" fill="#f5f5f5" stroke="#666666" stroke-dasharray="3 3" pointer-events="all"/>
<g transform="translate(-0.5 -0.5)">
<switch>
<foreignObject pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow: visible; text-align: left;">
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 118px; height: 1px; padding-top: 260px; margin-left: 21px;">
<div data-drawio-colors="color: #333333; " style="box-sizing: border-box; font-size: 0px; text-align: center;">
<div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(51, 51, 51); line-height: 1.2; pointer-events: all; white-space: normal; overflow-wrap: normal;">
Plugin X Frontend
</div>
</div>
</div>
</foreignObject>
<text x="80" y="264" fill="#333333" font-family="Helvetica" font-size="12px" text-anchor="middle">
Plugin X Frontend
</text>
</switch>
</g>
<path d="M 320 140 L 390 140 Q 400 140 400 130 L 400 70 Q 400 60 410 60 L 431.88 60" fill="none" stroke="#666666" stroke-miterlimit="10" pointer-events="stroke"/>
<path d="M 438.88 60 L 431.88 62.33 L 431.88 57.67 Z" fill="#666666" stroke="#666666" stroke-miterlimit="10" pointer-events="all"/>
<path d="M 480 80 L 480 111.88" fill="none" stroke="#666666" stroke-miterlimit="10" pointer-events="stroke"/>
<path d="M 480 118.88 L 477.67 111.88 L 482.33 111.88 Z" fill="#666666" stroke="#666666" stroke-miterlimit="10" pointer-events="all"/>
<path d="M 480 80 L 480 90 Q 480 100 490 100 L 560 100 Q 570 100 570 105.94 L 570 111.88" fill="none" stroke="#666666" stroke-miterlimit="10" pointer-events="stroke"/>
<path d="M 570 118.88 L 567.67 111.88 L 572.33 111.88 Z" fill="#666666" stroke="#666666" stroke-miterlimit="10" pointer-events="all"/>
<path d="M 480 80 L 480 90 Q 480 100 490 100 L 620 100 Q 630 100 630 105.94 L 630 111.88" fill="none" stroke="#666666" stroke-miterlimit="10" pointer-events="stroke"/>
<path d="M 630 118.88 L 627.67 111.88 L 632.33 111.88 Z" fill="#666666" stroke="#666666" stroke-miterlimit="10" pointer-events="all"/>
<rect x="440" y="40" width="80" height="40" fill="#f5f5f5" stroke="#666666" pointer-events="all"/>
<g transform="translate(-0.5 -0.5)">
<switch>
<foreignObject pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow: visible; text-align: left;">
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 78px; height: 1px; padding-top: 60px; margin-left: 441px;">
<div data-drawio-colors="color: #333333; " style="box-sizing: border-box; font-size: 0px; text-align: center;">
<div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(51, 51, 51); line-height: 1.2; pointer-events: all; white-space: normal; overflow-wrap: normal;">
Event Broker
</div>
</div>
</div>
</foreignObject>
<text x="480" y="64" fill="#333333" font-family="Helvetica" font-size="12px" text-anchor="middle">
Event Broker
</text>
</switch>
</g>
<rect x="550" y="120" width="40" height="40" fill="#f5f5f5" stroke="#666666" pointer-events="all"/>
<rect x="610" y="120" width="40" height="40" fill="#f5f5f5" stroke="#666666" pointer-events="all"/>
<rect x="530" y="120" width="20" height="40" fill="none" stroke="none" pointer-events="all"/>
<g transform="translate(-0.5 -0.5)">
<switch>
<foreignObject pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow: visible; text-align: left;">
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 18px; height: 1px; padding-top: 140px; margin-left: 531px;">
<div data-drawio-colors="color: rgb(0, 0, 0); " style="box-sizing: border-box; font-size: 0px; text-align: center;">
<div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; white-space: normal; overflow-wrap: normal;">
...
</div>
</div>
</div>
</foreignObject>
<text x="540" y="144" fill="rgb(0, 0, 0)" font-family="Helvetica" font-size="12px" text-anchor="middle">
...
</text>
</switch>
</g>
<rect x="590" y="120" width="20" height="40" fill="none" stroke="none" pointer-events="all"/>
<g transform="translate(-0.5 -0.5)">
<switch>
<foreignObject pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow: visible; text-align: left;">
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 18px; height: 1px; padding-top: 140px; margin-left: 591px;">
<div data-drawio-colors="color: rgb(0, 0, 0); " style="box-sizing: border-box; font-size: 0px; text-align: center;">
<div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; white-space: normal; overflow-wrap: normal;">
...
</div>
</div>
</div>
</foreignObject>
<text x="600" y="144" fill="rgb(0, 0, 0)" font-family="Helvetica" font-size="12px" text-anchor="middle">
...
</text>
</switch>
</g>
<rect x="320" y="120" width="80" height="20" fill="none" stroke="none" pointer-events="all"/>
<g transform="translate(-0.5 -0.5)">
<switch>
<foreignObject pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow: visible; text-align: left;">
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 78px; height: 1px; padding-top: 130px; margin-left: 321px;">
<div data-drawio-colors="color: rgb(0, 0, 0); " style="box-sizing: border-box; font-size: 0px; text-align: center;">
<div style="display: inline-block; font-size: 10px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; white-space: normal; overflow-wrap: normal;">
publish event
</div>
</div>
</div>
</foreignObject>
<text x="360" y="133" fill="rgb(0, 0, 0)" font-family="Helvetica" font-size="10px" text-anchor="middle">
publish event
</text>
</switch>
</g>
<rect x="520" y="80" width="80" height="20" fill="none" stroke="none" pointer-events="all"/>
<g transform="translate(-0.5 -0.5)">
<switch>
<foreignObject pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow: visible; text-align: left;">
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 78px; height: 1px; padding-top: 90px; margin-left: 521px;">
<div data-drawio-colors="color: rgb(0, 0, 0); " style="box-sizing: border-box; font-size: 0px; text-align: center;">
<div style="display: inline-block; font-size: 10px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; white-space: normal; overflow-wrap: normal;">
broadcast
</div>
</div>
</div>
</foreignObject>
<text x="560" y="93" fill="rgb(0, 0, 0)" font-family="Helvetica" font-size="10px" text-anchor="middle">
broadcast
</text>
</switch>
</g>
<rect x="500" y="180" width="50" height="20" fill="none" stroke="none" pointer-events="all"/>
<g transform="translate(-0.5 -0.5)">
<switch>
<foreignObject pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow: visible; text-align: left;">
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 48px; height: 1px; padding-top: 190px; margin-left: 501px;">
<div data-drawio-colors="color: rgb(0, 0, 0); " style="box-sizing: border-box; font-size: 0px; text-align: center;">
<div style="display: inline-block; font-size: 10px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; white-space: normal; overflow-wrap: normal;">
???
</div>
</div>
</div>
</foreignObject>
<text x="525" y="193" fill="rgb(0, 0, 0)" font-family="Helvetica" font-size="10px" text-anchor="middle">
???
</text>
</switch>
</g>
<rect x="480" y="210" width="70" height="20" fill="none" stroke="none" pointer-events="all"/>
<g transform="translate(-0.5 -0.5)">
<switch>
<foreignObject pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow: visible; text-align: left;">
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 68px; height: 1px; padding-top: 220px; margin-left: 481px;">
<div data-drawio-colors="color: rgb(0, 0, 0); " style="box-sizing: border-box; font-size: 0px; text-align: center;">
<div style="display: inline-block; font-size: 10px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; white-space: normal; overflow-wrap: normal;">
server push
</div>
</div>
</div>
</foreignObject>
<text x="515" y="223" fill="rgb(0, 0, 0)" font-family="Helvetica" font-size="10px" text-anchor="middle">
server push
</text>
</switch>
</g>
<rect x="140" y="120" width="60" height="20" fill="none" stroke="none" pointer-events="all"/>
<g transform="translate(-0.5 -0.5)">
<switch>
<foreignObject pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow: visible; text-align: left;">
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 58px; height: 1px; padding-top: 130px; margin-left: 141px;">
<div data-drawio-colors="color: rgb(0, 0, 0); " style="box-sizing: border-box; font-size: 0px; text-align: center;">
<div style="display: inline-block; font-size: 10px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; white-space: normal; overflow-wrap: normal;">
POST
</div>
</div>
</div>
</foreignObject>
<text x="170" y="133" fill="rgb(0, 0, 0)" font-family="Helvetica" font-size="10px" text-anchor="middle">
POST
</text>
</switch>
</g>
<path d="M 190 60 L 158.12 60" fill="none" stroke="#666666" stroke-miterlimit="10" stroke-dasharray="3 3" pointer-events="stroke"/>
<path d="M 151.12 60 L 158.12 57.67 L 158.12 62.33 Z" fill="#666666" stroke="#666666" stroke-miterlimit="10" pointer-events="all"/>
<rect x="30" y="40" width="120" height="40" fill="#f5f5f5" stroke="#666666" stroke-dasharray="3 3" pointer-events="all"/>
<g transform="translate(-0.5 -0.5)">
<switch>
<foreignObject pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow: visible; text-align: left;">
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 118px; height: 1px; padding-top: 60px; margin-left: 31px;">
<div data-drawio-colors="color: #333333; " style="box-sizing: border-box; font-size: 0px; text-align: center;">
<div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(51, 51, 51); line-height: 1.2; pointer-events: all; white-space: normal; overflow-wrap: normal;">
External System
</div>
</div>
</div>
</foreignObject>
<text x="90" y="64" fill="#333333" font-family="Helvetica" font-size="12px" text-anchor="middle">
External System
</text>
</switch>
</g>
<rect x="30" y="20" width="120" height="20" fill="none" stroke="none" pointer-events="all"/>
<g transform="translate(-0.5 -0.5)">
<switch>
<foreignObject pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow: visible; text-align: left;">
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 118px; height: 1px; padding-top: 30px; margin-left: 31px;">
<div data-drawio-colors="color: rgb(0, 0, 0); " style="box-sizing: border-box; font-size: 0px; text-align: center;">
<div style="display: inline-block; font-size: 10px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; white-space: normal; overflow-wrap: normal;">
email/slack/teams, etc.
</div>
</div>
</div>
</foreignObject>
<text x="90" y="33" fill="rgb(0, 0, 0)" font-family="Helvetica" font-size="10px" text-anchor="middle">
email/slack/teams, etc.
</text>
</switch>
</g>
<rect x="140" y="240" width="60" height="20" fill="none" stroke="none" pointer-events="all"/>
<g transform="translate(-0.5 -0.5)">
<switch>
<foreignObject pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow: visible; text-align: left;">
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 58px; height: 1px; padding-top: 250px; margin-left: 141px;">
<div data-drawio-colors="color: rgb(0, 0, 0); " style="box-sizing: border-box; font-size: 0px; text-align: center;">
<div style="display: inline-block; font-size: 10px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; white-space: normal; overflow-wrap: normal;">
Render
</div>
</div>
</div>
</foreignObject>
<text x="170" y="253" fill="rgb(0, 0, 0)" font-family="Helvetica" font-size="10px" text-anchor="middle">
Render
</text>
</switch>
</g>
<ellipse cx="230" cy="60" rx="40" ry="20" fill="#f5f5f5" stroke="#666666" stroke-dasharray="3 3" pointer-events="all"/>
<g transform="translate(-0.5 -0.5)">
<switch>
<foreignObject pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow: visible; text-align: left;">
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 78px; height: 1px; padding-top: 60px; margin-left: 191px;">
<div data-drawio-colors="color: #333333; " style="box-sizing: border-box; font-size: 0px; text-align: center;">
<div style="display: inline-block; font-size: 10px; font-family: Helvetica; color: rgb(51, 51, 51); line-height: 1.2; pointer-events: all; white-space: normal; overflow-wrap: normal;">
<span style="color: rgb(51, 51, 51); font-size: 12px;">
Processor
</span>
</div>
</div>
</div>
</foreignObject>
<text x="230" y="63" fill="#333333" font-family="Helvetica" font-size="10px" text-anchor="middle">
Processor
</text>
</switch>
</g>
<rect x="260" y="190" width="30" height="20" fill="none" stroke="none" pointer-events="all"/>
<g transform="translate(-0.5 -0.5)">
<switch>
<foreignObject pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow: visible; text-align: left;">
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 28px; height: 1px; padding-top: 200px; margin-left: 261px;">
<div data-drawio-colors="color: rgb(0, 0, 0); " style="box-sizing: border-box; font-size: 0px; text-align: center;">
<div style="display: inline-block; font-size: 10px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; white-space: normal; overflow-wrap: normal;">
GET
</div>
</div>
</div>
</foreignObject>
<text x="275" y="203" fill="rgb(0, 0, 0)" font-family="Helvetica" font-size="10px" text-anchor="middle">
GET
</text>
</switch>
</g>
<ellipse cx="160" cy="160" rx="10" ry="10" fill="#fff2cc" stroke="#d6b656" pointer-events="all"/>
<g transform="translate(-0.5 -0.5)">
<switch>
<foreignObject pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow: visible; text-align: left;">
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 18px; height: 1px; padding-top: 160px; margin-left: 151px;">
<div data-drawio-colors="color: rgb(0, 0, 0); " style="box-sizing: border-box; font-size: 0px; text-align: center;">
<div style="display: inline-block; font-size: 10px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; white-space: normal; overflow-wrap: normal;">
1
</div>
</div>
</div>
</foreignObject>
<text x="160" y="163" fill="rgb(0, 0, 0)" font-family="Helvetica" font-size="10px" text-anchor="middle">
1
</text>
</switch>
</g>
<ellipse cx="340" cy="160" rx="10" ry="10" fill="#fff2cc" stroke="#d6b656" pointer-events="all"/>
<g transform="translate(-0.5 -0.5)">
<switch>
<foreignObject pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow: visible; text-align: left;">
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 18px; height: 1px; padding-top: 160px; margin-left: 331px;">
<div data-drawio-colors="color: rgb(0, 0, 0); " style="box-sizing: border-box; font-size: 0px; text-align: center;">
<div style="display: inline-block; font-size: 10px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; white-space: normal; overflow-wrap: normal;">
3
</div>
</div>
</div>
</foreignObject>
<text x="340" y="163" fill="rgb(0, 0, 0)" font-family="Helvetica" font-size="10px" text-anchor="middle">
3
</text>
</switch>
</g>
<ellipse cx="210" cy="100" rx="10" ry="10" fill="#fff2cc" stroke="#d6b656" pointer-events="all"/>
<g transform="translate(-0.5 -0.5)">
<switch>
<foreignObject pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow: visible; text-align: left;">
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 18px; height: 1px; padding-top: 100px; margin-left: 201px;">
<div data-drawio-colors="color: rgb(0, 0, 0); " style="box-sizing: border-box; font-size: 0px; text-align: center;">
<div style="display: inline-block; font-size: 10px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; white-space: normal; overflow-wrap: normal;">
1b
</div>
</div>
</div>
</foreignObject>
<text x="210" y="103" fill="rgb(0, 0, 0)" font-family="Helvetica" font-size="10px" text-anchor="middle">
1b
</text>
</switch>
</g>
<ellipse cx="460" cy="100" rx="10" ry="10" fill="#fff2cc" stroke="#d6b656" pointer-events="all"/>
<g transform="translate(-0.5 -0.5)">
<switch>
<foreignObject pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow: visible; text-align: left;">
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 18px; height: 1px; padding-top: 100px; margin-left: 451px;">
<div data-drawio-colors="color: rgb(0, 0, 0); " style="box-sizing: border-box; font-size: 0px; text-align: center;">
<div style="display: inline-block; font-size: 10px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; white-space: normal; overflow-wrap: normal;">
4
</div>
</div>
</div>
</foreignObject>
<text x="460" y="103" fill="rgb(0, 0, 0)" font-family="Helvetica" font-size="10px" text-anchor="middle">
4
</text>
</switch>
</g>
<ellipse cx="500" cy="180" rx="10" ry="10" fill="#fff2cc" stroke="#d6b656" pointer-events="all"/>
<g transform="translate(-0.5 -0.5)">
<switch>
<foreignObject pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow: visible; text-align: left;">
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 18px; height: 1px; padding-top: 180px; margin-left: 491px;">
<div data-drawio-colors="color: rgb(0, 0, 0); " style="box-sizing: border-box; font-size: 0px; text-align: center;">
<div style="display: inline-block; font-size: 10px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; white-space: normal; overflow-wrap: normal;">
5
</div>
</div>
</div>
</foreignObject>
<text x="500" y="183" fill="rgb(0, 0, 0)" font-family="Helvetica" font-size="10px" text-anchor="middle">
5
</text>
</switch>
</g>
<ellipse cx="410" cy="280" rx="10" ry="10" fill="#fff2cc" stroke="#d6b656" pointer-events="all"/>
<g transform="translate(-0.5 -0.5)">
<switch>
<foreignObject pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow: visible; text-align: left;">
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 18px; height: 1px; padding-top: 280px; margin-left: 401px;">
<div data-drawio-colors="color: rgb(0, 0, 0); " style="box-sizing: border-box; font-size: 0px; text-align: center;">
<div style="display: inline-block; font-size: 10px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; white-space: normal; overflow-wrap: normal;">
6
</div>
</div>
</div>
</foreignObject>
<text x="410" y="283" fill="rgb(0, 0, 0)" font-family="Helvetica" font-size="10px" text-anchor="middle">
6
</text>
</switch>
</g>
<ellipse cx="180" cy="280" rx="10" ry="10" fill="#fff2cc" stroke="#d6b656" pointer-events="all"/>
<g transform="translate(-0.5 -0.5)">
<switch>
<foreignObject pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow: visible; text-align: left;">
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 18px; height: 1px; padding-top: 280px; margin-left: 171px;">
<div data-drawio-colors="color: rgb(0, 0, 0); " style="box-sizing: border-box; font-size: 0px; text-align: center;">
<div style="display: inline-block; font-size: 10px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; white-space: normal; overflow-wrap: normal;">
7b
</div>
</div>
</div>
</foreignObject>
<text x="180" y="283" fill="rgb(0, 0, 0)" font-family="Helvetica" font-size="10px" text-anchor="middle">
7b
</text>
</switch>
</g>
<ellipse cx="240" cy="220" rx="10" ry="10" fill="#fff2cc" stroke="#d6b656" pointer-events="all"/>
<g transform="translate(-0.5 -0.5)">
<switch>
<foreignObject pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow: visible; text-align: left;">
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 18px; height: 1px; padding-top: 220px; margin-left: 231px;">
<div data-drawio-colors="color: rgb(0, 0, 0); " style="box-sizing: border-box; font-size: 0px; text-align: center;">
<div style="display: inline-block; font-size: 10px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; white-space: normal; overflow-wrap: normal;">
7
</div>
</div>
</div>
</foreignObject>
<text x="240" y="223" fill="rgb(0, 0, 0)" font-family="Helvetica" font-size="10px" text-anchor="middle">
7
</text>
</switch>
</g>
<path d="M 300 34.96 C 300 32.22 308.95 30 320 30 C 325.3 30 330.39 30.52 334.14 31.45 C 337.89 32.38 340 33.65 340 34.96 L 340 75.04 C 340 76.35 337.89 77.62 334.14 78.55 C 330.39 79.48 325.3 80 320 80 C 314.7 80 309.61 79.48 305.86 78.55 C 302.11 77.62 300 76.35 300 75.04 Z" fill="#f5f5f5" stroke="#666666" stroke-miterlimit="10" pointer-events="all"/>
<path d="M 340 34.96 C 340 36.28 337.89 37.54 334.14 38.47 C 330.39 39.4 325.3 39.93 320 39.93 C 314.7 39.93 309.61 39.4 305.86 38.47 C 302.11 37.54 300 36.28 300 34.96" fill="none" stroke="#666666" stroke-miterlimit="10" pointer-events="all"/>
<ellipse cx="275" cy="100" rx="10" ry="10" fill="#fff2cc" stroke="#d6b656" pointer-events="all"/>
<g transform="translate(-0.5 -0.5)">
<switch>
<foreignObject pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow: visible; text-align: left;">
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 18px; height: 1px; padding-top: 100px; margin-left: 266px;">
<div data-drawio-colors="color: rgb(0, 0, 0); " style="box-sizing: border-box; font-size: 0px; text-align: center;">
<div style="display: inline-block; font-size: 10px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; white-space: normal; overflow-wrap: normal;">
2
</div>
</div>
</div>
</foreignObject>
<text x="275" y="103" fill="rgb(0, 0, 0)" font-family="Helvetica" font-size="10px" text-anchor="middle">
2
</text>
</switch>
</g>
<rect x="320" y="240" width="110" height="20" fill="none" stroke="none" pointer-events="all"/>
<g transform="translate(-0.5 -0.5)">
<switch>
<foreignObject pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow: visible; text-align: left;">
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 108px; height: 1px; padding-top: 250px; margin-left: 321px;">
<div data-drawio-colors="color: rgb(0, 0, 0); " style="box-sizing: border-box; font-size: 0px; text-align: center;">
<div style="display: inline-block; font-size: 10px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; white-space: normal; overflow-wrap: normal;">
Channel Callback
</div>
</div>
</div>
</foreignObject>
<text x="375" y="253" fill="rgb(0, 0, 0)" font-family="Helvetica" font-size="10px" text-anchor="middle">
Channel Callback
</text>
</switch>
</g>
</g>
<switch>
<g requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"/>
<a transform="translate(0,-5)" xlink:href="https://www.diagrams.net/doc/faq/svg-export-text-problems" target="_blank">
<text text-anchor="middle" font-size="10px" x="50%" y="100%">
Text is not SVG - cannot display
</text>
</a>
</switch>
</svg>

After

Width:  |  Height:  |  Size: 44 KiB

+63 -5
View File
@@ -65,20 +65,72 @@ This is an array used to determine where to retrieve cluster configuration from.
Valid cluster locator methods are:
- [`catalog`](#catalog)
- [`localKubectlProxy`](#localkubectlproxy)
- [`config`](#config)
- [`gke`](#gke)
- [`localKubectlProxy`](#localkubectlproxy)
- [custom `KubernetesClustersSupplier`](#custom-kubernetesclusterssupplier)
#### `catalog`
This cluster locator method will read cluster information from the catalog.
This cluster locator method will gather
[Resources](https://backstage.io/docs/features/software-catalog/system-model#resource)
of
[type](https://backstage.io/docs/features/software-catalog/descriptor-format#spectype-required-4)
`kubernetes-cluster` from the catalog and treat them as clusters for the
purposes of the Kubernetes plugin. In order for a resource to be detected by
this method, it must also have the following
[annotations](https://backstage.io/docs/features/software-catalog/descriptor-format#annotations-optional)
(as seen
[here](https://github.com/backstage/backstage/blob/86baccb2d7d378baed74eaebf017c60b410986e5/plugins/kubernetes-backend/src/cluster-locator/CatalogClusterLocator.ts#L51-L61)
in the code):
#### `localKubectlProxy`
- [`kubernetes.io/api-server`](https://backstage.io/docs/reference/plugin-kubernetes-common.annotation_kubernetes_api_server/),
denoting the base URL of the Kubernetes control plane
- [`kubernetes.io/api-server-certificate-authority`](https://backstage.io/docs/reference/plugin-kubernetes-common.annotation_kubernetes_api_server_ca/),
containing a base64-encoded certificate authority bundle in PEM format;
Backstage will check that the control plane presents a certificate signed by
this authority.
- [`kubernetes.io/auth-provider`](https://backstage.io/docs/reference/plugin-kubernetes-common.annotation_kubernetes_auth_provider/),
denoting the strategy to use to authenticate with the control plane.
This cluster locator method will assume a locally running [`kubectl proxy`](https://kubernetes.io/docs/tasks/extend-kubernetes/http-proxy-access-api/#using-kubectl-to-start-a-proxy-server) process using the default port (8001).
There are many other annotations that can be applied to a cluster resource to
configure the way Backstage communicates, documented
[here](https://backstage.io/docs/reference/plugin-kubernetes-common#variables)
in the API reference. Here is a YAML snippet illustrating an example of a
cluster in the catalog:
NOTE: This cluster locator method is for local development only and should not be used in production.
```yaml
apiVersion: backstage.io/v1alpha1
kind: Resource
metadata:
name: my-cluster
annotations:
kubernetes.io/api-server: 'https://127.0.0.1:53725'
kubernetes.io/api-server-certificate-authority: # base64-encoded CA
kubernetes.io/auth-provider: 'oidc'
kubernetes.io/oidc-token-provider: 'microsoft'
kubernetes.io/skip-metrics-lookup: 'true'
spec:
type: kubernetes-cluster
owner: user:guest
```
Note that it is insecure to store a Kubernetes service account token in an
annotation on a catalog entity (where it could easily be accidentally revealed
by the catalog API) -- therefore there is no annotation corresponding to the
[`serviceAccountToken` field](#clustersserviceaccounttoken-optional) used by
the [`config`](#config) cluster locator. Accordingly, the catalog cluster
locator does not support the [`serviceAccount`](#clustersauthprovider) auth
strategy.
This method can be quite helpful when used in combination with an ingestion
procedure like the
[`GkeEntityProvider`](https://backstage.io/docs/reference/plugin-catalog-backend-module-gcp.gkeentityprovider/)
(installation documented
[here](https://github.com/backstage/backstage/tree/master/plugins/catalog-backend-module-gcp#installation))
or the
[`AwsEKSClusterProcessor`](https://backstage.io/docs/reference/plugin-catalog-backend-module-aws.awseksclusterprocessor/)
to automatically update the set of clusters tracked by Backstage.
#### `config`
@@ -388,6 +440,12 @@ Defaults to `false`.
Array of key value labels used to filter out clusters which don't have the matching
[resource labels](https://cloud.google.com/resource-manager/docs/creating-managing-labels).
#### `localKubectlProxy`
This cluster locator method will assume a locally running [`kubectl proxy`](https://kubernetes.io/docs/tasks/extend-kubernetes/http-proxy-access-api/#using-kubectl-to-start-a-proxy-server) process using the default port (8001).
NOTE: This cluster locator method is for local development only and should not be used in production.
#### Custom `KubernetesClustersSupplier`
If the configuration-based cluster locators do not work for your use-case,
+1 -1
View File
@@ -5,6 +5,6 @@ authorUrl: https://snyk.io
category: Security
description: View Snyk scanned vulnerabilities and license compliance of your components directly in Backstage.
documentation: https://github.com/snyk-tech-services/backstage-plugin-snyk/blob/main/README.md
iconUrl: https://storage.googleapis.com/snyk-technical-services.appspot.com/snyk-logo-vertical-black.png
iconUrl: https://github.com/snyk-tech-services/backstage-plugin-snyk/assets/109112986/95eb1b06-b3e8-4910-9233-11926e02fa18
npmPackageName: 'backstage-plugin-snyk'
addedDate: '2021-01-22'
+1 -1
View File
@@ -21,6 +21,7 @@
"@backstage/plugin-airbrake": "workspace:^",
"@backstage/plugin-apache-airflow": "workspace:^",
"@backstage/plugin-api-docs": "workspace:^",
"@backstage/plugin-app-visualizer": "workspace:^",
"@backstage/plugin-azure-devops": "workspace:^",
"@backstage/plugin-azure-sites": "workspace:^",
"@backstage/plugin-badges": "workspace:^",
@@ -74,7 +75,6 @@
"@backstage/plugin-techdocs-react": "workspace:^",
"@backstage/plugin-todo": "workspace:^",
"@backstage/plugin-user-settings": "workspace:^",
"@backstage/plugin-visualizer": "workspace:^",
"@backstage/theme": "workspace:^",
"@circleci/backstage-plugin": "^0.1.1",
"@material-ui/core": "^4.12.2",
+1 -1
View File
@@ -32,7 +32,7 @@ import {
createExtensionOverrides,
} from '@backstage/frontend-plugin-api';
import techdocsPlugin from '@backstage/plugin-techdocs/alpha';
import appVisualizerPlugin from '@backstage/plugin-visualizer';
import appVisualizerPlugin from '@backstage/plugin-app-visualizer';
import { homePage } from './HomePage';
import { convertLegacyApp } from '@backstage/core-compat-api';
import { FlatRoutes } from '@backstage/core-app-api';
@@ -105,58 +105,28 @@ export class GithubUrlReader implements UrlReader {
credentials,
);
let response: Response;
try {
response = await fetch(ghUrl, {
headers: {
...credentials?.headers,
...(options?.etag && { 'If-None-Match': options.etag }),
...(options?.lastModifiedAfter && {
'If-Modified-Since': options.lastModifiedAfter.toUTCString(),
}),
Accept: 'application/vnd.github.v3.raw',
},
// TODO(freben): The signal cast is there because pre-3.x versions of
// node-fetch have a very slightly deviating AbortSignal type signature.
// The difference does not affect us in practice however. The cast can
// be removed after we support ESM for CLI dependencies and migrate to
// version 3 of node-fetch.
// https://github.com/backstage/backstage/issues/8242
signal: options?.signal as any,
});
} catch (e) {
throw new Error(`Unable to read ${url}, ${e}`);
}
const response = await this.fetchResponse(ghUrl, {
headers: {
...credentials?.headers,
...(options?.etag && { 'If-None-Match': options.etag }),
...(options?.lastModifiedAfter && {
'If-Modified-Since': options.lastModifiedAfter.toUTCString(),
}),
Accept: 'application/vnd.github.v3.raw',
},
// TODO(freben): The signal cast is there because pre-3.x versions of
// node-fetch have a very slightly deviating AbortSignal type signature.
// The difference does not affect us in practice however. The cast can
// be removed after we support ESM for CLI dependencies and migrate to
// version 3 of node-fetch.
// https://github.com/backstage/backstage/issues/8242
signal: options?.signal as any,
});
if (response.status === 304) {
throw new NotModifiedError();
}
if (response.ok) {
return ReadUrlResponseFactory.fromNodeJSReadable(response.body, {
etag: response.headers.get('ETag') ?? undefined,
lastModifiedAt: parseLastModified(
response.headers.get('Last-Modified'),
),
});
}
let message = `${url} could not be read as ${ghUrl}, ${response.status} ${response.statusText}`;
if (response.status === 404) {
throw new NotFoundError(message);
}
// GitHub returns a 403 response with a couple of headers indicating rate
// limit status. See more in the GitHub docs:
// https://docs.github.com/en/rest/overview/resources-in-the-rest-api#rate-limiting
if (
response.status === 403 &&
response.headers.get('X-RateLimit-Remaining') === '0'
) {
message += ' (rate limit exceeded)';
}
throw new Error(message);
return ReadUrlResponseFactory.fromNodeJSReadable(response.body, {
etag: response.headers.get('ETag') ?? undefined,
lastModifiedAt: parseLastModified(response.headers.get('Last-Modified')),
});
}
async readTree(
@@ -350,10 +320,26 @@ export class GithubUrlReader implements UrlReader {
const response = await fetch(urlAsString, init);
if (!response.ok) {
const message = `Request failed for ${urlAsString}, ${response.status} ${response.statusText}`;
let message = `Request failed for ${urlAsString}, ${response.status} ${response.statusText}`;
if (response.status === 304) {
throw new NotModifiedError();
}
if (response.status === 404) {
throw new NotFoundError(message);
}
// GitHub returns a 403 response with a couple of headers indicating rate
// limit status. See more in the GitHub docs:
// https://docs.github.com/en/rest/overview/resources-in-the-rest-api#rate-limiting
if (
response.status === 403 &&
response.headers.get('X-RateLimit-Remaining') === '0'
) {
message += ' (rate limit exceeded)';
}
throw new Error(message);
}
@@ -1,4 +1,4 @@
# @backstage/backend-plugin-manager
# @backstage/backend-dynamic-feature-service
## 0.0.5-next.2
@@ -1,10 +1,6 @@
# @backstage/backend-plugin-manager
# @backstage/backend-dynamic-feature-service
This package adds experimental support for **dynamic backend plugins**, according to the content of the proposal in [RFC 18390](https://github.com/backstage/backstage/issues/18390)
## Status
**This package is EXPERIMENTAL, and is subject to change according to the discussions and conclusions that happen around the RFC mentioned above.**
This package adds experimental support for **dynamic backend features (plugins and modules)**, according to the content of the proposal in [RFC 18390](https://github.com/backstage/backstage/issues/18390)
## Testing the backend dynamic plugins feature
@@ -12,9 +8,9 @@ In order to test the dynamic backend plugins feature provided by this package, e
## How it works
The backend plugin manager is a service that scans a configured root directory (`dynamicPlugins.rootDirectory` in the app config) for dynamic plugin packages, and loads them dynamically.
The dynamic plugin manager is a service that scans a configured root directory (`dynamicPlugins.rootDirectory` in the app config) for dynamic plugin packages, and loads them dynamically.
In the `backend-next` application, it can be enabled by adding the `backend-plugin-manager` as a dependency in the `package.json` and the following lines in the `src/index.ts` file:
In the `backend-next` application, it can be enabled by adding the `backend-dynamic-feature-service` as a dependency in the `package.json` and the following lines in the `src/index.ts` file:
```ts
const backend = createBackend();
@@ -36,9 +32,9 @@ Points 2 and 3 can be done by the `export-dynamic-plugin` CLI command used to pe
### About the `export-dynamic-plugin` command
The `export-dynamic-plugin` CLI command, used the dynamic plugin examples provided in the [showcase repository](https://github.com/janus-idp/dynamic-backend-plugins-showcase), is part of a `@backstage/cli` fork (`@dfatwork-pkgs/backstage-cli@0.22.9-next.6`), and can be used to help packaging the dynamic plugins according to the constraints mentioned above, in order to allow straightforward testing of the dynamic plugins feature.
The `export-dynamic-plugin` CLI command, used the dynamic plugin examples provided in the [showcase repository](https://github.com/janus-idp/dynamic-backend-plugins-showcase), is part of the `@janus-idp/cli` package, and can be used to help packaging the dynamic plugins according to the constraints mentioned above, in order to allow straightforward testing of the dynamic plugins feature.
However the `backend-plugin-manager` experimental package does **not** depend on the use of this additional CLI command, and in future steps of this backend dynamic plugin work, the use of such a dedicated command might not even be necessary.
However the `backend-dynamic-feature-service` experimental package does **not** depend on the use of this additional CLI command, and in future steps of this backend dynamic plugin work, the use of such a dedicated command might not even be necessary.
### About the support of the legacy backend system
@@ -49,4 +45,4 @@ This is why the API related to the old backend is already marked as deprecated.
### Future work
The current implementation of the backend plugin manager is a first step towards the final implementation of the dynamic backend plugins feature, which will be completed / simplified in future steps, as the status of the backstage codebase allows it.
The current implementation of the dynamic plugin manager is a first step towards the final implementation of the dynamic features loading, which will be completed / simplified in future steps, as the status of the backstage codebase allows it.
@@ -1,4 +1,4 @@
## API Report File for "@backstage/backend-plugin-manager"
## API Report File for "@backstage/backend-dynamic-feature-service"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
@@ -0,0 +1,10 @@
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: backstage-backend-dynamic-feature-service
title: '@backstage/backend-dynamic-feature-service'
description: Backstage backend service to handle dynamic features
spec:
lifecycle: experimental
type: backstage-node-library
owner: maintainers
@@ -1,8 +1,7 @@
{
"name": "@backstage/backend-plugin-manager",
"description": "Backstage plugin management backend",
"version": "0.0.5-next.2",
"private": true,
"name": "@backstage/backend-dynamic-feature-service",
"description": "Backstage dynamic feature service",
"version": "0.0.0",
"main": "src/index.ts",
"types": "src/index.ts",
"publishConfig": {
@@ -47,7 +47,7 @@ import { PluginScanner } from '../scanner/plugin-scanner';
import { findPaths } from '@backstage/cli-common';
import { createMockDirectory } from '@backstage/backend-test-utils';
describe('backend-plugin-manager', () => {
describe('backend-dynamic-feature-service', () => {
const mockDir = createMockDirectory();
describe('loadPlugins', () => {
@@ -1,10 +0,0 @@
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: backstage-backend-plugin-manager
title: '@backstage/backend-plugin-manager'
description: Backstage plugin management backend
spec:
lifecycle: experimental
type: backstage-node-library
owner: maintainers
@@ -14,11 +14,20 @@
* limitations under the License.
*/
import React, { ComponentType, ReactNode, useContext, useState } from 'react';
import React, {
ComponentType,
Fragment,
PropsWithChildren,
ReactNode,
useContext,
useState,
} from 'react';
import {
coreExtensionData,
createAppRootWrapperExtension,
createExtension,
createExtensionInput,
createRouterExtension,
createSignInPageExtension,
} from '@backstage/frontend-plugin-api';
import {
@@ -39,27 +48,49 @@ export const AppRoot = createExtension({
name: 'root',
attachTo: { id: 'app', input: 'root' },
inputs: {
router: createExtensionInput(
{ component: createRouterExtension.componentDataRef },
{ singleton: true, optional: true },
),
signInPage: createExtensionInput(
{
component: createSignInPageExtension.componentDataRef,
},
{ component: createSignInPageExtension.componentDataRef },
{ singleton: true, optional: true },
),
children: createExtensionInput(
{
element: coreExtensionData.reactElement,
},
{ element: coreExtensionData.reactElement },
{ singleton: true },
),
elements: createExtensionInput({
element: coreExtensionData.reactElement,
}),
wrappers: createExtensionInput({
component: createAppRootWrapperExtension.componentDataRef,
}),
},
output: {
element: coreExtensionData.reactElement,
},
factory({ inputs }) {
let content: React.ReactNode = (
<>
{inputs.elements.map(el => (
<Fragment key={el.node.spec.id}>{el.output.element}</Fragment>
))}
{inputs.children.output.element}
</>
);
for (const wrapper of inputs.wrappers) {
content = <wrapper.output.component>{content}</wrapper.output.component>;
}
return {
element: (
<AppRouter SignInPageComponent={inputs.signInPage?.output.component}>
{inputs.children.output.element}
<AppRouter
SignInPageComponent={inputs.signInPage?.output.component}
RouterComponent={inputs.router?.output.component}
>
{content}
</AppRouter>
),
};
@@ -111,12 +142,18 @@ function SignInPageWrapper({
export interface AppRouterProps {
children?: ReactNode;
SignInPageComponent?: ComponentType<SignInPageProps>;
RouterComponent?: ComponentType<PropsWithChildren<{}>>;
}
function DefaultRouter(props: PropsWithChildren<{}>) {
const configApi = useApi(configApiRef);
const basePath = getBasePath(configApi);
return <BrowserRouter basename={basePath}>{props.children}</BrowserRouter>;
}
/**
* App router and sign-in page wrapper.
*
* @public
* @remarks
*
* The AppRouter provides the routing context and renders the sign-in page.
@@ -125,7 +162,11 @@ export interface AppRouterProps {
* the app, while providing routing and route tracking for the app.
*/
export function AppRouter(props: AppRouterProps) {
const { children, SignInPageComponent } = props;
const {
children,
SignInPageComponent,
RouterComponent = DefaultRouter,
} = props;
const configApi = useApi(configApiRef);
const basePath = getBasePath(configApi);
@@ -161,15 +202,15 @@ export function AppRouter(props: AppRouterProps) {
);
return (
<BrowserRouter basename={basePath}>
<RouterComponent>
<RouteTracker routeObjects={routeObjects} />
{children}
</BrowserRouter>
</RouterComponent>
);
}
return (
<BrowserRouter basename={basePath}>
<RouterComponent>
<RouteTracker routeObjects={routeObjects} />
<SignInPageWrapper
component={SignInPageComponent}
@@ -177,6 +218,6 @@ export function AppRouter(props: AppRouterProps) {
>
{children}
</SignInPageWrapper>
</BrowserRouter>
</RouterComponent>
);
}
@@ -30,6 +30,7 @@ import { AppNodeSpec } from '@backstage/frontend-plugin-api';
import { resolveAppTree } from './resolveAppTree';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { resolveExtensionDefinition } from '../../../frontend-plugin-api/src/wiring/resolveExtensionDefinition';
import { withLogCollector } from '@backstage/test-utils';
const testDataRef = createExtensionDataRef<string>('test');
const otherDataRef = createExtensionDataRef<number>('other');
@@ -433,8 +434,8 @@ describe('createAppNodeInstance', () => {
);
});
it('should refuse to create an instance with undeclared inputs', () => {
expect(() =>
it('should warn when creating an instance with undeclared inputs', () => {
const { warn } = withLogCollector(['warn'], () =>
createAppNodeInstance({
attachments: new Map([
[
@@ -458,7 +459,7 @@ describe('createAppNodeInstance', () => {
resolveExtensionDefinition(
createExtension({
namespace: 'app',
name: 'test',
name: 'parent',
attachTo: { id: 'ignored', input: 'ignored' },
inputs: {
declared: createExtensionInput({
@@ -471,13 +472,15 @@ describe('createAppNodeInstance', () => {
),
),
}),
).toThrow(
"Failed to instantiate extension 'app/test', received undeclared input 'undeclared' from extension 'app/test'",
);
expect(warn).toEqual([
"The extension 'app/test' is attached to the input 'undeclared' of the extension 'app/parent', but it has no such input (candidates are 'declared')",
]);
});
it('should refuse to create an instance with multiple undeclared inputs', () => {
expect(() =>
const { warn } = withLogCollector(['warn'], () =>
createAppNodeInstance({
attachments: new Map([
[
@@ -496,7 +499,7 @@ describe('createAppNodeInstance', () => {
resolveExtensionDefinition(
createExtension({
namespace: 'app',
name: 'test',
name: 'parent',
attachTo: { id: 'ignored', input: 'ignored' },
output: {},
factory: () => ({}),
@@ -504,9 +507,12 @@ describe('createAppNodeInstance', () => {
),
),
}),
).toThrow(
"Failed to instantiate extension 'app/test', received undeclared inputs 'undeclared1' from extension 'app/test' and 'undeclared2' from extensions 'app/test', 'app/test'",
);
expect(warn).toEqual([
"The extension 'app/test' is attached to the input 'undeclared1' of the extension 'app/parent', but it has no inputs",
"The extensions 'app/test', 'app/test' are attached to the input 'undeclared2' of the extension 'app/parent', but it has no inputs",
]);
});
it('should refuse to create an instance with multiple inputs for required singleton', () => {
@@ -46,26 +46,32 @@ function resolveInputData(
}
function resolveInputs(
id: string,
inputMap: AnyExtensionInputMap,
attachments: ReadonlyMap<string, AppNode[]>,
): ResolvedExtensionInputs<AnyExtensionInputMap> {
const undeclaredAttachments = Array.from(attachments.entries()).filter(
([inputName]) => inputMap[inputName] === undefined,
);
// TODO: Make this a warning rather than an error
if (undeclaredAttachments.length > 0) {
throw new Error(
`received undeclared input${
undeclaredAttachments.length > 1 ? 's' : ''
} ${undeclaredAttachments
.map(
([k, exts]) =>
`'${k}' from extension${exts.length > 1 ? 's' : ''} '${exts
.map(e => e.spec.id)
.join("', '")}'`,
)
.join(' and ')}`,
);
if (process.env.NODE_ENV !== 'production') {
const inputNames = Object.keys(inputMap);
for (const [name, nodes] of undeclaredAttachments) {
const pl = nodes.length > 1;
// eslint-disable-next-line no-console
console.warn(
[
`The extension${pl ? 's' : ''} '${nodes
.map(n => n.spec.id)
.join("', '")}' ${pl ? 'are' : 'is'}`,
`attached to the input '${name}' of the extension '${id}', but it`,
inputNames.length === 0
? 'has no inputs'
: `has no such input (candidates are '${inputNames.join("', '")}')`,
].join(' '),
);
}
}
return mapValues(inputMap, (input, inputName) => {
@@ -129,7 +135,7 @@ export function createAppNodeInstance(options: {
const namedOutputs = internalExtension.factory({
node,
config: parsedConfig,
inputs: resolveInputs(internalExtension.inputs, attachments),
inputs: resolveInputs(id, internalExtension.inputs, attachments),
});
for (const [name, output] of Object.entries(namedOutputs)) {
@@ -67,6 +67,7 @@ import { OpenIdConnectApi } from '@backstage/core-plugin-api';
import { PendingOAuthRequest } from '@backstage/core-plugin-api';
import { ProfileInfo } from '@backstage/core-plugin-api';
import { ProfileInfoApi } from '@backstage/core-plugin-api';
import { PropsWithChildren } from 'react';
import { default as React_2 } from 'react';
import { ReactNode } from 'react';
import { SessionApi } from '@backstage/core-plugin-api';
@@ -390,6 +391,61 @@ export { createApiFactory };
export { createApiRef };
// @public
export function createAppRootElementExtension<
TConfig extends {},
TInputs extends AnyExtensionInputMap,
>(options: {
namespace?: string;
name?: string;
attachTo?: {
id: string;
input: string;
};
configSchema?: PortableSchema<TConfig>;
disabled?: boolean;
inputs?: TInputs;
element:
| JSX_2.Element
| ((options: {
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
config: TConfig;
}) => JSX_2.Element);
}): ExtensionDefinition<TConfig>;
// @public
export function createAppRootWrapperExtension<
TConfig extends {},
TInputs extends AnyExtensionInputMap,
>(options: {
namespace?: string;
name?: string;
attachTo?: {
id: string;
input: string;
};
configSchema?: PortableSchema<TConfig>;
disabled?: boolean;
inputs?: TInputs;
Component: ComponentType<
PropsWithChildren<{
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
config: TConfig;
}>
>;
}): ExtensionDefinition<TConfig>;
// @public (undocumented)
export namespace createAppRootWrapperExtension {
const // (undocumented)
componentDataRef: ConfigurableExtensionDataRef<
React_2.ComponentType<{
children?: React_2.ReactNode;
}>,
{}
>;
}
// @public (undocumented)
export function createComponentExtension<
TProps extends {},
@@ -631,6 +687,39 @@ export function createRouteRef<
}
>;
// @public
export function createRouterExtension<
TConfig extends {},
TInputs extends AnyExtensionInputMap,
>(options: {
namespace?: string;
name?: string;
attachTo?: {
id: string;
input: string;
};
configSchema?: PortableSchema<TConfig>;
disabled?: boolean;
inputs?: TInputs;
Component: ComponentType<
PropsWithChildren<{
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
config: TConfig;
}>
>;
}): ExtensionDefinition<TConfig>;
// @public (undocumented)
export namespace createRouterExtension {
const // (undocumented)
componentDataRef: ConfigurableExtensionDataRef<
React_2.ComponentType<{
children?: React_2.ReactNode;
}>,
{}
>;
}
// @public (undocumented)
export function createSchemaFromZod<TOutput, TInput>(
schemaCreator: (zImpl: typeof z) => ZodSchema<TOutput, ZodTypeDef, TInput>,
+16 -16
View File
@@ -22,6 +22,21 @@
"prepack": "backstage-cli package prepack",
"postpack": "backstage-cli package postpack"
},
"dependencies": {
"@backstage/core-components": "workspace:^",
"@backstage/core-plugin-api": "workspace:^",
"@backstage/types": "workspace:^",
"@backstage/version-bridge": "workspace:^",
"@material-ui/core": "^4.12.4",
"@types/react": "^16.13.1 || ^17.0.0",
"lodash": "^4.17.21",
"zod": "^3.22.4",
"zod-to-json-schema": "^3.21.4"
},
"peerDependencies": {
"react": "^16.13.1 || ^17.0.0 || ^18.0.0",
"react-router-dom": "6.0.0-beta.0 || ^6.3.0"
},
"devDependencies": {
"@backstage/cli": "workspace:^",
"@backstage/frontend-app-api": "workspace:^",
@@ -33,20 +48,5 @@
},
"files": [
"dist"
],
"peerDependencies": {
"react": "^16.13.1 || ^17.0.0 || ^18.0.0",
"react-router-dom": "6.0.0-beta.0 || ^6.3.0"
},
"dependencies": {
"@backstage/core-components": "workspace:^",
"@backstage/core-plugin-api": "workspace:^",
"@backstage/types": "workspace:^",
"@backstage/version-bridge": "workspace:^",
"@material-ui/core": "^4.12.4",
"@types/react": "^16.13.1 || ^17.0.0",
"lodash": "^4.17.21",
"zod": "^3.22.4",
"zod-to-json-schema": "^3.21.4"
}
]
}
@@ -0,0 +1,107 @@
/*
* Copyright 2023 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 { createExtensionTester } from '@backstage/frontend-test-utils';
import { screen } from '@testing-library/react';
import React from 'react';
import { createSchemaFromZod } from '../schema/createSchemaFromZod';
import { coreExtensionData } from '../wiring/coreExtensionData';
import { createExtension } from '../wiring/createExtension';
import { createExtensionInput } from '../wiring/createExtensionInput';
import { createAppRootElementExtension } from './createAppRootElementExtension';
describe('createAppRootElementExtension', () => {
it('works with simple options and just an element', async () => {
const extension = createAppRootElementExtension({
element: <div>Hello</div>,
});
expect(extension).toEqual({
$$type: '@backstage/ExtensionDefinition',
version: 'v1',
kind: 'app-root-element',
attachTo: { id: 'app/root', input: 'elements' },
disabled: false,
inputs: {},
output: {
element: expect.anything(),
},
factory: expect.any(Function),
toString: expect.any(Function),
});
createExtensionTester(extension).render();
await expect(screen.findByText('Hello')).resolves.toBeInTheDocument();
});
it('works with complex options and a callback', async () => {
const schema = createSchemaFromZod(z => z.object({ name: z.string() }));
const extension = createAppRootElementExtension({
namespace: 'ns',
name: 'test',
configSchema: schema,
attachTo: { id: 'other', input: 'slot' },
disabled: true,
inputs: {
children: createExtensionInput({
element: coreExtensionData.reactElement,
}),
},
element: ({ inputs, config }) => (
<div>
Hello, {config.name}, {inputs.children.length}
</div>
),
});
expect(extension).toEqual({
$$type: '@backstage/ExtensionDefinition',
version: 'v1',
kind: 'app-root-element',
namespace: 'ns',
name: 'test',
attachTo: { id: 'other', input: 'slot' },
configSchema: schema,
disabled: true,
inputs: {
children: createExtensionInput({
element: coreExtensionData.reactElement,
}),
},
output: {
element: expect.anything(),
},
factory: expect.any(Function),
toString: expect.any(Function),
});
createExtensionTester(extension, { config: { name: 'Robin' } })
.add(
createExtension({
attachTo: { id: 'app-root-element:ns/test', input: 'children' },
output: { element: coreExtensionData.reactElement },
factory: () => ({ element: <div /> }),
}),
)
.render();
await expect(
screen.findByText('Hello, Robin, 1'),
).resolves.toBeInTheDocument();
});
});
@@ -0,0 +1,71 @@
/*
* Copyright 2023 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 { JSX } from 'react';
import { PortableSchema } from '../schema/types';
import { Expand } from '../types';
import { coreExtensionData } from '../wiring/coreExtensionData';
import {
AnyExtensionInputMap,
ExtensionDefinition,
ResolvedExtensionInputs,
createExtension,
} from '../wiring/createExtension';
/**
* Creates an extension that renders a React element at the app root, outside of
* the app layout. This is useful for example for shared popups and similar.
*
* @public
*/
export function createAppRootElementExtension<
TConfig extends {},
TInputs extends AnyExtensionInputMap,
>(options: {
namespace?: string;
name?: string;
attachTo?: { id: string; input: string };
configSchema?: PortableSchema<TConfig>;
disabled?: boolean;
inputs?: TInputs;
element:
| JSX.Element
| ((options: {
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
config: TConfig;
}) => JSX.Element);
}): ExtensionDefinition<TConfig> {
return createExtension({
kind: 'app-root-element',
namespace: options.namespace,
name: options.name,
attachTo: options.attachTo ?? { id: 'app/root', input: 'elements' },
configSchema: options.configSchema,
disabled: options.disabled,
inputs: options.inputs,
output: {
element: coreExtensionData.reactElement,
},
factory({ inputs, config }) {
return {
element:
typeof options.element === 'function'
? options.element({ inputs, config })
: options.element,
};
},
});
}
@@ -0,0 +1,119 @@
/*
* Copyright 2023 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 { createExtensionTester } from '@backstage/frontend-test-utils';
import { screen } from '@testing-library/react';
import React from 'react';
import { createSchemaFromZod } from '../schema/createSchemaFromZod';
import { coreExtensionData } from '../wiring/coreExtensionData';
import { createExtension } from '../wiring/createExtension';
import { createExtensionInput } from '../wiring/createExtensionInput';
import { createAppRootWrapperExtension } from './createAppRootWrapperExtension';
import { createPageExtension } from './createPageExtension';
describe('createAppRootWrapperExtension', () => {
it('works with simple options and no props', async () => {
const extension = createAppRootWrapperExtension({
Component: () => <div>Hello</div>,
});
expect(extension).toEqual({
$$type: '@backstage/ExtensionDefinition',
version: 'v1',
kind: 'app-root-wrapper',
attachTo: { id: 'app/root', input: 'wrappers' },
disabled: false,
inputs: {},
output: {
component: expect.anything(),
},
factory: expect.any(Function),
toString: expect.any(Function),
});
createExtensionTester(
createPageExtension({
defaultPath: '/',
loader: async () => <div />,
}),
)
.add(extension)
.render();
await expect(screen.findByText('Hello')).resolves.toBeInTheDocument();
});
it('works with complex options and props', async () => {
const schema = createSchemaFromZod(z => z.object({ name: z.string() }));
const extension = createAppRootWrapperExtension({
namespace: 'ns',
name: 'test',
configSchema: schema,
disabled: true,
inputs: {
children: createExtensionInput({
element: coreExtensionData.reactElement,
}),
},
Component: ({ inputs, config, children }) => (
<div data-testid={`${config.name}-${inputs.children.length}`}>
{children}
</div>
),
});
expect(extension).toEqual({
$$type: '@backstage/ExtensionDefinition',
version: 'v1',
kind: 'app-root-wrapper',
namespace: 'ns',
name: 'test',
attachTo: { id: 'app/root', input: 'wrappers' },
configSchema: schema,
disabled: true,
inputs: {
children: createExtensionInput({
element: coreExtensionData.reactElement,
}),
},
output: {
component: expect.anything(),
},
factory: expect.any(Function),
toString: expect.any(Function),
});
createExtensionTester(
createPageExtension({
defaultPath: '/',
loader: async () => <div>Hello</div>,
}),
)
.add(extension, { config: { name: 'Robin' } })
.add(
createExtension({
attachTo: { id: 'app-root-wrapper:ns/test', input: 'children' },
output: { element: coreExtensionData.reactElement },
factory: () => ({ element: <div /> }),
}),
)
.render();
await expect(screen.findByText('Hello')).resolves.toBeInTheDocument();
await expect(screen.findByTestId('Robin-1')).resolves.toBeInTheDocument();
});
});
@@ -0,0 +1,84 @@
/*
* Copyright 2023 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, { ComponentType, PropsWithChildren } from 'react';
import { PortableSchema } from '../schema/types';
import {
AnyExtensionInputMap,
ExtensionDefinition,
ResolvedExtensionInputs,
createExtension,
} from '../wiring/createExtension';
import { createExtensionDataRef } from '../wiring/createExtensionDataRef';
import { Expand } from '../types';
/**
* Creates an extension that renders a React wrapper at the app root, enclosing
* the app layout. This is useful for example for adding global React contexts
* and similar.
*
* @public
*/
export function createAppRootWrapperExtension<
TConfig extends {},
TInputs extends AnyExtensionInputMap,
>(options: {
namespace?: string;
name?: string;
attachTo?: { id: string; input: string };
configSchema?: PortableSchema<TConfig>;
disabled?: boolean;
inputs?: TInputs;
Component: ComponentType<
PropsWithChildren<{
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
config: TConfig;
}>
>;
}): ExtensionDefinition<TConfig> {
return createExtension({
kind: 'app-root-wrapper',
namespace: options.namespace,
name: options.name,
attachTo: options.attachTo ?? { id: 'app/root', input: 'wrappers' },
configSchema: options.configSchema,
disabled: options.disabled,
inputs: options.inputs,
output: {
component: createAppRootWrapperExtension.componentDataRef,
},
factory({ inputs, config }) {
const Component = (props: PropsWithChildren<{}>) => {
return (
<options.Component inputs={inputs} config={config}>
{props.children}
</options.Component>
);
};
return {
component: Component,
};
},
});
}
/** @public */
export namespace createAppRootWrapperExtension {
export const componentDataRef =
createExtensionDataRef<ComponentType<PropsWithChildren<{}>>>(
'app.root.wrapper',
);
}
@@ -0,0 +1,167 @@
/*
* Copyright 2023 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 { createSpecializedApp } from '@backstage/frontend-app-api';
import { render, screen } from '@testing-library/react';
import React from 'react';
import { MockConfigApi } from '@backstage/test-utils';
import { MemoryRouter } from 'react-router-dom';
import { createSchemaFromZod } from '../schema/createSchemaFromZod';
import { coreExtensionData } from '../wiring/coreExtensionData';
import { createExtension } from '../wiring/createExtension';
import { createExtensionInput } from '../wiring/createExtensionInput';
import { createExtensionOverrides } from '../wiring/createExtensionOverrides';
import { createPageExtension } from './createPageExtension';
import { createRouterExtension } from './createRouterExtension';
describe('createRouterExtension', () => {
it('works with simple options and no props', async () => {
const extension = createRouterExtension({
namespace: 'test',
Component: ({ children }) => (
<MemoryRouter>
<div data-testid="test-router">{children}</div>
</MemoryRouter>
),
});
expect(extension).toEqual({
$$type: '@backstage/ExtensionDefinition',
version: 'v1',
kind: 'app-router-component',
namespace: 'test',
attachTo: { id: 'app/root', input: 'router' },
disabled: false,
inputs: {},
output: {
component: expect.anything(),
},
factory: expect.any(Function),
toString: expect.any(Function),
});
const app = createSpecializedApp({
features: [
createExtensionOverrides({
extensions: [
extension,
createPageExtension({
namespace: 'test',
defaultPath: '/',
loader: async () => <div data-testid="test-contents" />,
}),
],
}),
],
});
render(app.createRoot());
await expect(
screen.findByTestId('test-contents'),
).resolves.toBeInTheDocument();
await expect(
screen.findByTestId('test-router'),
).resolves.toBeInTheDocument();
});
it('works with complex options and props', async () => {
const schema = createSchemaFromZod(z => z.object({ name: z.string() }));
const extension = createRouterExtension({
namespace: 'test',
name: 'test',
configSchema: schema,
inputs: {
children: createExtensionInput({
element: coreExtensionData.reactElement,
}),
},
Component: ({ inputs, config, children }) => (
<MemoryRouter>
<div
data-testid={`test-router-${config.name}-${inputs.children.length}`}
>
{children}
</div>
</MemoryRouter>
),
});
expect(extension).toEqual({
$$type: '@backstage/ExtensionDefinition',
version: 'v1',
kind: 'app-router-component',
namespace: 'test',
name: 'test',
attachTo: { id: 'app/root', input: 'router' },
configSchema: schema,
disabled: false,
inputs: {
children: createExtensionInput({
element: coreExtensionData.reactElement,
}),
},
output: {
component: expect.anything(),
},
factory: expect.any(Function),
toString: expect.any(Function),
});
const app = createSpecializedApp({
features: [
createExtensionOverrides({
extensions: [
extension,
createExtension({
namespace: 'test',
attachTo: {
id: 'app-router-component:test/test',
input: 'children',
},
output: { element: coreExtensionData.reactElement }, // doesn't matter
factory: () => ({ element: <div /> }),
}),
createPageExtension({
namespace: 'test',
defaultPath: '/',
loader: async () => <div data-testid="test-contents" />,
}),
],
}),
],
config: new MockConfigApi({
app: {
extensions: [
{
'app-router-component:test/test': { config: { name: 'Robin' } },
},
],
},
}),
});
render(app.createRoot());
await expect(
screen.findByTestId('test-contents'),
).resolves.toBeInTheDocument();
await expect(
screen.findByTestId('test-router-Robin-1'),
).resolves.toBeInTheDocument();
});
});
@@ -0,0 +1,84 @@
/*
* Copyright 2023 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, { ComponentType, PropsWithChildren } from 'react';
import { PortableSchema } from '../schema/types';
import {
AnyExtensionInputMap,
ExtensionDefinition,
ResolvedExtensionInputs,
createExtension,
} from '../wiring/createExtension';
import { createExtensionDataRef } from '../wiring/createExtensionDataRef';
import { Expand } from '../types';
/**
* Creates an extension that replaces the router implementation at the app root.
* This is useful to be able to for example replace the BrowserRouter with a
* MemoryRouter in tests, or to add additional props to a BrowserRouter.
*
* @public
*/
export function createRouterExtension<
TConfig extends {},
TInputs extends AnyExtensionInputMap,
>(options: {
namespace?: string;
name?: string;
attachTo?: { id: string; input: string };
configSchema?: PortableSchema<TConfig>;
disabled?: boolean;
inputs?: TInputs;
Component: ComponentType<
PropsWithChildren<{
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
config: TConfig;
}>
>;
}): ExtensionDefinition<TConfig> {
return createExtension({
kind: 'app-router-component',
namespace: options.namespace,
name: options.name,
attachTo: options.attachTo ?? { id: 'app/root', input: 'router' },
configSchema: options.configSchema,
disabled: options.disabled,
inputs: options.inputs,
output: {
component: createRouterExtension.componentDataRef,
},
factory({ inputs, config }) {
const Component = (props: PropsWithChildren<{}>) => {
return (
<options.Component inputs={inputs} config={config}>
{props.children}
</options.Component>
);
};
return {
component: Component,
};
},
});
}
/** @public */
export namespace createRouterExtension {
export const componentDataRef =
createExtensionDataRef<ComponentType<PropsWithChildren<{}>>>(
'app.router.wrapper',
);
}
@@ -15,6 +15,9 @@
*/
export { createApiExtension } from './createApiExtension';
export { createAppRootElementExtension } from './createAppRootElementExtension';
export { createAppRootWrapperExtension } from './createAppRootWrapperExtension';
export { createRouterExtension } from './createRouterExtension';
export { createPageExtension } from './createPageExtension';
export { createNavItemExtension } from './createNavItemExtension';
export { createNavLogoExtension } from './createNavLogoExtension';
@@ -219,10 +219,14 @@ describe('createExtensionTester', () => {
fireEvent.click(await screen.findByRole('button', { name: 'See details' }));
await waitFor(() =>
expect(analyticsApiMock.getEvents()[0]).toMatchObject({
action: 'click',
subject: 'See details',
}),
expect(analyticsApiMock.getEvents()).toEqual(
expect.arrayContaining([
expect.objectContaining({
action: 'click',
subject: 'See details',
}),
]),
),
);
});
});
@@ -14,22 +14,20 @@
* limitations under the License.
*/
import React, { ComponentType, ReactNode, useContext, useState } from 'react';
import React from 'react';
import { MemoryRouter, Link } from 'react-router-dom';
import { RenderResult, render } from '@testing-library/react';
import { createSpecializedApp } from '@backstage/frontend-app-api';
import {
ExtensionDefinition,
IconComponent,
IdentityApi,
RouteRef,
configApiRef,
coreExtensionData,
createExtension,
createExtensionInput,
createExtensionOverrides,
createNavItemExtension,
useApi,
createRouterExtension,
useRouteRef,
} from '@backstage/frontend-plugin-api';
import { MockConfigApi } from '@backstage/test-utils';
@@ -37,15 +35,7 @@ import { JsonArray, JsonObject, JsonValue } from '@backstage/types';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { resolveExtensionDefinition } from '../../../frontend-plugin-api/src/wiring/resolveExtensionDefinition';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { createSignInPageExtension } from '../../../frontend-plugin-api/src/extensions/createSignInPageExtension';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { toInternalExtensionDefinition } from '../../../frontend-plugin-api/src/wiring/createExtension';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { InternalAppContext } from '../../../frontend-app-api/src/wiring/InternalAppContext';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { SignInPageProps } from '../../../core-plugin-api';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { getBasePath } from '../../../core-app-api/src/app/AppRouter';
const NavItem = (props: {
routeRef: RouteRef<undefined>;
@@ -63,7 +53,7 @@ const NavItem = (props: {
);
};
const TestCoreNavExtension = createExtension({
const TestAppNavExtension = createExtension({
namespace: 'app',
name: 'nav',
attachTo: { id: 'app/layout', input: 'nav' },
@@ -95,97 +85,6 @@ const TestCoreNavExtension = createExtension({
},
});
const AuthenticationProvider = (props: {
signInPage?: ComponentType<SignInPageProps>;
children: ReactNode;
}) => {
const { signInPage: SignInPage, children } = props;
const configApi = useApi(configApiRef);
const signOutTargetUrl = getBasePath(configApi) || '/';
const internalAppContext = useContext(InternalAppContext);
if (!internalAppContext) {
throw new Error('AppRouter must be rendered within the AppProvider');
}
const { appIdentityProxy } = internalAppContext;
const [identityApi, setIdentityApi] = useState<IdentityApi>();
if (!SignInPage) {
appIdentityProxy.setTarget(
{
getUserId: () => 'guest',
getIdToken: async () => undefined,
getProfile: () => ({
email: 'guest@example.com',
displayName: 'Guest',
}),
getProfileInfo: async () => ({
email: 'guest@example.com',
displayName: 'Guest',
}),
getBackstageIdentity: async () => ({
type: 'user',
userEntityRef: 'user:default/guest',
ownershipEntityRefs: ['user:default/guest'],
}),
getCredentials: async () => ({}),
signOut: async () => {},
},
{ signOutTargetUrl },
);
return children;
}
if (!identityApi) {
return <SignInPage onSignInSuccess={setIdentityApi} />;
}
appIdentityProxy.setTarget(identityApi, {
signOutTargetUrl,
});
return children;
};
const TestCoreRouterExtension = createExtension({
namespace: 'app',
name: 'root',
attachTo: { id: 'app', input: 'root' },
inputs: {
signInPage: createExtensionInput(
{
component: createSignInPageExtension.componentDataRef,
},
{ singleton: true, optional: true },
),
children: createExtensionInput(
{
element: coreExtensionData.reactElement,
},
{ singleton: true },
),
},
output: {
element: coreExtensionData.reactElement,
},
factory({ inputs }) {
const SignInPage = inputs.signInPage?.output.component;
const children = inputs.children.output.element;
return {
element: (
<MemoryRouter>
<AuthenticationProvider signInPage={SignInPage}>
{children}
</AuthenticationProvider>
</MemoryRouter>
),
};
},
});
/** @public */
export class ExtensionTester {
/** @internal */
@@ -278,8 +177,13 @@ export class ExtensionTester {
createExtensionOverrides({
extensions: [
...this.#extensions.map(extension => extension.definition),
TestCoreNavExtension,
TestCoreRouterExtension,
TestAppNavExtension,
createRouterExtension({
namespace: 'test',
Component: ({ children }) => (
<MemoryRouter>{children}</MemoryRouter>
),
}),
],
}),
],
@@ -56,9 +56,13 @@ describe('renderInTestApp', () => {
fireEvent.click(screen.getByRole('link', { name: 'See details' }));
expect(analyticsApiMock.getEvents()[0]).toMatchObject({
action: 'click',
subject: 'See details',
});
expect(analyticsApiMock.getEvents()).toEqual(
expect.arrayContaining([
expect.objectContaining({
action: 'click',
subject: 'See details',
}),
]),
);
});
});
+10
View File
@@ -0,0 +1,10 @@
# @backstage/plugin-app-visualizer
A plugin to help explore the structure of your Backstage app.
This plugin provides the following extensions:
| ID | Type | Description | Default Config |
| ------------------------- | --------- | ------------------------------------ | ------------------------- |
| `page:app-visualizer` | `Page` | The app visualizer page | `{ path: '/visualizer' }` |
| `nav-item:app-visualizer` | `NavItem` | Nav item for the app visualizer page | |
@@ -1,4 +1,4 @@
## API Report File for "@backstage/plugin-visualizer"
## API Report File for "@backstage/plugin-app-visualizer"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
@@ -1,8 +1,8 @@
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: backstage-plugin-visualizer
title: '@backstage/plugin-visualizer'
name: backstage-plugin-app-visualizer
title: '@backstage/plugin-app-visualizer'
description: Visualizes the Backstage app structure
spec:
lifecycle: experimental
@@ -1,8 +1,7 @@
{
"name": "@backstage/plugin-visualizer",
"name": "@backstage/plugin-app-visualizer",
"description": "Visualizes the Backstage app structure",
"private": true,
"version": "0.0.2-next.2",
"version": "0.0.0",
"publishConfig": {
"access": "public"
},
@@ -27,8 +26,7 @@
"@backstage/core-plugin-api": "workspace:^",
"@backstage/frontend-plugin-api": "workspace:^",
"@material-ui/core": "^4.12.2",
"@material-ui/icons": "^4.9.1",
"@types/react": "^16.13.1 || ^17.0.0"
"@material-ui/icons": "^4.9.1"
},
"peerDependencies": {
"react": "^16.13.1 || ^17.0.0 || ^18.0.0",
@@ -36,7 +34,8 @@
"react-router-dom": "6.0.0-beta.0 || ^6.3.0"
},
"devDependencies": {
"@backstage/cli": "workspace:^"
"@backstage/cli": "workspace:^",
"@types/react": "^16.13.1 || ^17.0.0"
},
"files": [
"dist"
@@ -25,14 +25,14 @@ import React from 'react';
const rootRouteRef = createRouteRef();
const visualizerPage = createPageExtension({
const appVisualizerPage = createPageExtension({
defaultPath: '/visualizer',
routeRef: rootRouteRef,
loader: () =>
import('./components/AppVisualizerPage').then(m => <m.AppVisualizerPage />),
});
export const visualizerNavItem = createNavItemExtension({
export const appVisualizerNavItem = createNavItemExtension({
title: 'Visualizer',
icon: VisualizerIcon,
routeRef: rootRouteRef,
@@ -40,6 +40,6 @@ export const visualizerNavItem = createNavItemExtension({
/** @public */
export const visualizerPlugin = createPlugin({
id: 'visualizer',
extensions: [visualizerPage, visualizerNavItem],
id: 'app-visualizer',
extensions: [appVisualizerPage, appVisualizerNavItem],
});
@@ -10,9 +10,13 @@ import { PassportOAuthAuthenticatorHelper } from '@backstage/plugin-auth-node';
import { PassportProfile } from '@backstage/plugin-auth-node';
import { SignInResolverFactory } from '@backstage/plugin-auth-node';
// @public (undocumented)
// @public @deprecated (undocumented)
export const authModuleMicrosoftProvider: () => BackendFeature;
// @public (undocumented)
const authModuleMicrosoftProvider_2: () => BackendFeature;
export default authModuleMicrosoftProvider_2;
// @public (undocumented)
export const microsoftAuthenticator: OAuthAuthenticator<
{
@@ -0,0 +1,24 @@
/*
* Copyright 2023 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 { authModuleMicrosoftProvider as deprecatedAuthModuleMicrosoftProvider } from './module';
/**
* @public
* @deprecated Use default import instead
*/
export const authModuleMicrosoftProvider =
deprecatedAuthModuleMicrosoftProvider;
@@ -21,5 +21,6 @@
*/
export { microsoftAuthenticator } from './authenticator';
export { authModuleMicrosoftProvider } from './module';
export { authModuleMicrosoftProvider as default } from './module';
export { microsoftSignInResolvers } from './resolvers';
export * from './deprecated';
@@ -10,9 +10,13 @@ import { OAuthAuthenticator } from '@backstage/plugin-auth-node';
import { Strategy } from 'openid-client';
import { TokenSet } from 'openid-client';
// @public (undocumented)
// @public @deprecated (undocumented)
export const authModulePinnipedProvider: () => BackendFeature;
// @public (undocumented)
const authModulePinnipedProvider_2: () => BackendFeature;
export default authModulePinnipedProvider_2;
// @public (undocumented)
export const pinnipedAuthenticator: OAuthAuthenticator<
PinnipedStrategyCache,
@@ -0,0 +1,23 @@
/*
* Copyright 2023 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 { authModulePinnipedProvider as deprecatedAuthModulePinnipedProvider } from './module';
/**
* @public
* @deprecated Use default import instead
*/
export const authModulePinnipedProvider = deprecatedAuthModulePinnipedProvider;
@@ -21,4 +21,5 @@
*/
export { pinnipedAuthenticator, PinnipedStrategyCache } from './authenticator';
export { authModulePinnipedProvider } from './module';
export { authModulePinnipedProvider as default } from './module';
export * from './deprecated';
@@ -213,4 +213,34 @@ describe('UrlReaderProcessor', () => {
expect(reader.search).toHaveBeenCalledTimes(1);
});
it('parser return valid URL with wildcard *', async () => {
const logger = getVoidLogger();
const reader: jest.Mocked<UrlReader> = {
readUrl: jest.fn(),
readTree: jest.fn(),
search: jest.fn().mockImplementation(async () => []),
};
const processor = new UrlReaderProcessor({ reader, logger });
const emit = jest.fn();
await processor.readLocation(
{
type: 'url',
target: 'https://storage.cloud.google.com/ah-backstage-poc-catalog/*',
},
false,
emit,
defaultEntityDataParser,
mockCache,
);
expect(reader.search).toHaveBeenCalledWith(
'https://storage.cloud.google.com/ah-backstage-poc-catalog/*',
{ etag: undefined },
);
});
});
@@ -17,7 +17,6 @@
import { UrlReader } from '@backstage/backend-common';
import { Entity } from '@backstage/catalog-model';
import { assertError } from '@backstage/errors';
import parseGitUrl from 'git-url-parse';
import limiterFactory from 'p-limit';
import { Logger } from 'winston';
import { LocationSpec } from '@backstage/plugin-catalog-common';
@@ -124,7 +123,8 @@ export class UrlReaderProcessor implements CatalogProcessor {
): Promise<{ response: { data: Buffer; url: string }[]; etag?: string }> {
// Does it contain globs? I.e. does it contain asterisks or question marks
// (no curly braces for now)
const { filepath } = parseGitUrl(location);
const { pathname: filepath } = new URL(location);
if (filepath?.match(/[*?]/)) {
const limiter = limiterFactory(5);
const response = await this.options.reader.search(location, { etag });
@@ -111,7 +111,7 @@ export class AwsSqsConsumingEventPublisher implements EventPublisher {
}
private async deleteMessages(messages?: Message[]): Promise<void> {
if (!messages) {
if (!messages || messages.length === 0) {
return;
}
@@ -129,7 +129,7 @@ export class AwsSqsConsumingEventPublisher implements EventPublisher {
const result = await this.sqs.send(
new DeleteMessageBatchCommand(deleteParams),
);
if (result.Failed) {
if (result.Failed && result.Failed.length > 0) {
this.logger.error(
`Failed to delete ${result.Failed!.length} of ${
messages.length
+2
View File
@@ -42,6 +42,7 @@ Add the following to `makeCreateEnv`
```diff
// packages/backend/src/index.ts
+ import { DefaultEventBroker } from '@backstage/plugin-events-backend';
+ const eventBroker = new DefaultEventBroker(root.child({ type: 'plugin' }));
```
@@ -49,6 +50,7 @@ Then update plugin environment to include the event broker.
```diff
// packages/backend/src/types.ts
+ import { EventBroker } from '@backstage/plugin-events-node';
+ eventBroker: EventBroker;
```
@@ -377,7 +377,7 @@ describe('createPublishGithubPullRequestAction', () => {
branchName: 'new-app',
description: 'This PR is really good',
reviewers: ['foobar'],
teamReviewers: ['team-foo'],
teamReviewers: ['team-foo', 'team-foo', 'team-bar'],
};
mockDir.setContent({ [workspacePath]: {} });
@@ -401,7 +401,7 @@ describe('createPublishGithubPullRequestAction', () => {
repo: 'myrepo',
pull_number: 123,
reviewers: ['foobar'],
team_reviewers: ['team-foo'],
team_reviewers: ['team-foo', 'team-bar'],
});
});
@@ -372,7 +372,7 @@ export const createPublishGithubPullRequestAction = (
repo: pr.repo,
pull_number: pr.number,
reviewers,
team_reviewers: teamReviewers,
team_reviewers: teamReviewers ? [...new Set(teamReviewers)] : undefined,
});
const addedUsers = result.data.requested_reviewers?.join(', ') ?? '';
const addedTeams = result.data.requested_teams?.join(', ') ?? '';
@@ -139,6 +139,24 @@
}
]
},
"presentation": {
"type": "object",
"description": "A way to redefine the labels for actionable buttons.",
"properties": {
"backButtonText": {
"type": "string",
"description": "A button which return the user to one step back."
},
"createButtonText": {
"type": "string",
"description": "A button which start the execution of the template."
},
"reviewButtonText": {
"type": "string",
"description": "A button which open the review step to verify the input prior to start the execution."
}
}
},
"steps": {
"type": "array",
"description": "A list of steps to execute.",
@@ -25,7 +25,10 @@ describe('<DefaultTemplateOutputs />', () => {
it('should render template output', async () => {
const output = {
links: [{ title: 'Link 1', url: 'https://backstage.io/' }],
text: [{ title: 'Text 1', content: 'Hello, **world**!' }],
text: [
{ title: 'Text 1', content: 'Hello, **world**!' },
{ title: 'Text 2', content: 'Hello, **mars**!' },
],
};
const { getByRole } = await renderInTestApp(
@@ -37,6 +40,11 @@ describe('<DefaultTemplateOutputs />', () => {
},
);
// first text output default visible
expect(getByRole('heading', { level: 2 }).innerHTML).toBe(
output.text[0].title,
);
// test link outputs
for (const link of output.links ?? []) {
expect(
@@ -28,17 +28,18 @@ import { TextOutputs } from './TextOutputs';
export const DefaultTemplateOutputs = (props: {
output?: ScaffolderTaskOutput;
}) => {
const [textOutputIndex, setTextOutputIndex] = useState<number | undefined>();
const { output } = props;
const [textOutputIndex, setTextOutputIndex] = useState<number | undefined>(
output?.text?.length ? 0 : undefined,
);
const textOutput = useMemo(
() =>
textOutputIndex !== undefined
? props.output?.text?.[textOutputIndex]
: null,
[props.output, textOutputIndex],
textOutputIndex !== undefined ? output?.text?.[textOutputIndex] : null,
[output, textOutputIndex],
);
if (!props.output) {
if (!output) {
return null;
}
@@ -48,11 +49,11 @@ export const DefaultTemplateOutputs = (props: {
<Paper>
<Box padding={2} justifyContent="center" display="flex" gridGap={16}>
<TextOutputs
output={props.output}
output={output}
index={textOutputIndex}
setIndex={setTextOutputIndex}
/>
<LinkOutputs output={props.output} />
<LinkOutputs output={output} />
</Box>
</Paper>
</Box>
@@ -47,7 +47,11 @@ export const TextOutputs = (props: {
startIcon={<Icon />}
component="div"
color="primary"
onClick={() => setIndex?.(index !== i ? i : undefined)}
onClick={() => {
if (index !== i) {
setIndex?.(i);
}
}}
variant={index === i ? 'outlined' : undefined}
>
{title}
-36
View File
@@ -1,36 +0,0 @@
# @backstage/plugin-visualizer
## 0.0.2-next.2
### Patch Changes
- Updated dependencies
- @backstage/frontend-plugin-api@0.4.1-next.2
## 0.0.2-next.1
### Patch Changes
- Updated dependencies
- @backstage/core-plugin-api@1.8.2-next.0
- @backstage/core-components@0.13.10-next.1
- @backstage/frontend-plugin-api@0.4.1-next.1
## 0.0.2-next.0
### Patch Changes
- Updated dependencies
- @backstage/core-components@0.13.10-next.0
- @backstage/frontend-plugin-api@0.4.1-next.0
- @backstage/core-plugin-api@1.8.1
## 0.0.1
### Patch Changes
- Updated dependencies
- @backstage/core-plugin-api@1.8.1
- @backstage/frontend-plugin-api@0.4.0
- @backstage/core-components@0.13.9
- @backstage/theme@0.5.0
-3
View File
@@ -1,3 +0,0 @@
# @backstage/plugin-visualizer
A plugin to help visualize the structure of your Backstage app.
+53 -53
View File
@@ -3344,6 +3344,40 @@ __metadata:
languageName: unknown
linkType: soft
"@backstage/backend-dynamic-feature-service@workspace:packages/backend-dynamic-feature-service":
version: 0.0.0-use.local
resolution: "@backstage/backend-dynamic-feature-service@workspace:packages/backend-dynamic-feature-service"
dependencies:
"@backstage/backend-app-api": "workspace:^"
"@backstage/backend-common": "workspace:^"
"@backstage/backend-plugin-api": "workspace:^"
"@backstage/backend-tasks": "workspace:^"
"@backstage/backend-test-utils": "workspace:^"
"@backstage/cli": "workspace:^"
"@backstage/cli-common": "workspace:^"
"@backstage/cli-node": "workspace:^"
"@backstage/config": "workspace:^"
"@backstage/config-loader": "workspace:^"
"@backstage/errors": "workspace:^"
"@backstage/plugin-auth-node": "workspace:^"
"@backstage/plugin-catalog-backend": "workspace:^"
"@backstage/plugin-events-backend": "workspace:^"
"@backstage/plugin-events-node": "workspace:^"
"@backstage/plugin-permission-common": "workspace:^"
"@backstage/plugin-permission-node": "workspace:^"
"@backstage/plugin-scaffolder-node": "workspace:^"
"@backstage/plugin-search-backend-node": "workspace:^"
"@backstage/plugin-search-common": "workspace:^"
"@backstage/types": "workspace:^"
"@types/express": ^4.17.6
chokidar: ^3.5.3
express: ^4.17.1
lodash: ^4.17.21
wait-for-expect: ^3.0.2
winston: ^3.2.1
languageName: unknown
linkType: soft
"@backstage/backend-openapi-utils@workspace:^, @backstage/backend-openapi-utils@workspace:packages/backend-openapi-utils":
version: 0.0.0-use.local
resolution: "@backstage/backend-openapi-utils@workspace:packages/backend-openapi-utils"
@@ -3380,40 +3414,6 @@ __metadata:
languageName: unknown
linkType: soft
"@backstage/backend-plugin-manager@workspace:packages/backend-plugin-manager":
version: 0.0.0-use.local
resolution: "@backstage/backend-plugin-manager@workspace:packages/backend-plugin-manager"
dependencies:
"@backstage/backend-app-api": "workspace:^"
"@backstage/backend-common": "workspace:^"
"@backstage/backend-plugin-api": "workspace:^"
"@backstage/backend-tasks": "workspace:^"
"@backstage/backend-test-utils": "workspace:^"
"@backstage/cli": "workspace:^"
"@backstage/cli-common": "workspace:^"
"@backstage/cli-node": "workspace:^"
"@backstage/config": "workspace:^"
"@backstage/config-loader": "workspace:^"
"@backstage/errors": "workspace:^"
"@backstage/plugin-auth-node": "workspace:^"
"@backstage/plugin-catalog-backend": "workspace:^"
"@backstage/plugin-events-backend": "workspace:^"
"@backstage/plugin-events-node": "workspace:^"
"@backstage/plugin-permission-common": "workspace:^"
"@backstage/plugin-permission-node": "workspace:^"
"@backstage/plugin-scaffolder-node": "workspace:^"
"@backstage/plugin-search-backend-node": "workspace:^"
"@backstage/plugin-search-common": "workspace:^"
"@backstage/types": "workspace:^"
"@types/express": ^4.17.6
chokidar: ^3.5.3
express: ^4.17.1
lodash: ^4.17.21
wait-for-expect: ^3.0.2
winston: ^3.2.1
languageName: unknown
linkType: soft
"@backstage/backend-tasks@workspace:^, @backstage/backend-tasks@workspace:packages/backend-tasks":
version: 0.0.0-use.local
resolution: "@backstage/backend-tasks@workspace:packages/backend-tasks"
@@ -4644,6 +4644,24 @@ __metadata:
languageName: unknown
linkType: soft
"@backstage/plugin-app-visualizer@workspace:^, @backstage/plugin-app-visualizer@workspace:plugins/app-visualizer":
version: 0.0.0-use.local
resolution: "@backstage/plugin-app-visualizer@workspace:plugins/app-visualizer"
dependencies:
"@backstage/cli": "workspace:^"
"@backstage/core-components": "workspace:^"
"@backstage/core-plugin-api": "workspace:^"
"@backstage/frontend-plugin-api": "workspace:^"
"@material-ui/core": ^4.12.2
"@material-ui/icons": ^4.9.1
"@types/react": ^16.13.1 || ^17.0.0
peerDependencies:
react: ^16.13.1 || ^17.0.0 || ^18.0.0
react-dom: ^16.13.1 || ^17.0.0 || ^18.0.0
react-router-dom: 6.0.0-beta.0 || ^6.3.0
languageName: unknown
linkType: soft
"@backstage/plugin-auth-backend-module-atlassian-provider@workspace:^, @backstage/plugin-auth-backend-module-atlassian-provider@workspace:plugins/auth-backend-module-atlassian-provider":
version: 0.0.0-use.local
resolution: "@backstage/plugin-auth-backend-module-atlassian-provider@workspace:plugins/auth-backend-module-atlassian-provider"
@@ -9564,24 +9582,6 @@ __metadata:
languageName: unknown
linkType: soft
"@backstage/plugin-visualizer@workspace:^, @backstage/plugin-visualizer@workspace:plugins/visualizer":
version: 0.0.0-use.local
resolution: "@backstage/plugin-visualizer@workspace:plugins/visualizer"
dependencies:
"@backstage/cli": "workspace:^"
"@backstage/core-components": "workspace:^"
"@backstage/core-plugin-api": "workspace:^"
"@backstage/frontend-plugin-api": "workspace:^"
"@material-ui/core": ^4.12.2
"@material-ui/icons": ^4.9.1
"@types/react": ^16.13.1 || ^17.0.0
peerDependencies:
react: ^16.13.1 || ^17.0.0 || ^18.0.0
react-dom: ^16.13.1 || ^17.0.0 || ^18.0.0
react-router-dom: 6.0.0-beta.0 || ^6.3.0
languageName: unknown
linkType: soft
"@backstage/plugin-xcmetrics@workspace:plugins/xcmetrics":
version: 0.0.0-use.local
resolution: "@backstage/plugin-xcmetrics@workspace:plugins/xcmetrics"
@@ -26384,6 +26384,7 @@ __metadata:
"@backstage/plugin-airbrake": "workspace:^"
"@backstage/plugin-apache-airflow": "workspace:^"
"@backstage/plugin-api-docs": "workspace:^"
"@backstage/plugin-app-visualizer": "workspace:^"
"@backstage/plugin-azure-devops": "workspace:^"
"@backstage/plugin-azure-sites": "workspace:^"
"@backstage/plugin-badges": "workspace:^"
@@ -26437,7 +26438,6 @@ __metadata:
"@backstage/plugin-techdocs-react": "workspace:^"
"@backstage/plugin-todo": "workspace:^"
"@backstage/plugin-user-settings": "workspace:^"
"@backstage/plugin-visualizer": "workspace:^"
"@backstage/test-utils": "workspace:^"
"@backstage/theme": "workspace:^"
"@circleci/backstage-plugin": ^0.1.1