Added linguist frontend, backend, and common plugins

Signed-off-by: Andre Wanlin <andrewanlin@gmail.com>

Removed refresh and permissions

Signed-off-by: Andre Wanlin <andrewanlin@gmail.com>

Small database refactor and improved naming

Signed-off-by: Andre Wanlin <andrewanlin@gmail.com>

Refactor stringifyEntityRef usage

Signed-off-by: Andre Wanlin <andrewanlin@gmail.com>

Fixed up yarn.lock

Signed-off-by: Andre Wanlin <andrewanlin@gmail.com>
This commit is contained in:
Andre Wanlin
2022-11-19 16:43:29 -06:00
parent e2489e8f86
commit 75cfee5688
51 changed files with 2005 additions and 11 deletions
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/plugin-linguist': minor
'@backstage/plugin-linguist-backend': minor
'@backstage/plugin-linguist-common': minor
---
Introduced the Linguist plugin, checkout the plugin's `README.md` for more details!
+2
View File
@@ -42,6 +42,8 @@
"@backstage/plugin-kubernetes": "workspace:^",
"@backstage/plugin-lighthouse": "workspace:^",
"@backstage/plugin-microsoft-calendar": "workspace:^",
"@backstage/plugin-linguist": "workspace:^",
"@backstage/plugin-linguist-common": "workspace:^",
"@backstage/plugin-newrelic": "workspace:^",
"@backstage/plugin-newrelic-dashboard": "workspace:^",
"@backstage/plugin-org": "workspace:^",
@@ -157,6 +157,10 @@ import {
LightBox,
} from '@backstage/plugin-techdocs-module-addons-contrib';
import { EntityCostInsightsContent } from '@backstage/plugin-cost-insights';
import {
isLinguistAvailable,
EntityLinguistCard,
} from '@backstage/plugin-linguist';
const customEntityFilterKind = ['Component', 'API', 'System'];
@@ -406,6 +410,14 @@ const overviewContent = (
</EntitySwitch.Case>
</EntitySwitch>
<EntitySwitch>
<EntitySwitch.Case if={isLinguistAvailable}>
<Grid item md={6}>
<EntityLinguistCard />
</Grid>
</EntitySwitch.Case>
</EntitySwitch>
<Grid item md={8} xs={12}>
<EntityHasSubcomponentsCard variant="gridItem" />
</Grid>
+1
View File
@@ -50,6 +50,7 @@
"@backstage/plugin-kafka-backend": "workspace:^",
"@backstage/plugin-kubernetes-backend": "workspace:^",
"@backstage/plugin-lighthouse-backend": "workspace:^",
"@backstage/plugin-linguist-backend": "workspace:^",
"@backstage/plugin-permission-backend": "workspace:^",
"@backstage/plugin-permission-common": "workspace:^",
"@backstage/plugin-permission-node": "workspace:^",
+3
View File
@@ -63,6 +63,7 @@ import permission from './plugins/permission';
import playlist from './plugins/playlist';
import adr from './plugins/adr';
import lighthouse from './plugins/lighthouse';
import linguist from './plugins/linguist';
import { PluginEnvironment } from './types';
import { ServerPermissionClient } from '@backstage/plugin-permission-node';
import { DefaultIdentityClient } from '@backstage/plugin-auth-node';
@@ -154,6 +155,7 @@ async function main() {
const eventBasedEntityProviders = await catalogEventBasedProviders(
catalogEnv,
);
const linguistEnv = useHotMemoize(module, () => createEnv('linguist'));
const apiRouter = Router();
apiRouter.use(
@@ -180,6 +182,7 @@ async function main() {
apiRouter.use('/playlist', await playlist(playlistEnv));
apiRouter.use('/explore', await explore(exploreEnv));
apiRouter.use('/adr', await adr(adrEnv));
apiRouter.use('/linguist', await linguist(linguistEnv));
apiRouter.use(notFoundHandler());
await lighthouse(lighthouseEnv);
+40
View File
@@ -0,0 +1,40 @@
/*
* Copyright 2022 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 { TaskScheduleDefinition } from '@backstage/backend-tasks';
import { createRouter } from '@backstage/plugin-linguist-backend';
import { Router } from 'express';
import type { PluginEnvironment } from '../types';
export default async function createPlugin(
env: PluginEnvironment,
): Promise<Router> {
const schedule: TaskScheduleDefinition = {
frequency: { minutes: 2 },
timeout: { minutes: 15 },
initialDelay: { seconds: 15 },
};
return createRouter({
logger: env.logger,
config: env.config,
reader: env.reader,
discovery: env.discovery,
database: env.database,
scheduler: env.scheduler,
schedule: schedule,
});
}
@@ -28,6 +28,8 @@ metadata:
- url: https://example.com/alert
title: Alerts
icon: alert
annotations:
backstage.io/linguist: 'https://github.com/backstage/backstage/tree/master/plugins/playlist'
spec:
type: service
lifecycle: experimental
+1
View File
@@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
+94
View File
@@ -0,0 +1,94 @@
# Linguist Backend
Welcome to the Linguist backend plugin! This plugin provides data for the Linguist frontend features.
## Setup
The following sections will help you get the Linguist Backend plugin setup and running.
### Up and Running
Here's how to get the backend up and running:
1. First we need to add the `@backstage/plugin-linguist-backend` package to your backend:
```sh
# From the Backstage root directory
cd packages/backend
yarn add @backstage/plugin-linguist-backend
```
2. Then we will create a new file named `packages/backend/src/plugins/linguist.ts`, and add the
following to it:
```ts
import { TaskScheduleDefinition } from '@backstage/backend-tasks';
import { createRouter } from '@backstage/plugin-linguist-backend';
import { Router } from 'express';
import type { PluginEnvironment } from '../types';
export default async function createPlugin(
env: PluginEnvironment,
): Promise<Router> {
const schedule: TaskScheduleDefinition = {
frequency: { hours: 24 },
timeout: { minutes: 30 },
initialDelay: { seconds: 90 },
};
return createRouter({
logger: env.logger,
config: env.config,
reader: env.reader,
discovery: env.discovery,
database: env.database,
scheduler: env.scheduler,
schedule: schedule,
});
}
```
3. Next we wire this into the overall backend router, edit `packages/backend/src/index.ts`:
```ts
import linguist from './plugins/linguist';
// ...
async function main() {
// ...
// Add this line under the other lines that follow the useHotMemoize pattern
const linguistEnv = useHotMemoize(module, () => createEnv('linguist'));
// ...
// Insert this line under the other lines that add their routers to apiRouter in the same way
apiRouter.use('/linguist', await linguist(linguistEnv));
```
4. Now run `yarn start-backend` from the repo root
5. Finally open `http://localhost:7007/api/linguist/health` in a browser and it should return `{"status":"ok"}`
## Scheduling
The Linguist backend can be configured to generate the language breakdown for all the entities with the linguist annotation. This is done by passing a `TaskScheduleDefinition` to the `createRouter` function like in Step 2 of the [Up and Running](#up-and-running) documentation.
Here's another example of the `TaskScheduleDefinition` that will run the language breakdown processing every 12 hours:
```ts
const schedule: TaskScheduleDefinition = {
frequency: { hours: 12 },
timeout: { minutes: 15 },
initialDelay: { seconds: 10 },
};
```
The default setup will only generate the language breakdown for entities with the linguist annotation that have not been generated yet. If you want this process to also refresh the data you can do so by adding the following configuration to your `app-config.yaml`:
```yaml
linguist:
age: 15 # Days
```
With the configuration setup like this if the language breakdown is older than 15 days it will get regenerated. It's recommended that if you choose to use this configuration to set it to a large value - 30, 90, or 180 - as this data generally does not change drastically.
## Links
- [Frontend part of the plugin](../linguist/README.md)
- [The Backstage homepage](https://backstage.io)
+86
View File
@@ -0,0 +1,86 @@
## API Report File for "@backstage/plugin-linguist-backend"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { Config } from '@backstage/config';
import { EntitiesOverview } from '@backstage/plugin-linguist-common';
import { EntityResults } from '@backstage/plugin-linguist-common';
import express from 'express';
import { Knex } from 'knex';
import { Languages } from '@backstage/plugin-linguist-common';
import { Logger } from 'winston';
import { PluginDatabaseManager } from '@backstage/backend-common';
import { PluginEndpointDiscovery } from '@backstage/backend-common';
import { PluginTaskScheduler } from '@backstage/backend-tasks';
import { ProcessedEntity } from '@backstage/plugin-linguist-common';
import { TaskScheduleDefinition } from '@backstage/backend-tasks';
import { UrlReader } from '@backstage/backend-common';
// @public (undocumented)
export function createRouter(options: RouterOptions): Promise<express.Router>;
// @public (undocumented)
export class LinguistBackendApi {
constructor(
config: Config,
logger: Logger,
store: LinguistBackendStore,
urlReader: UrlReader,
discovery: PluginEndpointDiscovery,
);
// (undocumented)
getEntitiesOverview(): Promise<EntitiesOverview>;
// (undocumented)
getEntityLanguages(entityRef: string): Promise<Languages>;
// (undocumented)
processEntities(): Promise<void>;
// (undocumented)
processEntity(entityRef: string, url: string): Promise<string>;
}
// @public (undocumented)
export class LinguistBackendDatabase implements LinguistBackendStore {
constructor(db: Knex);
// (undocumented)
static create(knex: Knex): Promise<LinguistBackendStore>;
// (undocumented)
getEntityResults(entityRef: string): Promise<Languages>;
// (undocumented)
getProcessedEntities(): Promise<ProcessedEntity[]>;
// (undocumented)
insertEntityResults(entityLanguages: EntityResults): Promise<string>;
}
// @public (undocumented)
export interface LinguistBackendStore {
// (undocumented)
getEntityResults(entityRef: string): Promise<Languages>;
// (undocumented)
getProcessedEntities(): Promise<ProcessedEntity[]>;
// (undocumented)
insertEntityResults(entityLanguages: EntityResults): Promise<string>;
}
// @public (undocumented)
export interface RouterOptions {
// (undocumented)
config: Config;
// (undocumented)
database: PluginDatabaseManager;
// (undocumented)
discovery: PluginEndpointDiscovery;
// (undocumented)
linguistBackendApi?: LinguistBackendApi;
// (undocumented)
logger: Logger;
// (undocumented)
reader: UrlReader;
// (undocumented)
schedule?: TaskScheduleDefinition;
// (undocumented)
scheduler?: PluginTaskScheduler;
}
// (No @packageDocumentation comment for this package)
```
+27
View File
@@ -0,0 +1,27 @@
/*
* Copyright 2022 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.
*/
export interface Config {
/** Configuration options for the linguist-backend plugin */
linguist?: {
/**
* The age of an entity's languages before a refresh occurs,
* an age of 30 would mean the languages would be regenerated after 30 days.
* The default is 0 meaning languages won't be refreshed once generated.
*/
age: number;
};
}
@@ -0,0 +1,67 @@
/*
* Copyright 2022 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.
*/
/**
* @param {import('knex').Knex} knex
*/
exports.up = async function up(knex) {
// Note for the reader: the knex increments types automatically make it a
// primary column, whether you like it or not. That's why the id column is
// not marked as primary as one might have expected; it's only used for
// lookups by ID. Because, SQLite and MySQL don't return RETURNING on
// inserts ... so we want a manually generated key for lookups (an uuid),
// and also an index for ordering guarantees :)
await knex.schema.createTable('entity_result', table => {
table.comment('Table containing results from running Linguist');
table
.bigIncrements('index')
.notNullable()
.comment('An insert counter to ensure ordering');
table.uuid('id').notNullable().comment('The ID of the Linguist result');
table
.text('entity_ref')
.unique()
.notNullable()
.comment('The entity ref that this Linguist result applies to');
table
.text('languages')
.notNullable()
.comment('The results json as a string');
table
.dateTime('created_at')
.defaultTo(knex.fn.now())
.notNullable()
.comment('The timestamp when this entry was created');
table
.dateTime('processed_date')
.defaultTo(knex.fn.now())
.notNullable()
.comment('The timestamp when this entity was processed');
table.index('index', 'entity_result_index_idx');
table.index('entity_ref', 'entity_result_entity_ref_idx');
});
};
/**
* @param {import('knex').Knex} knex
*/
exports.down = async function down(knex) {
await knex.schema.alterTable('entity_result', table => {
table.dropIndex([], 'entity_result_index_idx');
table.dropIndex([], 'entity_result_entity_ref_idx');
});
await knex.schema.dropTable('entity_result');
};
+57
View File
@@ -0,0 +1,57 @@
{
"name": "@backstage/plugin-linguist-backend",
"version": "0.0.0",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"publishConfig": {
"access": "public",
"main": "dist/index.cjs.js",
"types": "dist/index.d.ts"
},
"backstage": {
"role": "backend-plugin"
},
"scripts": {
"start": "backstage-cli package start",
"build": "backstage-cli package build",
"lint": "backstage-cli package lint",
"test": "backstage-cli package test",
"clean": "backstage-cli package clean",
"prepack": "backstage-cli package prepack",
"postpack": "backstage-cli package postpack"
},
"dependencies": {
"@backstage/backend-common": "workspace:^",
"@backstage/backend-tasks": "workspace:^",
"@backstage/catalog-client": "workspace:^",
"@backstage/catalog-model": "workspace:^",
"@backstage/config": "workspace:^",
"@backstage/errors": "workspace:^",
"@backstage/plugin-auth-node": "workspace:^",
"@backstage/plugin-linguist-common": "workspace:^",
"@types/express": "*",
"express": "^4.18.1",
"express-promise-router": "^4.1.0",
"fs-extra": "^10.0.0",
"knex": "^2.0.0",
"linguist-js": "^2.5.3",
"luxon": "^2.0.2",
"node-fetch": "^2.6.7",
"uuid": "^8.3.2",
"winston": "^3.2.1",
"yn": "^4.0.0"
},
"devDependencies": {
"@backstage/cli": "workspace:^",
"@types/supertest": "^2.0.8",
"msw": "^0.49.0",
"supertest": "^6.2.4"
},
"files": [
"dist",
"config.d.ts",
"migrations/**/*.{js,d.ts}"
],
"configSchema": "config.d.ts"
}
@@ -0,0 +1,173 @@
/*
* Copyright 2022 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 {
EntitiesOverview,
EntityResults,
Language,
Languages,
} from '@backstage/plugin-linguist-common';
import {
CATALOG_FILTER_EXISTS,
CatalogClient,
GetEntitiesRequest,
} from '@backstage/catalog-client';
import { PluginEndpointDiscovery, UrlReader } from '@backstage/backend-common';
import { Config } from '@backstage/config';
import { DateTime } from 'luxon';
import { LINGUIST_ANNOTATION } from '@backstage/plugin-linguist-common';
import { LinguistBackendStore } from '../db';
import { Logger } from 'winston';
import fs from 'fs-extra';
import linguist from 'linguist-js';
import { stringifyEntityRef } from '@backstage/catalog-model';
import { assertError } from '@backstage/errors';
/** @public */
export class LinguistBackendApi {
public constructor(
private readonly config: Config,
private readonly logger: Logger,
private readonly store: LinguistBackendStore,
private readonly urlReader: UrlReader,
private readonly discovery: PluginEndpointDiscovery,
) {}
public async getEntityLanguages(entityRef: string): Promise<Languages> {
this.logger?.debug(`Getting languages for entity "${entityRef}"`);
return this.store.getEntityResults(entityRef);
}
public async processEntity(entityRef: string, url: string): Promise<string> {
this.logger?.info(
`Processing languages for entity ${entityRef} from ${url}`,
);
const readTreeResponse = await this.urlReader.readTree(url);
const dir = await readTreeResponse.dir();
const results = await linguist(dir);
try {
const totalBytes = results.languages.bytes;
const langResults = results.languages.results;
const breakdown: Language[] = [];
for (const key in langResults) {
if (Object.prototype.hasOwnProperty.call(langResults, key)) {
const lang: Language = {
name: key,
percentage: +((langResults[key].bytes / totalBytes) * 100).toFixed(
2,
),
bytes: langResults[key].bytes,
type: langResults[key].type,
color: langResults[key].color,
};
breakdown.push(lang);
}
}
const languages: Languages = {
languageCount: results.languages.count,
totalBytes: totalBytes,
processedDate: new Date().toISOString(),
breakdown: breakdown,
};
const entityResults: EntityResults = {
entityRef: entityRef,
results: languages,
};
return await this.store.insertEntityResults(entityResults);
} finally {
this.logger?.info(`Cleaning up files from ${dir}`);
await fs.remove(dir);
}
}
public async processEntities() {
this.logger?.info('Processing applicable entities through Linguist');
const eo = await this.getEntitiesOverview();
this.logger?.info(
`Entities overview: Entity: ${eo.entityCount}, Processed: ${eo.processedCount}, Pending: ${eo.pendingCount}, Stale ${eo.staleCount}`,
);
const entities = eo.filteredEntities;
for (const key in entities) {
if (Object.prototype.hasOwnProperty.call(entities, key)) {
const entityRef = stringifyEntityRef(entities[key]);
const url =
entities[key].metadata.annotations?.[LINGUIST_ANNOTATION] ?? '';
try {
await this.processEntity(entityRef, url);
} catch (e) {
assertError(e);
this.logger.error(
`Unable to process "${entityRef}" using "${url}", ${e.message}`,
);
}
}
}
}
public async getEntitiesOverview(): Promise<EntitiesOverview> {
this.logger?.debug('Getting pending entities');
const age = this.config.getOptionalNumber('linguist.age') ?? 0;
const catalogApi = new CatalogClient({ discoveryApi: this.discovery });
const request: GetEntitiesRequest = {
filter: {
kind: ['API', 'Component', 'Template'],
[`metadata.annotations.${LINGUIST_ANNOTATION}`]: CATALOG_FILTER_EXISTS,
},
fields: ['kind', 'metadata'],
};
const response = await catalogApi.getEntities(request);
const entities = response.items;
const processedEntities = await this.store.getProcessedEntities();
const staleEntities = processedEntities.filter(pe => {
if (age === 0) return false;
const staleDate = DateTime.now().minus({ days: age });
return DateTime.fromJSDate(pe.processed_date) >= staleDate;
});
const filteredEntities = entities.filter(en => {
return processedEntities.every(pe => {
const entityRef = stringifyEntityRef(en);
return pe.entityRef !== entityRef;
});
});
const entitiesOverview: EntitiesOverview = {
entityCount: entities.length,
processedCount: processedEntities.length,
staleCount: staleEntities.length,
pendingCount: filteredEntities.length,
filteredEntities: filteredEntities,
};
return entitiesOverview;
}
}
+17
View File
@@ -0,0 +1,17 @@
/*
* Copyright 2022 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.
*/
export { LinguistBackendApi } from './LinguistBackendApi';
@@ -0,0 +1,103 @@
/*
* Copyright 2022 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 { resolvePackagePath } from '@backstage/backend-common';
import { Knex } from 'knex';
import { v4 as uuid } from 'uuid';
import {
EntityResults,
Languages,
ProcessedEntity,
} from '@backstage/plugin-linguist-common';
export type RawDbEntityResultRow = {
id: string;
entity_ref: string;
languages: string;
processed_date: Date;
};
/** @public */
export interface LinguistBackendStore {
insertEntityResults(entityLanguages: EntityResults): Promise<string>;
getEntityResults(entityRef: string): Promise<Languages>;
getProcessedEntities(): Promise<ProcessedEntity[]>;
}
const migrationsDir = resolvePackagePath(
'@backstage/plugin-linguist-backend',
'migrations',
);
/** @public */
export class LinguistBackendDatabase implements LinguistBackendStore {
static async create(knex: Knex): Promise<LinguistBackendStore> {
await knex.migrate.latest({
directory: migrationsDir,
});
return new LinguistBackendDatabase(knex);
}
constructor(private readonly db: Knex) {}
async insertEntityResults(entityLanguages: EntityResults): Promise<string> {
const entityLanguageId = uuid();
const entityRef = entityLanguages.entityRef;
const [result] = await this.db<RawDbEntityResultRow>('entity_result')
.insert({
id: entityLanguageId,
entity_ref: entityRef,
languages: JSON.stringify(entityLanguages.results),
processed_date: new Date(entityLanguages.results.processedDate),
})
.onConflict('entity_ref')
.merge(['languages', 'processed_date'])
.returning('id');
return result.id;
}
async getEntityResults(entityRef: string): Promise<Languages> {
const entityResults = await this.db<RawDbEntityResultRow>('entity_result')
.where({ entity_ref: entityRef })
.first();
if (!entityResults) {
const emptyResults: Languages = {
languageCount: 0,
totalBytes: 0,
processedDate: 'undefined',
breakdown: [],
};
return emptyResults;
}
try {
return JSON.parse(entityResults.languages);
} catch (error) {
throw new Error(`Failed to parse languages for '${entityRef}', ${error}`);
}
}
async getProcessedEntities(): Promise<ProcessedEntity[]> {
const entityResults = await this.db<ProcessedEntity>(
'entity_result',
).select('entity_ref', 'processed_date');
return entityResults;
}
}
+18
View File
@@ -0,0 +1,18 @@
/*
* Copyright 2022 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.
*/
export { LinguistBackendDatabase } from './LinguistBackendDatabase';
export type { LinguistBackendStore } from './LinguistBackendDatabase';
+20
View File
@@ -0,0 +1,20 @@
/*
* Copyright 2022 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.
*/
export * from './service/router';
export { LinguistBackendApi } from './api';
export { LinguistBackendDatabase } from './db';
export type { LinguistBackendStore } from './db';
+33
View File
@@ -0,0 +1,33 @@
/*
* Copyright 2022 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 { getRootLogger } from '@backstage/backend-common';
import yn from 'yn';
import { startStandaloneServer } from './service/standaloneServer';
const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7007;
const enableCors = yn(process.env.PLUGIN_CORS, { default: false });
const logger = getRootLogger();
startStandaloneServer({ port, enableCors, logger }).catch(err => {
logger.error(err);
process.exit(1);
});
process.on('SIGINT', () => {
logger.info('CTRL+C pressed; exiting.');
process.exit(0);
});
@@ -0,0 +1,83 @@
/*
* Copyright 2022 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 {
DatabaseManager,
getVoidLogger,
PluginDatabaseManager,
PluginEndpointDiscovery,
UrlReaders,
} from '@backstage/backend-common';
import express from 'express';
import request from 'supertest';
import { ConfigReader } from '@backstage/config';
import { createRouter } from './router';
import { LinguistBackendApi } from '../api';
function createDatabase(): PluginDatabaseManager {
return DatabaseManager.fromConfig(
new ConfigReader({
backend: {
database: {
client: 'better-sqlite3',
connection: ':memory:',
},
},
}),
).forPlugin('code-coverage');
}
const testDiscovery: jest.Mocked<PluginEndpointDiscovery> = {
getBaseUrl: jest
.fn()
.mockResolvedValue('http://localhost:7007/api/code-coverage'),
getExternalBaseUrl: jest.fn(),
};
const mockUrlReader = UrlReaders.default({
logger: getVoidLogger(),
config: new ConfigReader({}),
});
describe('createRouter', () => {
let linguistBackendApi: jest.Mocked<LinguistBackendApi>;
let app: express.Express;
beforeAll(async () => {
const router = await createRouter({
linguistBackendApi,
config: new ConfigReader({}),
discovery: testDiscovery,
database: createDatabase(),
reader: mockUrlReader,
logger: getVoidLogger(),
});
app = express().use(router);
});
beforeEach(() => {
jest.resetAllMocks();
});
describe('GET /health', () => {
it('returns ok', async () => {
const response = await request(app).get('/health');
expect(response.status).toEqual(200);
expect(response.body).toEqual({ status: 'ok' });
});
});
});
@@ -0,0 +1,108 @@
/*
* Copyright 2022 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 {
errorHandler,
PluginDatabaseManager,
PluginEndpointDiscovery,
UrlReader,
} from '@backstage/backend-common';
import { Config } from '@backstage/config';
import express from 'express';
import Router from 'express-promise-router';
import { Logger } from 'winston';
import { LinguistBackendApi } from '../api';
import { LinguistBackendDatabase } from '../db';
import {
PluginTaskScheduler,
TaskScheduleDefinition,
} from '@backstage/backend-tasks';
/** @public */
export interface RouterOptions {
linguistBackendApi?: LinguistBackendApi;
config: Config;
logger: Logger;
reader: UrlReader;
database: PluginDatabaseManager;
discovery: PluginEndpointDiscovery;
scheduler?: PluginTaskScheduler;
schedule?: TaskScheduleDefinition;
}
/** @public */
export async function createRouter(
options: RouterOptions,
): Promise<express.Router> {
const { config, logger, reader, database, discovery, scheduler, schedule } =
options;
const linguistBackendStore = await LinguistBackendDatabase.create(
await database.getClient(),
);
const linguistBackendApi =
options.linguistBackendApi ||
new LinguistBackendApi(
config,
logger,
linguistBackendStore,
reader,
discovery,
);
if (scheduler && schedule) {
logger.info(
`Scheduling processing of entities with: ${JSON.stringify(schedule)}`,
);
await scheduler.scheduleTask({
id: 'linguist_process_entities',
frequency: schedule.frequency,
timeout: schedule.timeout,
initialDelay: schedule.initialDelay,
scope: schedule.scope,
fn: async () => {
await linguistBackendApi.processEntities();
},
});
}
const router = Router();
router.use(express.json());
router.get('/health', (_, response) => {
response.send({ status: 'ok' });
});
/**
* /entity-languages?entity=component:default/my-component
*/
router.get('/entity-languages', async (req, res) => {
const { entityRef: entityRef } = req.query;
if (!entityRef) {
throw new Error('No entityRef was provided');
}
const entityLanguages = await linguistBackendApi.getEntityLanguages(
entityRef as string,
);
res.status(200).json(entityLanguages);
});
router.use(errorHandler());
return router;
}
@@ -0,0 +1,76 @@
/*
* Copyright 2022 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 {
createServiceBuilder,
loadBackendConfig,
SingleHostDiscovery,
UrlReaders,
useHotMemoize,
} from '@backstage/backend-common';
import { Server } from 'http';
import { Logger } from 'winston';
import { createRouter } from './router';
import knexFactory from 'knex';
export interface ServerOptions {
port: number;
enableCors: boolean;
logger: Logger;
}
export async function startStandaloneServer(
options: ServerOptions,
): Promise<Server> {
const logger = options.logger.child({ service: 'linguist-backend' });
const config = await loadBackendConfig({ logger, argv: process.argv });
const db = useHotMemoize(module, () => {
const knex = knexFactory({
client: 'sqlite3',
connection: ':memory:',
useNullAsDefault: true,
});
knex.client.pool.on('createSuccess', (_eventId: any, resource: any) => {
resource.run('PRAGMA foreign_keys = ON', () => {});
});
return knex;
});
logger.debug('Starting application server...');
const router = await createRouter({
database: { getClient: async () => db },
config,
discovery: SingleHostDiscovery.fromConfig(config),
reader: UrlReaders.default({ logger, config }),
logger,
});
let service = createServiceBuilder(module)
.setPort(options.port)
.addRouter('/linguist', router);
if (options.enableCors) {
service = service.enableCors({ origin: 'http://localhost:3000' });
}
return await service.start().catch(err => {
logger.error(err);
process.exit(1);
});
}
module.hot?.accept();
@@ -0,0 +1,17 @@
/*
* Copyright 2022 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.
*/
export {};
+1
View File
@@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
+3
View File
@@ -0,0 +1,3 @@
# Linguist Common
Common types, permissions, and constants for the Linguist plugin.
+50
View File
@@ -0,0 +1,50 @@
## API Report File for "@backstage/plugin-linguist-common"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { Entity } from '@backstage/catalog-model';
// @public (undocumented)
export type EntitiesOverview = {
entityCount: number;
processedCount: number;
pendingCount: number;
staleCount: number;
filteredEntities: Entity[];
};
// @public (undocumented)
export type EntityResults = {
entityRef: string;
results: Languages;
};
// @public (undocumented)
export type Language = {
name: string;
percentage: number;
bytes: number;
type: string;
color?: `#${string}`;
};
// @public (undocumented)
export type Languages = {
languageCount: number;
totalBytes: number;
processedDate: string;
breakdown: Language[];
};
// @public (undocumented)
export const LINGUIST_ANNOTATION = 'backstage.io/linguist';
// @public (undocumented)
export type ProcessedEntity = {
entityRef: string;
processed_date: Date;
};
// (No @packageDocumentation comment for this package)
```
+35
View File
@@ -0,0 +1,35 @@
{
"name": "@backstage/plugin-linguist-common",
"description": "Common functionalities for the linguist plugin",
"version": "0.0.0",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"publishConfig": {
"access": "public",
"main": "dist/index.cjs.js",
"module": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"backstage": {
"role": "common-library"
},
"scripts": {
"build": "backstage-cli package build",
"lint": "backstage-cli package lint",
"test": "backstage-cli package test",
"clean": "backstage-cli package clean",
"prepack": "backstage-cli package prepack",
"postpack": "backstage-cli package postpack"
},
"dependencies": {
"@backstage/catalog-model": "workspace:^",
"@backstage/plugin-permission-common": "workspace:^"
},
"devDependencies": {
"@backstage/cli": "workspace:^"
},
"files": [
"dist"
]
}
+18
View File
@@ -0,0 +1,18 @@
/*
* Copyright 2022 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.
*/
/** @public */
export const LINGUIST_ANNOTATION = 'backstage.io/linguist';
+18
View File
@@ -0,0 +1,18 @@
/*
* Copyright 2022 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.
*/
export * from './types';
export * from './constants';
+16
View File
@@ -0,0 +1,16 @@
/*
* Copyright 2022 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.
*/
export {};
+54
View File
@@ -0,0 +1,54 @@
/*
* Copyright 2022 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';
/** @public */
export type EntityResults = {
entityRef: string;
results: Languages;
};
/** @public */
export type Languages = {
languageCount: number;
totalBytes: number;
processedDate: string;
breakdown: Language[];
};
/** @public */
export type Language = {
name: string;
percentage: number;
bytes: number;
type: string;
color?: `#${string}`;
};
/** @public */
export type ProcessedEntity = {
entityRef: string;
processed_date: Date;
};
/** @public */
export type EntitiesOverview = {
entityCount: number;
processedCount: number;
pendingCount: number;
staleCount: number;
filteredEntities: Entity[];
};
+1
View File
@@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
+88
View File
@@ -0,0 +1,88 @@
# Linguist
Welcome to the Linguist plugin!
## Features
The Linguist plugin consists of a card that will give you the breakdown of the languages used by the configured Entity in the Catalog.
When there is no Linguist data yet the card will be empty:
![Example of empty Linguist card](./docs/linguist-no-data.png)
Clicking on the refresh icon will generate the language breakdown:
![Example of Linguist card with data](./docs/linguist-with-data.png)
Here's an example where the permissions are hiding the refresh button:
![Example of Linguist card without the refresh button](./docs/linguist-no-refresh.png)
## Setup
The following sections will help you get the Linguist plugin setup and running.
### Backend
You need to setup the [Linguist backend plugin](../linguist-backend/README.md) before you move forward with any of the following steps if you haven't already.
### Entity Annotation
To be able to use the Linguist plugin you need to add the following annotation to any entities you want to use it with:
```yaml
backstage.io/linguist: https://url.to/your/srouce/code
```
Linguist uses the `UrlReader` to pull down the files before it scans them to determine the languages, this means you can pull files from all the supported providers - GitHub, GitLab, Bitbucket, Azure, Google GCS, AWS S3, etc.
Here's what that will look like in action using the Backstage repo as an example:
```yaml
# Example catalog-info.yaml entity definition file
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
# ...
annotations:
backstage.io/linguist: https://github.com/backstage/backstage
spec:
type: service
# ...
```
### Frontend
To setup the Linguist Card frontend you'll need to do the following steps:
1. First we need to add the `@backstage/plugin-linguist` package to your frontend app:
```sh
# From your Backstage root directory
yarn add --cwd packages/app @backstage/plugin-linguist
```
2. Second we need to add the `EntityLinguistCard` extension to the entity page in your app:
```tsx
// In packages/app/src/components/catalog/EntityPage.tsx
import { isLinguistAvailable, EntityLinguistCard } from '@backstage/plugin-linguist';
// For example in the Overview section
const overviewContent = (
<Grid container spacing={3} alignItems="stretch">
// ...
<EntitySwitch>
<EntitySwitch.Case if={isLinguistAvailable}>
<Grid item md={6}>
<EntityLinguistCard />
</Grid>
</EntitySwitch.Case>
</EntitySwitch>
// ...
</Grid>
```
**Notes:**
- The `if` prop is optional on the `EntitySwitch.Case`, you can remove it if you always want to see the tab even if the entity being viewed does not have the needed annotation
+21
View File
@@ -0,0 +1,21 @@
## API Report File for "@backstage/plugin-linguist"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
/// <reference types="react" />
import { BackstagePlugin } from '@backstage/core-plugin-api';
import { Entity } from '@backstage/catalog-model';
// @public (undocumented)
export const EntityLinguistCard: () => JSX.Element;
// @public (undocumented)
export const isLinguistAvailable: (entity: Entity) => boolean;
// @public (undocumented)
export const linguistPlugin: BackstagePlugin<{}, {}, {}>;
// (No @packageDocumentation comment for this package)
```
+27
View File
@@ -0,0 +1,27 @@
/*
* Copyright 2022 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 React from 'react';
import { createDevApp } from '@backstage/dev-utils';
import { linguistPlugin, EntityLinguistCard } from '../src/plugin';
createDevApp()
.registerPlugin(linguistPlugin)
.addPage({
element: <EntityLinguistCard />,
title: 'Root Page',
path: '/linguist',
})
.render();
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

+63
View File
@@ -0,0 +1,63 @@
{
"name": "@backstage/plugin-linguist",
"version": "0.0.0",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"publishConfig": {
"access": "public",
"main": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"backstage": {
"role": "frontend-plugin"
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "plugins/linguist"
},
"scripts": {
"start": "backstage-cli package start",
"build": "backstage-cli package build",
"lint": "backstage-cli package lint",
"test": "backstage-cli package test",
"clean": "backstage-cli package clean",
"prepack": "backstage-cli package prepack",
"postpack": "backstage-cli package postpack"
},
"dependencies": {
"@backstage/catalog-model": "workspace:^",
"@backstage/core-components": "workspace:^",
"@backstage/core-plugin-api": "workspace:^",
"@backstage/errors": "workspace:^",
"@backstage/plugin-catalog-react": "workspace:^",
"@backstage/plugin-linguist-common": "workspace:^",
"@backstage/theme": "workspace:^",
"@material-ui/core": "^4.9.13",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "^4.0.0-alpha.57",
"luxon": "^2.0.2",
"react-use": "^17.2.4",
"slugify": "^1.6.4"
},
"peerDependencies": {
"react": "^16.13.1 || ^17.0.0"
},
"devDependencies": {
"@backstage/cli": "workspace:^",
"@backstage/core-app-api": "workspace:^",
"@backstage/dev-utils": "workspace:^",
"@backstage/test-utils": "workspace:^",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^12.1.3",
"@testing-library/user-event": "^14.0.0",
"@types/node": "*",
"cross-fetch": "^3.1.5",
"msw": "^0.49.0"
},
"files": [
"dist"
]
}
+26
View File
@@ -0,0 +1,26 @@
/*
* Copyright 2022 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 { createApiRef } from '@backstage/core-plugin-api';
import { Languages } from '@backstage/plugin-linguist-common';
export const linguistApiRef = createApiRef<LinguistApi>({
id: 'plugin.linguist.service',
});
export interface LinguistApi {
getLanguages(entityRef: string): Promise<Languages>;
}
@@ -0,0 +1,59 @@
/*
* Copyright 2022 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 { DiscoveryApi, IdentityApi } from '@backstage/core-plugin-api';
import { ResponseError } from '@backstage/errors';
import { Languages } from '@backstage/plugin-linguist-common';
import { LinguistApi } from './LinguistApi';
export class LinguistClient implements LinguistApi {
private readonly discoveryApi: DiscoveryApi;
private readonly identityApi: IdentityApi;
public constructor(options: {
discoveryApi: DiscoveryApi;
identityApi: IdentityApi;
}) {
this.discoveryApi = options.discoveryApi;
this.identityApi = options.identityApi;
}
public async getLanguages(entityRef: string): Promise<Languages> {
const queryString = new URLSearchParams();
queryString.append('entityRef', entityRef);
const urlSegment = `entity-languages?${queryString}`;
const items = await this.get<Languages>(urlSegment);
return items;
}
private async get<T>(path: string): Promise<T> {
const baseUrl = `${await this.discoveryApi.getBaseUrl('linguist')}/`;
const url = new URL(path, baseUrl);
const { token } = await this.identityApi.getCredentials();
const response = await fetch(url.toString(), {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!response.ok) {
throw await ResponseError.fromResponse(response);
}
return response.json() as Promise<T>;
}
}
+18
View File
@@ -0,0 +1,18 @@
/*
* Copyright 2022 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.
*/
export * from './LinguistApi';
export * from './LinguistClient';
@@ -0,0 +1,153 @@
/*
* Copyright 2022 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 {
Box,
Chip,
Tooltip,
Typography,
makeStyles,
Grid,
useTheme,
} from '@material-ui/core';
import { InfoCard, Progress } from '@backstage/core-components';
import Alert from '@material-ui/lab/Alert';
import { DateTime } from 'luxon';
import React from 'react';
import slugify from 'slugify';
import { useEntity } from '@backstage/plugin-catalog-react';
import { useLanguages } from '../../hooks';
const useStyles = makeStyles(theme => ({
infoCard: {
marginBottom: theme.spacing(3),
},
barContainer: {
height: theme.spacing(2),
marginBottom: theme.spacing(3),
borderRadius: '4px',
backgroundColor: 'transparent',
overflow: 'hidden',
},
bar: {
height: '100%',
position: 'relative',
},
languageDot: {
width: '10px',
height: '10px',
borderRadius: '50%',
marginRight: theme.spacing(1),
display: 'inline-block',
},
label: {
color: 'inherit',
},
}));
export const LinguistCard = () => {
const classes = useStyles();
const theme = useTheme();
const { entity } = useEntity();
const { items, loading, error } = useLanguages(entity);
let barWidth = 0;
if (loading) {
return <Progress />;
} else if (error) {
return <Alert severity="error">{error.message}</Alert>;
}
if (items && items.languageCount === 0 && items.totalBytes === 0) {
return (
<InfoCard title="Languages" className={classes.infoCard}>
<Grid container spacing={3}>
<Box p={2}>
<Typography>
There is currently no language data for this entity.
</Typography>
</Box>
</Grid>
</InfoCard>
);
}
const breakdown = items?.breakdown.sort((a, b) =>
a.percentage < b.percentage ? 1 : -1,
);
const processedDate = items?.processedDate;
return breakdown && processedDate ? (
<InfoCard title="Languages" className={classes.infoCard}>
<Box className={classes.barContainer}>
{breakdown.map((language, index: number) => {
barWidth = barWidth + language.percentage;
return (
<Tooltip
title={language.name}
placement="bottom-end"
key={slugify(language.name, { lower: true })}
>
<Box
className={classes.bar}
key={slugify(language.name, { lower: true })}
style={{
marginTop: index === 0 ? '0' : `-16px`,
zIndex: Object.keys(breakdown).length - index,
backgroundColor:
language.color?.toString() ||
theme.palette.background.default,
width: `${barWidth}%`,
}}
/>
</Tooltip>
);
})}
</Box>
<Tooltip
title={`Generated ${DateTime.fromISO(processedDate).toRelative()}`}
>
<Box>
{breakdown.map(languages => (
<Chip
classes={{
label: classes.label,
}}
label={
<Box>
<Box
component="span"
className={classes.languageDot}
style={{
backgroundColor:
languages?.color?.toString() ||
theme.palette.background.default,
}}
/>
{languages.name} - {languages.percentage}%
</Box>
}
variant="outlined"
key={slugify(languages.name, { lower: true })}
/>
))}
</Box>
</Tooltip>
</InfoCard>
) : (
<></>
);
};
@@ -0,0 +1,17 @@
/*
* Copyright 2022 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.
*/
export { LinguistCard } from './LinguistCard';
+17
View File
@@ -0,0 +1,17 @@
/*
* Copyright 2022 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.
*/
export * from './useLanguages';
@@ -0,0 +1,41 @@
/*
* Copyright 2022 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, stringifyEntityRef } from '@backstage/catalog-model';
import { linguistApiRef } from '../api';
import { useApi } from '@backstage/core-plugin-api';
import useAsync from 'react-use/lib/useAsync';
import { Languages } from '@backstage/plugin-linguist-common';
export function useLanguages(entity: Entity): {
items?: Languages;
loading: boolean;
error?: Error;
} {
const entityRef = stringifyEntityRef(entity);
const api = useApi(linguistApiRef);
const { value, loading, error } = useAsync(() => {
return api.getLanguages(entityRef);
}, [api, entityRef]);
return {
items: value,
loading,
error,
};
}
+20
View File
@@ -0,0 +1,20 @@
/*
* Copyright 2022 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.
*/
export {
linguistPlugin,
isLinguistAvailable,
EntityLinguistCard,
} from './plugin';
+22
View File
@@ -0,0 +1,22 @@
/*
* Copyright 2022 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 { linguistPlugin } from './plugin';
describe('linguist', () => {
it('should export plugin', () => {
expect(linguistPlugin).toBeDefined();
});
});
+53
View File
@@ -0,0 +1,53 @@
/*
* Copyright 2022 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 {
createApiFactory,
createComponentExtension,
createPlugin,
discoveryApiRef,
identityApiRef,
} from '@backstage/core-plugin-api';
import { linguistApiRef, LinguistClient } from './api';
import { LINGUIST_ANNOTATION } from '@backstage/plugin-linguist-common';
import { Entity } from '@backstage/catalog-model';
/** @public */
export const isLinguistAvailable = (entity: Entity) =>
Boolean(entity.metadata.annotations?.[LINGUIST_ANNOTATION]);
/** @public */
export const linguistPlugin = createPlugin({
id: 'linguist',
apis: [
createApiFactory({
api: linguistApiRef,
deps: { discoveryApi: discoveryApiRef, identityApi: identityApiRef },
factory: ({ discoveryApi, identityApi }) =>
new LinguistClient({ discoveryApi, identityApi }),
}),
],
});
/** @public */
export const EntityLinguistCard = linguistPlugin.provide(
createComponentExtension({
name: 'EntityLinguistCard',
component: {
lazy: () => import('./components/LinguistCard').then(m => m.LinguistCard),
},
}),
);
+18
View File
@@ -0,0 +1,18 @@
/*
* Copyright 2022 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 '@testing-library/jest-dom';
import 'cross-fetch/polyfill';
+119 -11
View File
@@ -6895,6 +6895,78 @@ __metadata:
languageName: unknown
linkType: soft
"@backstage/plugin-linguist-backend@workspace:^, @backstage/plugin-linguist-backend@workspace:plugins/linguist-backend":
version: 0.0.0-use.local
resolution: "@backstage/plugin-linguist-backend@workspace:plugins/linguist-backend"
dependencies:
"@backstage/backend-common": "workspace:^"
"@backstage/backend-tasks": "workspace:^"
"@backstage/catalog-client": "workspace:^"
"@backstage/catalog-model": "workspace:^"
"@backstage/cli": "workspace:^"
"@backstage/config": "workspace:^"
"@backstage/errors": "workspace:^"
"@backstage/plugin-auth-node": "workspace:^"
"@backstage/plugin-linguist-common": "workspace:^"
"@types/express": "*"
"@types/supertest": ^2.0.8
express: ^4.18.1
express-promise-router: ^4.1.0
fs-extra: ^10.0.0
knex: ^2.0.0
linguist-js: ^2.5.3
luxon: ^2.0.2
msw: ^0.49.0
node-fetch: ^2.6.7
supertest: ^6.2.4
uuid: ^8.3.2
winston: ^3.2.1
yn: ^4.0.0
languageName: unknown
linkType: soft
"@backstage/plugin-linguist-common@workspace:^, @backstage/plugin-linguist-common@workspace:plugins/linguist-common":
version: 0.0.0-use.local
resolution: "@backstage/plugin-linguist-common@workspace:plugins/linguist-common"
dependencies:
"@backstage/catalog-model": "workspace:^"
"@backstage/cli": "workspace:^"
"@backstage/plugin-permission-common": "workspace:^"
languageName: unknown
linkType: soft
"@backstage/plugin-linguist@workspace:^, @backstage/plugin-linguist@workspace:plugins/linguist":
version: 0.0.0-use.local
resolution: "@backstage/plugin-linguist@workspace:plugins/linguist"
dependencies:
"@backstage/catalog-model": "workspace:^"
"@backstage/cli": "workspace:^"
"@backstage/core-app-api": "workspace:^"
"@backstage/core-components": "workspace:^"
"@backstage/core-plugin-api": "workspace:^"
"@backstage/dev-utils": "workspace:^"
"@backstage/errors": "workspace:^"
"@backstage/plugin-catalog-react": "workspace:^"
"@backstage/plugin-linguist-common": "workspace:^"
"@backstage/test-utils": "workspace:^"
"@backstage/theme": "workspace:^"
"@material-ui/core": ^4.9.13
"@material-ui/icons": ^4.9.1
"@material-ui/lab": ^4.0.0-alpha.57
"@testing-library/jest-dom": ^5.10.1
"@testing-library/react": ^12.1.3
"@testing-library/user-event": ^14.0.0
"@types/node": "*"
cross-fetch: ^3.1.5
luxon: ^2.0.2
msw: ^0.49.0
react-use: ^17.2.4
slugify: ^1.6.4
peerDependencies:
react: ^16.13.1 || ^17.0.0
languageName: unknown
linkType: soft
"@backstage/plugin-microsoft-calendar@workspace:^, @backstage/plugin-microsoft-calendar@workspace:plugins/microsoft-calendar":
version: 0.0.0-use.local
resolution: "@backstage/plugin-microsoft-calendar@workspace:plugins/microsoft-calendar"
@@ -11244,7 +11316,7 @@ __metadata:
languageName: node
linkType: hard
"@material-ui/core@npm:^4.11.0, @material-ui/core@npm:^4.11.3, @material-ui/core@npm:^4.12.1, @material-ui/core@npm:^4.12.2, @material-ui/core@npm:^4.12.4":
"@material-ui/core@npm:^4.11.0, @material-ui/core@npm:^4.11.3, @material-ui/core@npm:^4.12.1, @material-ui/core@npm:^4.12.2, @material-ui/core@npm:^4.12.4, @material-ui/core@npm:^4.9.13":
version: 4.12.4
resolution: "@material-ui/core@npm:4.12.4"
dependencies:
@@ -17580,10 +17652,10 @@ __metadata:
languageName: node
linkType: hard
"binary-extensions@npm:^2.0.0":
version: 2.0.0
resolution: "binary-extensions@npm:2.0.0"
checksum: 554f65d3378cf71c3185c17dec3ca58334b8ff6ae242db3107284765ce33b2af19efd20c11faec41907a40534929e34b3a98e7d391c61e4211b45732dccb1115
"binary-extensions@npm:^2.0.0, binary-extensions@npm:^2.2.0":
version: 2.2.0
resolution: "binary-extensions@npm:2.2.0"
checksum: ccd267956c58d2315f5d3ea6757cf09863c5fc703e50fbeb13a7dc849b812ef76e3cf9ca8f35a0c48498776a7478d7b4a0418e1e2b8cb9cb9731f2922aaad7f8
languageName: node
linkType: hard
@@ -18943,7 +19015,7 @@ __metadata:
languageName: node
linkType: hard
"commander@npm:*, commander@npm:^9.1.0, commander@npm:^9.4.1":
"commander@npm:*, commander@npm:^9.1.0, commander@npm:^9.4.1, commander@npm:^9.5.0":
version: 9.5.0
resolution: "commander@npm:9.5.0"
checksum: c7a3e27aa59e913b54a1bafd366b88650bc41d6651f0cbe258d4ff09d43d6a7394232a4dadd0bf518b3e696fdf595db1028a0d82c785b88bd61f8a440cecfade
@@ -22227,6 +22299,8 @@ __metadata:
"@backstage/plugin-kafka": "workspace:^"
"@backstage/plugin-kubernetes": "workspace:^"
"@backstage/plugin-lighthouse": "workspace:^"
"@backstage/plugin-linguist": "workspace:^"
"@backstage/plugin-linguist-common": "workspace:^"
"@backstage/plugin-microsoft-calendar": "workspace:^"
"@backstage/plugin-newrelic": "workspace:^"
"@backstage/plugin-newrelic-dashboard": "workspace:^"
@@ -22326,6 +22400,7 @@ __metadata:
"@backstage/plugin-kafka-backend": "workspace:^"
"@backstage/plugin-kubernetes-backend": "workspace:^"
"@backstage/plugin-lighthouse-backend": "workspace:^"
"@backstage/plugin-linguist-backend": "workspace:^"
"@backstage/plugin-permission-backend": "workspace:^"
"@backstage/plugin-permission-common": "workspace:^"
"@backstage/plugin-permission-node": "workspace:^"
@@ -24806,10 +24881,10 @@ __metadata:
languageName: node
linkType: hard
"ignore@npm:^5.1.4, ignore@npm:^5.2.0":
version: 5.2.0
resolution: "ignore@npm:5.2.0"
checksum: 6b1f926792d614f64c6c83da3a1f9c83f6196c2839aa41e1e32dd7b8d174cef2e329d75caabb62cb61ce9dc432f75e67d07d122a037312db7caa73166a1bdb77
"ignore@npm:^5.1.4, ignore@npm:^5.2.0, ignore@npm:^5.2.4":
version: 5.2.4
resolution: "ignore@npm:5.2.4"
checksum: 3d4c309c6006e2621659311783eaea7ebcd41fe4ca1d78c91c473157ad6666a57a2df790fe0d07a12300d9aac2888204d7be8d59f9aaf665b1c7fcdb432517ef
languageName: node
linkType: hard
@@ -25826,7 +25901,7 @@ __metadata:
languageName: node
linkType: hard
"isbinaryfile@npm:^4.0.10, isbinaryfile@npm:^4.0.8":
"isbinaryfile@npm:^4.0.10, isbinaryfile@npm:^4.0.8, isbinaryfile@npm:^4.0.8 <5":
version: 4.0.10
resolution: "isbinaryfile@npm:4.0.10"
checksum: a6b28db7e23ac7a77d3707567cac81356ea18bd602a4f21f424f862a31d0e7ab4f250759c98a559ece35ffe4d99f0d339f1ab884ffa9795172f632ab8f88e686
@@ -27699,6 +27774,25 @@ __metadata:
languageName: node
linkType: hard
"linguist-js@npm:^2.5.3":
version: 2.5.4
resolution: "linguist-js@npm:2.5.4"
dependencies:
binary-extensions: ^2.2.0
commander: ^9.5.0
common-path-prefix: ^3.0.0
cross-fetch: ^3.1.5
ignore: ^5.2.4
isbinaryfile: ^4.0.8 <5
js-yaml: ^4.1.0
node-cache: ^5.1.2
bin:
linguist: bin/index.js
linguist-js: bin/index.js
checksum: 92ebfc45f0f17a056153068f7e939a3e6755307d3696673597e824011d83b698063241aa7d082a7f9059416efce85a49056f0a31b3158c25f79e273e2f51e309
languageName: node
linkType: hard
"linkify-it@npm:^3.0.1":
version: 3.0.2
resolution: "linkify-it@npm:3.0.2"
@@ -28244,6 +28338,13 @@ __metadata:
languageName: node
linkType: hard
"luxon@npm:^2.0.2":
version: 2.5.2
resolution: "luxon@npm:2.5.2"
checksum: d8b671ffd2ff0b438af862ac11082a81b3aedd9f8b6a4ca636f944224f8dd381125cd4d274ca0a8aedcaf2cfca0fc2ca96fe4cc1b16a28b7af701afb95c0bff8
languageName: node
linkType: hard
"luxon@npm:^3.0.0, luxon@npm:^3.2.1":
version: 3.2.1
resolution: "luxon@npm:3.2.1"
@@ -35075,6 +35176,13 @@ __metadata:
languageName: node
linkType: hard
"slugify@npm:^1.6.4":
version: 1.6.5
resolution: "slugify@npm:1.6.5"
checksum: a955a1b600201030f4c1daa9bb74a17d4402a0693fc40978bbd17e44e64fd72dad3bac4037422aa8aed55b5170edd57f3f4cd8f59ba331f5cf0f10f1a7795609
languageName: node
linkType: hard
"smart-buffer@npm:^4.2.0":
version: 4.2.0
resolution: "smart-buffer@npm:4.2.0"