add an entity inspection dialog

Signed-off-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
Fredrik Adelöw
2022-02-04 20:17:10 +01:00
parent d2c379aeab
commit f8633307c4
23 changed files with 1139 additions and 24 deletions
+10
View File
@@ -0,0 +1,10 @@
---
'@backstage/catalog-client': minor
---
Fixed the return type of the catalog API `getEntityAncestors`, to match the
actual server response shape.
While this technically is a breaking change, the old shape has never worked at
all if you tried to use it - so treating this as an immediately-shipped breaking
bug fix.
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/plugin-catalog': patch
'@backstage/plugin-catalog-react': patch
---
Added an "inspect" entry in the entity three-dots menu, for lower level catalog
insights and debugging.
+2 -2
View File
@@ -133,10 +133,10 @@ export type CatalogEntityAncestorsRequest = {
// @public
export type CatalogEntityAncestorsResponse = {
root: EntityName;
rootEntityRef: string;
items: {
entity: Entity;
parents: EntityName[];
parentEntityRefs: string[];
}[];
};
+2 -2
View File
@@ -121,8 +121,8 @@ export type CatalogEntityAncestorsRequest = {
* @public
*/
export type CatalogEntityAncestorsResponse = {
root: EntityName;
items: { entity: Entity; parents: EntityName[] }[];
rootEntityRef: string;
items: { entity: Entity; parentEntityRefs: string[] }[];
};
/**
+7
View File
@@ -766,6 +766,13 @@ export function getEntitySourceLocation(
scmIntegrationsApi: ScmIntegrationRegistry,
): EntitySourceLocation | undefined;
// @public
export function InspectEntityDialog(props: {
open: boolean;
entity: Entity;
onClose: () => void;
}): JSX.Element | null;
// Warning: (ae-missing-release-tag) "isOwnerOf" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public
+2
View File
@@ -42,11 +42,13 @@
"@material-ui/core": "^4.12.2",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.57",
"classnames": "^2.2.6",
"jwt-decode": "^3.1.0",
"lodash": "^4.17.21",
"qs": "^6.9.4",
"react-router": "6.0.0-beta.0",
"react-use": "^17.2.4",
"yaml": "^1.10.0",
"zen-observable": "^0.8.15"
},
"peerDependencies": {
@@ -0,0 +1,162 @@
/*
* Copyright 2022 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 { Entity } from '@backstage/catalog-model';
import {
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
makeStyles,
Tab,
Tabs,
} from '@material-ui/core';
import React, { useEffect } from 'react';
import { AncestryPage } from './components/AncestryPage';
import { ColocatedPage } from './components/ColocatedPage';
import { JsonPage } from './components/JsonPage';
import { OverviewPage } from './components/OverviewPage';
import { YamlPage } from './components/YamlPage';
const useStyles = makeStyles(theme => ({
fullHeightDialog: {
height: 'calc(100% - 64px)',
},
root: {
display: 'flex',
flexGrow: 1,
width: '100%',
backgroundColor: theme.palette.background.paper,
},
tabs: {
borderRight: `1px solid ${theme.palette.divider}`,
flexShrink: 0,
},
tabContents: {
flexGrow: 1,
overflowX: 'auto',
},
}));
function TabPanel(props: {
children?: React.ReactNode;
index: any;
value: any;
}) {
const { children, value, index, ...other } = props;
const classes = useStyles();
return (
<div
role="tabpanel"
hidden={value !== index}
id={`vertical-tabpanel-${index}`}
aria-labelledby={`vertical-tab-${index}`}
className={classes.tabContents}
{...other}
>
{value === index && (
<Box pl={3} pr={3}>
{children}
</Box>
)}
</div>
);
}
function a11yProps(index: number) {
return {
id: `vertical-tab-${index}`,
'aria-controls': `vertical-tabpanel-${index}`,
};
}
/**
* A dialog that lets users inspect the low level details of their entities.
*
* @public
*/
export function InspectEntityDialog(props: {
open: boolean;
entity: Entity;
onClose: () => void;
}) {
const classes = useStyles();
const [activeTab, setActiveTab] = React.useState(0);
useEffect(() => {
setActiveTab(0);
}, [props.open]);
if (!props.entity) {
return null;
}
return (
<Dialog
fullWidth
maxWidth="xl"
open={props.open}
onClose={props.onClose}
aria-labelledby="entity-inspector-dialog-title"
PaperProps={{ className: classes.fullHeightDialog }}
>
<DialogTitle id="entity-inspector-dialog-title">
Entity Inspector
</DialogTitle>
<DialogContent dividers>
<div className={classes.root}>
<Tabs
orientation="vertical"
variant="scrollable"
value={activeTab}
onChange={(_, newValue) => setActiveTab(newValue)}
aria-label="Inspector options"
className={classes.tabs}
>
<Tab label="Overview" {...a11yProps(0)} />
<Tab label="Ancestry" {...a11yProps(1)} />
<Tab label="Colocated" {...a11yProps(2)} />
<Tab label="Raw JSON" {...a11yProps(3)} />
<Tab label="Raw YAML" {...a11yProps(4)} />
</Tabs>
<TabPanel value={activeTab} index={0}>
<OverviewPage entity={props.entity} />
</TabPanel>
<TabPanel value={activeTab} index={1}>
<AncestryPage entity={props.entity} />
</TabPanel>
<TabPanel value={activeTab} index={2}>
<ColocatedPage entity={props.entity} />
</TabPanel>
<TabPanel value={activeTab} index={3}>
<JsonPage entity={props.entity} />
</TabPanel>
<TabPanel value={activeTab} index={4}>
<YamlPage entity={props.entity} />
</TabPanel>
</div>
</DialogContent>
<DialogActions>
<Button onClick={props.onClose} color="primary">
Close
</Button>
</DialogActions>
</Dialog>
);
}
@@ -0,0 +1,223 @@
/*
* Copyright 2022 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 {
Entity,
ENTITY_DEFAULT_NAMESPACE,
stringifyEntityRef,
} from '@backstage/catalog-model';
import {
DependencyGraph,
DependencyGraphTypes,
Link,
Progress,
ResponseErrorPanel,
} from '@backstage/core-components';
import { useApi, useRouteRef } from '@backstage/core-plugin-api';
import { DialogContentText, makeStyles } from '@material-ui/core';
import classNames from 'classnames';
import React, { useLayoutEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router';
import useAsync from 'react-use/lib/useAsync';
import { catalogApiRef } from '../../../api';
import { formatEntityRefTitle } from '../../../components/EntityRefLink/format';
import { entityRouteRef } from '../../../routes';
import { EntityKindIcon } from './EntityKindIcon';
const useStyles = makeStyles(theme => ({
node: {
fill: theme.palette.grey[300],
stroke: theme.palette.grey[300],
'&.primary': {
fill: theme.palette.primary.light,
stroke: theme.palette.primary.light,
},
'&.secondary': {
fill: theme.palette.secondary.light,
stroke: theme.palette.secondary.light,
},
},
text: {
fill: theme.palette.getContrastText(theme.palette.grey[300]),
'&.primary': {
fill: theme.palette.primary.contrastText,
},
'&.secondary': {
fill: theme.palette.secondary.contrastText,
},
'&.focused': {
fontWeight: 'bold',
},
},
clickable: {
cursor: 'pointer',
},
}));
type NodeType = Entity & { root: boolean };
function useAncestry(root: Entity): {
loading: boolean;
error?: Error;
nodes: DependencyGraphTypes.DependencyNode<NodeType>[];
edges: DependencyGraphTypes.DependencyEdge[];
} {
const catalogClient = useApi(catalogApiRef);
const entityRef = stringifyEntityRef(root);
const { loading, error, value } = useAsync(async () => {
const response = await catalogClient.getEntityAncestors({ entityRef });
const nodes = new Array<DependencyGraphTypes.DependencyNode<NodeType>>();
const edges = new Array<DependencyGraphTypes.DependencyEdge>();
for (const current of response.items) {
const currentRef = stringifyEntityRef(current.entity);
const isRootNode = currentRef === response.rootEntityRef;
nodes.push({ id: currentRef, root: isRootNode, ...current.entity });
for (const parentRef of current.parentEntityRefs) {
edges.push({ from: currentRef, to: parentRef });
}
}
return { nodes, edges };
}, [entityRef]);
return {
loading,
error,
nodes: value?.nodes || [],
edges: value?.edges || [],
};
}
function CustomNode({ node }: DependencyGraphTypes.RenderNodeProps<NodeType>) {
const classes = useStyles();
const navigate = useNavigate();
const entityRoute = useRouteRef(entityRouteRef);
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 iconSize = height;
const paddedIconWidth = iconSize + padding;
const paddedWidth = paddedIconWidth + width + padding * 2;
const paddedHeight = height + padding * 2;
const displayTitle =
node.metadata.title ||
(node.kind && node.metadata.name && node.metadata.namespace
? formatEntityRefTitle({
kind: node.kind,
name: node.metadata.name,
namespace: node.metadata.namespace || '',
})
: node.id);
const onClick = () => {
navigate(
entityRoute({
kind: node.kind,
namespace: node.metadata.namespace || ENTITY_DEFAULT_NAMESPACE,
name: node.metadata.name,
}),
);
};
const focused = false;
return (
<g onClick={onClick} className={classes.clickable}>
<rect
className={classNames(
classes.node,
node.root ? 'secondary' : 'primary',
)}
width={paddedWidth}
height={paddedHeight}
rx={10}
/>
<EntityKindIcon
kind={node.kind}
y={padding}
x={padding}
width={iconSize}
height={iconSize}
className={classNames(
classes.text,
focused && 'focused',
node.root ? 'secondary' : 'primary',
)}
/>
<text
ref={idRef}
className={classNames(
classes.text,
focused && 'focused',
node.root ? 'secondary' : 'primary',
)}
y={paddedHeight / 2}
x={paddedIconWidth + (width + padding * 2) / 2}
textAnchor="middle"
alignmentBaseline="middle"
>
{displayTitle}
</text>
</g>
);
}
export function AncestryPage(props: { entity: Entity }) {
const { loading, error, nodes, edges } = useAncestry(props.entity);
if (loading) {
return <Progress />;
} else if (error) {
return <ResponseErrorPanel error={error} />;
}
return (
<>
<DialogContentText variant="h2">Ancestry</DialogContentText>
<DialogContentText gutterBottom>
This is the ancestry of entities above the current one - as in, the
chain(s) of entities down to the current one, where{' '}
<Link to="https://backstage.io/docs/features/software-catalog/life-of-an-entity">
processors emitted
</Link>{' '}
child entities that ultimately led to the current one existing. Note
that this is a completely different mechanism from relations.
</DialogContentText>
<DependencyGraph
nodes={nodes}
edges={edges}
renderNode={CustomNode}
direction={DependencyGraphTypes.Direction.BOTTOM_TOP}
zoom="enable-on-click"
/>
</>
);
}
@@ -0,0 +1,171 @@
/*
* Copyright 2022 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 {
Entity,
LOCATION_ANNOTATION,
ORIGIN_LOCATION_ANNOTATION,
stringifyEntityRef,
} from '@backstage/catalog-model';
import { Progress, ResponseErrorPanel } from '@backstage/core-components';
import { useApi } from '@backstage/core-plugin-api';
import {
DialogContentText,
List,
ListItem,
ListItemIcon,
makeStyles,
} from '@material-ui/core';
import { Alert } from '@material-ui/lab';
import React from 'react';
import useAsync from 'react-use/lib/useAsync';
import { catalogApiRef } from '../../../api';
import { EntityRefLink } from '../../EntityRefLink';
import { KeyValueListItem, ListItemText } from './common';
import { EntityKindIcon } from './EntityKindIcon';
const useStyles = makeStyles({
root: {
display: 'flex',
flexDirection: 'column',
},
});
function useColocated(entity: Entity): {
loading: boolean;
error?: Error;
location?: string;
originLocation?: string;
colocatedEntities?: Entity[];
} {
const catalogApi = useApi(catalogApiRef);
const currentEntityRef = stringifyEntityRef(entity);
const location = entity.metadata.annotations?.[LOCATION_ANNOTATION];
const origin = entity.metadata.annotations?.[ORIGIN_LOCATION_ANNOTATION];
const { loading, error, value } = useAsync(async () => {
if (!location && !origin) {
return [];
}
const response = await catalogApi.getEntities({
filter: [
...(location
? [{ [`metadata.annotations.${LOCATION_ANNOTATION}`]: location }]
: []),
...(origin
? [{ [`metadata.annotations.${ORIGIN_LOCATION_ANNOTATION}`]: origin }]
: []),
],
});
return response.items;
}, [location, origin]);
return {
loading,
error,
location,
originLocation: origin,
colocatedEntities: value?.filter(
colocated => stringifyEntityRef(colocated) !== currentEntityRef,
),
};
}
function EntityList(props: { entities: Entity[]; header?: [string, string] }) {
return (
<List dense>
{props.header && <KeyValueListItem key="header" entry={props.header} />}
{props.entities.map(entity => (
<ListItem key={stringifyEntityRef(entity)}>
<ListItemIcon>
<EntityKindIcon kind={entity.kind} />
</ListItemIcon>
<ListItemText primary={<EntityRefLink entityRef={entity} />} />
</ListItem>
))}
</List>
);
}
function Contents(props: { entity: Entity }) {
const { entity } = props;
const { loading, error, location, originLocation, colocatedEntities } =
useColocated(entity);
if (loading) {
return <Progress />;
} else if (error) {
return <ResponseErrorPanel error={error} />;
}
if (!location && !originLocation) {
return (
<Alert severity="warning">Entity had no location information.</Alert>
);
} else if (!colocatedEntities?.length) {
return (
<Alert severity="info">
There were no other entities on this location.
</Alert>
);
}
if (location === originLocation) {
return <EntityList entities={colocatedEntities} />;
}
const atLocation = colocatedEntities.filter(
e => e.metadata.annotations?.[LOCATION_ANNOTATION] === location,
);
const atOrigin = colocatedEntities.filter(
e =>
e.metadata.annotations?.[ORIGIN_LOCATION_ANNOTATION] === originLocation,
);
return (
<>
{atLocation.length > 0 && (
<EntityList
entities={atLocation}
header={['At the same location', location!]}
/>
)}
{atOrigin.length > 0 && (
<EntityList
entities={atOrigin}
header={['At the same origin', originLocation!]}
/>
)}
</>
);
}
export function ColocatedPage(props: { entity: Entity }) {
const classes = useStyles();
return (
<>
<DialogContentText variant="h2">Colocated</DialogContentText>
<DialogContentText>
These are the entities that are colocated with this entity - as in, they
originated from the same data source (e.g. came from the same YAML
file), or from the same origin (e.g. the originally registered URL).
</DialogContentText>
<div className={classes.root}>
<Contents entity={props.entity} />
</div>
</>
);
}
@@ -0,0 +1,36 @@
/*
* Copyright 2022 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 { useApp } from '@backstage/core-plugin-api';
import WorkIcon from '@material-ui/icons/Work';
import React from 'react';
export function EntityKindIcon(props: {
kind: string;
x?: number;
y?: number;
width?: number;
height?: number;
className?: string;
}) {
const app = useApp();
const { kind, ...otherProps } = props;
const Icon =
app.getSystemIcon(`kind:${kind.toLocaleLowerCase('en-US')}`) ?? WorkIcon;
return <Icon {...otherProps} />;
}
@@ -0,0 +1,41 @@
/*
* Copyright 2022 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 { Entity } from '@backstage/catalog-model';
import { CodeSnippet } from '@backstage/core-components';
import { DialogContentText } from '@material-ui/core';
import React from 'react';
import { sortKeys } from './util';
export function JsonPage(props: { entity: Entity }) {
return (
<>
<DialogContentText variant="h2">Entity as JSON</DialogContentText>
<DialogContentText>
This is the raw entity data as received from the catalog, on JSON form.
</DialogContentText>
<DialogContentText>
<div style={{ fontSize: '75%' }}>
<CodeSnippet
text={JSON.stringify(sortKeys(props.entity), undefined, 2)}
language="json"
showCopyCodeButton
/>
</div>
</DialogContentText>
</>
);
}
@@ -0,0 +1,180 @@
/*
* Copyright 2022 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 { AlphaEntity, stringifyEntityRef } from '@backstage/catalog-model';
import {
Box,
DialogContentText,
List,
ListItem,
ListItemIcon,
makeStyles,
Typography,
} from '@material-ui/core';
import groupBy from 'lodash/groupBy';
import sortBy from 'lodash/sortBy';
import React from 'react';
import { EntityRefLink } from '../../EntityRefLink';
import {
Container,
HelpIcon,
KeyValueListItem,
ListItemText,
ListSubheader,
} from './common';
import { EntityKindIcon } from './EntityKindIcon';
const useStyles = makeStyles({
root: {
display: 'flex',
flexDirection: 'column',
},
});
export function OverviewPage(props: { entity: AlphaEntity }) {
const classes = useStyles();
const {
apiVersion,
kind,
metadata,
spec,
relations = [],
status = {},
} = props.entity;
const groupedRelations = groupBy(
sortBy(relations, r => stringifyEntityRef(r.target)),
'type',
);
return (
<>
<DialogContentText variant="h2">Overview</DialogContentText>
<div className={classes.root}>
<Container title="Identity">
<List dense>
<ListItem>
<ListItemText primary="apiVersion" secondary={apiVersion} />
</ListItem>
<ListItem>
<ListItemText primary="kind" secondary={kind} />
</ListItem>
{spec?.type && (
<ListItem>
<ListItemText primary="spec.type" secondary={spec.type} />
</ListItem>
)}
{metadata.uid && (
<ListItem>
<ListItemText primary="uid" secondary={metadata.uid} />
</ListItem>
)}
{metadata.etag && (
<ListItem>
<ListItemText primary="etag" secondary={metadata.etag} />
</ListItem>
)}
</List>
</Container>
<Container title="Metadata">
{!!Object.keys(metadata.annotations || {}).length && (
<List
dense
subheader={
<ListSubheader>
Annotations
<HelpIcon to="https://backstage.io/docs/features/software-catalog/well-known-annotations" />
</ListSubheader>
}
>
{Object.entries(metadata.annotations!).map(entry => (
<KeyValueListItem key={entry[0]} indent entry={entry} />
))}
</List>
)}
{!!Object.keys(metadata.labels || {}).length && (
<List
dense
subheader={
<ListSubheader>
Labels
<HelpIcon to="https://backstage.io/docs/features/software-catalog/well-known-labels" />
</ListSubheader>
}
>
{Object.entries(metadata.labels!).map(entry => (
<KeyValueListItem key={entry[0]} indent entry={entry} />
))}
</List>
)}
{!!metadata.tags?.length && (
<List dense subheader={<ListSubheader>Tags</ListSubheader>}>
{metadata.tags.map((tag, index) => (
<ListItem key={`${tag}-${index}`}>
<ListItemIcon />
<ListItemText primary={tag} />
</ListItem>
))}
</List>
)}
</Container>
{!!relations.length && (
<Container
title="Relations"
helpLink="https://backstage.io/docs/features/software-catalog/well-known-relations"
>
{Object.entries(groupedRelations).map(
([type, groupRelations], index) => (
<div key={index}>
<List dense subheader={<ListSubheader>{type}</ListSubheader>}>
{groupRelations.map(group => (
<ListItem key={stringifyEntityRef(group.target)}>
<ListItemIcon>
<EntityKindIcon kind={group.target.kind} />
</ListItemIcon>
<ListItemText
primary={<EntityRefLink entityRef={group.target} />}
/>
</ListItem>
))}
</List>
</div>
),
)}
</Container>
)}
{!!status.items?.length && (
<Container
title="Status"
helpLink="https://backstage.io/docs/features/software-catalog/well-known-statuses"
>
{status.items.map((item, index) => (
<div key={index}>
<Typography>
{item.level}: {item.type}
</Typography>
<Box ml={2}>{item.message}</Box>
</div>
))}
</Container>
)}
</div>
</>
);
}
@@ -0,0 +1,42 @@
/*
* Copyright 2022 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 { Entity } from '@backstage/catalog-model';
import { CodeSnippet } from '@backstage/core-components';
import DialogContentText from '@material-ui/core/DialogContentText';
import React from 'react';
import YAML from 'yaml';
import { sortKeys } from './util';
export function YamlPage(props: { entity: Entity }) {
return (
<>
<DialogContentText variant="h2">Entity as YAML</DialogContentText>
<DialogContentText>
This is the raw entity data as received from the catalog, on YAML form.
</DialogContentText>
<DialogContentText>
<div style={{ fontSize: '75%' }}>
<CodeSnippet
text={YAML.stringify(sortKeys(props.entity))}
language="yaml"
showCopyCodeButton
/>
</div>
</DialogContentText>
</>
);
}
@@ -0,0 +1,124 @@
/*
* Copyright 2022 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 { Link } from '@backstage/core-components';
import {
Box,
Card,
CardContent,
ListItem,
ListItemIcon,
ListItemText as MuiListItemText,
ListSubheader as MuiListSubheader,
makeStyles,
Typography,
} from '@material-ui/core';
import HelpOutlineIcon from '@material-ui/icons/HelpOutline';
import React from 'react';
const useStyles = makeStyles(theme => ({
root: {
display: 'flex',
flexDirection: 'column',
},
marginTop: {
marginTop: theme.spacing(2),
},
helpIcon: {
marginLeft: theme.spacing(1),
color: theme.palette.text.disabled,
},
monospace: {
fontFamily: 'monospace',
},
}));
export function ListItemText(props: {
primary: React.ReactNode;
secondary?: React.ReactNode;
}) {
const classes = useStyles();
return (
<MuiListItemText
{...props}
primaryTypographyProps={{ className: classes.monospace }}
secondaryTypographyProps={{ className: classes.monospace }}
/>
);
}
export function ListSubheader(props: { children?: React.ReactNode }) {
const classes = useStyles();
return (
<MuiListSubheader className={classes.monospace}>
{props.children}
</MuiListSubheader>
);
}
export function Container(props: {
title: React.ReactNode;
helpLink?: string;
children: React.ReactNode;
}) {
return (
<Box mt={2}>
<Card variant="outlined">
<CardContent>
<Typography variant="h6" gutterBottom>
{props.title}
{props.helpLink && <HelpIcon to={props.helpLink} />}
</Typography>
{props.children}
</CardContent>
</Card>
</Box>
);
}
export function KeyValueListItem(props: {
indent?: boolean;
entry: [string, string];
}) {
const [key, value] = props.entry;
return (
<ListItem>
{props.indent && <ListItemIcon />}
<ListItemText
primary={key}
secondary={
!value.match(/^url:https?:\/\//) ? (
value
) : (
<>
{value.substring(0, 4)}
<Link to={value.substring(4)}>{value.substring(4)}</Link>
</>
)
}
/>
</ListItem>
);
}
export function HelpIcon(props: { to: string }) {
const classes = useStyles();
return (
<Link to={props.to} className={classes.helpIcon}>
<HelpOutlineIcon fontSize="inherit" />
</Link>
);
}
@@ -0,0 +1,25 @@
/*
* Copyright 2022 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 { JsonObject } from '@backstage/types';
export function sortKeys(data: JsonObject): JsonObject {
// we could do something custom, but lexicographical sorting is actually a
// good choice at least for the default set of keys
return Object.fromEntries(
[...Object.entries(data)].sort((a, b) => (a[0] < b[0] ? -1 : 1)),
);
}
@@ -0,0 +1,17 @@
/*
* Copyright 2022 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 { InspectEntityDialog } from './InspectEntityDialog';
@@ -23,5 +23,6 @@ export * from './EntityTable';
export * from './EntityTagPicker';
export * from './EntityTypePicker';
export * from './FavoriteEntity';
export * from './InspectEntityDialog';
export * from './UnregisterEntityDialog';
export * from './UserListPicker';
+2 -2
View File
@@ -351,7 +351,7 @@ export const EntityOrphanWarning: () => JSX.Element;
// Warning: (ae-missing-release-tag) "EntityPageLayout" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
// @public @deprecated
export const EntityPageLayout: {
({
children,
@@ -481,6 +481,6 @@ export type SystemDiagramCardClassKey =
// src/components/CatalogTable/CatalogTable.d.ts:11:5 - (ae-forgotten-export) The symbol "columnFactories" needs to be exported by the entry point index.d.ts
// src/components/EntityLayout/EntityLayout.d.ts:43:5 - (ae-forgotten-export) The symbol "EntityLayoutProps" needs to be exported by the entry point index.d.ts
// src/components/EntityLayout/EntityLayout.d.ts:44:5 - (ae-forgotten-export) The symbol "SubRoute" needs to be exported by the entry point index.d.ts
// src/components/EntityPageLayout/EntityPageLayout.d.ts:17:5 - (ae-forgotten-export) The symbol "EntityPageLayoutProps" needs to be exported by the entry point index.d.ts
// src/components/EntityPageLayout/EntityPageLayout.d.ts:22:5 - (ae-forgotten-export) The symbol "EntityPageLayoutProps" needs to be exported by the entry point index.d.ts
// src/plugin.d.ts:22:5 - (ae-forgotten-export) The symbol "ColumnBreakpoints" needs to be exported by the entry point index.d.ts
```
@@ -43,7 +43,12 @@ describe('ComponentContextMenu', () => {
it('should call onUnregisterEntity on button click', async () => {
const mockCallback = jest.fn();
await render(<EntityContextMenu onUnregisterEntity={mockCallback} />);
await render(
<EntityContextMenu
onUnregisterEntity={mockCallback}
onInspectEntity={() => {}}
/>,
);
const button = await screen.findByTestId('menu-button');
expect(button).toBeInTheDocument();
@@ -56,6 +61,27 @@ describe('ComponentContextMenu', () => {
expect(mockCallback).toBeCalled();
});
it('should call onInspectEntity on button click', async () => {
const mockCallback = jest.fn();
await render(
<EntityContextMenu
onUnregisterEntity={() => {}}
onInspectEntity={mockCallback}
/>,
);
const button = await screen.findByTestId('menu-button');
expect(button).toBeInTheDocument();
fireEvent.click(button);
const unregister = await screen.findByText('Inspect entity');
expect(unregister).toBeInTheDocument();
fireEvent.click(unregister);
expect(mockCallback).toBeCalled();
});
it('supports extra items', async () => {
const extra = {
title: 'HELLO',
@@ -66,6 +92,7 @@ describe('ComponentContextMenu', () => {
await render(
<EntityContextMenu
onUnregisterEntity={jest.fn()}
onInspectEntity={jest.fn()}
UNSTABLE_extraContextMenuItems={[extra]}
/>,
);
@@ -24,7 +24,8 @@ import {
Popover,
} from '@material-ui/core';
import { makeStyles } from '@material-ui/core/styles';
import Cancel from '@material-ui/icons/Cancel';
import CancelIcon from '@material-ui/icons/Cancel';
import BugReportIcon from '@material-ui/icons/BugReport';
import MoreVert from '@material-ui/icons/MoreVert';
import React, { useState } from 'react';
import { IconComponent } from '@backstage/core-plugin-api';
@@ -55,12 +56,14 @@ type Props = {
UNSTABLE_extraContextMenuItems?: ExtraContextMenuItem[];
UNSTABLE_contextMenuOptions?: contextMenuOptions;
onUnregisterEntity: () => void;
onInspectEntity: () => void;
};
export const EntityContextMenu = ({
UNSTABLE_extraContextMenuItems,
UNSTABLE_contextMenuOptions,
onUnregisterEntity,
onInspectEntity,
}: Props) => {
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement>();
const classes = useStyles();
@@ -128,10 +131,21 @@ export const EntityContextMenu = ({
disabled={disableUnregister}
>
<ListItemIcon>
<Cancel fontSize="small" />
<CancelIcon fontSize="small" />
</ListItemIcon>
<ListItemText primary="Unregister entity" />
</MenuItem>
<MenuItem
onClick={() => {
onClose();
onInspectEntity();
}}
>
<ListItemIcon>
<BugReportIcon fontSize="small" />
</ListItemIcon>
<ListItemText primary="Inspect entity" />
</MenuItem>
</MenuList>
</Popover>
</>
@@ -39,13 +39,14 @@ import {
EntityRefLinks,
FavoriteEntity,
getEntityRelations,
InspectEntityDialog,
UnregisterEntityDialog,
useEntityCompoundName,
} from '@backstage/plugin-catalog-react';
import { Box, TabProps } from '@material-ui/core';
import { Alert } from '@material-ui/lab';
import React, { useContext, useState } from 'react';
import { useNavigate } from 'react-router';
import React, { useContext, useEffect, useState } from 'react';
import { useLocation, useNavigate } from 'react-router';
import { EntityContextMenu } from '../EntityContextMenu/EntityContextMenu';
type SubRoute = {
@@ -177,6 +178,7 @@ export const EntityLayout = ({
}: EntityLayoutProps) => {
const { kind, namespace, name } = useEntityCompoundName();
const { entity, loading, error } = useContext(EntityContext);
const location = useLocation();
const routes = useElementFilter(
children,
elements =>
@@ -212,13 +214,21 @@ export const EntityLayout = ({
);
const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false);
const [inspectionDialogOpen, setInspectionDialogOpen] = useState(false);
const navigate = useNavigate();
const cleanUpAfterRemoval = async () => {
setConfirmationDialogOpen(false);
setInspectionDialogOpen(false);
navigate('/');
};
const showRemovalDialog = () => setConfirmationDialogOpen(true);
// Make sure to close the dialog if the user clicks links in it that navigate
// to another entity.
useEffect(() => {
setConfirmationDialogOpen(false);
setInspectionDialogOpen(false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [location.pathname]);
return (
<Page themeId={entity?.spec?.type?.toString() ?? 'home'}>
@@ -233,7 +243,8 @@ export const EntityLayout = ({
<EntityContextMenu
UNSTABLE_extraContextMenuItems={UNSTABLE_extraContextMenuItems}
UNSTABLE_contextMenuOptions={UNSTABLE_contextMenuOptions}
onUnregisterEntity={showRemovalDialog}
onUnregisterEntity={() => setConfirmationDialogOpen(true)}
onInspectEntity={() => setInspectionDialogOpen(true)}
/>
</>
)}
@@ -267,6 +278,11 @@ export const EntityLayout = ({
onConfirm={cleanUpAfterRemoval}
onClose={() => setConfirmationDialogOpen(false)}
/>
<InspectEntityDialog
open={inspectionDialogOpen}
entity={entity!}
onClose={() => setInspectionDialogOpen(false)}
/>
</Page>
);
};
@@ -23,6 +23,7 @@ import {
EntityRefLinks,
FavoriteEntity,
getEntityRelations,
InspectEntityDialog,
UnregisterEntityDialog,
useEntityCompoundName,
} from '@backstage/plugin-catalog-react';
@@ -123,6 +124,11 @@ type EntityPageLayoutProps = {
children?: React.ReactNode;
};
/**
* Old entity page, only used by the old router based hierarchies.
*
* @deprecated Please use CatalogEntityPage instead
*/
export const EntityPageLayout = ({
children,
UNSTABLE_extraContextMenuItems,
@@ -138,14 +144,13 @@ export const EntityPageLayout = ({
);
const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false);
const [inspectionDialogOpen, setInspectionDialogOpen] = useState(false);
const navigate = useNavigate();
const cleanUpAfterRemoval = async () => {
setConfirmationDialogOpen(false);
navigate('/');
};
const showRemovalDialog = () => setConfirmationDialogOpen(true);
return (
<Page themeId={entity?.spec?.type?.toString() ?? 'home'}>
<Header
@@ -160,7 +165,8 @@ export const EntityPageLayout = ({
<EntityContextMenu
UNSTABLE_extraContextMenuItems={UNSTABLE_extraContextMenuItems}
UNSTABLE_contextMenuOptions={UNSTABLE_contextMenuOptions}
onUnregisterEntity={showRemovalDialog}
onUnregisterEntity={() => setConfirmationDialogOpen(true)}
onInspectEntity={() => setInspectionDialogOpen(true)}
/>
</>
)}
@@ -198,6 +204,11 @@ export const EntityPageLayout = ({
onConfirm={cleanUpAfterRemoval}
onClose={() => setConfirmationDialogOpen(false)}
/>
<InspectEntityDialog
open={inspectionDialogOpen}
entity={entity!}
onClose={() => setInspectionDialogOpen(false)}
/>
</Page>
);
};
@@ -14,18 +14,17 @@
* limitations under the License.
*/
import { AlphaEntity, stringifyEntityRef } from '@backstage/catalog-model';
import { ApiProvider } from '@backstage/core-app-api';
import {
CatalogApi,
catalogApiRef,
EntityProvider,
entityRouteRef,
} from '@backstage/plugin-catalog-react';
import { renderInTestApp, TestApiRegistry } from '@backstage/test-utils';
import React from 'react';
import { EntityProcessingErrorsPanel } from './EntityProcessingErrorsPanel';
import { AlphaEntity, getEntityName } from '@backstage/catalog-model';
import { ApiProvider } from '@backstage/core-app-api';
describe('<EntityProcessErrors />', () => {
const getEntityAncestors: jest.MockedFunction<
@@ -98,8 +97,8 @@ describe('<EntityProcessErrors />', () => {
};
getEntityAncestors.mockResolvedValue({
root: getEntityName(entity),
items: [{ entity, parents: [] }],
rootEntityRef: stringifyEntityRef(entity),
items: [{ entity, parentEntityRefs: [] }],
});
const { getByText, queryByText } = await renderInTestApp(
<ApiProvider apis={apis}>
@@ -199,10 +198,10 @@ describe('<EntityProcessErrors />', () => {
},
};
getEntityAncestors.mockResolvedValue({
root: getEntityName(entity),
rootEntityRef: stringifyEntityRef(entity),
items: [
{ entity, parents: [getEntityName(parent)] },
{ entity: parent, parents: [] },
{ entity, parentEntityRefs: [stringifyEntityRef(parent)] },
{ entity: parent, parentEntityRefs: [] },
],
});
const { getByText, queryByText } = await renderInTestApp(