Merge branch 'backstage:master' into topic/improve-badges-frontend-readme

This commit is contained in:
Andre Wanlin
2021-09-28 08:05:35 -05:00
committed by GitHub
51 changed files with 639 additions and 800 deletions
@@ -39,9 +39,7 @@
},
"devDependencies": {
"@backstage/cli": "^0.7.13",
"@backstage/test-utils": "^0.1.16",
"@types/lodash": "^4.14.151",
"msw": "^0.29.0"
"@types/lodash": "^4.14.151"
},
"files": [
"dist",
@@ -0,0 +1,38 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// @ts-check
/**
* @param {import('knex').Knex} knex
*/
exports.up = async function up(knex) {
await knex.schema.alterTable('refresh_state', table => {
table
.text('unprocessed_hash')
.nullable()
.comment('A hash of the unprocessed contents, used to detect changes');
});
};
/**
* @param {import('knex').Knex} knex
*/
exports.down = async function down(knex) {
await knex.schema.alterTable('refresh_state', table => {
table.dropColumn('unprocessed_hash');
});
};
-3
View File
@@ -36,7 +36,6 @@
"@backstage/config": "^0.1.10",
"@backstage/errors": "^0.1.2",
"@backstage/integration": "^0.6.5",
"@backstage/plugin-search-backend-node": "^0.4.2",
"@backstage/search-common": "^0.2.0",
"@octokit/graphql": "^4.5.8",
"@types/express": "^4.17.6",
@@ -53,10 +52,8 @@
"knex": "^0.95.1",
"lodash": "^4.17.21",
"luxon": "^2.0.2",
"morgan": "^1.10.0",
"p-limit": "^3.0.2",
"prom-client": "^13.2.0",
"qs": "^6.9.4",
"uuid": "^8.0.0",
"winston": "^3.2.1",
"yaml": "^1.9.2",
@@ -30,6 +30,7 @@ import {
} from './tables';
import { createRandomRefreshInterval } from '../refresh';
import { timestampToDateTime } from './conversion';
import { generateStableHash } from './util';
describe('Default Processing Database', () => {
const defaultLogger = getVoidLogger();
@@ -309,6 +310,7 @@ describe('Default Processing Database', () => {
entity_id: id,
entity_ref: 'location:default/fakelocation',
unprocessed_entity: '{}',
unprocessed_hash: generateStableHash({} as any),
processed_entity: '{}',
errors: '[]',
next_update_at: '2021-04-01 13:37:00',
@@ -996,6 +998,100 @@ describe('Default Processing Database', () => {
},
60_000,
);
it.each(databases.eachSupportedId())(
'should support replacing modified entities during a full update, %p',
async databaseId => {
const { knex, db } = await createDatabase(databaseId);
await db.transaction(async tx => {
await db.replaceUnprocessedEntities(tx, {
type: 'full',
sourceKey: 'lols',
items: [
{
entity: {
apiVersion: '1',
kind: 'Component',
metadata: { name: 'a' },
spec: { marker: 'WILL_CHANGE' },
} as Entity,
locationKey: 'file:///tmp/a',
},
{
entity: {
apiVersion: '1',
kind: 'Component',
metadata: { name: 'b' },
spec: { marker: 'NEVER_CHANGES' },
} as Entity,
locationKey: 'file:///tmp/b',
},
],
});
});
let state = await knex<DbRefreshStateRow>('refresh_state').select();
expect(state).toEqual(
expect.arrayContaining([
expect.objectContaining({
entity_ref: 'component:default/a',
location_key: 'file:///tmp/a',
unprocessed_entity: expect.stringContaining('WILL_CHANGE'),
}),
expect.objectContaining({
entity_ref: 'component:default/b',
location_key: 'file:///tmp/b',
unprocessed_entity: expect.stringContaining('NEVER_CHANGES'),
}),
]),
);
await db.transaction(async tx => {
await db.replaceUnprocessedEntities(tx, {
type: 'full',
sourceKey: 'lols',
items: [
{
entity: {
apiVersion: '1',
kind: 'Component',
metadata: { name: 'a' },
spec: { marker: 'HAS_CHANGED' },
} as Entity,
locationKey: 'file:///tmp/a',
},
{
entity: {
apiVersion: '1',
kind: 'Component',
metadata: { name: 'b' },
spec: { marker: 'NEVER_CHANGES' },
} as Entity,
locationKey: 'file:///tmp/b',
},
],
});
});
state = await knex<DbRefreshStateRow>('refresh_state').select();
expect(state).toEqual(
expect.arrayContaining([
expect.objectContaining({
entity_ref: 'component:default/a',
location_key: 'file:///tmp/a',
unprocessed_entity: expect.stringContaining('HAS_CHANGED'),
}),
expect.objectContaining({
entity_ref: 'component:default/b',
location_key: 'file:///tmp/b',
unprocessed_entity: expect.stringContaining('NEVER_CHANGES'),
}),
]),
);
},
60_000,
);
});
describe('getProcessableEntities', () => {
@@ -41,6 +41,7 @@ import {
ListAncestorsResult,
UpdateEntityCacheOptions,
} from './types';
import { generateStableHash } from './util';
// 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
@@ -156,7 +157,7 @@ export class DefaultProcessingDatabase implements ProcessingDatabase {
): Promise<void> {
const tx = txOpaque as Knex.Transaction;
const { toAdd, toRemove } = await this.createDelta(tx, options);
const { toUpsert, toRemove } = await this.createDelta(tx, options);
if (toRemove.length) {
// TODO(freben): Batch split, to not hit variable limits?
@@ -273,14 +274,27 @@ export class DefaultProcessingDatabase implements ProcessingDatabase {
);
}
if (toAdd.length) {
for (const { entity, locationKey } of toAdd) {
if (toUpsert.length) {
for (const {
deferred: { entity, locationKey },
hash,
} of toUpsert) {
const entityRef = stringifyEntityRef(entity);
try {
let ok = await this.insertUnprocessedEntity(tx, entity, locationKey);
let ok = await this.updateUnprocessedEntity(
tx,
entity,
hash,
locationKey,
);
if (!ok) {
ok = await this.updateUnprocessedEntity(tx, entity, locationKey);
ok = await this.insertUnprocessedEntity(
tx,
entity,
hash,
locationKey,
);
}
if (ok) {
@@ -448,6 +462,7 @@ export class DefaultProcessingDatabase implements ProcessingDatabase {
private async updateUnprocessedEntity(
tx: Knex.Transaction,
entity: Entity,
hash: string,
locationKey?: string,
): Promise<boolean> {
const entityRef = stringifyEntityRef(entity);
@@ -456,6 +471,7 @@ export class DefaultProcessingDatabase implements ProcessingDatabase {
const refreshResult = await tx<DbRefreshStateRow>('refresh_state')
.update({
unprocessed_entity: serializedEntity,
unprocessed_hash: hash,
location_key: locationKey,
last_discovery_at: tx.fn.now(),
// We only get to this point if a processed entity actually had any changes, or
@@ -483,6 +499,7 @@ export class DefaultProcessingDatabase implements ProcessingDatabase {
private async insertUnprocessedEntity(
tx: Knex.Transaction,
entity: Entity,
hash: string,
locationKey?: string,
): Promise<boolean> {
const entityRef = stringifyEntityRef(entity);
@@ -493,6 +510,7 @@ export class DefaultProcessingDatabase implements ProcessingDatabase {
entity_id: uuid(),
entity_ref: entityRef,
unprocessed_entity: serializedEntity,
unprocessed_hash: hash,
errors: '',
location_key: locationKey,
next_update_at: tx.fn.now(),
@@ -558,10 +576,16 @@ export class DefaultProcessingDatabase implements ProcessingDatabase {
private async createDelta(
tx: Knex.Transaction,
options: ReplaceUnprocessedEntitiesOptions,
): Promise<{ toAdd: DeferredEntity[]; toRemove: string[] }> {
): Promise<{
toUpsert: { deferred: DeferredEntity; hash: string }[];
toRemove: string[];
}> {
if (options.type === 'delta') {
return {
toAdd: options.added,
toUpsert: options.added.map(e => ({
deferred: e,
hash: generateStableHash(e.entity),
})),
toRemove: options.removed.map(e => stringifyEntityRef(e.entity)),
};
}
@@ -570,39 +594,55 @@ export class DefaultProcessingDatabase implements ProcessingDatabase {
const oldRefs = await tx<DbRefreshStateReferencesRow>(
'refresh_state_references',
)
.where({ source_key: options.sourceKey })
.leftJoin<DbRefreshStateRow>('refresh_state', {
target_entity_ref: 'entity_ref',
})
.select(['target_entity_ref', 'location_key']);
.where({ source_key: options.sourceKey })
.select({
target_entity_ref: 'refresh_state_references.target_entity_ref',
location_key: 'refresh_state.location_key',
unprocessed_hash: 'refresh_state.unprocessed_hash',
});
const items = options.items.map(deferred => ({
deferred,
ref: stringifyEntityRef(deferred.entity),
hash: generateStableHash(deferred.entity),
}));
const oldRefsSet = new Map(
oldRefs.map(r => [r.target_entity_ref, r.location_key]),
oldRefs.map(r => [
r.target_entity_ref,
{
locationKey: r.location_key,
oldEntityHash: r.unprocessed_hash,
},
]),
);
const newRefsSet = new Set(items.map(item => item.ref));
const toAdd = new Array<DeferredEntity>();
const toUpsert = new Array<{ deferred: DeferredEntity; hash: string }>();
const toRemove = oldRefs
.map(row => row.target_entity_ref)
.filter(ref => !newRefsSet.has(ref));
for (const item of items) {
if (!oldRefsSet.has(item.ref)) {
const oldRef = oldRefsSet.get(item.ref);
const upsertItem = { deferred: item.deferred, hash: item.hash };
if (!oldRef) {
// Add any entity that does not exist in the database
toAdd.push(item.deferred);
} else if (oldRefsSet.get(item.ref) !== item.deferred.locationKey) {
toUpsert.push(upsertItem);
} else if (oldRef.locationKey !== item.deferred.locationKey) {
// Remove and then re-add any entity that exists, but with a different location key
toRemove.push(item.ref);
toAdd.push(item.deferred);
toUpsert.push(upsertItem);
} else if (oldRef.oldEntityHash !== item.hash) {
// Entities with modifications should be pushed through too
toUpsert.push(upsertItem);
}
}
return { toAdd, toRemove };
return { toUpsert, toRemove };
}
/**
@@ -626,10 +666,12 @@ export class DefaultProcessingDatabase implements ProcessingDatabase {
// their entity ref.
for (const { entity, locationKey } of options.entities) {
const entityRef = stringifyEntityRef(entity);
const hash = generateStableHash(entity);
const updated = await this.updateUnprocessedEntity(
tx,
entity,
hash,
locationKey,
);
if (updated) {
@@ -640,6 +682,7 @@ export class DefaultProcessingDatabase implements ProcessingDatabase {
const inserted = await this.insertUnprocessedEntity(
tx,
entity,
hash,
locationKey,
);
if (inserted) {
@@ -24,6 +24,7 @@ export type DbRefreshStateRow = {
entity_id: string;
entity_ref: string;
unprocessed_entity: string;
unprocessed_hash?: string;
processed_entity?: string;
result_hash?: string;
cache?: string;
@@ -0,0 +1,25 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import { createHash } from 'crypto';
import stableStringify from 'fast-json-stable-stringify';
export function generateStableHash(entity: Entity) {
return createHash('sha1')
.update(stableStringify({ ...entity }))
.digest('hex');
}
+1 -4
View File
@@ -49,10 +49,7 @@
"@testing-library/react": "^11.2.5",
"@testing-library/user-event": "^13.1.8",
"@testing-library/react-hooks": "^3.4.2",
"@types/jest": "^26.0.7",
"@types/node": "^14.14.32",
"cross-fetch": "^3.0.6",
"msw": "^0.29.0"
"@types/jest": "^26.0.7"
},
"files": [
"dist"
+1 -6
View File
@@ -31,7 +31,6 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/backend-common": "^0.9.0",
"@backstage/catalog-model": "^0.9.0",
"@backstage/config": "^0.1.8",
"@graphql-modules/core": "^0.7.17",
@@ -48,12 +47,8 @@
"@graphql-codegen/cli": "^1.21.3",
"@graphql-codegen/typescript": "^1.17.7",
"@graphql-codegen/typescript-resolvers": "^1.17.7",
"@types/express": "^4.17.7",
"@types/supertest": "^2.0.8",
"eslint-plugin-graphql": "^4.0.0",
"msw": "^0.29.0",
"supertest": "^6.1.3",
"ts-node": "^10.0.0"
"msw": "^0.29.0"
},
"files": [
"dist"
-3
View File
@@ -38,7 +38,6 @@
"@backstage/integration": "^0.6.5",
"@backstage/integration-react": "^0.1.10",
"@backstage/plugin-catalog-react": "^0.5.0",
"@backstage/theme": "^0.2.10",
"@material-ui/core": "^4.12.2",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.57",
@@ -50,7 +49,6 @@
"react-dom": "^16.13.1",
"react-hook-form": "^7.12.2",
"react-router": "6.0.0-beta.0",
"react-router-dom": "6.0.0-beta.0",
"react-use": "^17.2.4",
"yaml": "^1.10.0",
"lodash": "^4.17.21"
@@ -65,7 +63,6 @@
"@testing-library/react-hooks": "^3.3.0",
"@testing-library/user-event": "^13.1.8",
"@types/jest": "^26.0.7",
"@types/node": "^14.14.32",
"cross-fetch": "^3.0.6",
"msw": "^0.29.0"
},
-4
View File
@@ -45,12 +45,10 @@
"qs": "^6.9.4",
"react": "^16.13.1",
"react-router": "6.0.0-beta.0",
"react-router-dom": "6.0.0-beta.0",
"react-use": "^17.2.4"
},
"devDependencies": {
"@backstage/cli": "^0.7.13",
"@backstage/dev-utils": "^0.2.10",
"@backstage/test-utils": "^0.1.17",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^11.2.5",
@@ -58,9 +56,7 @@
"@testing-library/user-event": "^13.1.8",
"@types/jest": "^26.0.7",
"@types/jwt-decode": "^3.1.0",
"@types/node": "^14.14.32",
"cross-fetch": "^3.0.6",
"msw": "^0.29.0",
"react-test-renderer": "^16.13.1"
},
"files": [
+1 -10
View File
@@ -35,8 +35,6 @@
"@backstage/catalog-model": "^0.9.3",
"@backstage/core-components": "^0.5.0",
"@backstage/core-plugin-api": "^0.1.8",
"@backstage/errors": "^0.1.1",
"@backstage/integration": "^0.6.5",
"@backstage/integration-react": "^0.1.10",
"@backstage/plugin-catalog-react": "^0.5.0",
"@backstage/theme": "^0.2.10",
@@ -44,14 +42,11 @@
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.57",
"@types/react": "*",
"classnames": "^2.2.6",
"git-url-parse": "^11.6.0",
"lodash": "^4.17.21",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-helmet": "6.1.0",
"react-router": "6.0.0-beta.0",
"react-router-dom": "6.0.0-beta.0",
"react-use": "^17.2.4"
},
"devDependencies": {
@@ -61,13 +56,9 @@
"@backstage/test-utils": "^0.1.17",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^11.2.5",
"@testing-library/react-hooks": "^3.3.0",
"@testing-library/user-event": "^13.1.8",
"@types/jest": "^26.0.7",
"@types/node": "^14.14.32",
"cross-fetch": "^3.0.6",
"msw": "^0.29.0",
"react-test-renderer": "^16.13.1"
"cross-fetch": "^3.0.6"
},
"files": [
"dist"
@@ -15,21 +15,88 @@
*/
import { rancherFormatter } from './rancher';
describe('clusterLinks - Rancher formatter', () => {
it('should return an url on the workloads when there is a namespace only', () => {
expect(() =>
rancherFormatter({
dashboardUrl: new URL('https://k8s.foo.com'),
object: {
metadata: {
name: 'foobar',
namespace: 'bar',
},
describe('clusterLinks - rancher formatter', () => {
it('should return a url on the workloads when there is a namespace only', () => {
const url = rancherFormatter({
dashboardUrl: new URL('https://k8s.foo.com'),
object: {
metadata: {
namespace: 'bar',
},
kind: 'Deployment',
}),
).toThrowError(
'Rancher formatter is not yet implemented. Please, contribute!',
},
kind: 'foo',
});
expect(url.href).toBe('https://k8s.foo.com/explorer/workload');
});
it('should return a url on the workloads when the kind is not recognized', () => {
const url = rancherFormatter({
dashboardUrl: new URL('https://k8s.foo.com'),
object: {
metadata: {
name: 'foobar',
namespace: 'bar',
},
},
kind: 'UnknownKind',
});
expect(url.href).toBe('https://k8s.foo.com/explorer/workload');
});
it('should return a url on the deployment', () => {
const url = rancherFormatter({
dashboardUrl: new URL('https://k8s.foo.com/'),
object: {
metadata: {
name: 'foobar',
namespace: 'bar',
},
},
kind: 'Deployment',
});
expect(url.href).toBe(
'https://k8s.foo.com/explorer/apps.deployment/bar/foobar',
);
});
it('should return a url on the service', () => {
const url = rancherFormatter({
dashboardUrl: new URL('https://k8s.foo.com/'),
object: {
metadata: {
name: 'foobar',
namespace: 'bar',
},
},
kind: 'Service',
});
expect(url.href).toBe('https://k8s.foo.com/explorer/service/bar/foobar');
});
it('should return a url on the ingress', () => {
const url = rancherFormatter({
dashboardUrl: new URL('https://k8s.foo.com/'),
object: {
metadata: {
name: 'foobar',
namespace: 'bar',
},
},
kind: 'Ingress',
});
expect(url.href).toBe(
'https://k8s.foo.com/explorer/networking.k8s.io.ingress/bar/foobar',
);
});
it('should return a url on the deployment for a hpa', () => {
const url = rancherFormatter({
dashboardUrl: new URL('https://k8s.foo.com/'),
object: {
metadata: {
name: 'foobar',
namespace: 'bar',
},
},
kind: 'HorizontalPodAutoscaler',
});
expect(url.href).toBe(
'https://k8s.foo.com/explorer/autoscaling.horizontalpodautoscaler/bar/foobar',
);
});
});
@@ -15,8 +15,22 @@
*/
import { ClusterLinksFormatterOptions } from '../../../types/types';
export function rancherFormatter(_options: ClusterLinksFormatterOptions): URL {
throw new Error(
'Rancher formatter is not yet implemented. Please, contribute!',
);
const kindMappings: Record<string, string> = {
deployment: 'apps.deployment',
ingress: 'networking.k8s.io.ingress',
service: 'service',
horizontalpodautoscaler: 'autoscaling.horizontalpodautoscaler',
};
export function rancherFormatter(options: ClusterLinksFormatterOptions): URL {
const result = new URL(options.dashboardUrl.href);
const name = options.object.metadata?.name;
const namespace = options.object.metadata?.namespace;
const validKind = kindMappings[options.kind.toLocaleLowerCase('en-US')];
if (validKind && name && namespace) {
result.pathname = `explorer/${validKind}/${namespace}/${name}`;
} else if (namespace) {
result.pathname = 'explorer/workload';
}
return result;
}
+2
View File
@@ -107,6 +107,8 @@ export interface TechRadarComponentProps {
// (undocumented)
id?: string;
// (undocumented)
searchText?: string;
// (undocumented)
svgProps?: object;
// (undocumented)
width: number;
+1
View File
@@ -81,4 +81,5 @@ export interface TechRadarComponentProps {
width: number;
height: number;
svgProps?: object;
searchText?: string;
}
@@ -14,19 +14,19 @@
* limitations under the License.
*/
import { Progress } from '@backstage/core-components';
import { errorApiRef, useApi } from '@backstage/core-plugin-api';
import React, { useEffect } from 'react';
import { useAsync } from 'react-use';
import Radar from '../components/Radar';
import {
RadarEntry,
techRadarApiRef,
TechRadarComponentProps,
TechRadarLoaderResponse,
} from '../api';
import Radar from '../components/Radar';
import { Entry } from '../utils/types';
import { Progress } from '@backstage/core-components';
import { useApi, errorApiRef } from '@backstage/core-plugin-api';
const useTechRadarLoader = (id: string | undefined) => {
const errorApi = useApi(errorApiRef);
const techRadarApi = useApi(techRadarApiRef);
@@ -45,24 +45,44 @@ const useTechRadarLoader = (id: string | undefined) => {
return { loading, value, error };
};
function matchFilter(filter?: string): (entry: RadarEntry) => boolean {
const terms = filter
?.toLocaleLowerCase('en-US')
.split(/\s/)
.map(e => e.trim())
.filter(Boolean);
if (!terms?.length) {
return () => true;
}
return entry => {
const text = `${entry.title} ${
entry.timeline[0]?.description || ''
}`.toLocaleLowerCase('en-US');
return terms.every(term => text.includes(term));
};
}
const RadarComponent = (props: TechRadarComponentProps): JSX.Element => {
const { loading, error, value: data } = useTechRadarLoader(props.id);
const mapToEntries = (
loaderResponse: TechRadarLoaderResponse | undefined,
loaderResponse: TechRadarLoaderResponse,
): Array<Entry> => {
return loaderResponse!.entries.map(entry => {
return {
return loaderResponse.entries
.filter(matchFilter(props.searchText))
.map(entry => ({
id: entry.key,
quadrant: loaderResponse!.quadrants.find(q => q.id === entry.quadrant)!,
quadrant: loaderResponse.quadrants.find(q => q.id === entry.quadrant)!,
title: entry.title,
ring: loaderResponse!.rings.find(
ring: loaderResponse.rings.find(
r => r.id === entry.timeline[0].ringId,
)!,
timeline: entry.timeline.map(e => {
return {
date: e.date,
ring: loaderResponse!.rings.find(a => a.id === e.ringId)!,
ring: loaderResponse.rings.find(a => a.id === e.ringId)!,
description: e.description,
moved: e.moved,
};
@@ -70,18 +90,17 @@ const RadarComponent = (props: TechRadarComponentProps): JSX.Element => {
moved: entry.timeline[0].moved,
description: entry.description || entry.timeline[0].description,
url: entry.url,
};
});
}));
};
return (
<>
{loading && <Progress />}
{!loading && !error && (
{!loading && !error && data && (
<Radar
{...props}
rings={data!.rings}
quadrants={data!.quadrants}
rings={data.rings}
quadrants={data.quadrants}
entries={mapToEntries(data)}
/>
)}
@@ -13,10 +13,10 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { makeStyles, Theme } from '@material-ui/core';
import type { Quadrant, Ring, Entry } from '../../utils/types';
import React from 'react';
import { WithLink } from '../../utils/components';
import type { Entry, Quadrant, Ring } from '../../utils/types';
import { RadarDescription } from '../RadarDescription';
type Segments = {
@@ -32,17 +32,21 @@ export type Props = {
};
const useStyles = makeStyles<Theme>(theme => ({
quadrantLegend: {
overflowY: 'auto',
scrollbarWidth: 'thin',
},
quadrant: {
height: '100%',
width: '100%',
overflow: 'hidden',
scrollbarWidth: 'thin',
pointerEvents: 'none',
},
quadrantHeading: {
pointerEvents: 'none',
userSelect: 'none',
marginTop: 0,
marginBottom: theme.spacing(8 / (18 * 0.375)),
marginBottom: theme.spacing(2),
fontSize: '18px',
},
rings: {
@@ -53,12 +57,16 @@ const useStyles = makeStyles<Theme>(theme => ({
pageBreakInside: 'avoid',
'-webkit-column-break-inside': 'avoid',
fontSize: '12px',
marginBottom: theme.spacing(2),
},
ringEmpty: {
color: theme.palette.text.secondary,
},
ringHeading: {
pointerEvents: 'none',
userSelect: 'none',
marginTop: 0,
marginBottom: theme.spacing(8 / (12 * 0.375)),
marginBottom: theme.spacing(1),
fontSize: '12px',
fontWeight: 800,
},
@@ -172,7 +180,7 @@ const RadarLegend = (props: Props): JSX.Element => {
<div data-testid="radar-ring" key={ring.id} className={classes.ring}>
<h3 className={classes.ringHeading}>{ring.name}</h3>
{entries.length === 0 ? (
<p>(empty)</p>
<p className={classes.ringEmpty}>(empty)</p>
) : (
<ol className={classes.ringList}>
{entries.map(entry => (
@@ -221,6 +229,7 @@ const RadarLegend = (props: Props): JSX.Element => {
y={quadrant.legendY}
width={quadrant.legendWidth}
height={quadrant.legendHeight}
className={classes.quadrantLegend}
data-testid="radar-quadrant"
>
<div className={classes.quadrant}>
@@ -14,17 +14,17 @@
* limitations under the License.
*/
import React from 'react';
import { Grid, makeStyles } from '@material-ui/core';
import RadarComponent from '../components/RadarComponent';
import { TechRadarComponentProps } from '../api';
import {
Content,
ContentHeader,
Page,
Header,
Page,
SupportButton,
} from '@backstage/core-components';
import { Grid, Input, makeStyles } from '@material-ui/core';
import React from 'react';
import { TechRadarComponentProps } from '../api';
import RadarComponent from '../components/RadarComponent';
const useStyles = makeStyles(() => ({
overflowXScroll: {
@@ -45,11 +45,19 @@ export const RadarPage = ({
...props
}: TechRadarPageProps): JSX.Element => {
const classes = useStyles();
const [searchText, setSearchText] = React.useState('');
return (
<Page themeId="tool">
<Header title={title} subtitle={subtitle} />
<Content className={classes.overflowXScroll}>
<ContentHeader title={pageTitle}>
<Input
id="tech-radar-filter"
type="search"
placeholder="Filter"
onChange={e => setSearchText(e.target.value)}
/>
<SupportButton>
This is used for visualizing the official guidelines of different
areas of software development such as languages, frameworks,
@@ -58,7 +66,7 @@ export const RadarPage = ({
</ContentHeader>
<Grid container spacing={3} direction="row">
<Grid item xs={12} sm={6} md={4}>
<RadarComponent {...props} />
<RadarComponent searchText={searchText} {...props} />
</Grid>
</Grid>
</Content>
+4 -4
View File
@@ -2,12 +2,12 @@
## Getting started
Set up Backstage and TechDocs by follow our guide on [Getting Started](../../docs/features/techdocs/getting-started.md).
Set up Backstage and TechDocs by following our guide on [Getting Started](../../docs/features/techdocs/getting-started.md).
## Configuration
http://backstage.io/docs/features/techdocs/configuration
Refer to our [configuration reference](../../docs/features/techdocs/configuration.md) for a complete listing of configuration options.
### TechDocs Storage Api
### TechDocs Storage API
The default setup of TechDocs assumes your documentation is accessed by reading a page with the format of `<storageUrl>/<entity kind>/<entity namespace>/<entity name>`. If for some reason you want to change this it can be configured by implementing a new techdocs storage API. Do this by implementing TechDocsStorage found in `plugins/techdocs/src/api`. Add your new API to the application in `app/src/apis.ts` (or replace if it's already registered as an API).
The default setup of TechDocs assumes that your documentation is accessed by reading a page with the format of `<storageUrl>/<entity kind>/<entity namespace>/<entity name>`. This can be configured by [implementing a new techdocs storage API](https://backstage.io/docs/features/techdocs/how-to-guides#how-to-implement-your-own-techdocs-apis).
@@ -28,12 +28,7 @@ export function createCopyDocsUrlAction(copyToClipboard: Function) {
icon: () => <ShareIcon fontSize="small" />,
tooltip: 'Click to copy documentation link to clipboard',
onClick: () =>
copyToClipboard(
`${window.location.origin}${window.location.pathname.replace(
/\/?$/,
'/',
)}${row.resolved.docsUrl}`,
),
copyToClipboard(`${window.location.origin}${row.resolved.docsUrl}`),
};
};
}