Merge pull request #21852 from backstage/rugvip/visualizer
add visualizer plugin for new frontend system
This commit is contained in:
@@ -75,6 +75,7 @@
|
||||
"@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",
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
createExtensionOverrides,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import techdocsPlugin from '@backstage/plugin-techdocs/alpha';
|
||||
import appVisualizerPlugin from '@backstage/plugin-visualizer';
|
||||
import { homePage } from './HomePage';
|
||||
import { convertLegacyApp } from '@backstage/core-compat-api';
|
||||
import { FlatRoutes } from '@backstage/core-app-api';
|
||||
@@ -123,6 +124,7 @@ const app = createApp({
|
||||
techdocsPlugin,
|
||||
userSettingsPlugin,
|
||||
homePlugin,
|
||||
appVisualizerPlugin,
|
||||
...collectedLegacyPlugins,
|
||||
createExtensionOverrides({
|
||||
extensions: [
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
|
||||
@@ -0,0 +1,3 @@
|
||||
# @backstage/plugin-visualizer
|
||||
|
||||
A plugin to help visualize the structure of your Backstage app.
|
||||
@@ -0,0 +1,13 @@
|
||||
## API Report File for "@backstage/plugin-visualizer"
|
||||
|
||||
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
|
||||
|
||||
```ts
|
||||
import { BackstagePlugin } from '@backstage/frontend-plugin-api';
|
||||
|
||||
// @public (undocumented)
|
||||
const visualizerPlugin: BackstagePlugin<{}, {}>;
|
||||
export default visualizerPlugin;
|
||||
|
||||
// (No @packageDocumentation comment for this package)
|
||||
```
|
||||
@@ -0,0 +1,10 @@
|
||||
apiVersion: backstage.io/v1alpha1
|
||||
kind: Component
|
||||
metadata:
|
||||
name: backstage-plugin-visualizer
|
||||
title: '@backstage/plugin-visualizer'
|
||||
description: Visualizes the Backstage app structure
|
||||
spec:
|
||||
lifecycle: experimental
|
||||
type: backstage-frontend-plugin
|
||||
owner: maintainers
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "@backstage/plugin-visualizer",
|
||||
"description": "Visualizes the Backstage app structure",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"backstage": {
|
||||
"role": "frontend-plugin"
|
||||
},
|
||||
"license": "Apache-2.0",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"sideEffects": false,
|
||||
"scripts": {
|
||||
"build": "backstage-cli package build",
|
||||
"start": "backstage-cli package start",
|
||||
"lint": "backstage-cli package lint",
|
||||
"test": "backstage-cli package test",
|
||||
"prepack": "backstage-cli package prepack",
|
||||
"postpack": "backstage-cli package postpack",
|
||||
"clean": "backstage-cli package clean"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/core-components": "workspace:^",
|
||||
"@backstage/core-plugin-api": "workspace:^",
|
||||
"@backstage/frontend-plugin-api": "workspace:^",
|
||||
"@backstage/theme": "workspace:^",
|
||||
"@material-ui/core": "^4.12.2",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"@material-ui/lab": "4.0.0-alpha.61",
|
||||
"@types/react": "^16.13.1 || ^17.0.0",
|
||||
"react-use": "^17.2.4"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "workspace:^"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
* 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 { Content, Header, HeaderTabs, Page } from '@backstage/core-components';
|
||||
import { useApi } from '@backstage/core-plugin-api';
|
||||
import { appTreeApiRef } from '@backstage/frontend-plugin-api';
|
||||
import Box from '@material-ui/core/Box';
|
||||
import React, { useCallback, useEffect, useMemo } from 'react';
|
||||
import { DetailedVisualizer } from './DetailedVisualizer';
|
||||
import { TextVisualizer } from './TextVisualizer';
|
||||
import { TreeVisualizer } from './TreeVisualizer';
|
||||
import {
|
||||
matchRoutes,
|
||||
useLocation,
|
||||
useNavigate,
|
||||
useParams,
|
||||
useRoutes,
|
||||
} from 'react-router-dom';
|
||||
|
||||
export function AppVisualizerPage() {
|
||||
const appTreeApi = useApi(appTreeApiRef);
|
||||
const { tree } = appTreeApi.getTree();
|
||||
|
||||
const tabs = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: 'tree',
|
||||
path: 'tree',
|
||||
label: 'Tree',
|
||||
element: <TreeVisualizer tree={tree} />,
|
||||
},
|
||||
{
|
||||
id: 'detailed',
|
||||
path: 'detailed',
|
||||
label: 'Detailed',
|
||||
element: <DetailedVisualizer tree={tree} />,
|
||||
},
|
||||
{
|
||||
id: 'text',
|
||||
path: 'text',
|
||||
label: 'Text',
|
||||
element: <TextVisualizer tree={tree} />,
|
||||
},
|
||||
],
|
||||
[tree],
|
||||
);
|
||||
|
||||
const location = useLocation();
|
||||
const element = useRoutes(tabs, location);
|
||||
|
||||
const currentPath = `/${useParams()['*']}`;
|
||||
const [matchedRoute] = matchRoutes(tabs, currentPath) ?? [];
|
||||
|
||||
const currentTabIndex = matchedRoute
|
||||
? tabs.findIndex(t => t.path === matchedRoute.route.path)
|
||||
: 0;
|
||||
|
||||
const navigate = useNavigate();
|
||||
const handleTabChange = useCallback(
|
||||
(index: number) => {
|
||||
navigate(tabs[index].id);
|
||||
},
|
||||
[navigate, tabs],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!element) {
|
||||
navigate(tabs[0].path);
|
||||
}
|
||||
}, [element, navigate, tabs]);
|
||||
|
||||
return (
|
||||
<Page themeId="tool">
|
||||
<Header title="App Visualizer" />
|
||||
<Content noPadding stretch>
|
||||
<Box display="flex" flexDirection="column" height="100%">
|
||||
<HeaderTabs
|
||||
tabs={tabs}
|
||||
selectedIndex={currentTabIndex}
|
||||
onChange={handleTabChange}
|
||||
/>
|
||||
{element}
|
||||
</Box>
|
||||
</Content>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
/*
|
||||
* 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 {
|
||||
AppNode,
|
||||
AppTree,
|
||||
ExtensionDataRef,
|
||||
RouteRef,
|
||||
coreExtensionData,
|
||||
createApiExtension,
|
||||
createNavItemExtension,
|
||||
createThemeExtension,
|
||||
useRouteRef,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import Box from '@material-ui/core/Box';
|
||||
import Paper from '@material-ui/core/Paper';
|
||||
import Tooltip from '@material-ui/core/Tooltip';
|
||||
import Typography from '@material-ui/core/Typography';
|
||||
import * as colors from '@material-ui/core/colors';
|
||||
import { makeStyles } from '@material-ui/core/styles';
|
||||
import InputIcon from '@material-ui/icons/InputSharp';
|
||||
import DisabledIcon from '@material-ui/icons/NotInterestedSharp';
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
function createOutputColorGenerator(
|
||||
colorMap: { [extDataId: string]: string },
|
||||
availableColors: string[],
|
||||
) {
|
||||
const map = new Map<string, string>();
|
||||
let i = 0;
|
||||
|
||||
return function getOutputColor(id: string) {
|
||||
if (id in colorMap) {
|
||||
return colorMap[id];
|
||||
}
|
||||
let color = map.get(id);
|
||||
if (color) {
|
||||
return color;
|
||||
}
|
||||
color = availableColors[i];
|
||||
i += 1;
|
||||
if (i >= availableColors.length) {
|
||||
i = 0;
|
||||
}
|
||||
map.set(id, color);
|
||||
return color;
|
||||
};
|
||||
}
|
||||
|
||||
const getOutputColor = createOutputColorGenerator(
|
||||
{
|
||||
[coreExtensionData.reactElement.id]: colors.green[500],
|
||||
[coreExtensionData.routePath.id]: colors.yellow[500],
|
||||
[coreExtensionData.routeRef.id]: colors.purple[500],
|
||||
[createApiExtension.factoryDataRef.id]: colors.blue[500],
|
||||
[createThemeExtension.themeDataRef.id]: colors.lime[500],
|
||||
[createNavItemExtension.targetDataRef.id]: colors.orange[500],
|
||||
},
|
||||
|
||||
[
|
||||
colors.blue[200],
|
||||
colors.orange[200],
|
||||
colors.green[200],
|
||||
colors.red[200],
|
||||
colors.yellow[200],
|
||||
colors.purple[200],
|
||||
colors.lime[200],
|
||||
],
|
||||
);
|
||||
|
||||
interface StyleProps {
|
||||
enabled: boolean;
|
||||
depth: number;
|
||||
}
|
||||
|
||||
const config = {
|
||||
borderWidth: 0.75,
|
||||
};
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
extension: {
|
||||
borderLeftWidth: theme.spacing(config.borderWidth),
|
||||
borderLeftStyle: 'solid',
|
||||
borderLeftColor: ({ depth }: StyleProps) =>
|
||||
colors.grey[(700 - (depth % 6) * 100) as keyof typeof colors.grey],
|
||||
cursor: 'pointer',
|
||||
|
||||
'&:hover $extensionHeader': {
|
||||
color: ({ enabled }: StyleProps) =>
|
||||
enabled ? theme.palette.primary.main : theme.palette.text.secondary,
|
||||
},
|
||||
},
|
||||
extensionHeader: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
width: 'fit-content',
|
||||
|
||||
padding: theme.spacing(0.5, 1),
|
||||
color: ({ enabled }: StyleProps) =>
|
||||
enabled ? theme.palette.text.primary : theme.palette.text.disabled,
|
||||
background: theme.palette.background.paper,
|
||||
|
||||
borderTopRightRadius: theme.shape.borderRadius,
|
||||
borderBottomRightRadius: theme.shape.borderRadius,
|
||||
},
|
||||
extensionHeaderId: {
|
||||
userSelect: 'all',
|
||||
},
|
||||
extensionHeaderOutputs: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
marginLeft: theme.spacing(1),
|
||||
gap: theme.spacing(1),
|
||||
},
|
||||
attachments: {
|
||||
gap: theme.spacing(2),
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
},
|
||||
attachmentsInput: {
|
||||
'&:first-child $attachmentsInputTitle': {
|
||||
borderTop: 0,
|
||||
},
|
||||
},
|
||||
attachmentsInputTitle: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
width: 'fit-content',
|
||||
padding: theme.spacing(1),
|
||||
|
||||
borderTopWidth: theme.spacing(config.borderWidth),
|
||||
borderTopStyle: 'solid',
|
||||
borderTopColor: ({ depth }: StyleProps) =>
|
||||
colors.grey[(700 - (depth % 6) * 100) as keyof typeof colors.grey],
|
||||
},
|
||||
attachmentsInputName: {
|
||||
marginLeft: theme.spacing(1),
|
||||
},
|
||||
attachmentsInputChildren: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
gap: theme.spacing(0.5),
|
||||
marginLeft: theme.spacing(1),
|
||||
marginBottom: theme.spacing(1),
|
||||
},
|
||||
}));
|
||||
|
||||
const useOutputStyles = makeStyles(theme => ({
|
||||
output: ({ color }: { color: string }) => ({
|
||||
padding: `0 10px`,
|
||||
height: 20,
|
||||
borderRadius: 10,
|
||||
color: theme.palette.getContrastText(color),
|
||||
backgroundColor: color,
|
||||
}),
|
||||
}));
|
||||
|
||||
function getFullPath(node?: AppNode): string {
|
||||
if (!node) {
|
||||
return '';
|
||||
}
|
||||
const parent = node.edges.attachedTo?.node;
|
||||
const part = node.instance?.getData(coreExtensionData.routePath);
|
||||
if (!part) {
|
||||
return getFullPath(parent);
|
||||
}
|
||||
return getFullPath(parent) + part;
|
||||
}
|
||||
|
||||
function OutputLink(props: {
|
||||
dataRef: ExtensionDataRef<unknown>;
|
||||
node?: AppNode;
|
||||
className: string;
|
||||
}) {
|
||||
const routeRef = props.node?.instance?.getData(coreExtensionData.routeRef);
|
||||
|
||||
let link: string | undefined = undefined;
|
||||
try {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
link = useRouteRef(routeRef as RouteRef<undefined>)();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip title={<Typography>{props.dataRef.id}</Typography>}>
|
||||
<Box className={props.className}>
|
||||
{link ? <Link to={link}>link</Link> : null}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function Output(props: { dataRef: ExtensionDataRef<unknown>; node?: AppNode }) {
|
||||
const { dataRef, node } = props;
|
||||
const { id } = dataRef;
|
||||
const instance = node?.instance;
|
||||
|
||||
const classes = useOutputStyles({ color: getOutputColor(id) });
|
||||
|
||||
if (id === coreExtensionData.routePath.id) {
|
||||
return (
|
||||
<Tooltip title={<Typography>{getFullPath(node)}</Typography>}>
|
||||
<Box className={classes.output}>
|
||||
{String(instance?.getData(dataRef) ?? '')}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
if (id === coreExtensionData.routeRef.id) {
|
||||
return <OutputLink {...props} className={classes.output} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip title={<Typography>{id}</Typography>}>
|
||||
<Box className={classes.output} />
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function Attachments(props: {
|
||||
node: AppNode;
|
||||
enabled: boolean;
|
||||
depth: number;
|
||||
}) {
|
||||
const { node, enabled, depth } = props;
|
||||
const { attachments } = node.edges;
|
||||
|
||||
const classes = useStyles({ enabled, depth });
|
||||
|
||||
if (attachments.size === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box className={classes.attachments}>
|
||||
{[...attachments.entries()]
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([key, children]) => {
|
||||
return (
|
||||
<Box key={key} className={classes.attachmentsInput}>
|
||||
<Box className={classes.attachmentsInputTitle}>
|
||||
<InputIcon />
|
||||
<Typography className={classes.attachmentsInputName}>
|
||||
{key}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box className={classes.attachmentsInputChildren}>
|
||||
{children.map(childNode => (
|
||||
<Extension
|
||||
key={childNode.spec.id}
|
||||
node={childNode}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function ExtensionTooltip(props: { node: AppNode }) {
|
||||
const parts = [];
|
||||
let node = props.node;
|
||||
parts.push(node.spec.id);
|
||||
while (node.edges.attachedTo) {
|
||||
const input = node.edges.attachedTo.input;
|
||||
node = node.edges.attachedTo.node;
|
||||
parts.push(`${node.spec.id} [${input}]`);
|
||||
}
|
||||
parts.reverse();
|
||||
|
||||
return (
|
||||
<>
|
||||
{parts.map(part => (
|
||||
<Typography key={part}>{part}</Typography>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Extension(props: { node: AppNode; depth: number }) {
|
||||
const { node, depth } = props;
|
||||
|
||||
const enabled = Boolean(node.instance);
|
||||
const classes = useStyles({ enabled, depth });
|
||||
|
||||
const dataRefs = node.instance && [...node.instance.getDataRefs()];
|
||||
|
||||
return (
|
||||
<Box key={node.spec.id} className={classes.extension}>
|
||||
<Box className={classes.extensionHeader}>
|
||||
<Tooltip title={<ExtensionTooltip node={node} />}>
|
||||
<Typography className={classes.extensionHeaderId}>
|
||||
{node.spec.id}
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
<Box className={classes.extensionHeaderOutputs}>
|
||||
{dataRefs &&
|
||||
dataRefs.length > 0 &&
|
||||
dataRefs
|
||||
.sort((a, b) => a.id.localeCompare(b.id))
|
||||
.map(ref => <Output key={ref.id} dataRef={ref} node={node} />)}
|
||||
{!enabled && <DisabledIcon fontSize="small" />}
|
||||
</Box>
|
||||
</Box>
|
||||
<Attachments node={node} enabled={enabled} depth={depth} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const legendMap = {
|
||||
'React Element': coreExtensionData.reactElement,
|
||||
'Utility API': createApiExtension.factoryDataRef,
|
||||
'Route Path': coreExtensionData.routePath,
|
||||
'Route Ref': coreExtensionData.routeRef,
|
||||
'Nav Target': createNavItemExtension.targetDataRef,
|
||||
Theme: createThemeExtension.themeDataRef,
|
||||
};
|
||||
|
||||
function Legend() {
|
||||
return (
|
||||
<Box
|
||||
display="grid"
|
||||
maxWidth={600}
|
||||
p={1}
|
||||
style={{
|
||||
grid: 'auto-flow / repeat(3, 1fr)',
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
{Object.entries(legendMap).map(([label, dataRef]) => (
|
||||
<Box
|
||||
key={dataRef.id}
|
||||
display="flex"
|
||||
style={{ gap: 8 }}
|
||||
alignItems="center"
|
||||
>
|
||||
<Output dataRef={dataRef} />
|
||||
<Typography>{label}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function DetailedVisualizer({ tree }: { tree: AppTree }) {
|
||||
return (
|
||||
<Box display="flex" height="100%" flex="1 1 100%" flexDirection="column">
|
||||
<Box flex="1 1 0" overflow="auto" ml={2} mt={2}>
|
||||
<Extension node={tree.root} depth={0} />
|
||||
</Box>
|
||||
|
||||
<Box component={Paper} flex="0 0 auto" m={1}>
|
||||
<Legend />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
* 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 { AppNode, AppTree } from '@backstage/frontend-plugin-api';
|
||||
import Box from '@material-ui/core/Box';
|
||||
import Checkbox from '@material-ui/core/Checkbox';
|
||||
import FormControlLabel from '@material-ui/core/FormControlLabel';
|
||||
import Paper from '@material-ui/core/Paper';
|
||||
import React, { ReactNode, useState } from 'react';
|
||||
|
||||
function mkDiv(
|
||||
children: ReactNode,
|
||||
options?: { indent?: boolean; key?: string | number; color?: string },
|
||||
) {
|
||||
return (
|
||||
<div
|
||||
key={options?.key}
|
||||
style={{
|
||||
color: options?.color,
|
||||
marginLeft: options?.indent ? 16 : undefined,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function nodeToText(
|
||||
node: AppNode,
|
||||
options?: { showOutputs?: boolean; showDisabled?: boolean },
|
||||
): ReactNode {
|
||||
const dataRefIds =
|
||||
node.instance && [...node.instance.getDataRefs()].map(r => r.id);
|
||||
const out =
|
||||
options?.showOutputs && dataRefIds && dataRefIds.length > 0
|
||||
? ` out="${[...dataRefIds].sort().join(', ')}"`
|
||||
: '';
|
||||
const color = node.instance ? undefined : 'gray';
|
||||
|
||||
if (node.edges.attachments.size === 0) {
|
||||
return mkDiv(`<${node.spec.id}${out}/>`, { color });
|
||||
}
|
||||
|
||||
return mkDiv([
|
||||
mkDiv(`<${node.spec.id}${out}>`, { key: 'start', color }),
|
||||
...[...node.edges.attachments.entries()]
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([key, v]) => {
|
||||
const children = v
|
||||
.filter(e => options?.showDisabled || e.instance)
|
||||
.sort((a, b) => a.spec.id.localeCompare(b.spec.id));
|
||||
if (children.length === 0) {
|
||||
return mkDiv(`${key} []`, { indent: true });
|
||||
}
|
||||
return mkDiv(
|
||||
[
|
||||
mkDiv(`${key} [`),
|
||||
...children.map(e =>
|
||||
mkDiv(nodeToText(e, options), { indent: true }),
|
||||
),
|
||||
mkDiv(']'),
|
||||
],
|
||||
{ key, indent: true },
|
||||
);
|
||||
}),
|
||||
mkDiv(`</${node.spec.id}>`, { key: 'end', color }),
|
||||
]);
|
||||
}
|
||||
|
||||
export function TextVisualizer({ tree }: { tree: AppTree }) {
|
||||
const [showOutputs, setShowOutputs] = useState(false);
|
||||
const [showDisabled, setShowDisabled] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box style={{ overflow: 'auto', flex: '1 0 0' }}>
|
||||
<div style={{ margin: 16, width: 'max-content' }}>
|
||||
{nodeToText(tree.root, { showOutputs, showDisabled })}
|
||||
</div>
|
||||
</Box>
|
||||
<Paper style={{ padding: '8px 16px' }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={showOutputs}
|
||||
onChange={(_, value) => setShowOutputs(value)}
|
||||
/>
|
||||
}
|
||||
label="Show Outputs"
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={showDisabled}
|
||||
onChange={(_, value) => setShowDisabled(value)}
|
||||
/>
|
||||
}
|
||||
label="Show Disabled"
|
||||
/>
|
||||
</Paper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
/*
|
||||
* 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 from 'react';
|
||||
import {
|
||||
DependencyGraph,
|
||||
DependencyGraphTypes,
|
||||
} from '@backstage/core-components';
|
||||
import { AppNode, AppTree } from '@backstage/frontend-plugin-api';
|
||||
import Box from '@material-ui/core/Box';
|
||||
import { makeStyles } from '@material-ui/core/styles';
|
||||
import { useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
type NodeType =
|
||||
| ({ type: 'node'; id: string } & AppNode)
|
||||
| { type: 'input'; id: string; name: string };
|
||||
|
||||
function inputId({ node, input }: { node: AppNode; input: string }) {
|
||||
return `${node.spec.id}$$${input}`;
|
||||
}
|
||||
|
||||
function trimNodeId(id: string) {
|
||||
let newId = id;
|
||||
if (newId.startsWith('apis.')) {
|
||||
newId = newId.slice('apis.'.length);
|
||||
}
|
||||
if (newId.startsWith('plugin.')) {
|
||||
newId = newId.slice('plugin.'.length);
|
||||
}
|
||||
if (newId.startsWith('catalog.filter.entity.')) {
|
||||
newId = newId.slice('catalog.filter.entity.'.length);
|
||||
}
|
||||
if (newId.endsWith('.nav.index')) {
|
||||
newId = newId.slice(0, -'.nav.index'.length);
|
||||
}
|
||||
return newId;
|
||||
}
|
||||
|
||||
function resolveGraphData(tree: AppTree): {
|
||||
nodes: NodeType[];
|
||||
edges: { from: string; to: string }[];
|
||||
} {
|
||||
const nodes = [...tree.nodes.values()]
|
||||
.filter(node => node.instance)
|
||||
.map(node => ({ ...node, id: node.spec.id, type: 'node' as const }));
|
||||
|
||||
return {
|
||||
nodes: [
|
||||
...nodes,
|
||||
...nodes.flatMap(node =>
|
||||
[...node.edges.attachments.keys()].map(input => ({
|
||||
id: inputId({ node, input }),
|
||||
type: 'input' as const,
|
||||
name: input,
|
||||
})),
|
||||
),
|
||||
],
|
||||
edges: [
|
||||
...nodes
|
||||
.filter(node => node.edges.attachedTo)
|
||||
.map(node => ({
|
||||
from: inputId(node.edges.attachedTo!),
|
||||
to: node.spec.id,
|
||||
})),
|
||||
...nodes.flatMap(node =>
|
||||
[...node.edges.attachments.keys()].map(input => ({
|
||||
from: node.spec.id,
|
||||
to: inputId({ node, input }),
|
||||
})),
|
||||
),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
node: {
|
||||
fill: (node: NodeType) =>
|
||||
node.type === 'node'
|
||||
? theme.palette.primary.light
|
||||
: theme.palette.grey[500],
|
||||
stroke: (node: NodeType) =>
|
||||
node.type === 'node'
|
||||
? theme.palette.primary.main
|
||||
: theme.palette.grey[600],
|
||||
},
|
||||
text: {
|
||||
fill: theme.palette.primary.contrastText,
|
||||
},
|
||||
}));
|
||||
|
||||
/** @public */
|
||||
export function Node(props: { node: NodeType }) {
|
||||
const { node } = props;
|
||||
const classes = useStyles(node);
|
||||
const [width, setWidth] = useState(0);
|
||||
const [height, setHeight] = useState(0);
|
||||
const idRef = useRef<SVGTextElement | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
// set the width to the length of the ID
|
||||
if (idRef.current) {
|
||||
let { height: renderedHeight, width: renderedWidth } =
|
||||
idRef.current.getBBox();
|
||||
renderedHeight = Math.round(renderedHeight);
|
||||
renderedWidth = Math.round(renderedWidth);
|
||||
|
||||
if (renderedHeight !== height || renderedWidth !== width) {
|
||||
setWidth(renderedWidth);
|
||||
setHeight(renderedHeight);
|
||||
}
|
||||
}
|
||||
}, [width, height]);
|
||||
|
||||
const padding = 10;
|
||||
const paddedWidth = width + padding * 2;
|
||||
const paddedHeight = height + padding * 2;
|
||||
|
||||
return (
|
||||
<g>
|
||||
<rect
|
||||
className={classes.node}
|
||||
width={paddedWidth}
|
||||
height={paddedHeight}
|
||||
rx={node.type === 'node' ? 0 : 20}
|
||||
/>
|
||||
<text
|
||||
ref={idRef}
|
||||
className={classes.text}
|
||||
y={paddedHeight / 2}
|
||||
x={paddedWidth / 2}
|
||||
textAnchor="middle"
|
||||
alignmentBaseline="middle"
|
||||
>
|
||||
{node.type === 'node' ? trimNodeId(node.id) : node.name}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
export function TreeVisualizer({ tree }: { tree: AppTree }) {
|
||||
const graphData = useMemo(() => resolveGraphData(tree), [tree]);
|
||||
|
||||
return (
|
||||
<Box height="100%" flex="1 1 100%" flexDirection="column" overflow="hidden">
|
||||
<DependencyGraph
|
||||
fit="contain"
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
{...graphData}
|
||||
nodeMargin={10}
|
||||
rankMargin={50}
|
||||
paddingX={50}
|
||||
renderNode={Node}
|
||||
align={DependencyGraphTypes.Alignment.DOWN_RIGHT}
|
||||
ranker={DependencyGraphTypes.Ranker.TIGHT_TREE}
|
||||
direction={DependencyGraphTypes.Direction.TOP_BOTTOM}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export { AppVisualizerPage } from './AppVisualizerPage';
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export { visualizerPlugin as default } from './plugin';
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* 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 {
|
||||
createNavItemExtension,
|
||||
createPageExtension,
|
||||
createPlugin,
|
||||
createRouteRef,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import VisualizerIcon from '@material-ui/icons/Visibility';
|
||||
import React from 'react';
|
||||
|
||||
const rootRouteRef = createRouteRef();
|
||||
|
||||
const visualizerPage = createPageExtension({
|
||||
defaultPath: '/visualizer',
|
||||
routeRef: rootRouteRef,
|
||||
loader: () =>
|
||||
import('./components/AppVisualizerPage').then(m => <m.AppVisualizerPage />),
|
||||
});
|
||||
|
||||
export const visualizerNavItem = createNavItemExtension({
|
||||
title: 'Visualizer',
|
||||
icon: VisualizerIcon,
|
||||
routeRef: rootRouteRef,
|
||||
});
|
||||
|
||||
/** @public */
|
||||
export const visualizerPlugin = createPlugin({
|
||||
id: 'visualizer',
|
||||
extensions: [visualizerPage, visualizerNavItem],
|
||||
});
|
||||
@@ -10072,6 +10072,27 @@ __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:^"
|
||||
"@backstage/theme": "workspace:^"
|
||||
"@material-ui/core": ^4.12.2
|
||||
"@material-ui/icons": ^4.9.1
|
||||
"@material-ui/lab": 4.0.0-alpha.61
|
||||
"@types/react": ^16.13.1 || ^17.0.0
|
||||
react-use: ^17.2.4
|
||||
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"
|
||||
@@ -26662,6 +26683,7 @@ __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
|
||||
|
||||
Reference in New Issue
Block a user