plugins: add visualizer

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2023-11-01 18:18:42 +01:00
parent 59d60e853b
commit 899530efb5
15 changed files with 100 additions and 22 deletions
+1
View File
@@ -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",
+2 -2
View File
@@ -32,7 +32,7 @@ import {
createExtensionOverrides,
} from '@backstage/frontend-plugin-api';
import techdocsPlugin from '@backstage/plugin-techdocs/alpha';
import { plugins } from './plugins';
import appVisualizerPlugin from '@backstage/plugin-visualizer';
import { homePage } from './HomePage';
import { convertLegacyApp } from '@backstage/core-compat-api';
import { FlatRoutes } from '@backstage/core-app-api';
@@ -124,7 +124,7 @@ const app = createApp({
techdocsPlugin,
userSettingsPlugin,
homePlugin,
...plugins,
appVisualizerPlugin,
...collectedLegacyPlugins,
createExtensionOverrides({
extensions: [
@@ -1,46 +0,0 @@
/*
* 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, { useState } from 'react';
import { GraphVisualizer } from './GraphVisualizer';
import { TextVisualizer } from './TextVisualizer';
export function AppVisualizerPage() {
const appTreeApi = useApi(appTreeApiRef);
const { tree } = appTreeApi.getTree();
const [tab, setTab] = useState(0);
const tabs = [
{ id: 'graph', label: 'Graph', element: <GraphVisualizer tree={tree} /> },
{ id: 'text', label: 'Text', element: <TextVisualizer tree={tree} /> },
];
return (
<Page themeId="tool">
<Header title="App Visualizer" />
<Content noPadding stretch>
<Box display="flex" flexDirection="column" height="100%">
<HeaderTabs tabs={tabs} selectedIndex={tab} onChange={setTab} />
{tabs[tab].element}
</Box>
</Content>
</Page>
);
}
@@ -1,246 +0,0 @@
/*
* 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 Tooltip from '@material-ui/core/Tooltip';
import Typography from '@material-ui/core/Typography';
import * as colors from '@material-ui/core/colors';
import { Theme, makeStyles } from '@material-ui/core/styles';
import InputIcon from '@material-ui/icons/InputSharp';
import DisabledIcon from '@material-ui/icons/NotInterestedSharp';
import React from 'react';
function createOutputColorGenerator(availableColors: string[]) {
const map = new Map<string, string>();
let i = 0;
return function getOutputColor(id: string) {
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([
colors.green[500],
colors.blue[500],
colors.yellow[500],
colors.purple[500],
colors.orange[500],
colors.red[500],
colors.lime[500],
colors.green[200],
colors.blue[200],
colors.yellow[200],
colors.purple[200],
colors.orange[200],
colors.red[200],
colors.lime[200],
]);
interface StyleProps {
enabled: boolean;
}
function borderColor(theme: Theme) {
return ({ enabled }: StyleProps) =>
enabled ? theme.palette.primary.main : theme.palette.divider;
}
const config = {
borderWidth: 0.75,
};
const useStyles = makeStyles(theme => ({
extension: {
borderLeftWidth: theme.spacing(config.borderWidth),
borderLeftStyle: 'solid',
borderLeftColor: borderColor(theme),
cursor: 'pointer',
'&:hover $extensionHeader': {
color: ({ enabled }: StyleProps) =>
enabled ? theme.palette.primary.main : theme.palette.text.secondary,
},
},
extensionHeader: {
display: 'flex',
flexFlow: 'row nowrap',
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,
},
extensionHeaderOutputs: {
display: 'flex',
flexFlow: 'row nowrap',
alignItems: 'center',
marginLeft: theme.spacing(1),
gap: theme.spacing(1),
},
attachments: {},
attachmentsInput: {
'&:first-child $attachmentsInputTitle': {
borderTop: 0,
},
},
attachmentsInputTitle: {
display: 'flex',
flexFlow: 'row nowrap',
alignItems: 'center',
width: 'fit-content',
padding: theme.spacing(1),
borderTopWidth: theme.spacing(config.borderWidth),
borderTopStyle: 'solid',
borderTopColor: borderColor(theme),
},
attachmentsInputName: {
marginLeft: theme.spacing(1),
},
attachmentsInputChildren: {
display: 'flex',
flexFlow: 'column nowrap',
alignItems: 'flex-start',
gap: theme.spacing(0.5),
marginLeft: theme.spacing(1),
marginBottom: theme.spacing(1),
},
}));
function Output(props: { id: string }) {
const { id } = props;
return (
<Tooltip title={<Typography>{id}</Typography>}>
<Box
width={18}
height={18}
borderRadius="50%"
bgcolor={getOutputColor(id)}
/>
</Tooltip>
);
}
function Attachments(props: {
attachments: ReadonlyMap<string, AppNode[]>;
enabled: boolean;
}) {
const { attachments, enabled } = props;
const classes = useStyles({ enabled });
if (attachments.size === 0) {
return null;
}
return (
<Box className={classes.attachments}>
{[...attachments.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, v]) => {
const children = v.sort((a, b) => a.spec.id.localeCompare(b.spec.id));
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(node => (
<Extension key={node.spec.id} node={node} />
))}
</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 }) {
const { node } = props;
const enabled = Boolean(node.instance);
const classes = useStyles({ enabled });
const dataRefIds =
node.instance && [...node.instance.getDataRefs()].map(r => r.id);
return (
<Box key={node.spec.id} className={classes.extension}>
<Box className={classes.extensionHeader}>
<Tooltip title={<ExtensionTooltip node={node} />}>
<Typography>{node.spec.id}</Typography>
</Tooltip>
<Box className={classes.extensionHeaderOutputs}>
{dataRefIds &&
dataRefIds.length > 0 &&
[...dataRefIds].sort().map(id => <Output key={id} id={id} />)}
{!enabled && <DisabledIcon fontSize="small" />}
</Box>
</Box>
<Attachments attachments={node.edges.attachments} enabled={enabled} />
</Box>
);
}
export function GraphVisualizer({ tree }: { tree: AppTree }) {
return (
<Box margin={3}>
<Extension node={tree.root} />
</Box>
);
}
@@ -1,116 +0,0 @@
/*
* 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>
</>
);
}
@@ -1,17 +0,0 @@
/*
* 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';
@@ -1,17 +0,0 @@
/*
* 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 { appVisualizerPlugin } from './plugin';
@@ -1,33 +0,0 @@
/*
* 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 {
createPageExtension,
createPlugin,
} from '@backstage/frontend-plugin-api';
import React from 'react';
const appVisualizerPage = createPageExtension({
id: 'plugin.app-visualizer.page',
defaultPath: '/visualizer',
loader: () =>
import('./components/AppVisualizerPage').then(m => <m.AppVisualizerPage />),
});
export const appVisualizerPlugin = createPlugin({
id: 'app-visualizer',
extensions: [appVisualizerPage],
});
-19
View File
@@ -1,19 +0,0 @@
/*
* 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 { appVisualizerPlugin } from './app-visualizer';
export const plugins = [appVisualizerPlugin];