diff --git a/.changeset/sixty-humans-rescue.md b/.changeset/sixty-humans-rescue.md new file mode 100644 index 0000000000..e6c9e78131 --- /dev/null +++ b/.changeset/sixty-humans-rescue.md @@ -0,0 +1,6 @@ +--- +'@backstage/create-app': patch +'@backstage/plugin-catalog': patch +--- + +Display a warning alert if relations are defined, which don't exist in the catalog. diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index be5ae3e3bf..25630253d4 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -66,6 +66,8 @@ import { isKind, isOrphan, hasLabels, + hasRelationWarnings, + EntityRelationWarning, } from '@internal/plugin-catalog-customized'; import { Direction, @@ -328,6 +330,14 @@ const entityWarningContent = ( + + + + + + + + diff --git a/packages/create-app/templates/default-app/packages/app/src/components/catalog/EntityPage.tsx b/packages/create-app/templates/default-app/packages/app/src/components/catalog/EntityPage.tsx index 54a05eea03..6722ea2d03 100644 --- a/packages/create-app/templates/default-app/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/create-app/templates/default-app/packages/app/src/components/catalog/EntityPage.tsx @@ -25,6 +25,8 @@ import { isKind, hasCatalogProcessingErrors, isOrphan, + hasRelationWarnings, + EntityRelationWarning, } from '@backstage/plugin-catalog'; import { isGithubActionsAvailable, @@ -101,6 +103,14 @@ const entityWarningContent = ( + + + + + + + + diff --git a/plugins/catalog/api-report.md b/plugins/catalog/api-report.md index f053c15c8a..358bb82b8a 100644 --- a/plugins/catalog/api-report.md +++ b/plugins/catalog/api-report.md @@ -377,6 +377,9 @@ export interface EntityPredicates { // @public export function EntityProcessingErrorsPanel(): JSX.Element | null; +// @public +export function EntityRelationWarning(): JSX.Element | null; + // @public (undocumented) export const EntitySwitch: { (props: EntitySwitchProps): JSX.Element; @@ -429,6 +432,14 @@ export interface HasComponentsCardProps { // @public export function hasLabels(entity: Entity): boolean; +// @public +export function hasRelationWarnings( + entity: Entity, + context: { + apis: ApiHolder; + }, +): Promise; + // @public (undocumented) export interface HasResourcesCardProps { // (undocumented) diff --git a/plugins/catalog/src/components/EntityRelationWarning/EntityRelationWarning.test.tsx b/plugins/catalog/src/components/EntityRelationWarning/EntityRelationWarning.test.tsx new file mode 100644 index 0000000000..41e5f7e3d5 --- /dev/null +++ b/plugins/catalog/src/components/EntityRelationWarning/EntityRelationWarning.test.tsx @@ -0,0 +1,176 @@ +/* + * Copyright 2020 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 { ApiProvider } from '@backstage/core-app-api'; +import { + CatalogApi, + catalogApiRef, + EntityProvider, +} from '@backstage/plugin-catalog-react'; +import { renderInTestApp, TestApiRegistry } from '@backstage/test-utils'; +import { screen } from '@testing-library/react'; +import React from 'react'; +import { + EntityRelationWarning, + hasRelationWarnings, +} from './EntityRelationWarning'; + +describe('', () => { + const getEntitiesByRefs: jest.MockedFunction< + CatalogApi['getEntitiesByRefs'] + > = jest.fn(); + const apis = TestApiRegistry.from([catalogApiRef, { getEntitiesByRefs }]); + + const entityExisting: Entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'existing', + }, + }; + + it('renders EntityRelationWarning if the entity has missing relations', async () => { + const entity: Entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'software', + }, + relations: [ + { + type: 'dependsOn', + targetRef: 'component:default/missing', + }, + { + type: 'dependsOn', + targetRef: 'component:default/existing', + }, + ], + }; + + getEntitiesByRefs.mockResolvedValue({ + items: [undefined, entityExisting], + }); + await renderInTestApp( + + + + + , + ); + + expect( + screen.getByText(content => + content.includes( + "This entity has relations to other entities, which can't be found in the catalog.", + ), + ), + ).toBeInTheDocument(); + expect( + screen.getByText(content => + content.includes('Entities not found are: component:default/missing'), + ), + ).toBeInTheDocument(); + }); + + it("doesn't render EntityRelationWarning if the entity has no missing relations", async () => { + const entity: Entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'software', + }, + relations: [ + { + type: 'dependsOn', + targetRef: 'component:default/existing', + }, + ], + }; + + getEntitiesByRefs.mockResolvedValue({ + items: [entityExisting], + }); + await renderInTestApp( + + + + + , + ); + + expect( + screen.queryByText(content => + content.includes( + "This entity has relations to other entities, which can't be found in the catalog.", + ), + ), + ).not.toBeInTheDocument(); + }); + + it('returns hasRelationWarnings truthy if the entity has missing relations', async () => { + const entity: Entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'software', + }, + relations: [ + { + type: 'dependsOn', + targetRef: 'component:default/missing', + }, + { + type: 'dependsOn', + targetRef: 'component:default/existing', + }, + ], + }; + + getEntitiesByRefs.mockResolvedValue({ + items: [undefined, entityExisting], + }); + + const hasWarnings = await hasRelationWarnings(entity, { apis }); + + expect(hasWarnings).toBeTruthy(); + }); + + it('returns hasRelationWarnings falsy if the entity has no missing relations', async () => { + const entity: Entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'software', + }, + relations: [ + { + type: 'dependsOn', + targetRef: 'component:default/existing', + }, + ], + }; + + getEntitiesByRefs.mockResolvedValue({ + items: [entityExisting], + }); + + const hasWarnings = await hasRelationWarnings(entity, { apis }); + + expect(hasWarnings).toBeFalsy(); + }); +}); diff --git a/plugins/catalog/src/components/EntityRelationWarning/EntityRelationWarning.tsx b/plugins/catalog/src/components/EntityRelationWarning/EntityRelationWarning.tsx new file mode 100644 index 0000000000..16384ac1d2 --- /dev/null +++ b/plugins/catalog/src/components/EntityRelationWarning/EntityRelationWarning.tsx @@ -0,0 +1,105 @@ +/* + * Copyright 2021 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 { + CatalogApi, + catalogApiRef, + useEntity, +} from '@backstage/plugin-catalog-react'; +import { Alert } from '@material-ui/lab'; +import React from 'react'; +import useAsync from 'react-use/lib/useAsync'; +import { Box } from '@material-ui/core'; +import { ResponseErrorPanel } from '@backstage/core-components'; +import { useApi, ApiHolder } from '@backstage/core-plugin-api'; + +async function getRelationWarnings(entity: Entity, catalogApi: CatalogApi) { + const entityRefRelations = entity.relations?.map( + relation => relation.targetRef, + ); + if ( + !entityRefRelations || + entityRefRelations?.length < 1 || + entityRefRelations.length > 1000 + ) { + return []; + } + + const relatedEntities = await catalogApi.getEntitiesByRefs({ + entityRefs: entityRefRelations, + fields: ['kind', 'metadata.name', 'metadata.namespace'], + }); + + return entityRefRelations.filter( + (_, index) => relatedEntities.items[index] === undefined, + ); +} + +/** + * Returns true if the given entity has relations to other entities, which + * don't exist in the catalog + * + * @public + */ +export async function hasRelationWarnings( + entity: Entity, + context: { apis: ApiHolder }, +) { + const catalogApi = context.apis.get(catalogApiRef); + if (!catalogApi) { + throw new Error(`No implementation available for ${catalogApiRef}`); + } + + const relatedEntitiesMissing = await getRelationWarnings(entity, catalogApi); + return relatedEntitiesMissing.length > 0; +} + +/** + * Displays a warning alert if the entity has relations to other entities, which + * don't exist in the catalog + * + * @public + */ +export function EntityRelationWarning() { + const { entity } = useEntity(); + const catalogApi = useApi(catalogApiRef); + const { loading, error, value } = useAsync(async () => { + return getRelationWarnings(entity, catalogApi); + }, [entity, catalogApi]); + + if (error) { + return ( + + + + ); + } + + if (loading || !value || value.length === 0) { + return null; + } + + return ( + <> + + This entity has relations to other entities, which can't be found in the + catalog.
+ Entities not found are: {value.join(', ')} +
+ + ); +} diff --git a/plugins/catalog/src/components/EntityRelationWarning/index.ts b/plugins/catalog/src/components/EntityRelationWarning/index.ts new file mode 100644 index 0000000000..1d88926f25 --- /dev/null +++ b/plugins/catalog/src/components/EntityRelationWarning/index.ts @@ -0,0 +1,20 @@ +/* + * Copyright 2021 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 { + EntityRelationWarning, + hasRelationWarnings, +} from './EntityRelationWarning'; diff --git a/plugins/catalog/src/index.ts b/plugins/catalog/src/index.ts index 42150dfd66..a691011c96 100644 --- a/plugins/catalog/src/index.ts +++ b/plugins/catalog/src/index.ts @@ -32,6 +32,7 @@ export * from './components/CatalogKindHeader'; export * from './components/CatalogTable'; export * from './components/EntityLayout'; export * from './components/EntityOrphanWarning'; +export * from './components/EntityRelationWarning'; export * from './components/EntityProcessingErrorsPanel'; export * from './components/EntitySwitch'; export * from './components/FilteredEntityLayout';