Merge branch 'master' of github.com:spotify/backstage into backend_plugin_version

This commit is contained in:
Fabian Chong
2020-10-11 12:11:57 +08:00
100 changed files with 1222 additions and 1068 deletions
+1 -49
View File
@@ -2,55 +2,7 @@
"$schema": "https://unpkg.com/@changesets/config@1.3.0/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"linked": [
[
"example-app",
"@backstage/backend-common",
"example-backend",
"@backstage/catalog-model",
"@backstage/cli-common",
"@backstage/cli",
"@backstage/config-loader",
"@backstage/config",
"@backstage/core-api",
"@backstage/core",
"@backstage/create-app",
"@backstage/dev-utils",
"docgen",
"e2e-test",
"storybook",
"@techdocs/cli",
"@backstage/test-utils-core",
"@backstage/test-utils",
"@backstage/theme",
"@backstage/plugin-api-docs",
"@backstage/plugin-app-backend",
"@backstage/plugin-auth-backend",
"@backstage/plugin-catalog-backend",
"@backstage/plugin-catalog",
"@backstage/plugin-circleci",
"@backstage/plugin-gcp-projects",
"@backstage/plugin-github-actions",
"@backstage/plugin-gitops-profiles",
"@backstage/plugin-graphiql",
"@backstage/plugin-graphql-backend",
"@backstage/plugin-jenkins",
"@backstage/plugin-lighthouse",
"@backstage/plugin-newrelic",
"@backstage/plugin-proxy-backend",
"@backstage/plugin-register-component",
"@backstage/plugin-rollbar-backend",
"@backstage/plugin-rollbar",
"@backstage/plugin-scaffolder-backend",
"@backstage/plugin-scaffolder",
"@backstage/plugin-sentry-backend",
"@backstage/plugin-sentry",
"@backstage/plugin-tech-radar",
"@backstage/plugin-techdocs-backend",
"@backstage/plugin-techdocs",
"@backstage/plugin-welcome"
]
],
"linked": [["*"]],
"access": "public",
"baseBranch": "master",
"updateInternalDependencies": "patch",
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-cost-insights': minor
---
Enable custom alert types in Cost Insights
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/cli': patch
---
Fixed duplicate help output, and print help on invalid command
+4 -7
View File
@@ -5,12 +5,9 @@
#### :heavy_check_mark: Checklist
<!--- Put an `x` in all the boxes that apply: -->
<!--- Please include the following in your Pull Request when applicable: -->
- [ ] All tests are passing `yarn test`
- [ ] A changeset describing the change and affected packages. ([more info](https://github.com/spotify/backstage/blob/master/CONTRIBUTING.md#creating-changesets))
- [ ] Added or updated documentation
- [ ] Tests for new functionality and regression tests for bug fixes
- [ ] Screenshots attached (for UI changes)
- [ ] Relevant documentation updated
- [ ] Prettier run on changed files
- [ ] Tests added for new functionality
- [ ] Regression tests added for bug fixes
- [ ] Added a changeset ([more info](https://github.com/spotify/backstage/blob/master/CONTRIBUTING.md#creating-changesets))
+1
View File
@@ -141,6 +141,7 @@ Raghunandan
rankdir
readme
Readme
Recharts
Redash
replicasets
repo
-12
View File
@@ -1,12 +0,0 @@
# Make sure that before you
# run the docker-compose that you have run
# $ yarn docker-build
version: '3'
services:
backstage:
image: 'example-backend:latest'
ports:
- '7000:7000'
environment:
NODE_ENV: development
-11
View File
@@ -17,14 +17,3 @@ $ docker run --rm -it -p 7000:7000 -e NODE_ENV=development example-backend:lates
```
Then open http://localhost/ on your browser.
### Running with `docker-compose`
There is also a `docker-compose.yaml` that you can use to replace the previous
`docker run` command:
```bash
$ yarn install
$ yarn docker-build
$ docker-compose up
```
+1 -1
View File
@@ -38,7 +38,7 @@
},
"version": "1.0.0",
"devDependencies": {
"@changesets/cli": "2.10.2",
"@changesets/cli": "^2.11.0",
"@spotify/eslint-config-oss": "^1.0.1",
"@spotify/prettier-config": "^8.0.0",
"concurrently": "^5.2.0",
+4 -1
View File
@@ -27,8 +27,8 @@
"@backstage/plugin-sentry": "^0.1.1-alpha.24",
"@backstage/plugin-tech-radar": "^0.1.1-alpha.24",
"@backstage/plugin-techdocs": "^0.1.1-alpha.24",
"@backstage/plugin-welcome": "^0.1.1-alpha.24",
"@backstage/plugin-user-settings": "^0.1.1-alpha.24",
"@backstage/plugin-welcome": "^0.1.1-alpha.24",
"@backstage/test-utils": "^0.1.1-alpha.24",
"@backstage/theme": "^0.1.1-alpha.24",
"@material-ui/core": "^4.11.0",
@@ -36,6 +36,7 @@
"@octokit/rest": "^18.0.0",
"@roadiehq/backstage-plugin-github-pull-requests": "^0.4.3",
"@roadiehq/backstage-plugin-travis-ci": "^0.2.3",
"dayjs": "^1.9.1",
"history": "^5.0.0",
"prop-types": "^15.7.2",
"react": "^16.12.0",
@@ -44,6 +45,7 @@
"react-router": "6.0.0-beta.0",
"react-router-dom": "6.0.0-beta.0",
"react-use": "^15.3.3",
"regression": "^2.0.1",
"zen-observable": "^0.8.15"
},
"devDependencies": {
@@ -55,6 +57,7 @@
"@types/jquery": "^3.3.34",
"@types/node": "^12.0.0",
"@types/react-dom": "^16.9.8",
"@types/regression": "^2.0.0",
"@types/zen-observable": "^0.8.0",
"cross-env": "^7.0.0",
"cypress": "^4.2.0",
@@ -15,16 +15,84 @@
*/
/* eslint-disable no-restricted-imports */
import dayjs from 'dayjs';
import regression, { DataPoint } from 'regression';
import {
CostInsightsApi,
Alert,
ChangeStatistic,
Cost,
CostInsightsApi,
DateAggregation,
Duration,
Project,
ProductCost,
exclusiveEndDateOf,
Group,
inclusiveStartDateOf,
Maybe,
ProductCost,
Project,
ProjectGrowthAlert,
ProjectGrowthData,
Trendline,
UnlabeledDataflowAlert,
UnlabeledDataflowData,
} from '@backstage/plugin-cost-insights';
function durationOf(intervals: string): Duration {
const match = intervals.match(/\/(?<duration>P\d+[DM])\//);
const { duration } = match!.groups!;
return duration as Duration;
}
function aggregationFor(
duration: Duration,
baseline: number,
): DateAggregation[] {
const days = dayjs(exclusiveEndDateOf(duration)).diff(
inclusiveStartDateOf(duration),
'day',
);
return [...Array(days).keys()].reduce(
(values: DateAggregation[], i: number): DateAggregation[] => {
const last = values.length ? values[values.length - 1].amount : baseline;
values.push({
date: dayjs(inclusiveStartDateOf(duration))
.add(i, 'day')
.format('YYYY-MM-DD'),
amount: last + (baseline / 20) * (Math.random() * 2 - 1),
});
return values;
},
[],
);
}
function trendlineOf(aggregation: DateAggregation[]): Trendline {
const data: ReadonlyArray<DataPoint> = aggregation.map(a => [
Date.parse(a.date) / 1000,
a.amount,
]);
const result = regression.linear(data, { precision: 5 });
return {
slope: result.equation[0],
intercept: result.equation[1],
};
}
function changeOf(aggregation: DateAggregation[]): ChangeStatistic {
const half = Math.ceil(aggregation.length / 2);
const before = aggregation
.slice(0, half)
.reduce((sum, a) => sum + a.amount, 0);
const after = aggregation
.slice(half, aggregation.length)
.reduce((sum, a) => sum + a.amount, 0);
return {
ratio: (after - before) / before,
amount: after - before,
};
}
export class ExampleCostInsightsClient implements CostInsightsApi {
private request(_: any, res: any): Promise<any> {
return new Promise(resolve => setTimeout(resolve, 0, res));
@@ -53,30 +121,17 @@ export class ExampleCostInsightsClient implements CostInsightsApi {
metric: string | null,
intervals: string,
): Promise<Cost> {
const aggregation = aggregationFor(
durationOf(intervals),
metric ? 0.3 : 8_000,
);
const groupDailyCost: Cost = await this.request(
{ group, metric, intervals },
{
id: metric, // costs with null ids will appear as "All Projects" in Cost Overview panel
aggregation: [
{ date: '2020-08-01', amount: 75_000 / (metric ? 200_000 : 1) },
{ date: '2020-08-02', amount: 120_000 / (metric ? 200_000 : 1) },
{ date: '2020-08-03', amount: 110_000 / (metric ? 200_000 : 1) },
{ date: '2020-08-04', amount: 90_000 / (metric ? 200_000 : 1) },
{ date: '2020-08-05', amount: 80_000 / (metric ? 200_000 : 1) },
{ date: '2020-08-06', amount: 85_000 / (metric ? 200_000 : 1) },
{ date: '2020-08-07', amount: 82_500 / (metric ? 200_000 : 1) },
{ date: '2020-08-08', amount: 100_000 / (metric ? 200_000 : 1) },
{ date: '2020-08-09', amount: 130_000 / (metric ? 200_000 : 1) },
{ date: '2020-08-10', amount: 140_000 / (metric ? 200_000 : 1) },
],
change: {
ratio: 0.86,
amount: 65_000,
},
trendline: {
slope: 0,
intercept: 90_000,
},
aggregation: aggregation,
change: changeOf(aggregation),
trendline: trendlineOf(aggregation),
},
);
@@ -88,30 +143,17 @@ export class ExampleCostInsightsClient implements CostInsightsApi {
metric: string | null,
intervals: string,
): Promise<Cost> {
const aggregation = aggregationFor(
durationOf(intervals),
metric ? 0.1 : 1_500,
);
const projectDailyCost: Cost = await this.request(
{ project, metric, intervals },
{
id: 'project-a',
aggregation: [
{ date: '2020-08-01', amount: 1000 },
{ date: '2020-08-02', amount: 2000 },
{ date: '2020-08-03', amount: 3000 },
{ date: '2020-08-04', amount: 4000 },
{ date: '2020-08-05', amount: 5000 },
{ date: '2020-08-06', amount: 6000 },
{ date: '2020-08-07', amount: 7000 },
{ date: '2020-08-08', amount: 8000 },
{ date: '2020-08-09', amount: 9000 },
{ date: '2020-08-10', amount: 10_000 },
],
change: {
ratio: 0.5,
amount: 10000,
},
trendline: {
slope: 0,
intercept: 0,
},
aggregation: aggregation,
change: changeOf(aggregation),
trendline: trendlineOf(aggregation),
},
);
@@ -122,9 +164,38 @@ export class ExampleCostInsightsClient implements CostInsightsApi {
product: string,
group: string,
duration: Duration,
project: Maybe<string>,
): Promise<ProductCost> {
const projectProductInsights = await this.request(
{ product, group, duration, project },
{
aggregation: [80_000, 110_000],
change: {
ratio: 0.375,
amount: 30_000,
},
entities: [
{
id: null, // entities with null ids will be appear as "Unlabeled" in product panels
aggregation: [45_000, 50_000],
},
{
id: 'entity-a',
aggregation: [15_000, 20_000],
},
{
id: 'entity-b',
aggregation: [20_000, 30_000],
},
{
id: 'entity-e',
aggregation: [0, 10_000],
},
],
},
);
const productInsights: ProductCost = await this.request(
{ product, group, duration },
{ product, group, duration, project },
{
aggregation: [200_000, 250_000],
change: {
@@ -134,7 +205,7 @@ export class ExampleCostInsightsClient implements CostInsightsApi {
entities: [
{
id: null, // entities with null ids will be appear as "Unlabeled" in product panels
aggregation: [45_000, 50_000],
aggregation: [15_000, 30_000],
},
{
id: 'entity-a',
@@ -172,36 +243,48 @@ export class ExampleCostInsightsClient implements CostInsightsApi {
},
);
return productInsights;
return project ? projectProductInsights : productInsights;
}
async getAlerts(group: string): Promise<Alert[]> {
const alerts: Alert[] = await this.request({ group }, [
{
id: 'projectGrowth',
project: 'example-project',
periodStart: 'Q1 2020',
periodEnd: 'Q2 2020',
aggregation: [60_000, 120_000],
change: {
ratio: 1,
amount: 60000,
},
products: [
{
id: 'Compute Engine',
aggregation: [58_000, 118_000],
},
{
id: 'Cloud Dataflow',
aggregation: [1200, 1500],
},
{
id: 'Cloud Storage',
aggregation: [800, 500],
},
],
const projectGrowthData: ProjectGrowthData = {
project: 'example-project',
periodStart: 'Q2 2020',
periodEnd: 'Q3 2020',
aggregation: [60_000, 120_000],
change: {
ratio: 1,
amount: 60000,
},
products: [
{ id: 'Compute Engine', aggregation: [58_000, 118_000] },
{ id: 'Cloud Dataflow', aggregation: [1200, 1500] },
{ id: 'Cloud Storage', aggregation: [800, 500] },
],
};
const unlabeledDataflowData: UnlabeledDataflowData = {
periodStart: '2020-09-01',
periodEnd: '2020-09-30',
labeledCost: 6_200,
unlabeledCost: 7_000,
projects: [
{
id: 'example-project-1',
unlabeledCost: 5_000,
labeledCost: 3_000,
},
{
id: 'example-project-2',
unlabeledCost: 2_000,
labeledCost: 3_200,
},
],
};
const alerts: Alert[] = await this.request({ group }, [
new ProjectGrowthAlert(projectGrowthData),
new UnlabeledDataflowAlert(unlabeledDataflowData),
]);
return alerts;
+1
View File
@@ -21,6 +21,7 @@ import { loadConfig } from '@backstage/config-loader';
* Load configuration for a Backend
*/
export async function loadBackendConfig() {
/* eslint-disable-next-line no-restricted-syntax */
const paths = findPaths(__dirname);
const configs = await loadConfig({
env: process.env.NODE_ENV ?? 'development',
+1
View File
@@ -14,6 +14,7 @@
* limitations under the License.
*/
/* eslint-disable no-restricted-syntax */
import { resolve as resolvePath } from 'path';
import { findPaths, findRootPath, findOwnDir, findOwnRootDir } from './paths';
+2
View File
@@ -18,6 +18,7 @@
const path = require('path');
// Figure out whether we're running inside the backstage repo or as an installed dependency
/* eslint-disable-next-line no-restricted-syntax */
const isLocal = require('fs').existsSync(path.resolve(__dirname, '../src'));
if (!isLocal || process.env.BACKSTAGE_E2E_CLI_TEST) {
@@ -25,6 +26,7 @@ if (!isLocal || process.env.BACKSTAGE_E2E_CLI_TEST) {
} else {
require('ts-node').register({
transpileOnly: true,
/* eslint-disable-next-line no-restricted-syntax */
project: path.resolve(__dirname, '../../../tsconfig.json'),
compilerOptions: {
module: 'CommonJS',
+5
View File
@@ -67,6 +67,11 @@ module.exports = {
selector:
'ImportDeclaration[source.value="winston"] ImportDefaultSpecifier',
},
{
message:
"`__dirname` doesn't refer to the same dir in production builds, try `resolvePackagePath()` from `@backstage/backend-common` instead.",
selector: 'Identifier[name="__dirname"]',
},
],
},
overrides: [
+1 -1
View File
@@ -96,7 +96,7 @@ async function getConfig() {
// Default behaviour is to not apply transforms for node_modules, but we still want
// to apply the esm-transformer to .esm.js files, since that's what we use in backstage packages.
transformIgnorePatterns: [
`/node_modules/${transformModulePattern}(?:(?!\\.esm).)*\\.(?:js|json)$`,
`/node_modules/${transformModulePattern}.*\\.(?:(?<!esm\\.)js|json)$`,
],
};
+2 -8
View File
@@ -27,18 +27,12 @@ const main = (argv: string[]) => {
program.on('command:*', () => {
console.log();
console.log(
chalk.red(`Invalid command: ${chalk.cyan(program.args.join(' '))}`),
);
console.log(chalk.red('See --help for a list of available commands.'));
console.log(chalk.red(`Invalid command: ${program.args.join(' ')}`));
console.log();
program.outputHelp();
process.exit(1);
});
if (!process.argv.slice(2).length) {
program.outputHelp(chalk.yellow);
}
program.parse(argv);
};
+1
View File
@@ -209,6 +209,7 @@ export async function createBackendConfig(
],
target: 'node' as const,
node: {
/* eslint-disable-next-line no-restricted-syntax */
__dirname: true,
__filename: true,
global: true,
+1
View File
@@ -16,4 +16,5 @@
import { findPaths } from '@backstage/cli-common';
/* eslint-disable-next-line no-restricted-syntax */
export const paths = findPaths(__dirname);
@@ -26,13 +26,9 @@ export default {
const containerStyle = { width: '100%', height: '100vh' };
export const Field = () => (
export const MissingAnnotation = () => (
<div style={containerStyle}>
<EmptyState
missing="field"
title="Your plugin is missing an annotation"
description="Add a description here."
/>
<MissingAnnotationEmptyState annotation="backstage.io/example" />
</div>
);
@@ -80,9 +76,3 @@ export const WithAction = () => (
/>
</div>
);
export const MissingAnnotation = () => (
<div style={containerStyle}>
<MissingAnnotationEmptyState annotation="backstage.io/example" />
</div>
);
@@ -17,7 +17,6 @@
import React from 'react';
import { makeStyles, Typography, Grid } from '@material-ui/core';
import { EmptyStateImage } from './EmptyStateImage';
import Background from './assets/Background.svg';
const useStyles = makeStyles(theme => ({
root: {
@@ -30,10 +29,6 @@ const useStyles = makeStyles(theme => ({
imageContainer: {
position: 'relative',
},
backgroundImage: {
position: 'absolute',
width: '100%',
},
}));
type Props = {
@@ -66,11 +61,6 @@ export const EmptyState = ({ title, description, missing, action }: Props) => {
</Grid>
</Grid>
<Grid item xs={12} md={6} className={classes.imageContainer}>
<img
src={Background}
className={classes.backgroundImage}
alt="background"
/>
<EmptyStateImage missing={missing} />
</Grid>
</Grid>
@@ -27,7 +27,7 @@ type Props = {
const useStyles = makeStyles({
generalImg: {
width: '75%',
width: '95%',
zIndex: 2,
position: 'absolute',
left: '50%',
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="641" height="393" fill="none" viewBox="0 0 641 393"><rect width="380.051" height="45.228" x="15.148" y="106.723" fill="#EEE" rx="22.614" transform="rotate(-10.5556 15.1477 106.723)"/><rect width="315.147" height="45.228" x="52.761" y="141.21" fill="#EEE" rx="22.614" transform="rotate(-10.5556 52.761 141.21)"/><rect width="411.631" height="45.228" x="51.057" y="256.093" fill="#EEE" rx="22.614" transform="rotate(-10.5556 51.0568 256.093)"/><rect width="411.033" height="45.228" x="35.578" y="300.474" fill="#EEE" rx="22.614" transform="rotate(-10.5556 35.5782 300.474)"/><rect width="406.8" height="45.228" x="199.649" y="137.293" fill="#EEE" rx="22.614" transform="rotate(-10.5556 199.649 137.293)"/><rect width="402.291" height="45.228" x="186.507" y="181.238" fill="#EEE" rx="22.614" transform="rotate(-10.5556 186.507 181.238)"/><rect width="368.583" height="45.228" x="219.599" y="273.399" fill="#EEE" rx="22.614" transform="rotate(-10.5556 219.599 273.399)"/><rect width="425.836" height="45.228" x="186.526" y="321.058" fill="#EEE" rx="22.614" transform="rotate(-10.5556 186.526 321.058)"/></svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

@@ -1 +1,36 @@
<svg xmlns="http://www.w3.org/2000/svg" width="282" height="186" fill="none" viewBox="0 0 282 186"><g filter="url(#filter0_d)"><path fill="#9E9E9E" d="M254.494 16.3785H23.3728C20.6336 16.3785 18.4131 18.5995 18.4131 21.3392V36.221C18.4131 38.9607 20.6336 41.1816 23.3728 41.1816H254.494C257.233 41.1816 259.454 38.9607 259.454 36.221V21.3392C259.454 18.5995 257.233 16.3785 254.494 16.3785Z"/><mask id="mask0" width="242" height="146" x="18" y="16" mask-type="alpha" maskUnits="userSpaceOnUse"><path fill="#404040" d="M254.494 16.3785H23.3728C20.6336 16.3785 18.4131 18.5995 18.4131 21.3392V156.268C18.4131 159.008 20.6336 161.228 23.3728 161.228H254.494C257.233 161.228 259.454 159.008 259.454 156.268V21.3392C259.454 18.5995 257.233 16.3785 254.494 16.3785Z"/></mask><g mask="url(#mask0)"><path fill="#EEE" d="M259.495 30.2683H18.4131V161.432H259.495V30.2683Z"/><path fill="#D9D9D9" d="M25.3567 25.306C26.4524 25.306 27.3406 24.418 27.3406 23.3226C27.3406 22.2272 26.4524 21.3392 25.3567 21.3392C24.261 21.3392 23.3728 22.2272 23.3728 23.3226C23.3728 24.418 24.261 25.306 25.3567 25.306Z" opacity=".4"/><path fill="#D9D9D9" d="M32.4084 25.306C33.5041 25.306 34.3923 24.418 34.3923 23.3226C34.3923 22.2272 33.5041 21.3392 32.4084 21.3392C31.3127 21.3392 30.4246 22.2272 30.4246 23.3226C30.4246 24.418 31.3127 25.306 32.4084 25.306Z" opacity=".4"/><path fill="#D9D9D9" d="M39.2439 25.306C40.3395 25.306 41.2278 24.418 41.2278 23.3226C41.2278 22.2272 40.3395 21.3392 39.2439 21.3392C38.1482 21.3392 37.26 22.2272 37.26 23.3226C37.26 24.418 38.1482 25.306 39.2439 25.306Z" opacity=".3"/><path fill="#616161" d="M32.5328 29.9937H18.4131V161.432H32.5328V29.9937Z"/><path fill="#D9D9D9" d="M259.495 29.9937H32.533V46.2271H259.495V29.9937Z"/><path fill="#fff" d="M67.3094 50.94H39.5928C38.5819 50.94 37.7625 51.7606 37.7625 52.7729C37.7625 53.7851 38.5819 54.6057 39.5928 54.6057H67.3094C68.3202 54.6057 69.1397 53.7851 69.1397 52.7729C69.1397 51.7606 68.3202 50.94 67.3094 50.94Z"/><path fill="#fff" d="M97.6407 57.224H39.5928C38.5819 57.224 37.7625 58.0445 37.7625 59.0568C37.7625 60.069 38.5819 60.8896 39.5928 60.8896H97.6407C98.6516 60.8896 99.471 60.069 99.471 59.0568C99.471 58.0445 98.6516 57.224 97.6407 57.224Z"/><path fill="#BDBDBD" d="M253.219 50.9401H228.118C227.54 50.9401 227.072 51.409 227.072 51.9874V58.2713C227.072 58.8497 227.54 59.3186 228.118 59.3186H253.219C253.797 59.3186 254.265 58.8497 254.265 58.2713V51.9874C254.265 51.409 253.797 50.9401 253.219 50.9401Z"/><path stroke="#D9D9D9" stroke-dasharray="5 5" stroke-width="2" d="M99.994 66.9117H40.3772C39.3663 66.9117 38.5469 67.7322 38.5469 68.7445V114.303C38.5469 115.315 39.3663 116.136 40.3772 116.136H99.994C101.005 116.136 101.824 115.315 101.824 114.303V68.7445C101.824 67.7322 101.005 66.9117 99.994 66.9117Z"/><path stroke="#D9D9D9" stroke-dasharray="5 5" stroke-width="2" d="M175.822 66.9117H112.545C111.534 66.9117 110.714 67.7322 110.714 68.7445V114.303C110.714 115.315 111.534 116.136 112.545 116.136H175.822C176.833 116.136 177.652 115.315 177.652 114.303V68.7445C177.652 67.7322 176.833 66.9117 175.822 66.9117Z"/><path stroke="#D9D9D9" stroke-dasharray="5 5" stroke-width="2" d="M251.65 66.9117H188.373C187.362 66.9117 186.543 67.7322 186.543 68.7445V114.303C186.543 115.315 187.362 116.136 188.373 116.136H251.65C252.661 116.136 253.481 115.315 253.481 114.303V68.7445C253.481 67.7322 252.661 66.9117 251.65 66.9117Z"/><path stroke="#D9D9D9" stroke-dasharray="5 5" stroke-width="2" d="M139.739 124.514H40.3772C39.3663 124.514 38.5469 125.335 38.5469 126.347V163.527C38.5469 164.539 39.3663 165.36 40.3772 165.36H139.739C140.749 165.36 141.569 164.539 141.569 163.527V126.347C141.569 125.335 140.749 124.514 139.739 124.514Z"/><path stroke="#D9D9D9" stroke-dasharray="5 5" stroke-width="2" d="M251.651 124.514H152.289C151.278 124.514 150.459 125.335 150.459 126.347V163.527C150.459 164.539 151.278 165.36 152.289 165.36H251.651C252.661 165.36 253.481 164.539 253.481 163.527V126.347C253.481 125.335 252.661 124.514 251.651 124.514Z"/></g></g><defs><filter id="filter0_d" width="281.041" height="184.85" x=".413" y=".379" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset dx="2" dy="4"/><feGaussianBlur stdDeviation="10"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/><feBlend in2="BackgroundImageFix" mode="normal" result="effect1_dropShadow"/><feBlend in="SourceGraphic" in2="effect1_dropShadow" mode="normal" result="shape"/></filter></defs></svg>
<svg width="693" height="425" viewBox="0 0 693 425" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M40.4387 110.977C27.1556 113.452 18.3941 126.227 20.8693 139.51C23.3445 152.793 36.1192 161.555 49.4023 159.079L67.7036 155.669C62.6078 161.153 60.0854 168.896 61.562 176.82C64.0372 190.103 76.8118 198.865 90.0949 196.39L220.39 172.11C220.416 172.267 220.444 172.425 220.473 172.582C221.95 180.507 227.092 186.822 233.821 190.102L225.826 191.592C212.542 194.067 203.781 206.841 206.256 220.125C208.731 233.408 221.506 242.169 234.789 239.694L614.544 168.929C627.827 166.454 636.588 153.679 634.113 140.396C632.636 132.472 627.494 126.157 620.765 122.877L633.556 120.493C646.839 118.018 655.601 105.244 653.126 91.9604C650.65 78.6773 637.876 69.9158 624.593 72.3911L396.82 114.835C396.794 114.678 396.766 114.52 396.737 114.363C395.26 106.438 390.118 100.123 383.389 96.8431L405.503 92.7224C418.786 90.2471 427.547 77.4725 425.072 64.1894C422.597 50.9063 409.822 42.1448 396.539 44.62L40.4387 110.977ZM59.718 301.107C57.2428 287.824 66.0043 275.05 79.2874 272.574L468.975 199.959C482.258 197.484 495.032 206.245 497.508 219.528C499.983 232.811 491.221 245.586 477.938 248.061L466.779 250.141C468.082 250.776 469.325 251.524 470.493 252.376L605.53 227.213C618.813 224.738 631.588 233.499 634.063 246.782C635.54 254.707 633.017 262.45 627.922 267.934L630.641 267.427C643.924 264.952 656.699 273.713 659.174 286.996C661.649 300.279 652.888 313.054 639.604 315.529L234.809 390.96C221.526 393.435 208.751 384.674 206.276 371.391C203.801 358.108 212.563 345.333 225.846 342.858L255.404 337.35C254.101 336.715 252.858 335.966 251.69 335.114L71.5053 368.69C58.2222 371.165 45.4476 362.404 42.9723 349.121C40.4971 335.838 49.2586 323.063 62.5417 320.588L73.0655 318.627C66.3367 315.347 61.1946 309.032 59.718 301.107Z" fill="black" fill-opacity="0.05"/>
<g filter="url(#filter0_d)">
<path d="M567.437 70H125.484C120.246 70 116 74.2412 116 79.473V107.892C116 113.124 120.246 117.365 125.484 117.365H567.437C572.675 117.365 576.921 113.124 576.921 107.892V79.473C576.921 74.2412 572.675 70 567.437 70Z" fill="#9E9E9E"/>
<mask id="mask0" mask-type="alpha" maskUnits="userSpaceOnUse" x="116" y="70" width="461" height="277">
<path d="M567.437 70H125.484C120.246 70 116 74.2412 116 79.473V337.138C116 342.37 120.246 346.611 125.484 346.611H567.437C572.675 346.611 576.921 342.37 576.921 337.138V79.473C576.921 74.2412 572.675 70 567.437 70Z" fill="#404040"/>
</mask>
<g mask="url(#mask0)">
<path d="M577 96.5244H116V347H577V96.5244Z" fill="#EEEEEE"/>
<path opacity="0.4" d="M129.278 87.0483C131.373 87.0483 133.071 85.3525 133.071 83.2606C133.071 81.1687 131.373 79.4729 129.278 79.4729C127.182 79.4729 125.484 81.1687 125.484 83.2606C125.484 85.3525 127.182 87.0483 129.278 87.0483Z" fill="#D9D9D9"/>
<path opacity="0.4" d="M142.762 87.0483C144.857 87.0483 146.555 85.3525 146.555 83.2606C146.555 81.1687 144.857 79.4729 142.762 79.4729C140.667 79.4729 138.968 81.1687 138.968 83.2606C138.968 85.3525 140.667 87.0483 142.762 87.0483Z" fill="#D9D9D9"/>
<path opacity="0.3" d="M155.833 87.0483C157.928 87.0483 159.626 85.3525 159.626 83.2606C159.626 81.1687 157.928 79.4729 155.833 79.4729C153.738 79.4729 152.039 81.1687 152.039 83.2606C152.039 85.3525 153.738 87.0483 155.833 87.0483Z" fill="#D9D9D9"/>
<rect x="116" y="96" width="27" height="251" fill="#616161"/>
<rect x="143" y="96" width="434" height="31" fill="#D9D9D9"/>
<rect x="153" y="136" width="60" height="7" rx="3.5" fill="white"/>
<rect x="153" y="148" width="118" height="7" rx="3.5" fill="white"/>
<rect x="515" y="136" width="52" height="16" rx="2" fill="#BDBDBD"/>
<rect x="154.5" y="166.5" width="121" height="94" rx="3.5" stroke="#D9D9D9" stroke-width="3" stroke-dasharray="5 5"/>
<rect x="292.5" y="166.5" width="128" height="94" rx="3.5" stroke="#D9D9D9" stroke-width="3" stroke-dasharray="5 5"/>
<rect x="437.5" y="166.5" width="128" height="94" rx="3.5" stroke="#D9D9D9" stroke-width="3" stroke-dasharray="5 5"/>
<rect x="154.5" y="276.5" width="197" height="78" rx="3.5" stroke="#D9D9D9" stroke-width="3" stroke-dasharray="5 5"/>
<rect x="368.5" y="276.5" width="197" height="78" rx="3.5" stroke="#D9D9D9" stroke-width="3" stroke-dasharray="5 5"/>
</g>
</g>
<defs>
<filter id="filter0_d" x="98" y="54" width="500.921" height="316.611" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
<feOffset dx="2" dy="4"/>
<feGaussianBlur stdDeviation="10"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
</filter>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

@@ -1 +1,42 @@
<svg xmlns="http://www.w3.org/2000/svg" width="259" height="162" fill="none" viewBox="0 0 259 162"><g filter="url(#filter0_d)"><rect width="243" height="25" x="6" y="4" fill="#9E9E9E" rx="5"/><mask id="mask0" width="243" height="146" x="6" y="4" mask-type="alpha" maskUnits="userSpaceOnUse"><rect width="243" height="146" x="6" y="4" fill="#404040" rx="5"/></mask><g mask="url(#mask0)"><rect width="247" height="139" x="6" y="18" fill="#EEE"/><rect width="181" height="4" x="42" y="61" fill="#fff" rx="2"/><rect width="113" height="4" x="42" y="70" fill="#fff" rx="2"/><rect width="50" height="4" x="42" y="43" fill="#fff" rx="2"/><rect width="36" height="4" x="22" y="25" fill="#fff" rx="2"/><rect width="4" height="4" x="11" y="25" fill="#F8F8F8" rx="2"/><rect width="4" height="4" x="11" y="34" fill="#F8F8F8" rx="2"/><rect width="4" height="4" x="11" y="43" fill="#F8F8F8" rx="2"/><rect width="4" height="4" x="11" y="52" fill="#F8F8F8" rx="2"/><rect width="4" height="4" x="11" y="62" fill="#F8F8F8" rx="2"/><rect width="4" height="4" x="11" y="70" fill="#F8F8F8" rx="2"/><rect width="4" height="4" x="11" y="79" fill="#F8F8F8" rx="2"/><rect width="4" height="4" x="11" y="88" fill="#F8F8F8" rx="2"/><rect width="181" height="4" x="42" y="52" fill="#fff" rx="2"/><rect width="35" height="4" x="22" y="88" fill="#fff" rx="2"/><rect width="92" height="4" x="32" y="79" fill="#fff" rx="2"/><rect width="45" height="4" x="32" y="34" fill="#fff" rx="2"/></g><ellipse cx="13" cy="10.999" fill="#D9D9D9" opacity=".4" rx="2" ry="1.999"/><ellipse cx="20.109" cy="10.999" fill="#D9D9D9" opacity=".4" rx="2" ry="1.999"/><ellipse cx="27" cy="10.999" fill="#D9D9D9" opacity=".3" rx="2" ry="1.999"/></g><defs><filter id="filter0_d" width="259" height="162" x="0" y="0" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset dx="2" dy="4"/><feGaussianBlur stdDeviation="4"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/><feBlend in2="BackgroundImageFix" mode="normal" result="effect1_dropShadow"/><feBlend in="SourceGraphic" in2="effect1_dropShadow" mode="normal" result="shape"/></filter></defs></svg>
<svg width="687" height="421" viewBox="0 0 687 421" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M40.0514 109.914C26.8955 112.366 18.2179 125.018 20.6695 138.174C23.121 151.33 35.7733 160.007 48.9292 157.556L67.0549 154.178C62.008 159.609 59.5097 167.278 60.9722 175.127C63.4237 188.283 76.076 196.96 89.2319 194.509L218.279 170.462C218.305 170.617 218.333 170.773 218.362 170.929C219.824 178.778 224.917 185.032 231.582 188.281L223.663 189.757C210.507 192.208 201.83 204.861 204.281 218.016C206.733 231.172 219.385 239.85 232.541 237.398L608.658 167.311C621.814 164.86 630.492 152.208 628.04 139.052C626.578 131.203 621.485 124.949 614.821 121.7L627.489 119.339C640.645 116.888 649.322 104.236 646.871 91.0797C644.419 77.9238 631.767 69.2462 618.611 71.6977L393.02 113.735C392.994 113.579 392.966 113.423 392.937 113.267C391.475 105.419 386.382 99.1643 379.717 95.9156L401.619 91.8343C414.775 89.3828 423.453 76.7305 421.001 63.5746C418.55 50.4187 405.897 41.7411 392.742 44.1927L40.0514 109.914ZM59.146 298.224C56.6945 285.068 65.3721 272.416 78.528 269.964L464.483 198.044C477.639 195.592 490.292 204.27 492.743 217.426C495.195 230.582 486.517 243.234 473.361 245.685L462.309 247.745C463.599 248.374 464.83 249.115 465.987 249.959L599.731 225.037C612.887 222.585 625.539 231.263 627.991 244.419C629.453 252.267 626.955 259.937 621.908 265.368L624.601 264.866C637.757 262.414 650.41 271.092 652.861 284.248C655.313 297.404 646.635 310.056 633.479 312.507L232.561 387.216C219.405 389.667 206.752 380.99 204.301 367.834C201.849 354.678 210.527 342.026 223.683 339.574L252.958 334.119C251.668 333.49 250.437 332.749 249.279 331.905L70.8205 365.159C57.6646 367.611 45.0123 358.933 42.5608 345.777C40.1093 332.621 48.7869 319.969 61.9428 317.518L72.3657 315.575C65.7014 312.327 60.6085 306.072 59.146 298.224Z" fill="black" fill-opacity="0.05"/>
<g filter="url(#filter0_d)">
<path d="M566.795 76.0288H124.842C119.604 76.0288 115.358 80.27 115.358 85.5018V113.921C115.358 119.153 119.604 123.394 124.842 123.394H566.795C572.033 123.394 576.279 119.153 576.279 113.921V85.5018C576.279 80.27 572.033 76.0288 566.795 76.0288Z" fill="#9E9E9E"/>
<mask id="mask0" mask-type="alpha" maskUnits="userSpaceOnUse" x="115" y="76" width="462" height="277">
<path d="M566.795 76.0288H124.842C119.604 76.0288 115.358 80.27 115.358 85.5018V343.167C115.358 348.399 119.604 352.64 124.842 352.64H566.795C572.033 352.64 576.279 348.399 576.279 343.167V85.5018C576.279 80.27 572.033 76.0288 566.795 76.0288Z" fill="#404040"/>
</mask>
<g mask="url(#mask0)">
<path d="M583.866 102.553H115.358V365.902H583.866V102.553Z" fill="#EEEEEE"/>
<path d="M523.169 184.021H187.436C185.341 184.021 183.643 185.717 183.643 187.81C183.643 189.903 185.341 191.599 187.436 191.599H523.169C525.264 191.599 526.962 189.903 526.962 187.81C526.962 185.717 525.264 184.021 523.169 184.021Z" fill="white"/>
<path d="M394.187 201.072H187.436C185.341 201.072 183.643 202.769 183.643 204.861C183.643 206.954 185.341 208.651 187.436 208.651H394.187C396.282 208.651 397.98 206.954 397.98 204.861C397.98 202.769 396.282 201.072 394.187 201.072Z" fill="white"/>
<path d="M274.689 149.918H187.436C185.341 149.918 183.643 151.615 183.643 153.707C183.643 155.8 185.341 157.497 187.436 157.497H274.689C276.784 157.497 278.482 155.8 278.482 153.707C278.482 151.615 276.784 149.918 274.689 149.918Z" fill="white"/>
<path d="M210.198 115.815H149.5C147.405 115.815 145.707 117.512 145.707 119.605C145.707 121.697 147.405 123.394 149.5 123.394H210.198C212.293 123.394 213.991 121.697 213.991 119.605C213.991 117.512 212.293 115.815 210.198 115.815Z" fill="white"/>
<path d="M132.429 119.605C132.429 117.512 130.731 115.815 128.636 115.815C126.54 115.815 124.842 117.512 124.842 119.605C124.842 121.697 126.54 123.394 128.636 123.394C130.731 123.394 132.429 121.697 132.429 119.605Z" fill="#F8F8F8"/>
<path d="M132.429 136.656C132.429 134.563 130.731 132.867 128.636 132.867C126.54 132.867 124.842 134.563 124.842 136.656C124.842 138.749 126.54 140.445 128.636 140.445C130.731 140.445 132.429 138.749 132.429 136.656Z" fill="#F8F8F8"/>
<path d="M132.429 153.707C132.429 151.615 130.731 149.918 128.636 149.918C126.54 149.918 124.842 151.615 124.842 153.707C124.842 155.8 126.54 157.497 128.636 157.497C130.731 157.497 132.429 155.8 132.429 153.707Z" fill="#F8F8F8"/>
<path d="M132.429 170.759C132.429 168.666 130.731 166.969 128.636 166.969C126.54 166.969 124.842 168.666 124.842 170.759C124.842 172.851 126.54 174.548 128.636 174.548C130.731 174.548 132.429 172.851 132.429 170.759Z" fill="#F8F8F8"/>
<path d="M132.429 189.705C132.429 187.612 130.731 185.916 128.636 185.916C126.54 185.916 124.842 187.612 124.842 189.705C124.842 191.797 126.54 193.494 128.636 193.494C130.731 193.494 132.429 191.797 132.429 189.705Z" fill="#F8F8F8"/>
<path d="M132.429 204.861C132.429 202.769 130.731 201.072 128.636 201.072C126.54 201.072 124.842 202.769 124.842 204.861C124.842 206.954 126.54 208.651 128.636 208.651C130.731 208.651 132.429 206.954 132.429 204.861Z" fill="#F8F8F8"/>
<path d="M132.429 221.913C132.429 219.82 130.731 218.124 128.636 218.124C126.54 218.124 124.842 219.82 124.842 221.913C124.842 224.005 126.54 225.702 128.636 225.702C130.731 225.702 132.429 224.005 132.429 221.913Z" fill="#F8F8F8"/>
<path d="M132.429 238.964C132.429 236.872 130.731 235.175 128.636 235.175C126.54 235.175 124.842 236.872 124.842 238.964C124.842 241.057 126.54 242.753 128.636 242.753C130.731 242.753 132.429 241.057 132.429 238.964Z" fill="#F8F8F8"/>
<path d="M523.169 166.969H187.436C185.341 166.969 183.643 168.666 183.643 170.759C183.643 172.851 185.341 174.548 187.436 174.548H523.169C525.264 174.548 526.962 172.851 526.962 170.759C526.962 168.666 525.264 166.969 523.169 166.969Z" fill="white"/>
<path d="M208.301 235.175H149.5C147.405 235.175 145.707 236.872 145.707 238.964C145.707 241.057 147.405 242.753 149.5 242.753H208.301C210.396 242.753 212.094 241.057 212.094 238.964C212.094 236.872 210.396 235.175 208.301 235.175Z" fill="white"/>
<path d="M335.386 218.124H168.468C166.373 218.124 164.675 219.82 164.675 221.913C164.675 224.005 166.373 225.702 168.468 225.702H335.386C337.481 225.702 339.18 224.005 339.18 221.913C339.18 219.82 337.481 218.124 335.386 218.124Z" fill="white"/>
<path d="M246.237 132.867H168.468C166.373 132.867 164.675 134.563 164.675 136.656C164.675 138.749 166.373 140.445 168.468 140.445H246.237C248.332 140.445 250.03 138.749 250.03 136.656C250.03 134.563 248.332 132.867 246.237 132.867Z" fill="white"/>
<path opacity="0.4" d="M128.636 93.0771C130.731 93.0771 132.429 91.3813 132.429 89.2894C132.429 87.1975 130.731 85.5017 128.636 85.5017C126.54 85.5017 124.842 87.1975 124.842 89.2894C124.842 91.3813 126.54 93.0771 128.636 93.0771Z" fill="#D9D9D9"/>
<path opacity="0.4" d="M142.12 93.0771C144.215 93.0771 145.913 91.3813 145.913 89.2894C145.913 87.1975 144.215 85.5017 142.12 85.5017C140.025 85.5017 138.326 87.1975 138.326 89.2894C138.326 91.3813 140.025 93.0771 142.12 93.0771Z" fill="#D9D9D9"/>
<path opacity="0.3" d="M155.191 93.0771C157.286 93.0771 158.984 91.3813 158.984 89.2894C158.984 87.1975 157.286 85.5017 155.191 85.5017C153.096 85.5017 151.397 87.1975 151.397 89.2894C151.397 91.3813 153.096 93.0771 155.191 93.0771Z" fill="#D9D9D9"/>
</g>
</g>
<defs>
<filter id="filter0_d" x="97.3579" y="60.0288" width="500.921" height="316.611" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
<feOffset dx="2" dy="4"/>
<feGaussianBlur stdDeviation="10"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
</filter>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="267" height="172" fill="none" viewBox="0 0 267 172"><g filter="url(#filter0_d)"><rect width="139" height="104.906" x="10" y="50.165" fill="#EEE" rx="5"/></g><mask id="mask0" width="121" height="98" x="19" y="58" mask-type="alpha" maskUnits="userSpaceOnUse"><rect width="9.179" height="70.156" x="19.835" y="85.571" fill="#fff" rx="4.59"/><rect width="9.179" height="78.679" x="38.194" y="77.047" fill="#fff" rx="4.59"/><rect width="9.179" height="97.693" x="56.552" y="58.033" fill="#fff" rx="4.59"/><rect width="9.179" height="81.957" x="74.91" y="73.769" fill="#fff" rx="4.59"/><rect width="9.179" height="60.321" x="93.269" y="95.406" fill="#fff" rx="4.59"/><rect width="9.179" height="74.09" x="111.627" y="81.637" fill="#fff" rx="4.59"/><rect width="9.179" height="93.104" x="129.986" y="62.623" fill="#fff" rx="4.59"/></mask><g mask="url(#mask0)"><rect width="139" height="100.316" x="10.656" y="50.165" fill="#C4C4C4"/></g><g filter="url(#filter1_d)"><rect width="144" height="108.679" x="109" y="8" fill="#EEE" rx="5"/></g><path fill="#D9D9D9" d="M173.85 62.1192C144.607 37.3215 129.993 65.1991 120.077 80.5984V106.585H241.923V25.7384C208.172 24.5834 212.569 94.9538 173.85 62.1192Z"/><path stroke="#9E9E9E" stroke-linecap="round" stroke-linejoin="round" d="M120.077 80.5984C129.993 65.1991 144.607 37.3215 173.85 62.1192C212.569 94.9539 208.172 24.5834 241.923 25.7384"/><defs><filter id="filter0_d" width="163" height="128.906" x="0" y="42.165" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset dx="2" dy="4"/><feGaussianBlur stdDeviation="6"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/><feBlend in2="BackgroundImageFix" mode="normal" result="effect1_dropShadow"/><feBlend in="SourceGraphic" in2="effect1_dropShadow" mode="normal" result="shape"/></filter><filter id="filter1_d" width="168" height="132.679" x="99" y="0" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset dx="2" dy="4"/><feGaussianBlur stdDeviation="6"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/><feBlend in2="BackgroundImageFix" mode="normal" result="effect1_dropShadow"/><feBlend in="SourceGraphic" in2="effect1_dropShadow" mode="normal" result="shape"/></filter></defs></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="267" height="172" fill="none" viewBox="0 0 267 172"><g filter="url(#filter0_d)"><rect width="139" height="104.906" x="10" y="50.165" fill="#EEE" rx="5"/></g><mask id="mask0" width="121" height="98" x="19" y="58" mask-type="alpha" maskUnits="userSpaceOnUse"><rect width="9.179" height="70.156" x="19.835" y="85.571" fill="#fff" rx="4.59"/><rect width="9.179" height="78.679" x="38.194" y="77.047" fill="#fff" rx="4.59"/><rect width="9.179" height="97.693" x="56.552" y="58.033" fill="#fff" rx="4.59"/><rect width="9.179" height="81.957" x="74.91" y="73.769" fill="#fff" rx="4.59"/><rect width="9.179" height="60.321" x="93.269" y="95.406" fill="#fff" rx="4.59"/><rect width="9.179" height="74.09" x="111.627" y="81.637" fill="#fff" rx="4.59"/><rect width="9.179" height="93.104" x="129.986" y="62.623" fill="#fff" rx="4.59"/></mask><g mask="url(#mask0)"><rect width="139" height="100.316" x="10.656" y="50.165" fill="#C4C4C4"/></g><g filter="url(#filter1_d)"><rect width="144" height="108.679" x="109" y="8" fill="#EEE" rx="5"/></g><path fill="#D9D9D9" d="M173.85 62.1192C144.607 37.3215 129.993 65.1991 120.077 80.5984V106.585H241.923V25.7384C208.172 24.5834 212.569 94.9538 173.85 62.1192Z"/><path stroke="#9E9E9E" stroke-linecap="round" stroke-linejoin="round" d="M120.077 80.5984C129.993 65.1991 144.607 37.3215 173.85 62.1192C212.569 94.9539 208.172 24.5834 241.923 25.7384"/><defs><filter id="filter0_d" width="163" height="128.906" x="0" y="42.165" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset dx="2" dy="4"/><feGaussianBlur stdDeviation="6"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/><feBlend in2="BackgroundImageFix" mode="normal" result="effect1_dropShadow"/><feBlend in="SourceGraphic" in2="effect1_dropShadow" mode="normal" result="shape"/></filter><filter id="filter1_d" width="168" height="132.679" x="99" y="0" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset dx="2" dy="4"/><feGaussianBlur stdDeviation="6"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/><feBlend in2="BackgroundImageFix" mode="normal" result="effect1_dropShadow"/><feBlend in="SourceGraphic" in2="effect1_dropShadow" mode="normal" result="shape"/></filter></defs></svg>

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

@@ -76,19 +76,6 @@ const VARIANT_STYLES = {
height: 'calc(100% - 10px)', // for pages without content header
marginBottom: '10px',
},
contentheader: {
height: 'calc(100% - 40px)', // for pages with content header
},
contentheadertabs: {
height: 'calc(100% - 97px)', // for pages with content header and tabs (Tingle)
},
noShrink: {
flexShrink: 0,
},
minheight300: {
minHeight: 300,
overflow: 'initial',
},
},
cardContent: {
fullHeight: {
@@ -97,10 +84,6 @@ const VARIANT_STYLES = {
height100: {
flex: 1,
},
contentRow: {
display: 'flex',
flexDirection: 'row',
},
},
};
@@ -120,10 +103,6 @@ const VARIANT_STYLES = {
* Display the card full height suitable for DataGrid:
*
* <InfoCard variant="height100">...</InfoCard>
*
* Variants can be combined in a whitespace delimited list like so:
*
* <InfoCard variant="noShrink">...</InfoCard>
*/
type Props = {
title?: ReactNode;
@@ -15,6 +15,7 @@
* limitations under the License.
*/
/* eslint-disable no-restricted-syntax */
const path = require('path');
// Figure out whether we're running inside the backstage repo or as an installed dependency
+1 -1
View File
@@ -74,7 +74,6 @@ async function buildApp(appDir: string) {
await runCmd('yarn install');
await runCmd('yarn tsc');
await runCmd('yarn build');
}
async function moveApp(tempDir: string, destination: string, id: string) {
@@ -88,6 +87,7 @@ async function moveApp(tempDir: string, destination: string, id: string) {
}
export default async (cmd: Command): Promise<void> => {
/* eslint-disable-next-line no-restricted-syntax */
const paths = findPaths(__dirname);
const questions: Question[] = [
+1
View File
@@ -15,6 +15,7 @@
* limitations under the License.
*/
/* eslint-disable no-restricted-syntax */
const path = require('path');
// Figure out whether we're running inside the backstage repo or as an installed dependency
+1
View File
@@ -37,6 +37,7 @@ export async function generate(
);
}
/* eslint-disable-next-line no-restricted-syntax */
const rootDir = resolvePath(__dirname, '../../..');
const srcDir = resolvePath(rootDir, 'packages', 'core-api', 'src');
const targetDir = resolvePath(targetPath);
+1
View File
@@ -21,6 +21,7 @@ import fs from 'fs-extra';
import { generate } from './generate';
const main = (argv: string[]) => {
/* eslint-disable-next-line no-restricted-syntax */
const pkgJson = fs.readJsonSync(resolvePath(__dirname, '../package.json'));
program.name('docgen').version(pkgJson.version);
+1
View File
@@ -33,6 +33,7 @@ import {
import pgtools from 'pgtools';
import { findPaths } from '@backstage/cli-common';
/* eslint-disable-next-line no-restricted-syntax */
const paths = findPaths(__dirname);
const templatePackagePaths = [
+1
View File
@@ -16,6 +16,7 @@
require('ts-node').register({
transpileOnly: true,
/* eslint-disable-next-line no-restricted-syntax */
project: require('path').resolve(__dirname, '../../../tsconfig.json'),
compilerOptions: {
module: 'CommonJS',
+1
View File
@@ -14,6 +14,7 @@ module.exports = {
'storybook-dark-mode/register',
],
webpackFinal: async config => {
/* eslint-disable-next-line no-restricted-syntax */
const coreSrc = path.resolve(__dirname, '../../core/src');
// Mirror config in packages/cli/src/lib/bundler
+1
View File
@@ -15,6 +15,7 @@
* limitations under the License.
*/
/* eslint-disable no-restricted-syntax */
const path = require('path');
// Figure out whether we're running inside the backstage repo or as an installed dependency
+1
View File
@@ -101,6 +101,7 @@ const main = (argv: string[]) => {
// Local Backstage Preview
const techdocsPreviewBundlePath = path.join(
/* eslint-disable-next-line no-restricted-syntax */
__dirname,
'..',
'dist',
@@ -25,6 +25,7 @@ import { createRouter } from './router';
jest.mock('../lib/config', () => ({ injectEnvConfig: jest.fn() }));
global.__non_webpack_require__ = {
/* eslint-disable-next-line no-restricted-syntax */
resolve: () => resolvePath(__dirname, '__fixtures__/app-dir/package.json'),
};
@@ -0,0 +1,37 @@
/*
* 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.
*/
// @ts-check
/**
* @param {import('knex')} knex
*/
exports.up = async function up(knex) {
await knex.schema.alterTable('entities_search', table => {
table.index(['key'], 'entities_search_key');
table.index(['value'], 'entities_search_value');
});
};
/**
* @param {import('knex')} knex
*/
exports.down = async function down(knex) {
await knex.schema.alterTable('entities_search', table => {
table.dropIndex('', 'entities_search_key');
table.dropIndex('', 'entities_search_value');
});
};
+1
View File
@@ -37,6 +37,7 @@
"lodash": "^4.17.15",
"morgan": "^1.10.0",
"node-fetch": "^2.6.0",
"p-limit": "^3.0.2",
"sqlite3": "^5.0.0",
"uuid": "^8.0.0",
"winston": "^3.2.1",
@@ -1,160 +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 { Entity, ENTITY_DEFAULT_NAMESPACE } from '@backstage/catalog-model';
import { Logger } from 'winston';
import { CoalescedEntitiesCatalog } from './CoalescedEntitiesCatalog';
import { EntitiesCatalog } from './types';
describe('CoalescedEntitiesCatalog', () => {
const e1: Entity = {
apiVersion: 'a',
kind: 'k',
metadata: { name: 'n1' },
};
const e2: Entity = {
apiVersion: 'a',
kind: 'k',
metadata: { name: 'n2' },
};
const c1: jest.Mocked<EntitiesCatalog> = {
entities: jest.fn(),
entityByUid: jest.fn(),
entityByName: jest.fn(),
addOrUpdateEntity: jest.fn(),
removeEntityByUid: jest.fn(),
};
const c2: jest.Mocked<EntitiesCatalog> = {
entities: jest.fn(),
entityByUid: jest.fn(),
entityByName: jest.fn(),
addOrUpdateEntity: jest.fn(),
removeEntityByUid: jest.fn(),
};
const mockLogger = {
warn: jest.fn(),
};
const logger = (mockLogger as unknown) as Logger;
beforeEach(() => {
jest.resetAllMocks();
});
describe('entities', () => {
it('flattens results from multiple sources', async () => {
c1.entities.mockResolvedValueOnce([e1]);
c2.entities.mockResolvedValueOnce([e2]);
const catalog = new CoalescedEntitiesCatalog([c1, c2], logger);
await expect(catalog.entities()).resolves.toEqual(
expect.arrayContaining([e1, e2]),
);
expect(c1.entities).toBeCalledTimes(1);
expect(c2.entities).toBeCalledTimes(1);
});
it('logs an error if any source throws', async () => {
c1.entities.mockResolvedValueOnce([e1]);
c2.entities.mockRejectedValueOnce(new Error('boo'));
const catalog = new CoalescedEntitiesCatalog([c1, c2], logger);
await expect(catalog.entities()).resolves.toEqual([e1]);
expect(c1.entities).toBeCalledTimes(1);
expect(c2.entities).toBeCalledTimes(1);
expect(mockLogger.warn).toBeCalledWith(expect.stringMatching(/boo/));
});
});
describe('entityByUid', () => {
it('returns the first non-undefined result', async () => {
c1.entityByUid.mockResolvedValueOnce(undefined);
c2.entityByUid.mockResolvedValueOnce(e2);
const catalog = new CoalescedEntitiesCatalog([c1, c2], logger);
await expect(catalog.entityByUid('e2')).resolves.toBe(e2);
expect(c1.entityByUid).toBeCalledTimes(1);
expect(c2.entityByUid).toBeCalledTimes(1);
});
it('returns undefined if all results were undefined', async () => {
c1.entityByUid.mockResolvedValueOnce(undefined);
c2.entityByUid.mockResolvedValueOnce(undefined);
const catalog = new CoalescedEntitiesCatalog([c1, c2], logger);
await expect(catalog.entityByUid('e2')).resolves.toBeUndefined();
expect(c1.entityByUid).toBeCalledTimes(1);
expect(c2.entityByUid).toBeCalledTimes(1);
});
it('logs an error if any source throws', async () => {
c1.entityByUid.mockResolvedValueOnce(e1);
c2.entityByUid.mockRejectedValueOnce(new Error('boo'));
const catalog = new CoalescedEntitiesCatalog([c1, c2], logger);
await expect(catalog.entityByUid('e2')).resolves.toBe(e1);
expect(c1.entityByUid).toBeCalledTimes(1);
expect(c2.entityByUid).toBeCalledTimes(1);
expect(mockLogger.warn).toBeCalledWith(expect.stringMatching(/boo/));
});
});
describe('entityByName', () => {
it('returns the first non-undefined result', async () => {
c1.entityByName.mockResolvedValueOnce(undefined);
c2.entityByName.mockResolvedValueOnce(e2);
const catalog = new CoalescedEntitiesCatalog([c1, c2], logger);
await expect(
catalog.entityByName({
kind: 'k',
namespace: ENTITY_DEFAULT_NAMESPACE,
name: 'n2',
}),
).resolves.toBe(e2);
expect(c1.entityByName).toBeCalledTimes(1);
expect(c2.entityByName).toBeCalledTimes(1);
});
it('returns undefined if all results were undefined', async () => {
c1.entityByName.mockResolvedValueOnce(undefined);
c2.entityByName.mockResolvedValueOnce(undefined);
const catalog = new CoalescedEntitiesCatalog([c1, c2], logger);
await expect(
catalog.entityByName({
kind: 'k',
namespace: ENTITY_DEFAULT_NAMESPACE,
name: 'n2',
}),
).resolves.toBeUndefined();
expect(c1.entityByName).toBeCalledTimes(1);
expect(c2.entityByName).toBeCalledTimes(1);
});
it('logs an error if any source throws', async () => {
c1.entityByName.mockResolvedValueOnce(e1);
c2.entityByName.mockRejectedValueOnce(new Error('boo'));
const catalog = new CoalescedEntitiesCatalog([c1, c2], logger);
await expect(
catalog.entityByName({
kind: 'k',
namespace: ENTITY_DEFAULT_NAMESPACE,
name: 'n2',
}),
).resolves.toBe(e1);
expect(c1.entityByName).toBeCalledTimes(1);
expect(c2.entityByName).toBeCalledTimes(1);
expect(mockLogger.warn).toBeCalledWith(expect.stringMatching(/boo/));
});
});
});
@@ -1,96 +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 { Entity, EntityName } from '@backstage/catalog-model';
import { Logger } from 'winston';
import { EntityFilters } from '../database';
import { EntitiesCatalog } from './types';
/**
* A simple coalescing catalog wrapper, that acts as a front for collecting
* catalog data from multiple sources.
*
* One possible usage could be to have this as a front to both a
* DatabaseEntitiesCatalog that holds Component kinds, and another company-
* specific catalog that is a thin wrapper on top of LDAP that supplies Group
* and User entities. That way you'll get a coherent view of two very different
* entity sources.
*
* This is mainly meant as a functional example, and you may want to provide
* your own more specialized collector if you have this distinct need. This
* one does not support adding/updating entities through the API for example.
* A more competent implementation may direct the writes to different catalogs
* based on entity kind or similar.
*/
export class CoalescedEntitiesCatalog implements EntitiesCatalog {
private inner: EntitiesCatalog[];
private logger: Logger;
constructor(inner: EntitiesCatalog[], logger: Logger) {
this.inner = inner;
this.logger = logger;
}
async entities(filters?: EntityFilters): Promise<Entity[]> {
const ops = this.inner.map(async catalog => {
try {
return await catalog.entities(filters);
} catch (e) {
this.logger.warn(`Inner entities call failed, ${e}`);
return [];
}
});
const results = await Promise.all(ops);
return results.flat();
}
async entityByUid(uid: string): Promise<Entity | undefined> {
const ops = this.inner.map(async catalog => {
try {
return await catalog.entityByUid(uid);
} catch (e) {
this.logger.warn(`Inner entityByUid call failed, ${e}`);
return undefined;
}
});
const results = await Promise.all(ops);
return results.find(Boolean);
}
async entityByName(name: EntityName): Promise<Entity | undefined> {
const ops = this.inner.map(async catalog => {
try {
return await catalog.entityByName(name);
} catch (e) {
this.logger.warn(`Inner entityByName call failed, ${e}`);
return undefined;
}
});
const results = await Promise.all(ops);
return results.find(Boolean);
}
addOrUpdateEntity(): Promise<Entity> {
throw new Error('Method not implemented.');
}
removeEntityByUid(): Promise<void> {
throw new Error('Method not implemented.');
}
}
@@ -24,12 +24,12 @@ describe('DatabaseEntitiesCatalog', () => {
beforeAll(() => {
db = {
transaction: jest.fn(),
addEntity: jest.fn(),
addEntities: jest.fn(),
updateEntity: jest.fn(),
entities: jest.fn(),
entityByName: jest.fn(),
entityByUid: jest.fn(),
removeEntity: jest.fn(),
removeEntityByUid: jest.fn(),
addLocation: jest.fn(),
removeLocation: jest.fn(),
location: jest.fn(),
@@ -56,7 +56,7 @@ describe('DatabaseEntitiesCatalog', () => {
};
db.entities.mockResolvedValue([]);
db.addEntity.mockResolvedValue({ entity });
db.addEntities.mockResolvedValue([{ entity }]);
const catalog = new DatabaseEntitiesCatalog(db);
const result = await catalog.addOrUpdateEntity(entity);
@@ -67,7 +67,7 @@ describe('DatabaseEntitiesCatalog', () => {
namespace: 'd',
name: 'c',
});
expect(db.addEntity).toHaveBeenCalledTimes(1);
expect(db.addEntities).toHaveBeenCalledTimes(1);
expect(result).toBe(entity);
});
@@ -72,13 +72,25 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog {
existing.entity.metadata.generation,
);
} else {
response = await this.database.addEntity(tx, { locationId, entity });
const added = await this.database.addEntities(tx, [
{ locationId, entity },
]);
response = added[0];
}
return response.entity;
});
}
async addEntities(entities: Entity[], locationId?: string): Promise<void> {
await this.database.transaction(async tx => {
await this.database.addEntities(
tx,
entities.map(entity => ({ locationId, entity })),
);
});
}
async removeEntityByUid(uid: string): Promise<void> {
return await this.database.transaction(async tx => {
const entityResponse = await this.database.entityByUid(tx, uid);
@@ -96,7 +108,10 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog {
])
: [entityResponse];
for (const dbResponse of colocatedEntities) {
await this.database.removeEntity(tx, dbResponse?.entity.metadata.uid!);
await this.database.removeEntityByUid(
tx,
dbResponse?.entity.metadata.uid!,
);
}
if (entityResponse.locationId) {
@@ -1,56 +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 { Entity, EntityName, getEntityName } from '@backstage/catalog-model';
import lodash from 'lodash';
import type { EntitiesCatalog } from './types';
export class StaticEntitiesCatalog implements EntitiesCatalog {
private _entities: Entity[];
constructor(entities: Entity[]) {
this._entities = entities;
}
async entities(): Promise<Entity[]> {
return lodash.cloneDeep(this._entities);
}
async entityByUid(uid: string): Promise<Entity | undefined> {
const item = this._entities.find(e => uid === e.metadata.uid);
return item ? lodash.cloneDeep(item) : undefined;
}
async entityByName(name: EntityName): Promise<Entity | undefined> {
const item = this._entities.find(e => {
const candidate = getEntityName(e);
return (
name.kind.toLowerCase() === candidate.kind.toLowerCase() &&
name.namespace.toLowerCase() === candidate.namespace.toLowerCase() &&
name.name.toLowerCase() === candidate.name.toLowerCase()
);
});
return item ? lodash.cloneDeep(item) : undefined;
}
async addOrUpdateEntity(): Promise<Entity> {
throw new Error('Not supported');
}
async removeEntityByUid(): Promise<void> {
throw new Error('Not supported');
}
}
@@ -16,5 +16,4 @@
export { DatabaseEntitiesCatalog } from './DatabaseEntitiesCatalog';
export { DatabaseLocationsCatalog } from './DatabaseLocationsCatalog';
export { StaticEntitiesCatalog } from './StaticEntitiesCatalog';
export type { EntitiesCatalog, LocationsCatalog } from './types';
@@ -26,6 +26,7 @@ export type EntitiesCatalog = {
entityByUid(uid: string): Promise<Entity | undefined>;
entityByName(name: EntityName): Promise<Entity | undefined>;
addOrUpdateEntity(entity: Entity, locationId?: string): Promise<Entity>;
addEntities(entities: Entity[], locationId?: string): Promise<void>;
removeEntityByUid(uid: string): Promise<void>;
};
@@ -17,12 +17,12 @@
import { ConflictError } from '@backstage/backend-common';
import type { Entity, Location } from '@backstage/catalog-model';
import { DatabaseManager } from './DatabaseManager';
import { Database, DatabaseLocationUpdateLogStatus } from './types';
import type {
DbEntityRequest,
DbEntityResponse,
DbLocationsRowWithStatus,
} from './types';
import { Database, DatabaseLocationUpdateLogStatus } from './types';
const bootstrapLocation = {
id: expect.any(String),
@@ -135,36 +135,99 @@ describe('CommonDatabase', () => {
await expect(db.location(location.id)).rejects.toThrow(/Found no location/);
});
describe('addEntity', () => {
it('happy path: adds entity to empty database', async () => {
const added = await db.transaction(tx => db.addEntity(tx, entityRequest));
expect(added).toStrictEqual(entityResponse);
expect(added.entity.metadata.generation).toBe(1);
describe('addEntities', () => {
it('happy path: adds entities to empty database', async () => {
const result = await db.transaction(tx =>
db.addEntities(tx, [entityRequest]),
);
expect(result).toEqual([entityResponse]);
});
it('rejects adding the same-named entity twice', async () => {
await db.transaction(tx => db.addEntity(tx, entityRequest));
const req: DbEntityRequest[] = [
{
entity: {
apiVersion: 'av1',
kind: 'k1',
metadata: { name: 'n1', namespace: 'ns1' },
},
},
{
entity: {
apiVersion: 'av1',
kind: 'k1',
metadata: { name: 'n1', namespace: 'ns1' },
},
},
];
await expect(
db.transaction(tx => db.addEntity(tx, entityRequest)),
db.transaction(tx => db.addEntities(tx, req)),
).rejects.toThrow(ConflictError);
});
it('rejects adding the almost-same-namespace entity twice', async () => {
entityRequest.entity.metadata.namespace = undefined;
await db.transaction(tx => db.addEntity(tx, entityRequest));
entityRequest.entity.metadata.namespace = '';
const req: DbEntityRequest[] = [
{
entity: {
apiVersion: 'av1',
kind: 'k1',
metadata: { name: 'n1', namespace: 'ns1' },
},
},
{
entity: {
apiVersion: 'av1',
kind: 'k1',
metadata: { name: 'n1', namespace: 'nS1' },
},
},
];
await expect(
db.transaction(tx => db.addEntity(tx, entityRequest)),
db.transaction(tx => db.addEntities(tx, req)),
).rejects.toThrow(ConflictError);
});
it('accepts adding the same-named entity twice if on different namespaces', async () => {
entityRequest.entity.metadata.namespace = 'namespace1';
await db.transaction(tx => db.addEntity(tx, entityRequest));
entityRequest.entity.metadata.namespace = 'namespace2';
const req: DbEntityRequest[] = [
{
entity: {
apiVersion: 'av1',
kind: 'k1',
metadata: { name: 'n1', namespace: 'ns1' },
},
},
{
entity: {
apiVersion: 'av1',
kind: 'k1',
metadata: { name: 'n1', namespace: 'ns2' },
},
},
];
await expect(
db.transaction(tx => db.addEntity(tx, entityRequest)),
).resolves.toBeDefined();
db.transaction(tx => db.addEntities(tx, req)),
).resolves.toEqual([
{
entity: expect.objectContaining({
metadata: expect.objectContaining({
namespace: 'ns1',
uid: expect.any(String),
etag: expect.any(String),
generation: expect.any(Number),
}),
}),
},
{
entity: expect.objectContaining({
metadata: expect.objectContaining({
namespace: 'ns2',
uid: expect.any(String),
etag: expect.any(String),
generation: expect.any(Number),
}),
}),
},
]);
});
});
@@ -216,7 +279,9 @@ describe('CommonDatabase', () => {
describe('updateEntity', () => {
it('can read and no-op-update an entity', async () => {
const added = await db.transaction(tx => db.addEntity(tx, entityRequest));
const [added] = await db.transaction(tx =>
db.addEntities(tx, [entityRequest]),
);
const updated = await db.transaction(tx =>
db.updateEntity(tx, { entity: added.entity }),
);
@@ -233,7 +298,9 @@ describe('CommonDatabase', () => {
});
it('can update name if uid matches', async () => {
const added = await db.transaction(tx => db.addEntity(tx, entityRequest));
const [added] = await db.transaction(tx =>
db.addEntities(tx, [entityRequest]),
);
added.entity.metadata.name! = 'new!';
const updated = await db.transaction(tx =>
db.updateEntity(tx, { entity: added.entity }),
@@ -242,7 +309,9 @@ describe('CommonDatabase', () => {
});
it('fails to update an entity if etag does not match', async () => {
const added = await db.transaction(tx => db.addEntity(tx, entityRequest));
const [added] = await db.transaction(tx =>
db.addEntities(tx, [entityRequest]),
);
await expect(
db.transaction(tx =>
db.updateEntity(tx, { entity: added.entity }, 'garbage'),
@@ -251,7 +320,9 @@ describe('CommonDatabase', () => {
});
it('fails to update an entity if generation does not match', async () => {
const added = await db.transaction(tx => db.addEntity(tx, entityRequest));
const [added] = await db.transaction(tx =>
db.addEntities(tx, [entityRequest]),
);
await expect(
db.transaction(tx =>
db.updateEntity(tx, { entity: added.entity }, undefined, 1e20),
@@ -274,8 +345,7 @@ describe('CommonDatabase', () => {
spec: { c: null },
};
await db.transaction(async tx => {
await db.addEntity(tx, { entity: e1 });
await db.addEntity(tx, { entity: e2 });
await db.addEntities(tx, [{ entity: e1 }, { entity: e2 }]);
});
const result = await db.transaction(async tx => db.entities(tx, []));
expect(result.length).toEqual(2);
@@ -311,9 +381,10 @@ describe('CommonDatabase', () => {
];
await db.transaction(async tx => {
for (const entity of entities) {
await db.addEntity(tx, { entity });
}
await db.addEntities(
tx,
entities.map(entity => ({ entity })),
);
});
await expect(
@@ -349,9 +420,10 @@ describe('CommonDatabase', () => {
];
await db.transaction(async tx => {
for (const entity of entities) {
await db.addEntity(tx, { entity });
}
await db.addEntities(
tx,
entities.map(entity => ({ entity })),
);
});
const rows = await db.transaction(async tx =>
@@ -398,9 +470,10 @@ describe('CommonDatabase', () => {
];
await db.transaction(async tx => {
for (const entity of entities) {
await db.addEntity(tx, { entity });
}
await db.addEntities(
tx,
entities.map(entity => ({ entity })),
);
});
const rows = await db.transaction(async tx =>
@@ -446,9 +519,10 @@ describe('CommonDatabase', () => {
];
await db.transaction(async tx => {
for (const entity of entities) {
await db.addEntity(tx, { entity });
}
await db.addEntities(
tx,
entities.map(entity => ({ entity })),
);
});
const e1 = await db.transaction(async tx =>
@@ -45,6 +45,12 @@ import type {
EntityFilters,
} from './types';
// The number of items that are sent per batch to the database layer, when
// doing .batchInsert calls to knex. This needs to be low enough to not cause
// errors in the underlying engine due to exceeding query limits, but large
// enough to get the speed benefits.
const BATCH_SIZE = 50;
/**
* The core database implementation.
*/
@@ -100,6 +106,52 @@ export class CommonDatabase implements Database {
return { locationId: request.locationId, entity: newEntity };
}
async addEntities(
txOpaque: unknown,
request: DbEntityRequest[],
): Promise<DbEntityResponse[]> {
const tx = txOpaque as Knex.Transaction<any, any>;
const result: DbEntityResponse[] = [];
const entityRows: DbEntitiesRow[] = [];
const searchRows: DbEntitiesSearchRow[] = [];
for (const { entity, locationId } of request) {
if (entity.metadata.uid !== undefined) {
throw new InputError('May not specify uid for new entities');
} else if (entity.metadata.etag !== undefined) {
throw new InputError('May not specify etag for new entities');
} else if (entity.metadata.generation !== undefined) {
throw new InputError('May not specify generation for new entities');
}
const newEntity = {
...entity,
metadata: {
...entity.metadata,
uid: generateEntityUid(),
etag: generateEntityEtag(),
generation: 1,
},
};
result.push({ entity: newEntity, locationId });
entityRows.push(this.toEntityRow(locationId, newEntity));
searchRows.push(...buildEntitySearch(newEntity.metadata.uid, newEntity));
}
await tx.batchInsert('entities', entityRows, BATCH_SIZE);
await tx<DbEntitiesSearchRow>('entities_search')
.whereIn(
'entity_id',
entityRows.map(r => r.id),
)
.del();
await tx.batchInsert('entities_search', searchRows, BATCH_SIZE);
return result;
}
async updateEntity(
txOpaque: unknown,
request: DbEntityRequest,
@@ -165,10 +217,10 @@ export class CommonDatabase implements Database {
): Promise<DbEntityResponse[]> {
const tx = txOpaque as Knex.Transaction<any, any>;
let builder = tx<DbEntitiesRow>('entities');
for (const [indexU, filter] of (filters ?? []).entries()) {
const index = Number(indexU);
const key = filter.key.toLowerCase().replace(/\*/g, '%');
let entitiesQuery = tx<DbEntitiesRow>('entities');
for (const filter of filters || []) {
const key = filter.key.toLowerCase().replace(/[*]/g, '%');
const keyOp = filter.key.includes('*') ? 'like' : '=';
let matchNulls = false;
@@ -179,36 +231,54 @@ export class CommonDatabase implements Database {
if (!value) {
matchNulls = true;
} else if (value.includes('*')) {
matchLike.push(value.toLowerCase().replace(/\*/g, '%'));
matchLike.push(value.toLowerCase().replace(/[*]/g, '%'));
} else {
matchIn.push(value.toLowerCase());
}
}
builder = builder
.leftOuterJoin(`entities_search as t${index}`, function joins() {
this.on('entities.id', '=', `t${index}.entity_id`);
this.andOn(`t${index}.key`, keyOp, tx.raw('?', [key]));
})
.where(function rules() {
if (matchIn.length) {
this.orWhereIn(`t${index}.value`, matchIn);
}
if (matchLike.length) {
for (const x of matchLike) {
this.orWhere(`t${index}.value`, 'like', tx.raw('?', [x]));
// NOTE(freben): This used to be a set of OUTER JOIN, which may seem to
// make a lot of sense. However, it had abysmal performance on sqlite
// when datasets grew large, so we're using IN instead.
const matchQuery = tx<DbEntitiesSearchRow>('entities_search')
.select('entity_id')
.where(function keyFilter() {
this.andWhere('key', keyOp, key);
this.andWhere(function valueFilter() {
if (matchIn.length === 1) {
this.orWhere({ value: matchIn[0] });
} else if (matchIn.length > 1) {
this.orWhereIn('value', matchIn);
}
}
if (matchNulls) {
this.orWhereNull(`t${index}.value`);
}
if (matchLike.length) {
for (const x of matchLike) {
this.orWhere('value', 'like', tx.raw('?', [x]));
}
}
if (matchNulls) {
// Match explicit nulls, and then handle absence separately below
this.orWhereNull('value');
}
});
});
// Handle absence as nulls as well
entitiesQuery = entitiesQuery.andWhere(function match() {
this.whereIn('id', matchQuery);
if (matchNulls) {
this.orWhereNotIn(
'id',
tx<DbEntitiesSearchRow>('entities_search')
.select('entity_id')
.where('key', keyOp, key),
);
}
});
}
const rows = await builder
const rows = await entitiesQuery
.select('entities.*')
.orderBy('full_name', 'asc')
.groupBy('id');
.orderBy('full_name', 'asc');
return rows.map(row => this.toEntityResponse(row));
}
@@ -249,7 +319,7 @@ export class CommonDatabase implements Database {
return this.toEntityResponse(rows[0]);
}
async removeEntity(txOpaque: unknown, uid: string): Promise<void> {
async removeEntityByUid(txOpaque: unknown, uid: string): Promise<void> {
const tx = txOpaque as Knex.Transaction<any, any>;
const result = await tx<DbEntitiesRow>('entities').where({ id: uid }).del();
@@ -89,13 +89,15 @@ export type Database = {
transaction<T>(fn: (tx: unknown) => Promise<T>): Promise<T>;
/**
* Adds a new entity to the catalog.
* Adds a set of new entities to the catalog.
*
* @param tx An ongoing transaction
* @param request The entity being added
* @returns The added entity, with uid, etag and generation set
* @param request The entities being added
*/
addEntity(tx: unknown, request: DbEntityRequest): Promise<DbEntityResponse>;
addEntities(
tx: unknown,
request: DbEntityRequest[],
): Promise<DbEntityResponse[]>;
/**
* Updates an existing entity in the catalog.
@@ -132,7 +134,7 @@ export type Database = {
entityByUid(tx: unknown, uid: string): Promise<DbEntityResponse | undefined>;
removeEntity(tx: unknown, uid: string): Promise<void>;
removeEntityByUid(tx: unknown, uid: string): Promise<void>;
addLocation(location: Location): Promise<DbLocationsRow>;
@@ -39,6 +39,7 @@ describe('HigherOrderOperations', () => {
entityByUid: jest.fn(),
entityByName: jest.fn(),
addOrUpdateEntity: jest.fn(),
addEntities: jest.fn(),
removeEntityByUid: jest.fn(),
};
locationsCatalog = {
@@ -191,8 +192,8 @@ describe('HigherOrderOperations', () => {
entities: [{ entity: desc, location }],
errors: [],
});
entitiesCatalog.entityByName.mockResolvedValue(undefined);
entitiesCatalog.addOrUpdateEntity.mockResolvedValue(desc);
entitiesCatalog.entities.mockResolvedValue([]);
entitiesCatalog.addEntities.mockResolvedValue(undefined);
await expect(
higherOrderOperation.refreshAllLocations(),
@@ -204,18 +205,19 @@ describe('HigherOrderOperations', () => {
type: 'some',
target: 'thing',
});
expect(entitiesCatalog.entityByName).toHaveBeenCalledTimes(1);
expect(entitiesCatalog.entityByName).toHaveBeenNthCalledWith(1, {
kind: 'Component',
namespace: ENTITY_DEFAULT_NAMESPACE,
name: 'c1',
});
expect(entitiesCatalog.addOrUpdateEntity).toHaveBeenCalledTimes(1);
expect(entitiesCatalog.addOrUpdateEntity).toHaveBeenNthCalledWith(
expect(entitiesCatalog.entities).toHaveBeenCalledTimes(1);
expect(entitiesCatalog.entities).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
metadata: expect.objectContaining({ name: 'c1' }),
}),
expect.arrayContaining([
{ key: 'kind', values: ['Component'] },
{ key: 'metadata.namespace', values: [ENTITY_DEFAULT_NAMESPACE] },
{ key: 'metadata.name', values: ['c1'] },
]),
);
expect(entitiesCatalog.addEntities).toHaveBeenCalledTimes(1);
expect(entitiesCatalog.addEntities).toHaveBeenNthCalledWith(
1,
[expect.objectContaining({ metadata: { name: 'c1' } })],
'123',
);
});
@@ -245,8 +247,8 @@ describe('HigherOrderOperations', () => {
entities: [{ entity: desc, location }],
errors: [],
});
entitiesCatalog.entityByName.mockResolvedValue(undefined);
entitiesCatalog.addOrUpdateEntity.mockResolvedValue(desc);
entitiesCatalog.entities.mockResolvedValue([]);
entitiesCatalog.addEntities.mockResolvedValue(undefined);
await expect(
higherOrderOperation.refreshAllLocations(),
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { InputError } from '@backstage/backend-common';
import { ConflictError, InputError } from '@backstage/backend-common';
import {
Entity,
entityHasChanges,
@@ -23,15 +23,37 @@ import {
LocationSpec,
serializeEntityRef,
} from '@backstage/catalog-model';
import { chunk, groupBy } from 'lodash';
import limiterFactory from 'p-limit';
import { v4 as uuidv4 } from 'uuid';
import { Logger } from 'winston';
import { EntitiesCatalog, LocationsCatalog } from '../catalog';
import { durationText } from '../util/timing';
import {
AddLocationResult,
HigherOrderOperation,
LocationReader,
} from './types';
type BatchContext = {
kind: string;
namespace: string;
location: Location;
};
// Some locations return tens or hundreds of thousands of entities. To make
// those payloads more manageable, we break work apart in batches of this
// many entities and write them to storage per batch.
const BATCH_SIZE = 100;
// When writing large batches, there's an increasing chance of contention in
// the form of conflicts where we compete with other writes. Each batch gets
// this many attempts at being written before giving up.
const BATCH_ATTEMPTS = 3;
// The number of batches that may be ongoing at the same time.
const BATCH_CONCURRENCY = 3;
/**
* Placeholder for operations that span several catalogs and/or stretches out
* in time.
@@ -88,7 +110,7 @@ export class HigherOrderOperations implements HigherOrderOperation {
if (readerOutput.errors.length) {
const item = readerOutput.errors[0];
throw new InputError(
`Failed to read location ${item.location.type} ${item.location.target}, ${item.error}`,
`Failed to read location ${item.location.type}:${item.location.target}, ${item.error}`,
);
}
@@ -121,85 +143,209 @@ export class HigherOrderOperations implements HigherOrderOperation {
* without changes.
*/
async refreshAllLocations(): Promise<void> {
const startTimestamp = new Date().valueOf();
const startTimestamp = process.hrtime();
this.logger.info('Beginning locations refresh');
const locations = await this.locationsCatalog.locations();
this.logger.info(`Visiting ${locations.length} locations`);
for (const { data: location } of locations) {
this.logger.debug(
`Refreshing location id="${location.id}" type="${location.type}" target="${location.target}"`,
this.logger.info(
`Refreshing location ${location.type}:${location.target}`,
);
try {
await this.refreshSingleLocation(location);
await this.locationsCatalog.logUpdateSuccess(location.id, undefined);
} catch (e) {
this.logger.debug(
`Failed to refresh location id="${location.id}" type="${location.type}" target="${location.target}", ${e}`,
this.logger.warn(
`Failed to refresh location ${location.type}:${location.target}, ${e}`,
);
await this.locationsCatalog.logUpdateFailure(location.id, e);
}
}
const endTimestamp = new Date().valueOf();
const duration = ((endTimestamp - startTimestamp) / 1000).toFixed(1);
this.logger.debug(`Completed locations refresh in ${duration} seconds`);
this.logger.info(
`Completed locations refresh in ${durationText(startTimestamp)}`,
);
}
// Performs a full refresh of a single location
private async refreshSingleLocation(location: Location) {
let startTimestamp = process.hrtime();
const readerOutput = await this.locationReader.read({
type: location.type,
target: location.target,
});
for (const item of readerOutput.errors) {
this.logger.debug(
`Failed item in location type="${item.location.type}" target="${item.location.target}", ${item.error}`,
this.logger.warn(
`Failed item in location ${item.location.type}:${item.location.target}, ${item.error}`,
);
}
this.logger.info(
`Read ${readerOutput.entities.length} entities from location ${location.type} ${location.target}`,
`Read ${readerOutput.entities.length} entities from location ${
location.type
}:${location.target} in ${durationText(startTimestamp)}`,
);
const startTimestamp = process.hrtime();
for (const item of readerOutput.entities) {
const { entity } = item;
startTimestamp = process.hrtime();
try {
const previous = await this.entitiesCatalog.entityByName(
getEntityName(entity),
);
await this.batchAddOrUpdateEntities(
readerOutput.entities.map(e => e.entity),
location,
);
if (!previous) {
await this.entitiesCatalog.addOrUpdateEntity(entity, location.id);
} else if (entityHasChanges(previous, entity)) {
await this.entitiesCatalog.addOrUpdateEntity(entity, location.id);
}
this.logger.info(
`Wrote ${readerOutput.entities.length} entities from location ${
location.type
}:${location.target} in ${durationText(startTimestamp)}`,
);
}
await this.locationsCatalog.logUpdateSuccess(
location.id,
entity.metadata.name,
);
} catch (error) {
this.logger.info(
`Failed refresh of entity ${serializeEntityRef(entity)}, ${error}`,
);
/**
* Writes a number of entities efficiently to storage.
*
* @param entities Some entities
* @param location The location that they all belong to
*/
async batchAddOrUpdateEntities(entities: Entity[], location: Location) {
// Group the entities by unique kind+namespace combinations
const entitiesByKindAndNamespace = groupBy(entities, entity => {
const name = getEntityName(entity);
return `${name.kind}:${name.namespace}`.toLowerCase();
});
await this.locationsCatalog.logUpdateFailure(
location.id,
error,
entity.metadata.name,
const limiter = limiterFactory(BATCH_CONCURRENCY);
const tasks: Promise<void>[] = [];
for (const groupEntities of Object.values(entitiesByKindAndNamespace)) {
const { kind, namespace } = getEntityName(groupEntities[0]);
// Go through the new entities in reasonable chunk sizes (sometimes,
// sources produce tens of thousands of entities, and those are too large
// batch sizes to reasonably send to the database)
for (const batch of chunk(groupEntities, BATCH_SIZE)) {
tasks.push(
limiter(async () => {
const first = serializeEntityRef(batch[0]);
const last = serializeEntityRef(batch[batch.length - 1]);
this.logger.debug(
`Considering batch ${first}-${last} (${batch.length} entries)`,
);
// Retry the batch write a few times to deal with contention
const context = { kind, namespace, location };
for (let attempt = 1; attempt <= BATCH_ATTEMPTS; ++attempt) {
try {
const { toAdd, toUpdate } = await this.analyzeBatch(
batch,
context,
);
if (toAdd.length) await this.batchAdd(toAdd, context);
if (toUpdate.length) await this.batchUpdate(toUpdate, context);
break;
} catch (e) {
if (e instanceof ConflictError && attempt < BATCH_ATTEMPTS) {
this.logger.warn(
`Failed to write batch at attempt ${attempt}/${BATCH_ATTEMPTS}, ${e}`,
);
} else {
throw e;
}
}
}
}),
);
}
}
const delta = process.hrtime(startTimestamp);
const durationMs = ((delta[0] * 1e9 + delta[1]) / 1e9).toFixed(1);
this.logger.info(
`Wrote ${readerOutput.entities.length} entities from location ${location.type} ${location.target} in ${durationMs} seconds`,
await Promise.all(tasks);
}
// Given a batch of entities that were just read from a location, take them
// into consideration by comparing against the existing catalog entities and
// produce the list of entities to be added, and the list of entities to be
// updated
private async analyzeBatch(
newEntities: Entity[],
{ kind, namespace }: BatchContext,
): Promise<{
toAdd: Entity[];
toUpdate: Entity[];
}> {
const markTimestamp = process.hrtime();
const names = newEntities.map(e => e.metadata.name);
const oldEntities = await this.entitiesCatalog.entities([
{ key: 'kind', values: [kind] },
{ key: 'metadata.namespace', values: [namespace] },
{ key: 'metadata.name', values: names },
]);
const oldEntitiesByName = new Map(
oldEntities.map(e => [e.metadata.name, e]),
);
const toAdd: Entity[] = [];
const toUpdate: Entity[] = [];
for (const newEntity of newEntities) {
const oldEntity = oldEntitiesByName.get(newEntity.metadata.name);
if (!oldEntity) {
toAdd.push(newEntity);
} else if (entityHasChanges(oldEntity, newEntity)) {
toUpdate.push(newEntity);
}
}
this.logger.debug(
`Found ${toAdd.length} entities to add, ${
toUpdate.length
} entities to update in ${durationText(markTimestamp)}`,
);
return { toAdd, toUpdate };
}
// Efficiently adds the given entities to storage, under the assumption that
// they do not conflict with any existing entities
private async batchAdd(entities: Entity[], { location }: BatchContext) {
const markTimestamp = process.hrtime();
await this.entitiesCatalog.addEntities(entities, location.id);
// TODO(freben): Still not batched
for (const entity of entities) {
await this.locationsCatalog.logUpdateSuccess(
location.id,
entity.metadata.name,
);
}
this.logger.debug(
`Added ${entities.length} entities in ${durationText(markTimestamp)}`,
);
}
// Efficiently updates the given entities into storage, under the assumption
// that there already exist entities with the same names
private async batchUpdate(entities: Entity[], { location }: BatchContext) {
const markTimestamp = process.hrtime();
// TODO(freben): Still not batched
for (const entity of entities) {
await this.entitiesCatalog.addOrUpdateEntity(entity);
await this.locationsCatalog.logUpdateSuccess(
location.id,
entity.metadata.name,
);
}
this.logger.debug(
`Updated ${entities.length} entities in ${durationText(markTimestamp)}`,
);
}
}
@@ -35,6 +35,7 @@ describe('createRouter', () => {
entityByUid: jest.fn(),
entityByName: jest.fn(),
addOrUpdateEntity: jest.fn(),
addEntities: jest.fn(),
removeEntityByUid: jest.fn(),
};
locationsCatalog = {
@@ -0,0 +1,31 @@
/*
* 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.
*/
/**
* Returns a string with the elapsed time since the start of an operation,
* with some human friendly precision, e.g. "133ms" or "14.5s".
*
* @param startTimestamp The timestamp (from process.hrtime()) at the start ot
* the operation
*/
export function durationText(startTimestamp: [number, number]): string {
const delta = process.hrtime(startTimestamp);
const seconds = delta[0] + delta[1] / 1e9;
if (seconds > 1) {
return `${seconds.toFixed(1)}s`;
}
return `${(seconds * 1000).toFixed(0)}ms`;
}
+2 -4
View File
@@ -21,16 +21,14 @@ import { BuildWithStepsPage } from './BuildWithStepsPage/';
import { BuildsPage } from './BuildsPage';
import { CIRCLECI_ANNOTATION } from '../constants';
import { Entity } from '@backstage/catalog-model';
import { WarningPanel } from '@backstage/core';
import { MissingAnnotationEmptyState } from '@backstage/core';
export const isPluginApplicableToEntity = (entity: Entity) =>
Boolean(entity.metadata.annotations?.[CIRCLECI_ANNOTATION]);
export const Router = ({ entity }: { entity: Entity }) =>
!isPluginApplicableToEntity(entity) ? (
<WarningPanel title="CircleCI plugin:">
<pre>{CIRCLECI_ANNOTATION}</pre> annotation is missing on the entity.
</WarningPanel>
<MissingAnnotationEmptyState annotation={CIRCLECI_ANNOTATION} />
) : (
<Routes>
<Route path={`/${circleCIRouteRef.path}`} element={<BuildsPage />} />
+2 -4
View File
@@ -20,7 +20,7 @@ import { rootRouteRef, buildRouteRef } from '../plugin';
import { WorkflowRunDetails } from './WorkflowRunDetails';
import { WorkflowRunsTable } from './WorkflowRunsTable';
import { CLOUDBUILD_ANNOTATION } from './useProjectName';
import { WarningPanel } from '@backstage/core';
import { MissingAnnotationEmptyState } from '@backstage/core';
export const isPluginApplicableToEntity = (entity: Entity) =>
Boolean(entity.metadata.annotations?.[CLOUDBUILD_ANNOTATION]);
@@ -28,9 +28,7 @@ export const isPluginApplicableToEntity = (entity: Entity) =>
export const Router = ({ entity }: { entity: Entity }) =>
// TODO(shmidt-i): move warning to a separate standardized component
!isPluginApplicableToEntity(entity) ? (
<WarningPanel title="Cloudbuild plugin:">
<pre>{CLOUDBUILD_ANNOTATION}</pre> annotation is missing on the entity.
</WarningPanel>
<MissingAnnotationEmptyState annotation={CLOUDBUILD_ANNOTATION} />
) : (
<Routes>
<Route
+8
View File
@@ -104,3 +104,11 @@ costInsights:
metricC:
name: Metric C
```
## Alerts
The CostInsightsApi `getAlerts` method may return any type of alert or recommendation (called collectively "Action Items" in Cost Insights) that implements the [Alert type](https://github.com/spotify/backstage/blob/master/plugins/cost-insights/src/types/Alert.tsx). This allows you to deliver any alerts or recommendations specific to your infrastructure or company migrations.
The Alert type includes an `element` field to supply the JSX Element that will be rendered in the Cost Insights "Action Items" section; we recommend using Backstage's [InfoCard](https://backstage.io/storybook/?path=/story/layout-information-card--default) and [Recharts](http://recharts.org/en-US/) to show actionable visualizations.
The Alert `url` should link to documentation or instructions for resolving the alert.
+2 -1
View File
@@ -29,6 +29,8 @@
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"@material-ui/styles": "^4.9.6",
"@types/react": "^16.9",
"@types/recharts": "^1.8.14",
"canvas": "^2.6.1",
"classnames": "^2.2.6",
"history": "^5.0.0",
@@ -48,7 +50,6 @@
"@testing-library/user-event": "^12.0.7",
"@types/jest": "^26.0.7",
"@types/node": "^12.0.0",
"@types/recharts": "^1.8.14",
"jest-fetch-mock": "^3.0.3",
"msw": "^0.20.5",
"node-fetch": "^2.6.1"
@@ -15,7 +15,15 @@
*/
import { createApiRef } from '@backstage/core';
import { Alert, Cost, Duration, Group, Project, ProductCost } from '../types';
import {
Alert,
Cost,
Duration,
Group,
Project,
ProductCost,
Maybe,
} from '../types';
export type CostInsightsApi = {
/**
@@ -87,6 +95,9 @@ export type CostInsightsApi = {
* in this product. The type of entity depends on the product - it may be deployed services,
* storage buckets, managed database instances, etc.
*
* If project is supplied, this should only return product costs for the given billing entity
* (project in GCP).
*
* The time period is supplied as a Duration rather than intervals, since this is always expected
* to return data for two bucketed time period (e.g. month vs month, or quarter vs quarter).
*
@@ -94,11 +105,13 @@ export type CostInsightsApi = {
* @param group
* @param duration A time duration, such as P1M. See the Duration type for a detailed explanation
* of how the durations are interpreted in Cost Insights.
* @param project (optional) The project id from getGroupProjects or query parameters
*/
getProductInsights(
product: string,
group: string,
duration: Duration,
project: Maybe<string>,
): Promise<ProductCost>;
/**
@@ -17,19 +17,18 @@
import React from 'react';
import { renderInTestApp } from '@backstage/test-utils';
import AlertActionCard from './AlertActionCard';
import { AlertType, ProjectGrowthAlert } from '../../types';
import { getAlertText } from '../../utils/alerts';
import { ProjectGrowthAlert, ProjectGrowthData } from '../../types';
import { MockScrollProvider } from '../../utils/tests';
const alert = {
id: AlertType.ProjectGrowth,
const data: ProjectGrowthData = {
aggregation: [500000.8, 970502.8],
project: 'test-project',
periodStart: '2019-10-01',
periodEnd: '2020-03-31',
change: { ratio: 120, amount: 120000 },
products: [],
} as ProjectGrowthAlert;
};
const alert = new ProjectGrowthAlert(data);
describe('<AlertActionCard/>', () => {
it('Renders an alert', async () => {
@@ -40,11 +39,7 @@ describe('<AlertActionCard/>', () => {
);
expect(rendered.getByText('1')).toBeInTheDocument();
const text = getAlertText(alert);
expect(text).toBeDefined();
if (text) {
expect(rendered.getByText(text.title)).toBeInTheDocument();
expect(rendered.getByText(text.subtitle)).toBeInTheDocument();
}
expect(rendered.getByText(alert.title)).toBeInTheDocument();
expect(rendered.getByText(alert.subtitle)).toBeInTheDocument();
});
});
@@ -17,10 +17,9 @@ import React from 'react';
import { Avatar, Card, CardHeader } from '@material-ui/core';
import { useScroll } from '../../hooks';
import { Alert } from '../../types';
import { getAlertText, getAlertNavigation } from '../../utils/alerts';
import {
useAlertActionCardStyles as useStyles,
useAlertActionCardHeader as useHeaderStyles,
useAlertActionCardStyles as useStyles,
} from '../../utils/styles';
type AlertActionCardProps = {
@@ -29,9 +28,8 @@ type AlertActionCardProps = {
};
const AlertActionCard = ({ alert, number }: AlertActionCardProps) => {
const { scrollIntoView } = useScroll(getAlertNavigation(alert, number));
const { scrollIntoView } = useScroll(`alert-${number}`);
const headerClasses = useHeaderStyles();
const text = getAlertText(alert);
const classes = useStyles();
return (
@@ -39,8 +37,8 @@ const AlertActionCard = ({ alert, number }: AlertActionCardProps) => {
<CardHeader
classes={headerClasses}
avatar={<Avatar className={classes.avatar}>{number}</Avatar>}
title={text?.title}
subheader={text?.subtitle}
title={alert.title}
subheader={alert.subtitle}
/>
</Card>
);
@@ -25,7 +25,7 @@ type AlertActionCardList = {
const AlertActionCardList: FC<AlertActionCardList> = ({ alerts }) => (
<Paper>
{alerts.map((alert, index) => (
<Fragment key={`${alert.id}-${index}`}>
<Fragment key={`alert-${index}`}>
<AlertActionCard alert={alert} number={index + 1} />
{index < alerts.length - 1 && <Divider variant="fullWidth" />}
</Fragment>
@@ -19,7 +19,6 @@ import { Grid } from '@material-ui/core';
import AlertInsightsSection from './AlertInsightsSection';
import AlertInsightsHeader from './AlertInsightsHeader';
import { Alert } from '../../types';
import { renderAlert } from '../../utils/alerts';
const title = "Your team's action items";
const subtitle =
@@ -37,11 +36,7 @@ const AlertInsights = ({ alerts }: AlertInsightsProps) => (
<Grid item container direction="column" spacing={4}>
{alerts.map((alert, index) => (
<Grid item key={`alert-card-${index}`}>
<AlertInsightsSection
alert={alert}
number={index + 1}
render={renderAlert}
/>
<AlertInsightsSection alert={alert} number={index + 1} />
</Grid>
))}
</Grid>
@@ -16,45 +16,28 @@
import React from 'react';
import { Box, Button } from '@material-ui/core';
import AlertInsightsSectionHeader from './AlertInsightsSectionHeader';
import {
getAlertButtonText,
getAlertText,
getAlertUrl,
} from '../../utils/alerts';
import { Alert, Currency } from '../../types';
import { useCurrency } from '../../hooks';
import { Alert } from '../../types';
type AlertInsightsSectionProps = {
alert: Alert;
number: number;
render: (alert: Alert, currency: Currency) => JSX.Element;
};
const AlertInsightsSection = ({
alert,
number,
render,
}: AlertInsightsSectionProps) => {
const [currency] = useCurrency();
const text = getAlertText(alert);
const url = getAlertUrl(alert);
const buttonText = getAlertButtonText(alert);
const AlertInsightsSection = ({ alert, number }: AlertInsightsSectionProps) => {
return (
<Box display="flex" flexDirection="column">
<AlertInsightsSectionHeader
alert={alert}
title={text.title}
subtitle={text.subtitle}
title={alert.title}
subtitle={alert.subtitle}
number={number}
/>
<Box textAlign="left" mt={0} mb={4}>
<Button variant="contained" color="primary" href={url}>
{buttonText}
<Button variant="contained" color="primary" href={alert.url}>
{alert.buttonText || 'View Instructions'}
</Button>
{/* <Button color="primary">Dismiss notification</Button> */}
</Box>
{render(alert, currency)}
{alert.element}
</Box>
);
};
@@ -15,26 +15,22 @@
*/
import React from 'react';
import { Avatar, Box, Typography, Grid } from '@material-ui/core';
import { Alert } from '../../types';
import { getAlertNavigation } from '../../utils/alerts';
import { Avatar, Box, Grid, Typography } from '@material-ui/core';
import { useAlertInsightsSectionStyles as useStyles } from '../../utils/styles';
import { useScroll } from '../../hooks';
type AlertInsightsSectionHeaderProps = {
alert: Alert;
number: number;
title: string;
subtitle: string;
};
const AlertInsightsSectionHeader = ({
alert,
number,
title,
subtitle,
}: AlertInsightsSectionHeaderProps) => {
const { ScrollAnchor } = useScroll(getAlertNavigation(alert, number));
const { ScrollAnchor } = useScroll(`alert-${number}`);
const classes = useStyles();
return (
<Box position="relative" mb={3} textAlign="left">
@@ -19,6 +19,7 @@ import { Box, Button, Container, makeStyles } from '@material-ui/core';
import ChevronLeftIcon from '@material-ui/icons/ChevronLeft';
import { Header, Page, pageTheme } from '@backstage/core';
import { CostInsightsThemeProvider } from '../CostInsightsPage/CostInsightsThemeProvider';
import { ConfigProvider, CurrencyProvider } from '../../hooks';
const useStyles = makeStyles(theme => ({
root: {
@@ -39,21 +40,29 @@ const AlertInstructionsLayout = ({
const classes = useStyles();
return (
<CostInsightsThemeProvider>
<Page theme={pageTheme.tool}>
<Header title="Cost Insights" pageTitleOverride={title} type="Tool" />
<Container maxWidth="md" disableGutters className={classes.root}>
<Box mb={3}>
<Button
variant="outlined"
startIcon={<ChevronLeftIcon />}
href="/cost-insights"
>
Back to Cost Insights
</Button>
</Box>
{children}
</Container>
</Page>
<ConfigProvider>
<CurrencyProvider>
<Page theme={pageTheme.tool}>
<Header
title="Cost Insights"
pageTitleOverride={title}
type="Tool"
/>
<Container maxWidth="md" disableGutters className={classes.root}>
<Box mb={3}>
<Button
variant="outlined"
startIcon={<ChevronLeftIcon />}
href="/cost-insights"
>
Back to Cost Insights
</Button>
</Box>
{children}
</Container>
</Page>
</CurrencyProvider>
</ConfigProvider>
</CostInsightsThemeProvider>
);
};
@@ -15,7 +15,7 @@
*/
import React, { useCallback, useEffect, useState } from 'react';
import { Box, Container, Divider, Grid } from '@material-ui/core';
import { Box, Container, Divider, Grid, Typography } from '@material-ui/core';
import { Progress, useApi, featureFlagsApiRef } from '@backstage/core';
import { default as MaterialAlert } from '@material-ui/lab/Alert';
import { costInsightsApiRef } from '../../api';
@@ -41,6 +41,7 @@ import {
} from '../../hooks';
import { Alert, Cost, intervalsOf, Maybe, Project } from '../../types';
import { mapLoadingToProps } from './selector';
import ProjectSelect from '../ProjectSelect';
const CostInsightsPage = () => {
const flags = useApi(featureFlagsApiRef).getFlags();
@@ -55,7 +56,7 @@ const CostInsightsPage = () => {
const [alerts, setAlerts] = useState<Maybe<Alert[]>>(null);
const [error, setError] = useState<Maybe<Error>>(null);
const { pageFilters } = useFilters(p => p);
const { pageFilters, setPageFilters } = useFilters(p => p);
const {
loadingActions,
loadingGroups,
@@ -63,6 +64,7 @@ const CostInsightsPage = () => {
dispatchInitial,
dispatchInsights,
dispatchNone,
dispatchReset,
} = useLoading(mapLoadingToProps);
/* eslint-disable react-hooks/exhaustive-deps */
@@ -75,8 +77,15 @@ const CostInsightsPage = () => {
const dispatchLoadingInitial = useCallback(dispatchInitial, []);
const dispatchLoadingInsights = useCallback(dispatchInsights, []);
const dispatchLoadingNone = useCallback(dispatchNone, []);
const dispatchLoadingReset = useCallback(dispatchReset, []);
/* eslint-enable react-hooks/exhaustive-deps */
const setProject = (project: Maybe<string>) =>
setPageFilters({
...pageFilters,
project: project === 'all' ? null : project,
});
useEffect(() => {
async function getInsights() {
setError(null);
@@ -165,6 +174,41 @@ const CostInsightsPage = () => {
);
}
const onProjectSelect = (project: Maybe<string>) => {
setProject(project);
dispatchLoadingReset(loadingActions);
};
const CostOverviewBanner = () => (
<Box
px={3}
marginTop={10}
display="flex"
flexDirection="row"
justifyContent="space-between"
>
<Box minHeight={40} width="75%" pt={2}>
<Typography variant="h4">Cost Overview</Typography>
</Box>
<Box minHeight={40} maxHeight={60} display="flex">
{!!flags.get('cost-insights-currencies') && (
<Box mr={1}>
<CurrencySelect
currency={currency}
currencies={currencies}
onSelect={setCurrency}
/>
</Box>
)}
<ProjectSelect
project={pageFilters.project}
projects={projects || []}
onSelect={onProjectSelect}
/>
</Box>
</Box>
);
return (
<CostInsightsLayout groups={groups}>
<Grid container wrap="nowrap">
@@ -180,15 +224,6 @@ const CostInsightsPage = () => {
justifyContent="flex-end"
mb={2}
>
{!!flags.get('cost-insights-currencies') && (
<Box mr={1}>
<CurrencySelect
currency={currency}
currencies={currencies}
onSelect={setCurrency}
/>
</Box>
)}
<CopyUrlToClipboard />
<CostInsightsSupportButton />
</Box>
@@ -212,6 +247,9 @@ const CostInsightsPage = () => {
<Divider />
</>
)}
<Grid item xs>
<CostOverviewBanner />
</Grid>
<Grid item xs>
<Box px={3} py={6}>
{!!dailyCost.aggregation.length && (
@@ -14,7 +14,11 @@
* limitations under the License.
*/
import { MapLoadingToProps } from '../../hooks';
import { getResetState, DefaultLoadingAction } from '../../types';
import {
getResetState,
DefaultLoadingAction,
getResetStateWithoutInitial,
} from '../../types';
type CostInsightsPageLoadingProps = {
loadingActions: Array<string>;
@@ -23,6 +27,7 @@ type CostInsightsPageLoadingProps = {
dispatchInitial: (isLoading: boolean) => void;
dispatchInsights: (isLoading: boolean) => void;
dispatchNone: (loadingActions: string[]) => void;
dispatchReset: (loadingActions: string[]) => void;
};
export const mapLoadingToProps: MapLoadingToProps<CostInsightsPageLoadingProps> = ({
@@ -39,4 +44,6 @@ export const mapLoadingToProps: MapLoadingToProps<CostInsightsPageLoadingProps>
dispatch({ [DefaultLoadingAction.CostInsightsPage]: isLoading }),
dispatchNone: (loadingActions: string[]) =>
dispatch(getResetState(loadingActions)),
dispatchReset: (loadingActions: string[]) =>
dispatch(getResetStateWithoutInitial(loadingActions)),
});
@@ -19,10 +19,8 @@ import { Box, Card, CardContent, Divider } from '@material-ui/core';
import CostOverviewChart from '../CostOverviewChart';
import CostOverviewChartLegend from '../CostOverviewChartLegend';
import CostOverviewHeader from './CostOverviewHeader';
import CostOverviewFooter from './CostOverviewFooter';
import MetricSelect from '../MetricSelect';
import PeriodSelect from '../PeriodSelect';
import ProjectSelect from '../ProjectSelect';
import { useScroll, useFilters, useConfig } from '../../hooks';
import { mapFiltersToProps } from './selector';
import { DefaultNavigation } from '../../utils/navigation';
@@ -45,7 +43,6 @@ const CostOverviewCard = ({
change,
aggregation,
trendline,
projects,
}: CostOverviewCardProps) => {
const { metrics } = useConfig();
const { ScrollAnchor } = useScroll(DefaultNavigation.CostOverviewCard);
@@ -64,7 +61,7 @@ const CostOverviewCard = ({
</CostOverviewHeader>
<Divider />
<Box marginY={1} display="flex" flexDirection="column">
<CostOverviewChartLegend change={change} title={name} />
<CostOverviewChartLegend change={change} title={`${name} Trend`} />
<CostOverviewChart
responsive
metric={metric}
@@ -73,18 +70,13 @@ const CostOverviewCard = ({
trendline={trendline}
/>
</Box>
<CostOverviewFooter>
<ProjectSelect
project={filters.project}
projects={projects}
onSelect={setProject}
/>
<Box display="flex" justifyContent="flex-end" alignItems="center">
<MetricSelect
metric={metric}
metrics={metrics}
onSelect={setMetric}
/>
</CostOverviewFooter>
</Box>
</CardContent>
</Card>
);
@@ -39,7 +39,7 @@ const ProductInsightsCard = ({ product }: ProductInsightsCardProps) => {
const [resource, setResource] = useState<Maybe<ProductCost>>(null);
const [error, setError] = useState<Maybe<Error>>(null);
const { group, product: productFilter, setProduct } = useFilters(
const { group, product: productFilter, setProduct, project } = useFilters(
mapFiltersToProps(product.kind),
);
const { loadingProduct, dispatchLoading } = useLoading(
@@ -68,6 +68,7 @@ const ProductInsightsCard = ({ product }: ProductInsightsCardProps) => {
product.kind,
group!,
productFilter!.duration,
project,
);
setResource(p);
} catch (e) {
@@ -87,6 +88,7 @@ const ProductInsightsCard = ({ product }: ProductInsightsCardProps) => {
productFilter,
group,
product.kind,
project,
]);
const onPeriodSelect = (duration: Duration) => {
@@ -17,7 +17,7 @@
import React from 'react';
import { renderInTestApp } from '@backstage/test-utils';
import ProjectGrowthAlertCard from './ProjectGrowthAlertCard';
import { createMockProjectGrowthAlert } from '../../utils/mockData';
import { createMockProjectGrowthData } from '../../utils/mockData';
import { MockCurrencyProvider, MockConfigProvider } from '../../utils/tests';
import { AlertCost, defaultCurrencies, findAlways } from '../../types';
@@ -29,8 +29,8 @@ const MockAlertCosts: AlertCost[] = [
{ id: 'test-id-2', aggregation: [235, 400] },
];
const MockProjectGrowthAlert = createMockProjectGrowthAlert(alert => ({
...alert,
const MockProjectGrowthAlert = createMockProjectGrowthData(data => ({
...data,
project: MockProject,
products: MockAlertCosts,
}));
@@ -19,11 +19,11 @@ import { Box } from '@material-ui/core';
import { InfoCard } from '@backstage/core';
import ResourceGrowthBarChart from '../ResourceGrowthBarChart';
import ResourceGrowthBarChartLegend from '../ResourceGrowthBarChartLegend';
import { Duration, ProjectGrowthAlert } from '../../types';
import { Duration, ProjectGrowthData } from '../../types';
import { pluralOf } from '../../utils/grammar';
type ProjectGrowthAlertProps = {
alert: ProjectGrowthAlert;
alert: ProjectGrowthData;
};
const ProjectGrowthAlertCard = ({ alert }: ProjectGrowthAlertProps) => {
@@ -18,20 +18,19 @@ import React from 'react';
import { Box, Typography } from '@material-ui/core';
import { InfoCard } from '@backstage/core';
import AlertInstructionsLayout from '../AlertInstructionsLayout';
import ProjectGrowthAlertCard from '../ProjectGrowthAlertCard';
import {
AlertType,
Alert,
Duration,
Entity,
Product,
ProjectGrowthAlert,
ProjectGrowthData,
} from '../../types';
import ResourceGrowthBarChartLegend from '../ResourceGrowthBarChartLegend';
import ResourceGrowthBarChart from '../ResourceGrowthBarChart';
const ProjectGrowthInstructionsPage = () => {
const projectGrowthAlert: ProjectGrowthAlert = {
id: AlertType.ProjectGrowth,
const alertData: ProjectGrowthData = {
project: 'example-project',
periodStart: 'Q1 2020',
periodEnd: 'Q2 2020',
@@ -55,6 +54,7 @@ const ProjectGrowthInstructionsPage = () => {
},
],
};
const projectGrowthAlert: Alert = new ProjectGrowthAlert(alertData);
const product: Product = {
kind: 'ComputeEngine',
@@ -135,7 +135,7 @@ const ProjectGrowthInstructionsPage = () => {
comparison of cloud products over the examined time period:
</Typography>
<Box mt={2} mb={2}>
<ProjectGrowthAlertCard alert={projectGrowthAlert} />
{projectGrowthAlert.element}
</Box>
<Typography paragraph>
This allows you to quickly see which cloud products contributed to the
@@ -48,7 +48,7 @@ const ProjectSelect = ({ project, projects, onSelect }: ProjectSelectProps) => {
<Select
className={classes.select}
variant="outlined"
value={project}
value={project || 'all'}
renderValue={renderValue}
onChange={handleOnChange}
data-testid="project-filter-select"
@@ -17,23 +17,23 @@
import React from 'react';
import UnlabeledDataflowAlertCard from './UnlabeledDataflowAlertCard';
import {
createMockUnlabeledDataflowAlert,
createMockUnlabeledDataflowData,
createMockUnlabeledDataflowAlertProject,
} from '../../utils/mockData';
import { renderInTestApp } from '@backstage/test-utils';
const MockUnlabeledDataflowAlertMultipleProjects = createMockUnlabeledDataflowAlert(
alert => ({
...alert,
const MockUnlabeledDataflowAlertMultipleProjects = createMockUnlabeledDataflowData(
data => ({
...data,
projects: [...Array(10)].map(() =>
createMockUnlabeledDataflowAlertProject(),
),
}),
);
const MockUnlabeledDataflowAlertSingleProject = createMockUnlabeledDataflowAlert(
alert => ({
...alert,
const MockUnlabeledDataflowAlertSingleProject = createMockUnlabeledDataflowData(
data => ({
...data,
projects: [...Array(1)].map(() =>
createMockUnlabeledDataflowAlertProject(),
),
@@ -19,11 +19,11 @@ import { Box } from '@material-ui/core';
import { InfoCard } from '@backstage/core';
import UnlabeledDataflowBarChart from '../UnlabeledDataflowBarChart';
import UnlabeledDataflowBarChartLegend from '../UnlabeledDataflowBarChartLegend';
import { UnlabeledDataflowAlert } from '../../types';
import { UnlabeledDataflowData } from '../../types';
import { pluralOf } from '../../utils/grammar';
type UnlabeledDataflowAlertProps = {
alert: UnlabeledDataflowAlert;
alert: UnlabeledDataflowData;
};
const UnlabeledDataflowAlertCard = ({ alert }: UnlabeledDataflowAlertProps) => {
@@ -13,24 +13,6 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { Box } from '@material-ui/core';
type CostOverviewFooterProps = {
children?: React.ReactNode;
};
const CostOverviewFooter = ({ children }: CostOverviewFooterProps) => (
<Box
display="flex"
flexDirection="row"
justifyContent="space-between"
alignItems="center"
>
{React.Children.map(children, child => (
<Box marginY={1}>{child}</Box>
))}
</Box>
);
export default CostOverviewFooter;
export { default as BarChart } from './BarChart';
export { default as LegendItem } from './LegendItem';
+1
View File
@@ -16,4 +16,5 @@
export { plugin } from './plugin';
export * from './api';
export * from './components';
export * from './types';
-77
View File
@@ -1,77 +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 { ChangeStatistic } from './ChangeStatistic';
import { Maybe } from './Maybe';
export type Alert = ProjectGrowthAlert | UnlabeledDataflowAlert;
export interface AlertProps {
alert: Alert;
}
export enum AlertType {
ProjectGrowth = 'projectGrowth',
UnlabeledDataflow = 'unlabeledDataflow',
}
export interface AlertCost {
id: string;
aggregation: [number, number];
}
export interface ResourceData {
previous: number;
current: number;
name: Maybe<string>;
}
export interface BarChartData {
previousFill: string;
currentFill: string;
previousName: string;
currentName: string;
}
export enum DataKey {
Previous = 'previous',
Current = 'current',
Name = 'name',
}
export interface ProjectGrowthAlert {
id: AlertType.ProjectGrowth;
project: string;
periodStart: string;
periodEnd: string;
aggregation: [number, number];
change: ChangeStatistic;
products: Array<AlertCost>;
}
export interface UnlabeledDataflowAlert {
id: AlertType.UnlabeledDataflow;
periodStart: string;
periodEnd: string;
projects: Array<UnlabeledDataflowAlertProject>;
unlabeledCost: number;
labeledCost: number;
}
export interface UnlabeledDataflowAlertProject {
id: string;
unlabeledCost: number;
labeledCost: number;
}
+123
View File
@@ -0,0 +1,123 @@
/*
* 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 { ChangeStatistic } from './ChangeStatistic';
import { Maybe } from './Maybe';
import UnlabeledDataflowAlertCard from '../components/UnlabeledDataflowAlertCard';
import ProjectGrowthAlertCard from '../components/ProjectGrowthAlertCard';
/**
* Generic alert type with required fields for display. The `element` field will be rendered in
* the Cost Insights "Action Items" section. This should use data fetched in the CostInsightsApi
* implementation to render an InfoCard or other visualization.
*/
export type Alert = {
title: string;
subtitle: string;
url: string;
buttonText?: string; // Default: View Instructions
element: JSX.Element;
};
export interface AlertCost {
id: string;
aggregation: [number, number];
}
export interface ResourceData {
previous: number;
current: number;
name: Maybe<string>;
}
export interface BarChartData {
previousFill: string;
currentFill: string;
previousName: string;
currentName: string;
}
export enum DataKey {
Previous = 'previous',
Current = 'current',
Name = 'name',
}
/**
* The alerts below are examples of Alert implementation; the CostInsightsApi permits returning
* any implementation of the Alert type, so adopters can create their own. The CostInsightsApi
* fetches alert data from the backend, then creates Alert classes with the data.
*/
export interface ProjectGrowthData {
project: string;
periodStart: string;
periodEnd: string;
aggregation: [number, number];
change: ChangeStatistic;
products: Array<AlertCost>;
}
export class ProjectGrowthAlert implements Alert {
data: ProjectGrowthData;
constructor(data: ProjectGrowthData) {
this.data = data;
}
get title() {
return `Investigate cost growth in project ${this.data.project}`;
}
subtitle =
'Cost growth outpacing business growth is unsustainable long-term.';
url = '/cost-insights/investigating-growth';
get element() {
return <ProjectGrowthAlertCard alert={this.data} />;
}
}
export interface UnlabeledDataflowData {
periodStart: string;
periodEnd: string;
projects: Array<UnlabeledDataflowAlertProject>;
unlabeledCost: number;
labeledCost: number;
}
export class UnlabeledDataflowAlert implements Alert {
data: UnlabeledDataflowData;
constructor(data: UnlabeledDataflowData) {
this.data = data;
}
title = 'Add labels to workflows';
subtitle =
'Labels show in billing data, enabling cost insights for each workflow.';
url = '/cost-insights/labeling-jobs';
get element() {
return <UnlabeledDataflowAlertCard alert={this.data} />;
}
}
export interface UnlabeledDataflowAlertProject {
id: string;
unlabeledCost: number;
labeledCost: number;
}
@@ -1,82 +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 React from 'react';
import { Alert, AlertType, assertNever } from '../types';
import ProjectGrowthAlertCard from '../components/ProjectGrowthAlertCard';
import UnlabeledDataflowAlertCard from '../components/UnlabeledDataflowAlertCard';
export function getAlertText(alert: Alert) {
switch (alert.id) {
case AlertType.ProjectGrowth:
return {
title: `Investigate cost growth in project ${alert.project}`,
subtitle:
'Cost growth outpacing business growth is unsustainable long-term.',
} as AlertText;
case AlertType.UnlabeledDataflow:
return {
title: 'Add labels to workflows',
subtitle:
'Labels show in billing data, enabling cost insights for each workflow.',
};
default:
return assertNever(alert);
}
}
export function getAlertUrl(alert: Alert) {
switch (alert.id) {
case AlertType.ProjectGrowth:
return '/cost-insights/investigating-growth' as AlertUrl;
case AlertType.UnlabeledDataflow:
return '/cost-insights/labeling-jobs' as AlertUrl;
default:
return assertNever(alert);
}
}
export function getAlertButtonText(alert: Alert) {
switch (alert.id) {
case AlertType.ProjectGrowth:
case AlertType.UnlabeledDataflow:
return 'View Instructions' as AlertButtonText;
default:
return assertNever(alert);
}
}
export function getAlertNavigation(alert: Alert, number: number) {
return `${alert.id}-${number}`;
}
export function renderAlert(alert: Alert) {
switch (alert.id) {
case AlertType.ProjectGrowth:
return <ProjectGrowthAlertCard alert={alert} />;
case AlertType.UnlabeledDataflow:
return <UnlabeledDataflowAlertCard alert={alert} />;
default:
return assertNever(alert);
}
}
export type AlertUrl = string;
export type AlertButtonText = string;
export interface AlertText {
title: string;
subtitle: string;
}
+19 -22
View File
@@ -15,17 +15,16 @@
*/
import {
AlertType,
Entity,
ProjectGrowthAlert,
Product,
UnlabeledDataflowAlert,
UnlabeledDataflowAlertProject,
getDefaultState,
DefaultLoadingAction,
Duration,
ProductCost,
Entity,
findAlways,
getDefaultState,
Product,
ProductCost,
ProjectGrowthData,
UnlabeledDataflowAlertProject,
UnlabeledDataflowData,
} from '../types';
import { Config } from '@backstage/config';
import { ConfigApi } from '@backstage/core';
@@ -73,11 +72,10 @@ export const createMockProductCost = (
return { ...defaultProduct };
};
export const createMockProjectGrowthAlert = (
callback?: mockAlertRenderer<ProjectGrowthAlert>,
): ProjectGrowthAlert => {
const defaultAlert: ProjectGrowthAlert = {
id: AlertType.ProjectGrowth,
export const createMockProjectGrowthData = (
callback?: mockAlertRenderer<ProjectGrowthData>,
): ProjectGrowthData => {
const data: ProjectGrowthData = {
project: 'test-project-growth-alert',
periodStart: '2019-10-01',
periodEnd: '2020-03-31',
@@ -90,17 +88,16 @@ export const createMockProjectGrowthAlert = (
};
if (typeof callback === 'function') {
return callback({ ...defaultAlert });
return callback({ ...data });
}
return { ...defaultAlert };
return { ...data };
};
export const createMockUnlabeledDataflowAlert = (
callback?: mockAlertRenderer<UnlabeledDataflowAlert>,
): UnlabeledDataflowAlert => {
const defaultAlert: UnlabeledDataflowAlert = {
id: AlertType.UnlabeledDataflow,
export const createMockUnlabeledDataflowData = (
callback?: mockAlertRenderer<UnlabeledDataflowData>,
): UnlabeledDataflowData => {
const data: UnlabeledDataflowData = {
periodStart: '2020-05-01',
periodEnd: '2020-06-1',
projects: [],
@@ -109,10 +106,10 @@ export const createMockUnlabeledDataflowAlert = (
};
if (typeof callback === 'function') {
return callback({ ...defaultAlert });
return callback({ ...data });
}
return { ...defaultAlert };
return { ...data };
};
export const createMockUnlabeledDataflowAlertProject = (
@@ -385,6 +385,7 @@ export const useSelectStyles = makeStyles<BackstageTheme>(
select: {
minWidth: 200,
textAlign: 'start',
backgroundColor: theme.palette.background.paper,
},
menuItem: {
minWidth: 200,
+2 -5
View File
@@ -19,7 +19,7 @@ import { buildRouteRef, rootRouteRef } from '../plugin';
import { DetailedViewPage } from './BuildWithStepsPage/';
import { JENKINS_ANNOTATION } from '../constants';
import { Entity } from '@backstage/catalog-model';
import { WarningPanel } from '@backstage/core';
import { MissingAnnotationEmptyState } from '@backstage/core';
import { CITable } from './BuildsPage/lib/CITable';
export const isPluginApplicableToEntity = (entity: Entity) =>
@@ -27,10 +27,7 @@ export const isPluginApplicableToEntity = (entity: Entity) =>
export const Router = ({ entity }: { entity: Entity }) => {
return !isPluginApplicableToEntity(entity) ? (
<WarningPanel title="Jenkins plugin:">
<pre>entity.metadata.annotations['{JENKINS_ANNOTATION}']</pre>
key is missing on the entity.
</WarningPanel>
<MissingAnnotationEmptyState annotation={JENKINS_ANNOTATION} />
) : (
<Routes>
<Route path={`/${rootRouteRef.path}`} element={<CITable />} />
+2 -6
View File
@@ -20,7 +20,7 @@ import { Route, Routes } from 'react-router-dom';
import { rootCatalogKubernetesRouteRef } from './plugin';
import { KubernetesContent } from './components/KubernetesContent';
import { WarningPanel } from '@backstage/core';
import { MissingAnnotationEmptyState } from '@backstage/core';
const KUBERNETES_ANNOTATION = 'backstage.io/kubernetes-id';
@@ -29,11 +29,7 @@ export const Router = ({ entity }: { entity: Entity }) => {
entity.metadata.annotations?.[KUBERNETES_ANNOTATION];
if (!kubernetesAnnotationValue) {
return (
<WarningPanel title="Kubernetes plugin:">
<pre>{KUBERNETES_ANNOTATION}</pre> annotation is missing on the entity.
</WarningPanel>
);
return <MissingAnnotationEmptyState annotation={KUBERNETES_ANNOTATION} />;
}
return (
+3 -5
View File
@@ -23,7 +23,7 @@ import CreateAudit, { CreateAuditContent } from './components/CreateAudit';
import { Entity } from '@backstage/catalog-model';
import { LIGHTHOUSE_WEBSITE_URL_ANNOTATION } from '../constants';
import { AuditListForEntity } from './components/AuditList/AuditListForEntity';
import { EmptyState } from '@backstage/core';
import { MissingAnnotationEmptyState } from '@backstage/core';
export const isPluginApplicableToEntity = (entity: Entity) =>
Boolean(entity.metadata.annotations?.[LIGHTHOUSE_WEBSITE_URL_ANNOTATION]);
@@ -38,10 +38,8 @@ export const Router = () => (
export const EmbeddedRouter = ({ entity }: { entity: Entity }) =>
!isPluginApplicableToEntity(entity) ? (
<EmptyState
missing="field"
title="Your plugin is missing an annotation"
description={`Please add the ${LIGHTHOUSE_WEBSITE_URL_ANNOTATION} annotation`}
<MissingAnnotationEmptyState
annotation={LIGHTHOUSE_WEBSITE_URL_ANNOTATION}
/>
) : (
<Routes>
+2 -4
View File
@@ -17,7 +17,7 @@
import React from 'react';
import { Routes, Route } from 'react-router';
import { Entity } from '@backstage/catalog-model';
import { WarningPanel } from '@backstage/core';
import { MissingAnnotationEmptyState } from '@backstage/core';
import { catalogRouteRef } from '../routes';
import { ROLLBAR_ANNOTATION } from '../constants';
import { EntityPageRollbar } from './EntityPageRollbar/EntityPageRollbar';
@@ -31,9 +31,7 @@ type Props = {
export const Router = ({ entity }: Props) =>
!isPluginApplicableToEntity(entity) ? (
<WarningPanel title="Rollbar plugin:">
<pre>{ROLLBAR_ANNOTATION}</pre> annotation is missing on the entity.
</WarningPanel>
<MissingAnnotationEmptyState annotation={ROLLBAR_ANNOTATION} />
) : (
<Routes>
<Route
+2 -6
View File
@@ -16,7 +16,7 @@
import React from 'react';
import { Entity } from '@backstage/catalog-model';
import { Routes, Route } from 'react-router';
import { WarningPanel } from '@backstage/core';
import { MissingAnnotationEmptyState } from '@backstage/core';
import { SentryPluginWidget } from './SentryPluginWidget/SentryPluginWidget';
const SENTRY_ANNOTATION = 'sentry.io/project-slug';
@@ -25,11 +25,7 @@ export const Router = ({ entity }: { entity: Entity }) => {
const projectId = entity.metadata.annotations?.[SENTRY_ANNOTATION];
if (!projectId) {
return (
<WarningPanel title="Sentry plugin:">
<pre>{SENTRY_ANNOTATION}</pre> annotation is missing on the entity.
</WarningPanel>
);
return <MissingAnnotationEmptyState annotation={SENTRY_ANNOTATION} />;
}
return (
@@ -78,7 +78,7 @@ export async function createRouter({
}
});
router.get('/metadata/entity/:kind/:namespace/:name', async (req, res) => {
router.get('/metadata/entity/:namespace/:kind/:name', async (req, res) => {
const baseUrl = config.getString('backend.baseUrl');
const { kind, namespace, name } = req.params;
@@ -101,7 +101,7 @@ export async function createRouter({
}
});
router.get('/docs/:kind/:namespace/:name/*', async (req, res) => {
router.get('/docs/:namespace/:kind/:name/*', async (req, res) => {
const storageUrl = config.getString('techdocs.storageUrl');
const { kind, namespace, name } = req.params;
@@ -13,6 +13,8 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* eslint-disable no-restricted-syntax */
import fs from 'fs-extra';
import path from 'path';
import { getVoidLogger } from '@backstage/backend-common';
@@ -53,7 +55,7 @@ describe('local publisher', () => {
const resultDir = path.resolve(
__dirname,
`../../../../static/docs/${mockEntity.kind}/default/${mockEntity.metadata.name}`,
`../../../../static/docs/default/${mockEntity.kind}/${mockEntity.metadata.name}`,
);
expect(fs.existsSync(resultDir)).toBeTruthy();
@@ -42,8 +42,8 @@ export class LocalPublish implements PublisherBase {
const publishDir = resolvePackagePath(
'@backstage/plugin-techdocs-backend',
'static/docs',
entity.kind,
entityNamespace,
entity.kind,
entity.metadata.name,
);
+1 -1
View File
@@ -23,7 +23,7 @@ export const EntityPageDocs = ({ entity }: { entity: Entity }) => {
<Reader
entityId={{
kind: entity.kind,
namespace: entity.metadata.namespace,
namespace: entity.metadata.namespace ?? 'default',
name: entity.metadata.name,
}}
/>
+2 -11
View File
@@ -17,8 +17,7 @@
import React from 'react';
import { Entity } from '@backstage/catalog-model';
import { Route, Routes } from 'react-router-dom';
import { WarningPanel } from '@backstage/core';
import { MissingAnnotationEmptyState } from '@backstage/core';
import {
rootRouteRef,
rootDocsRouteRef,
@@ -43,15 +42,7 @@ export const EmbeddedDocsRouter = ({ entity }: { entity: Entity }) => {
const projectId = entity.metadata.annotations?.[TECHDOCS_ANNOTATION];
if (!projectId) {
return (
<WarningPanel title="Techdocs plugin:">
<pre>{TECHDOCS_ANNOTATION}</pre> annotation is missing on the entity.
<br />
<a href="https://backstage.io/docs/features/techdocs/creating-and-publishing">
Getting Started
</a>
</WarningPanel>
);
return <MissingAnnotationEmptyState annotation={TECHDOCS_ANNOTATION} />;
}
return (
+2 -2
View File
@@ -28,7 +28,7 @@ describe('TechDocsStorageApi', () => {
const storageApi = new TechDocsStorageApi({ apiOrigin: DOC_STORAGE_URL });
expect(storageApi.getBaseUrl('test.js', mockEntity, '')).toEqual(
`${DOC_STORAGE_URL}/docs/${mockEntity.kind}/${mockEntity.namespace}/${mockEntity.name}/test.js`,
`${DOC_STORAGE_URL}/docs/${mockEntity.namespace}/${mockEntity.kind}/${mockEntity.name}/test.js`,
);
});
@@ -36,7 +36,7 @@ describe('TechDocsStorageApi', () => {
const storageApi = new TechDocsStorageApi({ apiOrigin: DOC_STORAGE_URL });
expect(storageApi.getBaseUrl('test/', mockEntity, '')).toEqual(
`${DOC_STORAGE_URL}/docs/${mockEntity.kind}/${mockEntity.namespace}/${mockEntity.name}/test/`,
`${DOC_STORAGE_URL}/docs/${mockEntity.namespace}/${mockEntity.kind}/${mockEntity.name}/test/`,
);
});
});
+3 -9
View File
@@ -51,9 +51,7 @@ export class TechDocsApi implements TechDocs {
async getMetadata(metadataType: string, entityId: ParsedEntityId) {
const { kind, namespace, name } = entityId;
const requestUrl = `${this.apiOrigin}/metadata/${metadataType}/${kind}/${
namespace ? namespace : 'default'
}/${name}`;
const requestUrl = `${this.apiOrigin}/metadata/${metadataType}/${namespace}/${kind}/${name}`;
const request = await fetch(`${requestUrl}`);
const res = await request.json();
@@ -72,9 +70,7 @@ export class TechDocsStorageApi implements TechDocsStorage {
async getEntityDocs(entityId: ParsedEntityId, path: string) {
const { kind, namespace, name } = entityId;
const url = `${this.apiOrigin}/docs/${kind}/${
namespace ? namespace : 'default'
}/${name}/${path}`;
const url = `${this.apiOrigin}/docs/${namespace}/${kind}/${name}/${path}`;
const request = await fetch(
`${url.endsWith('/') ? url : `${url}/`}index.html`,
@@ -96,9 +92,7 @@ export class TechDocsStorageApi implements TechDocsStorage {
return new URL(
oldBaseUrl,
`${this.apiOrigin}/docs/${kind}/${
namespace ? namespace : 'default'
}/${name}/${path}`,
`${this.apiOrigin}/docs/${namespace}/${kind}/${name}/${path}`,
).toString();
}
}
+1 -1
View File
@@ -48,7 +48,7 @@ export const rootRouteRef = createRouteRef({
});
export const rootDocsRouteRef = createRouteRef({
path: ':entityId/*',
path: ':namespace/:kind/:name/*',
title: 'Docs',
});
@@ -84,9 +84,9 @@ export const TechDocsHome = () => {
onClick={() =>
navigate(
generatePath(rootDocsRouteRef.path, {
entityId: `${entity.kind}:${
entity.metadata.namespace ?? ''
}:${entity.metadata.name}`,
namespace: entity.metadata.namespace ?? 'default',
kind: entity.kind,
name: entity.metadata.name,
}),
)
}
@@ -24,8 +24,7 @@ import { techdocsApiRef } from '../../api';
export const TechDocsPage = () => {
const [documentReady, setDocumentReady] = useState<boolean>(false);
const { entityId } = useParams();
const [kind, namespace, name] = entityId.split(':');
const { namespace, kind, name } = useParams();
const techDocsApi = useApi(techdocsApiRef);
+57 -34
View File
@@ -1325,10 +1325,10 @@
resolve-from "^5.0.0"
semver "^5.4.1"
"@changesets/assemble-release-plan@^3.0.0", "@changesets/assemble-release-plan@^3.0.1":
version "3.0.1"
resolved "https://registry.npmjs.org/@changesets/assemble-release-plan/-/assemble-release-plan-3.0.1.tgz#cd501d0c57d435a594fc7bb630fa589d5b75c2a0"
integrity sha512-PChmYuibH8RPiebMIzuYZ/DFS8ehf7yq+X5X0rJklg2njP3zYWJX7nlctpnBhZ0zpgvP2IgrtUigoVGNkv5m/Q==
"@changesets/assemble-release-plan@^4.0.0":
version "4.0.0"
resolved "https://registry.npmjs.org/@changesets/assemble-release-plan/-/assemble-release-plan-4.0.0.tgz#60c2392c0e2c99f24778ab3a5c8e8c80ddaaaa59"
integrity sha512-3Kv21FNvysTQvZs3fHr6aZeDibhZHtgI1++fMZplzVtwNVmpjow3zv9lcZmJP26LthbpVH3I8+nqlU7M43lfWA==
dependencies:
"@babel/runtime" "^7.10.4"
"@changesets/errors" "^0.1.4"
@@ -1337,23 +1337,23 @@
"@manypkg/get-packages" "^1.0.1"
semver "^5.4.1"
"@changesets/cli@2.10.2":
version "2.10.2"
resolved "https://registry.npmjs.org/@changesets/cli/-/cli-2.10.2.tgz#85a7a8d8aca2ef682c671f352645bc75c72f9b85"
integrity sha512-m4YwTmT0ElOuBD3GCbbT75EWmdU4uCFARE4X+Ml1/knc4Z/MEzOjV0bi0/9eHACcQIMNg7rdB6f4vXtHiQU6bA==
"@changesets/cli@^2.11.0":
version "2.11.0"
resolved "https://registry.npmjs.org/@changesets/cli/-/cli-2.11.0.tgz#048449289da95f24d96b187f3148c2955065d609"
integrity sha512-imsXWxl+QpLt6PYHSiuZS7xPdKrGs2c8saSG4ENzeZBgnTBMiJdxDloOT6Bmv15ICggHZj9mnaUBNOMPbvnlbA==
dependencies:
"@babel/runtime" "^7.10.4"
"@changesets/apply-release-plan" "^4.0.0"
"@changesets/assemble-release-plan" "^3.0.1"
"@changesets/config" "^1.3.0"
"@changesets/assemble-release-plan" "^4.0.0"
"@changesets/config" "^1.4.0"
"@changesets/errors" "^0.1.4"
"@changesets/get-dependents-graph" "^1.1.3"
"@changesets/get-release-plan" "^2.0.0"
"@changesets/get-release-plan" "^2.0.1"
"@changesets/git" "^1.0.6"
"@changesets/logger" "^0.0.5"
"@changesets/pre" "^1.0.4"
"@changesets/read" "^0.4.6"
"@changesets/types" "^3.1.1"
"@changesets/types" "^3.2.0"
"@changesets/write" "^0.1.3"
"@manypkg/get-packages" "^1.0.1"
"@types/semver" "^6.0.0"
@@ -1373,7 +1373,7 @@
term-size "^2.1.0"
tty-table "^2.7.0"
"@changesets/config@^1.2.0", "@changesets/config@^1.3.0":
"@changesets/config@^1.2.0":
version "1.3.0"
resolved "https://registry.npmjs.org/@changesets/config/-/config-1.3.0.tgz#82fcbf572b00ba16636be9ea45167983f1fc203b"
integrity sha512-IeAHmN5kI7OywBUNJXsk/v4vcXDDscwgTe/K5D3FSng5QTvzbgiMAe5K1iwBxBvuT4u/33n89kxSJdg4TTTFfA==
@@ -1385,6 +1385,19 @@
"@manypkg/get-packages" "^1.0.1"
fs-extra "^7.0.1"
"@changesets/config@^1.4.0":
version "1.4.0"
resolved "https://registry.npmjs.org/@changesets/config/-/config-1.4.0.tgz#c157a4121f198b749f2bbc2e9015b6e976ece7d6"
integrity sha512-eoTOcJ6py7jBDY8rUXwEGxR5UtvUX+p//0NhkVpPGcnvIeITHq+DOIsuWyGzWcb+1FaYkof3CCr32/komZTu4Q==
dependencies:
"@changesets/errors" "^0.1.4"
"@changesets/get-dependents-graph" "^1.1.3"
"@changesets/logger" "^0.0.5"
"@changesets/types" "^3.2.0"
"@manypkg/get-packages" "^1.0.1"
fs-extra "^7.0.1"
micromatch "^4.0.2"
"@changesets/errors@^0.1.4":
version "0.1.4"
resolved "https://registry.npmjs.org/@changesets/errors/-/errors-0.1.4.tgz#f79851746c43679a66b383fdff4c012f480f480d"
@@ -1403,13 +1416,13 @@
fs-extra "^7.0.1"
semver "^5.4.1"
"@changesets/get-release-plan@^2.0.0":
version "2.0.0"
resolved "https://registry.npmjs.org/@changesets/get-release-plan/-/get-release-plan-2.0.0.tgz#570dbd0abcdd4169a73e8332ec139a01130f3b72"
integrity sha512-MHbgXMhkfWhXH1zUefrdtQ8IR+H46lAcKthKjptV28k0qGEcDk7KriYLukJ6BNkWiZkkZ/aycaivbNDclF9zaw==
"@changesets/get-release-plan@^2.0.1":
version "2.0.1"
resolved "https://registry.npmjs.org/@changesets/get-release-plan/-/get-release-plan-2.0.1.tgz#b95d8f1a3cc719ff4b42b9b9aae72458d8787c13"
integrity sha512-+x5N9/Iaka+c0Kq7+3JsboMNyffKYlWPmdm+VeafDcMwJFhBDkxm84qaCJ93ydmnzQOTig6gYVqw0k8BbHExyQ==
dependencies:
"@babel/runtime" "^7.4.4"
"@changesets/assemble-release-plan" "^3.0.0"
"@babel/runtime" "^7.10.4"
"@changesets/assemble-release-plan" "^4.0.0"
"@changesets/config" "^1.2.0"
"@changesets/pre" "^1.0.4"
"@changesets/read" "^0.4.6"
@@ -1478,6 +1491,11 @@
resolved "https://registry.npmjs.org/@changesets/types/-/types-3.1.1.tgz#447481380c42044a8788e46c0dbdf592b338b62f"
integrity sha512-XWGEGWXhM92zvBWiQt2sOwhjTt8eCQbrsRbqkv4WYwW3Zsl4qPpvhHsNt845S42dJXrxgjWvId+jxFQocCayNQ==
"@changesets/types@^3.2.0":
version "3.2.0"
resolved "https://registry.npmjs.org/@changesets/types/-/types-3.2.0.tgz#d8306d7219c3b19b6d860ddeb9d7374e2dd6b035"
integrity sha512-rAmPtOyXpisEEE25CchKNUAf2ApyAeuZ/h78YDoqKZaCk5tUD0lgYZGPIRV9WTPoqNjJULIym37ogc6pkax5jg==
"@changesets/write@^0.1.3":
version "0.1.3"
resolved "https://registry.npmjs.org/@changesets/write/-/write-0.1.3.tgz#00ae575af50274773d7493e77fb96838a08ad8ad"
@@ -5201,22 +5219,12 @@
resolved "https://registry.npmjs.org/@types/node/-/node-14.0.26.tgz#22a3b8a46510da8944b67bfc27df02c34a35331c"
integrity sha512-W+fpe5s91FBGE0pEa0lnqGLL4USgpLgs4nokw16SrBBco/gQxuua7KnArSEOd5iaMqbbSHV10vUDkJYJJqpXKA==
"@types/node@^10.1.0":
version "10.17.28"
resolved "https://registry.npmjs.org/@types/node/-/node-10.17.28.tgz#0e36d718a29355ee51cec83b42d921299200f6d9"
integrity sha512-dzjES1Egb4c1a89C7lKwQh8pwjYmlOAG9dW1pBgxEk57tMrLnssOfEthz8kdkNaBd7lIqQx7APm5+mZ619IiCQ==
"@types/node@^10.12.0":
"@types/node@^10.1.0", "@types/node@^10.12.0":
version "10.17.35"
resolved "https://registry.npmjs.org/@types/node/-/node-10.17.35.tgz#58058f29b870e6ae57b20e4f6e928f02b7129f56"
integrity sha512-gXx7jAWpMddu0f7a+L+txMplp3FnHl53OhQIF9puXKq3hDGY/GjH+MF04oWnV/adPSCrbtHumDCFwzq2VhltWA==
"@types/node@^12.0.0":
version "12.12.53"
resolved "https://registry.npmjs.org/@types/node/-/node-12.12.53.tgz#be0d375933c3d15ef2380dafb3b0350ea7021129"
integrity sha512-51MYTDTyCziHb70wtGNFRwB4l+5JNvdqzFSkbDvpbftEgVUBEE+T5f7pROhWMp/fxp07oNIEQZd5bbfAH22ohQ==
"@types/node@^12.7.1":
"@types/node@^12.0.0", "@types/node@^12.7.1":
version "12.12.58"
resolved "https://registry.npmjs.org/@types/node/-/node-12.12.58.tgz#46dae9b2b9ee5992818c8f7cee01ff4ce03ab44c"
integrity sha512-Be46CNIHWAagEfINOjmriSxuv7IVcqbGe+sDSg2SYCEz/0CRBy7LRASGfRbD8KZkqoePU73Wsx3UvOSFcq/9hA==
@@ -5467,6 +5475,11 @@
dependencies:
"@types/node" "*"
"@types/regression@^2.0.0":
version "2.0.0"
resolved "https://registry.npmjs.org/@types/regression/-/regression-2.0.0.tgz#0677ea78d7bdb37039c02ebbccf062042f756ae3"
integrity sha512-Ch2FD53M1HpFLL6zSTc/sfuyqQcIPy+/PV3xFT6QYtk9EOiMI29XOYmLNxBb1Y0lfMOR/NNa86J1gRc/1jGLyw==
"@types/relateurl@*":
version "0.2.28"
resolved "https://registry.npmjs.org/@types/relateurl/-/relateurl-0.2.28.tgz#6bda7db8653fa62643f5ee69e9f69c11a392e3a6"
@@ -5714,9 +5727,9 @@
source-map "^0.6.1"
"@types/webpack@*", "@types/webpack@^4.41.7", "@types/webpack@^4.41.8":
version "4.41.21"
resolved "https://registry.npmjs.org/@types/webpack/-/webpack-4.41.21.tgz#cc685b332c33f153bb2f5fc1fa3ac8adeb592dee"
integrity sha512-2j9WVnNrr/8PLAB5csW44xzQSJwS26aOnICsP3pSGCEdsu6KYtfQ6QJsVUKHWRnm1bL7HziJsfh5fHqth87yKA==
version "4.41.22"
resolved "https://registry.npmjs.org/@types/webpack/-/webpack-4.41.22.tgz#ff9758a17c6bd499e459b91e78539848c32d0731"
integrity sha512-JQDJK6pj8OMV9gWOnN1dcLCyU9Hzs6lux0wBO4lr1+gyEhIBR9U3FMrz12t2GPkg110XAxEAw2WHF6g7nZIbRQ==
dependencies:
"@types/anymatch" "*"
"@types/node" "*"
@@ -9539,6 +9552,11 @@ dateformat@^3.0.0:
resolved "https://registry.npmjs.org/dateformat/-/dateformat-3.0.3.tgz#a6e37499a4d9a9cf85ef5872044d62901c9889ae"
integrity sha512-jyCETtSl3VMZMWeRo7iY1FL19ges1t55hMo5yaam4Jrsm5EPL89UQkoQRyiI+Yf4k8r2ZpdngkV8hr1lIdjb3Q==
dayjs@^1.9.1:
version "1.9.1"
resolved "https://registry.npmjs.org/dayjs/-/dayjs-1.9.1.tgz#201a755f7db5103ed6de63ba93a984141c754541"
integrity sha512-01NCTBg8cuMJG1OQc6PR7T66+AFYiPwgDvdJmvJBn29NGzIG+DIFxPLNjHzwz3cpFIvG+NcwIjP9hSaPVoOaDg==
de-indent@^1.0.2:
version "1.0.2"
resolved "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz#b2038e846dc33baa5796128d0804b455b8c1e21d"
@@ -19887,6 +19905,11 @@ regjsparser@^0.6.4:
dependencies:
jsesc "~0.5.0"
regression@^2.0.1:
version "2.0.1"
resolved "https://registry.npmjs.org/regression/-/regression-2.0.1.tgz#8d29c3e8224a10850c35e337e85a8b2fac3b0c87"
integrity sha1-jSnD6CJKEIUMNeM36FqLL6w7DIc=
relateurl@^0.2.7:
version "0.2.7"
resolved "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz#54dbf377e51440aca90a4cd274600d3ff2d888a9"