transitive ownership of entities for group kind

Signed-off-by: Prasetya Aria Wibawa <prasetya.wibawa@grabtaxi.com>
This commit is contained in:
Prasetya Aria Wibawa
2022-02-17 00:36:13 +07:00
parent 4431873583
commit 2566b48ebb
6 changed files with 641 additions and 183 deletions
+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,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 (
<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 DirectRelationsGrid = ({
entity,
entityFilterKind,
}: {
entity: Entity;
entityFilterKind?: string[];
}) => {
const catalogLink = useRouteRef(catalogIndexRouteRef);
const { componentsWithCounters, loading, error } = useDirectEntities(
entity,
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>
);
};
@@ -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 (
<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>
<DirectRelationsGrid entity={entity} entityFilterKind={entityFilterKind} />
);
};
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 (
<TransitiveRelationsGrid
entity={entity}
entityFilterKind={entityFilterKind}
/>
);
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 <Progress />;
} else if (error) {
return <ResponseErrorPanel error={error} />;
}
const isGroup = entity.kind === 'Group';
const renderedGrid =
relationsType !== 'direct' && isGroup
? transitiveRelationsGrid(entity, entityFilterKind)
: directRelationsGrid(entity, entityFilterKind);
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' : 'Transitive'
} Relations`}
>
<Switch
color="primary"
// checked={isPinned}
onChange={() =>
relationsType === 'direct'
? setRelationsType('transitive')
: setRelationsType('direct')
}
name="pin"
inputProps={{ 'aria-label': 'Pin Sidebar Switch' }}
disabled={!isGroup}
/>
</Tooltip>
Transitive Relations
</ListItemSecondaryAction>
</ListItem>
</List>
{renderedGrid}
</InfoCard>
);
};
@@ -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 (
<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>
);
};
// 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 <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>
);
};
@@ -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,
};
}
@@ -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<string, Promise<Entity | undefined>>();
const processedEntities = new Set<string>();
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<Entity> =
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,
};
}