Merge pull request #9589 from prasaria/parent-group-ownership-aggregation

Parent group ownership aggregation
This commit is contained in:
Fredrik Adelöw
2022-03-24 14:17:54 +01:00
committed by GitHub
6 changed files with 430 additions and 197 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-org': patch
---
add aggregated ownership type for kind group in OwnershipCard
+1
View File
@@ -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"
@@ -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 (
<Link to={url} variant="body2">
<Box
className={`${classes.card} ${classes.entityTypeBox}`}
display="flex"
flexDirection="column"
alignItems="center"
>
<Typography className={classes.bold} variant="h6">
{counter}
</Typography>
<Typography className={classes.bold} variant="h6">
{pluralize(name, counter)}
</Typography>
</Box>
</Link>
);
};
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 <Progress />;
} else if (error) {
return <ResponseErrorPanel error={error} />;
}
return (
<Grid container>
{componentsWithCounters?.map(c => (
<Grid item xs={6} md={6} lg={4} key={c.name}>
<EntityCountTile
counter={c.counter}
type={c.type}
name={c.name}
url={`${catalogLink()}/?${c.queryParams}`}
/>
</Grid>
))}
</Grid>
);
};
@@ -112,9 +112,9 @@ const getEntitiesMock = (
request?: GetEntitiesRequest,
): Promise<GetEntitiesResponse> => {
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',
);
});
});
@@ -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 (
<Link to={url} variant="body2">
<Box
className={`${classes.card} ${classes.entityTypeBox}`}
display="flex"
flexDirection="column"
alignItems="center"
>
<Typography className={classes.bold} variant="h6">
{counter}
</Typography>
<Typography className={classes.bold} variant="h6">
{pluralize(name, counter)}
</Typography>
</Box>
</Link>
);
};
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 <Progress />;
} else if (error) {
return <ResponseErrorPanel error={error} />;
}
const isGroup = entity.kind === 'Group';
const [relationsType, setRelationsType] = useState('direct');
return (
<InfoCard title="Ownership" variant={variant}>
<Grid container>
{componentsWithCounters?.map(c => (
<Grid item xs={6} md={6} lg={4} key={c.name}>
<EntityCountTile
counter={c.counter}
type={c.type}
name={c.name}
url={`${catalogLink()}/?${c.queryParams}`}
/>
</Grid>
))}
</Grid>
<List dense>
<ListItem className={classes.list}>
<ListItemText className={classes.listItemText} />
<ListItemSecondaryAction className={classes.listItemSecondaryAction}>
Direct Relations
<Tooltip
placement="top"
arrow
title={`${
relationsType === 'direct' ? 'Direct' : 'Aggregated'
} Relations`}
>
<Switch
color="primary"
checked={relationsType !== 'direct'}
onChange={() =>
relationsType === 'direct'
? setRelationsType('aggregated')
: setRelationsType('direct')
}
name="pin"
inputProps={{ 'aria-label': 'Ownership Type Switch' }}
disabled={!isGroup}
/>
</Tooltip>
Aggregated Relations
</ListItemSecondaryAction>
</ListItem>
</List>
<ComponentsGrid
entity={entity}
relationsType={relationsType}
isGroup={isGroup}
entityFilterKind={entityFilterKind}
/>
</InfoCard>
);
};
@@ -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<string[]> => {
const requestedEntities: Entity[] = [];
const outstandingEntities = new Map<string, Promise<Entity | undefined>>();
const processedEntities = new Set<string>();
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,
};
}