From 2566b48ebb6d303dfd9a8504c457333c50f4212d Mon Sep 17 00:00:00 2001 From: Prasetya Aria Wibawa Date: Thu, 17 Feb 2022 00:36:13 +0700 Subject: [PATCH] transitive ownership of entities for group kind Signed-off-by: Prasetya Aria Wibawa --- plugins/org/package.json | 1 + .../OwnershipCard/DirectRelationsGrid.tsx | 122 ++++++++ .../Cards/OwnershipCard/OwnershipCard.tsx | 261 ++++++------------ .../OwnershipCard/TransitiveRelationsGrid.tsx | 122 ++++++++ .../Cards/OwnershipCard/useDirectEntities.ts | 138 +++++++++ .../OwnershipCard/useTransitiveEntities.ts | 180 ++++++++++++ 6 files changed, 641 insertions(+), 183 deletions(-) create mode 100644 plugins/org/src/components/Cards/OwnershipCard/DirectRelationsGrid.tsx create mode 100644 plugins/org/src/components/Cards/OwnershipCard/TransitiveRelationsGrid.tsx create mode 100644 plugins/org/src/components/Cards/OwnershipCard/useDirectEntities.ts create mode 100644 plugins/org/src/components/Cards/OwnershipCard/useTransitiveEntities.ts 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/DirectRelationsGrid.tsx b/plugins/org/src/components/Cards/OwnershipCard/DirectRelationsGrid.tsx new file mode 100644 index 0000000000..a7cd521ecc --- /dev/null +++ b/plugins/org/src/components/Cards/OwnershipCard/DirectRelationsGrid.tsx @@ -0,0 +1,122 @@ +/* + * 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 { useDirectEntities } from './useDirectEntities'; + +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 DirectRelationsGrid = ({ + entity, + entityFilterKind, +}: { + entity: Entity; + entityFilterKind?: string[]; +}) => { + const catalogLink = useRouteRef(catalogIndexRouteRef); + + const { componentsWithCounters, loading, error } = useDirectEntities( + entity, + entityFilterKind, + ); + + if (loading) { + return ; + } else if (error) { + return ; + } + + return ( + + {componentsWithCounters?.map(c => ( + + + + ))} + + ); +}; diff --git a/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.tsx b/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.tsx index fb49843e92..9ede02ce6e 100644 --- a/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.tsx +++ b/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.tsx @@ -14,122 +14,61 @@ * limitations under the License. */ -import { Entity, UserEntity } from '@backstage/catalog-model'; +import { Entity } 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 { DirectRelationsGrid } from './DirectRelationsGrid'; +import { TransitiveRelationsGrid } from './TransitiveRelationsGrid'; -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 }); + }, +})); +const directRelationsGrid = (entity: Entity, entityFilterKind?: string[]) => { 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', - }, +const transitiveRelationsGrid = ( + entity: Entity, + entityFilterKind?: string[], +) => { + return ( + ); - - return queryParams; }; export const OwnershipCard = ({ @@ -139,90 +78,46 @@ export const OwnershipCard = ({ variant?: InfoCardVariants; entityFilterKind?: string[]; }) => { + const [relationsType, setRelationsType] = useState('direct'); + 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 renderedGrid = + relationsType !== 'direct' && isGroup + ? transitiveRelationsGrid(entity, entityFilterKind) + : directRelationsGrid(entity, entityFilterKind); return ( - - {componentsWithCounters?.map(c => ( - - - - ))} - + + + + + Direct Relations + + + relationsType === 'direct' + ? setRelationsType('transitive') + : setRelationsType('direct') + } + name="pin" + inputProps={{ 'aria-label': 'Pin Sidebar Switch' }} + disabled={!isGroup} + /> + + Transitive Relations + + + + {renderedGrid} ); }; diff --git a/plugins/org/src/components/Cards/OwnershipCard/TransitiveRelationsGrid.tsx b/plugins/org/src/components/Cards/OwnershipCard/TransitiveRelationsGrid.tsx new file mode 100644 index 0000000000..8ed4d1c413 --- /dev/null +++ b/plugins/org/src/components/Cards/OwnershipCard/TransitiveRelationsGrid.tsx @@ -0,0 +1,122 @@ +/* + * 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 { useTransitiveEntities } from './useTransitiveEntities'; + +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)} + + + + ); +}; + +// can only be used for group entity +export const TransitiveRelationsGrid = ({ + entity, + entityFilterKind, +}: { + entity: Entity; + entityFilterKind?: string[]; +}) => { + const catalogLink = useRouteRef(catalogIndexRouteRef); + const { componentsWithCounters, loading, error } = useTransitiveEntities( + entity, + entityFilterKind, + ); + + if (loading) { + return ; + } else if (error) { + return ; + } + + return ( + + {componentsWithCounters?.map(c => ( + + + + ))} + + ); +}; diff --git a/plugins/org/src/components/Cards/OwnershipCard/useDirectEntities.ts b/plugins/org/src/components/Cards/OwnershipCard/useDirectEntities.ts new file mode 100644 index 0000000000..5e12d839de --- /dev/null +++ b/plugins/org/src/components/Cards/OwnershipCard/useDirectEntities.ts @@ -0,0 +1,138 @@ +/* + * 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, UserEntity } from '@backstage/catalog-model'; +import { useApi } from '@backstage/core-plugin-api'; +import { + catalogApiRef, + formatEntityRefTitle, + isOwnerOf, +} from '@backstage/plugin-catalog-react'; +import qs from 'qs'; +import { useAsync } from 'react-use'; + +type EntityTypeProps = { + kind: string; + type: string; + count: number; +}; + +const getQueryParams = ( + owner: Entity, + selectedEntity: EntityTypeProps, +): string => { + const ownerName = formatEntityRefTitle(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, + }); + + return queryParams; +}; + +export function useDirectEntities( + entity: Entity, + entityFilterKind?: string[], +): { + componentsWithCounters: + | { + counter: number; + type: string; + name: string; + queryParams: string; + }[] + | undefined; + loading: boolean; + error?: Error; +} { + const catalogApi = useApi(catalogApiRef); + + const { + loading, + error, + value: componentsWithCounters, + } = useAsync(async () => { + const kinds = entityFilterKind ?? ['Component', 'API', 'System']; + 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]); + + return { + componentsWithCounters, + loading, + error, + }; +} diff --git a/plugins/org/src/components/Cards/OwnershipCard/useTransitiveEntities.ts b/plugins/org/src/components/Cards/OwnershipCard/useTransitiveEntities.ts new file mode 100644 index 0000000000..f2665ec798 --- /dev/null +++ b/plugins/org/src/components/Cards/OwnershipCard/useTransitiveEntities.ts @@ -0,0 +1,180 @@ +/* + * 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_PARENT_OF } from '@backstage/catalog-model'; +import { + catalogApiRef, + getEntityRelations, +} from '@backstage/plugin-catalog-react'; +import limiterFactory from 'p-limit'; +import { useApi } from '@backstage/core-plugin-api'; +import { useAsync } from 'react-use'; +import { CatalogListResponse } from '@backstage/catalog-client'; +import qs from 'qs'; + +const limiter = limiterFactory(10); + +type EntityTypeProps = { + kind: string; + type: string; + count: number; +}; + +const getQueryParams = ( + owners: string[], + selectedEntity: EntityTypeProps, +): string => { + const { kind, type } = selectedEntity; + const filters = { + kind, + type, + owners, + user: 'all', + }; + const queryParams = qs.stringify({ + filters, + }); + + return queryParams; +}; + +export function useTransitiveEntities( + entity: Entity, + entityFilterKind?: string[], +): { + componentsWithCounters: + | { + counter: number; + type: string; + name: string; + queryParams: string; + }[] + | undefined; + loading: boolean; + error?: Error; +} { + const catalogApi = useApi(catalogApiRef); + const requestedEntities: Entity[] = []; + const outstandingEntities = new Map>(); + const processedEntities = new Set(); + requestedEntities.push(entity); + let isLoop = true; + let currentEntity = entity; + const kinds = entityFilterKind ?? ['Component', 'API', 'System']; + + const { + loading, + error, + value: componentsWithCounters, + } = useAsync(async () => { + while (isLoop) { + const childRelations = getEntityRelations( + currentEntity, + RELATION_PARENT_OF, + { + kind: 'Group', + }, + ); + + await Promise.all( + childRelations.map(childGroup => + limiter(async () => { + const promise = catalogApi.getEntityByName({ + kind: 'Group', + namespace: 'default', + name: childGroup.name, + }); + + outstandingEntities.set(childGroup.name, promise); + try { + const processedEntity = await promise; + if (processedEntity) { + requestedEntities.push(processedEntity); + } + } finally { + outstandingEntities.delete(childGroup.name); + } + }), + ), + ); + requestedEntities.shift(); + processedEntities.add(currentEntity.metadata.name); + currentEntity = requestedEntities[0]; + if (requestedEntities.length === 0) isLoop = false; + } + + const owners = Array.from(processedEntities); + + const ownedAggregationEntitiesList: CatalogListResponse = + await catalogApi.getEntities({ + filter: [ + { + kind: kinds, + 'spec.owner': owners, + }, + ], + fields: [ + 'kind', + 'metadata.name', + 'metadata.namespace', + 'spec.type', + 'relations', + ], + }); + + const counts = ownedAggregationEntitiesList.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]); + + return { + componentsWithCounters, + loading, + error, + }; +}