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,
+ };
+}