From 42ac760b44374cc0207b363c5d2b99a7af9bdaa6 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 30 Oct 2023 11:18:39 +0100 Subject: [PATCH] app-next: add graph visualizer Signed-off-by: Patrik Oldsberg --- .../AppVisualizerPage.tsx | 33 +-- .../AppVisualizerPage/GraphVisualizer.tsx | 266 ++++++++++++++++++ .../AppVisualizerPage/TextVisualizer.tsx | 116 ++++++++ .../components/AppVisualizerPage/index.ts | 17 ++ 4 files changed, 416 insertions(+), 16 deletions(-) rename packages/app-next/src/plugins/app-visualizer/components/{ => AppVisualizerPage}/AppVisualizerPage.tsx (56%) create mode 100644 packages/app-next/src/plugins/app-visualizer/components/AppVisualizerPage/GraphVisualizer.tsx create mode 100644 packages/app-next/src/plugins/app-visualizer/components/AppVisualizerPage/TextVisualizer.tsx create mode 100644 packages/app-next/src/plugins/app-visualizer/components/AppVisualizerPage/index.ts diff --git a/packages/app-next/src/plugins/app-visualizer/components/AppVisualizerPage.tsx b/packages/app-next/src/plugins/app-visualizer/components/AppVisualizerPage/AppVisualizerPage.tsx similarity index 56% rename from packages/app-next/src/plugins/app-visualizer/components/AppVisualizerPage.tsx rename to packages/app-next/src/plugins/app-visualizer/components/AppVisualizerPage/AppVisualizerPage.tsx index 08e95305a2..ec6145a1af 100644 --- a/packages/app-next/src/plugins/app-visualizer/components/AppVisualizerPage.tsx +++ b/packages/app-next/src/plugins/app-visualizer/components/AppVisualizerPage/AppVisualizerPage.tsx @@ -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: }, + { id: 'text', label: 'Text', element: }, + ]; return (
- - - - - + + + + {tabs[tab].element} + + ); } diff --git a/packages/app-next/src/plugins/app-visualizer/components/AppVisualizerPage/GraphVisualizer.tsx b/packages/app-next/src/plugins/app-visualizer/components/AppVisualizerPage/GraphVisualizer.tsx new file mode 100644 index 0000000000..cd41c45875 --- /dev/null +++ b/packages/app-next/src/plugins/app-visualizer/components/AppVisualizerPage/GraphVisualizer.tsx @@ -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(); + 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 ( + +
+ + ); +} + +function Attachments(props: { + attachments: ReadonlyMap; + enabled: boolean; +}) { + const { attachments, enabled } = props; + + const classes = useStyles({ enabled }); + + if (attachments.size === 0) { + return null; + } + + return ( +
+ {[...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 ( +
+
+ +
{key}
+
+
+ {children.map(node => ( + + ))} +
+
+ ); + })} +
+ ); +} + +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 => ( +
{part}
+ ))} + + ); +} + +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 ( +
+
+ } + classes={{ tooltip: classes.extensionTooltip }} + > +
{node.spec.id}
+
+
+ {dataRefIds && + dataRefIds.length > 0 && + [...dataRefIds] + .sort() + .map(id => )} +
+
+ +
+ ); +} + +export function GraphVisualizer({ tree }: { tree: AppTree }) { + return ( + + + + ); +} diff --git a/packages/app-next/src/plugins/app-visualizer/components/AppVisualizerPage/TextVisualizer.tsx b/packages/app-next/src/plugins/app-visualizer/components/AppVisualizerPage/TextVisualizer.tsx new file mode 100644 index 0000000000..50b1f61484 --- /dev/null +++ b/packages/app-next/src/plugins/app-visualizer/components/AppVisualizerPage/TextVisualizer.tsx @@ -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 ( +
+ {children} +
+ ); +} + +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(``, { key: 'end', color }), + ]); +} + +export function TextVisualizer({ tree }: { tree: AppTree }) { + const [showOutputs, setShowOutputs] = useState(false); + const [showDisabled, setShowDisabled] = useState(false); + + return ( + <> + +
+ {nodeToText(tree.root, { showOutputs, showDisabled })} +
+
+ + setShowOutputs(value)} + /> + } + label="Show Outputs" + /> + setShowDisabled(value)} + /> + } + label="Show Disabled" + /> + + + ); +} diff --git a/packages/app-next/src/plugins/app-visualizer/components/AppVisualizerPage/index.ts b/packages/app-next/src/plugins/app-visualizer/components/AppVisualizerPage/index.ts new file mode 100644 index 0000000000..971bc6ccb2 --- /dev/null +++ b/packages/app-next/src/plugins/app-visualizer/components/AppVisualizerPage/index.ts @@ -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';