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/.changeset/ten-geese-hide.md b/.changeset/ten-geese-hide.md
new file mode 100644
index 0000000000..c2386fc44f
--- /dev/null
+++ b/.changeset/ten-geese-hide.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-catalog': patch
+---
+
+Deprecated the `EntityPageLayout`; please use the new extension based `CatalogEntityPage` instead
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 a0274b3b5a..056f10f135 100644
--- a/plugins/catalog-react/api-report.md
+++ b/plugins/catalog-react/api-report.md
@@ -480,6 +480,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 e2f9b8d264..2084745f1e 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..bfb5fd4179
--- /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: number;
+ value: number;
+}) {
+ const { children, value, index, ...other } = props;
+ const classes = useStyles();
+ return (
+
+ {value === index && (
+
+ {children}
+
+ )}
+
+ );
+}
+
+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 (
+
+ );
+}
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..b27bbe3948
--- /dev/null
+++ b/plugins/catalog-react/src/components/InspectEntityDialog/components/AncestryPage.tsx
@@ -0,0 +1,222 @@
+/*
+ * 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 { Box, 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,
+ }),
+ );
+ };
+
+ 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.test.tsx b/plugins/catalog-react/src/components/InspectEntityDialog/components/JsonPage.test.tsx
new file mode 100644
index 0000000000..83888a867f
--- /dev/null
+++ b/plugins/catalog-react/src/components/InspectEntityDialog/components/JsonPage.test.tsx
@@ -0,0 +1,45 @@
+/*
+ * 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 { ComponentEntity } from '@backstage/catalog-model';
+import { renderInTestApp } from '@backstage/test-utils';
+import { screen } from '@testing-library/react';
+import React from 'react';
+import { JsonPage } from './JsonPage';
+
+describe('JsonPage', () => {
+ it('renders', async () => {
+ const entity: ComponentEntity = {
+ apiVersion: 'backstage.io/v1alpha1',
+ kind: 'Component',
+ metadata: {
+ namespace: 'default',
+ name: 'c1',
+ },
+ spec: {
+ type: 'service',
+ lifecycle: 'production',
+ owner: 'ops',
+ },
+ };
+
+ await renderInTestApp();
+
+ expect(screen.getByTestId('code-snippet')).toHaveTextContent(
+ '"lifecycle": "production"',
+ );
+ });
+});
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..44dd626963
--- /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
+
+ >
+ );
+}
diff --git a/plugins/catalog-react/src/components/InspectEntityDialog/components/YamlPage.test.tsx b/plugins/catalog-react/src/components/InspectEntityDialog/components/YamlPage.test.tsx
new file mode 100644
index 0000000000..8106197301
--- /dev/null
+++ b/plugins/catalog-react/src/components/InspectEntityDialog/components/YamlPage.test.tsx
@@ -0,0 +1,45 @@
+/*
+ * 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 { ComponentEntity } from '@backstage/catalog-model';
+import { renderInTestApp } from '@backstage/test-utils';
+import { screen } from '@testing-library/react';
+import React from 'react';
+import { YamlPage } from './YamlPage';
+
+describe('YamlPage', () => {
+ it('renders', async () => {
+ const entity: ComponentEntity = {
+ apiVersion: 'backstage.io/v1alpha1',
+ kind: 'Component',
+ metadata: {
+ namespace: 'default',
+ name: 'c1',
+ },
+ spec: {
+ type: 'service',
+ lifecycle: 'production',
+ owner: 'ops',
+ },
+ };
+
+ await renderInTestApp();
+
+ expect(screen.getByTestId('code-snippet')).toHaveTextContent(
+ 'lifecycle: production',
+ );
+ });
+});
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..ad74611ec5
--- /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}
>
-
+
+
>
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(