Merge pull request #31631 from backstage/rugvip/vis-bui

app-visualizer: migrate to use BUI
This commit is contained in:
Patrik Oldsberg
2025-11-10 19:49:34 +01:00
committed by GitHub
8 changed files with 230 additions and 296 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-app-visualizer': patch
---
Migrated to use `@backstage/ui`.
+3 -2
View File
@@ -37,8 +37,9 @@
"@backstage/core-components": "workspace:^",
"@backstage/core-plugin-api": "workspace:^",
"@backstage/frontend-plugin-api": "workspace:^",
"@material-ui/core": "^4.12.2",
"@material-ui/icons": "^4.9.1"
"@backstage/ui": "workspace:^",
"@remixicon/react": "^4.6.0",
"react-aria-components": "^1.13.0"
},
"devDependencies": {
"@backstage/cli": "workspace:^",
@@ -17,7 +17,7 @@
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 { Flex } from '@backstage/ui';
import { useCallback, useEffect, useMemo } from 'react';
import { DetailedVisualizer } from './DetailedVisualizer';
import { TextVisualizer } from './TextVisualizer';
@@ -86,14 +86,14 @@ export function AppVisualizerPage() {
<Page themeId="tool">
<Header title="App Visualizer" />
<Content noPadding stretch>
<Box display="flex" flexDirection="column" height="100%">
<Flex direction="column" style={{ height: '100%' }}>
<HeaderTabs
tabs={tabs}
selectedIndex={currentTabIndex}
onChange={handleTabChange}
/>
{element}
</Box>
</Flex>
</Content>
</Page>
);
@@ -18,157 +18,81 @@ import {
AppNode,
AppTree,
ExtensionDataRef,
RouteRef,
coreExtensionData,
ApiBlueprint,
NavItemBlueprint,
ThemeBlueprint,
useRouteRef,
useApi,
routeResolutionApiRef,
} 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 { Link } from 'react-router-dom';
import { Box, Flex, Link, Text, Tooltip, TooltipTrigger } from '@backstage/ui';
import {
RiInputField as InputIcon,
RiCloseCircleLine as DisabledIcon,
} from '@remixicon/react';
import { Focusable } from 'react-aria-components';
function getContrastColor(bgColor: string): string {
const hex = bgColor.replace('#', '');
const r = parseInt(hex.substr(0, 2), 16);
const g = parseInt(hex.substr(2, 2), 16);
const b = parseInt(hex.substr(4, 2), 16);
const brightness = (r * 299 + g * 587 + b * 114) / 1000;
return brightness > 128 ? '#000000' : '#ffffff';
}
function createOutputColorGenerator(
colorMap: { [extDataId: string]: string },
availableColors: string[],
) {
const map = new Map<string, string>();
const map = new Map<string, { backgroundColor: string; color: string }>();
let i = 0;
return function getOutputColor(id: string) {
let backgroundColor: string;
if (id in colorMap) {
return colorMap[id];
backgroundColor = colorMap[id];
} else {
const cached = map.get(id);
if (cached) {
return cached;
}
backgroundColor = availableColors[i];
i += 1;
if (i >= availableColors.length) {
i = 0;
}
}
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 result = {
backgroundColor,
color: getContrastColor(backgroundColor),
};
map.set(id, result);
return result;
};
}
const getOutputColor = createOutputColorGenerator(
{
[coreExtensionData.reactElement.id]: colors.green[500],
[coreExtensionData.routePath.id]: colors.yellow[500],
[coreExtensionData.routeRef.id]: colors.purple[500],
[ApiBlueprint.dataRefs.factory.id]: colors.blue[500],
[ThemeBlueprint.dataRefs.theme.id]: colors.lime[500],
[NavItemBlueprint.dataRefs.target.id]: colors.orange[500],
[coreExtensionData.reactElement.id]: '#4caf50',
[coreExtensionData.routePath.id]: '#ffeb3b',
[coreExtensionData.routeRef.id]: '#9c27b0',
[ApiBlueprint.dataRefs.factory.id]: '#2196f3',
[ThemeBlueprint.dataRefs.theme.id]: '#cddc39',
[NavItemBlueprint.dataRefs.target.id]: '#ff9800',
},
[
colors.blue[200],
colors.orange[200],
colors.green[200],
colors.red[200],
colors.yellow[200],
colors.purple[200],
colors.lime[200],
],
['#90caf9', '#ffcc80', '#a5d6a7', '#ef9a9a', '#fff59d', '#ce93d8', '#e6ee9c'],
);
interface StyleProps {
enabled: boolean;
depth: number;
// Helper function to get border color based on depth
function getBorderColor(depth: number): string {
const greyLevels = [8, 7, 6, 5]; // darker levels that contrast well with background
const index = depth % greyLevels.length;
const level = greyLevels[index];
return `var(--bui-gray-${level})`;
}
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 '';
@@ -181,60 +105,62 @@ function getFullPath(node?: AppNode): string {
return getFullPath(parent) + part;
}
function OutputLink(props: {
dataRef: ExtensionDataRef<unknown>;
node?: AppNode;
className: string;
}) {
const routeRef = props.node?.instance?.getData(coreExtensionData.routeRef);
try {
const link = useRouteRef(routeRef as RouteRef<undefined>);
return (
<Tooltip title={<Typography>{props.dataRef.id}</Typography>}>
<Box className={props.className}>
{link ? <Link to={link()}>link</Link> : null}
</Box>
</Tooltip>
);
} catch (ex) {
// eslint-disable-next-line no-console
console.warn(
props.node?.spec.id
? `Unable to generate output link for ${props.node.spec.id}`
: 'Unable to generate output link',
ex,
);
return null;
}
}
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) });
const routeResolutionApi = useApi(routeResolutionApiRef);
if (id === coreExtensionData.routePath.id) {
return (
<Tooltip title={<Typography>{getFullPath(node)}</Typography>}>
<Box className={classes.output}>
{String(instance?.getData(dataRef) ?? '')}
</Box>
</Tooltip>
);
const { backgroundColor, color } = getOutputColor(id);
const chipStyle: React.CSSProperties = {
height: 20,
padding: '0 10px',
borderRadius: '10px',
color,
backgroundColor,
display: 'flex',
alignItems: 'center',
fontWeight:
'var(--bui-font-weight-regular)' as React.CSSProperties['fontWeight'],
};
if (id === coreExtensionData.routeRef.id && node) {
try {
const routeRef = props.node?.instance?.getData(
coreExtensionData.routeRef,
);
const link = routeRef && routeResolutionApi.resolve(routeRef)?.();
if (link) {
return (
<TooltipTrigger>
<Link href={link} style={chipStyle}>
link
</Link>
<Tooltip>{id}</Tooltip>
</TooltipTrigger>
);
}
} catch {
/* ignore */
}
}
if (id === coreExtensionData.routeRef.id) {
return <OutputLink {...props} className={classes.output} />;
let tooltip = id;
let text: string | undefined = undefined;
if (id === coreExtensionData.routePath.id) {
text = String(instance?.getData(dataRef) ?? '');
tooltip = getFullPath(node);
}
return (
<Tooltip title={<Typography>{id}</Typography>}>
<Box className={classes.output} />
</Tooltip>
<TooltipTrigger>
<Focusable>
<Text style={{ ...chipStyle, cursor: 'help' }}>{text}</Text>
</Focusable>
<Tooltip style={{ maxWidth: 'unset' }}>{tooltip}</Tooltip>
</TooltipTrigger>
);
}
@@ -243,29 +169,35 @@ function Attachments(props: {
enabled: boolean;
depth: number;
}) {
const { node, enabled, depth } = props;
const { node, depth } = props;
const { attachments } = node.edges;
const classes = useStyles({ enabled, depth });
if (attachments.size === 0) {
return null;
}
return (
<Box className={classes.attachments}>
<Flex direction="column" gap="4">
{[...attachments.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, children]) => {
.map(([key, children], idx) => {
return (
<Box key={key} className={classes.attachmentsInput}>
<Box className={classes.attachmentsInputTitle}>
<InputIcon />
<Typography className={classes.attachmentsInputName}>
{key}
</Typography>
</Box>
<Box className={classes.attachmentsInputChildren}>
<Box key={key}>
<Flex
p="2"
align="center"
style={{
borderTopWidth: 'var(--bui-space-1_5)',
borderTopStyle: 'solid',
borderTopColor: getBorderColor(depth),
borderTop: idx === 0 ? 'none' : undefined,
width: 'fit-content',
}}
>
<InputIcon size={16} />
<div style={{ marginLeft: 'var(--bui-space-2)' }}>{key}</div>
</Flex>
<Flex ml="2" mb="2" direction="column" align="start" gap="1">
{children.map(childNode => (
<Extension
key={childNode.spec.id}
@@ -273,31 +205,11 @@ function Attachments(props: {
depth={depth + 1}
/>
))}
</Box>
</Flex>
</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>
))}
</>
</Flex>
);
}
@@ -305,27 +217,58 @@ 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()];
// Build tooltip text
const tooltipParts = [];
let currentNode = node;
tooltipParts.push(currentNode.spec.id);
while (currentNode.edges.attachedTo) {
const input = currentNode.edges.attachedTo.input;
currentNode = currentNode.edges.attachedTo.node;
tooltipParts.push(`${currentNode.spec.id} [${input}]`);
}
tooltipParts.reverse();
const tooltipText = tooltipParts.join('\n');
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}>
<Box
key={node.spec.id}
style={{
borderLeftWidth: 'var(--bui-space-1_5)',
borderLeftStyle: 'solid',
borderLeftColor: getBorderColor(depth),
}}
>
<Flex
py="1"
px="2"
align="center"
style={{
width: 'fit-content',
color: enabled ? 'var(--bui-fg-primary)' : 'var(--bui-fg-disabled)',
background: 'var(--bui-bg-surface-1)',
borderTopRightRadius: 'var(--bui-radius-2)',
borderBottomRightRadius: 'var(--bui-radius-2)',
}}
>
<TooltipTrigger>
<Focusable>
<Text style={{ userSelect: 'all' }}>{node.spec.id}</Text>
</Focusable>
<Tooltip style={{ maxWidth: 'unset' }}>
<Text style={{ whiteSpace: 'pre-wrap' }}>{tooltipText}</Text>
</Tooltip>
</TooltipTrigger>
<Flex ml="2" align="center" gap="2">
{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>
{!enabled && <DisabledIcon size={16} />}
</Flex>
</Flex>
<Attachments node={node} enabled={enabled} depth={depth} />
</Box>
);
@@ -343,24 +286,19 @@ const legendMap = {
function Legend() {
return (
<Box
display="grid"
maxWidth={600}
p={1}
p="2"
style={{
display: 'grid',
maxWidth: 600,
grid: 'auto-flow / repeat(3, 1fr)',
gap: 16,
gap: 'var(--bui-space-4)',
}}
>
{Object.entries(legendMap).map(([label, dataRef]) => (
<Box
key={dataRef.id}
display="flex"
style={{ gap: 8 }}
alignItems="center"
>
<Flex key={dataRef.id} gap="2" align="center">
<Output dataRef={dataRef} />
<Typography>{label}</Typography>
</Box>
<div>{label}</div>
</Flex>
))}
</Box>
);
@@ -368,14 +306,22 @@ function Legend() {
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}>
<Flex direction="column" style={{ height: '100%', flex: '1 1 100%' }}>
<Box ml="4" mt="4" style={{ flex: '1 1 0', overflow: 'auto' }}>
<Extension node={tree.root} depth={0} />
</Box>
<Box component={Paper} flex="0 0 auto" m={1}>
<Box
m="2"
style={{
flex: '0 0 auto',
background: 'var(--bui-bg-surface-1)',
border: '1px solid var(--bui-border)',
borderRadius: 'var(--bui-radius-2)',
}}
>
<Legend />
</Box>
</Box>
</Flex>
);
}
@@ -15,10 +15,7 @@
*/
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 { Box, Checkbox } from '@backstage/ui';
import { ReactNode, useState } from 'react';
function mkDiv(
@@ -30,7 +27,7 @@ function mkDiv(
key={options?.key}
style={{
color: options?.color,
marginLeft: options?.indent ? 16 : undefined,
marginLeft: options?.indent ? 'var(--bui-space-4)' : undefined,
}}
>
{children}
@@ -87,30 +84,25 @@ export function TextVisualizer({ tree }: { tree: AppTree }) {
return (
<>
<Box style={{ overflow: 'auto', flex: '1 0 0' }}>
<div style={{ margin: 16, width: 'max-content' }}>
<Box m="4" style={{ width: 'max-content' }}>
{nodeToText(tree.root, { showOutputs, showDisabled })}
</div>
</Box>
</Box>
<Box
py="2"
px="4"
style={{
background: 'var(--bui-bg-surface-1)',
borderTop: '1px solid var(--bui-border)',
}}
>
<Checkbox isSelected={showOutputs} onChange={setShowOutputs}>
Show Outputs
</Checkbox>
<Checkbox isSelected={showDisabled} onChange={setShowDisabled}>
Show Disabled
</Checkbox>
</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>
</>
);
}
@@ -19,8 +19,7 @@ import {
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 { Flex } from '@backstage/ui';
import { useLayoutEffect, useMemo, useRef, useState } from 'react';
type NodeType =
@@ -84,26 +83,9 @@ function resolveGraphData(tree: AppTree): {
};
}
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);
@@ -127,17 +109,23 @@ export function Node(props: { node: NodeType }) {
const paddedWidth = width + padding * 2;
const paddedHeight = height + padding * 2;
// Simple inline styles for SVG elements
const nodeFill = node.type === 'node' ? '#90caf9' : '#9e9e9e';
const nodeStroke = node.type === 'node' ? '#2196f3' : '#757575';
const textFill = '#000000';
return (
<g>
<rect
className={classes.node}
fill={nodeFill}
stroke={nodeStroke}
width={paddedWidth}
height={paddedHeight}
rx={node.type === 'node' ? 0 : 20}
/>
<text
ref={idRef}
className={classes.text}
fill={textFill}
y={paddedHeight / 2}
x={paddedWidth / 2}
textAnchor="middle"
@@ -153,12 +141,13 @@ export function TreeVisualizer({ tree }: { tree: AppTree }) {
const graphData = useMemo(() => resolveGraphData(tree), [tree]);
return (
<Box
flex="1 1 0"
display="flex"
justifyContent="stretch"
alignItems="stretch"
overflow="hidden"
<Flex
style={{
flex: '1 1 0',
overflow: 'hidden',
justifyContent: 'stretch',
alignItems: 'stretch',
}}
>
<DependencyGraph
fit="contain"
@@ -170,6 +159,6 @@ export function TreeVisualizer({ tree }: { tree: AppTree }) {
ranker={DependencyGraphTypes.Ranker.TIGHT_TREE}
direction={DependencyGraphTypes.Direction.LEFT_RIGHT}
/>
</Box>
</Flex>
);
}
+2 -2
View File
@@ -20,7 +20,7 @@ import {
NavItemBlueprint,
PageBlueprint,
} from '@backstage/frontend-plugin-api';
import VisualizerIcon from '@material-ui/icons/Visibility';
import { RiEyeLine as VisualizerIcon } from '@remixicon/react';
const rootRouteRef = createRouteRef();
@@ -38,7 +38,7 @@ const appVisualizerPage = PageBlueprint.make({
export const appVisualizerNavItem = NavItemBlueprint.make({
params: {
title: 'Visualizer',
icon: VisualizerIcon,
icon: () => <VisualizerIcon />,
routeRef: rootRouteRef,
},
});
+3 -2
View File
@@ -4121,10 +4121,11 @@ __metadata:
"@backstage/core-plugin-api": "workspace:^"
"@backstage/frontend-defaults": "workspace:^"
"@backstage/frontend-plugin-api": "workspace:^"
"@material-ui/core": "npm:^4.12.2"
"@material-ui/icons": "npm:^4.9.1"
"@backstage/ui": "workspace:^"
"@remixicon/react": "npm:^4.6.0"
"@types/react": "npm:^18.0.0"
react: "npm:^18.0.2"
react-aria-components: "npm:^1.13.0"
react-dom: "npm:^18.0.2"
react-router-dom: "npm:^6.3.0"
peerDependencies: