Merge remote-tracking branch 'origin/master' into orkohunter/techdocs-publish-to-cloud-storage

This commit is contained in:
Himanshu Mishra
2020-12-08 08:12:38 +01:00
95 changed files with 3111 additions and 1644 deletions
@@ -0,0 +1,7 @@
---
'@backstage/plugin-cost-insights': minor
---
Add support for multiple types of entity cost breakdown.
This change is backwards-incompatible with plugin-cost-insights 0.3.x; the `entities` field on Entity returned in product cost queries changed from `Entity[]` to `Record<string, Entity[]`.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/cli': patch
---
Update experimental backend bundle command to only output archives to `dist/` instead of a full workspace mirror in `dist-workspace/`.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-backend': patch
---
Batch the fetching of relations
+13
View File
@@ -0,0 +1,13 @@
---
'@backstage/create-app': patch
---
Add `"files": ["dist"]` to both app and backend packages. This ensures that packaged versions of these packages do not contain unnecessary files.
To apply this change to an existing app, add the following to `packages/app/package.json` and `packages/backend/package.json`:
```json
"files": [
"dist"
]
```
+8
View File
@@ -0,0 +1,8 @@
---
'@backstage/catalog-model': minor
'@backstage/plugin-catalog-backend': minor
---
Remove the deprecated fields `ancestors` and `descendants` from the `Group` entity.
See https://github.com/backstage/backstage/issues/3049 and the PRs linked from it for details.
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/plugin-techdocs': minor
---
Removed modifyCss transformer and moved the css to injectCss transformer
Fixed issue where some internal doc links would cause a reload of the page
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/theme': patch
---
Add a little more padding in dense tables
@@ -20,4 +20,5 @@ stringData:
AZURE_TOKEN: {{ .Values.auth.azure.api.token }}
NEW_RELIC_REST_API_KEY: {{ .Values.auth.newRelicRestApiKey }}
TRAVISCI_AUTH_TOKEN: {{ .Values.auth.travisciAuthToken }}
PAGERDUTY_TOKEN: {{ .Values.auth.pagerdutyToken }}
{{- end }}
+1
View File
@@ -250,3 +250,4 @@ auth:
gitlabToken: g
newRelicRestApiKey: r
travisciAuthToken: fake-travis-ci-auth-token
pagerdutyToken: h
@@ -724,9 +724,7 @@ metadata:
spec:
type: business-unit
parent: ops
ancestors: [ops, global-synergies, acme-corp]
children: [backstage, other]
descendants: [backstage, other, team-a, team-b, team-c, team-d]
```
In addition to the [common envelope metadata](#common-to-all-kinds-the-metadata)
@@ -762,25 +760,6 @@ namespace as the user. Only `Group` entities may be referenced. Most commonly,
this field points to a group in the same namespace, so in those cases it is
sufficient to enter only the `metadata.name` field of that group.
### `spec.ancestors` [required]
**NOTE**: This field was marked for deprecation on Nov 22nd, 2020. It will be
removed entirely from the model on Dec 6th, 2020 in the repository and will not
be present in released packages following the next release after that. Please
update your code to not consume this field before the removal date.
The recursive list of parents up the hierarchy, by stepping through parents one
by one. The list must be present, but may be empty if `parent` is not present.
The first entry in the list is equal to `parent`, and then the following ones
are progressively farther up the hierarchy.
The entries of this array are
[entity references](https://backstage.io/docs/features/software-catalog/references),
with the default kind `Group` and the default namespace equal to the same
namespace as the user. Only `Group` entities may be referenced. Most commonly,
these entries point to groups in the same namespace, so in those cases it is
sufficient to enter only the `metadata.name` field of those groups.
### `spec.children` [required]
The immediate child groups of this group in the hierarchy (whose `parent` field
@@ -795,25 +774,6 @@ namespace as the user. Only `Group` entities may be referenced. Most commonly,
these entries point to groups in the same namespace, so in those cases it is
sufficient to enter only the `metadata.name` field of those groups.
### `spec.descendants` [required]
**NOTE**: This field was marked for deprecation on Nov 22nd, 2020. It will be
removed entirely from the model on Dec 6th, 2020 in the repository and will not
be present in released packages following the next release after that. Please
update your code to not consume this field before the removal date.
The immediate and recursive child groups of this group in the hierarchy
(children, and children's children, etc.). The list must be present, but may be
empty if there are no child groups. The items are not guaranteed to be ordered
in any particular way.
The entries of this array are
[entity references](https://backstage.io/docs/features/software-catalog/references),
with the default kind `Group` and the default namespace equal to the same
namespace as the user. Only `Group` entities may be referenced. Most commonly,
these entries point to groups in the same namespace, so in those cases it is
sufficient to enter only the `metadata.name` field of those groups.
## Kind: User
Describes the following entity kind:
+1 -1
View File
@@ -15,7 +15,7 @@
},
"devDependencies": {
"@spotify/prettier-config": "^9.0.0",
"docusaurus": "^2.0.0-alpha.66",
"docusaurus": "^2.0.0-alpha.378053ac5",
"js-yaml": "^3.14.0",
"prettier": "^2.2.1"
},
+568 -500
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -18,6 +18,7 @@
"@backstage/plugin-github-actions": "^0.2.3",
"@backstage/plugin-gitops-profiles": "^0.2.1",
"@backstage/plugin-graphiql": "^0.2.1",
"@backstage/plugin-org": "^0.3.0",
"@backstage/plugin-jenkins": "^0.3.2",
"@backstage/plugin-kubernetes": "^0.3.1",
"@backstage/plugin-lighthouse": "^0.2.4",
@@ -89,5 +90,8 @@
"last 1 safari version"
]
},
"files": [
"dist"
],
"license": "Apache-2.0"
}
@@ -13,7 +13,12 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ApiEntity, Entity } from '@backstage/catalog-model';
import {
ApiEntity,
Entity,
GroupEntity,
UserEntity,
} from '@backstage/catalog-model';
import { EmptyState } from '@backstage/core';
import {
ApiDefinitionCard,
@@ -48,6 +53,12 @@ import {
isPluginApplicableToEntity as isLighthouseAvailable,
LastLighthouseAuditCard,
} from '@backstage/plugin-lighthouse';
import {
OwnershipCard,
MembersListCard,
GroupProfileCard,
UserProfileCard,
} from '@backstage/plugin-org';
import { Router as SentryRouter } from '@backstage/plugin-sentry';
import { EmbeddedDocsRouter as DocsRouter } from '@backstage/plugin-techdocs';
import { Button, Grid } from '@material-ui/core';
@@ -323,6 +334,51 @@ const ApiEntityPage = ({ entity }: { entity: Entity }) => (
</EntityPageLayout>
);
const UserOverviewContent = ({ entity }: { entity: UserEntity }) => (
<Grid container spacing={3}>
<Grid item xs={12} md={6}>
<UserProfileCard entity={entity} variant="gridItem" />
</Grid>
<Grid item xs={12} md={6}>
<OwnershipCard entity={entity} variant="gridItem" />
</Grid>
</Grid>
);
const UserEntityPage = ({ entity }: { entity: Entity }) => (
<EntityPageLayout>
<EntityPageLayout.Content
path="/*"
title="Overview"
element={<UserOverviewContent entity={entity as UserEntity} />}
/>
</EntityPageLayout>
);
const GroupOverviewContent = ({ entity }: { entity: GroupEntity }) => (
<Grid container spacing={3}>
<Grid item xs={12} md={6}>
<GroupProfileCard entity={entity} variant="gridItem" />
</Grid>
<Grid item xs={12} md={6}>
<OwnershipCard entity={entity} variant="gridItem" />
</Grid>
<Grid item xs={12}>
<MembersListCard entity={entity} />
</Grid>
</Grid>
);
const GroupEntityPage = ({ entity }: { entity: Entity }) => (
<EntityPageLayout>
<EntityPageLayout.Content
path="/*"
title="Overview"
element={<GroupOverviewContent entity={entity as GroupEntity} />}
/>
</EntityPageLayout>
);
export const EntityPage = () => {
const { entity } = useEntity();
@@ -331,6 +387,10 @@ export const EntityPage = () => {
return <ComponentEntityPage entity={entity} />;
case 'api':
return <ApiEntityPage entity={entity} />;
case 'group':
return <GroupEntityPage entity={entity} />;
case 'user':
return <UserEntityPage entity={entity} />;
default:
return <DefaultEntityPage entity={entity} />;
}
+1
View File
@@ -42,3 +42,4 @@ export { plugin as UserSettings } from '@backstage/plugin-user-settings';
export { plugin as PagerDuty } from '@backstage/plugin-pagerduty';
export { plugin as Buildkite } from '@roadiehq/backstage-plugin-buildkite';
export { plugin as Search } from '@backstage/plugin-search';
export { plugin as Org } from '@backstage/plugin-org';
+4 -1
View File
@@ -50,5 +50,8 @@
"@types/express": "^4.17.6",
"@types/express-serve-static-core": "^4.17.5",
"@types/helmet": "^0.0.48"
}
},
"files": [
"dist"
]
}
@@ -6,6 +6,4 @@ metadata:
spec:
type: sub-department
parent: infrastructure
ancestors: [infrastructure, acme-corp]
children: [team-a, team-b]
descendants: [team-a, team-b]
@@ -6,6 +6,4 @@ metadata:
spec:
type: sub-department
parent: infrastructure
ancestors: [infrastructure, acme-corp]
children: [team-c, team-d]
descendants: [team-c, team-d]
@@ -6,6 +6,4 @@ metadata:
spec:
type: department
parent: acme-corp
ancestors: [acme-corp]
children: [backstage, boxoffice]
descendants: [backstage, boxoffice, team-a, team-b, team-c, team-d]
@@ -5,10 +5,7 @@ metadata:
description: The acme-corp organization
spec:
type: organization
ancestors: []
children: [infrastructure]
descendants:
[infrastructure, backstage, boxoffice, team-a, team-b, team-c, team-d]
---
apiVersion: backstage.io/v1alpha1
kind: Location
@@ -6,9 +6,7 @@ metadata:
spec:
type: team
parent: backstage
ancestors: [backstage, infrastructure, acme-corp]
children: []
descendants: []
---
apiVersion: backstage.io/v1alpha1
kind: User
@@ -6,9 +6,7 @@ metadata:
spec:
type: team
parent: backstage
ancestors: [backstage, infrastructure, acme-corp]
children: []
descendants: []
---
apiVersion: backstage.io/v1alpha1
kind: User
@@ -6,9 +6,7 @@ metadata:
spec:
type: team
parent: boxoffice
ancestors: [boxoffice, infrastructure, acme-corp]
children: []
descendants: []
---
apiVersion: backstage.io/v1alpha1
kind: User
@@ -6,9 +6,7 @@ metadata:
spec:
type: team
parent: boxoffice
ancestors: [boxoffice, infrastructure, acme-corp]
children: []
descendants: []
---
apiVersion: backstage.io/v1alpha1
kind: User
@@ -34,9 +34,7 @@ describe('GroupV1alpha1Validator', () => {
spec: {
type: 'squad',
parent: 'group-a',
ancestors: ['group-a', 'global-synergies', 'acme-corp'],
children: ['child-a', 'child-b'],
descendants: ['desc-a', 'desc-b'],
},
};
});
@@ -85,26 +83,6 @@ describe('GroupV1alpha1Validator', () => {
await expect(validator.check(entity)).rejects.toThrow(/parent/);
});
it('rejects missing ancestors', async () => {
delete (entity as any).spec.ancestors;
await expect(validator.check(entity)).rejects.toThrow(/ancestor/);
});
it('rejects empty ancestors', async () => {
(entity as any).spec.ancestors = [''];
await expect(validator.check(entity)).rejects.toThrow(/ancestor/);
});
it('rejects undefined ancestors', async () => {
(entity as any).spec.ancestors = [undefined];
await expect(validator.check(entity)).rejects.toThrow(/ancestor/);
});
it('accepts no ancestors', async () => {
(entity as any).spec.ancestors = [];
await expect(validator.check(entity)).resolves.toBe(true);
});
it('rejects missing children', async () => {
delete (entity as any).spec.children;
await expect(validator.check(entity)).rejects.toThrow(/children/);
@@ -124,24 +102,4 @@ describe('GroupV1alpha1Validator', () => {
(entity as any).spec.children = [];
await expect(validator.check(entity)).resolves.toBe(true);
});
it('rejects missing descendants', async () => {
delete (entity as any).spec.descendants;
await expect(validator.check(entity)).rejects.toThrow(/descendants/);
});
it('rejects empty descendants', async () => {
(entity as any).spec.descendants = [''];
await expect(validator.check(entity)).rejects.toThrow(/descendants/);
});
it('rejects undefined descendants', async () => {
(entity as any).spec.descendants = [undefined];
await expect(validator.check(entity)).rejects.toThrow(/descendants/);
});
it('accepts no descendants', async () => {
(entity as any).spec.descendants = [];
await expect(validator.check(entity)).resolves.toBe(true);
});
});
@@ -32,21 +32,11 @@ const schema = yup.object<Partial<GroupEntityV1alpha1>>({
// one element and there is no simple workaround -_-
// the cast is there to convince typescript that the array itself is
// required without using .required()
ancestors: yup.array(yup.string().required()).test({
name: 'isDefined',
message: 'ancestors must be defined',
test: v => Boolean(v),
}) as yup.ArraySchema<string, object>,
children: yup.array(yup.string().required()).test({
name: 'isDefined',
message: 'children must be defined',
test: v => Boolean(v),
}) as yup.ArraySchema<string, object>,
descendants: yup.array(yup.string().required()).test({
name: 'isDefined',
message: 'descendants must be defined',
test: v => Boolean(v),
}) as yup.ArraySchema<string, object>,
})
.required(),
});
@@ -57,23 +47,7 @@ export interface GroupEntityV1alpha1 extends Entity {
spec: {
type: string;
parent?: string;
/**
* @deprecated This field will disappear on Dec 6th, 2020. Please remove
* any consuming code. Producers can stop producing this field
* before that date, as long as the catalog backend uses the
* BuiltinKindsEntityProcessor which inserts the fields in the
* mean time.
*/
ancestors: string[];
children: string[];
/**
* @deprecated This field will disappear on Dec 6th, 2020. Please remove
* any consuming code. Producers can stop producing this field
* before that date, as long as the catalog backend uses the
* BuiltinKindsEntityProcessor which inserts the fields in the
* mean time.
*/
descendants: string[];
};
}
+47 -14
View File
@@ -14,26 +14,59 @@
* limitations under the License.
*/
import { Command } from 'commander';
import os from 'os';
import fs from 'fs-extra';
import { resolve as resolvePath } from 'path';
import tar, { CreateOptions } from 'tar';
import { Command } from 'commander';
import { createDistWorkspace } from '../../lib/packager';
import { paths } from '../../lib/paths';
import { parseParallel, PARALLEL_ENV_VAR } from '../../lib/parallel';
import { buildPackage, Output } from '../../lib/builder';
const PKG_PATH = 'package.json';
const TARGET_DIR = 'dist-workspace';
const BUNDLE_FILE = 'bundle.tar.gz';
const SKELETON_FILE = 'skeleton.tar.gz';
export default async (cmd: Command) => {
const targetDir = paths.resolveTarget(TARGET_DIR);
const pkgPath = paths.resolveTarget(PKG_PATH);
const pkg = await fs.readJson(pkgPath);
const targetDir = paths.resolveTarget('dist');
const pkg = await fs.readJson(paths.resolveTarget('package.json'));
await fs.remove(targetDir);
await fs.mkdir(targetDir);
await createDistWorkspace([pkg.name], {
targetDir: targetDir,
buildDependencies: Boolean(cmd.build),
parallel: parseParallel(process.env[PARALLEL_ENV_VAR]),
skeleton: 'skeleton.tar',
});
// We build the target package without generating type declarations.
await buildPackage({ outputs: new Set([Output.cjs]) });
const tmpDir = await fs.mkdtemp(resolvePath(os.tmpdir(), 'backstage-bundle'));
try {
await createDistWorkspace([pkg.name], {
targetDir: tmpDir,
buildDependencies: Boolean(cmd.build),
buildExcludes: [pkg.name],
parallel: parseParallel(process.env[PARALLEL_ENV_VAR]),
skeleton: SKELETON_FILE,
});
// We built the target backend package using the regular build process, but the result of
// that has now been packed into the dist workspace, so clean up the dist dir.
await fs.remove(targetDir);
await fs.mkdir(targetDir);
// Move out skeleton.tar.gz before we create the main bundle, no point having that included up twice.
await fs.move(
resolvePath(tmpDir, SKELETON_FILE),
resolvePath(targetDir, SKELETON_FILE),
);
// Create main bundle.tar.gz, with some tweaks to make it more likely hit Docker build cache.
await tar.create(
{
file: resolvePath(targetDir, BUNDLE_FILE),
cwd: tmpDir,
portable: true,
noMtime: true,
gzip: true,
} as CreateOptions & { noMtime: boolean },
[''],
);
} finally {
await fs.remove(tmpDir);
}
};
+12 -2
View File
@@ -67,6 +67,11 @@ type Options = {
*/
buildDependencies?: boolean;
/**
* When `buildDependencies` is set, this list of packages will not be built even if they are dependencies.
*/
buildExcludes?: string[];
/**
* Enable (true/false) or control amount of (number) parallelism in some build steps.
*/
@@ -76,7 +81,7 @@ type Options = {
* If set, creates a skeleton tarball that contains all package.json files
* with the same structure as the workspace dir.
*/
skeleton?: 'skeleton.tar';
skeleton?: 'skeleton.tar' | 'skeleton.tar.gz';
};
/**
@@ -98,7 +103,11 @@ export async function createDistWorkspace(
const targets = await findTargetPackages(packageNames);
if (options.buildDependencies) {
const scopeArgs = targets.flatMap(target => ['--scope', target.name]);
const exclude = options.buildExcludes ?? [];
const scopeArgs = targets
.filter(target => !exclude.includes(target.name))
.flatMap(target => ['--scope', target.name]);
const lernaArgs =
options.parallel && Number.isInteger(options.parallel)
? ['--concurrency', options.parallel.toString()]
@@ -131,6 +140,7 @@ export async function createDistWorkspace(
cwd: targetDir,
portable: true,
noMtime: true,
gzip: options.skeleton.endsWith('.gz'),
} as CreateOptions & { noMtime: boolean },
skeletonFiles,
);
@@ -0,0 +1,171 @@
/*
* Copyright 2020 Spotify AB
*
* 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 React, { useState } from 'react';
import {
Drawer,
Button,
Typography,
makeStyles,
IconButton,
createStyles,
Theme,
} from '@material-ui/core';
import Close from '@material-ui/icons/Close';
export default {
title: 'Layout/Drawer',
component: Drawer,
};
const useDrawerStyles = makeStyles((theme: Theme) =>
createStyles({
paper: {
width: '50%',
justifyContent: 'space-between',
padding: theme.spacing(2.5),
},
}),
);
const useDrawerContentStyles = makeStyles((theme: Theme) =>
createStyles({
header: {
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
},
icon: {
fontSize: 20,
},
content: {
height: '80%',
backgroundColor: '#EEEEEE',
},
secondaryAction: {
marginLeft: theme.spacing(2.5),
},
}),
);
/* Example content wrapped inside the Drawer component */
const DrawerContent = ({
toggleDrawer,
}: {
toggleDrawer: (isOpen: boolean) => void;
}) => {
const classes = useDrawerContentStyles();
return (
<>
<div className={classes.header}>
<Typography variant="h5">Side Panel Title</Typography>
<IconButton
key="dismiss"
title="Close the drawer"
onClick={() => toggleDrawer(false)}
color="inherit"
>
<Close className={classes.icon} />
</IconButton>
</div>
<div className={classes.content} />
<div>
<Button
variant="contained"
color="primary"
onClick={() => toggleDrawer(false)}
>
Primary Action
</Button>
<Button
className={classes.secondaryAction}
variant="outlined"
color="primary"
onClick={() => toggleDrawer(false)}
>
Secondary Action
</Button>
</div>
</>
);
};
/* Default drawer can toggle open or closed.
* It can be cancelled by clicking the overlay
* or pressing the esc key.
*/
export const DefaultDrawer = () => {
const [isOpen, toggleDrawer] = useState(false);
const classes = useDrawerStyles();
return (
<>
<Button
variant="contained"
color="primary"
onClick={() => toggleDrawer(true)}
>
Open Default Drawer
</Button>
<Drawer
classes={{
paper: classes.paper,
}}
anchor="right"
open={isOpen}
onClose={() => toggleDrawer(false)}
>
<DrawerContent toggleDrawer={toggleDrawer} />
</Drawer>
</>
);
};
/* Persistent drawer works like the default one -
* except that the content sits on the same level
* as the main content and you can't cancel it by
* clicking the overlay or pressing the esc key.
*
* Set the Drawer variant props: 'persistent'
*/
export const PersistentDrawer = () => {
const [isOpen, toggleDrawer] = useState(false);
const classes = useDrawerStyles();
return (
<>
<Button
variant="contained"
color="primary"
onClick={() => toggleDrawer(true)}
>
Open Persistent Drawer
</Button>
<Drawer
classes={{
paper: classes.paper,
}}
variant="persistent"
anchor="right"
open={isOpen}
onClose={() => toggleDrawer(false)}
>
<DrawerContent toggleDrawer={toggleDrawer} />
</Drawer>
</>
);
};
@@ -61,5 +61,8 @@
"last 1 firefox version",
"last 1 safari version"
]
}
},
"files": [
"dist"
]
}
@@ -47,5 +47,8 @@
"@types/express": "^4.17.6",
"@types/express-serve-static-core": "^4.17.5",
"@types/helmet": "^0.0.47"
}
},
"files": [
"dist"
]
}
@@ -40,6 +40,7 @@ export class DirectoryPreparer implements PreparerBase {
switch (type) {
case 'github':
case 'gitlab':
case 'url':
case 'azure/api': {
const parsedGitLocation = parseGitUrl(target);
const repoLocation = await checkoutGitRepository(target, this.logger);
+1 -1
View File
@@ -135,7 +135,7 @@ export function createThemeOverrides(theme: BackstageTheme): Overrides {
borderBottom: 0,
},
sizeSmall: {
padding: theme.spacing(1, 2, 1, 2.5),
padding: theme.spacing(1.5, 2, 1.5, 2.5),
},
head: {
wordBreak: 'break-word',
@@ -271,7 +271,7 @@ export class CommonDatabase implements Database {
.select('entities.*')
.orderBy('full_name', 'asc');
return Promise.all(rows.map(row => this.toEntityResponse(tx, row)));
return this.toEntityResponses(tx, rows);
}
async entityByName(
@@ -290,7 +290,7 @@ export class CommonDatabase implements Database {
return undefined;
}
return this.toEntityResponse(tx, rows[0]);
return this.toEntityResponses(tx, rows).then(r => r[0]);
}
async entityByUid(
@@ -307,7 +307,7 @@ export class CommonDatabase implements Database {
return undefined;
}
return this.toEntityResponse(tx, rows[0]);
return this.toEntityResponses(tx, rows).then(r => r[0]);
}
async removeEntityByUid(txOpaque: Transaction, uid: string): Promise<void> {
@@ -494,31 +494,60 @@ export class CommonDatabase implements Database {
};
}
private async toEntityResponse(
private async toEntityResponses(
tx: Knex.Transaction<any, any>,
row: DbEntitiesRow,
): Promise<DbEntityResponse> {
const entity = JSON.parse(row.data) as Entity;
entity.metadata.uid = row.id;
entity.metadata.etag = row.etag;
entity.metadata.generation = Number(row.generation); // cast due to sqlite
rows: DbEntitiesRow[],
): Promise<DbEntityResponse[]> {
// TODO(Rugvip): This is here because it's simple for now, but we likely
// need to refactor this to be more efficient or introduce pagination.
const relations = await tx<DbEntitiesRelationsRow>('entities_relations')
.where({ source_full_name: row.full_name })
.orderBy(['type', 'target_full_name'])
.select();
const relations = await this.getRelationsPerFullName(
tx,
rows.map(r => r.full_name),
);
entity.relations = deduplicateRelations(relations).map(r => ({
target: parseEntityName(r.target_full_name),
type: r.type,
}));
const result = new Array<DbEntityResponse>();
for (const row of rows) {
const entity = JSON.parse(row.data) as Entity;
entity.metadata.uid = row.id;
entity.metadata.etag = row.etag;
entity.metadata.generation = Number(row.generation); // cast due to sqlite
return {
locationId: row.location_id || undefined,
entity,
};
entity.relations = (relations[row.full_name] ?? []).map(r => ({
target: parseEntityName(r.target_full_name),
type: r.type,
}));
result.push({
locationId: row.location_id || undefined,
entity,
});
}
return result;
}
// Returns a mapping from e.g. component:default/foo to the relations whose
// source_full_name matches that.
private async getRelationsPerFullName(
tx: Knex.Transaction<any, any>,
sourceFullNames: string[],
): Promise<Record<string, DbEntitiesRelationsRow[]>> {
const batches = lodash.chunk(lodash.uniq(sourceFullNames), 500);
const relations = new Array<DbEntitiesRelationsRow>();
for (const batch of batches) {
relations.push(
...(await tx<DbEntitiesRelationsRow>('entities_relations')
.whereIn('source_full_name', batch)
.orderBy(['type', 'target_full_name'])
.select()),
);
}
return lodash.groupBy(
deduplicateRelations(relations),
r => r.source_full_name,
);
}
}
@@ -85,7 +85,7 @@ export class HigherOrderOperations implements HigherOrderOperation {
// Write
if (!previousLocation && !dryRun) {
// TODO: We do not include location operations in the dryRun. We might perform
// this operation as a seperate dry run.
// this operation as a separate dry run.
await this.locationsCatalog.addLocation(location);
}
if (readerOutput.entities.length === 0) {
@@ -23,34 +23,6 @@ import {
import { BuiltinKindsEntityProcessor } from './BuiltinKindsEntityProcessor';
describe('BuiltinKindsEntityProcessor', () => {
it('fills in fields for #3049', async () => {
const p = new BuiltinKindsEntityProcessor();
const result = await p.preProcessEntity({
apiVersion: 'backstage.io/v1alpha1',
kind: 'Group',
metadata: {
name: 'n',
},
spec: {
type: 't',
children: [],
} as any,
});
expect(result).toEqual({
apiVersion: 'backstage.io/v1alpha1',
kind: 'Group',
metadata: {
name: 'n',
},
spec: {
type: 't',
children: [],
ancestors: [],
descendants: [],
},
});
});
describe('postProcessEntity', () => {
const processor = new BuiltinKindsEntityProcessor();
const location = { type: 'a', target: 'b' };
@@ -215,9 +187,7 @@ describe('BuiltinKindsEntityProcessor', () => {
spec: {
type: 't',
parent: 'p',
ancestors: [],
children: ['c'],
descendants: [],
},
};
@@ -53,25 +53,6 @@ export class BuiltinKindsEntityProcessor implements CatalogProcessor {
userEntityV1alpha1Validator,
];
async preProcessEntity(entity: Entity): Promise<Entity> {
// NOTE(freben): Part of Group field deprecation on Nov 22nd, 2020. Fields
// scheduled for removal Dec 6th, 2020. This code can be deleted after that
// point. See https://github.com/backstage/backstage/issues/3049
if (
entity.apiVersion === 'backstage.io/v1alpha1' &&
entity.kind === 'Group' &&
entity.spec
) {
if (!entity.spec.ancestors) {
entity.spec.ancestors = [];
}
if (!entity.spec.descendants) {
entity.spec.descendants = [];
}
}
return entity;
}
async validateEntityKind(entity: Entity): Promise<boolean> {
for (const validator of this.validators) {
const result = await validator.check(entity);
@@ -47,7 +47,7 @@ function group(data: RecursivePartial<GroupEntity>): GroupEntity {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Group',
metadata: { name: 'name' },
spec: { type: 'type', ancestors: [], children: [], descendants: [] },
spec: { type: 'type', children: [] },
} as GroupEntity,
data,
);
@@ -173,9 +173,7 @@ describe('readLdapGroups', () => {
},
spec: {
type: 'type-value',
ancestors: [],
children: [],
descendants: [],
},
}),
]);
@@ -150,9 +150,7 @@ export async function readLdapGroups(
},
spec: {
type: 'unknown',
ancestors: [],
children: [],
descendants: [],
},
};
@@ -49,9 +49,7 @@ function group(data: RecursivePartial<GroupEntity>): GroupEntity {
name: 'name',
},
spec: {
ancestors: [],
children: [],
descendants: [],
type: 'team',
},
} as GroupEntity,
@@ -306,33 +304,20 @@ describe('read microsoft graph', () => {
resolveRelations(rootGroup, groups, users, groupMember, groupMemberOf);
expect(rootGroup.spec.parent).toBeUndefined();
expect(rootGroup.spec.ancestors).toEqual(expect.arrayContaining([]));
expect(rootGroup.spec.children).toEqual(
expect.arrayContaining(['a', 'b']),
);
expect(rootGroup.spec.descendants).toEqual(
expect.arrayContaining(['a', 'b', 'c']),
);
expect(groupA.spec.parent).toEqual('root');
expect(groupA.spec.ancestors).toEqual(expect.arrayContaining(['root']));
expect(groupA.spec.children).toEqual(expect.arrayContaining([]));
expect(groupA.spec.descendants).toEqual(expect.arrayContaining([]));
expect(groupB.spec.parent).toEqual('root');
expect(groupB.spec.ancestors).toEqual(expect.arrayContaining(['root']));
expect(groupB.spec.children).toEqual(expect.arrayContaining(['c']));
expect(groupB.spec.descendants).toEqual(expect.arrayContaining(['c']));
expect(groupC.spec.parent).toEqual('b');
expect(groupC.spec.ancestors).toEqual(
expect.arrayContaining(['root', 'b']),
);
expect(groupC.spec.children).toEqual(expect.arrayContaining([]));
expect(groupC.spec.descendants).toEqual(expect.arrayContaining([]));
expect(user1.spec.memberOf).toEqual(expect.arrayContaining(['a']));
expect(user2.spec.memberOf).toEqual(expect.arrayContaining(['b', 'c']));
});
});
@@ -14,6 +14,7 @@
* limitations under the License.
*/
import { GroupEntity, UserEntity } from '@backstage/catalog-model';
import limiterFactory from 'p-limit';
import { buildMemberOf, buildOrgHierarchy } from '../util/org';
import { MicrosoftGraphClient } from './client';
import {
@@ -21,7 +22,6 @@ import {
MICROSOFT_GRAPH_TENANT_ID_ANNOTATION,
MICROSOFT_GRAPH_USER_ID_ANNOTATION,
} from './constants';
import limiterFactory from 'p-limit';
export function normalizeEntityName(name: string): string {
return name
@@ -98,7 +98,7 @@ export async function readMicrosoftGraphOrganization(
): Promise<{
rootGroup: GroupEntity; // With all relations empty
}> {
// For now we expect a single root orgranization
// For now we expect a single root organization
const organization = await client.getOrganization(tenantId);
const name = normalizeEntityName(organization.displayName!);
const rootGroup: GroupEntity = {
@@ -113,9 +113,7 @@ export async function readMicrosoftGraphOrganization(
},
spec: {
type: 'root',
ancestors: [],
children: [],
descendants: [],
},
};
@@ -165,9 +163,7 @@ export async function readMicrosoftGraphGroups(
spec: {
type: 'team',
// TODO: We could include a group email and picture
ancestors: [],
children: [],
descendants: [],
},
};
@@ -278,7 +274,7 @@ export function resolveRelations(
});
});
// Make sure that all groups have proper ancestors and descendants
// Make sure that all groups have proper parents and children
buildOrgHierarchy(groups);
// Set relations for all users
@@ -100,9 +100,7 @@ describe('github', () => {
spec: {
type: 'team',
parent: 'parent',
ancestors: [],
children: [],
descendants: [],
},
}),
],
@@ -162,9 +162,7 @@ export async function getOrganizationTeams(
},
spec: {
type: 'team',
ancestors: [],
children: [],
descendants: [],
},
};
@@ -26,7 +26,7 @@ function g(
apiVersion: 'backstage.io/v1alpha1',
kind: 'Group',
metadata: { name },
spec: { type: 'team', parent, children, ancestors: [], descendants: [] },
spec: { type: 'team', parent, children },
};
}
@@ -43,28 +43,16 @@ describe('buildOrgHierarchy', () => {
expect(d.spec.children).toEqual([]);
});
it('fills out descendants', () => {
const a = g('a', undefined, []);
const b = g('b', 'a', []);
const c = g('c', 'b', []);
const d = g('d', 'a', []);
it('sets parent of groups children', () => {
const a = g('a', undefined, ['b', 'd']);
const b = g('b', undefined, ['c']);
const c = g('c', undefined, []);
const d = g('d', undefined, []);
buildOrgHierarchy([a, b, c, d]);
expect(a.spec.descendants).toEqual(expect.arrayContaining(['b', 'c', 'd']));
expect(b.spec.descendants).toEqual(expect.arrayContaining(['c']));
expect(c.spec.descendants).toEqual([]);
expect(d.spec.descendants).toEqual([]);
});
it('fills out ancestors', () => {
const a = g('a', undefined, []);
const b = g('b', 'a', []);
const c = g('c', 'b', []);
const d = g('d', 'a', []);
buildOrgHierarchy([a, b, c, d]);
expect(a.spec.ancestors).toEqual([]);
expect(b.spec.ancestors).toEqual(expect.arrayContaining(['a']));
expect(c.spec.ancestors).toEqual(expect.arrayContaining(['a', 'b']));
expect(d.spec.ancestors).toEqual(expect.arrayContaining(['a']));
expect(a.spec.parent).toBeUndefined();
expect(b.spec.parent).toBe('a');
expect(c.spec.parent).toBe('b');
expect(d.spec.parent).toBe('a');
});
});
@@ -35,62 +35,17 @@ export function buildOrgHierarchy(groups: GroupEntity[]) {
}
//
// Make sure that g.descendants is complete
// Make sure that g.children.parent is g
//
function visitDescendants(current: GroupEntity): string[] {
if (current.spec.descendants.length) {
return current.spec.descendants;
}
const accumulator = new Set<string>();
for (const childName of current.spec.children) {
accumulator.add(childName);
for (const group of groups) {
const selfName = group.metadata.name;
for (const childName of group.spec.children) {
const child = groupsByName.get(childName);
if (child) {
for (const d of visitDescendants(child)) {
accumulator.add(d);
}
if (child && !child.spec.parent) {
child.spec.parent = selfName;
}
}
const descendants = Array.from(accumulator);
current.spec.descendants = descendants;
return descendants;
}
for (const group of groups) {
visitDescendants(group);
}
//
// Make sure that g.ancestors is complete
//
function visitAncestors(current: GroupEntity): string[] {
if (current.spec.ancestors.length) {
return current.spec.ancestors;
}
let ancestors: string[];
const parentName = current.spec.parent;
if (!parentName) {
ancestors = [];
} else {
const parent = groupsByName.get(parentName);
if (parent) {
ancestors = [parentName, ...visitAncestors(parent)];
} else {
ancestors = [parentName];
}
}
current.spec.ancestors = ancestors;
return ancestors;
}
for (const group of groups) {
visitAncestors(group);
}
}
@@ -100,15 +55,24 @@ export function buildMemberOf(groups: GroupEntity[], users: UserEntity[]) {
const groupsByName = new Map(groups.map(g => [g.metadata.name, g]));
users.forEach(user => {
const transitiveMemberOf = new Set([...user.spec.memberOf]);
const transitiveMemberOf = new Set<string>();
user.spec.memberOf.forEach(groupName => {
const group = groupsByName.get(groupName);
if (group) {
group.spec.ancestors.forEach(g => transitiveMemberOf.add(g));
const todo = [...user.spec.memberOf];
for (;;) {
const current = todo.pop();
if (!current) {
break;
}
});
if (!transitiveMemberOf.has(current)) {
transitiveMemberOf.add(current);
const group = groupsByName.get(current);
if (group?.spec.parent) {
todo.push(group.spec.parent);
}
}
}
user.spec.memberOf = [...transitiveMemberOf];
});
}
@@ -39,12 +39,12 @@ const EntityPageTitle = ({
</Box>
);
function headerProps(
const headerProps = (
kind: string,
namespace: string | undefined,
name: string,
entity: Entity | undefined,
): { headerTitle: string; headerType: string } {
): { headerTitle: string; headerType: string } => {
return {
headerTitle: `${name}${
namespace && namespace !== ENTITY_DEFAULT_NAMESPACE
@@ -60,7 +60,7 @@ function headerProps(
return t;
})(),
};
}
};
export const EntityPageLayout = ({ children }: PropsWithChildren<{}>) => {
const { kind, namespace, name } = useEntityCompoundName();
@@ -88,7 +88,8 @@ export const EntityPageLayout = ({ children }: PropsWithChildren<{}>) => {
pageTitleOverride={headerTitle}
type={headerType}
>
{entity && (
{/* TODO: fix after catalog page customization is added */}
{entity && kind !== 'user' && (
<>
<HeaderLabel
label="Owner"
+2
View File
@@ -36,6 +36,7 @@
"dayjs": "^1.9.4",
"history": "^5.0.0",
"moment": "^2.27.0",
"pluralize": "^8.0.0",
"qs": "^6.9.4",
"react": "^16.13.1",
"react-dom": "^16.13.1",
@@ -54,6 +55,7 @@
"@testing-library/user-event": "^12.0.7",
"@types/jest": "^26.0.7",
"@types/node": "^12.0.0",
"@types/pluralize": "^0.0.29",
"@types/recharts": "^1.8.14",
"@types/regression": "^2.0.0",
"@types/yup": "^0.29.8",
@@ -139,6 +139,7 @@ export type CostInsightsApi = {
* @param options Options to use when fetching insights for a particular cloud product and interval timeframe.
*/
getProductInsights(options: ProductInsightsOptions): Promise<Entity>;
/**
* Get current cost alerts for a given group. These show up as Action Items for the group on the
* Cost Insights page. Alerts may include cost-saving recommendations, such as infrastructure
@@ -40,7 +40,7 @@ const MockComputeEngine: Product = {
const MockComputeEngineInsights: Entity = {
id: 'compute-engine',
entities: [],
entities: {},
aggregation: [0, 0],
change: {
ratio: 0,
@@ -55,7 +55,7 @@ const MockCloudDataflow: Product = {
const MockCloudDataflowInsights: Entity = {
id: MockCloudDataflow.kind,
entities: [],
entities: {},
aggregation: [1_000, 2_000],
change: {
ratio: 1,
@@ -70,7 +70,7 @@ const MockCloudStorage: Product = {
const MockCloudStorageInsights: Entity = {
id: MockCloudStorage.kind,
entities: [],
entities: {},
aggregation: [2_000, 4_000],
change: {
ratio: 1,
@@ -85,7 +85,7 @@ const MockBigQuery: Product = {
const MockBigQueryInsights: Entity = {
id: MockBigQuery.kind,
entities: [],
entities: {},
aggregation: [8_000, 16_000],
change: {
ratio: 1,
@@ -100,7 +100,7 @@ const MockBigTable: Product = {
const MockBigTableInsights: Entity = {
id: MockBigTable.kind,
entities: [],
entities: {},
aggregation: [16_000, 32_000],
change: {
ratio: 1,
@@ -115,7 +115,7 @@ const MockCloudPubSub: Product = {
const MockCloudPubSubInsights: Entity = {
id: MockCloudPubSub.kind,
entities: [],
entities: {},
aggregation: [32_000, 64_000],
change: {
ratio: 1,
@@ -0,0 +1,113 @@
/*
* Copyright 2020 Spotify AB
*
* 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 React from 'react';
import { wrapInTestApp } from '@backstage/test-utils';
import { ProductEntityDialog } from './ProductEntityDialog';
import { render } from '@testing-library/react';
import { Entity } from '../../types';
const atomicEntity: Entity = {
id: null,
aggregation: [0, 0],
change: { ratio: 0, amount: 0 },
entities: {},
};
const singleBreakdownEntity = {
...atomicEntity,
entities: {
SKU: [
{
id: 'sku-1',
aggregation: [0, 0],
change: { ratio: 0, amount: 0 },
entities: {},
},
{
id: 'sku-2',
aggregation: [0, 0],
change: { ratio: 0, amount: 0 },
entities: {},
},
] as Entity[],
},
};
const multiBreakdownEntity = {
...singleBreakdownEntity,
entities: {
...singleBreakdownEntity.entities,
deployment: [
{
id: 'd-1',
aggregation: [0, 0],
change: { ratio: 0, amount: 0 },
entities: {},
},
{
id: 'd-2',
aggregation: [0, 0],
change: { ratio: 0, amount: 0 },
entities: {},
},
] as Entity[],
},
};
describe('<ProductEntityDialog/>', () => {
it('Should error if no sub-entities exist', () => {
expect(() =>
render(
wrapInTestApp(
<ProductEntityDialog
open
entity={atomicEntity}
onClose={jest.fn()}
/>,
),
),
).toThrow();
});
it('Should show a tab for a single sub-entity type', () => {
const { getByText } = render(
wrapInTestApp(
<ProductEntityDialog
open
entity={singleBreakdownEntity}
onClose={jest.fn()}
/>,
),
);
expect(getByText('Breakdown by SKU')).toBeInTheDocument();
});
it('Should show tabs when multiple sub-entity types exist', () => {
const { getByText } = render(
wrapInTestApp(
<ProductEntityDialog
open
entity={multiBreakdownEntity}
onClose={jest.fn()}
/>,
),
);
expect(getByText('Breakdown by SKU')).toBeInTheDocument();
expect(getByText('Breakdown by deployment')).toBeInTheDocument();
expect(getByText('sku-1')).toBeInTheDocument();
});
});
@@ -14,179 +14,55 @@
* limitations under the License.
*/
import React from 'react';
import classnames from 'classnames';
import { Table, TableColumn } from '@backstage/core';
import { Dialog, IconButton, Typography } from '@material-ui/core';
import React, { useState } from 'react';
import { HeaderTabs } from '@backstage/core';
import { Dialog, IconButton } from '@material-ui/core';
import { default as CloseButton } from '@material-ui/icons/Close';
import { CostGrowthIndicator } from '../CostGrowth';
import { costFormatter, formatPercent } from '../../utils/formatters';
import { useEntityDialogStyles as useStyles } from '../../utils/styles';
import { BarChartOptions, Entity } from '../../types';
function createRenderer(col: keyof RowData, classes: Record<string, string>) {
return function render(rowData: {}): JSX.Element {
const row = rowData as RowData;
const rowStyles = classnames(classes.row, {
[classes.rowTotal]: row.id === 'total',
[classes.colFirst]: col === 'label',
[classes.colLast]: col === 'ratio',
});
switch (col) {
case 'previous':
case 'current':
return (
<Typography className={rowStyles}>
{costFormatter.format(row[col])}
</Typography>
);
case 'ratio':
return (
<CostGrowthIndicator
className={rowStyles}
ratio={row.ratio}
formatter={amount => formatPercent(Math.abs(amount))}
/>
);
default:
return <Typography className={rowStyles}>{row.label}</Typography>;
}
};
}
// material-table does not support fixed rows. Override the sorting algorithm
// to force Total row to bottom by default or when a user sort toggles a column.
function createSorter(field?: keyof Omit<RowData, 'id'>) {
return function rowSort(data1: {}, data2: {}): number {
const a = data1 as RowData;
const b = data2 as RowData;
if (a.id === 'total') return 1;
if (b.id === 'total') return 1;
if (field === 'label') return a.label.localeCompare(b.label);
return field
? a[field] - b[field]
: b.previous + b.current - (a.previous - a.current);
};
}
const defaultEntity: Entity = {
id: null,
aggregation: [0, 0],
change: { ratio: 0, amount: 0 },
entities: [],
};
type RowData = {
id: string;
label: string;
previous: number;
current: number;
ratio: number;
};
type ProductEntityDialogOptions = Partial<
Pick<BarChartOptions, 'previousName' | 'currentName'>
>;
import { Entity } from '../../types';
import {
ProductEntityTable,
ProductEntityTableOptions,
} from './ProductEntityTable';
import { findAlways } from '../../utils/assert';
type ProductEntityDialogProps = {
open: boolean;
entity?: Entity;
entitiesLabel: string;
options?: ProductEntityDialogOptions;
entity: Entity;
options?: ProductEntityTableOptions;
onClose: () => void;
};
export const ProductEntityDialog = ({
open,
entity = defaultEntity,
entitiesLabel,
entity,
options = {},
onClose,
}: ProductEntityDialogProps) => {
const classes = useStyles();
const data = Object.assign(
{
previousName: 'Previous',
currentName: 'Current',
},
options,
const labels = Object.keys(entity.entities);
const [selectedLabel, setSelectedLabel] = useState(
findAlways(labels, _ => true),
);
const firstColClasses = classnames(classes.column, classes.colFirst);
const lastColClasses = classnames(classes.column, classes.colLast);
const columns: TableColumn[] = [
{
field: 'label',
title: (
<Typography className={firstColClasses}>{entitiesLabel}</Typography>
),
render: createRenderer('label', classes),
customSort: createSorter('label'),
width: '33.33%',
},
{
field: 'previous',
title: (
<Typography className={classes.column}>{data.previousName}</Typography>
),
align: 'right',
render: createRenderer('previous', classes),
customSort: createSorter('previous'),
},
{
field: 'current',
title: (
<Typography className={classes.column}>{data.currentName}</Typography>
),
align: 'right',
render: createRenderer('current', classes),
customSort: createSorter('current'),
},
{
field: 'ratio',
title: <Typography className={lastColClasses}>M/M</Typography>,
align: 'right',
render: createRenderer('ratio', classes),
customSort: createSorter('ratio'),
},
];
const rowData: RowData[] = entity.entities
.map(e => ({
id: e.id || 'Unknown',
label: e.id || 'Unknown',
previous: e.aggregation[0],
current: e.aggregation[1],
ratio: e.change.ratio,
}))
.concat({
id: 'total',
label: 'Total',
previous: entity.aggregation[0],
current: entity.aggregation[1],
ratio: entity.change.ratio,
})
.sort(createSorter());
const tabs = labels.map((label, index) => ({
id: index.toString(),
label: `Breakdown by ${label}`,
}));
return (
<Dialog open={open} onClose={onClose} scroll="body" fullWidth maxWidth="lg">
<IconButton className={classes.closeButton} onClick={onClose}>
<CloseButton />
</IconButton>
<Table
columns={columns}
data={rowData}
title={entity.id || 'Unlabeled'}
subtitle="Resource breakdown"
options={{
paging: false,
search: false,
hideFilterIcons: true,
}}
<HeaderTabs
tabs={tabs}
onChange={index => setSelectedLabel(labels[index])}
/>
<ProductEntityTable
entityLabel={selectedLabel}
entity={entity}
options={options}
/>
</Dialog>
);
@@ -0,0 +1,174 @@
/*
* Copyright 2020 Spotify AB
*
* 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 React from 'react';
import classnames from 'classnames';
import { Table, TableColumn } from '@backstage/core';
import { Typography } from '@material-ui/core';
import { costFormatter, formatPercent } from '../../utils/formatters';
import { useEntityDialogStyles as useStyles } from '../../utils/styles';
import { CostGrowthIndicator } from '../CostGrowth';
import { BarChartOptions, Entity } from '../../types';
export type ProductEntityTableOptions = Partial<
Pick<BarChartOptions, 'previousName' | 'currentName'>
>;
type RowData = {
id: string;
label: string;
previous: number;
current: number;
ratio: number;
};
function createRenderer(col: keyof RowData, classes: Record<string, string>) {
return function render(rowData: {}): JSX.Element {
const row = rowData as RowData;
const rowStyles = classnames(classes.row, {
[classes.rowTotal]: row.id === 'total',
[classes.colFirst]: col === 'label',
[classes.colLast]: col === 'ratio',
});
switch (col) {
case 'previous':
case 'current':
return (
<Typography className={rowStyles}>
{costFormatter.format(row[col])}
</Typography>
);
case 'ratio':
return (
<CostGrowthIndicator
className={rowStyles}
ratio={row.ratio}
formatter={amount => formatPercent(Math.abs(amount))}
/>
);
default:
return <Typography className={rowStyles}>{row.label}</Typography>;
}
};
}
// material-table does not support fixed rows. Override the sorting algorithm
// to force Total row to bottom by default or when a user sort toggles a column.
function createSorter(field?: keyof Omit<RowData, 'id'>) {
return function rowSort(data1: {}, data2: {}): number {
const a = data1 as RowData;
const b = data2 as RowData;
if (a.id === 'total') return 1;
if (b.id === 'total') return 1;
if (field === 'label') return a.label.localeCompare(b.label);
return field
? a[field] - b[field]
: b.previous + b.current - (a.previous - a.current);
};
}
type ProductEntityTableProps = {
entityLabel: string;
entity: Entity;
options: ProductEntityTableOptions;
};
export const ProductEntityTable = ({
entityLabel,
entity,
options,
}: ProductEntityTableProps) => {
const classes = useStyles();
const entities = entity.entities[entityLabel];
const data = Object.assign(
{
previousName: 'Previous',
currentName: 'Current',
},
options,
);
const firstColClasses = classnames(classes.column, classes.colFirst);
const lastColClasses = classnames(classes.column, classes.colLast);
const columns: TableColumn[] = [
{
field: 'label',
title: <Typography className={firstColClasses}>{entityLabel}</Typography>,
render: createRenderer('label', classes),
customSort: createSorter('label'),
width: '33.33%',
},
{
field: 'previous',
title: (
<Typography className={classes.column}>{data.previousName}</Typography>
),
align: 'right',
render: createRenderer('previous', classes),
customSort: createSorter('previous'),
},
{
field: 'current',
title: (
<Typography className={classes.column}>{data.currentName}</Typography>
),
align: 'right',
render: createRenderer('current', classes),
customSort: createSorter('current'),
},
{
field: 'ratio',
title: <Typography className={lastColClasses}>Change</Typography>,
align: 'right',
render: createRenderer('ratio', classes),
customSort: createSorter('ratio'),
},
];
const rowData: RowData[] = entities
.map(e => ({
id: e.id || 'Unknown',
label: e.id || 'Unknown',
previous: e.aggregation[0],
current: e.aggregation[1],
ratio: e.change.ratio,
}))
.concat({
id: 'total',
label: 'Total',
previous: entity.aggregation[0],
current: entity.aggregation[1],
ratio: entity.change.ratio,
})
.sort(createSorter());
return (
<Table
columns={columns}
data={rowData}
title={entity.id || 'Unlabeled'}
options={{
paging: false,
search: false,
hideFilterIcons: true,
}}
/>
);
};
@@ -42,7 +42,7 @@ const costInsightsApi = (entity: Entity): Partial<CostInsightsApi> => ({
const mockProductCost = createMockEntity(() => ({
id: 'test-id',
entities: [],
entities: {},
aggregation: [3000, 4000],
change: {
ratio: 0.23,
@@ -91,21 +91,21 @@ describe('<ProductInsightsCard/>', () => {
it('Should render the right subheader for products with cost data', async () => {
const entity = {
...mockProductCost,
entities: [...Array(1000)].map(createMockEntity),
entities: { entity: [...Array(1000)].map(createMockEntity) },
};
const rendered = await renderProductInsightsCardInTestApp(
entity,
MockComputeEngine,
);
const subheader = 'entities, sorted by cost';
const subheaderRgx = new RegExp(`${entity.entities.length} ${subheader}`);
expect(rendered.getByText(subheaderRgx)).toBeInTheDocument();
expect(
rendered.getByText(/1000 entities, sorted by cost/),
).toBeInTheDocument();
});
it('Should render the right subheader if there is no cost data or change data', async () => {
const entity: Entity = {
id: 'test-id',
entities: [],
entities: {},
aggregation: [0, 0],
change: { ratio: 0, amount: 0 },
};
@@ -135,7 +135,7 @@ describe('<ProductInsightsCard/>', () => {
it(`Should display the correct relative time for ${duration}`, async () => {
const entity = {
...mockProductCost,
entities: [...Array(3)].map(createMockEntity),
entities: { entity: [...Array(3)].map(createMockEntity) },
};
const rendered = await renderProductInsightsCardInTestApp(
entity,
@@ -21,6 +21,7 @@ import React, {
useRef,
useState,
} from 'react';
import pluralize from 'pluralize';
import { InfoCard } from '@backstage/core';
import { Typography } from '@material-ui/core';
import { default as Alert } from '@material-ui/lab/Alert';
@@ -30,12 +31,12 @@ import { useProductInsightsCardStyles as useStyles } from '../../utils/styles';
import { DefaultLoadingAction } from '../../utils/loading';
import { Duration, Entity, Maybe, Product } from '../../types';
import {
useLastCompleteBillingDate,
useScroll,
useLoading,
MapLoadingToProps,
useLastCompleteBillingDate,
useLoading,
useScroll,
} from '../../hooks';
import { pluralOf } from '../../utils/grammar';
import { findAnyKey } from '../../utils/assert';
type LoadingProps = (isLoading: boolean) => void;
@@ -91,13 +92,12 @@ export const ProductInsightsCard = ({
}
}, [product, duration, onSelectAsync, dispatchLoadingProduct]);
const entities = entity?.entities ?? [];
const subheader = entities.length
? `${entities.length} ${pluralOf(
entities.length,
'entity',
'entities',
)}, sorted by cost`
// Only a single entities Record for the root product entity is supported
const entityKey = findAnyKey(entity?.entities);
const entities = entityKey ? entity!.entities[entityKey] : [];
const subheader = entityKey
? `${pluralize(entityKey, entities.length, true)}, sorted by cost`
: null;
const headerProps = {
classes: classes,
@@ -20,6 +20,7 @@ import {
TooltipProps as RechartsTooltipProps,
RechartsFunction,
} from 'recharts';
import pluralize from 'pluralize';
import { Box, Typography } from '@material-ui/core';
import { default as FullScreenIcon } from '@material-ui/icons/Fullscreen';
import { LegendItem } from '../LegendItem';
@@ -32,8 +33,13 @@ import {
BarChartTooltipItem,
BarChartLegendOptions,
} from '../BarChart';
import { pluralOf } from '../../utils/grammar';
import { findAlways, notEmpty, isUndefined } from '../../utils/assert';
import {
findAlways,
notEmpty,
isUndefined,
findAnyKey,
assertAlways,
} from '../../utils/assert';
import { formatPeriod, formatPercent } from '../../utils/formatters';
import {
titleOf,
@@ -62,19 +68,26 @@ export const ProductInsightsChart = ({
}: ProductInsightsChartProps) => {
const classes = useStyles();
const layoutClasses = useLayoutStyles();
// Only a single entities Record for the root product entity is supported
const entityLabel = assertAlways(findAnyKey(entity.entities));
const entities = entity.entities[entityLabel] ?? [];
const [activeLabel, setActive] = useState<Maybe<string>>();
const [selectLabel, setSelected] = useState<Maybe<string>>();
const isSelected = useMemo(() => !isUndefined(selectLabel), [selectLabel]);
const isClickable = useMemo(() => {
const breakdownEntities =
entity.entities.find(e => e.id === activeLabel)?.entities ?? [];
return breakdownEntities.length > 0;
}, [entity, activeLabel]);
const breakdowns = Object.keys(
entities.find(e => e.id === activeLabel)?.entities ?? {},
);
return breakdowns.length > 0;
}, [entities, activeLabel]);
const legendTitle = `Cost ${entity.change.ratio <= 0 ? 'Savings' : 'Growth'}`;
const costStart = entity.aggregation[0];
const costEnd = entity.aggregation[1];
const resources = entity.entities.map(resourceOf);
const resources = entities.map(resourceOf);
const options: Partial<BarChartLegendOptions> = {
previousName: formatPeriod(duration, billingDate, false),
@@ -120,15 +133,14 @@ export const ProductInsightsChart = ({
const title = titleOf(label);
const items = payload.map(tooltipItemOf).filter(notEmpty);
const activeEntity = findAlways(entity.entities, e => e.id === id);
const activeEntity = findAlways(entities, e => e.id === id);
const ratio = activeEntity.change.ratio;
const breakdownEntities = activeEntity.entities;
const subtitle = `${breakdownEntities.length} ${pluralOf(
breakdownEntities.length,
entity.entitiesLabel || 'SKU',
)}`;
const breakdowns = Object.keys(activeEntity.entities);
if (breakdownEntities.length) {
if (breakdowns.length) {
const subtitle = breakdowns
.map(b => pluralize(b, activeEntity.entities[b].length, true))
.join(', ');
return (
<BarChartTooltip
title={title}
@@ -194,13 +206,12 @@ export const ProductInsightsChart = ({
options={options}
{...barChartProps}
/>
{isSelected && entity.entities.length && (
{isSelected && entities.length && (
<ProductEntityDialog
open={isSelected}
onClose={() => setSelected(undefined)}
entity={entity.entities.find(e => e.id === selectLabel)}
entity={findAlways(entities, e => e.id === selectLabel)}
options={options}
entitiesLabel={entity.entitiesLabel || 'SKU'}
/>
)}
</Box>
@@ -15,10 +15,10 @@
*/
import React from 'react';
import pluralize from 'pluralize';
import { InfoCard } from '@backstage/core';
import { ProjectGrowthAlertChart } from './ProjectGrowthAlertChart';
import { ProjectGrowthData } from '../../types';
import { pluralOf } from '../../utils/grammar';
type ProjectGrowthAlertProps = {
alert: ProjectGrowthData;
@@ -26,7 +26,7 @@ type ProjectGrowthAlertProps = {
export const ProjectGrowthAlertCard = ({ alert }: ProjectGrowthAlertProps) => {
const subheader = `
${alert.products.length} ${pluralOf(alert.products.length, 'product')}${
${pluralize('product', alert.products.length, true)}${
alert.products.length > 1 ? ', sorted by cost' : ''
}`;
@@ -72,26 +72,28 @@ export const ProjectGrowthInstructionsPage = () => {
ratio: 3,
amount: 40_000,
},
entities: [
{
id: 'service-one',
aggregation: [18_200, 58_500],
entities: [],
change: { ratio: 2.21, amount: 40_300 },
},
{
id: 'service-two',
aggregation: [1200, 1300],
entities: [],
change: { ratio: 0.083, amount: 100 },
},
{
id: 'service-three',
aggregation: [600, 200],
entities: [],
change: { ratio: -0.666, amount: -400 },
},
],
entities: {
service: [
{
id: 'service-one',
aggregation: [18_200, 58_500],
entities: {},
change: { ratio: 2.21, amount: 40_300 },
},
{
id: 'service-two',
aggregation: [1200, 1300],
entities: {},
change: { ratio: 0.083, amount: 100 },
},
{
id: 'service-three',
aggregation: [600, 200],
entities: {},
change: { ratio: -0.666, amount: -400 },
},
],
},
};
return (
@@ -15,11 +15,11 @@
*/
import React from 'react';
import pluralize from 'pluralize';
import { InfoCard } from '@backstage/core';
import { Box } from '@material-ui/core';
import { BarChart, BarChartLegend } from '../BarChart';
import { UnlabeledDataflowData, ResourceData } from '../../types';
import { pluralOf } from '../../utils/grammar';
import { useBarChartLayoutStyles as useStyles } from '../../utils/styles';
type UnlabeledDataflowAlertProps = {
@@ -30,9 +30,9 @@ export const UnlabeledDataflowAlertCard = ({
alert,
}: UnlabeledDataflowAlertProps) => {
const classes = useStyles();
const projects = pluralOf(alert.projects.length, 'project');
const projects = pluralize('project', alert.projects.length, true);
const subheader = `
Showing costs from ${alert.projects.length} ${projects} with unlabeled Dataflow jobs in the last 30 days.
Showing costs from ${projects} with unlabeled Dataflow jobs in the last 30 days.
`;
const options = {
previousName: 'Unlabeled Cost',
+71 -41
View File
@@ -20,9 +20,8 @@ import { Maybe } from './Maybe';
export interface Entity {
id: Maybe<string>;
aggregation: [number, number];
entities: Entity[];
entities: Record<string, Entity[]>;
change: ChangeStatistic;
entitiesLabel?: string;
}
/*
@@ -31,8 +30,15 @@ export interface Entity {
An entity could be atomic or composite. An atomic entity is indivisible
and cannot be broken into sub-entities.
A composite entity can be broken down recursively into sub-entities
that generate cost **over the same time period**. All costs must sum to the root cost.
A composite entity is divided into sub-entities that account for portions
of the total cost **over the same time period**. The root entity is
expected to only have _one_ Record consisting of the sub-entities to display
in the product panel (keyed by the entity type, such as "service" for
compute entities).
The root sub-entities may have multiple breakdowns - for example, a
breakdown of an entity cost by SKU vs deployment environment. The sum
aggregated cost of each keyed breakdown should equal the sub-entity's cost.
Entities with null ids are considered "unlabeled" - costs without attribution.
If an entity is a composite, it may only have one (1) null child but may have any number of
@@ -45,44 +51,68 @@ export interface Entity {
ratio: 2000,
amount: 200
},
entities: [
{
id: 'service-a',
aggregation: [0, 100],
change: {
ratio: 100,
amount: 100
},
entities: []
},
{
id: 'service-b',
aggregation: [0, 100],
change: {
ratio: 100,
amount: 100
},
entities: [
{
id: 'service-b-sku-a',
aggregation: [0, 25],
change: {
ratio: 25,
amount: 25
},
entities: []
entities: {
service: [
{
id: 'service-a',
aggregation: [0, 100],
change: {
ratio: 100,
amount: 100
},
{
id: null, // Unlabeled cost for service-b
aggregation: [0, 75],
change: {
ratio: 75,
amount: 75
},
entities: []
entities: {}
},
{
id: 'service-b',
aggregation: [0, 100],
change: {
ratio: 100,
amount: 100
},
]
},
]
entities: {
SKU: [
{
id: 'service-b-sku-a',
aggregation: [0, 25],
change: {
ratio: 25,
amount: 25
},
entities: {}
},
{
id: null, // Unlabeled cost for service-b
aggregation: [0, 75],
change: {
ratio: 75,
amount: 75
},
entities: {}
},
],
deployment: [
{
id: 'service-b-env-a',
aggregation: [0, 50],
change: {
ratio: 50,
amount: 50
},
entities: {}
},
{
id: 'service-b-env-b',
aggregation: [0, 50],
change: {
ratio: 50,
amount: 50
},
entities: {}
},
]
}
},
]
}
}
*/
@@ -50,3 +50,9 @@ export function findAlways<T>(
): T {
return assertAlways(collection.find(callback));
}
export function findAnyKey<T>(
record: Record<string, T> | undefined,
): string | undefined {
return Object.keys(record ?? {}).find(_ => true);
}
@@ -15,9 +15,9 @@
*/
import moment from 'moment';
import pluralize from 'pluralize';
import { Duration, DEFAULT_DATE_FORMAT } from '../types';
import { inclusiveEndDateOf, inclusiveStartDateOf } from '../utils/duration';
import { pluralOf } from '../utils/grammar';
export type Period = {
periodStart: string;
@@ -75,7 +75,7 @@ export function formatCurrency(amount: number, currency?: string): string {
const n = Math.round(amount);
const numString = numberFormatter.format(n);
return currency ? `${numString} ${pluralOf(n, currency)}` : numString;
return currency ? `${numString} ${pluralize(currency, n)}` : numString;
}
export function formatPercent(n: number): string {
@@ -22,20 +22,6 @@ const vowels = {
u: 'U',
};
export const pluralOf = (
n: number,
string: string,
plural?: string,
): string => {
if (n !== 1) {
if (plural) {
return plural;
}
return string.concat('s');
}
return string;
};
export const indefiniteArticleOf = (
articles: [string, string],
word: string,
+420 -353
View File
@@ -52,7 +52,7 @@ export const createMockEntity = (
const defaultEntity: Entity = {
id: 'test-entity',
aggregation: [100, 200],
entities: [],
entities: {},
change: {
ratio: 0,
amount: 0,
@@ -517,35 +517,37 @@ export const SampleBigQueryInsights: Entity = {
ratio: 3,
amount: 20_000,
},
entities: [
{
id: 'entity-a',
aggregation: [5_000, 10_000],
change: {
ratio: 1,
amount: 5_000,
entities: {
dataset: [
{
id: 'entity-a',
aggregation: [5_000, 10_000],
change: {
ratio: 1,
amount: 5_000,
},
entities: {},
},
entities: [],
},
{
id: 'entity-b',
aggregation: [5_000, 10_000],
change: {
ratio: 1,
amount: 5_000,
{
id: 'entity-b',
aggregation: [5_000, 10_000],
change: {
ratio: 1,
amount: 5_000,
},
entities: {},
},
entities: [],
},
{
id: 'entity-c',
aggregation: [0, 10_000],
change: {
ratio: 10_000,
amount: 10_000,
{
id: 'entity-c',
aggregation: [0, 10_000],
change: {
ratio: 10_000,
amount: 10_000,
},
entities: {},
},
entities: [],
},
],
],
},
};
export const SampleCloudDataflowInsights: Entity = {
@@ -555,110 +557,118 @@ export const SampleCloudDataflowInsights: Entity = {
ratio: 0.58,
amount: 58_000,
},
entities: [
{
id: null,
aggregation: [10_000, 12_000],
change: {
ratio: 0.2,
amount: 2_000,
entities: {
pipeline: [
{
id: null,
aggregation: [10_000, 12_000],
change: {
ratio: 0.2,
amount: 2_000,
},
entities: {
SKU: [
{
id: 'Sample SKU A',
aggregation: [3_000, 4_000],
change: {
ratio: 0.333333,
amount: 1_000,
},
entities: {},
},
{
id: 'Sample SKU B',
aggregation: [7_000, 8_000],
change: {
ratio: 0.14285714,
amount: 1_000,
},
entities: {},
},
],
},
},
entities: [
{
id: 'Sample SKU A',
aggregation: [3_000, 4_000],
change: {
ratio: 0.333333,
amount: 1_000,
},
entities: [],
{
id: 'entity-a',
aggregation: [60_000, 70_000],
change: {
ratio: 0.16666666666666666,
amount: 10_000,
},
{
id: 'Sample SKU B',
aggregation: [7_000, 8_000],
change: {
ratio: 0.14285714,
amount: 1_000,
},
entities: [],
entities: {
SKU: [
{
id: 'Sample SKU A',
aggregation: [20_000, 15_000],
change: {
ratio: -0.25,
amount: -5_000,
},
entities: {},
},
{
id: 'Sample SKU B',
aggregation: [30_000, 35_000],
change: {
ratio: -0.16666666666666666,
amount: -5_000,
},
entities: {},
},
{
id: 'Sample SKU C',
aggregation: [10_000, 20_000],
change: {
ratio: 1,
amount: 10_000,
},
entities: {},
},
],
},
],
},
{
id: 'entity-a',
aggregation: [60_000, 70_000],
change: {
ratio: 0.16666666666666666,
amount: 10_000,
},
entities: [
{
id: 'Sample SKU A',
aggregation: [20_000, 15_000],
change: {
ratio: -0.25,
amount: -5_000,
},
entities: [],
{
id: 'entity-b',
aggregation: [12_000, 8_000],
change: {
ratio: -0.33333,
amount: -4_000,
},
{
id: 'Sample SKU B',
aggregation: [30_000, 35_000],
change: {
ratio: -0.16666666666666666,
amount: -5_000,
},
entities: [],
entities: {
SKU: [
{
id: 'Sample SKU A',
aggregation: [4_000, 4_000],
change: {
ratio: 0,
amount: 0,
},
entities: {},
},
{
id: 'Sample SKU B',
aggregation: [8_000, 4_000],
change: {
ratio: -0.5,
amount: -4_000,
},
entities: {},
},
],
},
{
id: 'Sample SKU C',
aggregation: [10_000, 20_000],
change: {
ratio: 1,
amount: 10_000,
},
entities: [],
},
],
},
{
id: 'entity-b',
aggregation: [12_000, 8_000],
change: {
ratio: -0.33333,
amount: -4_000,
},
entities: [
{
id: 'Sample SKU A',
aggregation: [4_000, 4_000],
change: {
ratio: 0,
amount: 0,
},
entities: [],
{
id: 'entity-c',
aggregation: [0, 10_000],
change: {
ratio: 10_000,
amount: 10_000,
},
{
id: 'Sample SKU B',
aggregation: [8_000, 4_000],
change: {
ratio: -0.5,
amount: -4_000,
},
entities: [],
},
],
},
{
id: 'entity-c',
aggregation: [0, 10_000],
change: {
ratio: 10_000,
amount: 10_000,
entities: {},
},
entities: [],
},
],
],
},
};
export const SampleCloudStorageInsights: Entity = {
@@ -668,91 +678,97 @@ export const SampleCloudStorageInsights: Entity = {
ratio: 0,
amount: 0,
},
entities: [
{
id: 'entity-a',
aggregation: [15_000, 20_000],
change: {
ratio: 0.333,
amount: 5_000,
entities: {
bucket: [
{
id: 'entity-a',
aggregation: [15_000, 20_000],
change: {
ratio: 0.333,
amount: 5_000,
},
entities: {
SKU: [
{
id: 'Sample SKU A',
aggregation: [10_000, 11_000],
change: {
ratio: 0.1,
amount: 1_000,
},
entities: {},
},
{
id: 'Sample SKU B',
aggregation: [2_000, 5_000],
change: {
ratio: 1.5,
amount: 3_000,
},
entities: {},
},
{
id: 'Sample SKU C',
aggregation: [3_000, 4_000],
change: {
ratio: 0.3333,
amount: 1_000,
},
entities: {},
},
],
},
},
entities: [
{
id: 'Sample SKU A',
aggregation: [10_000, 11_000],
change: {
ratio: 0.1,
amount: 1_000,
},
entities: [],
{
id: 'entity-b',
aggregation: [30_000, 25_000],
change: {
ratio: -0.16666,
amount: -5_000,
},
{
id: 'Sample SKU B',
aggregation: [2_000, 5_000],
change: {
ratio: 1.5,
amount: 3_000,
},
entities: [],
entities: {
SKU: [
{
id: 'Sample SKU A',
aggregation: [12_000, 13_000],
change: {
ratio: 0.08333333333333333,
amount: 1_000,
},
entities: {},
},
{
id: 'Sample SKU B',
aggregation: [16_000, 12_000],
change: {
ratio: -0.25,
amount: -4_000,
},
entities: {},
},
{
id: 'Sample SKU C',
aggregation: [2_000, 0],
change: {
ratio: -1,
amount: -2000,
},
entities: {},
},
],
},
{
id: 'Sample SKU C',
aggregation: [3_000, 4_000],
change: {
ratio: 0.3333,
amount: 1_000,
},
entities: [],
},
],
},
{
id: 'entity-b',
aggregation: [30_000, 25_000],
change: {
ratio: -0.16666,
amount: -5_000,
},
entities: [
{
id: 'Sample SKU A',
aggregation: [12_000, 13_000],
change: {
ratio: 0.08333333333333333,
amount: 1_000,
},
entities: [],
{
id: 'entity-c',
aggregation: [0, 0],
change: {
ratio: 0,
amount: 0,
},
{
id: 'Sample SKU B',
aggregation: [16_000, 12_000],
change: {
ratio: -0.25,
amount: -4_000,
},
entities: [],
},
{
id: 'Sample SKU C',
aggregation: [2_000, 0],
change: {
ratio: -1,
amount: -2000,
},
entities: [],
},
],
},
{
id: 'entity-c',
aggregation: [0, 0],
change: {
ratio: 0,
amount: 0,
entities: {},
},
entities: [],
},
],
],
},
};
export const SampleComputeEngineInsights: Entity = {
@@ -762,91 +778,137 @@ export const SampleComputeEngineInsights: Entity = {
ratio: 0.125,
amount: 10_000,
},
entities: [
{
id: 'entity-a',
aggregation: [20_000, 10_000],
change: {
ratio: -0.5,
amount: -10_000,
entities: {
service: [
{
id: 'entity-a',
aggregation: [20_000, 10_000],
change: {
ratio: -0.5,
amount: -10_000,
},
entities: {
SKU: [
{
id: 'Sample SKU A',
aggregation: [4_000, 2_000],
change: {
ratio: -0.5,
amount: -2_000,
},
entities: {},
},
{
id: 'Sample SKU B',
aggregation: [7_000, 6_000],
change: {
ratio: -0.14285714285714285,
amount: -1_000,
},
entities: {},
},
{
id: 'Sample SKU C',
aggregation: [9_000, 2_000],
change: {
ratio: -0.7777777777777778,
amount: -7000,
},
entities: {},
},
],
deployment: [
{
id: 'Compute Engine',
aggregation: [7_000, 6_000],
change: {
ratio: -0.5,
amount: -2_000,
},
entities: {},
},
{
id: 'Kubernetes',
aggregation: [4_000, 2_000],
change: {
ratio: -0.14285714285714285,
amount: -1_000,
},
entities: {},
},
],
},
},
entities: [
{
id: 'Sample SKU A',
aggregation: [4_000, 2_000],
change: {
ratio: -0.5,
amount: -2_000,
},
entities: [],
{
id: 'entity-b',
aggregation: [10_000, 20_000],
change: {
ratio: 1,
amount: 10_000,
},
{
id: 'Sample SKU B',
aggregation: [7_000, 6_000],
change: {
ratio: -0.14285714285714285,
amount: -1_000,
},
entities: [],
entities: {
SKU: [
{
id: 'Sample SKU A',
aggregation: [1_000, 2_000],
change: {
ratio: 1,
amount: 1_000,
},
entities: {},
},
{
id: 'Sample SKU B',
aggregation: [4_000, 8_000],
change: {
ratio: 1,
amount: 4_000,
},
entities: {},
},
{
id: 'Sample SKU C',
aggregation: [5_000, 10_000],
change: {
ratio: 1,
amount: 5_000,
},
entities: {},
},
],
deployment: [
{
id: 'Compute Engine',
aggregation: [7_000, 6_000],
change: {
ratio: -0.5,
amount: -2_000,
},
entities: {},
},
{
id: 'Kubernetes',
aggregation: [4_000, 2_000],
change: {
ratio: -0.14285714285714285,
amount: -1_000,
},
entities: {},
},
],
},
{
id: 'Sample SKU C',
aggregation: [9_000, 2_000],
change: {
ratio: -0.7777777777777778,
amount: -7000,
},
entities: [],
},
],
},
{
id: 'entity-b',
aggregation: [10_000, 20_000],
change: {
ratio: 1,
amount: 10_000,
},
entities: [
{
id: 'Sample SKU A',
aggregation: [1_000, 2_000],
change: {
ratio: 1,
amount: 1_000,
},
entities: [],
{
id: 'entity-c',
aggregation: [0, 10_000],
change: {
ratio: 10_000,
amount: 10_000,
},
{
id: 'Sample SKU B',
aggregation: [4_000, 8_000],
change: {
ratio: 1,
amount: 4_000,
},
entities: [],
},
{
id: 'Sample SKU C',
aggregation: [5_000, 10_000],
change: {
ratio: 1,
amount: 5_000,
},
entities: [],
},
],
},
{
id: 'entity-c',
aggregation: [0, 10_000],
change: {
ratio: 10_000,
amount: 10_000,
entities: {},
},
entities: [],
},
],
],
},
};
export const SampleEventsInsights: Entity = {
@@ -856,83 +918,88 @@ export const SampleEventsInsights: Entity = {
ratio: -0.5,
amount: -10_000,
},
entitiesLabel: 'Product',
entities: [
{
id: 'entity-a',
aggregation: [15_000, 7_000],
change: {
ratio: -0.53333333333,
amount: -8_000,
entities: {
event: [
{
id: 'entity-a',
aggregation: [15_000, 7_000],
change: {
ratio: -0.53333333333,
amount: -8_000,
},
entities: {
product: [
{
id: 'Sample Product A',
aggregation: [5_000, 2_000],
change: {
ratio: -0.6,
amount: -3_000,
},
entities: {},
},
{
id: 'Sample Product B',
aggregation: [7_000, 2_500],
change: {
ratio: -0.64285714285,
amount: -4_500,
},
entities: {},
},
{
id: 'Sample Product C',
aggregation: [3_000, 2_500],
change: {
ratio: -0.16666666666,
amount: -500,
},
entities: {},
},
],
},
},
entities: [
{
id: 'Sample Product A',
aggregation: [5_000, 2_000],
change: {
ratio: -0.6,
amount: -3_000,
},
entities: [],
{
id: 'entity-b',
aggregation: [5_000, 3_000],
change: {
ratio: -0.4,
amount: -2_000,
},
{
id: 'Sample Product B',
aggregation: [7_000, 2_500],
change: {
ratio: -0.64285714285,
amount: -4_500,
},
entities: [],
entities: {
product: [
{
id: 'Sample Product A',
aggregation: [2_000, 1_000],
change: {
ratio: -0.5,
amount: -1_000,
},
entities: {},
},
{
id: 'Sample Product B',
aggregation: [1_000, 1_500],
change: {
ratio: 0.5,
amount: 500,
},
entities: {},
},
{
id: 'Sample Product C',
aggregation: [2_000, 500],
change: {
ratio: -0.75,
amount: -1_500,
},
entities: {},
},
],
},
{
id: 'Sample Product C',
aggregation: [3_000, 2_500],
change: {
ratio: -0.16666666666,
amount: -500,
},
entities: [],
},
],
},
{
id: 'entity-b',
aggregation: [5_000, 3_000],
change: {
ratio: -0.4,
amount: -2_000,
},
entities: [
{
id: 'Sample Product A',
aggregation: [2_000, 1_000],
change: {
ratio: -0.5,
amount: -1_000,
},
entities: [],
},
{
id: 'Sample Product B',
aggregation: [1_000, 1_500],
change: {
ratio: 0.5,
amount: 500,
},
entities: [],
},
{
id: 'Sample Product C',
aggregation: [2_000, 500],
change: {
ratio: -0.75,
amount: -1_500,
},
entities: [],
},
],
},
],
],
},
};
export function entityOf(product: string): Entity {
+2 -3
View File
@@ -27,8 +27,7 @@ your app's [`plugins.ts`](https://github.com/backstage/backstage/blob/master/pac
to enable the plugin:
```js
import { default as LighthousePlugin } from '@backstage/plugin-lighthouse';
export LighthousePlugin;
export { plugin as LighthousePlugin } from '@backstage/plugin-lighthouse';
```
Then, you need to use the `lighthouseApiRef` exported from the plugin to initialize the Rest API in
@@ -74,7 +73,7 @@ metadata:
lighthouse.com/website-url: # A single website url e.g. https://backstage.io/
```
> NOTE: The lighthouse plugin only supports one website url per component at this time.
> NOTE: The lighthouse plugin only supports one website URL per component at this time.
Add a lighthouse tab to the EntityPage:
@@ -26,7 +26,7 @@ export const LIGHTHOUSE_INTRO_LOCAL_STORAGE =
const USE_CASES = `
Google's [Lighthouse](https://developers.google.com/web/tools/lighthouse) auditing tool for websites
is a great open-source resource forbenchmarking and improving the accessibility, performance, SEO, and best practices of your site.
is a great open-source resource for benchmarking and improving the accessibility, performance, SEO, and best practices of your site.
At Spotify, we keep track of Lighthouse audit scores over time to look at trends and overall areas for investment.
This plugin allows you to generate on-demand Lighthouse audits for websites, and to track the trends for the
@@ -46,8 +46,7 @@ your app's [\`plugins.ts\`](https://github.com/backstage/backstage/blob/master/p
to enable the plugin:
\`\`\`js
import { default as LighthousePlugin } from '@backstage/plugin-lighthouse';
export LighthousePlugin;
export { plugin as LighthousePlugin } from '@backstage/plugin-lighthouse';
\`\`\`
Then, you need to use the \`lighthouseApiRef\` exported from the plugin to initialize the Rest API in
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint')],
};
+6
View File
@@ -0,0 +1,6 @@
# Org Plugin for Backstage
## Features
- Show Group Page
- Show User Profile
+19
View File
@@ -0,0 +1,19 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { createDevApp } from '@backstage/dev-utils';
import { plugin } from '../src/plugin';
createDevApp().registerPlugin(plugin).render();
+50
View File
@@ -0,0 +1,50 @@
{
"name": "@backstage/plugin-org",
"version": "0.3.0",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"publishConfig": {
"access": "public",
"main": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"scripts": {
"build": "backstage-cli plugin:build",
"start": "backstage-cli plugin:serve",
"lint": "backstage-cli lint",
"test": "backstage-cli test",
"diff": "backstage-cli plugin:diff",
"prepack": "backstage-cli prepack",
"postpack": "backstage-cli postpack",
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/catalog-model": "^0.4.0",
"@backstage/core": "^0.3.2",
"@backstage/plugin-catalog": "^0.2.5",
"@backstage/theme": "^0.2.1",
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-router-dom": "6.0.0-beta.0",
"react-use": "^15.3.3"
},
"devDependencies": {
"@backstage/cli": "^0.4.0",
"@backstage/dev-utils": "^0.1.5",
"@backstage/test-utils": "^0.1.4",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^10.4.1",
"@testing-library/user-event": "^12.0.7",
"@types/jest": "^26.0.7",
"@types/node": "^12.0.0",
"cross-fetch": "^3.0.6",
"msw": "^0.21.2"
},
"files": [
"dist"
]
}
@@ -0,0 +1,73 @@
/*
* Copyright 2020 Spotify AB
*
* 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 React, { CSSProperties } from 'react';
import {
Avatar as MaterialAvatar,
createStyles,
makeStyles,
Theme,
} from '@material-ui/core';
const useStyles = makeStyles((theme: Theme) =>
createStyles({
avatar: {
width: '4rem',
height: '4rem',
color: '#fff',
fontWeight: theme.typography.fontWeightBold,
letterSpacing: '1px',
textTransform: 'uppercase',
},
}),
);
const stringToColour = (str: string) => {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
let colour = '#';
for (let i = 0; i < 3; i++) {
const value = (hash >> (i * 8)) & 0xff;
colour += `00${value.toString(16)}`.substr(-2);
}
return colour;
};
export const Avatar = ({
displayName,
picture,
customStyles,
}: {
displayName: string | undefined;
picture: string | undefined;
customStyles?: CSSProperties;
}) => {
const classes = useStyles();
return (
<MaterialAvatar
alt={displayName}
src={picture}
className={classes.avatar}
style={{
backgroundColor: stringToColour(displayName || picture || ''),
...customStyles,
}}
>
{displayName && displayName.match(/\b\w/g)!.join('').substring(0, 2)}
</MaterialAvatar>
);
};
@@ -0,0 +1,16 @@
/*
* Copyright 2020 Spotify AB
*
* 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.
*/
export { Avatar } from './Avatar';
@@ -0,0 +1,141 @@
/*
* Copyright 2020 Spotify AB
*
* 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.
*/
/*
* Copyright 2020 Spotify AB
*
* 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 React from 'react';
import { Box, Grid, Link, Tooltip, Typography } from '@material-ui/core';
import Alert from '@material-ui/lab/Alert';
import { InfoCard } from '@backstage/core';
import { entityRouteParams } from '@backstage/plugin-catalog';
import {
Entity,
GroupEntity,
RELATION_CHILD_OF,
RELATION_PARENT_OF,
} from '@backstage/catalog-model';
import AccountTreeIcon from '@material-ui/icons/AccountTree';
import GroupIcon from '@material-ui/icons/Group';
import { Link as RouterLink, generatePath } from 'react-router-dom';
const GroupLink = ({
groupName,
index = 0,
entity,
}: {
groupName: string;
index?: number;
entity: Entity;
}) => (
<>
{index >= 1 ? ', ' : ''}
<Link
component={RouterLink}
to={generatePath(
`/catalog/:namespace/group/${groupName}`,
entityRouteParams(entity),
)}
>
[{groupName}]
</Link>
</>
);
const CardTitle = ({ title }: { title: string }) => (
<Box display="flex" alignItems="center">
<GroupIcon fontSize="inherit" />
<Box ml={1}>{title}</Box>
</Box>
);
export const GroupProfileCard = ({
entity: group,
variant,
}: {
entity: GroupEntity;
variant: string;
}) => {
const {
metadata: { name, description },
} = group;
const parent = group?.relations
?.filter(r => r.type === RELATION_CHILD_OF)
?.map(group => group.target.name)
.toString();
const childrens = group?.relations
?.filter(r => r.type === RELATION_PARENT_OF)
?.map(group => group.target.name);
if (!group) return <Alert severity="error">User not found</Alert>;
return (
<InfoCard
title={<CardTitle title={name} />}
subheader={description}
variant={variant}
>
<Grid container spacing={3}>
<Grid item>
{parent ? (
<Typography variant="subtitle1">
<Box display="flex" alignItems="center">
<Tooltip title="Group Parent">
<AccountTreeIcon fontSize="inherit" />
</Tooltip>
<Box ml={1} display="inline">
<GroupLink groupName={parent} entity={group} />
</Box>
</Box>
</Typography>
) : null}
{childrens?.length ? (
<Typography variant="subtitle1">
<Box display="flex" alignItems="center">
<Tooltip title="Parent of">
<GroupIcon fontSize="inherit" />
</Tooltip>
<Box ml={1} display="inline">
{childrens.map((children, index) => (
<GroupLink
groupName={children}
entity={group}
index={index}
key={children}
/>
))}
</Box>
</Box>
</Typography>
) : null}
</Grid>
</Grid>
</InfoCard>
);
};
@@ -0,0 +1,16 @@
/*
* Copyright 2020 Spotify AB
*
* 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.
*/
export * from './GroupProfileCard';
@@ -0,0 +1,100 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { renderWithEffects, wrapInTestApp } from '@backstage/test-utils';
import React from 'react';
import { ApiProvider, ApiRegistry } from '@backstage/core';
import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog';
import { Entity } from '@backstage/catalog-model';
import { MembersListCard } from './MembersListCard';
describe('MemberTab Test', () => {
const groupEntity = {
apiVersion: 'v1',
kind: 'Group',
metadata: {
name: 'team-d',
description: 'The evil-corp organization',
namespace: 'default',
},
spec: {
type: 'team',
parent: 'boxoffice',
ancestors: ['boxoffice', 'acme-corp'],
children: [],
descendants: [],
},
};
const catalogApi: Partial<CatalogApi> = {
getEntities: () =>
Promise.resolve({
items: [
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Group',
metadata: {
name: 'tara.macgovern',
namespace: 'default',
uid: 'a5gerth56',
},
relations: [
{
type: 'memberOf',
target: {
kind: 'group',
name: 'team-d',
namespace: 'default',
},
},
],
spec: {
profile: {
displayName: 'Tara MacGovern',
email: 'tara-macgovern@example.com',
picture: 'https://example.com/staff/tara.jpeg',
},
memberOf: ['team-d'],
},
},
] as Entity[],
}),
};
const apis = ApiRegistry.from([[catalogApiRef, catalogApi]]);
it('Display Profile Card', async () => {
const rendered = await renderWithEffects(
wrapInTestApp(
<ApiProvider apis={apis}>
<MembersListCard entity={groupEntity} />
</ApiProvider>,
),
);
expect(rendered.getByAltText('Tara MacGovern')).toHaveAttribute(
'src',
'https://example.com/staff/tara.jpeg',
);
expect(
rendered.getByText('tara-macgovern@example.com'),
).toBeInTheDocument();
expect(rendered.getByText('Tara MacGovern')).toHaveAttribute(
'href',
'/catalog/default/user/tara.macgovern',
);
});
});
@@ -0,0 +1,155 @@
/*
* Copyright 2020 Spotify AB
*
* 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 React from 'react';
import Alert from '@material-ui/lab/Alert';
import {
Box,
createStyles,
Grid,
Link,
makeStyles,
Theme,
Typography,
} from '@material-ui/core';
import { InfoCard, Progress, useApi } from '@backstage/core';
import {
UserEntity,
RELATION_MEMBER_OF,
Entity,
} from '@backstage/catalog-model';
import { Link as RouterLink, generatePath } from 'react-router-dom';
import { catalogApiRef, entityRouteParams } from '@backstage/plugin-catalog';
import { useAsync } from 'react-use';
import { Avatar } from '../../../Avatar';
const useStyles = makeStyles((theme: Theme) =>
createStyles({
card: {
border: `1px solid ${theme.palette.divider}`,
boxShadow: theme.shadows[2],
borderRadius: '4px',
overflow: 'visible',
position: 'relative',
margin: theme.spacing(3, 0, 0),
},
}),
);
const MemberComponent = ({
member,
groupEntity,
}: {
member: UserEntity;
groupEntity: Entity;
}) => {
const classes = useStyles();
const { name: metaName } = member.metadata;
const { profile } = member.spec;
return (
<Grid item xs={12} sm={6} md={3} xl={2}>
<Box className={classes.card}>
<Box
display="flex"
flexDirection="column"
m={3}
alignItems="center"
justifyContent="center"
>
<Avatar
displayName={profile?.displayName}
picture={profile?.picture}
customStyles={{
position: 'absolute',
top: '-2rem',
}}
/>
<Box pt={2} textAlign="center">
<Typography variant="h5">
<Link
component={RouterLink}
to={generatePath(
`/catalog/:namespace/user/${metaName}`,
entityRouteParams(groupEntity),
)}
>
{profile?.displayName}
</Link>
</Typography>
<Typography variant="caption">{profile?.email}</Typography>
</Box>
</Box>
</Box>
</Grid>
);
};
export const MembersListCard = ({
entity: groupEntity,
}: {
entity: Entity;
}) => {
const {
metadata: { name: groupName },
} = groupEntity;
const catalogApi = useApi(catalogApiRef);
const { loading, error, value: members } = useAsync(async () => {
const membersList = await catalogApi.getEntities({
filter: {
kind: 'User',
},
});
const groupMembersList = ((membersList.items as unknown) as Array<
UserEntity
>).filter(member =>
member?.relations?.some(
r => r.type === RELATION_MEMBER_OF && r.target.name === groupName,
),
);
return groupMembersList;
}, [catalogApi]);
if (loading) {
return <Progress />;
} else if (error) {
return <Alert severity="error">{error.message}</Alert>;
}
return (
<Grid item>
<InfoCard
title={`Members (${members?.length || 0})`}
subheader={`of ${groupName}`}
>
<Grid container spacing={3}>
{members && members.length ? (
members.map(member => (
<MemberComponent
member={member}
groupEntity={groupEntity}
key={member.metadata.uid}
/>
))
) : (
<Box p={2}>
<Typography>This group has no members.</Typography>
</Box>
)}
</Grid>
</InfoCard>
</Grid>
);
};
@@ -0,0 +1,16 @@
/*
* Copyright 2020 Spotify AB
*
* 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.
*/
export * from './MembersListCard';
@@ -0,0 +1,17 @@
/*
* Copyright 2020 Spotify AB
*
* 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.
*/
export * from './MembersList';
export * from './GroupProfile';
@@ -0,0 +1,196 @@
/*
* Copyright 2020 Spotify AB
*
* 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 React from 'react';
import { InfoCard, useApi, Progress } from '@backstage/core';
import { Entity, RELATION_OWNED_BY } from '@backstage/catalog-model';
import { catalogApiRef } from '@backstage/plugin-catalog';
import { useAsync } from 'react-use';
import Alert from '@material-ui/lab/Alert';
import {
Box,
createStyles,
Grid,
makeStyles,
Theme,
Typography,
} from '@material-ui/core';
import { pageTheme } from '@backstage/theme';
type EntitiesKinds = 'Component' | 'API';
type EntitiesTypes =
| 'service'
| 'website'
| 'library'
| 'documentation'
| 'api'
| 'tool';
const useStyles = makeStyles((theme: Theme) =>
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,
},
service: {
background: `${pageTheme.home.shape}, linear-gradient(90deg, ${pageTheme.service.colors})`,
},
website: {
background: `${pageTheme.home.shape}, linear-gradient(90deg, ${pageTheme.website.colors})`,
},
library: {
background: `${pageTheme.home.shape}, linear-gradient(90deg, ${pageTheme.library.colors})`,
},
documentation: {
background: `${pageTheme.home.shape}, linear-gradient(90deg, ${pageTheme.documentation.colors})`,
},
api: {
background: `${pageTheme.home.shape}, linear-gradient(90deg, #005B4B, #005B4B)`,
},
tool: {
background: `${pageTheme.home.shape}, linear-gradient(90deg, ${pageTheme.tool.colors})`,
},
}),
);
const countEntitiesBy = (
entities: Array<Entity>,
kind: EntitiesKinds,
type?: EntitiesTypes,
) =>
entities.filter(
e => e.kind === kind && (type ? e?.spec?.type === type : true),
).length;
const EntityCountTile = ({
counter,
className,
name,
}: {
counter: number;
className: EntitiesTypes;
name: string;
}) => {
const classes = useStyles();
return (
<Box
className={`${classes.card} ${classes[className]}`}
display="flex"
flexDirection="column"
alignItems="center"
>
<Typography className={classes.bold} variant="h6">
{counter}
</Typography>
<Typography className={classes.bold} variant="h6">
{name}
</Typography>
</Box>
);
};
export const OwnershipCard = ({
entity,
variant,
}: {
entity: Entity;
variant: string;
}) => {
const {
metadata: { name: groupName },
} = entity;
const catalogApi = useApi(catalogApiRef);
const {
loading,
error,
value: componentsWithCounters,
} = useAsync(async () => {
const entitiesList = await catalogApi.getEntities();
const ownedEntitiesList = entitiesList.items.filter(component =>
component?.relations?.some(
r => r.type === RELATION_OWNED_BY && r.target.name === groupName,
),
) as Array<Entity>;
return [
{
counter: countEntitiesBy(ownedEntitiesList, 'Component', 'service'),
className: 'service',
name: 'Services',
},
{
counter: countEntitiesBy(
ownedEntitiesList,
'Component',
'documentation',
),
className: 'documentation',
name: 'Documentation',
},
{
counter: countEntitiesBy(ownedEntitiesList, 'API'),
className: 'api',
name: 'APIs',
},
{
counter: countEntitiesBy(ownedEntitiesList, 'Component', 'library'),
className: 'library',
name: 'Libraries',
},
{
counter: countEntitiesBy(ownedEntitiesList, 'Component', 'website'),
className: 'website',
name: 'Websites',
},
{
counter: countEntitiesBy(ownedEntitiesList, 'Component', 'tool'),
className: 'tool',
name: 'Tools',
},
] as Array<{ counter: number; className: EntitiesTypes; name: string }>;
}, [catalogApi]);
if (loading) {
return <Progress />;
} else if (error) {
return <Alert severity="error">{error.message}</Alert>;
}
return (
<InfoCard title="Ownership" variant={variant}>
<Grid container>
{componentsWithCounters?.map(c => (
<Grid item xs={12} md={6} lg={4} key={c.name}>
<EntityCountTile
counter={c.counter}
className={c.className}
name={c.name}
/>
</Grid>
))}
</Grid>
</InfoCard>
);
};
@@ -0,0 +1,16 @@
/*
* Copyright 2020 Spotify AB
*
* 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.
*/
export * from './OwnershipCard';
@@ -0,0 +1,64 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { UserEntity } from '@backstage/catalog-model';
import { renderWithEffects, wrapInTestApp } from '@backstage/test-utils';
import React from 'react';
import { UserProfileCard } from './UserProfileCard';
describe('UserSummary Test', () => {
const userEntity: UserEntity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'User',
metadata: {
name: 'calum.leavy',
},
spec: {
profile: {
displayName: 'Calum Leavy',
email: 'calum-leavy@example.com',
picture: 'https://example.com/staff/calum.jpeg',
},
memberOf: ['ExampleGroup'],
},
relations: [
{
type: 'memberOf',
target: {
kind: 'group',
name: 'ExampleGroup',
namespace: 'default',
},
},
],
};
it('Display Profile Card', async () => {
const rendered = await renderWithEffects(
wrapInTestApp(<UserProfileCard entity={userEntity} variant="gridItem" />),
);
expect(rendered.getByText('calum-leavy@example.com')).toBeInTheDocument();
expect(rendered.getByAltText('Calum Leavy')).toHaveAttribute(
'src',
'https://example.com/staff/calum.jpeg',
);
expect(rendered.getByText('[ExampleGroup]')).toHaveAttribute(
'href',
'/catalog/default/group/ExampleGroup',
);
});
});
@@ -0,0 +1,134 @@
/*
* Copyright 2020 Spotify AB
*
* 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 React from 'react';
import { Box, Grid, Link, Tooltip, Typography } from '@material-ui/core';
import Alert from '@material-ui/lab/Alert';
import { InfoCard } from '@backstage/core';
import { entityRouteParams } from '@backstage/plugin-catalog';
import {
Entity,
RELATION_MEMBER_OF,
UserEntity,
} from '@backstage/catalog-model';
import EmailIcon from '@material-ui/icons/Email';
import GroupIcon from '@material-ui/icons/Group';
import PersonIcon from '@material-ui/icons/Person';
import { Link as RouterLink, generatePath } from 'react-router-dom';
import { Avatar } from '../../../Avatar';
const GroupLink = ({
groupName,
index,
entity,
}: {
groupName: string;
index: number;
entity: Entity;
}) => (
<>
{index >= 1 ? ', ' : ''}
<Link
component={RouterLink}
to={generatePath(
`/catalog/:namespace/group/${groupName}`,
entityRouteParams(entity),
)}
>
[{groupName}]
</Link>
</>
);
const CardTitle = ({ title }: { title?: string }) =>
title ? (
<Box display="flex" alignItems="center">
<PersonIcon fontSize="inherit" />
<Box ml={1}>{title}</Box>
</Box>
) : null;
export const UserProfileCard = ({
entity: user,
variant,
}: {
entity: UserEntity;
variant: string;
}) => {
const {
spec: { profile },
} = user;
const groupNames =
user?.relations
?.filter(r => r.type === RELATION_MEMBER_OF)
?.map(group => group.target.name) || [];
if (!user) return <Alert severity="error">User not found</Alert>;
return (
<InfoCard
title={<CardTitle title={profile?.displayName} />}
variant={variant}
>
<Grid container spacing={3}>
<Grid item xs={12} sm={2} xl={1}>
<Box
display="flex"
alignItems="flex-start"
justifyContent="center"
height="100%"
width="100%"
>
<Avatar
displayName={profile?.displayName}
picture={profile?.picture}
/>
</Box>
</Grid>
<Grid item md={10} xl={11}>
<Typography variant="subtitle1">
<Box display="flex" alignItems="center">
<Tooltip title="Email">
<EmailIcon fontSize="inherit" />
</Tooltip>
{profile?.email && (
<Box ml={1} display="inline">
{profile.email}
</Box>
)}
</Box>
</Typography>
<Typography variant="subtitle1">
<Box display="flex" alignItems="center">
<Tooltip title="Member of">
<GroupIcon />
</Tooltip>
<Box ml={1} display="inline">
{groupNames.map((groupName, index) => (
<GroupLink
groupName={groupName}
index={index}
key={groupName}
entity={user}
/>
))}
</Box>
</Box>
</Typography>
</Grid>
</Grid>
</InfoCard>
);
};
@@ -0,0 +1,16 @@
/*
* Copyright 2020 Spotify AB
*
* 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.
*/
export * from './UserProfileCard';
@@ -0,0 +1,16 @@
/*
* Copyright 2020 Spotify AB
*
* 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.
*/
export * from './UserProfileCard';
+18
View File
@@ -0,0 +1,18 @@
/*
* Copyright 2020 Spotify AB
*
* 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.
*/
export * from './Group';
export * from './User';
export * from './OwnershipCard';
+17
View File
@@ -0,0 +1,17 @@
/*
* Copyright 2020 Spotify AB
*
* 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.
*/
export * from './Cards';
+17
View File
@@ -0,0 +1,17 @@
/*
* Copyright 2020 Spotify AB
*
* 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.
*/
export { plugin } from './plugin';
export * from './components';
+22
View File
@@ -0,0 +1,22 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { plugin } from './plugin';
describe('groups', () => {
it('should export plugin', () => {
expect(plugin).toBeDefined();
});
});
+20
View File
@@ -0,0 +1,20 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { createPlugin } from '@backstage/core';
export const plugin = createPlugin({
id: 'org',
});
+17
View File
@@ -0,0 +1,17 @@
/*
* Copyright 2020 Spotify AB
*
* 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 '@testing-library/jest-dom';
import 'cross-fetch/polyfill';
@@ -30,7 +30,6 @@ import transformer, {
addLinkClickListener,
removeMkdocsHeader,
simplifyMkdocsFooter,
modifyCss,
onCssReady,
sanitizeDOM,
injectCss,
@@ -71,15 +70,6 @@ export const Reader = ({ entityId, onReady }: Props) => {
path,
}),
rewriteDocLinks(),
modifyCss({
cssTransforms: {
'.md-main__inner': [{ 'margin-top': '0' }],
'.md-sidebar': [{ top: '0' }, { width: '20rem' }],
'.md-typeset': [{ 'font-size': '1rem' }],
'.md-nav': [{ 'font-size': '1rem' }],
'.md-grid': [{ 'max-width': '80vw' }],
},
}),
removeMkdocsHeader(),
simplifyMkdocsFooter(),
injectCss({
@@ -92,6 +82,11 @@ export const Reader = ({ entityId, onReady }: Props) => {
--md-code-fg-color: ${theme.palette.text.primary};
--md-code-bg-color: ${theme.palette.background.paper};
}
.md-main__inner { margin-top: 0; }
.md-sidebar { top: 0; width: 20rem; }
.md-typeset { font-size: 1rem; }
.md-nav { font-size: 1rem; }
.md-grid { max-width: 80vw; }
`,
}),
]);
@@ -28,13 +28,13 @@ export const addLinkClickListener = ({
return dom => {
Array.from(dom.getElementsByTagName('a')).forEach(elem => {
elem.addEventListener('click', (e: MouseEvent) => {
const target = e.target as HTMLAnchorElement;
const href = target?.getAttribute('href');
const target = elem as HTMLAnchorElement;
const href = target.getAttribute('href');
if (!href) return;
if (href.startsWith(baseUrl)) {
e.preventDefault();
onClick(e, target.getAttribute('href')!);
onClick(e, href);
}
});
});
@@ -19,7 +19,6 @@ export * from './rewriteDocLinks';
export * from './addLinkClickListener';
export * from './removeMkdocsHeader';
export * from './simplifyMkdocsFooter';
export * from './modifyCss';
export * from './onCssReady';
export * from './sanitizeDOM';
export * from './injectCss';
@@ -1,58 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { createTestShadowDom } from '../../test-utils';
import { modifyCss } from '../transformers';
describe('modifyCss', () => {
it('does not modify css', () => {
const shadowDom = createTestShadowDom(
`<div class="md-typeset" style="font-size: 0.8em"></div>`,
{
preTransformers: [],
postTransformers: [],
},
);
const { fontSize } = getComputedStyle(
shadowDom.querySelector<HTMLElement>('.md-typeset')!,
);
expect(fontSize).toBe('0.8em');
});
it('does modify css', () => {
const shadowDom = createTestShadowDom(
`<div class="md-typeset" style="font-size: 1px"></div>`,
{
preTransformers: [
modifyCss({
cssTransforms: {
'.md-typeset': [{ 'font-size': '1em' }],
},
}),
],
postTransformers: [],
},
);
const { fontSize } = getComputedStyle(
shadowDom.querySelector<HTMLElement>('.md-typeset')!,
);
expect(fontSize).toBe('1em');
});
});
@@ -1,43 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* 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 type { Transformer } from './index';
type ModifyCssOptions = {
// Example: { '.md-container': { 'marginTop': '10px' }}
cssTransforms: { [key: string]: { [key: string]: string }[] };
};
export const modifyCss = ({ cssTransforms }: ModifyCssOptions): Transformer => {
return dom => {
Object.entries(cssTransforms).forEach(([cssSelector, cssChanges]) => {
const elementsToChange = Array.from(
dom.querySelectorAll<HTMLElement>(cssSelector),
);
if (elementsToChange.length < 1) return;
cssChanges.forEach(changes => {
elementsToChange.forEach((element: HTMLElement) => {
Object.entries(changes).forEach(([cssProperty, cssValue]) => {
element.style.setProperty(cssProperty, cssValue);
});
});
});
});
return dom;
};
};
+15 -61
View File
@@ -2540,16 +2540,6 @@
source-map "^0.6.1"
write-file-atomic "^3.0.0"
"@jest/types@^25.5.0":
version "25.5.0"
resolved "https://registry.npmjs.org/@jest/types/-/types-25.5.0.tgz#4d6a4793f7b9599fc3680877b856a97dbccf2a9d"
integrity sha512-OXD0RgQ86Tu3MazKo8bnrkDRaDXXMGUqd+kTtLtK1Zb7CRzQcaSRPPPV37SvYTdevXEBVxe0HXylEjs8ibkmCw==
dependencies:
"@types/istanbul-lib-coverage" "^2.0.0"
"@types/istanbul-reports" "^1.1.1"
"@types/yargs" "^15.0.0"
chalk "^3.0.0"
"@jest/types@^26.5.2", "@jest/types@^26.6.1":
version "26.6.1"
resolved "https://registry.npmjs.org/@jest/types/-/types-26.6.1.tgz#2638890e8031c0bc8b4681e0357ed986e2f866c5"
@@ -5447,14 +5437,6 @@
dependencies:
"@types/istanbul-lib-coverage" "*"
"@types/istanbul-reports@^1.1.1":
version "1.1.1"
resolved "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-1.1.1.tgz#7a8cbf6a406f36c8add871625b278eaf0b0d255a"
integrity sha512-UpYjBi8xefVChsCoBpKShdxTllC9pwISirfoZsUa2AAdQg/Jd2KQGtSbw+ya7GPo7x/wAPlH6JBhKhAsXUEZNA==
dependencies:
"@types/istanbul-lib-coverage" "*"
"@types/istanbul-lib-report" "*"
"@types/istanbul-reports@^3.0.0":
version "3.0.0"
resolved "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.0.tgz#508b13aa344fa4976234e75dddcc34925737d821"
@@ -5462,15 +5444,7 @@
dependencies:
"@types/istanbul-lib-report" "*"
"@types/jest@*", "@types/jest@^26.0.7":
version "26.0.14"
resolved "https://registry.npmjs.org/@types/jest/-/jest-26.0.14.tgz#078695f8f65cb55c5a98450d65083b2b73e5a3f3"
integrity sha512-Hz5q8Vu0D288x3iWXePSn53W7hAjP0H7EQ6QvDO9c7t46mR0lNOLlfuwQ+JkVxuhygHzlzPX+0jKdA3ZgSh+Vg==
dependencies:
jest-diff "^25.2.1"
pretty-format "^25.2.1"
"@types/jest@26.x":
"@types/jest@*", "@types/jest@26.x", "@types/jest@^26.0.7":
version "26.0.15"
resolved "https://registry.npmjs.org/@types/jest/-/jest-26.0.15.tgz#12e02c0372ad0548e07b9f4e19132b834cb1effe"
integrity sha512-s2VMReFXRg9XXxV+CW9e5Nz8fH2K1aEhwgjUqPPbQd7g95T0laAcvLv032EhFHIa5GHsZ8W7iJEQVaJq6k3Gog==
@@ -5790,6 +5764,11 @@
dependencies:
"@types/express" "*"
"@types/pluralize@^0.0.29":
version "0.0.29"
resolved "https://registry.npmjs.org/@types/pluralize/-/pluralize-0.0.29.tgz#6ffa33ed1fc8813c469b859681d09707eb40d03c"
integrity sha512-BYOID+l2Aco2nBik+iYS4SZX0Lf20KPILP5RGmM1IgzdwNdTs0eebiFriOPcej1sX9mLnSoiNte5zcFxssgpGA==
"@types/prettier@^2.0.0":
version "2.0.0"
resolved "https://registry.npmjs.org/@types/prettier/-/prettier-2.0.0.tgz#dc85454b953178cc6043df5208b9e949b54a3bc4"
@@ -6096,9 +6075,9 @@
"@types/estree" "*"
"@types/testing-library__jest-dom@^5.9.1":
version "5.9.1"
resolved "https://registry.npmjs.org/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.9.1.tgz#aba5ee062b7880f69c212ef769389f30752806e5"
integrity sha512-yYn5EKHO3MPEMSOrcAb1dLWY+68CG29LiXKsWmmpVHqoP5+ZRiAVLyUHvPNrO2dABDdUGZvavMsaGpWNjM6N2g==
version "5.9.5"
resolved "https://registry.npmjs.org/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.9.5.tgz#5bf25c91ad2d7b38f264b12275e5c92a66d849b0"
integrity sha512-ggn3ws+yRbOHog9GxnXiEZ/35Mow6YtPZpd7Z5mKDeZS/o7zx3yAle0ov/wjhVB5QT4N2Dt+GNoGCdqkBGCajQ==
dependencies:
"@types/jest" "*"
@@ -10640,11 +10619,6 @@ dicer@0.3.0:
dependencies:
streamsearch "0.1.2"
diff-sequences@^25.2.6:
version "25.2.6"
resolved "https://registry.npmjs.org/diff-sequences/-/diff-sequences-25.2.6.tgz#5f467c00edd35352b7bca46d7927d60e687a76dd"
integrity sha512-Hq8o7+6GaZeoFjtpgvRBUknSXNeJiCx7V9Fr94ZMljNiCr9n9L8H8aJqgWOQiDDGdyn29fRNcDdRVJ5fdyihfg==
diff-sequences@^26.5.0:
version "26.5.0"
resolved "https://registry.npmjs.org/diff-sequences/-/diff-sequences-26.5.0.tgz#ef766cf09d43ed40406611f11c6d8d9dd8b2fefd"
@@ -14995,16 +14969,6 @@ jest-css-modules@^2.1.0:
dependencies:
identity-obj-proxy "3.0.0"
jest-diff@^25.2.1:
version "25.5.0"
resolved "https://registry.npmjs.org/jest-diff/-/jest-diff-25.5.0.tgz#1dd26ed64f96667c068cef026b677dfa01afcfa9"
integrity sha512-z1kygetuPiREYdNIumRpAHY6RXiGmp70YHptjdaxTWGmA085W3iCnXNx0DhflK3vwrKmrRWyY1wUpkPMVxMK7A==
dependencies:
chalk "^3.0.0"
diff-sequences "^25.2.6"
jest-get-type "^25.2.6"
pretty-format "^25.5.0"
jest-diff@^26.0.0, jest-diff@^26.5.2:
version "26.6.1"
resolved "https://registry.npmjs.org/jest-diff/-/jest-diff-26.6.1.tgz#38aa194979f454619bb39bdee299fb64ede5300c"
@@ -15066,11 +15030,6 @@ jest-esm-transformer@^1.0.0:
"@babel/core" "^7.4.4"
"@babel/plugin-transform-modules-commonjs" "^7.4.4"
jest-get-type@^25.2.6:
version "25.2.6"
resolved "https://registry.npmjs.org/jest-get-type/-/jest-get-type-25.2.6.tgz#0b0a32fab8908b44d508be81681487dbabb8d877"
integrity sha512-DxjtyzOHjObRM+sM1knti6or+eOgcGU4xVSb2HNP1TqO4ahsT+rqZg+nyqHWJSvWgKC5cG3QjGFBqxLghiF/Ig==
jest-get-type@^26.3.0:
version "26.3.0"
resolved "https://registry.npmjs.org/jest-get-type/-/jest-get-type-26.3.0.tgz#e97dc3c3f53c2b406ca7afaed4493b1d099199e0"
@@ -19154,6 +19113,11 @@ please-upgrade-node@^3.2.0:
dependencies:
semver-compare "^1.0.0"
pluralize@^8.0.0:
version "8.0.0"
resolved "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz#1a6fa16a38d12a1901e0320fa017051c539ce3b1"
integrity sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==
pn@^1.1.0:
version "1.1.0"
resolved "https://registry.npmjs.org/pn/-/pn-1.1.0.tgz#e2f4cef0e219f463c179ab37463e4e1ecdccbafb"
@@ -19684,16 +19648,6 @@ pretty-error@^2.1.1:
renderkid "^2.0.1"
utila "~0.4"
pretty-format@^25.2.1, pretty-format@^25.5.0:
version "25.5.0"
resolved "https://registry.npmjs.org/pretty-format/-/pretty-format-25.5.0.tgz#7873c1d774f682c34b8d48b6743a2bf2ac55791a"
integrity sha512-kbo/kq2LQ/A/is0PQwsEHM7Ca6//bGPPvU6UnsdDRSKTWxT/ru/xb88v4BJf6a69H+uTytOEsTusT9ksd/1iWQ==
dependencies:
"@jest/types" "^25.5.0"
ansi-regex "^5.0.0"
ansi-styles "^4.0.0"
react-is "^16.12.0"
pretty-format@^26.0.0, pretty-format@^26.4.2, pretty-format@^26.5.2, pretty-format@^26.6.1:
version "26.6.1"
resolved "https://registry.npmjs.org/pretty-format/-/pretty-format-26.6.1.tgz#af9a2f63493a856acddeeb11ba6bcf61989660a8"
@@ -20368,7 +20322,7 @@ react-inspector@^5.0.1:
is-dom "^1.1.0"
prop-types "^15.6.1"
react-is@^16.12.0, react-is@^16.6.0, react-is@^16.7.0, react-is@^16.8.0, react-is@^16.8.1, react-is@^16.8.6, react-is@^16.9.0:
react-is@^16.6.0, react-is@^16.7.0, react-is@^16.8.0, react-is@^16.8.1, react-is@^16.8.6, react-is@^16.9.0:
version "16.13.1"
resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==