app-next: add graph visualizer
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
+17
-16
@@ -14,32 +14,33 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
Header,
|
||||
Page,
|
||||
TabbedLayout,
|
||||
CodeSnippet,
|
||||
} from '@backstage/core-components';
|
||||
import { Content, Header, HeaderTabs, Page } from '@backstage/core-components';
|
||||
import { useApi } from '@backstage/core-plugin-api';
|
||||
import { appTreeApiRef } from '@backstage/frontend-plugin-api';
|
||||
import React from 'react';
|
||||
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" />
|
||||
<TabbedLayout>
|
||||
<TabbedLayout.Route path="/text" title="Text">
|
||||
<CodeSnippet
|
||||
text={String(tree.root)}
|
||||
language="sql"
|
||||
customStyle={{ margin: 0, padding: 0 }}
|
||||
/>
|
||||
</TabbedLayout.Route>
|
||||
</TabbedLayout>
|
||||
<Content noPadding stretch>
|
||||
<Box display="flex" flexDirection="column" height="100%">
|
||||
<HeaderTabs tabs={tabs} selectedIndex={tab} onChange={setTab} />
|
||||
{tabs[tab].element}
|
||||
</Box>
|
||||
</Content>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
+266
@@ -0,0 +1,266 @@
|
||||
/*
|
||||
* 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 * as colors from '@material-ui/core/colors';
|
||||
import { Theme, makeStyles } from '@material-ui/core/styles';
|
||||
import InputIcon from '@material-ui/icons/InputSharp';
|
||||
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 mainColor(theme: Theme) {
|
||||
return ({ enabled }: StyleProps) =>
|
||||
enabled ? theme.palette.primary.main : colors.grey[600];
|
||||
}
|
||||
|
||||
function hoverColor(theme: Theme) {
|
||||
return ({ enabled }: StyleProps) =>
|
||||
enabled ? theme.palette.primary.dark : colors.grey[500];
|
||||
}
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
extension: {
|
||||
borderLeftWidth: theme.spacing(1),
|
||||
borderLeftStyle: 'solid',
|
||||
borderLeftColor: mainColor(theme),
|
||||
marginBottom: theme.spacing(0.5),
|
||||
cursor: 'pointer',
|
||||
fontSize: theme.typography.h6.fontSize,
|
||||
|
||||
'&:hover': {
|
||||
borderLeftColor: hoverColor(theme),
|
||||
},
|
||||
'&:hover $extensionHeader': {
|
||||
background: hoverColor(theme),
|
||||
},
|
||||
},
|
||||
extensionHeader: {
|
||||
display: 'flex',
|
||||
flexFlow: 'row nowrap',
|
||||
alignItems: 'center',
|
||||
width: 'fit-content',
|
||||
padding: theme.spacing(0.5, 1),
|
||||
background: mainColor(theme),
|
||||
},
|
||||
extensionHeaderId: {
|
||||
flex: '0 0 auto',
|
||||
color: theme.palette.primary.contrastText,
|
||||
},
|
||||
extensionHeaderOutputs: {
|
||||
margin: theme.spacing(0, 0.5),
|
||||
marginTop: 1,
|
||||
display: 'flex',
|
||||
flexFlow: 'row nowrap',
|
||||
alignItems: 'center',
|
||||
},
|
||||
extensionTooltip: {
|
||||
fontSize: theme.typography.h6.fontSize,
|
||||
maxWidth: 'unset',
|
||||
},
|
||||
output: {
|
||||
marginLeft: theme.spacing(1),
|
||||
width: theme.spacing(2.3),
|
||||
height: theme.spacing(2.3),
|
||||
borderRadius: '50%',
|
||||
},
|
||||
outputId: {
|
||||
maxWidth: 'unset',
|
||||
padding: theme.spacing(1),
|
||||
fontSize: theme.typography.h6.fontSize,
|
||||
},
|
||||
extensionDisabledIcon: {
|
||||
marginLeft: theme.spacing(1),
|
||||
},
|
||||
attachments: {},
|
||||
attachmentsInput: () => ({
|
||||
marginBottom: theme.spacing(1),
|
||||
|
||||
'&:not(:first-child) $attachmentsInputTitle': {
|
||||
borderTopWidth: theme.spacing(1),
|
||||
borderTopStyle: 'solid',
|
||||
borderTopColor: mainColor(theme),
|
||||
},
|
||||
'&:not(:first-child):hover $attachmentsInputTitle': {
|
||||
borderTopColor: hoverColor(theme),
|
||||
},
|
||||
}),
|
||||
attachmentsInputTitle: () => ({
|
||||
display: 'flex',
|
||||
flexFlow: 'row nowrap',
|
||||
alignItems: 'center',
|
||||
width: 'fit-content',
|
||||
padding: theme.spacing(1, 2),
|
||||
}),
|
||||
attachmentsInputIcon: {},
|
||||
attachmentsInputName: {
|
||||
marginLeft: theme.spacing(1),
|
||||
color: theme.palette.text.primary,
|
||||
},
|
||||
attachmentsInputChildren: {
|
||||
marginLeft: theme.spacing(1),
|
||||
},
|
||||
}));
|
||||
|
||||
function Output(props: { id: string; enabled: boolean }) {
|
||||
const { id, enabled } = props;
|
||||
const classes = useStyles({ enabled });
|
||||
|
||||
return (
|
||||
<Tooltip title={id} classes={{ tooltip: classes.outputId }}>
|
||||
<div
|
||||
className={classes.output}
|
||||
style={{ background: 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 (
|
||||
<div 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 (
|
||||
<div key={key} className={classes.attachmentsInput}>
|
||||
<div className={classes.attachmentsInputTitle}>
|
||||
<InputIcon className={classes.attachmentsInputIcon} />
|
||||
<div className={classes.attachmentsInputName}>{key}</div>
|
||||
</div>
|
||||
<div className={classes.attachmentsInputChildren}>
|
||||
{children.map(node => (
|
||||
<Extension key={node.spec.id} node={node} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 => (
|
||||
<div key={part}>{part}</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div key={node.spec.id} className={classes.extension}>
|
||||
<div className={classes.extensionHeader}>
|
||||
<Tooltip
|
||||
title={<ExtensionTooltip node={node} />}
|
||||
classes={{ tooltip: classes.extensionTooltip }}
|
||||
>
|
||||
<div className={classes.extensionHeaderId}>{node.spec.id}</div>
|
||||
</Tooltip>
|
||||
<div className={classes.extensionHeaderOutputs}>
|
||||
{dataRefIds &&
|
||||
dataRefIds.length > 0 &&
|
||||
[...dataRefIds]
|
||||
.sort()
|
||||
.map(id => <Output key={id} id={id} enabled={enabled} />)}
|
||||
</div>
|
||||
</div>
|
||||
<Attachments attachments={node.edges.attachments} enabled={enabled} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GraphVisualizer({ tree }: { tree: AppTree }) {
|
||||
return (
|
||||
<Box margin={3}>
|
||||
<Extension node={tree.root} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
+116
@@ -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,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';
|
||||
Reference in New Issue
Block a user