Merge branch 'master' of github.com:spotify/backstage into migrate-to-msw

* 'master' of github.com:spotify/backstage: (34 commits)
  TechDocs Backend: Replace hardcoded github api by configuration value (#3004)
  fix: accordion details design for job stage failure  (#3014)
  fix: responsive of page example in storybook (#3005)
  fix: update the ItemCard component and it's story (#3007)
  rename stories folder to Chip (#3009)
  feat: Display the plugins InfoCards on EntityPage Overwiev suitable full height (#2826)
  fix(cost-insights): More exports to help custom alerting
  chore(scaffolder): bump rjsf
  remove unused CSS (#2999)
  feat: update github pull requests package version
  fix(github-actions): fix crash when viewing ongoing workflows
  fix(cli): removePlugin forgotten await
  catalog-backend: relations db review comments and more tests
  Feat/add aws lambda plugin (#2985)
  chore(deps): bump object-path from 0.11.4 to 0.11.5 (#2983)
  add changeset
  fix CodeSnippet test case warnings
  fix CopyTextButton test case warnings
  fix the warning by wrapping in act
  fix(catalog): fix link to techdocs
  ...
This commit is contained in:
blam
2020-10-21 11:27:18 +02:00
105 changed files with 1276 additions and 994 deletions
@@ -0,0 +1,54 @@
/*
* 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.createTable('entities_relations', table => {
table.comment('All relations between entities in the catalog');
table
.uuid('originating_entity_id')
.references('id')
.inTable('entities')
.onDelete('CASCADE')
.notNullable()
.comment('The originating entity of the relation');
table
.string('source_full_name')
.notNullable()
.comment('The full name of the source entity of the relation');
table
.string('type')
.notNullable()
.comment('The type of the relation between the entities');
table
.string('target_full_name')
.notNullable()
.comment('The full name of the target entity of the relation');
table.primary(['source_full_name', 'type', 'target_full_name']);
});
};
/**
* @param {import('knex')} knex
*/
exports.down = async function down(knex) {
await knex.schema.dropTable('entities_relations');
};
@@ -31,6 +31,7 @@ describe('DatabaseEntitiesCatalog', () => {
entityByName: jest.fn(),
entityByUid: jest.fn(),
removeEntityByUid: jest.fn(),
setRelations: jest.fn(),
addLocation: jest.fn(),
removeLocation: jest.fn(),
location: jest.fn(),
@@ -15,13 +15,14 @@
*/
import { ConflictError, NotFoundError } from '@backstage/backend-common';
import type { Entity } from '@backstage/catalog-model';
import {
entityHasChanges,
generateUpdatedEntity,
getEntityName,
LOCATION_ANNOTATION,
serializeEntityRef,
Entity,
EntityRelationSpec,
} from '@backstage/catalog-model';
import { chunk, groupBy } from 'lodash';
import limiterFactory from 'p-limit';
@@ -191,6 +192,16 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog {
await Promise.all(tasks);
}
// Set the relations originating from an entity using the DB layer
async setRelations(
originatingEntityId: string,
relations: EntityRelationSpec[],
): Promise<void> {
return await this.database.transaction(tx =>
this.database.setRelations(tx, originatingEntityId, relations),
);
}
// 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
+7 -1
View File
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { Entity, Location } from '@backstage/catalog-model';
import { Entity, Location, EntityRelationSpec } from '@backstage/catalog-model';
import type { EntityFilters } from '../database';
//
@@ -37,6 +37,12 @@ export type EntitiesCatalog = {
entities: Entity[],
locationId?: string,
): Promise<void>;
// Same as the DB layer
setRelations(
entityUid: string,
relations: EntityRelationSpec[],
): Promise<void>;
};
//
@@ -15,7 +15,7 @@
*/
import { ConflictError } from '@backstage/backend-common';
import type { Entity, Location } from '@backstage/catalog-model';
import { Entity, Location, parseEntityRef } from '@backstage/catalog-model';
import { DatabaseManager } from './DatabaseManager';
import type {
DbEntityRequest,
@@ -494,6 +494,196 @@ describe('CommonDatabase', () => {
});
});
describe('setRelations', () => {
it('adds a relation for an entity', async () => {
const mockRelations = [
{
source: {
kind: entityRequest.entity.kind,
namespace: entityRequest.entity.metadata.namespace!,
name: entityRequest.entity.metadata.name,
},
target: {
kind: 'component',
namespace: 'asd',
name: 'bleb',
},
type: 'child',
},
];
const entityId = await db.transaction(async tx => {
const [{ entity }] = await db.addEntities(tx, [entityRequest]);
await db.setRelations(tx, entity?.metadata?.uid!, mockRelations);
return entity.metadata.uid;
});
const returnedEntity1 = await db.transaction(tx =>
db.entityByUid(tx, entityId!),
);
expect(returnedEntity1?.entity.relations).toEqual([
{ target: mockRelations[0].target, type: 'child' },
]);
const returnedEntity2 = await db.transaction(tx =>
db.entityByName(tx, mockRelations[0].source),
);
expect(returnedEntity2?.entity.relations).toEqual([
{ target: mockRelations[0].target, type: 'child' },
]);
const [returnedEntity3] = await db.transaction(tx => db.entities(tx));
expect(returnedEntity3?.entity.relations).toEqual([
{ target: mockRelations[0].target, type: 'child' },
]);
});
function makeRelation(source: string, type: string, target: string) {
return {
source: parseEntityRef(source, {
defaultKind: 'x',
defaultNamespace: 'x',
}),
type,
target: parseEntityRef(target, {
defaultKind: 'x',
defaultNamespace: 'x',
}),
};
}
it('should not allow setting relations on nonexistent entities', async () => {
await expect(
db.transaction(async tx => {
await db.setRelations(tx, 'nonexistent', [
makeRelation('a:b/c', 'rel1', 'x:y/z'),
]);
}),
).rejects.toThrow(/constraint failed/);
});
it('should allow setting relations on nonexistent entities without any relations', async () => {
await expect(
db.transaction(async tx => {
await db.setRelations(tx, 'nonexistent', []);
}),
).resolves.toBeUndefined();
});
it('adds multiple relations for entities', async () => {
const entity1 = {
apiVersion: 'v1',
kind: 'a',
metadata: {
name: 'c',
namespace: 'b',
},
};
const entity2 = {
apiVersion: 'v1',
kind: 'x',
metadata: {
name: 'z',
namespace: 'y',
},
};
const fromEntity1 = [
makeRelation('a:b/c', 'rel1', 'x:y/z'),
makeRelation('x:y/z', 'rel2', 'a:b/c'),
makeRelation('a:b/c', 'rel2', 'x:y/z'),
];
const fromEntity2 = [
makeRelation('a:b/c', 'rel4', 'x:y/z'),
makeRelation('a:b/c', 'rel5', 'x:y/z'),
makeRelation('x:y/z', 'rel6', 'a:b/c'),
// relations don't have to reference the originating entity, so this should be fine, but not show up
makeRelation('g:h/i', 'rel8', 'd:e/f'),
];
const { id2: secondEntityId } = await db.transaction(async tx => {
const [{ entity: e1 }, { entity: e2 }] = await db.addEntities(tx, [
{ entity: entity1 },
{ entity: entity2 },
]);
const id1 = e1?.metadata?.uid!;
const id2 = e2?.metadata?.uid!;
await db.setRelations(tx, id1, fromEntity1);
await db.setRelations(tx, id2, fromEntity2);
return { id1, id2 };
});
const res = await db.transaction(tx => db.entities(tx));
expect(
res.map(r => ({
name: r.entity.metadata.name,
relations: r.entity.relations,
})),
).toEqual([
{
name: 'c',
relations: [
{
type: 'rel1',
target: { kind: 'x', namespace: 'y', name: 'z' },
},
{
type: 'rel2',
target: { kind: 'x', namespace: 'y', name: 'z' },
},
{
type: 'rel4',
target: { kind: 'x', namespace: 'y', name: 'z' },
},
{
type: 'rel5',
target: { kind: 'x', namespace: 'y', name: 'z' },
},
],
},
{
name: 'z',
relations: [
{
type: 'rel2',
target: { kind: 'a', namespace: 'b', name: 'c' },
},
{
type: 'rel6',
target: { kind: 'a', namespace: 'b', name: 'c' },
},
],
},
]);
await db.transaction(tx => db.removeEntityByUid(tx, secondEntityId));
const res2 = await db.transaction(tx => db.entities(tx));
expect(
res2.map(r => ({
name: r.entity.metadata.name,
relations: r.entity.relations,
})),
).toEqual([
{
name: 'c',
relations: [
{
type: 'rel1',
target: { kind: 'x', namespace: 'y', name: 'z' },
},
{
type: 'rel2',
target: { kind: 'x', namespace: 'y', name: 'z' },
},
],
},
]);
});
});
describe('entityByName', () => {
it('can get entities case insensitively', async () => {
const entities: Entity[] = [
@@ -27,15 +27,18 @@ import {
generateEntityEtag,
generateEntityUid,
Location,
EntityRelationSpec,
parseEntityName,
} from '@backstage/catalog-model';
import Knex from 'knex';
import lodash from 'lodash';
import type { Logger } from 'winston';
import { buildEntitySearch } from './search';
import type {
import {
Database,
DatabaseLocationUpdateLogEvent,
DatabaseLocationUpdateLogStatus,
DbEntitiesRelationsRow,
DbEntitiesRow,
DbEntitiesSearchRow,
DbEntityRequest,
@@ -123,6 +126,8 @@ export class CommonDatabase implements Database {
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');
} else if (entity.relations !== undefined) {
throw new InputError('May not specify relations for new entities');
}
const newEntity = {
@@ -281,7 +286,7 @@ export class CommonDatabase implements Database {
.select('entities.*')
.orderBy('full_name', 'asc');
return rows.map(row => this.toEntityResponse(row));
return Promise.all(rows.map(row => this.toEntityResponse(tx, row)));
}
async entityByName(
@@ -300,7 +305,7 @@ export class CommonDatabase implements Database {
return undefined;
}
return this.toEntityResponse(rows[0]);
return this.toEntityResponse(tx, rows[0]);
}
async entityByUid(
@@ -317,7 +322,7 @@ export class CommonDatabase implements Database {
return undefined;
}
return this.toEntityResponse(rows[0]);
return this.toEntityResponse(tx, rows[0]);
}
async removeEntityByUid(txOpaque: unknown, uid: string): Promise<void> {
@@ -330,6 +335,34 @@ export class CommonDatabase implements Database {
}
}
async setRelations(
txOpaque: unknown,
originatingEntityId: string,
relations: EntityRelationSpec[],
): Promise<void> {
const tx = txOpaque as Knex.Transaction<any, any>;
// remove all relations that exist for the originating entity id.
await tx<DbEntitiesRelationsRow>('entities_relations')
.where({ originating_entity_id: originatingEntityId })
.del();
const serializeName = (e: EntityName) =>
`${e.kind}:${e.namespace}/${e.name}`.toLowerCase();
const relationsRows: DbEntitiesRelationsRow[] = relations.map(
({ source, target, type }) => ({
originating_entity_id: originatingEntityId,
source_full_name: serializeName(source),
target_full_name: serializeName(target),
type,
}),
);
// TODO(blam): translate constraint failures to sane NotFoundError instead
await tx.batchInsert('entities_relations', relationsRows, BATCH_SIZE);
}
async addLocation(location: Location): Promise<DbLocationsRow> {
return await this.database.transaction<DbLocationsRow>(async tx => {
const row: DbLocationsRow = {
@@ -469,12 +502,27 @@ export class CommonDatabase implements Database {
};
}
private toEntityResponse(row: DbEntitiesRow): DbEntityResponse {
private async toEntityResponse(
tx: Knex.Transaction<any, any>,
row: DbEntitiesRow,
): Promise<DbEntityResponse> {
const entity = JSON.parse(row.data) as Entity;
entity.metadata.uid = row.id;
entity.metadata.etag = row.etag;
entity.metadata.generation = Number(row.generation); // cast due to sqlite
// TODO(Rugvip): This is here because it's simple for now, but we likely
// need to refactor this to be more efficient or introduce pagination.
const relations = await tx<DbEntitiesRelationsRow>('entities_relations')
.where({ source_full_name: row.full_name })
.orderBy(['type', 'target_full_name'])
.select();
entity.relations = relations.map(r => ({
target: parseEntityName(r.target_full_name),
type: r.type,
}));
return {
locationId: row.location_id || undefined,
entity,
+25 -1
View File
@@ -14,7 +14,12 @@
* limitations under the License.
*/
import type { Entity, EntityName, Location } from '@backstage/catalog-model';
import type {
Entity,
EntityName,
Location,
EntityRelationSpec,
} from '@backstage/catalog-model';
export type DbEntitiesRow = {
id: string;
@@ -35,6 +40,13 @@ export type DbEntityResponse = {
entity: Entity;
};
export type DbEntitiesRelationsRow = {
originating_entity_id: string;
source_full_name: string;
type: string;
target_full_name: string;
};
export type DbEntitiesSearchRow = {
entity_id: string;
key: string;
@@ -152,6 +164,18 @@ export type Database = {
removeEntityByUid(tx: unknown, uid: string): Promise<void>;
/**
* Remove current relations for the entity and replace them with the new relations array
* @param tx An ongoing transaction
* @param entityUid the entity uid
* @param relations the relationships to be set
*/
setRelations(
tx: unknown,
entityUid: string,
relations: EntityRelationSpec[],
): Promise<void>;
addLocation(location: Location): Promise<DbLocationsRow>;
removeLocation(tx: unknown, id: string): Promise<void>;
@@ -34,6 +34,7 @@ describe('HigherOrderOperations', () => {
addOrUpdateEntity: jest.fn(),
addEntities: jest.fn(),
removeEntityByUid: jest.fn(),
setRelations: jest.fn(),
batchAddOrUpdateEntities: jest.fn(),
};
locationsCatalog = {
@@ -17,6 +17,7 @@
import { UrlReader } from '@backstage/backend-common';
import {
Entity,
EntityPolicy,
ENTITY_DEFAULT_NAMESPACE,
LocationSpec,
} from '@backstage/catalog-model';
@@ -44,6 +45,7 @@ type Options = {
config: Config;
processors: CatalogProcessor[];
rulesEnforcer: CatalogRulesEnforcer;
policy: EntityPolicy;
};
/**
@@ -74,10 +76,12 @@ export class LocationReaders implements LocationReader {
} else if (item.type === 'entity') {
if (rulesEnforcer.isAllowed(item.entity, item.location)) {
const entity = await this.handleEntity(item, emit);
output.entities.push({
entity,
location: item.location,
});
if (entity) {
output.entities.push({
entity,
location: item.location,
});
}
} else {
output.errors.push({
location: item.location,
@@ -162,25 +166,65 @@ export class LocationReaders implements LocationReader {
private async handleEntity(
item: CatalogProcessorEntityResult,
emit: CatalogProcessorEmit,
): Promise<Entity> {
): Promise<Entity | undefined> {
const { processors, logger } = this.options;
let current = item.entity;
// Construct the name carefully, this happens before validation below
// so we do not want to crash here due to missing metadata or so
const kind = current.kind || '';
const namespace = !current.metadata
? ''
: current.metadata.namespace ?? ENTITY_DEFAULT_NAMESPACE;
const name = !current.metadata ? '' : current.metadata.name;
for (const processor of processors) {
if (processor.processEntity) {
if (processor.preProcessEntity) {
try {
current = await processor.processEntity(current, item.location, emit);
current = await processor.preProcessEntity(
current,
item.location,
emit,
);
} catch (e) {
// Construct the name carefully, if we got validation errors we do
// not want to crash here due to missing metadata or so
const namespace = !current.metadata
? ''
: current.metadata.namespace ?? ENTITY_DEFAULT_NAMESPACE;
const name = !current.metadata ? '' : current.metadata.name;
const message = `Processor ${processor.constructor.name} threw an error while processing entity ${current.kind}:${namespace}/${name} at ${item.location.type} ${item.location.target}, ${e}`;
const message = `Processor ${processor.constructor.name} threw an error while preprocessing entity ${kind}:${namespace}/${name} at ${item.location.type} ${item.location.target}, ${e}`;
emit(result.generalError(item.location, message));
logger.warn(message);
return undefined;
}
}
}
try {
const next = await this.options.policy.enforce(current);
if (!next) {
const message = `Policy unexpectedly returned no data while analyzing entity ${kind}:${namespace}/${name} at ${item.location.type} ${item.location.target}`;
emit(result.generalError(item.location, message));
logger.warn(message);
return undefined;
}
current = next;
} catch (e) {
const message = `Policy check failed while analyzing entity ${kind}:${namespace}/${name} at ${item.location.type} ${item.location.target}, ${e}`;
emit(result.inputError(item.location, message));
logger.warn(message);
return undefined;
}
for (const processor of processors) {
if (processor.postProcessEntity) {
try {
current = await processor.postProcessEntity(
current,
item.location,
emit,
);
} catch (e) {
const message = `Processor ${processor.constructor.name} threw an error while postprocessing entity ${kind}:${namespace}/${name} at ${item.location.type} ${item.location.target}, ${e}`;
emit(result.generalError(item.location, message));
logger.warn(message);
return undefined;
}
}
}
@@ -19,7 +19,10 @@ import lodash from 'lodash';
import { CatalogProcessor } from './types';
export class AnnotateLocationEntityProcessor implements CatalogProcessor {
async processEntity(entity: Entity, location: LocationSpec): Promise<Entity> {
async preProcessEntity(
entity: Entity,
location: LocationSpec,
): Promise<Entity> {
return lodash.merge(
{
metadata: {
@@ -224,7 +224,7 @@ describe('CodeOwnersProcessor', () => {
spec: { owner: '@acme/foo-team' },
});
const result = await processor.processEntity(
const result = await processor.preProcessEntity(
entity as any,
mockLocation(),
);
@@ -235,7 +235,7 @@ describe('CodeOwnersProcessor', () => {
it('should ignore url locations', async () => {
const { entity, processor } = setupTest();
const result = await processor.processEntity(
const result = await processor.preProcessEntity(
entity as any,
mockLocation({ type: 'url' }),
);
@@ -246,7 +246,7 @@ describe('CodeOwnersProcessor', () => {
it('should ignore invalid kinds', async () => {
const { entity, processor } = setupTest({ kind: 'Group' });
const result = await processor.processEntity(
const result = await processor.preProcessEntity(
entity as any,
mockLocation(),
);
@@ -257,7 +257,7 @@ describe('CodeOwnersProcessor', () => {
it('should set owner from codeowner', async () => {
const { entity, processor } = setupTest();
const result = await processor.processEntity(
const result = await processor.preProcessEntity(
entity as any,
mockLocation(),
);
@@ -40,7 +40,10 @@ type Options = {
export class CodeOwnersProcessor implements CatalogProcessor {
constructor(private readonly options: Options) {}
async processEntity(entity: Entity, location: LocationSpec): Promise<Entity> {
async preProcessEntity(
entity: Entity,
location: LocationSpec,
): Promise<Entity> {
// Only continue if the owner is not set
if (
!entity ||
@@ -1,34 +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, EntityPolicy } from '@backstage/catalog-model';
import { CatalogProcessor } from './types';
export class EntityPolicyProcessor implements CatalogProcessor {
private readonly policy: EntityPolicy;
constructor(policy: EntityPolicy) {
this.policy = policy;
}
async processEntity(entity: Entity): Promise<Entity> {
const output = await this.policy.enforce(entity);
if (!output) {
throw new Error(`Entity did not match any known schema`);
}
return output;
}
}
@@ -19,7 +19,7 @@ import * as result from './results';
import { CatalogProcessor, CatalogProcessorEmit } from './types';
export class LocationRefProcessor implements CatalogProcessor {
async processEntity(
async postProcessEntity(
entity: Entity,
_location: LocationSpec,
emit: CatalogProcessorEmit,
@@ -46,7 +46,7 @@ describe('PlaceholderProcessor', () => {
reader,
});
await expect(
processor.processEntity(input, { type: 't', target: 'l' }),
processor.preProcessEntity(input, { type: 't', target: 'l' }),
).resolves.toBe(input);
});
@@ -62,7 +62,7 @@ describe('PlaceholderProcessor', () => {
});
await expect(
processor.processEntity(
processor.preProcessEntity(
{
apiVersion: 'a',
kind: 'k',
@@ -98,7 +98,7 @@ describe('PlaceholderProcessor', () => {
});
await expect(
processor.processEntity(
processor.preProcessEntity(
{
apiVersion: 'a',
kind: 'k',
@@ -122,7 +122,7 @@ describe('PlaceholderProcessor', () => {
});
await expect(
processor.processEntity(
processor.preProcessEntity(
{
apiVersion: 'a',
kind: 'k',
@@ -143,7 +143,7 @@ describe('PlaceholderProcessor', () => {
});
await expect(
processor.processEntity(
processor.preProcessEntity(
{
apiVersion: 'a',
kind: 'k',
@@ -177,7 +177,7 @@ describe('PlaceholderProcessor', () => {
});
await expect(
processor.processEntity(
processor.preProcessEntity(
{
apiVersion: 'a',
kind: 'k',
@@ -209,7 +209,7 @@ describe('PlaceholderProcessor', () => {
});
await expect(
processor.processEntity(
processor.preProcessEntity(
{
apiVersion: 'a',
kind: 'k',
@@ -241,7 +241,7 @@ describe('PlaceholderProcessor', () => {
});
await expect(
processor.processEntity(
processor.preProcessEntity(
{
apiVersion: 'a',
kind: 'k',
@@ -277,7 +277,7 @@ describe('PlaceholderProcessor', () => {
});
await expect(
processor.processEntity(
processor.preProcessEntity(
{
apiVersion: 'a',
kind: 'k',
@@ -316,7 +316,7 @@ describe('PlaceholderProcessor', () => {
});
await expect(
processor.processEntity(
processor.preProcessEntity(
{
apiVersion: 'a',
kind: 'k',
@@ -45,7 +45,10 @@ type Options = {
export class PlaceholderProcessor implements CatalogProcessor {
constructor(private readonly options: Options) {}
async processEntity(entity: Entity, location: LocationSpec): Promise<Entity> {
async preProcessEntity(
entity: Entity,
location: LocationSpec,
): Promise<Entity> {
const process = async (data: any): Promise<[any, boolean]> => {
if (!data || !(data instanceof Object)) {
// Scalars can't have placeholders
@@ -23,7 +23,6 @@ export { AnnotateLocationEntityProcessor } from './AnnotateLocationEntityProcess
export { AzureApiReaderProcessor } from './AzureApiReaderProcessor';
export { BitbucketApiReaderProcessor } from './BitbucketApiReaderProcessor';
export { CodeOwnersProcessor } from './CodeOwnersProcessor';
export { EntityPolicyProcessor } from './EntityPolicyProcessor';
export { FileReaderProcessor } from './FileReaderProcessor';
export { GithubOrgReaderProcessor } from './GithubOrgReaderProcessor';
export { GithubReaderProcessor } from './GithubReaderProcessor';
@@ -46,15 +46,33 @@ export type CatalogProcessor = {
): Promise<boolean>;
/**
* Processes an emitted entity, e.g. by validating or modifying it.
* Pre-processes an emitted entity, after it has been emitted but before it
* has been validated.
*
* @param entity The entity to process
* This type of processing usually involves enriching the entity with
* additional data, and the input entity may actually still be incomplete
* when the processor is invoked.
*
* @param entity The (possibly partial) entity to process
* @param location The location that the entity came from
* @param read Reads the contents of a location
* @param emit A sink for auxiliary items resulting from the processing
* @returns The same entity or a modified version of it
*/
processEntity?(
preProcessEntity?(
entity: Entity,
location: LocationSpec,
emit: CatalogProcessorEmit,
): Promise<Entity>;
/**
* Post-processes an emitted entity, after it has been validated.
*
* @param entity The entity to process
* @param location The location that the entity came from
* @param emit A sink for auxiliary items resulting from the processing
* @returns The same entity or a modified version of it
*/
postProcessEntity?(
entity: Entity,
location: LocationSpec,
emit: CatalogProcessorEmit,
@@ -49,49 +49,6 @@ describe('CatalogBuilder', () => {
reader.read.mockResolvedValue(Buffer.from('junk'));
const builder = new CatalogBuilder(env)
.replaceReaderProcessors([
{
async readLocation(
location: LocationSpec,
_optional: boolean,
emit: CatalogProcessorEmit,
) {
expect(location.type).toBe('test');
emit(result.data(location, await reader.read('ignored')));
return true;
},
},
])
.replaceParserProcessors([
{
async parseData(
data: Buffer,
location: LocationSpec,
emit: CatalogProcessorEmit,
) {
expect(data.toString()).toEqual('junk');
emit(
result.entity(location, {
apiVersion: 'av',
kind: 'Component',
metadata: { name: 'n' },
}),
);
return true;
},
},
])
.replacePreProcessors([
{
async processEntity(entity: Entity) {
expect(entity.apiVersion).toBe('av');
return {
...entity,
metadata: { ...entity.metadata, namespace: 'ns' },
};
},
},
])
.replaceEntityPolicies([
{
async enforce(entity: Entity) {
@@ -108,9 +65,51 @@ describe('CatalogBuilder', () => {
},
},
])
.replacePostProcessors([
.setPlaceholderResolver('t', async ({ value }) => {
expect(value).toBe('tt');
return 'tt2';
})
.setFieldFormatValidators({
isValidEntityName: n => {
expect(n).toBe('n');
return true;
},
})
.replaceProcessors([
{
async processEntity(entity: Entity) {
async readLocation(
location: LocationSpec,
_optional: boolean,
emit: CatalogProcessorEmit,
) {
expect(location.type).toBe('test');
emit(result.data(location, await reader.read('ignored')));
return true;
},
async parseData(
data: Buffer,
location: LocationSpec,
emit: CatalogProcessorEmit,
) {
expect(data.toString()).toEqual('junk');
emit(
result.entity(location, {
apiVersion: 'av',
kind: 'Component',
metadata: { name: 'n', replaced: { $t: 'tt' } },
}),
);
return true;
},
async preProcessEntity(entity: Entity) {
expect(entity.apiVersion).toBe('av');
return {
...entity,
metadata: { ...entity.metadata, namespace: 'ns' },
};
},
async postProcessEntity(entity: Entity) {
expect(entity.metadata.namespace).toBe('ns');
return {
...entity,
metadata: { ...entity.metadata, post: 'p' },
@@ -124,6 +123,7 @@ describe('CatalogBuilder', () => {
type: 'test',
target: '',
});
expect.assertions(8);
expect(added.entities).toEqual([
{
apiVersion: 'av',
@@ -132,6 +132,7 @@ describe('CatalogBuilder', () => {
name: 'n',
namespace: 'ns',
post: 'p',
replaced: 'tt2',
}),
},
]);
@@ -26,7 +26,6 @@ import {
locationEntityV1alpha1Policy,
makeValidator,
NoForeignRootFieldsEntityPolicy,
ReservedFieldsEntityPolicy,
SchemaValidEntityPolicy,
templateEntityV1alpha1Policy,
userEntityV1alpha1Policy,
@@ -48,7 +47,6 @@ import {
BitbucketApiReaderProcessor,
CatalogProcessor,
CodeOwnersProcessor,
EntityPolicyProcessor,
FileReaderProcessor,
GithubOrgReaderProcessor,
GithubReaderProcessor,
@@ -84,36 +82,24 @@ export type CatalogEnvironment = {
*
* The touch points where you can replace or extend behavior are as follows:
*
* - Reader processors can be added or replaced. These implement the
* functionality of reading raw data from a location, in the form of an
* entity definition file.
* - Parser processors can be added or replaced. These accept the raw data as
* read by the previous processors and parse it into raw structured data
* (for example, from a binary buffer containing yaml text, to a JS object
* structure).
* - Entity policies can be added or replaced. These are automatically run
* after the processors' pre-processing steps. All policies are given the
* chance to inspect the entity, and all of them have to pass in order for
* the entity to be considered valid from an overall point of view.
* - Entity kinds can be added or replaced. These are the second line of
* validation that is applied after the entity policies, which adds
* additional kind-specific validation (usually based on a schema). Only one
* of the entity kinds has to accept the entity, but if none of them do, the
* entity is rejected as a whole.
* - Placeholder resolvers can be replaced or added. These run on the raw
* structured data between the parsing and pre-processing steps, to replace
* dollar-prefixed entries with their actual values (like $file).
* - Pre-processors can be added or replaced. These take the raw unvalidated
* data from the parser processors and can enrich or extend it before
* validation. This is the place where for example codeowners data can be
* injected into partial entity definitions.
* - Entity policies can be added or replaced. These are the first line of
* validation from the output of the pre-processing step. All policies are
* given the chance to inspect the entity, and all of them have to pass in
* order for the entity to be considered valid from an overall point of
* view.
* - Field format validators can be replaced. These check the format of
* individual core fields such as metadata.name, such that they adhere to
* certain rules.
* - Entity kinds can be added or replaced. These are the second line of
* validation that is applied after the entity policies, which add additional
* kind-specific validation (usually based on a schema). Only one of the
* entity kinds has to accept the entity, but if none of them do, the
* entity is rejected as a whole.
* - Post-processors can be added or replaced. These take the validated
* entities out of the validation step and can perform additional actions on
* them.
* individual core fields such as metadata.name, to ensure that they adhere
* to certain rules.
* - Processors can be added or replaced. These implement the functionality of
* reading, parsing and processing the entity data before it is persisted in
* the catalog.
*/
export class CatalogBuilder {
private readonly env: CatalogEnvironment;
@@ -121,16 +107,10 @@ export class CatalogBuilder {
private entityPoliciesReplace: boolean;
private entityKinds: EntityPolicy[];
private entityKindsReplace: boolean;
private readerProcessors: CatalogProcessor[];
private readerProcessorsReplace: boolean;
private parserProcessors: CatalogProcessor[];
private parserProcessorsReplace: boolean;
private preProcessors: CatalogProcessor[];
private preProcessorsReplace: boolean;
private postProcessors: CatalogProcessor[];
private postProcessorsReplace: boolean;
private placeholderResolvers: Record<string, PlaceholderResolver>;
private fieldFormatValidators: Partial<Validators>;
private processors: CatalogProcessor[];
private processorsReplace: boolean;
constructor(env: CatalogEnvironment) {
this.env = env;
@@ -138,16 +118,10 @@ export class CatalogBuilder {
this.entityPoliciesReplace = false;
this.entityKinds = [];
this.entityKindsReplace = false;
this.readerProcessors = [];
this.readerProcessorsReplace = false;
this.parserProcessors = [];
this.parserProcessorsReplace = false;
this.preProcessors = [];
this.preProcessorsReplace = false;
this.postProcessors = [];
this.postProcessorsReplace = false;
this.placeholderResolvers = {};
this.fieldFormatValidators = {};
this.processors = [];
this.processorsReplace = false;
}
/**
@@ -212,114 +186,6 @@ export class CatalogBuilder {
return this;
}
/**
* Adds processors that support reading of definition files. These are run
* before the entities are parsed, pre-processed, validated and post-
* processed.
*
* @param processors One or more processors
*/
addReaderProcessor(...processors: CatalogProcessor[]): CatalogBuilder {
this.readerProcessors.push(...processors);
return this;
}
/**
* Sets what processors to use for the reading of definition files. These are
* run before the entities are parsed, pre-processed, validated and post-
* processed.
*
* This function replaces the default set of processors in this stage; use
* with care.
*
* @param processors One or more processors
*/
replaceReaderProcessors(processors: CatalogProcessor[]): CatalogBuilder {
this.readerProcessors = [...processors];
this.readerProcessorsReplace = true;
return this;
}
/**
* Adds processors that run after each definition file has been read, in
* order to parse the raw data. These are run before the entities are
* pre-processed, validated and post-processed.
*
* @param processors One or more processors
*/
addParserProcessor(...processors: CatalogProcessor[]): CatalogBuilder {
this.parserProcessors.push(...processors);
return this;
}
/**
* Sets what processors to run after each definition file has been read, in
* order to parse the raw data. These are run before the entities are
* pre-processed, validated and post-processed.
*
* This function replaces the default set of processors in this stage; use
* with care.
*
* @param processors One or more processors
*/
replaceParserProcessors(processors: CatalogProcessor[]): CatalogBuilder {
this.parserProcessors = [...processors];
this.parserProcessorsReplace = true;
return this;
}
/**
* Adds processors that run after each entity has been read and parsed,
* but before being validated and post-processed.
*
* @param processors One or more processors
*/
addPreProcessor(...processors: CatalogProcessor[]): CatalogBuilder {
this.preProcessors.push(...processors);
return this;
}
/**
* Sets what processors to run after each entity has been read and parsed,
* but before being validated and post-processed.
*
* This function replaces the default set of processors in this stage; use
* with care.
*
* @param processors One or more processors
*/
replacePreProcessors(processors: CatalogProcessor[]): CatalogBuilder {
this.preProcessors = [...processors];
this.preProcessorsReplace = true;
return this;
}
/**
* Adds processors that run after each entity has been read, parsed,
* run through the pre-processors, and validated.
*
* @param processors One or more processors
*/
addPostProcessor(...processors: CatalogProcessor[]): CatalogBuilder {
this.postProcessors.push(...processors);
return this;
}
/**
* Sets what processors to run after each entity has been read, parsed,
* run through the pre-processors, and validated.
*
* This function replaces the default set of processors in this stage; use
* with care.
*
* @param processors One or more processors
*/
replacePostProcessors(processors: CatalogProcessor[]): CatalogBuilder {
this.postProcessors = [...processors];
this.postProcessorsReplace = true;
return this;
}
/**
* Adds, or overwrites, a handler for placeholders (e.g. $file) in entity
* definition files.
@@ -327,8 +193,12 @@ export class CatalogBuilder {
* @param key The key that identifies the placeholder, e.g. "file"
* @param resolver The resolver that gets values for this placeholder
*/
setPlaceholderResolver(key: string, resolver: PlaceholderResolver) {
setPlaceholderResolver(
key: string,
resolver: PlaceholderResolver,
): CatalogBuilder {
this.placeholderResolvers[key] = resolver;
return this;
}
/**
@@ -341,8 +211,34 @@ export class CatalogBuilder {
*
* @param validators The (subset of) validators to set
*/
setFieldFormatValidators(validators: Partial<Validators>) {
setFieldFormatValidators(validators: Partial<Validators>): CatalogBuilder {
lodash.merge(this.fieldFormatValidators, validators);
return this;
}
/**
* Adds entity processors. These are responsible for reading, parsing, and
* processing entities before they are persisted in the catalog.
*
* @param processors One or more processors
*/
addProcessor(...processors: CatalogProcessor[]): CatalogBuilder {
this.processors.push(...processors);
return this;
}
/**
* Sets what entity processors to use. These are responsible for reading,
* parsing, and processing entities before they are persisted in the catalog.
*
* This function replaces the default set of processors; use with care.
*
* @param processors One or more processors
*/
replaceProcessors(processors: CatalogProcessor[]): CatalogBuilder {
this.processors = [...processors];
this.processorsReplace = true;
return this;
}
/**
@@ -355,14 +251,15 @@ export class CatalogBuilder {
}> {
const { config, database, logger } = this.env;
const entityPolicy = this.buildEntityPolicy();
const processors = this.buildProcessors(entityPolicy);
const policy = this.buildEntityPolicy();
const processors = this.buildProcessors();
const rulesEnforcer = CatalogRulesEnforcer.fromConfig(config);
const locationReader = new LocationReaders({
...this.env,
processors,
rulesEnforcer,
policy,
});
const db = await DatabaseManager.createDatabase(
@@ -396,7 +293,6 @@ export class CatalogBuilder {
new FieldFormatEntityPolicy(
makeValidator(this.fieldFormatValidators),
),
new ReservedFieldsEntityPolicy(),
...this.entityPolicies,
];
@@ -418,106 +314,74 @@ export class CatalogBuilder {
]);
}
private buildProcessors(entityPolicy: EntityPolicy): CatalogProcessor[] {
const { config, reader } = this.env;
private buildProcessors(): CatalogProcessor[] {
const { config, logger, reader } = this.env;
const placeholderResolvers = lodash.merge(
{
json: jsonPlaceholderResolver,
yaml: yamlPlaceholderResolver,
text: textPlaceholderResolver,
},
this.placeholderResolvers,
);
const placeholderResolvers: Record<string, PlaceholderResolver> = {
json: jsonPlaceholderResolver,
yaml: yamlPlaceholderResolver,
text: textPlaceholderResolver,
...this.placeholderResolvers,
};
const processors = this.processorsReplace
? this.processors
: [
new FileReaderProcessor(),
GithubOrgReaderProcessor.fromConfig(config, { logger }),
LdapOrgReaderProcessor.fromConfig(config, { logger }),
new UrlReaderProcessor({ reader, logger }),
new YamlProcessor(),
new CodeOwnersProcessor({ reader }),
new LocationRefProcessor(),
new AnnotateLocationEntityProcessor(),
];
return [
StaticLocationProcessor.fromConfig(config),
...this.buildReaderProcessors(),
...this.buildParserProcessors(),
new PlaceholderProcessor({ resolvers: placeholderResolvers, reader }),
...this.buildPreProcessors(),
new EntityPolicyProcessor(entityPolicy),
...this.buildPostProcessors(),
...this.buildDeprecatedReaderProcessors(),
...processors,
];
}
private buildReaderProcessors(): CatalogProcessor[] {
const { config, logger, reader } = this.env;
// TODO(Rugvip): These are added for backwards compatibility if config exists
// The idea is to have everyone migrate from using the old processors to
// the new integration config driven UrlReaders. In an upcoming release we
// can then completely remove support for the old processors, but still
// keep handling the deprecated location types for a while, but with a
// warning.
private buildDeprecatedReaderProcessors(): CatalogProcessor[] {
const { config, logger } = this.env;
if (this.readerProcessorsReplace) {
return this.readerProcessors;
}
// TODO(Rugvip): These are added for backwards compatibility if config exists
// The idea is to have everyone migrate from using the old processors to the new
// integration config driven UrlReaders. In an upcoming release we can then completely
// remove support for the old processors, but still keep handling the deprecated location
// types for a while, but with a warning.
const oldProcessors = [];
const result = [];
const pc = config.getOptionalConfig('catalog.processors');
if (pc?.has('github')) {
logger.warn(
`Using deprecated configuration for catalog.processors.github, move to using integrations.github instead`,
);
oldProcessors.push(GithubReaderProcessor.fromConfig(config, logger));
result.push(GithubReaderProcessor.fromConfig(config, logger));
}
if (pc?.has('gitlabApi')) {
logger.warn(
`Using deprecated configuration for catalog.processors.gitlabApi, move to using integrations.gitlab instead`,
);
oldProcessors.push(new GitlabApiReaderProcessor(config));
oldProcessors.push(new GitlabReaderProcessor());
result.push(new GitlabApiReaderProcessor(config));
result.push(new GitlabReaderProcessor());
}
if (pc?.has('bitbucketApi')) {
logger.warn(
`Using deprecated configuration for catalog.processors.bitbucketApi, move to using integrations.bitbucket instead`,
);
oldProcessors.push(new BitbucketApiReaderProcessor(config));
result.push(new BitbucketApiReaderProcessor(config));
}
if (pc?.has('azureApi')) {
logger.warn(
`Using deprecated configuration for catalog.processors.azureApi, move to using integrations.azure instead`,
);
oldProcessors.push(new AzureApiReaderProcessor(config));
result.push(new AzureApiReaderProcessor(config));
}
return [
new FileReaderProcessor(),
...oldProcessors,
GithubOrgReaderProcessor.fromConfig(config, { logger }),
LdapOrgReaderProcessor.fromConfig(config, { logger }),
new UrlReaderProcessor({ reader, logger }),
...this.readerProcessors,
];
}
private buildParserProcessors(): CatalogProcessor[] {
if (this.parserProcessorsReplace) {
return this.parserProcessors;
}
return [new YamlProcessor(), ...this.parserProcessors];
}
private buildPreProcessors(): CatalogProcessor[] {
const { reader } = this.env;
if (this.preProcessorsReplace) {
return this.preProcessors;
}
return [new CodeOwnersProcessor({ reader }), ...this.preProcessors];
}
private buildPostProcessors(): CatalogProcessor[] {
if (this.postProcessorsReplace) {
return this.postProcessors;
}
return [
new LocationRefProcessor(),
new AnnotateLocationEntityProcessor(),
...this.postProcessors,
];
return result;
}
}
@@ -35,6 +35,7 @@ describe('createRouter', () => {
addOrUpdateEntity: jest.fn(),
addEntities: jest.fn(),
removeEntityByUid: jest.fn(),
setRelations: jest.fn(),
batchAddOrUpdateEntities: jest.fn(),
};
locationsCatalog = {
@@ -14,24 +14,23 @@
* limitations under the License.
*/
import React from 'react';
import { Entity, ENTITY_DEFAULT_NAMESPACE } from '@backstage/catalog-model';
import {
Grid,
Typography,
makeStyles,
Chip,
IconButton,
Card,
CardContent,
CardHeader,
Chip,
Divider,
Grid,
IconButton,
makeStyles,
Typography,
} from '@material-ui/core';
import { Entity } from '@backstage/catalog-model';
import GitHubIcon from '@material-ui/icons/GitHub';
import { IconLinkVertical } from './IconLinkVertical';
import EditIcon from '@material-ui/icons/Edit';
import DocsIcon from '@material-ui/icons/Description';
import EditIcon from '@material-ui/icons/Edit';
import GitHubIcon from '@material-ui/icons/GitHub';
import React from 'react';
import { IconLinkVertical } from './IconLinkVertical';
const useStyles = makeStyles(theme => ({
links: {
@@ -59,6 +58,15 @@ const useStyles = makeStyles(theme => ({
description: {
wordBreak: 'break-word',
},
gridItemCard: {
display: 'flex',
flexDirection: 'column',
height: 'calc(100% - 10px)', // for pages without content header
marginBottom: '10px',
},
gridItemCardContent: {
flex: 1,
},
}));
const iconMap: Record<string, React.ReactNode> = {
@@ -83,14 +91,15 @@ function getCodeLinkInfo(entity: Entity): CodeLinkInfo {
type AboutCardProps = {
entity: Entity;
variant?: string;
};
export function AboutCard({ entity }: AboutCardProps) {
export function AboutCard({ entity, variant }: AboutCardProps) {
const classes = useStyles();
const codeLink = getCodeLinkInfo(entity);
return (
<Card>
<Card className={variant === 'gridItem' ? classes.gridItemCard : ''}>
<CardHeader
title="About"
action={
@@ -107,15 +116,17 @@ export function AboutCard({ entity }: AboutCardProps) {
}
label="View Techdocs"
icon={<DocsIcon />}
href={`/docs/${entity.kind}:${entity.metadata.namespace ?? ''}:${
entity.metadata.name
}`}
href={`/docs/${
entity.metadata.namespace || ENTITY_DEFAULT_NAMESPACE
}/${entity.kind}/${entity.metadata.name}`}
/>
</nav>
}
/>
<Divider />
<CardContent>
<CardContent
className={variant === 'gridItem' ? classes.gridItemCardContent : ''}
>
<Grid container>
<AboutField label="Description" gridSizes={{ xs: 12 }}>
<Typography
@@ -30,7 +30,7 @@ import {
import { Box, useTheme } from '@material-ui/core';
import BarChartTick from './BarChartTick';
import BarChartStepper from './BarChartStepper';
import Tooltip, { TooltipItemProps } from '../Tooltip';
import { Tooltip, TooltipItemProps } from '../Tooltip';
import { currencyFormatter } from '../../utils/formatters';
import {
@@ -16,7 +16,7 @@
import React from 'react';
import moment from 'moment';
import { TooltipPayload, TooltipProps } from 'recharts';
import Tooltip, { TooltipItemProps } from '../../components/Tooltip';
import { Tooltip, TooltipItemProps } from '../../components/Tooltip';
import { DEFAULT_DATE_FORMAT } from '../../types';
export type CostOverviewTooltipProps = TooltipProps & {
@@ -14,5 +14,7 @@
* limitations under the License.
*/
export { default } from './Tooltip';
export * from './TooltipItem';
export { default as Tooltip } from './Tooltip';
export type { TooltipProps } from './Tooltip';
export { default as TooltipItem } from './TooltipItem';
export type { TooltipItemProps } from './TooltipItem';
@@ -16,3 +16,4 @@
export { default as BarChart } from './BarChart';
export { default as LegendItem } from './LegendItem';
export * from './Tooltip';
+1
View File
@@ -17,4 +17,5 @@
export { plugin } from './plugin';
export * from './api';
export * from './components';
export { useCurrency } from './hooks';
export * from './types';
+1 -1
View File
@@ -17,7 +17,7 @@
export type Step = {
name: string;
status: string;
conclusion: string;
conclusion?: string;
number: number; // starts from 1
started_at: string;
completed_at: string;
@@ -81,10 +81,9 @@ const WidgetContent = ({
export const LatestWorkflowRunCard = ({
entity,
branch = 'master',
}: {
entity: Entity;
branch: string;
}) => {
// Display the card full height suitable for
variant,
}: Props) => {
const errorApi = useApi(errorApiRef);
const [owner, repo] = (
entity?.metadata.annotations?.[GITHUB_ACTIONS_ANNOTATION] ?? '/'
@@ -102,7 +101,7 @@ export const LatestWorkflowRunCard = ({
}, [error, errorApi]);
return (
<InfoCard title={`Last ${branch} build`}>
<InfoCard title={`Last ${branch} build`} variant={variant}>
<WidgetContent
error={error}
loading={loading}
@@ -113,14 +112,18 @@ export const LatestWorkflowRunCard = ({
);
};
type Props = {
entity: Entity;
branch: string;
variant?: string;
};
export const LatestWorkflowsForBranchCard = ({
entity,
branch = 'master',
}: {
entity: Entity;
branch: string;
}) => (
<InfoCard title={`Last ${branch} build`}>
variant,
}: Props) => (
<InfoCard title={`Last ${branch} build`} variant={variant}>
<WorkflowRunsTable branch={branch} entity={entity} />
</InfoCard>
);
@@ -18,9 +18,9 @@ import { errorApiRef, useApi } from '@backstage/core-api';
import { GITHUB_ACTIONS_ANNOTATION } from '../useProjectName';
import { useWorkflowRuns } from '../useWorkflowRuns';
import React, { useEffect } from 'react';
import { EmptyState, Table } from '@backstage/core';
import { EmptyState, InfoCard, Table } from '@backstage/core';
import { WorkflowRunStatus } from '../WorkflowRunStatus';
import { Button, Card, Link, TableContainer } from '@material-ui/core';
import { Button, Link } from '@material-ui/core';
import { generatePath, Link as RouterLink } from 'react-router-dom';
const firstLine = (message: string): string => message.split('\n')[0];
@@ -30,6 +30,7 @@ export type Props = {
branch?: string;
dense?: boolean;
limit?: number;
variant?: string;
};
export const RecentWorkflowRunsCard = ({
@@ -37,6 +38,7 @@ export const RecentWorkflowRunsCard = ({
branch,
dense = false,
limit = 5,
variant,
}: Props) => {
const errorApi = useApi(errorApiRef);
const [owner, repo] = (
@@ -70,15 +72,19 @@ export const RecentWorkflowRunsCard = ({
}
/>
) : (
<TableContainer component={Card}>
<InfoCard
title="Recent Workflow Runs"
subheader={branch ? `Branch: ${branch}` : 'All Branches'}
noPadding
variant={variant}
>
<Table
title="Recent Workflow Runs"
subtitle={branch ? `Branch: ${branch}` : 'All Branches'}
isLoading={loading}
options={{
search: false,
paging: false,
padding: dense ? 'dense' : 'default',
toolbar: false,
}}
columns={[
{
@@ -98,6 +104,6 @@ export const RecentWorkflowRunsCard = ({
]}
data={runs}
/>
</TableContainer>
</InfoCard>
);
};
@@ -77,8 +77,9 @@ const JobsList = ({ jobs, entity }: { jobs?: Jobs; entity: Entity }) => {
<Box>
{jobs &&
jobs.total_count > 0 &&
jobs.jobs.map((job: Job) => (
jobs.jobs.map(job => (
<JobListItem
key={job.id}
job={job}
className={
job.status !== 'success' ? classes.failed : classes.success
@@ -108,7 +109,7 @@ const StepView = ({ step }: { step: Step }) => {
<TableCell>
<WorkflowRunStatus
status={step.status.toUpperCase()}
conclusion={step.conclusion.toUpperCase()}
conclusion={step.conclusion?.toUpperCase()}
/>
</TableCell>
</TableRow>
@@ -142,8 +143,8 @@ const JobListItem = ({
<AccordionDetails className={classes.accordionDetails}>
<TableContainer>
<Table>
{job.steps.map((step: Step) => (
<StepView step={step} />
{job.steps.map(step => (
<StepView key={step.number} step={step} />
))}
</Table>
</TableContainer>
@@ -61,12 +61,18 @@ const WidgetContent = ({
);
};
export const LatestRunCard = ({ branch = 'master' }: { branch: string }) => {
export const LatestRunCard = ({
branch = 'master',
variant,
}: {
branch: string;
variant?: string;
}) => {
const { owner, repo } = useProjectSlugFromEntity();
const [{ builds, loading }] = useBuilds(owner, repo, branch);
const lastRun = builds ?? {};
return (
<InfoCard title={`Last ${branch} build`}>
<InfoCard title={`Last ${branch} build`} variant={variant}>
<WidgetContent loading={loading} branch={branch} lastRun={lastRun} />
</InfoCard>
);
@@ -88,9 +88,10 @@ const LighthouseAuditSummary: FC<{ audit: Audit; dense?: boolean }> = ({
return <StructuredMetadataTable metadata={tableData} dense={dense} />;
};
export const LastLighthouseAuditCard: FC<{ dense?: boolean }> = ({
dense = false,
}) => {
export const LastLighthouseAuditCard: FC<{
dense?: boolean;
variant?: string;
}> = ({ dense = false, variant }) => {
const { value: website, loading, error } = useWebsiteForEntity();
let content;
@@ -105,5 +106,9 @@ export const LastLighthouseAuditCard: FC<{ dense?: boolean }> = ({
<LighthouseAuditSummary audit={website.lastAudit} dense={dense} />
);
}
return <InfoCard title="Lighthouse Audit">{content}</InfoCard>;
return (
<InfoCard title="Lighthouse Audit" variant={variant}>
{content}
</InfoCard>
);
};
+2 -2
View File
@@ -28,8 +28,8 @@
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"@rjsf/core": "^2.1.0",
"@rjsf/material-ui": "^2.1.0",
"@rjsf/core": "^2.4.0",
"@rjsf/material-ui": "^2.4.0",
"classnames": "^2.2.6",
"moment": "^2.26.0",
"react": "^16.13.1",
@@ -125,7 +125,9 @@ export const JobStage = ({ endedAt, startedAt, name, log, status }: Props) => {
</AccordionSummary>
<AccordionDetails className={classes.accordionDetails}>
{log.length === 0 ? (
<Box px={4}>No logs available for this step</Box>
<Box px={9} pb={2} width="100%">
No logs available for this step
</Box>
) : (
<Suspense fallback={<LinearProgress />}>
<div style={{ height: '20vh', width: '100%' }}>
@@ -42,9 +42,11 @@ interface IGitlabBranch {
};
}
function getGithubApiUrl(url: string): URL {
function getGithubApiUrl(config: Config, url: string): URL {
const { protocol, owner, name } = parseGitUrl(url);
const apiBaseUrl = 'api.github.com';
const apiBaseUrl =
config.getOptionalString('integrations.github.apiBaseUrl') ||
'api.github.com';
const apiRepos = 'repos';
return new URL(`${protocol}://${apiBaseUrl}/${apiRepos}/${owner}/${name}`);
@@ -133,7 +135,7 @@ async function getGithubDefaultBranch(
repositoryUrl: string,
config: Config,
): Promise<string> {
const path = getGithubApiUrl(repositoryUrl).toString();
const path = getGithubApiUrl(config, repositoryUrl).toString();
const options = getGithubRequestOptions(config);
try {