diff --git a/.changeset/mighty-suns-drop.md b/.changeset/mighty-suns-drop.md new file mode 100644 index 0000000000..f8bb686aaf --- /dev/null +++ b/.changeset/mighty-suns-drop.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-org': patch +--- + +add aggregated ownership type for kind group in OwnershipCard diff --git a/plugins/org/package.json b/plugins/org/package.json index 11478bbe64..8b8c6709dc 100644 --- a/plugins/org/package.json +++ b/plugins/org/package.json @@ -34,6 +34,7 @@ "@material-ui/lab": "4.0.0-alpha.57", "pluralize": "^8.0.0", "qs": "^6.10.1", + "p-limit": "^3.1.0", "react-router": "6.0.0-beta.0", "react-router-dom": "6.0.0-beta.0", "react-use": "^17.2.4" diff --git a/plugins/org/src/components/Cards/OwnershipCard/ComponentsGrid.tsx b/plugins/org/src/components/Cards/OwnershipCard/ComponentsGrid.tsx new file mode 100644 index 0000000000..6f7680849c --- /dev/null +++ b/plugins/org/src/components/Cards/OwnershipCard/ComponentsGrid.tsx @@ -0,0 +1,127 @@ +/* + * 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 { Link, Progress, ResponseErrorPanel } from '@backstage/core-components'; +import { useRouteRef } from '@backstage/core-plugin-api'; +import { BackstageTheme } from '@backstage/theme'; +import { + Box, + createStyles, + Grid, + makeStyles, + Typography, +} from '@material-ui/core'; +import React from 'react'; +import pluralize from 'pluralize'; +import { catalogIndexRouteRef } from '../../../routes'; +import { useGetEntities } from './useGetEntities'; + +const useStyles = makeStyles((theme: BackstageTheme) => + createStyles({ + card: { + border: `1px solid ${theme.palette.divider}`, + boxShadow: theme.shadows[2], + borderRadius: '4px', + padding: theme.spacing(2), + color: '#fff', + transition: `${theme.transitions.duration.standard}ms`, + '&:hover': { + boxShadow: theme.shadows[4], + }, + }, + bold: { + fontWeight: theme.typography.fontWeightBold, + }, + entityTypeBox: { + background: (props: { type: string }) => + theme.getPageTheme({ themeId: props.type }).backgroundImage, + }, + }), +); + +const EntityCountTile = ({ + counter, + type, + name, + url, +}: { + counter: number; + type: string; + name: string; + url: string; +}) => { + const classes = useStyles({ type }); + + return ( + + + + {counter} + + + {pluralize(name, counter)} + + + + ); +}; + +export const ComponentsGrid = ({ + entity, + relationsType, + isGroup, + entityFilterKind, +}: { + entity: Entity; + relationsType: string; + isGroup: boolean; + entityFilterKind?: string[]; +}) => { + const catalogLink = useRouteRef(catalogIndexRouteRef); + const { componentsWithCounters, loading, error } = useGetEntities( + entity, + relationsType, + isGroup, + entityFilterKind, + ); + + if (loading) { + return ; + } else if (error) { + return ; + } + + return ( + + {componentsWithCounters?.map(c => ( + + + + ))} + + ); +}; diff --git a/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.test.tsx b/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.test.tsx index 483cf9744f..dcaae2d4bf 100644 --- a/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.test.tsx +++ b/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.test.tsx @@ -112,9 +112,9 @@ const getEntitiesMock = ( request?: GetEntitiesRequest, ): Promise => { const filterKinds = - !Array.isArray(request?.filter) && Array.isArray(request?.filter?.kind) - ? request?.filter?.kind ?? [] - : []; // we expect the request to be like { filter: { kind: ['API','System'], .... }. If changed in OwnerShipCard, let's change in also here + Array.isArray(request?.filter) && Array.isArray(request?.filter[0].kind) + ? request?.filter[0].kind ?? [] + : []; // we expect the request to be like { filter: [{ kind: ['API','System'], 'relations.ownedBy': [group:default/my-team], .... }]. If changed in OwnerShipCard, let's change in also here return Promise.resolve({ items: items.filter(item => filterKinds.find(k => k === item.kind)), } as GetEntitiesResponse); @@ -160,7 +160,12 @@ describe('OwnershipCard', () => { ); expect(catalogApi.getEntities).toHaveBeenCalledWith({ - filter: { kind: ['Component', 'API'] }, + filter: [ + { + kind: ['Component', 'API', 'System'], + 'relations.ownedBy': ['group:default/my-team'], + }, + ], fields: [ 'kind', 'metadata.name', @@ -182,7 +187,10 @@ describe('OwnershipCard', () => { expect( queryByText(getByText('LIBRARY').parentElement!, '1'), ).toBeInTheDocument(); - expect(() => getByText('SYSTEM')).toThrowError(); + expect(getByText('SYSTEM')).toBeInTheDocument(); + expect( + queryByText(getByText('SYSTEM').parentElement!, '1'), + ).toBeInTheDocument(); }); it('applies CustomFilterDefinition', async () => { @@ -238,7 +246,7 @@ describe('OwnershipCard', () => { expect(getByText('OPENAPI').closest('a')).toHaveAttribute( 'href', - '/create/?filters%5Bkind%5D=API&filters%5Btype%5D=openapi&filters%5Bowners%5D=my-team&filters%5Buser%5D=all', + '/create/?filters%5Bkind%5D=API&filters%5Btype%5D=openapi&filters%5Bowners%5D%5B0%5D=my-team&filters%5Buser%5D=all', ); }); @@ -280,7 +288,7 @@ describe('OwnershipCard', () => { expect(getByText('OPENAPI').closest('a')).toHaveAttribute( 'href', - '/create/?filters%5Bkind%5D=API&filters%5Btype%5D=openapi&filters%5Bowners%5D=user%3Athe-user&filters%5Bowners%5D=my-team&filters%5Buser%5D=all', + '/create/?filters%5Bkind%5D=API&filters%5Btype%5D=openapi&filters%5Bowners%5D%5B0%5D=the-user&filters%5Bowners%5D%5B1%5D=my-team&filters%5Buser%5D=all', ); }); }); diff --git a/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.tsx b/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.tsx index fb49843e92..13e8502b7a 100644 --- a/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.tsx +++ b/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.tsx @@ -14,123 +14,42 @@ * limitations under the License. */ -import { Entity, UserEntity } from '@backstage/catalog-model'; +import { InfoCard, InfoCardVariants } from '@backstage/core-components'; +import { useEntity } from '@backstage/plugin-catalog-react'; import { - InfoCard, - InfoCardVariants, - Link, - Progress, - ResponseErrorPanel, -} from '@backstage/core-components'; -import { useApi, useRouteRef } from '@backstage/core-plugin-api'; -import { - catalogApiRef, - humanizeEntityRef, - isOwnerOf, - useEntity, -} from '@backstage/plugin-catalog-react'; -import { BackstageTheme } from '@backstage/theme'; -import { - Box, - createStyles, - Grid, + List, + ListItem, + ListItemSecondaryAction, + ListItemText, makeStyles, - Typography, + Switch, + Tooltip, } from '@material-ui/core'; -import qs from 'qs'; -import React from 'react'; -import pluralize from 'pluralize'; -import useAsync from 'react-use/lib/useAsync'; -import { catalogIndexRouteRef } from '../../../routes'; +import React, { useState } from 'react'; +import { ComponentsGrid } from './ComponentsGrid'; -type EntityTypeProps = { - kind: string; - type: string; - count: number; -}; - -const useStyles = makeStyles((theme: BackstageTheme) => - createStyles({ - card: { - border: `1px solid ${theme.palette.divider}`, - boxShadow: theme.shadows[2], - borderRadius: '4px', - padding: theme.spacing(2), - color: '#fff', - transition: `${theme.transitions.duration.standard}ms`, - '&:hover': { - boxShadow: theme.shadows[4], - }, +const useStyles = makeStyles(theme => ({ + list: { + [theme.breakpoints.down('xs')]: { + padding: `0 0 12px`, }, - bold: { - fontWeight: theme.typography.fontWeightBold, + }, + listItemText: { + [theme.breakpoints.down('xs')]: { + paddingRight: 0, + paddingLeft: 0, }, - entityTypeBox: { - background: (props: { type: string }) => - theme.getPageTheme({ themeId: props.type }).backgroundImage, + }, + listItemSecondaryAction: { + [theme.breakpoints.down('xs')]: { + width: '100%', + top: 'auto', + right: 'auto', + position: 'relative', + transform: 'unset', }, - }), -); - -const EntityCountTile = ({ - counter, - type, - name, - url, -}: { - counter: number; - type: string; - name: string; - url: string; -}) => { - const classes = useStyles({ type }); - - return ( - - - - {counter} - - - {pluralize(name, counter)} - - - - ); -}; - -const getQueryParams = ( - owner: Entity, - selectedEntity: EntityTypeProps, -): string => { - const ownerName = humanizeEntityRef(owner, { defaultKind: 'group' }); - const { kind, type } = selectedEntity; - const filters = { - kind, - type, - owners: [ownerName], - user: 'all', - }; - if (owner.kind === 'User') { - const user = owner as UserEntity; - filters.owners = [...filters.owners, ...(user.spec.memberOf ?? [])]; - } - const queryParams = qs.stringify( - { - filters, - }, - { - arrayFormat: 'repeat', - }, - ); - - return queryParams; -}; + }, +})); export const OwnershipCard = ({ variant, @@ -139,90 +58,48 @@ export const OwnershipCard = ({ variant?: InfoCardVariants; entityFilterKind?: string[]; }) => { + const classes = useStyles(); const { entity } = useEntity(); - const catalogApi = useApi(catalogApiRef); - const catalogLink = useRouteRef(catalogIndexRouteRef); - - const { - loading, - error, - value: componentsWithCounters, - } = useAsync(async () => { - const kinds = entityFilterKind ?? ['Component', 'API']; - const entitiesList = await catalogApi.getEntities({ - filter: { - kind: kinds, - }, - fields: [ - 'kind', - 'metadata.name', - 'metadata.namespace', - 'spec.type', - 'relations', - ], - }); - - const ownedEntitiesList = entitiesList.items.filter(component => - isOwnerOf(entity, component), - ); - - const counts = ownedEntitiesList.reduce( - (acc: EntityTypeProps[], ownedEntity) => { - const match = acc.find( - x => - x.kind === ownedEntity.kind && - x.type === (ownedEntity.spec?.type ?? ownedEntity.kind), - ); - if (match) { - match.count += 1; - } else { - acc.push({ - kind: ownedEntity.kind, - type: ownedEntity.spec?.type?.toString() ?? ownedEntity.kind, - count: 1, - }); - } - return acc; - }, - [], - ); - - // Return top N (six) entities to be displayed in ownership boxes - const topN = counts.sort((a, b) => b.count - a.count).slice(0, 6); - - return topN.map(topOwnedEntity => ({ - counter: topOwnedEntity.count, - type: topOwnedEntity.type, - name: topOwnedEntity.type.toLocaleUpperCase('en-US'), - queryParams: getQueryParams(entity, topOwnedEntity), - })) as Array<{ - counter: number; - type: string; - name: string; - queryParams: string; - }>; - }, [catalogApi, entity]); - - if (loading) { - return ; - } else if (error) { - return ; - } + const isGroup = entity.kind === 'Group'; + const [relationsType, setRelationsType] = useState('direct'); return ( - - {componentsWithCounters?.map(c => ( - - - - ))} - + + + + + Direct Relations + + + relationsType === 'direct' + ? setRelationsType('aggregated') + : setRelationsType('direct') + } + name="pin" + inputProps={{ 'aria-label': 'Ownership Type Switch' }} + disabled={!isGroup} + /> + + Aggregated Relations + + + + ); }; diff --git a/plugins/org/src/components/Cards/OwnershipCard/useGetEntities.ts b/plugins/org/src/components/Cards/OwnershipCard/useGetEntities.ts new file mode 100644 index 0000000000..48760002fa --- /dev/null +++ b/plugins/org/src/components/Cards/OwnershipCard/useGetEntities.ts @@ -0,0 +1,215 @@ +/* + * 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, + RELATION_MEMBER_OF, + RELATION_PARENT_OF, + stringifyEntityRef, +} from '@backstage/catalog-model'; +import { + CatalogApi, + catalogApiRef, + getEntityRelations, +} from '@backstage/plugin-catalog-react'; +import limiterFactory from 'p-limit'; +import { useApi } from '@backstage/core-plugin-api'; +import useAsync from 'react-use/lib/useAsync'; +import qs from 'qs'; + +const limiter = limiterFactory(10); + +type EntityTypeProps = { + kind: string; + type: string; + count: number; +}; + +const getQueryParams = ( + ownersEntityRef: string[], + selectedEntity: EntityTypeProps, +): string => { + const { kind, type } = selectedEntity; + const owners = ownersEntityRef.map(owner => owner.split('/')[1]); + const filters = { + kind, + type, + owners, + user: 'all', + }; + const queryParams = qs.stringify({ + filters, + }); + + return queryParams; +}; + +const getOwnersEntityRef = (owner: Entity): string[] => { + let owners = [stringifyEntityRef(owner)]; + if (owner.kind === 'User') { + const ownerGroups = getEntityRelations(owner, RELATION_MEMBER_OF, { + kind: 'Group', + }); + const ownerGroupsName = ownerGroups.map(ownerGroup => + stringifyEntityRef({ + kind: ownerGroup.kind, + namespace: ownerGroup.namespace, + name: ownerGroup.name, + }), + ); + owners = [...owners, ...ownerGroupsName]; + } + return owners; +}; + +const getAggregatedOwnersEntityRef = async ( + parentGroup: Entity, + catalogApi: CatalogApi, +): Promise => { + const requestedEntities: Entity[] = []; + const outstandingEntities = new Map>(); + const processedEntities = new Set(); + requestedEntities.push(parentGroup); + let currentEntity = parentGroup; + + while (requestedEntities.length > 0) { + const childRelations = getEntityRelations( + currentEntity, + RELATION_PARENT_OF, + { + kind: 'Group', + }, + ); + + await Promise.all( + childRelations.map(childGroup => + limiter(async () => { + const promise = catalogApi.getEntityByRef(childGroup); + outstandingEntities.set(childGroup.name, promise); + try { + const processedEntity = await promise; + if (processedEntity) { + requestedEntities.push(processedEntity); + } + } finally { + outstandingEntities.delete(childGroup.name); + } + }), + ), + ); + requestedEntities.shift(); + processedEntities.add( + stringifyEntityRef({ + kind: currentEntity.kind, + namespace: currentEntity.metadata.namespace, + name: currentEntity.metadata.name, + }), + ); + // always set currentEntity to the first element of array requestedEntities + currentEntity = requestedEntities[0]; + } + + return Array.from(processedEntities); +}; + +export function useGetEntities( + entity: Entity, + relationsType: string, + isGroup: boolean, + entityFilterKind?: string[], +): { + componentsWithCounters: + | { + counter: number; + type: string; + name: string; + queryParams: string; + }[] + | undefined; + loading: boolean; + error?: Error; +} { + const catalogApi = useApi(catalogApiRef); + const kinds = entityFilterKind ?? ['Component', 'API', 'System']; + + const { + loading, + error, + value: componentsWithCounters, + } = useAsync(async () => { + const owners = + relationsType === 'aggregated' && isGroup + ? await getAggregatedOwnersEntityRef(entity, catalogApi) + : getOwnersEntityRef(entity); + const ownedEntitiesList = await catalogApi.getEntities({ + filter: [ + { + kind: kinds, + 'relations.ownedBy': owners, + }, + ], + fields: [ + 'kind', + 'metadata.name', + 'metadata.namespace', + 'spec.type', + 'relations', + ], + }); + + const counts = ownedEntitiesList.items.reduce( + (acc: EntityTypeProps[], ownedEntity) => { + const match = acc.find( + x => + x.kind === ownedEntity.kind && + x.type === (ownedEntity.spec?.type ?? ownedEntity.kind), + ); + if (match) { + match.count += 1; + } else { + acc.push({ + kind: ownedEntity.kind, + type: ownedEntity.spec?.type?.toString() ?? ownedEntity.kind, + count: 1, + }); + } + return acc; + }, + [], + ); + + // Return top N (six) entities to be displayed in ownership boxes + const topN = counts.sort((a, b) => b.count - a.count).slice(0, 6); + + return topN.map(topOwnedEntity => ({ + counter: topOwnedEntity.count, + type: topOwnedEntity.type, + name: topOwnedEntity.type.toLocaleUpperCase('en-US'), + queryParams: getQueryParams(owners, topOwnedEntity), + })) as Array<{ + counter: number; + type: string; + name: string; + queryParams: string; + }>; + }, [catalogApi, entity, relationsType]); + + return { + componentsWithCounters, + loading, + error, + }; +}