diff --git a/plugins/dynamic-plugins-info/.eslintrc.js b/plugins/dynamic-plugins-info/.eslintrc.js
new file mode 100644
index 0000000000..e2a53a6ad2
--- /dev/null
+++ b/plugins/dynamic-plugins-info/.eslintrc.js
@@ -0,0 +1 @@
+module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
diff --git a/plugins/dynamic-plugins-info/CONTRIBUTING.md b/plugins/dynamic-plugins-info/CONTRIBUTING.md
new file mode 100644
index 0000000000..234259f812
--- /dev/null
+++ b/plugins/dynamic-plugins-info/CONTRIBUTING.md
@@ -0,0 +1,7 @@
+# Setting up the development environment for Dynamic Plugins Info plugin
+
+In [Backstage plugin terminology](https://backstage.io/docs/local-dev/cli-build-system#package-roles), the Dynamic Plugins Info plugin is a front-end plugin. You can start a live development session from the repository root using the following command:
+
+```console
+yarn workspace @janus-idp/backstage-plugin-dynamic-plugins-info run start
+```
diff --git a/plugins/dynamic-plugins-info/README.md b/plugins/dynamic-plugins-info/README.md
new file mode 100644
index 0000000000..9b723841f2
--- /dev/null
+++ b/plugins/dynamic-plugins-info/README.md
@@ -0,0 +1,45 @@
+# Dynamic Plugins Info plugin for Backstage
+
+The dynamic-plugins-info plugin is a frontend component for the [dynamic-plugins-info-backend](https://github.com/janus-idp/backstage-showcase/tree/main/plugins/dynamic-plugins-info-backend) plugin. It offers a simple table UI that supports client-side sorting, filtering and pagination.
+
+The plugin is designed to be installed dynamically in the [backstage-showcase](https://github.com/janus-idp/backstage-showcase/tree/main) app.
+
+To build this plugin and the dynamic entrypoint:
+
+`yarn install`
+
+`yarn tsc`
+
+`yarn build`
+
+`yarn export-dynamic`
+
+To install the dynamic plugin from a local build:
+
+```bash
+cd dist-scalprum
+npm pack .
+archive=$(npm pack $pkg)
+tar -xzf "$archive" && rm "$archive"
+mv package $(echo $archive | sed -e 's:\.tgz$::')
+```
+
+Move the resulting directory (`janus-idp-backstage-plugin-dynamic-plugins-info-0.1.0`) into the `dynamic-plugins-root` folder of your [backstage-showcase](https://github.com/janus-idp/backstage-showcase/tree/main) clone.
+
+This configuration will enable the plugin to be visible in the UI:
+
+```yaml
+dynamicPlugins:
+ frontend:
+ janus-idp.backstage-plugin-dynamic-plugins-info:
+ dynamicRoutes:
+ - path: /admin/plugins
+ importName: DynamicPluginsInfo
+ mountPoints:
+ - mountPoint: admin.page.plugins/cards
+ importName: DynamicPluginsInfo
+ config:
+ layout:
+ gridColumn: '1 / -1'
+ width: 100vw
+```
diff --git a/plugins/dynamic-plugins-info/app-config.janus-idp.yaml b/plugins/dynamic-plugins-info/app-config.janus-idp.yaml
new file mode 100644
index 0000000000..490c0123a9
--- /dev/null
+++ b/plugins/dynamic-plugins-info/app-config.janus-idp.yaml
@@ -0,0 +1,13 @@
+dynamicPlugins:
+ frontend:
+ janus-idp.backstage-plugin-dynamic-plugins-info:
+ dynamicRoutes:
+ - path: /admin/plugins
+ importName: DynamicPluginsInfo
+ mountPoints:
+ - mountPoint: admin.page.plugins/cards
+ importName: DynamicPluginsInfo
+ config:
+ layout:
+ gridColumn: '1 / -1'
+ width: 100vw
diff --git a/plugins/dynamic-plugins-info/dev/index.tsx b/plugins/dynamic-plugins-info/dev/index.tsx
new file mode 100644
index 0000000000..c40e8b3503
--- /dev/null
+++ b/plugins/dynamic-plugins-info/dev/index.tsx
@@ -0,0 +1,85 @@
+import React from 'react';
+
+import { Content, Header, HeaderTabs, Page } from '@backstage/core-components';
+import { createDevApp } from '@backstage/dev-utils';
+import { TestApiProvider } from '@backstage/test-utils';
+
+import { dynamicPluginsInfoApiRef } from '../src/api/types';
+import { DynamicPluginsInfoContent } from '../src/components/DynamicPluginsInfoContent/DynamicPluginsInfoContent';
+import { dynamicPluginsInfoPlugin } from '../src/plugin';
+
+export const listLoadedPluginsResult = [
+ {
+ name: 'some-plugin-one',
+ version: '0.1.0',
+ role: 'frontend-plugin',
+ platform: 'web',
+ },
+ {
+ name: 'some-plugin-two',
+ version: '1.1.0',
+ role: 'backend-plugin-module',
+ platform: 'node',
+ },
+ {
+ name: 'some-plugin-three',
+ version: '0.1.2',
+ role: 'backend-plugin',
+ platform: 'node',
+ },
+ {
+ name: 'some-plugin-four',
+ version: '1.1.0',
+ role: 'frontend-plugin',
+ platform: 'web',
+ },
+ {
+ name: 'some-plugin-five',
+ version: '1.2.0',
+ role: 'frontend-plugin',
+ platform: 'web',
+ },
+ {
+ name: 'some-plugin-six',
+ version: '0.6.3',
+ role: 'backend-plugin',
+ platform: 'node',
+ },
+];
+
+const mockedApi = {
+ listLoadedPlugins: async () => {
+ return listLoadedPluginsResult;
+ },
+};
+
+createDevApp()
+ .registerPlugin(dynamicPluginsInfoPlugin)
+ .addPage({
+ element: (
+
+
+
+
+
+
+
+
+
+ ),
+ title: 'Root Page',
+ path: '/dynamic-plugins-info',
+ })
+ .render();
diff --git a/plugins/dynamic-plugins-info/package.json b/plugins/dynamic-plugins-info/package.json
new file mode 100644
index 0000000000..6899efbecb
--- /dev/null
+++ b/plugins/dynamic-plugins-info/package.json
@@ -0,0 +1,65 @@
+{
+ "name": "@janus-idp/backstage-plugin-dynamic-plugins-info",
+ "version": "0.1.0",
+ "main": "src/index.ts",
+ "types": "src/index.ts",
+ "license": "Apache-2.0",
+ "publishConfig": {
+ "access": "public",
+ "main": "dist/index.esm.js",
+ "types": "dist/index.d.ts"
+ },
+ "backstage": {
+ "role": "frontend-plugin"
+ },
+ "scripts": {
+ "build": "backstage-cli package build",
+ "clean": "backstage-cli package clean",
+ "export-dynamic": "janus-cli package export-dynamic-plugin",
+ "lint": "backstage-cli package lint",
+ "postpack": "backstage-cli package postpack",
+ "postversion": "yarn run export-dynamic",
+ "prepack": "backstage-cli package prepack",
+ "start": "backstage-cli package start",
+ "test": "backstage-cli package test --passWithNoTests --coverage",
+ "tsc": "tsc"
+ },
+ "dependencies": {
+ "@backstage/core-components": "^0.13.6",
+ "@backstage/core-plugin-api": "^1.7.0",
+ "@backstage/theme": "^0.4.3",
+ "@material-table/core": "^3.1.0",
+ "react-use": "^17.4.0"
+ },
+ "peerDependencies": {
+ "react": "^16.13.1 || ^17.0.0",
+ "react-router-dom": "^6.20.0"
+ },
+ "devDependencies": {
+ "@backstage/cli": "0.23.0",
+ "@backstage/core-app-api": "1.11.0",
+ "@backstage/dev-utils": "1.0.22",
+ "@backstage/test-utils": "1.4.4",
+ "@testing-library/jest-dom": "5.17.0",
+ "@testing-library/react": "12.1.5",
+ "@testing-library/user-event": "14.5.1",
+ "msw": "1.3.2"
+ },
+ "files": [
+ "dist",
+ "dist-scalprum"
+ ],
+ "scalprum": {
+ "name": "janus-idp.backstage-plugin-dynamic-plugins-info",
+ "exposedModules": {
+ "PluginRoot": "./src/index.ts"
+ }
+ },
+ "repository": "github:janus-idp/backstage-plugins",
+ "keywords": [
+ "backstage",
+ "plugin"
+ ],
+ "homepage": "https://janus-idp.io/",
+ "bugs": "https://github.com/janus-idp/backstage-plugins/issues"
+}
diff --git a/plugins/dynamic-plugins-info/src/api/DynamicPluginsInfoClient.ts b/plugins/dynamic-plugins-info/src/api/DynamicPluginsInfoClient.ts
new file mode 100644
index 0000000000..b3c4224dea
--- /dev/null
+++ b/plugins/dynamic-plugins-info/src/api/DynamicPluginsInfoClient.ts
@@ -0,0 +1,30 @@
+import { DiscoveryApi, FetchApi } from '@backstage/core-plugin-api';
+
+import { DynamicPluginInfo, DynamicPluginsInfoApi } from './types';
+
+export interface DynamicPluginsInfoClientOptions {
+ discoveryApi: DiscoveryApi;
+ fetchApi: FetchApi;
+}
+
+const loadedPluginsEndpoint = '/loaded-plugins';
+
+export class DynamicPluginsInfoClient implements DynamicPluginsInfoApi {
+ private readonly discoveryApi: DiscoveryApi;
+ private readonly fetchApi: FetchApi;
+
+ constructor(options: DynamicPluginsInfoClientOptions) {
+ this.discoveryApi = options.discoveryApi;
+ this.fetchApi = options.fetchApi;
+ }
+ async listLoadedPlugins(): Promise {
+ const baseUrl = await this.discoveryApi.getBaseUrl('dynamic-plugins-info');
+ const targetUrl = `${baseUrl}${loadedPluginsEndpoint}`;
+ const response = await this.fetchApi.fetch(targetUrl);
+ const data = await response.json();
+ if (!response.ok) {
+ throw new Error(`${data.message}`);
+ }
+ return data;
+ }
+}
diff --git a/plugins/dynamic-plugins-info/src/api/types.ts b/plugins/dynamic-plugins-info/src/api/types.ts
new file mode 100644
index 0000000000..af6b4d908c
--- /dev/null
+++ b/plugins/dynamic-plugins-info/src/api/types.ts
@@ -0,0 +1,16 @@
+import { createApiRef } from '@backstage/core-plugin-api';
+
+export type DynamicPluginInfo = {
+ name: string;
+ version: string;
+ role: string;
+ platform: string;
+};
+
+export interface DynamicPluginsInfoApi {
+ listLoadedPlugins(): Promise;
+}
+
+export const dynamicPluginsInfoApiRef = createApiRef({
+ id: 'plugin.dynamic-plugins-info',
+});
diff --git a/plugins/dynamic-plugins-info/src/components/DynamicPluginsInfoContent/DynamicPluginsInfoContent.tsx b/plugins/dynamic-plugins-info/src/components/DynamicPluginsInfoContent/DynamicPluginsInfoContent.tsx
new file mode 100644
index 0000000000..ad4760dddb
--- /dev/null
+++ b/plugins/dynamic-plugins-info/src/components/DynamicPluginsInfoContent/DynamicPluginsInfoContent.tsx
@@ -0,0 +1,14 @@
+import React from 'react';
+
+import { ContentHeader, SupportButton } from '@backstage/core-components';
+
+import { DynamicPluginsTable } from '../DynamicPluginsTable/DynamicPluginsTable';
+
+export const DynamicPluginsInfoContent = () => (
+ <>
+
+ Some placeholder text
+
+
+ >
+);
diff --git a/plugins/dynamic-plugins-info/src/components/DynamicPluginsTable/DynamicPluginsTable.tsx b/plugins/dynamic-plugins-info/src/components/DynamicPluginsTable/DynamicPluginsTable.tsx
new file mode 100644
index 0000000000..f79b7855cd
--- /dev/null
+++ b/plugins/dynamic-plugins-info/src/components/DynamicPluginsTable/DynamicPluginsTable.tsx
@@ -0,0 +1,95 @@
+import React, { useState } from 'react';
+
+import {
+ ResponseErrorPanel,
+ Table,
+ TableColumn,
+} from '@backstage/core-components';
+import { useApi } from '@backstage/core-plugin-api';
+
+import { Query, QueryResult } from '@material-table/core';
+
+import { DynamicPluginInfo, dynamicPluginsInfoApiRef } from '../../api/types';
+
+export const DynamicPluginsTable = () => {
+ const [error, setError] = useState(undefined);
+ const [count, setCount] = useState(0);
+ const dynamicPluginInfo = useApi(dynamicPluginsInfoApiRef);
+ const columns: TableColumn[] = [
+ {
+ title: 'Name',
+ field: 'name',
+ defaultSort: 'asc',
+ },
+ {
+ title: 'Version',
+ field: 'version',
+ width: '30%',
+ },
+ {
+ title: 'Role',
+ render: ({ platform, role }) => <>{`${role} (${platform})`}>,
+ sorting: false,
+ },
+ ];
+ const fetchData = async (
+ query: Query,
+ ): Promise> => {
+ const {
+ orderBy = { field: 'name' },
+ orderDirection = 'asc',
+ page = 0,
+ pageSize = 5,
+ search = '',
+ } = query || {};
+ try {
+ // for now sorting/searching/pagination is handled client-side
+ const data = (await dynamicPluginInfo.listLoadedPlugins())
+ .sort((a: Record, b: Record) => {
+ const field = orderBy.field!;
+ if (!a[field] || !b[field]) {
+ return 0;
+ }
+ return (
+ a[field].localeCompare(b[field]) *
+ (orderDirection === 'desc' ? -1 : 1)
+ );
+ })
+ .filter(
+ value =>
+ search.trim() === '' ||
+ JSON.stringify(value).indexOf(search.trim()) > 0,
+ );
+ const totalCount = data.length;
+ let start = 0;
+ let end = totalCount;
+ if (totalCount > pageSize) {
+ start = page * pageSize;
+ end = start + pageSize;
+ }
+ setCount(totalCount);
+ return { data: data.slice(start, end), page, totalCount };
+ } catch (loadingError) {
+ setError(loadingError as Error);
+ return { data: [], totalCount: 0, page: 0 };
+ }
+ };
+ if (error) {
+ return ;
+ }
+ return (
+
+ );
+};
diff --git a/plugins/dynamic-plugins-info/src/index.ts b/plugins/dynamic-plugins-info/src/index.ts
new file mode 100644
index 0000000000..46440887c3
--- /dev/null
+++ b/plugins/dynamic-plugins-info/src/index.ts
@@ -0,0 +1 @@
+export { dynamicPluginsInfoPlugin, DynamicPluginsInfo } from './plugin';
diff --git a/plugins/dynamic-plugins-info/src/plugin.test.ts b/plugins/dynamic-plugins-info/src/plugin.test.ts
new file mode 100644
index 0000000000..1b311ce7b4
--- /dev/null
+++ b/plugins/dynamic-plugins-info/src/plugin.test.ts
@@ -0,0 +1,7 @@
+import { dynamicPluginsInfoPlugin } from './plugin';
+
+describe('dynamic-plugins-info', () => {
+ it('should export plugin', () => {
+ expect(dynamicPluginsInfoPlugin).toBeDefined();
+ });
+});
diff --git a/plugins/dynamic-plugins-info/src/plugin.ts b/plugins/dynamic-plugins-info/src/plugin.ts
new file mode 100644
index 0000000000..f603e06ef8
--- /dev/null
+++ b/plugins/dynamic-plugins-info/src/plugin.ts
@@ -0,0 +1,40 @@
+import {
+ createApiFactory,
+ createPlugin,
+ createRoutableExtension,
+ discoveryApiRef,
+ fetchApiRef,
+} from '@backstage/core-plugin-api';
+
+import { DynamicPluginsInfoClient } from './api/DynamicPluginsInfoClient';
+import { dynamicPluginsInfoApiRef } from './api/types';
+import { dynamicPluginsInfoRouteRef } from './routes';
+
+export const dynamicPluginsInfoPlugin = createPlugin({
+ id: 'dynamic-plugins-info',
+ routes: {
+ root: dynamicPluginsInfoRouteRef,
+ },
+ apis: [
+ createApiFactory({
+ api: dynamicPluginsInfoApiRef,
+ deps: {
+ discoveryApi: discoveryApiRef,
+ fetchApi: fetchApiRef,
+ },
+ factory: ({ discoveryApi, fetchApi }) =>
+ new DynamicPluginsInfoClient({ discoveryApi, fetchApi }),
+ }),
+ ],
+});
+
+export const DynamicPluginsInfo = dynamicPluginsInfoPlugin.provide(
+ createRoutableExtension({
+ name: 'DynamicPluginsInfo',
+ component: () =>
+ import(
+ './components/DynamicPluginsInfoContent/DynamicPluginsInfoContent'
+ ).then(m => m.DynamicPluginsInfoContent),
+ mountPoint: dynamicPluginsInfoRouteRef,
+ }),
+);
diff --git a/plugins/dynamic-plugins-info/src/routes.ts b/plugins/dynamic-plugins-info/src/routes.ts
new file mode 100644
index 0000000000..478de64255
--- /dev/null
+++ b/plugins/dynamic-plugins-info/src/routes.ts
@@ -0,0 +1,5 @@
+import { createRouteRef } from '@backstage/core-plugin-api';
+
+export const dynamicPluginsInfoRouteRef = createRouteRef({
+ id: 'dynamic-plugins-info',
+});
diff --git a/plugins/dynamic-plugins-info/src/setupTests.ts b/plugins/dynamic-plugins-info/src/setupTests.ts
new file mode 100644
index 0000000000..7b0828bfa8
--- /dev/null
+++ b/plugins/dynamic-plugins-info/src/setupTests.ts
@@ -0,0 +1 @@
+import '@testing-library/jest-dom';
diff --git a/plugins/dynamic-plugins-info/tsconfig.json b/plugins/dynamic-plugins-info/tsconfig.json
new file mode 100644
index 0000000000..c81a299b6e
--- /dev/null
+++ b/plugins/dynamic-plugins-info/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "extends": "@backstage/cli/config/tsconfig.json",
+ "include": ["src", "dev", "migrations"],
+ "exclude": ["node_modules"],
+ "compilerOptions": {
+ "outDir": "../../dist-types/plugins/dynamic-plugins-info",
+ "rootDir": "."
+ }
+}
diff --git a/plugins/dynamic-plugins-info/turbo.json b/plugins/dynamic-plugins-info/turbo.json
new file mode 100644
index 0000000000..aad6de81a6
--- /dev/null
+++ b/plugins/dynamic-plugins-info/turbo.json
@@ -0,0 +1,9 @@
+{
+ "extends": ["//"],
+ "pipeline": {
+ "tsc": {
+ "outputs": ["../../dist-types/plugins/dynamic-plugins-info/**"],
+ "dependsOn": ["^tsc"]
+ }
+ }
+}