Merge branch 'master' into timbonicus/example-data

This commit is contained in:
Tim Hansen
2020-10-09 10:35:40 -06:00
102 changed files with 1529 additions and 961 deletions
+5 -3
View File
@@ -17,7 +17,6 @@
import express from 'express';
import Router from 'express-promise-router';
import cookieParser from 'cookie-parser';
import Knex from 'knex';
import { Logger } from 'winston';
import { createAuthProvider } from '../providers';
import { Config } from '@backstage/config';
@@ -25,11 +24,12 @@ import { DatabaseKeyStore, TokenFactory, createOidcRouter } from '../identity';
import {
NotFoundError,
PluginEndpointDiscovery,
PluginDatabaseManager,
} from '@backstage/backend-common';
export interface RouterOptions {
logger: Logger;
database: Knex;
database: PluginDatabaseManager;
config: Config;
discovery: PluginEndpointDiscovery;
}
@@ -47,7 +47,9 @@ export async function createRouter({
const keyDurationSeconds = 3600;
const keyStore = await DatabaseKeyStore.create({ database });
const keyStore = await DatabaseKeyStore.create({
database: await database.getClient(),
});
const tokenIssuer = new TokenFactory({
issuer: authUrl,
keyStore,
@@ -53,7 +53,11 @@ export async function startStandaloneServer(
const router = await createRouter({
logger,
config,
database,
database: {
async getClient() {
return database;
},
},
discovery,
});
@@ -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');
});
};
+2 -1
View File
@@ -31,12 +31,13 @@
"express": "^4.17.1",
"express-promise-router": "^3.0.3",
"fs-extra": "^9.0.0",
"git-url-parse": "^11.2.0",
"git-url-parse": "^11.3.0",
"knex": "^0.21.1",
"ldapjs": "^2.2.0",
"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);
@@ -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
+1 -1
View File
@@ -36,7 +36,7 @@
"express": "^4.17.1",
"express-promise-router": "^3.0.3",
"fs-extra": "^9.0.0",
"git-url-parse": "^11.2.0",
"git-url-parse": "^11.3.0",
"globby": "^11.0.0",
"helmet": "^4.0.0",
"jsonschema": "^1.2.6",
+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 (
+1 -1
View File
@@ -31,7 +31,7 @@
"express": "^4.17.1",
"express-promise-router": "^3.0.3",
"fs-extra": "^9.0.1",
"git-url-parse": "^11.2.0",
"git-url-parse": "^11.3.0",
"knex": "^0.21.1",
"node-fetch": "^2.6.0",
"nodegit": "^0.27.0",
@@ -61,6 +61,16 @@ function getGitlabApiUrl(url: string): URL {
);
}
function getAzureApiUrl(url: string): URL {
const { protocol, resource, organization, owner, name } = parseGitUrl(url);
const apiRepoPath = '_apis/git/repositories';
const apiVersion = 'api-version=6.0';
return new URL(
`${protocol}://${resource}/${organization}/${owner}/${apiRepoPath}/${name}?${apiVersion}`,
);
}
function getGithubRequestOptions(config: Config): RequestInit {
const headers: HeadersInit = {
Accept: 'application/vnd.github.v3.raw',
@@ -99,6 +109,26 @@ function getGitlabRequestOptions(config: Config): RequestInit {
};
}
function getAzureRequestOptions(config: Config): RequestInit {
const headers: HeadersInit = {};
const token =
config.getOptionalString('catalog.processors.azureApi.privateToken') ??
process.env.AZURE_TOKEN;
if (token !== '') {
headers.Authorization = `Basic ${Buffer.from(`:${token}`, 'utf8').toString(
'base64',
)}`;
}
const requestOptions: RequestInit = {
headers,
};
return requestOptions;
}
async function getGithubDefaultBranch(
repositoryUrl: string,
config: Config,
@@ -159,6 +189,42 @@ async function getGitlabDefaultBranch(
}
}
async function getAzureDefaultBranch(
repositoryUrl: string,
config: Config,
): Promise<string> {
const path = getAzureApiUrl(repositoryUrl).toString();
const options = getAzureRequestOptions(config);
try {
const urlResponse = await fetch(path, options);
if (!urlResponse.ok) {
throw new Error(
`Failed to load url: ${urlResponse.status} ${urlResponse.statusText}. Make sure you have permission to repository: ${repositoryUrl}`,
);
}
const urlResult = await urlResponse.json();
const idResponse = await fetch(urlResult.url, options);
if (!idResponse.ok) {
throw new Error(
`Failed to load url: ${idResponse.status} ${idResponse.statusText}. Make sure you have permission to repository: ${urlResult.repository.url}`,
);
}
const idResult = await idResponse.json();
const name = idResult.defaultBranch;
if (!name) {
throw new Error('Not found Azure DevOps default branch');
}
return name;
} catch (error) {
throw new Error(`Failed to get Azure DevOps default branch: ${error}`);
}
}
export const getDefaultBranch = async (
repositoryUrl: string,
): Promise<string> => {
@@ -166,6 +232,7 @@ export const getDefaultBranch = async (
const typeMapping = [
{ url: /github*/g, type: 'github' },
{ url: /gitlab*/g, type: 'gitlab' },
{ url: /azure*/g, type: 'azure/api' },
];
const type = typeMapping.filter(item => item.url.test(repositoryUrl))[0]
@@ -177,6 +244,8 @@ export const getDefaultBranch = async (
return await getGithubDefaultBranch(repositoryUrl, config);
case 'gitlab':
return await getGitlabDefaultBranch(repositoryUrl, config);
case 'azure/api':
return await getAzureDefaultBranch(repositoryUrl, config);
default:
throw new Error('Failed to get repository type');
+6 -1
View File
@@ -76,6 +76,7 @@ export const getLocationForEntity = (
switch (type) {
case 'github':
case 'gitlab':
case 'azure/api':
return { type, target };
case 'dir':
if (path.isAbsolute(target)) return { type, target };
@@ -124,9 +125,13 @@ export const checkoutGitRepository = async (
const user =
process.env.GITHUB_PRIVATE_TOKEN_USER ||
process.env.GITLAB_PRIVATE_TOKEN_USER ||
process.env.AZURE_PRIVATE_TOKEN_USER ||
'';
const token =
process.env.GITHUB_TOKEN || process.env.GITLAB_PRIVATE_TOKEN_USER || '';
process.env.GITHUB_TOKEN ||
process.env.GITLAB_PRIVATE_TOKEN_USER ||
process.env.AZURE_TOKEN ||
'';
if (fs.existsSync(repositoryTmpPath)) {
try {
@@ -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;
@@ -0,0 +1,63 @@
/*
* 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 { getVoidLogger } from '@backstage/backend-common';
import { AzurePreparer } from './azure';
import { checkoutGitRepository } from '../../../helpers';
function normalizePath(path: string) {
return path
.replace(/^[a-z]:/i, '')
.split('\\')
.join('/');
}
jest.mock('../../../helpers', () => ({
...jest.requireActual<{}>('../../../helpers'),
checkoutGitRepository: jest.fn(() => '/tmp/backstage-repo/org/name/branch'),
}));
const createMockEntity = (annotations = {}) => {
return {
apiVersion: 'version',
kind: 'TestKind',
metadata: {
name: 'test-component-name',
annotations: {
...annotations,
},
},
};
};
const logger = getVoidLogger();
describe('Azure DevOps preparer', () => {
it('should prepare temp docs path from Azure DevOps repo', async () => {
const preparer = new AzurePreparer(logger);
const mockEntity = createMockEntity({
'backstage.io/techdocs-ref':
'azure/api:https://dev.azure.com/backstage-org/backstage-project/_git/template-repo?path=%2Ftemplate.yaml',
});
const tempDocsPath = await preparer.prepare(mockEntity);
expect(checkoutGitRepository).toHaveBeenCalledTimes(1);
expect(normalizePath(tempDocsPath)).toEqual(
'/tmp/backstage-repo/org/name/branch/template.yaml',
);
});
});
@@ -0,0 +1,55 @@
/*
* 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 path from 'path';
import { Entity } from '@backstage/catalog-model';
import { InputError } from '@backstage/backend-common';
import { PreparerBase } from './types';
import parseGitUrl from 'git-url-parse';
import {
parseReferenceAnnotation,
checkoutGitRepository,
} from '../../../helpers';
import { Logger } from 'winston';
export class AzurePreparer implements PreparerBase {
private readonly logger: Logger;
constructor(logger: Logger) {
this.logger = logger;
}
async prepare(entity: Entity): Promise<string> {
const { type, target } = parseReferenceAnnotation(
'backstage.io/techdocs-ref',
entity,
);
if (type !== 'azure/api') {
throw new InputError(`Wrong target type: ${type}, should be 'azure/api'`);
}
try {
const repoPath = await checkoutGitRepository(target, this.logger);
const parsedGitLocation = parseGitUrl(target);
return path.join(repoPath, parsedGitLocation.filepath);
} catch (error) {
this.logger.debug(`Repo checkout failed with error ${error.message}`);
throw error;
}
}
}
@@ -42,7 +42,8 @@ export class DirectoryPreparer implements PreparerBase {
);
switch (type) {
case 'github':
case 'gitlab': {
case 'gitlab':
case 'azure/api': {
const parsedGitLocation = parseGitUrl(target);
const repoLocation = await checkoutGitRepository(target, this.logger);
@@ -16,5 +16,6 @@
export { DirectoryPreparer } from './dir';
export { GithubPreparer } from './github';
export { GitlabPreparer } from './gitlab';
export { AzurePreparer } from './azure';
export { Preparers } from './preparers';
export type { PreparerBuilder, PreparerBase } from './types';
@@ -30,4 +30,4 @@ export type PreparerBuilder = {
get(entity: Entity): PreparerBase;
};
export type RemoteProtocol = 'dir' | 'github' | 'gitlab' | 'file';
export type RemoteProtocol = 'dir' | 'github' | 'gitlab' | 'file' | 'azure/api';
@@ -53,7 +53,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);