From f8633307c40b0af9bf813e9d90986bc954a51bc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Fri, 4 Feb 2022 20:17:10 +0100 Subject: [PATCH] add an entity inspection dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .changeset/big-days-divide.md | 10 + .changeset/smooth-wasps-knock.md | 7 + packages/catalog-client/api-report.md | 4 +- packages/catalog-client/src/types/api.ts | 4 +- plugins/catalog-react/api-report.md | 7 + plugins/catalog-react/package.json | 2 + .../InspectEntityDialog.tsx | 162 +++++++++++++ .../components/AncestryPage.tsx | 223 ++++++++++++++++++ .../components/ColocatedPage.tsx | 171 ++++++++++++++ .../components/EntityKindIcon.tsx | 36 +++ .../components/JsonPage.tsx | 41 ++++ .../components/OverviewPage.tsx | 180 ++++++++++++++ .../components/YamlPage.tsx | 42 ++++ .../InspectEntityDialog/components/common.tsx | 124 ++++++++++ .../InspectEntityDialog/components/util.ts | 25 ++ .../components/InspectEntityDialog/index.ts | 17 ++ plugins/catalog-react/src/components/index.ts | 1 + plugins/catalog/api-report.md | 4 +- .../EntityContextMenu.test.tsx | 29 ++- .../EntityContextMenu/EntityContextMenu.tsx | 18 +- .../components/EntityLayout/EntityLayout.tsx | 24 +- .../EntityPageLayout/EntityPageLayout.tsx | 17 +- .../EntityProcessingErrorsPanel.test.tsx | 15 +- 23 files changed, 1139 insertions(+), 24 deletions(-) create mode 100644 .changeset/big-days-divide.md create mode 100644 .changeset/smooth-wasps-knock.md create mode 100644 plugins/catalog-react/src/components/InspectEntityDialog/InspectEntityDialog.tsx create mode 100644 plugins/catalog-react/src/components/InspectEntityDialog/components/AncestryPage.tsx create mode 100644 plugins/catalog-react/src/components/InspectEntityDialog/components/ColocatedPage.tsx create mode 100644 plugins/catalog-react/src/components/InspectEntityDialog/components/EntityKindIcon.tsx create mode 100644 plugins/catalog-react/src/components/InspectEntityDialog/components/JsonPage.tsx create mode 100644 plugins/catalog-react/src/components/InspectEntityDialog/components/OverviewPage.tsx create mode 100644 plugins/catalog-react/src/components/InspectEntityDialog/components/YamlPage.tsx create mode 100644 plugins/catalog-react/src/components/InspectEntityDialog/components/common.tsx create mode 100644 plugins/catalog-react/src/components/InspectEntityDialog/components/util.ts create mode 100644 plugins/catalog-react/src/components/InspectEntityDialog/index.ts diff --git a/.changeset/big-days-divide.md b/.changeset/big-days-divide.md new file mode 100644 index 0000000000..0285a5aacd --- /dev/null +++ b/.changeset/big-days-divide.md @@ -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. diff --git a/.changeset/smooth-wasps-knock.md b/.changeset/smooth-wasps-knock.md new file mode 100644 index 0000000000..de4c58e04b --- /dev/null +++ b/.changeset/smooth-wasps-knock.md @@ -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. diff --git a/packages/catalog-client/api-report.md b/packages/catalog-client/api-report.md index f3921993d3..13b2e6b5f0 100644 --- a/packages/catalog-client/api-report.md +++ b/packages/catalog-client/api-report.md @@ -133,10 +133,10 @@ export type CatalogEntityAncestorsRequest = { // @public export type CatalogEntityAncestorsResponse = { - root: EntityName; + rootEntityRef: string; items: { entity: Entity; - parents: EntityName[]; + parentEntityRefs: string[]; }[]; }; diff --git a/packages/catalog-client/src/types/api.ts b/packages/catalog-client/src/types/api.ts index 3a09e14d71..0622d654fa 100644 --- a/packages/catalog-client/src/types/api.ts +++ b/packages/catalog-client/src/types/api.ts @@ -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[] }[]; }; /** diff --git a/plugins/catalog-react/api-report.md b/plugins/catalog-react/api-report.md index 2c064a504f..83c5d4aaa2 100644 --- a/plugins/catalog-react/api-report.md +++ b/plugins/catalog-react/api-report.md @@ -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 diff --git a/plugins/catalog-react/package.json b/plugins/catalog-react/package.json index 9018ea75d7..98c671feec 100644 --- a/plugins/catalog-react/package.json +++ b/plugins/catalog-react/package.json @@ -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": { diff --git a/plugins/catalog-react/src/components/InspectEntityDialog/InspectEntityDialog.tsx b/plugins/catalog-react/src/components/InspectEntityDialog/InspectEntityDialog.tsx new file mode 100644 index 0000000000..2e904e4eb9 --- /dev/null +++ b/plugins/catalog-react/src/components/InspectEntityDialog/InspectEntityDialog.tsx @@ -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 ( + + ); +} + +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 ( + + + Entity Inspector + + +
+ setActiveTab(newValue)} + aria-label="Inspector options" + className={classes.tabs} + > + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+ ); +} diff --git a/plugins/catalog-react/src/components/InspectEntityDialog/components/AncestryPage.tsx b/plugins/catalog-react/src/components/InspectEntityDialog/components/AncestryPage.tsx new file mode 100644 index 0000000000..cab6faff0e --- /dev/null +++ b/plugins/catalog-react/src/components/InspectEntityDialog/components/AncestryPage.tsx @@ -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[]; + 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>(); + const edges = new Array(); + 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) { + const classes = useStyles(); + const navigate = useNavigate(); + const entityRoute = useRouteRef(entityRouteRef); + const [width, setWidth] = useState(0); + const [height, setHeight] = useState(0); + const idRef = useRef(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 ( + + + + + {displayTitle} + + + ); +} + +export function AncestryPage(props: { entity: Entity }) { + const { loading, error, nodes, edges } = useAncestry(props.entity); + if (loading) { + return ; + } else if (error) { + return ; + } + + return ( + <> + Ancestry + + This is the ancestry of entities above the current one - as in, the + chain(s) of entities down to the current one, where{' '} + + processors emitted + {' '} + child entities that ultimately led to the current one existing. Note + that this is a completely different mechanism from relations. + + + + ); +} diff --git a/plugins/catalog-react/src/components/InspectEntityDialog/components/ColocatedPage.tsx b/plugins/catalog-react/src/components/InspectEntityDialog/components/ColocatedPage.tsx new file mode 100644 index 0000000000..96d3900a59 --- /dev/null +++ b/plugins/catalog-react/src/components/InspectEntityDialog/components/ColocatedPage.tsx @@ -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 ( + + {props.header && } + {props.entities.map(entity => ( + + + + + } /> + + ))} + + ); +} + +function Contents(props: { entity: Entity }) { + const { entity } = props; + + const { loading, error, location, originLocation, colocatedEntities } = + useColocated(entity); + if (loading) { + return ; + } else if (error) { + return ; + } + + if (!location && !originLocation) { + return ( + Entity had no location information. + ); + } else if (!colocatedEntities?.length) { + return ( + + There were no other entities on this location. + + ); + } + + if (location === originLocation) { + return ; + } + + 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 && ( + + )} + {atOrigin.length > 0 && ( + + )} + + ); +} + +export function ColocatedPage(props: { entity: Entity }) { + const classes = useStyles(); + return ( + <> + Colocated + + 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). + +
+ +
+ + ); +} diff --git a/plugins/catalog-react/src/components/InspectEntityDialog/components/EntityKindIcon.tsx b/plugins/catalog-react/src/components/InspectEntityDialog/components/EntityKindIcon.tsx new file mode 100644 index 0000000000..c2fc49aa0c --- /dev/null +++ b/plugins/catalog-react/src/components/InspectEntityDialog/components/EntityKindIcon.tsx @@ -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 ; +} diff --git a/plugins/catalog-react/src/components/InspectEntityDialog/components/JsonPage.tsx b/plugins/catalog-react/src/components/InspectEntityDialog/components/JsonPage.tsx new file mode 100644 index 0000000000..cee350f62a --- /dev/null +++ b/plugins/catalog-react/src/components/InspectEntityDialog/components/JsonPage.tsx @@ -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 ( + <> + Entity as JSON + + This is the raw entity data as received from the catalog, on JSON form. + + +
+ +
+
+ + ); +} diff --git a/plugins/catalog-react/src/components/InspectEntityDialog/components/OverviewPage.tsx b/plugins/catalog-react/src/components/InspectEntityDialog/components/OverviewPage.tsx new file mode 100644 index 0000000000..dbb7399481 --- /dev/null +++ b/plugins/catalog-react/src/components/InspectEntityDialog/components/OverviewPage.tsx @@ -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 ( + <> + Overview +
+ + + + + + + + + {spec?.type && ( + + + + )} + {metadata.uid && ( + + + + )} + {metadata.etag && ( + + + + )} + + + + + {!!Object.keys(metadata.annotations || {}).length && ( + + Annotations + + + } + > + {Object.entries(metadata.annotations!).map(entry => ( + + ))} + + )} + {!!Object.keys(metadata.labels || {}).length && ( + + Labels + + + } + > + {Object.entries(metadata.labels!).map(entry => ( + + ))} + + )} + {!!metadata.tags?.length && ( + Tags}> + {metadata.tags.map((tag, index) => ( + + + + + ))} + + )} + + + {!!relations.length && ( + + {Object.entries(groupedRelations).map( + ([type, groupRelations], index) => ( +
+ {type}}> + {groupRelations.map(group => ( + + + + + } + /> + + ))} + +
+ ), + )} +
+ )} + + {!!status.items?.length && ( + + {status.items.map((item, index) => ( +
+ + {item.level}: {item.type} + + {item.message} +
+ ))} +
+ )} +
+ + ); +} diff --git a/plugins/catalog-react/src/components/InspectEntityDialog/components/YamlPage.tsx b/plugins/catalog-react/src/components/InspectEntityDialog/components/YamlPage.tsx new file mode 100644 index 0000000000..c876fe9edd --- /dev/null +++ b/plugins/catalog-react/src/components/InspectEntityDialog/components/YamlPage.tsx @@ -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 ( + <> + Entity as YAML + + This is the raw entity data as received from the catalog, on YAML form. + + +
+ +
+
+ + ); +} diff --git a/plugins/catalog-react/src/components/InspectEntityDialog/components/common.tsx b/plugins/catalog-react/src/components/InspectEntityDialog/components/common.tsx new file mode 100644 index 0000000000..79a3c69955 --- /dev/null +++ b/plugins/catalog-react/src/components/InspectEntityDialog/components/common.tsx @@ -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 ( + + ); +} + +export function ListSubheader(props: { children?: React.ReactNode }) { + const classes = useStyles(); + return ( + + {props.children} + + ); +} + +export function Container(props: { + title: React.ReactNode; + helpLink?: string; + children: React.ReactNode; +}) { + return ( + + + + + {props.title} + {props.helpLink && } + + {props.children} + + + + ); +} + +export function KeyValueListItem(props: { + indent?: boolean; + entry: [string, string]; +}) { + const [key, value] = props.entry; + return ( + + {props.indent && } + + {value.substring(0, 4)} + {value.substring(4)} + + ) + } + /> + + ); +} + +export function HelpIcon(props: { to: string }) { + const classes = useStyles(); + return ( + + + + ); +} diff --git a/plugins/catalog-react/src/components/InspectEntityDialog/components/util.ts b/plugins/catalog-react/src/components/InspectEntityDialog/components/util.ts new file mode 100644 index 0000000000..9dd25ba538 --- /dev/null +++ b/plugins/catalog-react/src/components/InspectEntityDialog/components/util.ts @@ -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)), + ); +} diff --git a/plugins/catalog-react/src/components/InspectEntityDialog/index.ts b/plugins/catalog-react/src/components/InspectEntityDialog/index.ts new file mode 100644 index 0000000000..1360ade125 --- /dev/null +++ b/plugins/catalog-react/src/components/InspectEntityDialog/index.ts @@ -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'; diff --git a/plugins/catalog-react/src/components/index.ts b/plugins/catalog-react/src/components/index.ts index b80472a81d..807d6bb702 100644 --- a/plugins/catalog-react/src/components/index.ts +++ b/plugins/catalog-react/src/components/index.ts @@ -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'; diff --git a/plugins/catalog/api-report.md b/plugins/catalog/api-report.md index ad391b594f..e7825127a6 100644 --- a/plugins/catalog/api-report.md +++ b/plugins/catalog/api-report.md @@ -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 ``` diff --git a/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.test.tsx b/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.test.tsx index f16a17bc1d..eb920aaa20 100644 --- a/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.test.tsx +++ b/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.test.tsx @@ -43,7 +43,12 @@ describe('ComponentContextMenu', () => { it('should call onUnregisterEntity on button click', async () => { const mockCallback = jest.fn(); - await render(); + await render( + {}} + />, + ); 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( + {}} + 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( , ); diff --git a/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.tsx b/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.tsx index 6648514382..7bda1f4629 100644 --- a/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.tsx +++ b/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.tsx @@ -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(); const classes = useStyles(); @@ -128,10 +131,21 @@ export const EntityContextMenu = ({ disabled={disableUnregister} > - + + { + onClose(); + onInspectEntity(); + }} + > + + + + + diff --git a/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx b/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx index c5679355e5..144e5d01a8 100644 --- a/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx +++ b/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx @@ -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 ( @@ -233,7 +243,8 @@ export const EntityLayout = ({ setConfirmationDialogOpen(true)} + onInspectEntity={() => setInspectionDialogOpen(true)} /> )} @@ -267,6 +278,11 @@ export const EntityLayout = ({ onConfirm={cleanUpAfterRemoval} onClose={() => setConfirmationDialogOpen(false)} /> + setInspectionDialogOpen(false)} + /> ); }; diff --git a/plugins/catalog/src/components/EntityPageLayout/EntityPageLayout.tsx b/plugins/catalog/src/components/EntityPageLayout/EntityPageLayout.tsx index c371d6245e..82a980afcb 100644 --- a/plugins/catalog/src/components/EntityPageLayout/EntityPageLayout.tsx +++ b/plugins/catalog/src/components/EntityPageLayout/EntityPageLayout.tsx @@ -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 (
setConfirmationDialogOpen(true)} + onInspectEntity={() => setInspectionDialogOpen(true)} /> )} @@ -198,6 +204,11 @@ export const EntityPageLayout = ({ onConfirm={cleanUpAfterRemoval} onClose={() => setConfirmationDialogOpen(false)} /> + setInspectionDialogOpen(false)} + /> ); }; diff --git a/plugins/catalog/src/components/EntityProcessingErrorsPanel/EntityProcessingErrorsPanel.test.tsx b/plugins/catalog/src/components/EntityProcessingErrorsPanel/EntityProcessingErrorsPanel.test.tsx index ae5a4684b1..2297493a7a 100644 --- a/plugins/catalog/src/components/EntityProcessingErrorsPanel/EntityProcessingErrorsPanel.test.tsx +++ b/plugins/catalog/src/components/EntityProcessingErrorsPanel/EntityProcessingErrorsPanel.test.tsx @@ -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('', () => { const getEntityAncestors: jest.MockedFunction< @@ -98,8 +97,8 @@ describe('', () => { }; getEntityAncestors.mockResolvedValue({ - root: getEntityName(entity), - items: [{ entity, parents: [] }], + rootEntityRef: stringifyEntityRef(entity), + items: [{ entity, parentEntityRefs: [] }], }); const { getByText, queryByText } = await renderInTestApp( @@ -199,10 +198,10 @@ describe('', () => { }, }; 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(