Add Mend plugin (#1554)

* Added mend plugin

Signed-off-by: Darius <dariusz.sobkowicz@gmail.com>

* Feedback changes

Signed-off-by: Darius <dariusz.sobkowicz@gmail.com>

* Prettier fixes

Signed-off-by: Darius <dariusz.sobkowicz@gmail.com>

* Updated config visibility

Signed-off-by: Darius <dariusz.sobkowicz@gmail.com>

* Added API reports

Signed-off-by: Darius <dariusz.sobkowicz@gmail.com>

* Test fixes

Signed-off-by: Darius <dariusz.sobkowicz@gmail.com>

---------

Signed-off-by: Darius <dariusz.sobkowicz@gmail.com>
This commit is contained in:
dariuszsobkowicz 2024-10-25 21:45:50 +02:00 committed by GitHub
parent 32ffb060be
commit 270ac98dd7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
99 changed files with 32012 additions and 0 deletions

View File

@ -0,0 +1,8 @@
# Changesets
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
with multi-package repos, or single-package repos to help you version and publish your code. You can
find the full documentation for it [in our repository](https://github.com/changesets/changesets)
We have a quick list of common questions to get you started engaging with this project in
[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)

View File

@ -0,0 +1,10 @@
{
"$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [],
"linked": [],
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch"
}

View File

@ -0,0 +1,8 @@
.git
.yarn/cache
.yarn/install-state.gz
node_modules
packages/*/src
packages/*/node_modules
plugins
*.local.yaml

View File

@ -0,0 +1 @@
playwright.config.ts

View File

@ -0,0 +1,3 @@
module.exports = {
root: true,
};

54
workspaces/mend/.gitignore vendored Normal file
View File

@ -0,0 +1,54 @@
# macOS
.DS_Store
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Coverage directory generated when running tests with coverage
coverage
# Dependencies
node_modules/
# Yarn 3 files
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
# Node version directives
.nvmrc
# dotenv environment variables file
.env
.env.test
# Build output
dist
dist-types
# Temporary change files created by Vim
*.swp
# MkDocs build output
site
# Local configuration files
*.local.yaml
# Sensitive credentials
*-credentials.yaml
# vscode database functionality support files
*.session.sql
# E2E test reports
e2e-test-report/

View File

@ -0,0 +1,5 @@
dist
dist-types
coverage
.vscode
.eslintrc.js

10
workspaces/mend/README.md Normal file
View File

@ -0,0 +1,10 @@
# [Backstage](https://backstage.io)
This is your newly scaffolded Backstage App, Good Luck!
To start the app, run:
```sh
yarn install
yarn dev
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 408 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 355 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 679 KiB

View File

@ -0,0 +1,3 @@
{
"version": "1.29.0"
}

View File

@ -0,0 +1,13 @@
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: mend
description: An example of a Backstage application.
# Example for optional annotations
# annotations:
# github.com/project-slug: backstage/backstage
# backstage.io/techdocs-ref: dir:.
spec:
type: website
owner: john@example.com
lifecycle: experimental

View File

@ -0,0 +1,58 @@
{
"name": "@internal/mend",
"version": "1.0.0",
"private": true,
"engines": {
"node": "18 || 20"
},
"scripts": {
"tsc": "tsc",
"tsc:full": "tsc --skipLibCheck false --incremental false",
"build:all": "backstage-cli repo build --all",
"build:api-reports": "yarn build:api-reports:only --tsc",
"build:api-reports:only": "backstage-repo-tools api-reports -o ae-wrong-input-file-type --validate-release-tags",
"clean": "backstage-cli repo clean",
"test": "backstage-cli repo test",
"test:all": "backstage-cli repo test --coverage",
"fix": "backstage-cli repo fix",
"lint": "backstage-cli repo lint --since origin/main",
"lint:all": "backstage-cli repo lint",
"prettier:check": "prettier --check .",
"new": "backstage-cli new --scope @backstage-community"
},
"workspaces": {
"packages": [
"packages/*",
"plugins/*"
]
},
"repository": {
"type": "git",
"url": "https://github.com/backstage/community-plugins",
"directory": "workspaces/mend"
},
"devDependencies": {
"@backstage/cli": "^0.26.11",
"@backstage/e2e-test-utils": "^0.1.1",
"@backstage/repo-tools": "^0.8.0",
"@changesets/cli": "^2.27.1",
"@spotify/prettier-config": "^12.0.0",
"node-gyp": "^9.0.0",
"prettier": "^2.3.2",
"typescript": "~5.3.0"
},
"resolutions": {
"@types/react": "^18",
"@types/react-dom": "^18"
},
"prettier": "@spotify/prettier-config",
"lint-staged": {
"*.{js,jsx,ts,tsx,mjs,cjs}": [
"eslint --fix",
"prettier --write"
],
"*.{json,md}": [
"prettier --write"
]
}
}

View File

@ -0,0 +1,9 @@
# The Plugins Folder
This is where your own plugins and their associated modules live, each in a
separate folder of its own.
If you want to create a new plugin here, go to your project root directory, run
the command `yarn new`, and follow the on-screen instructions.
You can also check out existing plugins on [the plugin marketplace](https://backstage.io/plugins)!

View File

@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);

View File

@ -0,0 +1,54 @@
# mend.io - backend
> [!IMPORTANT]
> New Backend System
In your `packages/backend/src/index.ts` file:
```ts
backend.add(import('@backstage-community/plugin-mend-backend'));
```
### Plugin Permission (optional)
The plugin offers methods to construct conditional permissions an additional top layer to filter projects, which can be integrated into the your Organization Permission Policy.
- Provide a list of project IDs to the plugin. This will enable it to filter projects.
- Use the `exclude` property to fine-tune the filtering behavior, ensuring precise control over which projects are included or excluded from the permission set.
Here is a sample:
```ts
// ... other imports here
import {
mendReadPermission,
mendConditions,
createMendProjectConditionalDecision,
} from '@mend/backstage-plugin-mend-backend';
// ... other polices
export class OrganizationPolicy implements PermissionPolicy {
async handle(
request: PolicyQuery,
user?: BackstageIdentityResponse,
): Promise<PolicyDecision> {
if (isPermission(request.permission, mendReadPermission)) {
return createMendProjectConditionalDecision(
request.permission,
mendConditions.filter({
ids: [], // List of project id
exclude: true, // Default
}),
);
}
// ... other conditions
return {
result: AuthorizeResult.ALLOW,
};
}
}
// ...
```
**Add the mend.io frontend plugin**
See the [mend frontend plugin instructions](../mend/README.md).

View File

@ -0,0 +1,91 @@
## API Report File for "@backstage-community/plugin-mend-backend"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { AuthService } from '@backstage/backend-plugin-api';
import { BackendFeatureCompat } from '@backstage/backend-plugin-api';
import { ConditionalPolicyDecision } from '@backstage/plugin-permission-common';
import { Conditions } from '@backstage/plugin-permission-node';
import { Config } from '@backstage/config';
import { DiscoveryService } from '@backstage/backend-plugin-api';
import express from 'express';
import { HttpAuthService } from '@backstage/backend-plugin-api';
import { LoggerService } from '@backstage/backend-plugin-api';
import { PermissionCondition } from '@backstage/plugin-permission-common';
import { PermissionCriteria } from '@backstage/plugin-permission-common';
import { PermissionRule } from '@backstage/plugin-permission-node';
import { PermissionsService } from '@backstage/backend-plugin-api';
import { ResourcePermission } from '@backstage/plugin-permission-common';
// Warning: (ae-forgotten-export) The symbol "RESOURCE_TYPE" needs to be exported by the entry point index.d.ts
// Warning: (ae-missing-release-tag) "createMendProjectConditionalDecision" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const createMendProjectConditionalDecision: (
permission: ResourcePermission<RESOURCE_TYPE>,
conditions: PermissionCriteria<PermissionCondition<RESOURCE_TYPE>>,
) => ConditionalPolicyDecision;
// Warning: (ae-missing-release-tag) "createRouter" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export function createRouter(options: RouterOptions): Promise<express.Router>;
// Warning: (ae-missing-release-tag) "mendConditions" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const mendConditions: Conditions<{
filter: PermissionRule<
{
permission: {
type: string;
name: string;
attributes: {
action?: 'update' | 'read' | 'delete' | 'create' | undefined;
};
resourceType: RESOURCE_TYPE;
};
resourceRef: string;
},
FilterProps,
RESOURCE_TYPE,
{
ids: string[];
exclude?: boolean | undefined;
}
>;
}>;
// @public
const mendPlugin: BackendFeatureCompat;
export default mendPlugin;
// Warning: (ae-missing-release-tag) "mendReadPermission" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const mendReadPermission: ResourcePermission<RESOURCE_TYPE>;
// Warning: (ae-missing-release-tag) "RouterOptions" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export type RouterOptions = {
logger: LoggerService;
config: Config;
discovery: DiscoveryService;
auth: AuthService;
httpAuth: HttpAuthService;
permissions: PermissionsService;
};
// Warnings were encountered during analysis:
//
// src/permission/conditions.d.ts:5:22 - (ae-undocumented) Missing documentation for "mendConditions".
// src/permission/conditions.d.ts:6:5 - (ae-forgotten-export) The symbol "FilterProps" needs to be exported by the entry point index.d.ts
// src/permission/conditions.d.ts:21:22 - (ae-undocumented) Missing documentation for "createMendProjectConditionalDecision".
// src/permission/permissions.d.ts:4:22 - (ae-undocumented) Missing documentation for "mendReadPermission".
// src/service/router.d.ts:4:1 - (ae-undocumented) Missing documentation for "RouterOptions".
// src/service/router.d.ts:12:1 - (ae-undocumented) Missing documentation for "createRouter".
// (No @packageDocumentation comment for this package)
```

View File

@ -0,0 +1,9 @@
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: backstage-plugin-mend-backend
title: '@backstage-community/plugin-mend-backend'
spec:
lifecycle: experimental
type: backstage-backend-plugin
owner: maintainers

View File

@ -0,0 +1,12 @@
export type Config = {
mend: {
/**
* @visibility secret
*/
activationKey: string;
/**
* @visibility backend
*/
baseUrl: string;
};
};

View File

@ -0,0 +1,9 @@
import { createBackend } from '@backstage/backend-defaults';
const backend = createBackend();
backend.add(import('@backstage/plugin-auth-backend'));
backend.add(import('@backstage/plugin-auth-backend-module-guest-provider'));
backend.add(import('../src'));
backend.start();

View File

@ -0,0 +1,68 @@
{
"name": "@backstage-community/plugin-mend-backend",
"version": "0.1.0",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": true,
"publishConfig": {
"access": "public",
"main": "dist/index.cjs.js",
"types": "dist/index.d.ts"
},
"backstage": {
"role": "backend-plugin",
"pluginId": "mend",
"pluginPackages": [
"@backstage-community/plugin-mend",
"@backstage-community/plugin-mend-backend"
]
},
"configSchema": "config.d.ts",
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/community-plugins",
"directory": "workspaces/mend/plugins/mend-backend"
},
"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-defaults": "^0.4.1",
"@backstage/backend-plugin-api": "^0.7.0",
"@backstage/catalog-client": "^1.6.5",
"@backstage/catalog-model": "^1.5.0",
"@backstage/config": "^1.2.0",
"@backstage/plugin-permission-common": "^0.8.0",
"@backstage/plugin-permission-node": "^0.8.0",
"@types/express": "*",
"express": "^4.17.1",
"express-promise-router": "^4.1.0",
"jsonwebtoken": "^9.0.2",
"node-fetch": "^2.6.7",
"path-to-regexp": "^7.1.0",
"winston": "^3.2.1",
"yn": "^4.0.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@backstage/backend-test-utils": "^0.4.4",
"@backstage/cli": "^0.26.11",
"@backstage/plugin-auth-backend": "^0.22.9",
"@backstage/plugin-auth-backend-module-guest-provider": "^0.1.8",
"@types/supertest": "^2.0.12",
"msw": "^1.0.0",
"supertest": "^6.2.4"
},
"files": [
"dist",
"config.d.ts"
]
}

View File

@ -0,0 +1,128 @@
import { MendAuthSevice } from '../service/auth.service';
export type QueryParams = Record<string, string>;
type RequestHeaders = Record<string, string>;
enum ApiHeaders {
AUTH_TOKEN = 'Authorization',
CONTENT_TYPE = 'Content-Type',
AGENT_NAME = 'agent-name',
AGENT_VERSION = 'agent-version',
}
enum REQUEST_METHOD {
GET = 'GET',
POST = 'POST',
PUT = 'PUT',
DELETE = 'DELETE',
}
interface RequestOptions {
body?: any;
headers?: RequestHeaders;
params?: Record<string, any> | null;
}
function assembleUri(uri: string, params?: QueryParams): string {
if (!params) {
return uri;
}
const queryString = new URLSearchParams(params).toString();
return `${uri}?${queryString}`;
}
function buildHeaders(optionHeaders: RequestHeaders): Headers {
const headers = new Headers();
headers.set(ApiHeaders.CONTENT_TYPE, 'application/json');
headers.set(ApiHeaders.AGENT_NAME, 'pi-backstage');
headers.set(ApiHeaders.AGENT_VERSION, '24.8.2');
const authToken = MendAuthSevice.getAuthToken();
if (authToken) {
headers.set(ApiHeaders.AUTH_TOKEN, `Bearer ${authToken}`);
}
Object.keys(optionHeaders).forEach(header => {
const headerValue = optionHeaders[header];
if (headerValue) {
headers.set(header, headerValue);
}
});
return headers;
}
function fetchRequest(
method: REQUEST_METHOD,
path: string,
opts: RequestOptions,
): Promise<any> {
return MendAuthSevice.validateAuthToken(path).then(() => {
const { params, body, headers } = opts;
const url = `${MendAuthSevice.getBaseUrl()}${path}`;
const requestURL = params ? assembleUri(url, params) : url;
const requestParams = {
headers: buildHeaders(headers || {}),
method,
body,
};
if (body) {
requestParams.body =
typeof body === 'string' ? body : JSON.stringify(body);
}
const requestObject: Request = new Request(requestURL, requestParams);
return fetch(requestObject);
});
}
function toJson(response: Response): Promise<any> {
if (response.status === 204 || response.body === null) {
return Promise.resolve({});
}
return response.json().then(json => {
return response.ok ? json : Promise.reject(json);
});
}
const defaultOpts: RequestOptions = {
body: null,
headers: {},
params: null,
};
export function get<T>(
url: string,
opts: RequestOptions = defaultOpts,
): Promise<T> {
return fetchRequest(REQUEST_METHOD.GET, url, opts).then(toJson);
}
export function post<T>(
url: string,
opts: RequestOptions = defaultOpts,
): Promise<T> {
return fetchRequest(REQUEST_METHOD.POST, url, opts).then(toJson);
}
export function put<T>(
url: string,
opts: RequestOptions = defaultOpts,
): Promise<T> {
return fetchRequest(REQUEST_METHOD.PUT, url, opts).then(toJson);
}
export function remove<T>(
url: string,
opts: RequestOptions = defaultOpts,
): Promise<T> {
return fetchRequest(REQUEST_METHOD.DELETE, url, opts).then(toJson);
}

View File

@ -0,0 +1,7 @@
export * from './service/router';
export { mendPlugin as default } from './plugin';
export {
mendReadPermission,
mendConditions,
createMendProjectConditionalDecision,
} from './permission';

View File

@ -0,0 +1,37 @@
import express from 'express';
import { createConditionExports } from '@backstage/plugin-permission-node';
import {
createPermissionIntegrationRouter,
createConditionTransformer,
ConditionTransformer,
} from '@backstage/plugin-permission-node';
import { rules, type FilterProps } from './rules';
import { RESOURCE_TYPE, mendReadPermission } from './permissions';
const { conditions, createConditionalDecision } = createConditionExports({
pluginId: 'mend',
resourceType: RESOURCE_TYPE.PROJECT,
rules,
});
export const mendConditions = conditions;
export const createMendProjectConditionalDecision = createConditionalDecision;
export const permissionIntegrationRouter: express.Router =
createPermissionIntegrationRouter({
permissions: [mendReadPermission],
getResources: async resourceRefs => {
return resourceRefs.map(resourceRef => {
return {
permission: mendReadPermission,
resourceRef,
};
});
},
resourceType: RESOURCE_TYPE.PROJECT,
rules: Object.values(rules),
});
export const transformConditions: ConditionTransformer<FilterProps> =
createConditionTransformer(Object.values(rules));

View File

@ -0,0 +1,8 @@
export { RESOURCE_TYPE, mendReadPermission } from './permissions';
export {
createMendProjectConditionalDecision,
mendConditions,
permissionIntegrationRouter,
transformConditions,
} from './conditions';
export { rules, type FilterProps } from './rules';

View File

@ -0,0 +1,13 @@
import { createPermission } from '@backstage/plugin-permission-common';
export enum RESOURCE_TYPE {
PROJECT = 'mend-project',
}
export const mendReadPermission = createPermission({
name: 'mend.project.read',
attributes: { action: 'read' },
resourceType: RESOURCE_TYPE.PROJECT,
});
export const mendPermissions = [mendReadPermission];

View File

@ -0,0 +1,51 @@
import { z } from 'zod';
import { makeCreatePermissionRule } from '@backstage/plugin-permission-node';
import { RESOURCE_TYPE } from './permissions';
type PermissionAttributes = {
action?: 'create' | 'read' | 'update' | 'delete';
};
type ResourceProps = {
permission: {
type: string;
name: string;
attributes: PermissionAttributes;
resourceType: typeof RESOURCE_TYPE.PROJECT;
};
resourceRef: string;
};
export type FilterProps = {
ids: string[];
exclude?: boolean;
};
export const createProjectPermissionRule = makeCreatePermissionRule<
ResourceProps,
FilterProps,
typeof RESOURCE_TYPE.PROJECT
>();
export const filter = createProjectPermissionRule({
name: 'filter',
description: 'Should allow read-only access to filtered projects.',
resourceType: RESOURCE_TYPE.PROJECT,
paramsSchema: z.object({
ids: z.string().array().describe('Project ID to match resource'),
exclude: z.boolean().optional().describe('Exclude or include project'),
}),
apply: (resource, { ids, exclude = true }) => {
return exclude
? !ids.includes(resource.resourceRef)
: ids.includes(resource.resourceRef);
},
toQuery: ({ ids, exclude = true }) => {
return {
ids,
exclude,
};
},
});
export const rules = { filter };

View File

@ -0,0 +1,51 @@
import {
coreServices,
createBackendPlugin,
} from '@backstage/backend-plugin-api';
import { createRouter } from './service/router';
/**
* mendPlugin backend plugin
*
* @public
*/
export const mendPlugin = createBackendPlugin({
pluginId: 'mend',
register(env) {
env.registerInit({
deps: {
auth: coreServices.auth,
config: coreServices.rootConfig,
discovery: coreServices.discovery,
httpAuth: coreServices.httpAuth,
httpRouter: coreServices.httpRouter,
logger: coreServices.logger,
permissions: coreServices.permissions,
},
async init({
auth,
config,
discovery,
httpAuth,
httpRouter,
logger,
permissions,
}) {
httpRouter.use(
await createRouter({
auth,
config,
discovery,
httpAuth,
logger,
permissions,
}),
);
httpRouter.addAuthPolicy({
path: '/health',
allow: 'unauthenticated',
});
},
});
},
});

View File

@ -0,0 +1,10 @@
export const caesarCipherDecrypt = (activationKey: string): string => {
let tmp = '';
const OFFSET = 4;
for (let i = 0; i < activationKey.length; i++) {
tmp += String.fromCharCode(activationKey.charCodeAt(i) - OFFSET);
}
const reversed = tmp.split('').reverse().join('');
return Buffer.from(reversed, 'base64').toString();
};

View File

@ -0,0 +1,112 @@
import jwt from 'jsonwebtoken';
import { post } from '../api';
import { caesarCipherDecrypt } from './auth.service.helpers';
import {
JwtAuthToken,
JwtLicenceKeyPayload,
LoginSuccessResponseData,
MendConfig,
RefreshAccessTokenSuccessResponseData,
} from './auth.services.types';
enum AuthRoutes {
LOGIN = '/login',
REFRESH_TOKEN = '/login/accessToken',
}
export class MendAuthSevice {
private static authToken = '';
private static refreshToken = '';
private static baseUrl = '';
private static clientEmail = '';
private static clientKey = '';
private static clientUrl = '';
private static clientName = '';
private static clientUuid = '';
constructor(config: MendConfig) {
MendAuthSevice.baseUrl = config.baseUrl;
this.getConfig(config.activationKey);
}
private getConfig(activationKey: string) {
const licenseKey = caesarCipherDecrypt(activationKey);
const jwtPayload = jwt.decode(licenseKey) as JwtLicenceKeyPayload;
MendAuthSevice.clientEmail = jwtPayload.integratorEmail;
MendAuthSevice.clientKey = jwtPayload.userKey;
MendAuthSevice.clientUrl = jwtPayload.wsEnvUrl;
}
private static async login(): Promise<void> {
return post<LoginSuccessResponseData>(AuthRoutes.LOGIN, {
body: {
email: this.clientEmail,
userKey: this.clientKey,
},
}).then(data => {
this.refreshToken = data.response.refreshToken;
return Promise.resolve();
});
}
private static async refreshAccessToken(): Promise<void> {
return post<RefreshAccessTokenSuccessResponseData>(
AuthRoutes.REFRESH_TOKEN,
{
headers: {
'wss-refresh-token': this.refreshToken,
},
},
).then(data => {
this.authToken = data.response.jwtToken;
this.clientName = data.response.orgName;
this.clientUuid = data.response.orgUuid;
return Promise.resolve();
});
}
static async connect(): Promise<void> {
return MendAuthSevice.login().then(() =>
MendAuthSevice.refreshAccessToken(),
);
}
static async validateAuthToken(url: string): Promise<void> {
if (
[AuthRoutes.LOGIN, AuthRoutes.REFRESH_TOKEN].includes(url as AuthRoutes)
) {
return Promise.resolve();
}
if (!this.authToken) {
return this.connect();
}
const token = jwt.decode(this.authToken) as JwtAuthToken;
if (new Date(Number(`${token.exp}000`)).getTime() - Date.now() < 0) {
return this.connect();
}
return Promise.resolve();
}
static getAuthToken(): string {
return MendAuthSevice.authToken;
}
static getBaseUrl(): string {
return MendAuthSevice.baseUrl;
}
static getOrganizationUuid(): string {
return MendAuthSevice.clientUuid;
}
static getClientUrl(): string {
return MendAuthSevice.clientUrl;
}
static getClientName(): string {
return MendAuthSevice.clientName;
}
}

View File

@ -0,0 +1,61 @@
export type MendConfig = {
baseUrl: string;
activationKey: string;
};
export type LoginSuccessResponseData = {
additionalData: Record<string, unknown>;
supportToken: string;
response: {
userUuid: string;
userName: string;
email: string;
refreshToken: string;
jwtTTL: number;
sessionStartTime: number;
};
};
export type RefreshAccessTokenSuccessResponseData = {
additionalData: Record<string, unknown>;
supportToken: string;
response: {
userUuid: string;
username: string;
email: string;
jwtToken: string;
tokenType: string;
orgName: string;
orgUuid: string;
tokenTTL: number;
sessionStartTime: number;
};
};
export type JwtLicenceKeyPayload = {
sub: string;
iss: string;
iat: number;
exp: number;
wsEnvIdentifier: string;
wsEnvUrl: string;
orgToken: string;
orgUuid: string;
userKey: string;
integratorEmail: string;
};
export type JwtAuthToken = {
sub: string;
email: string;
uuid: string;
orgName: string;
orgUuid: string;
domainName: string;
domainUuid: string;
tier: string;
sessionStartTime: number;
correlationId: string;
iat: number;
exp: number;
};

View File

@ -0,0 +1,359 @@
import { Entity } from '@backstage/catalog-model';
import { match } from 'path-to-regexp';
import type { QueryParams } from '../api';
import {
ProjectStatisticsSuccessResponseData,
EntityURL,
OrganizationProjectSuccessResponseData,
PaginationQueryParams,
Project,
CodeFindingSuccessResponseData,
DependenciesFindingSuccessResponseData,
ContainersFindingSuccessResponseData,
Finding,
StatisticsEngine,
StatisticsName,
} from './data.service.types';
enum FINDING_TYPE {
DEPENDENCIES = 'ALERTS',
CODE = 'SAST_VULNERABILITIES_BY_SEVERITY',
CONTAINERS = 'IMG_SECURITY',
LAST_SCAN = 'LAST_SCAN',
}
type OverviewData = {
projectList: Project[];
};
export const dataProjectParser = (
projectStatistics: Array<
ProjectStatisticsSuccessResponseData & { entity: EntityURL }
>,
organizationProjects: OrganizationProjectSuccessResponseData[],
) => {
const organizationData = organizationProjects.reduce((prev, next) => {
prev[next.uuid] = next;
return prev;
}, {} as { [key: string]: OrganizationProjectSuccessResponseData });
const projectData = projectStatistics.reduce(
(
prev: OverviewData,
next: ProjectStatisticsSuccessResponseData & { entity: EntityURL },
) => {
const dependenciesCritical =
next.statistics[FINDING_TYPE.DEPENDENCIES]
.criticalSeverityVulnerabilities;
const dependenciesHigh =
next.statistics[FINDING_TYPE.DEPENDENCIES].highSeverityVulnerabilities;
const dependenciesMedium =
next.statistics[FINDING_TYPE.DEPENDENCIES]
.mediumSeverityVulnerabilities;
const dependenciesLow =
next.statistics[FINDING_TYPE.DEPENDENCIES].lowSeverityVulnerabilities;
const dependeciesTotal =
dependenciesCritical +
dependenciesHigh +
dependenciesMedium +
dependenciesLow;
const codeHigh =
next.statistics[FINDING_TYPE.CODE].sastHighVulnerabilities;
const codeMedium =
next.statistics[FINDING_TYPE.CODE].sastMediumVulnerabilities;
const codeLow = next.statistics[FINDING_TYPE.CODE].sastLowVulnerabilities;
const codeTotal = codeHigh + codeMedium + codeLow;
const containersCritical =
next.statistics[FINDING_TYPE.CONTAINERS].imgCriticalVulnerabilities;
const containersHigh =
next.statistics[FINDING_TYPE.CONTAINERS].imgHighVulnerabilities;
const containersMedium =
next.statistics[FINDING_TYPE.CONTAINERS].imgMediumVulnerabilities;
const containersLow =
next.statistics[FINDING_TYPE.CONTAINERS].imgLowVulnerabilities;
const containersTotal =
containersCritical + containersHigh + containersMedium + containersLow;
const criticalTotal = dependenciesCritical + containersCritical;
const highTotal = dependenciesHigh + codeHigh + containersHigh;
const mediumTotal = dependenciesMedium + codeMedium + containersMedium;
const lowTotal = dependenciesLow + codeLow + containersLow;
const total = dependeciesTotal + codeTotal + containersTotal;
const statistics = {
[StatisticsEngine.DEPENDENCIES]: {
critical: dependenciesCritical,
high: dependenciesHigh,
medium: dependenciesMedium,
low: dependenciesLow,
total: dependeciesTotal,
},
[StatisticsEngine.CODE]: {
critical: null,
high: codeHigh,
medium: codeMedium,
low: codeLow,
total: codeTotal,
},
[StatisticsEngine.CONTAINERS]: {
critical: containersCritical,
high: containersHigh,
medium: containersMedium,
low: containersLow,
total: containersTotal,
},
critical: criticalTotal,
high: highTotal,
medium: mediumTotal,
low: lowTotal,
total: total,
};
const project = {
statistics,
uuid: next.uuid,
name: next.name,
path: next.path,
entity: next.entity,
applicationName: organizationData[next.uuid].applicationName,
applicationUuid: next.applicationUuid,
lastScan: next.statistics[FINDING_TYPE.LAST_SCAN].lastScanTime,
languages: Object.entries(next.statistics.LIBRARY_TYPE_HISTOGRAM).sort(
(a, b) => b[1] - a[1],
),
};
prev.projectList.unshift(project);
return prev;
},
{
projectList: [],
},
);
projectData.projectList.sort(
(a, b) => b.statistics.critical - a.statistics.critical,
);
return projectData;
};
const parseEntityURL = (entityUrl?: string) => {
try {
if (!entityUrl) {
return null;
}
const matches = entityUrl.match(
/https?:\/\/[a-zA-Z0-9\-\.]+\.[a-zA-Z]{2,}(:[0-9]{1,5})?(\/.*)?/g,
);
if (!matches) {
return null;
}
const url = new URL(matches[0]);
const fn = match('/:org/:repo', { end: false });
return fn(url.pathname);
} catch (error) {
return null;
}
};
export const dataMatcher = (
entities: Entity[],
projects: ProjectStatisticsSuccessResponseData[],
) => {
return entities.reduce(
(
prev: Array<
ProjectStatisticsSuccessResponseData & {
entity: EntityURL;
}
>,
next: Entity,
) => {
const entityURL = parseEntityURL(
next?.metadata?.annotations?.['backstage.io/source-location'],
);
if (!entityURL) {
return prev;
}
// NOTE: Find project based on GH_ prefix
const project = projects.find(
(item: { path: string }) =>
item.path.match(/^GH_(.*)/)?.[1] === entityURL?.params.repo,
);
if (!project) {
return prev;
}
const entity = {
path: entityURL.path,
params: entityURL.params,
namespace: next.metadata.namespace,
kind: 'component',
source: 'catalog',
};
prev.push({ ...project, entity });
return prev;
},
[],
);
};
const getIssueStatus = (
engine: StatisticsEngine,
finding:
| CodeFindingSuccessResponseData
| DependenciesFindingSuccessResponseData
| ContainersFindingSuccessResponseData,
): string => {
if (engine === StatisticsEngine.CODE) {
if ((finding as CodeFindingSuccessResponseData)?.suppressed)
return 'suppressed';
if (
(finding as CodeFindingSuccessResponseData)?.almIssues?.jiraPlatform
?.issueStatus
)
return 'created';
if ((finding as CodeFindingSuccessResponseData)?.reviewed)
return 'reviewed';
}
if (engine === StatisticsEngine.DEPENDENCIES) {
// NOTE: Available status: IGNORED and ACTIVE
// ACTIVE means unreviewed
// IGNORED means suppressed, comment fields are available to this status
if (
(finding as DependenciesFindingSuccessResponseData)?.findingInfo
?.status === 'IGNORED'
)
return 'suppressed';
}
return 'unreviewed';
};
export const dataFindingParser = (
code: CodeFindingSuccessResponseData[] = [],
dependencies: DependenciesFindingSuccessResponseData[] = [],
containers: ContainersFindingSuccessResponseData[] = [],
) => {
let codeFindings: Finding[] = [];
let dependenciesFindings: Finding[] = [];
let containersFindings: Finding[] = [];
if (code.length) {
codeFindings = code.map(finding => {
return {
kind: StatisticsEngine.CODE,
level: finding.severity.toLowerCase() as StatisticsName,
name: finding.type.cwe.title,
origin: `${finding.sharedStep.file}:${finding.sharedStep.line}`,
time: finding?.createdTime,
issue: {
issueStatus: finding.almIssues.jiraPlatform.issueStatus,
reporter: finding.almIssues.jiraPlatform.createdByName,
creationDate: finding.almIssues.jiraPlatform.createdTime,
ticketName: finding.almIssues.jiraPlatform.issueKey,
link: `${finding.almIssues.jiraPlatform.publicLink}/browse/${finding.almIssues.jiraPlatform.issueKey}`,
status: getIssueStatus(StatisticsEngine.CODE, finding),
},
};
});
}
if (dependencies.length) {
dependenciesFindings = dependencies.map(finding => {
return {
kind: StatisticsEngine.DEPENDENCIES,
level: finding.vulnerability.severity.toLowerCase() as StatisticsName,
name: finding.vulnerability.name,
origin: finding.component.name,
time: finding.vulnerability.modifiedDate,
issue: {
issueStatus: '',
reporter: '',
creationDate: '',
ticketName: '',
link: '',
status: getIssueStatus(StatisticsEngine.DEPENDENCIES, finding),
},
};
});
}
if (containers.length) {
containersFindings = containers.map(finding => {
return {
kind: StatisticsEngine.CONTAINERS,
level: finding.severity.toLowerCase() as StatisticsName,
name: finding.vulnerabilityId,
origin: finding.packageName,
time: finding.detectionDate,
issue: {
issueStatus: '',
reporter: '',
creationDate: '',
ticketName: '',
link: '',
status: getIssueStatus(StatisticsEngine.CONTAINERS, finding), // NOTE: Currently, issue for finding in containers no exist.
},
};
});
}
const order: { [k: string]: number } = {
critical: 1,
high: 2,
medium: 3,
low: 4,
};
return [...codeFindings, ...dependenciesFindings, ...containersFindings].sort(
(a, b) => {
return order[a.level] - order[b.level];
},
);
};
const parseQueryString = (href = '?'): QueryParams => {
const [, queryString] = href.split('?');
const queryParams: QueryParams = {};
new URLSearchParams(queryString).forEach((val, key) => {
queryParams[key] = val;
});
return queryParams;
};
export const fetchQueryPagination = async <T>(cb: Function) => {
const defaultQueryParams = { limit: '10000', cursor: '0' };
const collection: T[] = [];
const fetchLoop = async (queryParams: PaginationQueryParams) => {
const result = await cb({ queryParams });
collection.push(...result.response);
const nextQuery = result.additionalData?.paging?.next;
if (nextQuery) {
const newQueryParams = parseQueryString(nextQuery);
await fetchLoop(newQueryParams);
}
};
await fetchLoop(defaultQueryParams);
return collection;
};

View File

@ -0,0 +1,76 @@
import { get, post } from '../api';
import { MendAuthSevice } from './auth.service';
import {
GetOrganizationProjectRequestData,
GetProjectStatisticsRequestData,
GetCodeFindingsRequestData,
GetDependenciesFindingsRequestData,
GetContainersFindingsRequestData,
GetOrganizationProjectSuccessResponseData,
GetProjectStatisticsSuccessResponseData,
GetCodeFindingSuccessResponseData,
GetDependenciesFindingSuccessResponseData,
GetContainersFindingSuccessResponseData,
} from './data.service.types';
export class MendDataService extends MendAuthSevice {
async getOrganizationProject({
queryParams,
}: GetOrganizationProjectRequestData): Promise<GetOrganizationProjectSuccessResponseData> {
return get(`/orgs/${MendAuthSevice.getOrganizationUuid()}/projects`, {
params: {
...queryParams,
},
});
}
async getProjectStatistics({
queryParams,
bodyParams,
}: GetProjectStatisticsRequestData): Promise<GetProjectStatisticsSuccessResponseData> {
return post(
`/orgs/${MendAuthSevice.getOrganizationUuid()}/projects/summaries`,
{
params: {
...queryParams,
},
body: {
...bodyParams,
},
},
);
}
async getCodeFinding({
pathParams,
queryParams,
}: GetCodeFindingsRequestData): Promise<GetCodeFindingSuccessResponseData> {
return get(`/projects/${pathParams.uuid}/code/findings`, {
params: {
...queryParams,
},
});
}
async getDependenciesFinding({
pathParams,
queryParams,
}: GetDependenciesFindingsRequestData): Promise<GetDependenciesFindingSuccessResponseData> {
return get(`/projects/${pathParams.uuid}/dependencies/findings/security`, {
params: {
...queryParams,
},
});
}
async getContainersFinding({
pathParams,
queryParams,
}: GetContainersFindingsRequestData): Promise<GetContainersFindingSuccessResponseData> {
return get(`/projects/${pathParams.uuid}/images/findings/security`, {
params: {
...queryParams,
},
});
}
}

View File

@ -0,0 +1,490 @@
export type PaginationQueryParams = {
cursor?: string;
limit?: string;
};
type PaginationSuccessResponseData = {
additionalData: {
totalItems: number;
paging: {
next?: string;
};
};
};
type BodyParams = {
projectUuids?: string[];
applicationUuid?: string[];
};
type PathParams = {
uuid: string;
};
export type GetOrganizationProjectRequestData = {
queryParams?: PaginationQueryParams;
};
export type OrganizationProjectSuccessResponseData = {
uuid: string;
name: string;
path: string;
applicationName: string;
applicationUuid: string;
};
export type GetOrganizationProjectSuccessResponseData = {
supportToken: string;
response: OrganizationProjectSuccessResponseData[];
} & PaginationSuccessResponseData;
export type GetProjectStatisticsRequestData = {
queryParams?: PaginationQueryParams;
bodyParams?: BodyParams;
};
export type ProjectStatisticsSuccessResponseData = {
uuid: string;
name: string;
path: string;
applicationUuid: string;
creationDate: string;
tags: [];
labels: [];
statistics: {
UNIFIED_VULNERABILITIES: {
unifiedCriticalVulnerabilities: number;
unifiedHighVulnerabilities: number;
unifiedMediumVulnerabilities: number;
unifiedLowVulnerabilities: number;
unifiedVulnerabilities: number;
};
VULNERABILITY_EFFECTIVENESS: {};
LIBRARY_TYPE_HISTOGRAM: Record<string, number>;
IMG_USAGE: {};
POLICY_VIOLATION_LIBRARIES: {
policyViolatingLibraries: number;
};
SAST_VULNERABILITIES_BY_TYPE: Record<string, number>;
GENERAL: {
totalLibraries: number;
};
LLM_SECURITY: {
llmTotalLines: number;
};
IMG_SECURITY: {
imgCriticalVulnerabilities: number;
imgMaxRiskScore: number;
imgMediumVulnerabilities: number;
imgLowVulnerabilities: number;
imgSecretMediumVulnerabilities: number;
imgUnknownVulnerabilities: number;
imgSecretHighVulnerabilities: number;
imgTotalVulnerabilities: number;
imgHighVulnerabilities: number;
imgSecretCriticalVulnerabilities: number;
imgSecretLowVulnerabilities: number;
};
ALERTS: {
criticalSeverityVulnerabilities: number;
highSeverityVulnerabilities: number;
vulnerableLibraries: number;
mediumSeverityVulnerabilities: number;
lowSeverityVulnerabilities: number;
};
OUTDATED_LIBRARIES: {
outdatedLibraries: number;
};
POLICY_VIOLATIONS: {};
SAST_SCAN: {
sastTotalLines: number;
sastTestedFiles: number;
sastTotalFiles: number;
sastTestedLines: number;
sastTotalMended: number;
sastTotalRemediations: number;
};
VULNERABILITY_SEVERITY_LIBRARIES: {
lowSeverityLibraries: number;
highSeverityLibraries: number;
mediumSeverityLibraries: number;
criticalSeverityLibraries: number;
};
LICENSE_RISK: {
highRiskLicenses: number;
mediumRiskLicenses: number;
lowRiskLicenses: number;
};
IAC_SECURITY: {
iacCriticalMisconfigurations: number;
iacHighMisconfigurations: number;
iacTotalMisconfigurations: number;
iacLowMisconfigurations: number;
iacMediumMisconfigurations: number;
};
SCA_SECURITY: {};
LICENSE_HISTOGRAM: Record<string, number>;
SAST_VULNERABILITIES_BY_SEVERITY: {
sastVulnerabilities: number;
sastHighVulnerabilities: number;
sastMediumVulnerabilities: number;
sastLowVulnerabilities: number;
};
LAST_SCAN: {
lastScanTime: number;
lastScaScanTime: number;
lastImgScanTime: number;
lastSastScanTime: number;
};
};
};
export type GetProjectStatisticsSuccessResponseData = {
supportToken: string;
response: ProjectStatisticsSuccessResponseData[];
} & PaginationSuccessResponseData;
export type EntityURL = {
path: string;
params: {
org?: string;
repo?: string;
};
namespace?: string;
kind: string;
source: string;
};
export enum StatisticsName {
CRITICAL = 'critical',
HIGH = 'high',
MEDIUM = 'medium',
LOW = 'low',
TOTAL = 'total',
}
export enum StatisticsEngine {
DEPENDENCIES = 'dependencies',
CODE = 'code',
CONTAINERS = 'containers',
}
type StatisticsBase = {
[StatisticsName.CRITICAL]: number;
[StatisticsName.HIGH]: number;
[StatisticsName.MEDIUM]: number;
[StatisticsName.LOW]: number;
[StatisticsName.TOTAL]: number;
};
export type Statistics = {
[StatisticsEngine.DEPENDENCIES]: StatisticsBase;
[StatisticsEngine.CODE]: Omit<StatisticsBase, StatisticsName.CRITICAL> & {
[StatisticsName.CRITICAL]: null;
};
[StatisticsEngine.CONTAINERS]: StatisticsBase;
} & StatisticsBase;
export type Project = {
statistics: Statistics;
uuid: string;
name: string;
path: string;
applicationName: string;
applicationUuid: string;
lastScan: number;
languages: Array<[string, number]>;
entity: EntityURL;
};
// Code Finding API Data
type CodeFindingDataFlowSuccessResponseData = {
id: string;
sink: string;
sinkKind: string;
sinkFile: string;
sinkSnippet: string;
sinkLine: number;
inputSource: string;
inputKind: string;
inputFlow: [
{
name: string;
kind: string;
file: string;
snippet: string;
line: number;
startLine: number;
endLine: number;
},
];
functionCalls: [
{
name: string;
kind: string;
file: string;
snippet: string;
line: number;
startLine: number;
endLine: number;
},
];
filter: {
isFiltered: boolean;
filterTypes: unknown[];
};
isNew: boolean;
rating: number;
confidenceRating: number;
ageRating: number;
};
export type CodeFindingSuccessResponseData = {
id: string;
scanId: string;
snapshotId: string;
projectId: string;
appId: string;
type: {
id: number;
name: string;
engineId: number;
language: string;
sarif: string;
sarifLevel: string;
order: number;
severity: StatisticsName;
severityRating: number;
description: string;
recommendations: [string];
references: [string];
cwe: {
id: string;
title: string;
url: string;
};
pcidss: {
section: string;
title: string;
};
nist: {
control: string;
priority: string;
title: string;
url: string;
};
hipaa: {
control: string;
title: string;
};
hitrust: {
control: string;
title: string;
};
owasp: {
index: string;
title: string;
url: string;
};
owasp2021: {
index: string;
title: string;
url: string;
};
capec: {
id: string;
title: string;
url: string;
};
sansTop25: {
rank: number;
title: string;
};
};
description: string;
createdTime: string;
isNew: boolean;
severity: StatisticsName;
baseline: boolean;
hasRemediation: boolean;
suppressed: boolean;
suppressedBy: string;
suppressionTime: string;
suppressionMessage: string;
reviewed: boolean;
IssueStatus: number;
sharedStep: {
name: string;
kind: string;
file: string;
snippet: string;
line: number;
startLine: number;
endLine: number;
lineBlame: {
commitId: string;
file: string;
line: number;
};
};
dataFlows: CodeFindingDataFlowSuccessResponseData[];
severityRating: number;
confidenceRating: number;
ageRating: number;
rating: number;
almIssues: {
jira: {
issueId: string;
project: string;
};
azure: {
workItemId: number;
project: string;
};
jiraPlatform: {
internalStatus: string;
issueStatus: string;
issueKey: string;
publicLink: string;
createdTime: string;
createdBy: string;
createdByName: string;
};
};
comments: unknown[];
};
export type GetCodeFindingSuccessResponseData = {
response: CodeFindingSuccessResponseData[];
supportToken: string;
} & PaginationSuccessResponseData;
export type GetCodeFindingsRequestData = {
queryParams?: PaginationQueryParams;
pathParams: PathParams;
};
// Dependencies Finding API Data
export type DependenciesFindingSuccessResponseData = {
uuid: string;
name: string;
type: string;
component: {
uuid: string;
name: string;
description: string;
componentType: string;
libraryType: string;
rootLibrary: boolean;
references: {
url: string;
homePage: string;
genericPackageIndex: string;
};
groupId: string;
artifactId: string;
version: string;
path: string;
};
findingInfo: {
status: string;
comment: unknown;
detectedAt: string;
modifiedAt: string;
};
project: {
uuid: string;
name: string;
path: string;
applicationUuid: string;
};
application: {
uuid: string;
name: string;
};
vulnerability: {
name: string;
type: string;
description: string;
score: number;
severity: StatisticsName;
publishDate: string;
modifiedDate: string;
vulnerabilityScoring: {
score: number;
severity: string;
type: string;
}[];
};
topFix: {
id: number;
vulnerability: string;
type: string;
origin: string;
url: string;
fixResolution: string;
date: string;
message: string;
};
effective: string;
threatAssessment: {
exploitCodeMaturity: string;
epssPercentage: number;
};
exploitable: boolean;
scoreMetadataVector: string;
};
export type GetDependenciesFindingSuccessResponseData = {
supportToken: string;
response: DependenciesFindingSuccessResponseData[];
} & PaginationSuccessResponseData;
export type GetDependenciesFindingsRequestData = {
queryParams?: PaginationQueryParams;
pathParams: PathParams;
};
// Containers Finding API Data
export type ContainersFindingSuccessResponseData = {
uuid: string;
vulnerabilityId: string;
description: string;
projectUuid: string;
imageName: string;
packageName: string;
packageVersion: string;
severity: StatisticsName;
cvss: number;
epss: number;
hasFix: false;
fixVersion: string;
publishedDate: string;
detectionDate: string;
};
export type GetContainersFindingSuccessResponseData = {
supportToken: string;
response: ContainersFindingSuccessResponseData[];
} & PaginationSuccessResponseData;
export type GetContainersFindingsRequestData = {
queryParams?: PaginationQueryParams;
pathParams: PathParams;
};
export type Finding = {
kind: StatisticsEngine;
level: StatisticsName;
name: string;
origin: string;
time: string;
issue: {
issueStatus: string;
reporter: string;
creationDate: string;
ticketName: string;
link: string;
status: string;
};
};

View File

@ -0,0 +1,57 @@
import { mockServices } from '@backstage/backend-test-utils';
import { ConfigReader } from '@backstage/config';
import { PermissionEvaluator } from '@backstage/plugin-permission-common';
import express from 'express';
import request from 'supertest';
import jwt from 'jsonwebtoken';
import { createRouter } from './router';
const mockedAuthorize: jest.MockedFunction<PermissionEvaluator['authorize']> =
jest.fn();
const mockedPermissionQuery: jest.MockedFunction<
PermissionEvaluator['authorizeConditional']
> = jest.fn();
const permissionEvaluator: PermissionEvaluator = {
authorize: mockedAuthorize,
authorizeConditional: mockedPermissionQuery,
};
describe('createRouter', () => {
let app: express.Express;
beforeAll(async () => {
jest.spyOn(jwt, 'decode').mockImplementation(() => ({
integratorEmail: 'DUMMY_INTEGRATOR_EMAIL',
userKey: 'DUMMY_USER_KEY',
wsEnvUrl: 'DUMMY_WS_ENV_URL',
}));
const router = await createRouter({
logger: mockServices.logger.mock(),
config: new ConfigReader({
mend: {
activationKey: 'DUMMY_ACTIVATION_KEY',
baseUrl: 'DUMMY_BASE_URL',
},
}),
discovery: mockServices.discovery(),
auth: mockServices.auth(),
httpAuth: mockServices.httpAuth(),
permissions: permissionEvaluator,
});
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' });
});
});
});

View File

@ -0,0 +1,280 @@
import express from 'express';
import Router from 'express-promise-router';
import { MiddlewareFactory } from '@backstage/backend-defaults/rootHttpRouter';
import {
LoggerService,
DiscoveryService,
AuthService,
HttpAuthService,
PermissionsService,
} from '@backstage/backend-plugin-api';
import { CatalogClient } from '@backstage/catalog-client';
import { Config } from '@backstage/config';
import { AuthorizeResult } from '@backstage/plugin-permission-common';
import {
dataFindingParser,
dataMatcher,
dataProjectParser,
fetchQueryPagination,
} from './data.service.helpers';
import { MendDataService } from './data.service';
import { MendAuthSevice } from './auth.service';
import {
PaginationQueryParams,
ProjectStatisticsSuccessResponseData,
OrganizationProjectSuccessResponseData,
CodeFindingSuccessResponseData,
DependenciesFindingSuccessResponseData,
ContainersFindingSuccessResponseData,
} from './data.service.types';
import {
mendReadPermission,
transformConditions,
permissionIntegrationRouter,
type FilterProps,
} from '../permission';
export type RouterOptions = {
logger: LoggerService;
config: Config;
discovery: DiscoveryService;
auth: AuthService;
httpAuth: HttpAuthService;
permissions: PermissionsService;
};
enum ROUTE {
PROJECT = '/project',
FINDING = '/finding',
}
export async function createRouter(
options: RouterOptions,
): Promise<express.Router> {
const { logger, config, discovery, auth, httpAuth, permissions } = options;
const router = Router();
router.use(express.json());
router.use(permissionIntegrationRouter);
const checkForAuth = (
_request: express.Request,
response: express.Response,
next: express.NextFunction,
) => {
if (MendAuthSevice.getAuthToken()) {
next();
return;
}
MendAuthSevice.connect()
.then(next)
.catch(() => {
response.status(401).json({ error: 'Oops! Unauthorized' });
});
};
const baseUrl = config.getString('mend.baseUrl');
const activationKey = config.getString('mend.activationKey');
// Init api service
const mendDataService = new MendDataService({
baseUrl,
activationKey,
});
// Init catalog client
const catalogClient = new CatalogClient({ discoveryApi: discovery });
// Routes
router.get(ROUTE.PROJECT, checkForAuth, async (request, response) => {
try {
// service to service auth
const credentials = await httpAuth.credentials(request);
const { token } = await auth.getPluginRequestToken({
onBehalfOf: credentials,
targetPluginId: 'plugin.catalog.service',
});
// entity to project match
const results = await Promise.all([
catalogClient.getEntities(
{ filter: [{ kind: ['Component'] }] },
{ token },
),
fetchQueryPagination<ProjectStatisticsSuccessResponseData>(
mendDataService.getProjectStatistics,
),
fetchQueryPagination<OrganizationProjectSuccessResponseData>(
mendDataService.getOrganizationProject,
),
]);
// permission - filter to exclude or include project
const decision = (
await permissions.authorizeConditional(
[{ permission: mendReadPermission }],
{
credentials,
},
)
)[0];
let items;
if (decision.result === AuthorizeResult.CONDITIONAL) {
const filter = transformConditions(decision.conditions) as FilterProps;
items = results[1].filter(item =>
filter?.exclude
? !filter.ids.includes(item.uuid)
: filter.ids.includes(item.uuid),
);
}
const data = dataMatcher(results[0].items, items || results[1]);
// parse data
const projects = dataProjectParser(data, results[2]);
response.json({
...projects,
clientUrl: MendAuthSevice.getClientUrl(),
clientName: MendAuthSevice.getClientName(),
});
// Allow any object structure here
} catch (error: any) {
logger.error('/project', error);
response.status(500).json({ error: 'Oops! Please try again later.' });
}
});
router.post(ROUTE.FINDING, checkForAuth, async (request, response) => {
try {
// service to service auth
const credentials = await httpAuth.credentials(request);
const { token } = await auth.getPluginRequestToken({
onBehalfOf: credentials,
targetPluginId: 'plugin.catalog.service',
});
// entity to project match
const uid = request.body.uid;
if (!uid) {
response.status(401).json({ error: 'Oops! No UUID provided' });
return;
}
const projectResult = await Promise.all([
catalogClient.getEntities(
{ filter: [{ 'metadata.uid': uid }] },
{ token },
),
fetchQueryPagination<ProjectStatisticsSuccessResponseData>(
mendDataService.getProjectStatistics,
),
fetchQueryPagination<OrganizationProjectSuccessResponseData>(
mendDataService.getOrganizationProject,
),
]);
// permission - filter to exclude or include project
const decision = (
await permissions.authorizeConditional(
[{ permission: mendReadPermission }],
{
credentials,
},
)
)[0];
let items;
if (decision.result === AuthorizeResult.CONDITIONAL) {
const filter = transformConditions(decision.conditions) as FilterProps;
items = projectResult[1].filter(item =>
filter?.exclude
? !filter.ids.includes(item.uuid)
: filter.ids.includes(item.uuid),
);
}
const data = dataMatcher(
projectResult[0].items,
items || projectResult[1],
);
if (!data.length) {
response.json({
findingList: [],
projectName: '',
projectUuid: '',
clientUrl: MendAuthSevice.getClientUrl(),
clientName: MendAuthSevice.getClientName(),
});
return;
}
const params = {
pathParams: {
uuid: data[0].uuid,
},
};
// get project findings
const findingResult = await Promise.all([
fetchQueryPagination<CodeFindingSuccessResponseData>(
(queryParam: PaginationQueryParams) =>
mendDataService.getCodeFinding({
...params,
...queryParam,
}),
),
fetchQueryPagination<DependenciesFindingSuccessResponseData>(
(queryParam: PaginationQueryParams) =>
mendDataService.getDependenciesFinding({
...params,
...queryParam,
}),
),
fetchQueryPagination<ContainersFindingSuccessResponseData>(
(queryParam: PaginationQueryParams) =>
mendDataService.getContainersFinding({
...params,
...queryParam,
}),
),
]);
const project = dataProjectParser(data, projectResult[2]);
const findingList = dataFindingParser(
findingResult[0].filter(item => !item.suppressed), // NOTE: Do not show suppressed item
findingResult[1].filter(
item => !(item.findingInfo.status === 'IGNORED'),
), // NOTE: Do not show ignored item
findingResult[2], // ESC-51: Follow Jira activity
);
response.json({
findingList,
projectName: project.projectList[0].entity.params.repo,
projectUuid: project.projectList[0].uuid,
clientUrl: MendAuthSevice.getClientUrl(),
clientName: MendAuthSevice.getClientName(),
});
// Allow any object structure here
} catch (error: any) {
logger.error('/finding', error);
response.status(500).json({ error: 'Oops! Please try again later.' });
}
});
router.get('/health', (_, response) => {
logger.info('PONG!');
response.json({ status: 'ok' });
});
const middleware = MiddlewareFactory.create({ logger, config });
router.use(middleware.error());
return router;
}

View File

@ -0,0 +1 @@
export {};

View File

@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);

View File

@ -0,0 +1,134 @@
# mend.io
This plugin integrates mend.io functionality seamlessly into your Backstage application.
### Plugin Compatibility
The plugin has been successfully tested with Backstage v1.28. If you are using a newer version of Backstage, please file an issue, and we will provide guidance on the best integration practices for your specific version.
### Features
This plugin provides views to display:
- Collections of integrated projects
- Collection of project security findings
Use these views to visualize your data.
### Project Overview
This view showcases a project list along with statistics derived from these projects.
![Project Overview](../../assets/overview.png)
### Findings Overview
This view presents the project's security findings and detailed statistics derived from these findings.
![Findings Overview](../../assets/tab.png)
### Installation
From your Backstage root directory, run the following commands:
```bash
yarn --cwd packages/app add @backstage-community/plugin-mend
yarn --cwd packages/backend add @backstage-community/plugin-mend-backend
```
### Dependencies
> [!IMPORTANT]
> Please note that the frontend plugin will not function without the backend plugin.
### Getting Started
**Get mend.io Activation Key:**
1. Navigate to the "Settings" menu.
2. Select "Integrations" from the available options.
3. Click on the "Backstage" card.
4. Click "Get Activation Key" to generate key.
![Activation Key](../../assets/key.png)
**Configure your mend.io Activation Key** in your local app-config.yaml or production app-config.production.yaml file:
```yaml
mend:
baseUrl: ${API_URL_HERE}
activationKey: ${YOUR_ACTIVATION_KEY_HERE}
```
**Add the mend.io tab to your entity page:**
In your `packages/app/src/components/Catalog/EntityPage.tsx` file:
```tsx
// ... other imports here
import { MendTab } from '@backstage-community/plugin-mend';
// ... other components
const serviceEntityPage = (
<EntityLayout>
<EntityLayout.Route path="/" title="Overview">
// ... other elements
<EntityLayout.Route path="/mend" title="mend.io">
<MendTab />
</EntityLayout.Route>
// ... other elements
</EntityLayout.Route>
</EntityLayout>
// ...
);
// ...
```
**Add the mend.io page to your routes:**
In your `packages/app/src/App.tsx` file:
```tsx
// ... other imports here
import { MendPage } from '@backstage-community/plugin-mend';
// ... other components
const routes = (
<FlatRoutes>
<Route path="/" element={<Navigate to="catalog" />} />
<Route path="/catalog" element={<CatalogIndexPage />} />
// ... other elements
<Route path="/mend" element={<MendPage />} />
// ... other elements
</FlatRoutes>
// ...
);
// ...
```
**Add the mend.io sidebar button:**
In your `packages/app/src/components/Root/Root.tsx` file:
```tsx
// ... other imports here
import { MendSidebar } from '@backstage-community/plugin-mend';
// ... other components
export const Root = ({ children }: PropsWithChildren<{}>) => (
<SidebarPage>
<Sidebar>
// ... other elements
<MendSidebar />
// ... other elements
</Sidebar>
{children}
</SidebarPage>
// ...
);
// ...
```
**Add the mend.io backend plugin**
See the [mend backend plugin instructions](../mend-backend/README.md).

View File

@ -0,0 +1,47 @@
## API Report File for "@backstage-community/plugin-mend"
> 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 { JSX as JSX_2 } from 'react';
import { default as React_2 } from 'react';
import { RouteRef } from '@backstage/core-plugin-api';
// Warning: (ae-missing-release-tag) "Page" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const MendPage: () => JSX_2.Element;
// Warning: (ae-missing-release-tag) "Sidebar" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const MendSidebar: () => React_2.JSX.Element;
// Warning: (ae-missing-release-tag) "TabProvider" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const MendTab: () => React_2.JSX.Element;
// Warning: (ae-missing-release-tag) "plugin" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const plugin: BackstagePlugin<
{
root: RouteRef<undefined>;
},
{},
{}
>;
// Warnings were encountered during analysis:
//
// src/components/Sidebar.d.ts:2:22 - (ae-undocumented) Missing documentation for "Sidebar".
// src/pages/tab/TabProvider.d.ts:2:22 - (ae-undocumented) Missing documentation for "TabProvider".
// src/plugin.d.ts:2:22 - (ae-undocumented) Missing documentation for "plugin".
// src/plugin.d.ts:5:22 - (ae-undocumented) Missing documentation for "Page".
// (No @packageDocumentation comment for this package)
```

View File

@ -0,0 +1,9 @@
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: backstage-plugin-mend
title: '@backstage-community/plugin-mend'
spec:
lifecycle: experimental
type: backstage-frontend-plugin
owner: maintainers

View File

@ -0,0 +1,12 @@
import React from 'react';
import { createDevApp } from '@backstage/dev-utils';
import { plugin, Page } from '../src/plugin';
createDevApp()
.registerPlugin(plugin)
.addPage({
element: <Page />,
title: 'Mend Page',
path: '/mend',
})
.render();

View File

@ -0,0 +1,70 @@
{
"name": "@backstage-community/plugin-mend",
"version": "0.1.0",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": true,
"publishConfig": {
"access": "public",
"main": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"backstage": {
"role": "frontend-plugin",
"pluginId": "mend",
"pluginPackages": [
"@backstage-community/plugin-mend",
"@backstage-community/plugin-mend-backend"
]
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/community-plugins",
"directory": "workspaces/mend/plugins/mend"
},
"sideEffects": false,
"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/core-components": "^0.14.9",
"@backstage/core-plugin-api": "^1.9.3",
"@backstage/plugin-catalog-react": "^1.12.2",
"@backstage/theme": "^0.5.6",
"@material-table/core": "4.1.0",
"@material-ui/core": "^4.9.13",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.61",
"@tanstack/react-query": "^5.51.11",
"react-use": "^17.2.4"
},
"peerDependencies": {
"react": "^16.13.1 || ^17.0.0 || ^18.0.0",
"react-dom": "^16.13.1 || ^17.0.0 || ^18.0.0",
"react-router-dom": "^6.25.1"
},
"devDependencies": {
"@backstage/cli": "^0.26.11",
"@backstage/core-app-api": "^1.14.1",
"@backstage/dev-utils": "^1.0.36",
"@backstage/test-utils": "^1.5.9",
"@testing-library/jest-dom": "^6.0.0",
"@testing-library/react": "^14.0.0",
"@testing-library/user-event": "^14.0.0",
"msw": "^1.0.0",
"react": "^16.13.1 || ^17.0.0 || ^18.0.0",
"react-dom": "^16.13.1 || ^17.0.0 || ^18.0.0",
"react-router-dom": "^6.25.1"
},
"files": [
"dist"
]
}

View File

@ -0,0 +1,16 @@
import React from 'react';
import { Routes, Route } from 'react-router-dom';
import { QueryClientProvider } from '@tanstack/react-query';
import { queryClient } from './api';
import { Overview } from './pages/overview';
export const App = () => {
return (
<QueryClientProvider client={queryClient}>
<Routes>
{/* myPlugin.routes.root will take the user to this page */}
<Route path="/" element={<Overview />} />
</Routes>
</QueryClientProvider>
);
};

View File

@ -0,0 +1,145 @@
import { QueryClient } from '@tanstack/react-query';
import { createApiRef, DiscoveryApi } from '@backstage/core-plugin-api';
/**
* Plugin API
*/
export type MendApi = {
discoveryApi: DiscoveryApi;
};
export const mendApiRef = createApiRef<MendApi>({
id: 'plugin.mend.service',
});
export class MendClient {
discoveryApi: DiscoveryApi;
constructor(options: { discoveryApi: DiscoveryApi }) {
this.discoveryApi = options.discoveryApi;
}
}
/**
* React Query Client
*/
export const queryClient = new QueryClient();
/**
* Fetch: GET, POST, PUT, REMOVE
*/
type QueryParams = Record<string, string>;
type RequestHeaders = Record<string, string>;
enum ApiHeaders {
CONTENT_TYPE = 'Content-Type',
}
enum REQUEST_METHOD {
GET = 'GET',
POST = 'POST',
PUT = 'PUT',
DELETE = 'DELETE',
}
interface RequestOptions {
body?: any;
headers?: RequestHeaders;
params?: Record<string, any> | null;
signal?: AbortSignal;
}
function assembleUri(uri: string, params?: QueryParams): string {
if (!params) {
return uri;
}
const queryString = new URLSearchParams(params).toString();
return `${uri}?${queryString}`;
}
function buildHeaders(optionHeaders: RequestHeaders): Headers {
const headers = new Headers();
headers.set(ApiHeaders.CONTENT_TYPE, 'application/json');
Object.keys(optionHeaders).forEach(header => {
const headerValue = optionHeaders[header];
if (headerValue) {
headers.set(header, headerValue);
}
});
return headers;
}
function fetchRequest(
fetchApi: typeof fetch,
method: REQUEST_METHOD,
url: string,
opts: RequestOptions,
): Promise<any> {
const { params, body, headers, signal } = opts;
const requestURL = params ? assembleUri(url, params) : url;
const requestParams: RequestInit = {
headers: buildHeaders(headers || {}),
method,
signal,
};
if (body) {
requestParams.body = typeof body === 'string' ? body : JSON.stringify(body);
}
return fetchApi(requestURL, requestParams);
}
function toJson(response: Response): Promise<any> {
if (response.status === 204 || response.body === null) {
return Promise.resolve({});
}
return response.json().then(json => {
return response.ok ? json : Promise.reject(json);
});
}
const defaultOpts: RequestOptions = {
body: null,
headers: {},
params: null,
};
export function get<T>(
fetchApi: typeof fetch,
url: string,
opts: RequestOptions = defaultOpts,
): Promise<T> {
return fetchRequest(fetchApi, REQUEST_METHOD.GET, url, opts).then(toJson);
}
export function post<T>(
fetchApi: typeof fetch,
url: string,
opts: RequestOptions = defaultOpts,
): Promise<T> {
return fetchRequest(fetchApi, REQUEST_METHOD.POST, url, opts).then(toJson);
}
export function put<T>(
fetchApi: typeof fetch,
url: string,
opts: RequestOptions = defaultOpts,
): Promise<T> {
return fetchRequest(fetchApi, REQUEST_METHOD.PUT, url, opts).then(toJson);
}
export function remove<T>(
fetchApi: typeof fetch,
url: string,
opts: RequestOptions = defaultOpts,
): Promise<T> {
return fetchRequest(fetchApi, REQUEST_METHOD.DELETE, url, opts).then(toJson);
}

View File

@ -0,0 +1,55 @@
import React from 'react';
import {
Card as Containter,
CardContent,
CardHeader,
CircularProgress,
Divider,
makeStyles,
} from '@material-ui/core';
type CardProps = {
children: React.ReactNode;
loading?: boolean;
title: string;
};
const useStyles = makeStyles(() => ({
container: {
border: '1px solid #dfdfdf',
height: '100%',
minHeight: 168,
},
header: {
justifyContent: 'center',
padding: '16px',
'& span': {
fontWeight: 500,
fontSize: '20px',
},
},
content: {
alignItems: 'center',
justifyContent: 'space-around',
display: 'flex',
flexDirection: 'column',
},
}));
export const Card = ({
children,
loading,
title,
}: CardProps): React.ReactNode => {
const classes = useStyles();
return (
<Containter className={classes.container}>
<CardHeader className={classes.header} title={title} />
<Divider />
<CardContent className={classes.content}>
{loading ? <CircularProgress /> : children}
</CardContent>
</Containter>
);
};

View File

@ -0,0 +1,9 @@
import React from 'react';
import { Header as CoreHeader } from '@backstage/core-components';
export const Header = () => (
<CoreHeader
title="mend.io"
style={{ backgroundImage: 'none', backgroundColor: '#073C8C' }}
/>
);

View File

@ -0,0 +1,24 @@
import React from 'react';
import { SvgIcon } from '@material-ui/core';
import { SidebarItem } from '@backstage/core-components';
export const Sidebar = () => {
const icon = () => (
<SvgIcon viewBox="0 0 16 16">
<path
d="M8 16C12.4183 16 16 12.4183 16 8C16 3.58172 12.4183 0 8 0C3.58172 0 0 3.58172 0 8C0 12.4183 3.58172 16 8 16Z"
fill="white"
/>
<path
d="M14.7268 10.9104C13.599 13.5032 11.0124 15.3176 7.99982 15.3176C7.87708 15.3176 7.75565 15.3149 7.63496 15.3088C3.75499 15.1185 0.668518 11.9184 0.668518 7.99864C0.668518 7.34333 0.756684 6.70761 0.918775 6.10373C1.08426 5.92773 1.3162 5.81738 1.57459 5.81738C2.0751 5.81738 2.4827 6.2283 2.4827 6.73195V7.02647C2.4827 7.03121 2.48066 7.03664 2.48066 7.0407V9.26664C2.48066 10.1873 3.22871 10.9368 4.14699 10.9368C5.06527 10.9368 5.81331 10.1873 5.81331 9.26664V7.47058C5.81331 7.46447 5.81131 7.45973 5.81131 7.45367V6.73264C5.81131 6.22898 6.21885 5.81801 6.71936 5.81801C7.21988 5.81801 7.62748 6.22898 7.62748 6.73264V9.26733C7.62748 10.188 8.37485 10.9374 9.29313 10.9374C10.2114 10.9374 10.9595 10.188 10.9595 9.26733V7.47127C10.9595 7.46515 10.9574 7.46041 10.9574 7.4543V6.73333C10.9574 6.22967 11.365 5.8187 11.8655 5.8187C12.366 5.8187 12.7736 6.22967 12.7736 6.73333V6.79293C12.7736 6.79698 12.773 6.80104 12.773 6.80578V9.26733C12.773 10.1149 13.4064 10.8169 14.2236 10.9232C14.2935 10.932 14.3654 10.9374 14.4386 10.9374C14.537 10.9374 14.6332 10.9273 14.7275 10.911"
fill="#35393C"
/>
<path
d="M15.3306 7.99994C15.3306 8.66268 15.2405 9.30314 15.0764 9.91445L15.0757 9.91714C15.0743 9.91851 15.0729 9.9192 15.0729 9.9192C14.9088 10.081 14.6857 10.1812 14.4382 10.1812C13.9377 10.1812 13.5301 9.77022 13.5301 9.26657V8.74057C13.5301 8.73514 13.5308 8.73177 13.5308 8.72702V6.73194C13.5308 5.81119 12.7834 5.0611 11.8651 5.0611C10.9468 5.0611 10.1988 5.81119 10.1988 6.73194V8.77171C10.1988 8.77639 10.2008 8.78182 10.2008 8.78794V9.26657C10.2008 9.77022 9.79322 10.1812 9.2927 10.1812C8.79219 10.1812 8.38459 9.77022 8.38459 9.26657V6.73257C8.38459 5.81188 7.6379 5.06177 6.71962 5.06177C5.80133 5.06177 5.05328 5.81188 5.05328 6.73257V8.77234C5.05328 8.77708 5.05532 8.78319 5.05532 8.78862V9.26725C5.05532 9.77091 4.64772 10.1819 4.14721 10.1819C3.6467 10.1819 3.23911 9.77091 3.23911 9.26725V8.97274C3.23911 8.96799 3.24114 8.96394 3.24114 8.95851V6.73257C3.24114 5.81188 2.49377 5.06313 1.57549 5.06177H1.57481C1.47173 5.06177 1.37068 5.07193 1.27234 5.09021C2.3995 2.49598 4.98615 0.681641 7.99939 0.681641C8.03871 0.681641 8.07733 0.681641 8.1167 0.682321C12.1113 0.745281 15.33 3.99687 15.33 7.99925"
fill="#35393C"
/>
</SvgIcon>
);
return <SidebarItem icon={icon} to="mend" text="mend.io" />;
};

View File

@ -0,0 +1,102 @@
import React, { ReactElement, useState } from 'react';
import { makeStyles } from '@material-ui/core';
import { StatisticsBarScrap } from './internal/StatisticsBarScrap';
import { StatisticsBarSegment } from './internal/StatisticsBarSegment';
import { StatisticsBarProps } from './statisticsBar.types';
import {
getTotalFindings,
getTotalFindingsByEngine,
} from './statisticsBar.helpers';
const useStyles = makeStyles(() => ({
container: {
backgroundColor: 'white',
display: 'flex',
flexDirection: 'column',
borderRadius: '4px',
border: '1px solid #dfdfdf',
},
header: {
padding: '1rem',
fontSize: '16px',
fontWeight: 600,
},
content: {
width: '100%',
},
barContainer: {
display: 'flex',
gap: '1rem',
flexDirection: 'column',
},
barScrapContainer: {
display: 'flex',
flexWrap: 'wrap',
rowGap: '0.5rem',
},
barSegmentContainer: {
display: 'flex',
borderRadius: '4px',
overflow: 'hidden',
},
}));
export const StatisticsBar = ({
statistics,
type,
}: StatisticsBarProps): ReactElement => {
const classes = useStyles({});
const getStatistics = {
default: getTotalFindings,
engine: getTotalFindingsByEngine,
};
const data = getStatistics[type](statistics);
const [hoveredElementId, setHoveredElementId] = useState<string | null>(null);
const total = data.reduce((acc, current) => acc + current.value, 0);
const extendedData = data.map(item => ({
...item,
percentage: (100 * item.value) / total,
}));
return (
<div className={classes.content}>
<div className={classes.barContainer}>
<div className={classes.barSegmentContainer}>
{!!total ? (
extendedData.map(item => (
<StatisticsBarSegment
key={item.key}
color={item.color}
percentage={item.percentage}
onHover={() => setHoveredElementId(item.key)}
isHovered={item.key === hoveredElementId}
onLeave={() => setHoveredElementId(null)}
/>
))
) : (
<StatisticsBarSegment percentage={100} isHovered={false} />
)}
</div>
<div className={classes.barScrapContainer}>
{extendedData.map(item => (
<StatisticsBarScrap
key={item.key}
color={item.color}
value={total ? item.value : 0}
name={item.key}
onHover={() =>
total ? setHoveredElementId(item.key) : undefined
}
onLeave={() => (total ? setHoveredElementId(null) : undefined)}
isHovered={total ? item.key === hoveredElementId : false}
/>
))}
</div>
</div>
</div>
);
};

View File

@ -0,0 +1 @@
export { StatisticsBar } from './StatisticsBar';

View File

@ -0,0 +1,82 @@
import React, { ReactElement } from 'react';
import { makeStyles, Theme } from '@material-ui/core';
import { numberToShortText } from '../../../utils';
import { StatisticsBarScrapProps } from '../statisticsBar.types';
import { linearGradient } from '../statisticsBar.helpers';
const useStyles = makeStyles<Theme, { color: string; isHovered: boolean }>(
() => ({
scrapContainer: {
display: 'flex',
justifyContent: 'between',
width: 'auto',
gap: '0.3rem',
rowGap: '0.5rem',
},
scrapContent: {
height: '20px',
width: '20px',
borderRadius: '3px',
backgroundColor: ({ color }) => color,
alignItems: 'center',
backgroundSize: '6px 6px',
flexShrink: 0,
backgroundImage: ({ isHovered }: { isHovered: boolean }): string =>
isHovered ? linearGradient : '',
cursor: ({ isHovered }: { isHovered: boolean }): string =>
isHovered ? 'pointer' : '',
'&:hover': {
backgroundImage: ({ isHovered }: { isHovered: boolean }): string =>
isHovered ? linearGradient : '',
cursor: ({ isHovered }: { isHovered: boolean }): string =>
isHovered ? 'pointer' : '',
},
},
scrapLabel: {
display: 'flex',
width: '100%',
gap: '0.3rem',
textTransform: 'capitalize',
alignItems: 'center',
paddingRight: '16px',
},
scrapName: {
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
display: 'block',
},
amount: {
fontWeight: 500,
},
}),
);
export const StatisticsBarScrap = ({
color,
value,
name,
onHover,
isHovered,
onLeave,
}: StatisticsBarScrapProps): ReactElement => {
const classes = useStyles({
isHovered,
color,
});
return (
<div className={classes.scrapContainer}>
<div
onMouseEnter={onHover}
onMouseLeave={onLeave}
className={classes.scrapContent}
/>
<span
className={`${classes.scrapLabel} MuiTypography-root MuiTypography-body1`}
>
<span className={classes.scrapName}>{name}</span>
<span className={classes.amount}>{numberToShortText(value)}</span>
</span>
</div>
);
};

View File

@ -0,0 +1,66 @@
import React from 'react';
import { makeStyles, Tooltip, Theme } from '@material-ui/core';
import { StatisticsBarSegmentProps } from '../statisticsBar.types';
import { linearGradient } from '../statisticsBar.helpers';
const useStyles = makeStyles<
Theme,
{ percentage: number; color?: string; isHovered: boolean }
>(theme => ({
segment: {
height: '36px',
backgroundSize: '10px 10px',
width: ({ percentage }) => `${percentage}%`,
backgroundColor: ({ color }) => {
if (color) return color;
return theme.palette.type === 'light'
? '#F5F6F8'
: theme.palette.background.default;
},
backgroundImage: ({ isHovered }: { isHovered: boolean }) =>
isHovered ? linearGradient : '',
cursor: ({ isHovered }: { isHovered: boolean }) =>
isHovered ? 'pointer' : '',
'&:hover': {
backgroundImage: ({ isHovered }: { isHovered: boolean }) =>
isHovered ? linearGradient : '',
cursor: ({ isHovered }: { isHovered: boolean }) =>
isHovered ? 'pointer' : '',
},
},
tooltipContainer: {
display: 'flex',
flexDirection: 'column',
},
}));
export const StatisticsBarSegment = ({
percentage,
color = '',
onHover,
isHovered,
onLeave,
tooltipContent = null,
}: StatisticsBarSegmentProps) => {
const classes = useStyles({
isHovered,
color,
percentage,
});
return tooltipContent ? (
<Tooltip title={tooltipContent} arrow placement="top">
<div
onMouseEnter={onHover}
onMouseLeave={onLeave}
className={classes.segment}
/>
</Tooltip>
) : (
<div
onMouseEnter={onHover}
onMouseLeave={onLeave}
className={classes.segment}
/>
);
};

View File

@ -0,0 +1,64 @@
import { Statistics, StatisticsName, StatisticsEngine } from '../../models';
import { BarChartData } from './statisticsBar.types';
export const getTotalFindings = (data: Statistics): BarChartData[] => {
return [
{
value: data?.[StatisticsName.CRITICAL],
key: StatisticsName.CRITICAL,
color: '#a72461',
diff: 0,
isIncrease: true,
},
{
value: data?.[StatisticsName.HIGH],
key: StatisticsName.HIGH,
color: '#f73c57',
diff: 0,
isIncrease: true,
},
{
value: data?.[StatisticsName.MEDIUM],
key: StatisticsName.MEDIUM,
color: '#f09c4f',
diff: 0,
isIncrease: true,
},
{
value: data?.[StatisticsName.LOW],
key: StatisticsName.LOW,
color: '#f6bc35',
diff: 0,
isIncrease: true,
},
];
};
export const getTotalFindingsByEngine = (data: Statistics): BarChartData[] => {
return [
{
value: data?.[StatisticsEngine.DEPENDENCIES]?.total,
key: StatisticsEngine.DEPENDENCIES,
color: '#3453c1',
diff: 0,
isIncrease: true,
},
{
value: data?.[StatisticsEngine.CODE]?.total,
key: StatisticsEngine.CODE,
color: '#3e8bff',
diff: 0,
isIncrease: true,
},
{
value: data?.[StatisticsEngine.CONTAINERS]?.total,
key: StatisticsEngine.CONTAINERS,
color: '#4bc4d4',
diff: 0,
isIncrease: true,
},
];
};
export const linearGradient =
'linear-gradient(135deg, transparent 25%, rgba(255, 255, 255, 0.2) 25%, rgba(255, 255, 255, 0.2) 50%, transparent 50%, transparent 75%, rgba(255, 255, 255, 02) 75%, rgba(255, 255, 255, 0.2) 75%, rgba(255, 255, 255, 0.2) 100%)';

View File

@ -0,0 +1,34 @@
import { ReactElement } from 'react';
import { Statistics } from './../../models';
export type BarChartData = {
key: string;
value: number;
color: string;
diff: number;
isIncrease: boolean;
};
export type StatisticsBarProps = {
statistics: Statistics;
type: 'default' | 'engine';
tooltipContent?: ReactElement | null;
};
export type StatisticsBarSegmentProps = {
percentage: number;
color?: string;
tooltipContent?: ReactElement | null;
onHover?: () => void;
onLeave?: () => void;
isHovered: boolean;
};
export type StatisticsBarScrapProps = {
color: string;
value: number;
name: string;
onHover?: () => void;
onLeave?: () => void;
isHovered: boolean;
};

View File

@ -0,0 +1,124 @@
import React, { forwardRef } from 'react';
import { Chip, makeStyles, Theme } from '@material-ui/core';
export enum TagColor {
CRITICAL = 'critical',
HIGH = 'high',
MEDIUM = 'medium',
LOW = 'low',
DEPENDENCIES = 'dependencies',
CODE = 'code',
CONTAINERS = 'containers',
ACTIVE = 'active',
DISABLED = 'disabled',
SUCCESS = 'success',
NEUTRAL = 'neutral',
}
export const colorVariants: {
[key: string]: {
backgroundColor: string;
color: string;
};
} = {
critical: {
backgroundColor: '#a72461',
color: 'white',
},
high: {
backgroundColor: '#f73c57',
color: 'white',
},
medium: {
backgroundColor: '#f09c4f',
color: '#232f3e',
},
low: {
backgroundColor: '#f6bc35',
color: '#232f3e',
},
dependencies: {
backgroundColor: '#3453c1',
color: 'white',
},
code: {
backgroundColor: '#3e8bff',
color: 'white',
},
containers: {
backgroundColor: '#4bc4d4',
color: 'white',
},
active: {
backgroundColor: '#E7F5FC',
color: '#0073B9',
},
disabled: {
backgroundColor: '#EDEEEF',
color: '#232F3E',
},
success: {
backgroundColor: '#E9F7F4',
color: '#007C5D',
},
neutral: {
backgroundColor: '#E7E8EB',
color: '#232F3E',
},
};
type TagProps = {
color?: TagColor;
label: string | number;
shapeVariant?: 'rounded' | 'square';
width?: string;
height?: string;
fontWeight?: number;
};
const useStyles = makeStyles<
Theme,
{
color: string;
shapeVariant: string;
width: string;
height: string;
fontWeight: number;
}
>(() => ({
root: ({ color, shapeVariant, width, height }) => ({
...colorVariants[color],
borderRadius: shapeVariant === 'square' ? '4px' : '',
width,
margin: 0,
textTransform: 'none',
height,
}),
label: ({ fontWeight }) => ({
fontSize: '12px',
fontWeight,
}),
}));
export const Tag = forwardRef<HTMLDivElement, TagProps>(
(
{
label = '',
color = TagColor.NEUTRAL,
shapeVariant = 'rounded',
width = 'auto',
height = '',
fontWeight = 400,
},
ref,
) => {
const classes = useStyles({
color,
shapeVariant,
width,
height,
fontWeight,
});
return <Chip classes={classes} label={label} ref={ref} />;
},
);

View File

@ -0,0 +1,99 @@
import React from 'react';
import {
makeStyles,
Theme,
Tooltip as MaterialTooltip,
} from '@material-ui/core';
import { useResize } from '../hooks';
type ExtendedClassesProps = {
tooltip?: {
[key: string]: string | number;
};
arrow?: {
[key: string]: string | number;
};
};
type TooltipProps = {
children: string | React.ReactElement;
tooltipContent: string | React.ReactElement;
isAlwaysVisible?: boolean;
extendedClasses?: ExtendedClassesProps;
};
const useStyles = makeStyles<
Theme,
{
extendedClasses?: ExtendedClassesProps;
}
>(theme => ({
tooltip: ({ extendedClasses }) => ({
backgroundColor:
theme.palette.type === 'light'
? '#232F3E'
: theme.palette.background.default,
...extendedClasses?.tooltip,
}),
arrow: {
color:
theme.palette.type === 'light'
? '#232F3E'
: theme.palette.background.default,
},
}));
const useAdditionalStyles = makeStyles(() => ({
contentWrapper: {
cursor: 'auto',
display: 'block',
width: 'max-content',
},
}));
export const Tooltip = ({
children,
tooltipContent,
isAlwaysVisible = true,
extendedClasses = {},
}: TooltipProps) => {
const node = React.useRef<HTMLDivElement | null>(null);
const [isEllipsis, setIsEllipsis] = React.useState(false);
const compare = () => {
const firstChild = node?.current?.children?.length
? Array.from(node?.current?.children)?.[0]
: null;
let refChild = null;
if (firstChild) {
refChild = firstChild?.children?.length
? firstChild?.children?.[0]
: firstChild;
}
if (refChild) setIsEllipsis(refChild?.scrollWidth > refChild?.clientWidth);
};
useResize(compare);
const isDisabled = isAlwaysVisible ? false : !isEllipsis;
const classes = useStyles({ extendedClasses });
const additionalClasses = useAdditionalStyles();
return (
<MaterialTooltip
classes={classes}
title={tooltipContent}
disableHoverListener={isDisabled}
placement="top"
arrow
interactive
>
<span className={additionalClasses.contentWrapper} ref={node}>
{children}
</span>
</MaterialTooltip>
);
};

View File

@ -0,0 +1,127 @@
import React from 'react';
import {
Card as MaterialCard,
CardActions,
CardContent,
CardHeader,
Divider,
Grid,
Link,
makeStyles,
SvgIcon,
Typography,
} from '@material-ui/core';
import { Statistics } from '../models';
import { numberToShortText } from '../utils';
import { Card } from './Card';
import { StatisticsBar } from './StatisticsBar';
type TotalProps = {
clientName: string;
data: Statistics;
dataLoading: boolean;
title: string;
url: string;
};
const useStyles = makeStyles(theme => ({
container: {
border: '1px solid #dfdfdf',
boxShadow: '0px 2px 4px 0px #00000026',
},
content: {
alignItems: 'center',
display: 'flex',
flexDirection: 'column',
backgroundColor:
theme.palette.type === 'light'
? '#F5F6F8'
: theme.palette.background.default,
},
header: {
justifyContent: 'center',
padding: '16px',
'& span': {
fontWeight: 500,
fontSize: '20px',
},
},
actions: {
display: 'flex',
padding: '16px',
alignItems: 'center',
justifyContent: 'flex-start',
},
icon: {
width: '16px',
height: '16px',
},
link: {
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-start',
gap: '0.4rem',
},
total: {
fontWeight: 500,
fontSize: '63px',
},
}));
export const Total = ({
clientName,
data,
dataLoading,
title,
url,
}: TotalProps) => {
const classes = useStyles();
return (
<MaterialCard className={classes.container}>
<CardHeader className={classes.header} title={title} />
<Divider />
<CardContent className={classes.content}>
<Grid container direction="row" style={{ paddingBottom: '1rem' }}>
<Grid item lg={2} md={12}>
<Card title="Total Findings" loading={dataLoading}>
<Typography
variant="h1"
component="span"
className={classes.total}
>
{numberToShortText(data.total)}
</Typography>
</Card>
</Grid>
<Grid item lg={5} md={12}>
<Card title="Total Findings by Severity" loading={dataLoading}>
<StatisticsBar statistics={data} type="default" />
</Card>
</Grid>
<Grid item lg={5} md={12}>
<Card title="Total Findings by Scan Engine" loading={dataLoading}>
<StatisticsBar statistics={data} type="engine" />
</Card>
</Grid>
</Grid>
</CardContent>
<Divider />
<CardActions className={classes.actions}>
<Link
href={url}
target="_blank"
rel="noreferrer"
variant="subtitle1"
className={classes.link}
>
<SvgIcon viewBox="0 0 16 16" className={classes.icon}>
<path d="M9.1875 0.0488281C8.94687 0.0488281 8.75 0.245703 8.75 0.486328C8.75 0.726953 8.94687 0.923828 9.1875 0.923828L12.507 0.923828L5.81602 7.61484C5.64648 7.78438 5.64648 8.06328 5.81602 8.23281C5.98555 8.40234 6.26445 8.40234 6.43398 8.23281L13.125 1.5418V4.86133C13.125 5.10195 13.3219 5.29883 13.5625 5.29883C13.8031 5.29883 14 5.10195 14 4.86133V0.486328C14 0.245703 13.8031 0.0488281 13.5625 0.0488281L9.1875 0.0488281ZM1.75 1.79883C0.784766 1.79883 0 2.58359 0 3.54883L0 12.2988C0 13.2641 0.784766 14.0488 1.75 14.0488L10.5 14.0488C11.4652 14.0488 12.25 13.2641 12.25 12.2988V8.36133C12.25 8.1207 12.0531 7.92383 11.8125 7.92383C11.5719 7.92383 11.375 8.1207 11.375 8.36133V12.2988C11.375 12.7828 10.984 13.1738 10.5 13.1738L1.75 13.1738C1.26602 13.1738 0.875 12.7828 0.875 12.2988L0.875 3.54883C0.875 3.06484 1.26602 2.67383 1.75 2.67383L5.6875 2.67383C5.92812 2.67383 6.125 2.47695 6.125 2.23633C6.125 1.9957 5.92812 1.79883 5.6875 1.79883L1.75 1.79883Z" />
</SvgIcon>
{!dataLoading &&
`Go to Organization “${clientName}” in the mend.io Platform`}
</Link>
</CardActions>
</MaterialCard>
);
};

View File

@ -0,0 +1,8 @@
export { Card } from './Card';
export { Tag, TagColor } from './Tag';
export { Header } from './Header';
export { Total } from './Total';
export { Sidebar } from './Sidebar';
export { StatisticsBar } from './StatisticsBar';
export { Tooltip } from './Tooltip';
export * from './table';

View File

@ -0,0 +1,166 @@
import React, { forwardRef } from 'react';
import { Table as TableBackstage } from '@backstage/core-components';
import { makeStyles, SvgIcon } from '@material-ui/core';
import { Project, Finding, Statistics } from '../../models';
import { TableMessage } from './TableMessage';
import { TableHeader } from './TableHeader';
import { TablePagination } from './TablePagination';
import { tableBackstageIcons, TableIcon } from './table.icons';
import { TableBar } from './TableBar';
import { TablePaper } from './TablePaper';
type MaterialTable = {
dataManager?: {
sortedData: Project[] & Finding[];
searchText: string;
};
};
export type TableRowProjectProps = Project & {
tableData?: {
id: number;
uuid: string;
};
};
export type TableRowFindingProps = Finding & {
tableData?: {
id: number;
uuid: string;
};
};
export type TableColumnProps<T> = {
title: React.ReactElement;
field: string;
width: string;
headerStyle: any;
cellStyle: any;
render: (row: T) => React.ReactNode;
};
const useStyles = makeStyles(theme => ({
funnelIcon: {
color: theme.palette.type === 'light' ? '#232F3E' : 'white',
},
}));
type TableProps = {
clientName?: string;
clientUrl: string;
getStatistics: (data?: (Project | Finding)[]) => Statistics;
headerTitle?: string;
tableColumns?: TableColumnProps<
TableRowProjectProps & TableRowFindingProps
>[];
tableData: (Project | Finding)[];
tableDataError: Error | null;
tableDataLoading: boolean;
tableTitle: string;
totalTitle: string;
};
export const Table = ({
clientName = '',
clientUrl = '',
getStatistics,
headerTitle,
tableColumns = [],
tableData = [],
tableDataError,
tableDataLoading,
tableTitle,
totalTitle,
}: TableProps) => {
const classes = useStyles();
const tableRef = React.useRef<MaterialTable>(null);
return (
<TableBackstage
localization={{
body: {
emptyDataSourceMessage: tableDataError ? (
<TableMessage
icon={TableIcon.ERROR}
title="Oops! Something Went Wrong"
message="An unexpected error occurred when loading this table. Please try
refreshing the page."
/>
) : (
<TableMessage
icon={TableIcon.EMPTY}
title="No Results Found"
message="No results were found for your filter. Please check your spelling and
try again."
/>
),
},
}}
tableRef={tableRef}
options={{
showEmptyDataSourceMessage: true,
search: true,
paging: true,
toolbar: true,
grouping: true, // NOTE: require to display groupbar component
pageSize: 50,
pageSizeOptions: [50, 100, 200],
emptyRowsWhenPaging: false,
rowStyle: {
borderTop: '1px solid #DFDFDF',
borderBottom: '1px solid #DFDFDF',
},
}}
isLoading={tableDataLoading}
columns={tableColumns}
data={tableData as (Project & Finding)[]} // Accept both types
icons={{
...tableBackstageIcons,
Search: forwardRef((_, ref: React.Ref<SVGSVGElement>) => (
<SvgIcon
ref={ref}
width="16"
height="14"
viewBox="-2 0 23 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={classes.funnelIcon}
>
<path d="M0 1.23438C0 0.553125 0.553125 0 1.23438 0H14.7656C15.4469 0 16 0.553125 16 1.23438C16 1.52188 15.9 1.8 15.7156 2.02187L10 8.93125V12.9406C10 13.525 9.525 14 8.94063 14C8.70625 14 8.47812 13.9219 8.29062 13.7781L6.38438 12.2969C6.14062 12.1062 5.99687 11.8156 5.99687 11.5063V8.93125L0.284375 2.02187C0.1 1.8 0 1.52188 0 1.23438ZM1.23438 1C1.10312 1 1 1.10625 1 1.23438C1 1.29062 1.01875 1.34063 1.05313 1.38438L6.88438 8.43125C6.95937 8.52187 7 8.63437 7 8.75V11.5063L8.90625 12.9875C8.91562 12.9969 8.92813 13 8.94063 13C8.97188 13 9 12.975 9 12.9406V8.75C9 8.63437 9.04063 8.52187 9.11563 8.43125L14.9469 1.38438C14.9812 1.34375 15 1.29062 15 1.23438C15 1.10312 14.8938 1 14.7656 1H1.23438Z" />
</SvgIcon>
)),
}}
components={{
// NOTE: This component only wrap table into paper component
Container: props => <TablePaper {...props} />,
// NOTE: This component act as table toolbar.
Groupbar: () => (
<TableBar
active={tableRef.current?.dataManager?.sortedData?.length}
title={tableTitle}
total={
!!tableRef.current?.dataManager?.searchText &&
tableRef.current?.dataManager?.sortedData?.length <
tableData.length &&
tableData.length
}
/>
),
// NOTE: This component contain search/filter input and total statistics rendered at the top of page.
Toolbar: props => {
return (
<TableHeader
clientName={clientName}
data={getStatistics(tableRef.current?.dataManager?.sortedData)}
dataLoading={tableDataLoading}
headerTitle={headerTitle}
toolbar={props}
totalTitle={totalTitle}
url={clientUrl}
/>
);
},
Pagination: props => <TablePagination {...props} />,
}}
/>
);
};

View File

@ -0,0 +1,37 @@
import React from 'react';
import { Typography, makeStyles } from '@material-ui/core';
import { numberToShortText } from '../../utils';
type TableBarProps = {
active?: number;
title: string;
total?: number | boolean;
};
const useStyles = makeStyles(() => ({
toolbar: {
width: 'max-content',
padding: '12px 20px',
'& h5': {
fontWeight: 500,
fontSize: '20px',
},
},
}));
export const TableBar = ({ active, title, total }: TableBarProps) => {
const classes = useStyles();
return (
<div className={classes.toolbar}>
<Typography variant="h5">
{title} ({numberToShortText(active)}
{!!total && (
<Typography component="span" variant="body1">
{` / ${numberToShortText(total as number)}`}
</Typography>
)}
)
</Typography>
</div>
);
};

View File

@ -0,0 +1,36 @@
import React from 'react';
import { Statistics } from '../../models';
import { Total } from '../Total';
import { TableToolbar } from './TableToolbar';
type TableHeaderProps = {
clientName: string;
data: Statistics;
dataLoading: boolean;
headerTitle?: string;
toolbar: any; // Allow any object structure here
totalTitle: string;
url: string;
};
export const TableHeader = ({
clientName,
data,
dataLoading,
headerTitle,
toolbar,
totalTitle,
url,
}: TableHeaderProps) => {
return (
<TableToolbar toolbar={toolbar} title={headerTitle}>
<Total
clientName={clientName}
data={data}
dataLoading={dataLoading}
title={totalTitle}
url={url}
/>
</TableToolbar>
);
};

View File

@ -0,0 +1,44 @@
import React from 'react';
import { makeStyles, Typography, SvgIcon } from '@material-ui/core';
import { tableIconMap, TableIcon } from './table.icons';
type TableMessageProps = {
icon: TableIcon;
message: string;
title: string;
};
const useStyles = makeStyles(() => ({
container: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 'auto',
flexDirection: 'column',
gap: '16px',
padding: '100px 0',
},
icon: {
backgroundColor: '#DBE8F8',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: '50px',
width: '40px',
height: '40px',
},
}));
export const TableMessage = ({ icon, message, title }: TableMessageProps) => {
const classes = useStyles();
return (
<div className={classes.container}>
<div className={classes.icon}>
<SvgIcon viewBox="-4 -3 24 24">{tableIconMap[icon]}</SvgIcon>
</div>
<Typography variant="h6">{title}</Typography>
<Typography variant="body1">{message}</Typography>
</div>
);
};

View File

@ -0,0 +1,70 @@
import React from 'react';
import {
TablePagination as MaterialTablePagination,
makeStyles,
} from '@material-ui/core';
import { TablePaginationActions } from './TablePaginationActions';
type TablePaginationProps = {
rowsPerPageOptions?: Array<number | { value: number; label: string }>;
colSpan: number;
count: number;
rowsPerPage: number;
page: number;
onPageChange: (
event: React.MouseEvent<HTMLButtonElement> | null,
page: number,
) => void;
onRowsPerPageChange: React.ChangeEventHandler<
HTMLTextAreaElement | HTMLInputElement
>;
};
const useStyles = makeStyles(theme => ({
root: {
color: theme.palette.type === 'light' ? '#232F3E' : 'white',
backgroundColor:
theme.palette.type === 'light'
? 'white'
: theme.palette.background.default,
},
input: {
marginRight: 'auto',
},
spacer: {
flex: 'none',
},
selectIcon: {
color: theme.palette.type === 'light' ? '#232F3E' : 'white',
},
}));
export const TablePagination = ({
rowsPerPageOptions,
colSpan,
count,
rowsPerPage,
page,
onPageChange,
onRowsPerPageChange,
}: TablePaginationProps) => {
const classes = useStyles();
return (
<MaterialTablePagination
component="div"
classes={classes}
rowsPerPageOptions={rowsPerPageOptions}
colSpan={colSpan}
count={count}
rowsPerPage={rowsPerPage}
page={page}
onPageChange={onPageChange}
SelectProps={{
inputProps: { 'aria-label': 'per page' },
}}
onRowsPerPageChange={onRowsPerPageChange}
ActionsComponent={TablePaginationActions}
labelDisplayedRows={({ from, to }) => `${from}-${to} of ${count}`}
/>
);
};

View File

@ -0,0 +1,143 @@
import React from 'react';
import { IconButton, makeStyles, SvgIcon, useTheme } from '@material-ui/core';
type TablePaginationActionsProps = {
count: number;
page: number;
rowsPerPage: number;
onPageChange: (e: React.MouseEvent<HTMLButtonElement>, value: number) => void;
};
const FirstPageIcon = () => (
<SvgIcon
width="12"
height="9"
viewBox="-4 0 21 11"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M1.10156 5.10156L5.60156 0.625C5.8125 0.390625 6.16406 0.390625 6.39844 0.625C6.60938 0.835938 6.60938 1.1875 6.39844 1.39844L2.27344 5.5L6.375 9.625C6.60938 9.83594 6.60938 10.1875 6.375 10.3984C6.16406 10.6328 5.8125 10.6328 5.60156 10.3984L1.10156 5.89844C0.867188 5.6875 0.867188 5.33594 1.10156 5.10156ZM10.1016 0.601562V0.625C10.3125 0.390625 10.6641 0.390625 10.8984 0.625C11.1094 0.835938 11.1094 1.1875 10.8984 1.39844L6.77344 5.52344L10.875 9.625C11.1094 9.83594 11.1094 10.1875 10.875 10.3984C10.6641 10.6328 10.3125 10.6328 10.1016 10.3984L5.60156 5.89844C5.36719 5.6875 5.36719 5.33594 5.60156 5.10156L10.1016 0.601562Z" />
</SvgIcon>
);
const LastPageIcon = () => (
<SvgIcon
width="12"
height="9"
viewBox="-5 0 21 11"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M10.8984 5.10156C11.1094 5.33594 11.1094 5.6875 10.8984 5.89844L6.39844 10.3984C6.16406 10.6328 5.8125 10.6328 5.60156 10.3984C5.36719 10.1875 5.36719 9.83594 5.60156 9.625L9.70312 5.52344L5.60156 1.39844C5.36719 1.1875 5.36719 0.835938 5.60156 0.625C5.8125 0.390625 6.16406 0.390625 6.375 0.625L10.8984 5.10156ZM1.89844 0.601562L6.39844 5.10156C6.60938 5.33594 6.60938 5.6875 6.39844 5.89844L1.89844 10.3984C1.66406 10.6328 1.3125 10.6328 1.10156 10.3984C0.867188 10.1875 0.867188 9.83594 1.10156 9.625L5.20312 5.52344L1.10156 1.39844C0.867188 1.1875 0.867188 0.835938 1.10156 0.625C1.3125 0.390625 1.66406 0.390625 1.875 0.625L1.89844 0.601562Z" />
</SvgIcon>
);
const ArrowLeft = () => (
<SvgIcon
width="12"
height="9"
viewBox="-6 0 21 11"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M0.601562 5.10156L5.10156 0.625C5.3125 0.390625 5.66406 0.390625 5.89844 0.625C6.10938 0.835938 6.10938 1.1875 5.89844 1.39844L1.77344 5.5L5.875 9.625C6.10938 9.83594 6.10938 10.1875 5.875 10.3984C5.66406 10.6328 5.3125 10.6328 5.10156 10.3984L0.601562 5.89844C0.367188 5.6875 0.367188 5.33594 0.601562 5.10156Z" />
</SvgIcon>
);
const ArrowRight = () => (
<SvgIcon
width="12"
height="9"
viewBox="-7 0 21 11"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M6.39844 5.10156C6.60938 5.33594 6.60938 5.6875 6.39844 5.89844L1.89844 10.3984C1.66406 10.6328 1.3125 10.6328 1.10156 10.3984C0.867188 10.1875 0.867188 9.83594 1.10156 9.625L5.20312 5.52344L1.10156 1.39844C0.867188 1.1875 0.867188 0.835938 1.10156 0.625C1.3125 0.390625 1.66406 0.390625 1.875 0.625L6.39844 5.10156Z" />
</SvgIcon>
);
const useStyles = makeStyles(theme => ({
container: {
display: 'flex',
gap: '8px',
alignItems: 'center',
paddingLeft: '24px',
paddingRight: '12px',
},
buttonContainer: {
padding: '0',
color: theme.palette.type === 'light' ? '#073C8C' : 'white',
'&:disabled': {
color: '#C4C6CB',
},
},
}));
export const TablePaginationActions = ({
count,
page,
rowsPerPage,
onPageChange,
}: TablePaginationActionsProps) => {
const theme = useTheme();
const handleFirstPageButtonClick = (
e: React.MouseEvent<HTMLButtonElement>,
) => {
onPageChange(e, 0);
};
const handleBackButtonClick = (e: React.MouseEvent<HTMLButtonElement>) => {
onPageChange(e, page - 1);
};
const handleNextButtonClick = (e: React.MouseEvent<HTMLButtonElement>) => {
onPageChange(e, page + 1);
};
const handleLastPageButtonClick = (
e: React.MouseEvent<HTMLButtonElement>,
) => {
onPageChange(e, Math.max(0, Math.ceil(count / rowsPerPage) - 1));
};
const classes = useStyles();
return (
<div className={classes.container}>
<IconButton
onClick={handleFirstPageButtonClick}
disabled={page === 0}
aria-label="first page"
className={classes.buttonContainer}
>
{theme.direction === 'rtl' ? <LastPageIcon /> : <FirstPageIcon />}
</IconButton>
<IconButton
onClick={handleBackButtonClick}
disabled={page === 0}
aria-label="previous page"
className={classes.buttonContainer}
>
{theme.direction === 'rtl' ? <ArrowRight /> : <ArrowLeft />}
</IconButton>
<span>{`Page ${page + 1} of ${Math.ceil(count / rowsPerPage)}`}</span>
<IconButton
onClick={handleNextButtonClick}
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
aria-label="next page"
className={classes.buttonContainer}
>
{theme.direction === 'rtl' ? <ArrowLeft /> : <ArrowRight />}
</IconButton>
<IconButton
onClick={handleLastPageButtonClick}
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
aria-label="last page"
className={classes.buttonContainer}
>
{theme.direction === 'rtl' ? <FirstPageIcon /> : <LastPageIcon />}
</IconButton>
</div>
);
};

View File

@ -0,0 +1 @@
export { TablePagination } from './TablePagination';

View File

@ -0,0 +1,20 @@
import React from 'react';
import { Grid, Paper } from '@material-ui/core';
type TablePaperProps = {
children: React.ReactNode[];
style: React.CSSProperties;
};
export const TablePaper: React.FC<TablePaperProps> = ({ children, style }) => {
return (
<Grid direction="column" xs={12}>
{[
children[1],
<Paper elevation={3} style={{ ...style, marginTop: '50px' }}>
{[children[2], children[3], children[5]]}
</Paper>,
]}
</Grid>
);
};

View File

@ -0,0 +1,29 @@
import React from 'react';
import { MTableToolbar } from '@material-table/core';
import { Grid, Typography } from '@material-ui/core';
type TableToolbarProps = {
children: React.ReactNode;
title?: string;
toolbar: any; // Allow any object structure here
};
export const TableToolbar = ({
toolbar,
children,
title,
}: TableToolbarProps) => {
return (
<Grid container alignItems="center" alignContent="center">
<Grid item xs={6}>
{title && <Typography variant="h4">{title}</Typography>}
</Grid>
<Grid item xs={6}>
<MTableToolbar {...toolbar} />
</Grid>
<Grid item xs={12}>
{children}
</Grid>
</Grid>
);
};

View File

@ -0,0 +1,5 @@
export {
Table,
type TableRowProjectProps,
type TableRowFindingProps,
} from './Table';

View File

@ -0,0 +1,126 @@
import React from 'react';
import { forwardRef } from 'react';
import AddBox from '@material-ui/icons/AddBox';
import ArrowUpward from '@material-ui/icons/ArrowUpward';
import Check from '@material-ui/icons/Check';
import ChevronLeft from '@material-ui/icons/ChevronLeft';
import ChevronRight from '@material-ui/icons/ChevronRight';
import Clear from '@material-ui/icons/Clear';
import DeleteOutline from '@material-ui/icons/DeleteOutline';
import Edit from '@material-ui/icons/Edit';
import FilterList from '@material-ui/icons/FilterList';
import FirstPage from '@material-ui/icons/FirstPage';
import LastPage from '@material-ui/icons/LastPage';
import Remove from '@material-ui/icons/Remove';
import SaveAlt from '@material-ui/icons/SaveAlt';
import ViewColumn from '@material-ui/icons/ViewColumn';
import Retry from '@material-ui/icons/Replay';
import Resize from '@material-ui/icons/Height';
export const tableBackstageIcons = {
Add: forwardRef((props, ref: React.Ref<SVGSVGElement>) => (
<AddBox {...props} ref={ref} />
)),
Check: forwardRef((props, ref: React.Ref<SVGSVGElement>) => (
<Check {...props} ref={ref} />
)),
Clear: forwardRef((props, ref: React.Ref<SVGSVGElement>) => (
<Clear {...props} ref={ref} />
)),
Delete: forwardRef((props, ref: React.Ref<SVGSVGElement>) => (
<DeleteOutline {...props} ref={ref} />
)),
DetailPanel: forwardRef((props, ref: React.Ref<SVGSVGElement>) => (
<ChevronRight {...props} ref={ref} />
)),
Edit: forwardRef((props, ref: React.Ref<SVGSVGElement>) => (
<Edit {...props} ref={ref} />
)),
Export: forwardRef((props, ref: React.Ref<SVGSVGElement>) => (
<SaveAlt {...props} ref={ref} />
)),
Filter: forwardRef((props, ref: React.Ref<SVGSVGElement>) => (
<FilterList {...props} ref={ref} />
)),
FirstPage: forwardRef((props, ref: React.Ref<SVGSVGElement>) => (
<FirstPage {...props} ref={ref} />
)),
LastPage: forwardRef((props, ref: React.Ref<SVGSVGElement>) => (
<LastPage {...props} ref={ref} />
)),
NextPage: forwardRef((props, ref: React.Ref<SVGSVGElement>) => (
<ChevronRight {...props} ref={ref} />
)),
PreviousPage: forwardRef((props, ref: React.Ref<SVGSVGElement>) => (
<ChevronLeft {...props} ref={ref} />
)),
ResetSearch: forwardRef((props, ref: React.Ref<SVGSVGElement>) => (
<Clear {...props} ref={ref} />
)),
Resize: forwardRef((props, ref: React.Ref<SVGSVGElement>) => (
<Resize {...props} ref={ref} />
)),
/**
* Search icons added directly to the table
*/
SortArrow: forwardRef((props, ref: React.Ref<SVGSVGElement>) => (
<ArrowUpward {...props} ref={ref} />
)),
ThirdStateCheck: forwardRef((props, ref: React.Ref<SVGSVGElement>) => (
<Remove {...props} ref={ref} />
)),
ViewColumn: forwardRef((props, ref: React.Ref<SVGSVGElement>) => (
<ViewColumn {...props} ref={ref} />
)),
Retry: forwardRef((props, ref: React.Ref<SVGSVGElement>) => (
<Retry {...props} ref={ref} />
)),
};
export enum TableIcon {
ERROR = 'error',
EMPTY = 'empty',
}
export const tableIconMap = {
[TableIcon.ERROR]: (
<>
<g clipPath="url(#clip0_387_4215)">
<path
d="M8 1.5C9.85652 1.5 11.637 2.2375 12.9497 3.55025C14.2625 4.86301 15 6.64348 15 8.5C15 10.3565 14.2625 12.137 12.9497 13.4497C11.637 14.7625 9.85652 15.5 8 15.5C6.14348 15.5 4.36301 14.7625 3.05025 13.4497C1.7375 12.137 1 10.3565 1 8.5C1 6.64348 1.7375 4.86301 3.05025 3.55025C4.36301 2.2375 6.14348 1.5 8 1.5ZM8 16.5C10.1217 16.5 12.1566 15.6571 13.6569 14.1569C15.1571 12.6566 16 10.6217 16 8.5C16 6.37827 15.1571 4.34344 13.6569 2.84315C12.1566 1.34285 10.1217 0.5 8 0.5C5.87827 0.5 3.84344 1.34285 2.34315 2.84315C0.842855 4.34344 0 6.37827 0 8.5C0 10.6217 0.842855 12.6566 2.34315 14.1569C3.84344 15.6571 5.87827 16.5 8 16.5ZM6.5 11.5C6.225 11.5 6 11.725 6 12C6 12.275 6.225 12.5 6.5 12.5H9.5C9.775 12.5 10 12.275 10 12C10 11.725 9.775 11.5 9.5 11.5H8.5V8C8.5 7.725 8.275 7.5 8 7.5H6.75C6.475 7.5 6.25 7.725 6.25 8C6.25 8.275 6.475 8.5 6.75 8.5H7.5V11.5H6.5ZM8 6.25C8.19891 6.25 8.38968 6.17098 8.53033 6.03033C8.67098 5.88968 8.75 5.69891 8.75 5.5C8.75 5.30109 8.67098 5.11032 8.53033 4.96967C8.38968 4.82902 8.19891 4.75 8 4.75C7.80109 4.75 7.61032 4.82902 7.46967 4.96967C7.32902 5.11032 7.25 5.30109 7.25 5.5C7.25 5.69891 7.32902 5.88968 7.46967 6.03033C7.61032 6.17098 7.80109 6.25 8 6.25Z"
fill="#073C8C"
/>
</g>
<defs>
<clipPath id="clip0_387_4215">
<rect
width="16"
height="16"
fill="white"
transform="translate(0 0.5)"
/>
</clipPath>
</defs>
</>
),
[TableIcon.EMPTY]: (
<>
<g clipPath="url(#clip0_387_4132)">
<path
d="M12 7C12 6.27773 11.8577 5.56253 11.5813 4.89524C11.3049 4.22795 10.8998 3.62163 10.3891 3.11091C9.87837 2.60019 9.27205 2.19506 8.60476 1.91866C7.93747 1.64226 7.22227 1.5 6.5 1.5C5.77773 1.5 5.06253 1.64226 4.39524 1.91866C3.72795 2.19506 3.12163 2.60019 2.61091 3.11091C2.10019 3.62163 1.69506 4.22795 1.41866 4.89524C1.14226 5.56253 1 6.27773 1 7C1 7.72227 1.14226 8.43747 1.41866 9.10476C1.69506 9.77205 2.10019 10.3784 2.61091 10.8891C3.12163 11.3998 3.72795 11.8049 4.39524 12.0813C5.06253 12.3577 5.77773 12.5 6.5 12.5C7.22227 12.5 7.93747 12.3577 8.60476 12.0813C9.27205 11.8049 9.87837 11.3998 10.3891 10.8891C10.8998 10.3784 11.3049 9.77205 11.5813 9.10476C11.8577 8.43747 12 7.72227 12 7ZM10.7281 11.9375C9.59375 12.9125 8.11563 13.5 6.5 13.5C2.90937 13.5 0 10.5906 0 7C0 3.40937 2.90937 0.5 6.5 0.5C10.0906 0.5 13 3.40937 13 7C13 8.61563 12.4125 10.0938 11.4375 11.2281L15.8531 15.6469C16.0469 15.8406 16.0469 16.1594 15.8531 16.3531C15.6594 16.5469 15.3406 16.5469 15.1469 16.3531L10.7281 11.9375Z"
fill="#073C8C"
/>
</g>
<defs>
<clipPath id="clip0_387_4132">
<rect
width="16"
height="16"
fill="white"
transform="translate(0 0.5)"
/>
</clipPath>
</defs>
</>
),
};

View File

@ -0,0 +1,9 @@
import { useEffect } from 'react';
export const useResize = (fn: () => void): void => {
useEffect(() => {
fn();
window.addEventListener('resize', fn);
return () => window.removeEventListener('resize', fn);
}, []);
};

View File

@ -0,0 +1,3 @@
export { Page as MendPage, plugin } from './plugin';
export { Sidebar as MendSidebar } from './components';
export { Tab as MendTab } from './pages/tab';

View File

@ -0,0 +1,76 @@
export enum StatisticsName {
CRITICAL = 'critical',
HIGH = 'high',
MEDIUM = 'medium',
LOW = 'low',
TOTAL = 'total',
}
export enum StatisticsEngine {
DEPENDENCIES = 'dependencies',
CODE = 'code',
CONTAINERS = 'containers',
}
type StatisticsBase = {
[StatisticsName.CRITICAL]: number;
[StatisticsName.HIGH]: number;
[StatisticsName.MEDIUM]: number;
[StatisticsName.LOW]: number;
[StatisticsName.TOTAL]: number;
};
export type Statistics = {
[StatisticsEngine.DEPENDENCIES]: StatisticsBase;
[StatisticsEngine.CODE]: Omit<StatisticsBase, StatisticsName.CRITICAL> & {
[StatisticsName.CRITICAL]: null;
};
[StatisticsEngine.CONTAINERS]: StatisticsBase;
} & StatisticsBase;
export type EntityURL = {
path: string;
params: {
org?: string;
repo?: string;
};
namespace?: string;
kind: string;
source: string;
};
export type Project = {
statistics: Statistics;
uuid: string;
name: string;
path: string;
applicationName: string;
applicationUuid: string;
lastScan: number;
languages: Array<[string, number]>;
entity: EntityURL;
};
export enum FindingIssueStatus {
CREATED = 'created',
REVIEWED = 'reviewed',
UNREVIEWED = 'unreviewed',
SUPPRESSED = 'suppressed',
}
export type Finding = {
kind: StatisticsEngine;
level: StatisticsName;
name: string;
origin: string;
time: string;
issue: {
issueStatus: string;
ticketName: string;
tracking: string;
reporter: string;
creationDate: string;
link: string;
status: FindingIssueStatus;
};
};

View File

@ -0,0 +1,25 @@
import React from 'react';
import { Page, Content } from '@backstage/core-components';
import { useApi, fetchApiRef } from '@backstage/core-plugin-api';
import { mendApiRef } from '../../api';
import { Header } from '../../components';
import { ProjectTable } from './components';
import { useProjectData } from '../../queries';
export const Overview = () => {
const connectBackendApi = useApi(mendApiRef);
const { fetch } = useApi(fetchApiRef);
const data = useProjectData({
connectApi: connectBackendApi,
fetchApi: fetch,
});
return (
<Page themeId="tool">
<Header />
<Content>
<ProjectTable {...data} />
</Content>
</Page>
);
};

View File

@ -0,0 +1,24 @@
import React from 'react';
import { Project } from '../../../models';
import { Table } from '../../../components';
import { ProjectData } from '../../../queries';
import { projectTableColumnSchema } from './projectTable.schema';
import { getProjetStatistics } from './projectTable.helpers';
export const ProjectTable = ({
projectData,
projectDataError,
projectDataLoading,
}: ProjectData) => (
<Table
clientName={projectData?.clientName}
clientUrl={`${projectData?.clientUrl}/app/orgs/${projectData?.clientName}/projects`}
getStatistics={data => getProjetStatistics(data as Project[])}
tableColumns={projectTableColumnSchema}
tableData={projectData?.projectList as Project[]}
tableDataError={projectDataError}
tableDataLoading={projectDataLoading}
tableTitle="Projects"
totalTitle="Projects Overview"
/>
);

View File

@ -0,0 +1,147 @@
import React from 'react';
import { makeStyles, Typography } from '@material-ui/core';
import { Tag, Tooltip } from '../../../components';
import { useResize } from '../../../hooks';
const useStyles = makeStyles(() => ({
cellContainer: {
display: 'flex',
alignItems: 'center',
gap: '4px',
overflow: 'hidden',
cursor: 'pointer',
},
hiddenCellContainer: {
display: 'flex',
alignItems: 'center',
gap: '4px',
overflow: 'hidden',
cursor: 'pointer',
height: '0',
opacity: '0',
},
tooltipContainer: {
display: 'flex',
flexDirection: 'column',
margin: '6px',
gap: '4px',
},
listHeader: {
fontWeight: 700,
fontSize: '12px',
},
listContent: {
display: 'flex',
flexDirection: 'column',
alignItems: 'start',
width: '100%',
paddingLeft: '12px',
gap: '2px',
margin: 0,
},
listItem: {
fontSize: '12px',
fontWeight: 400,
},
}));
export const ProjectTableLanguages = ({
items = [],
}: {
items: [string, number][];
}) => {
const languagesNode = React.useRef<HTMLDivElement | null>(null);
const indicatorNode = React.useRef<HTMLDivElement | null>(null);
const [displayingLength, setDisplayingLength] = React.useState(0);
const classes = useStyles();
const compare = () => {
const allChildren: HTMLElement[] | null = languagesNode?.current?.children
?.length
? Array.from(languagesNode?.current?.children).map(
item => item?.children?.[0] as HTMLElement,
)
: null;
if (allChildren) {
let currentWidth = 0;
const visibleItems = [];
allChildren.forEach(child => {
const spanWidth = child?.offsetWidth;
const indicatorWidth = indicatorNode?.current?.offsetWidth || 0;
if (currentWidth + spanWidth + indicatorWidth + 25 < 270) {
visibleItems.push(child);
currentWidth += spanWidth;
}
});
setDisplayingLength(visibleItems?.length);
}
};
useResize(compare);
return (
<Tooltip
extendedClasses={{ tooltip: { borderRadius: '8px' } }}
isAlwaysVisible
tooltipContent={
<div className={classes.tooltipContainer}>
<Typography
className={classes.listHeader}
variant="caption"
component="span"
>
Languages{' '}
<Typography
variant="caption"
component="span"
style={{ fontWeight: 400 }}
>{`(${items?.length})`}</Typography>
</Typography>
<ul className={classes.listContent}>
{items.map((language: [string, number]) => {
const lg = language[0];
return (
lg?.length && (
<li key={`tooltip_${lg}`}>
<Typography className={classes.listItem}>{lg}</Typography>
</li>
)
);
})}
</ul>
</div>
}
>
<div style={{ width: '310px' }}>
<div ref={languagesNode} className={classes.hiddenCellContainer}>
{items?.map((language: [string, number]) => {
const lg = language[0];
return lg?.length && <Tag key={lg} label={lg} width="auto" />;
})}
<Tag
label={`+${items.length - displayingLength}`}
width="auto"
ref={indicatorNode}
/>
</div>
<div className={classes.cellContainer}>
{items
?.slice(0, displayingLength)
.map((language: [string, number]) => {
const lg = language[0];
return lg?.length && <Tag key={lg} label={lg} width="auto" />;
})}
{items?.length > displayingLength && (
<Tag label={`+${items.length - displayingLength}`} width="auto" />
)}
</div>
</div>
</Tooltip>
);
};

View File

@ -0,0 +1 @@
export { ProjectTable } from './ProjectTable';

View File

@ -0,0 +1,52 @@
import { Project, Statistics } from '../../../models';
export const getProjetStatistics = (data: Project[] = []) => {
return data.reduce<Statistics>(
(prev, next) => {
prev.dependencies.critical =
prev.dependencies.critical + next.statistics.dependencies.critical;
prev.dependencies.high =
prev.dependencies.high + next.statistics.dependencies.high;
prev.dependencies.medium =
prev.dependencies.medium + next.statistics.dependencies.medium;
prev.dependencies.low =
prev.dependencies.low + next.statistics.dependencies.low;
prev.dependencies.total =
prev.dependencies.total + next.statistics.dependencies.total;
prev.code.high = prev.code.high + next.statistics.code.high;
prev.code.medium = prev.code.medium + next.statistics.code.medium;
prev.code.low = prev.code.low + next.statistics.code.low;
prev.code.total = prev.code.total + next.statistics.code.total;
prev.containers.critical =
prev.containers.critical + next.statistics.containers.critical;
prev.containers.high =
prev.containers.high + next.statistics.containers.high;
prev.containers.medium =
prev.containers.medium + next.statistics.containers.medium;
prev.containers.low =
prev.containers.low + next.statistics.containers.low;
prev.containers.total =
prev.containers.total + next.statistics.containers.total;
prev.critical = prev.critical + next.statistics.critical;
prev.high = prev.high + next.statistics.high;
prev.medium = prev.medium + next.statistics.medium;
prev.low = prev.low + next.statistics.low;
prev.total = prev.total + next.statistics.total;
return prev;
},
{
dependencies: { critical: 0, high: 0, medium: 0, low: 0, total: 0 },
code: { critical: null, high: 0, medium: 0, low: 0, total: 0 },
containers: { critical: 0, high: 0, medium: 0, low: 0, total: 0 },
critical: 0,
high: 0,
medium: 0,
low: 0,
total: 0,
},
);
};

View File

@ -0,0 +1,205 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { styled, Typography } from '@material-ui/core';
import { Tag, TagColor, type TableRowProjectProps } from '../../../components';
import { dateTimeFormat, getObjValue } from '../../../utils';
import { ProjectTableLanguages } from './ProjectTableLanguages';
enum PROJECT_FIELD {
NAME = 'name',
APPLICATION_NAME = 'applicationName',
STATISTICS_TOTAL = 'statistics.total',
STATISTICS_CRITICAL = 'statistics.critical',
STATISTICS_HIGH = 'statistics.high',
STATISTICS_MEDIUM = 'statistics.medium',
STATISTICS_LOW = 'statistics.low',
LANGUAGES = 'languages',
LAST_SCAN = 'lastScan',
}
enum SEVERITY_LEVEL {
CRITICAL = 'Critical',
HIGH = 'High',
MEDIUM = 'Medium',
LOW = 'Low',
}
export const tagColorMap = {
[SEVERITY_LEVEL.CRITICAL]: TagColor.CRITICAL,
[SEVERITY_LEVEL.HIGH]: TagColor.HIGH,
[SEVERITY_LEVEL.MEDIUM]: TagColor.MEDIUM,
[SEVERITY_LEVEL.LOW]: TagColor.LOW,
};
const StyledTypography = styled(Typography)(({ theme }) => ({
color: theme?.palette?.type === 'light' ? '#073C8C' : 'white',
}));
const classes: Record<string, React.CSSProperties> = {
ellipsis: {
maxWidth: 200, // percentage also works
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
},
};
const textColumn = {
headerStyle: {
padding: '12px 20px',
},
cellStyle: {
padding: '12px 20px',
},
width: 'auto',
};
const tagColumn = {
headerStyle: {
padding: 0,
},
cellStyle: {
padding: 0,
},
width: '50px',
align: 'center',
};
export const projectColumn = [
{
title: 'Project',
field: PROJECT_FIELD.NAME,
...textColumn,
},
{
title: 'Application',
field: PROJECT_FIELD.APPLICATION_NAME,
...textColumn,
},
{
title: 'Total Findings',
field: PROJECT_FIELD.STATISTICS_TOTAL,
...textColumn,
},
{
title: SEVERITY_LEVEL.CRITICAL,
field: PROJECT_FIELD.STATISTICS_CRITICAL,
...tagColumn,
},
{
title: SEVERITY_LEVEL.HIGH,
field: PROJECT_FIELD.STATISTICS_HIGH,
...tagColumn,
},
{
title: SEVERITY_LEVEL.MEDIUM,
field: PROJECT_FIELD.STATISTICS_MEDIUM,
...tagColumn,
},
{
title: SEVERITY_LEVEL.LOW,
field: PROJECT_FIELD.STATISTICS_LOW,
...tagColumn,
},
{
title: 'Languages',
field: PROJECT_FIELD.LANGUAGES,
headerStyle: {},
cellStyle: {
padding: '6px 20px',
display: 'flex',
scrollbarWidth: 'none',
height: '100%',
} as Record<string, React.CSSProperties>, // NOTE: scrollbarWidth is not recoginized
width: '310px',
},
{
title: 'Last Scan',
field: PROJECT_FIELD.LAST_SCAN,
...textColumn,
width: '200px',
minWidth: '200px',
},
];
export const projectTableColumnSchema = projectColumn.map(rowData => {
const prepareTitle = () => {
switch (rowData.title) {
case SEVERITY_LEVEL.CRITICAL:
case SEVERITY_LEVEL.HIGH:
case SEVERITY_LEVEL.MEDIUM:
case SEVERITY_LEVEL.LOW: {
return (
<Tag
label={rowData.title}
color={tagColorMap[rowData.title]}
shapeVariant="square"
width="80px"
/>
);
}
default:
return (
<div>
<Typography
style={{ textTransform: 'none', fontWeight: 500 }}
variant="subtitle2"
color="textPrimary"
>
{rowData.title}
</Typography>
</div>
);
}
};
return {
title: prepareTitle(),
field: rowData.field,
width: rowData.width,
headerStyle: rowData.headerStyle,
cellStyle: rowData.cellStyle,
render: (row: TableRowProjectProps): React.ReactNode => {
const value = getObjValue(row, rowData.field) as any;
switch (rowData.field) {
case PROJECT_FIELD.NAME: {
const uri = `/${row.entity?.source}/${row.entity?.namespace}/${row.entity?.kind}/${row.entity?.params.repo}/mend`;
return (
<Link to={uri}>
<StyledTypography style={classes.ellipsis} variant="body2">
{value}
</StyledTypography>
</Link>
);
}
case PROJECT_FIELD.LAST_SCAN: {
return (
<div style={{ minWidth: 200 }}>
<Typography variant="body2">{dateTimeFormat(value)}</Typography>
</div>
);
}
case PROJECT_FIELD.LANGUAGES: {
return <ProjectTableLanguages items={value} />;
}
case PROJECT_FIELD.STATISTICS_CRITICAL:
case PROJECT_FIELD.STATISTICS_HIGH:
case PROJECT_FIELD.STATISTICS_MEDIUM:
case PROJECT_FIELD.STATISTICS_LOW: {
return <Tag label={value} shapeVariant="square" width="80px" />;
}
default: {
return (
<Typography
component="span"
style={classes.ellipsis}
variant="body2"
>
{value}
</Typography>
);
}
}
},
};
});

View File

@ -0,0 +1 @@
export { Overview } from './Overview';

View File

@ -0,0 +1,24 @@
import React from 'react';
import { Content } from '@backstage/core-components';
import { useApi, fetchApiRef } from '@backstage/core-plugin-api';
import { useEntity } from '@backstage/plugin-catalog-react';
import { mendApiRef } from '../../api';
import { FindingTable } from './components';
import { useFindingData } from '../../queries';
export const Tab = () => {
const connectBackendApi = useApi(mendApiRef);
const { fetch } = useApi(fetchApiRef);
const { entity } = useEntity();
const data = useFindingData({
connectApi: connectBackendApi,
fetchApi: fetch,
uid: entity.metadata.uid,
});
return (
<Content>
<FindingTable {...data} />
</Content>
);
};

View File

@ -0,0 +1,12 @@
import React from 'react';
import { QueryClientProvider } from '@tanstack/react-query';
import { queryClient } from '../../api';
import { Tab } from './Tab';
export const TabProvider = () => {
return (
<QueryClientProvider client={queryClient}>
<Tab />
</QueryClientProvider>
);
};

View File

@ -0,0 +1,29 @@
import React from 'react';
import { Finding } from '../../../models';
import { Table } from '../../../components';
import { FindingData } from '../../../queries';
import { findingTableColumnSchema } from './findingTable.schema';
import { getFindingStatistics } from './findingTable.helpers';
export const FindingTable = ({
findingData,
findingDataError,
findingDataLoading,
}: FindingData) => (
<Table
clientName={findingData?.clientName}
clientUrl={`${
!!findingData?.projectUuid
? `${findingData?.clientUrl}/app/orgs/${findingData.clientName}/applications/summary?project=${findingData?.projectUuid}`
: findingData?.clientUrl
}`}
getStatistics={data => getFindingStatistics(data as Finding[])}
headerTitle="mend.io"
tableColumns={findingTableColumnSchema}
tableData={findingData?.findingList as Finding[]}
tableDataError={findingDataError}
tableDataLoading={findingDataLoading}
tableTitle="Project Findings"
totalTitle="Findings Overview"
/>
);

View File

@ -0,0 +1,135 @@
import React from 'react';
import {
Box,
Divider,
makeStyles,
SvgIcon,
Typography,
} from '@material-ui/core';
import { Tag, TagColor, Tooltip } from '../../../components';
import { dateTimeFormat } from '../../../utils';
type FindingTableIssueTrackingTooltipProps = {
reporter: string;
creationDate: string;
ticketName: string;
link: string;
issue: string;
issueStatus: string;
};
const useStyles = makeStyles(() => ({
container: {
display: 'flex',
flexDirection: 'column',
padding: '8px',
lineHeight: '16px',
},
label: { fontWeight: 700, fontSize: '12px', lineHeight: '16px' },
header: {
lineHeight: '16px',
fontSize: '12px',
fontWeight: 400,
},
innerContainer: {
backgroundColor: 'rgba(0, 0, 0, 0.1)',
padding: '8px',
color: 'white',
borderRadius: '4px',
gap: '8px',
},
smBlock: {
display: 'flex',
columnGap: '8px',
rowGap: '4px',
},
divider: { border: '1px solid white', opacity: '0.1' },
dataGroup: {
display: 'flex',
gap: '4px',
flexDirection: 'column',
width: '50%',
},
linkAction: {
display: 'flex',
alignItems: 'center',
gap: '4px',
cursor: 'pointer',
},
issueLink: {
textDecoration: 'underline',
width: 'auto',
},
}));
export const FindingTableIssueTrackingTooltip = ({
ticketName,
creationDate,
reporter,
link,
issue,
issueStatus,
}: FindingTableIssueTrackingTooltipProps) => {
const classes = useStyles();
return (
<Tooltip
isAlwaysVisible
tooltipContent={
<Box className={classes.container}>
<Typography className={classes.label}>
Issue Tracking Status
</Typography>
<Typography className={classes.header}>{`"${issue}"`}</Typography>
<div className={classes.innerContainer}>
<div className={classes.smBlock}>
<p className={classes.dataGroup}>
<Typography className={classes.label}>Ticket #</Typography>
<Typography className={classes.linkAction}>
<a
className={classes.issueLink}
target="_blank"
rel="noopener noreferrer"
href={link}
>
{ticketName}
</a>
<SvgIcon viewBox="0 0 12 11" style={{ height: '16px' }}>
<path
d="M7.64062 0.25C7.46016 0.25 7.3125 0.397656 7.3125 0.578125C7.3125 0.758594 7.46016 0.90625 7.64062 0.90625H10.1303L5.11201 5.92451C4.98486 6.05166 4.98486 6.26084 5.11201 6.38799C5.23916 6.51514 5.44834 6.51514 5.57549 6.38799L10.5938 1.36973V3.85938C10.5938 4.03984 10.7414 4.1875 10.9219 4.1875C11.1023 4.1875 11.25 4.03984 11.25 3.85938V0.578125C11.25 0.397656 11.1023 0.25 10.9219 0.25H7.64062ZM2.0625 1.5625C1.33857 1.5625 0.75 2.15107 0.75 2.875V9.4375C0.75 10.1614 1.33857 10.75 2.0625 10.75H8.625C9.34893 10.75 9.9375 10.1614 9.9375 9.4375V6.48438C9.9375 6.30391 9.78984 6.15625 9.60938 6.15625C9.42891 6.15625 9.28125 6.30391 9.28125 6.48438V9.4375C9.28125 9.80049 8.98799 10.0938 8.625 10.0938H2.0625C1.69951 10.0938 1.40625 9.80049 1.40625 9.4375V2.875C1.40625 2.51201 1.69951 2.21875 2.0625 2.21875H5.01562C5.19609 2.21875 5.34375 2.07109 5.34375 1.89062C5.34375 1.71016 5.19609 1.5625 5.01562 1.5625H2.0625Z"
fill="white"
/>
</SvgIcon>
</Typography>
</p>
<p className={classes.dataGroup}>
<Typography className={classes.label}>Status</Typography>
<Tag
label={issueStatus}
color={TagColor.ACTIVE}
height="21px"
width="fit-content"
/>
</p>
</div>
<Divider className={classes.divider} />
<div className={classes.smBlock}>
<p className={classes.dataGroup}>
<Typography className={classes.label}>Creation date</Typography>
<Typography variant="caption">
{dateTimeFormat(creationDate)}
</Typography>
</p>
<p className={classes.dataGroup}>
<Typography className={classes.label}>Reporter</Typography>
<Typography variant="caption">{reporter}</Typography>
</p>
</div>
</div>
</Box>
}
>
<Tag label={issueStatus} color={TagColor.ACTIVE} width="110px" />
</Tooltip>
);
};

View File

@ -0,0 +1,25 @@
import { Finding, Statistics } from '../../../models';
export const getFindingStatistics = (data: Finding[] = []) => {
return data.reduce<Statistics>(
(prev, next) => {
prev[next.kind][next.level] = prev[next.kind][next.level]! + 1;
prev[next.kind].total = prev[next.kind].total + 1;
prev[next.level] = prev[next.level] + 1;
prev.total = prev.total + 1;
return prev;
},
{
dependencies: { critical: 0, high: 0, medium: 0, low: 0, total: 0 },
code: { critical: null, high: 0, medium: 0, low: 0, total: 0 },
containers: { critical: 0, high: 0, medium: 0, low: 0, total: 0 },
critical: 0,
high: 0,
medium: 0,
low: 0,
total: 0,
},
);
};

View File

@ -0,0 +1,274 @@
import React from 'react';
import { Typography } from '@material-ui/core';
import { dateTimeFormat, getObjValue } from '../../../utils';
import {
Tag,
TagColor,
Tooltip,
type TableRowFindingProps,
} from '../../../components';
import { StatisticsName, FindingIssueStatus } from '../../../models';
import { FindingTableIssueTrackingTooltip } from './FindingTableIssueTrackingTooltip';
enum FINDING_FIELD {
SEVERITY = 'level',
FINDINGS = 'name',
ORIGIN = 'origin',
DETECTION_DATE = 'time',
STATUS = 'issue.status',
ISSUE_TRACKING = 'issue.issueStatus',
SCAN_ENGINE = 'kind',
}
const tagSeverityColorMap: { [key: string]: TagColor } = {
[StatisticsName.CRITICAL]: TagColor.CRITICAL,
[StatisticsName.HIGH]: TagColor.HIGH,
[StatisticsName.MEDIUM]: TagColor.MEDIUM,
[StatisticsName.LOW]: TagColor.LOW,
};
const tagSeverityNameMap: { [key: string]: string } = {
[StatisticsName.CRITICAL]: 'Critical',
[StatisticsName.HIGH]: 'High',
[StatisticsName.MEDIUM]: 'Medium',
[StatisticsName.LOW]: 'Low',
};
const tagEngineNameMap: { [key: string]: string } = {
code: 'Code',
dependencies: 'Dependencies',
containers: 'Containers',
};
const tagEngineColorMap: { [key: string]: TagColor } = {
code: TagColor.CODE,
dependencies: TagColor.DEPENDENCIES,
containers: TagColor.CONTAINERS,
};
const classes: Record<string, React.CSSProperties> = {
ellipsis: {
maxWidth: 200, // percentage also works
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
display: 'block',
width: 'inherit',
},
ellipsisrtl: {
direction: 'rtl',
},
title: {
textTransform: 'none',
fontWeight: 500,
},
date: {
minWidth: 150,
},
empty: {
borderBottom: '1px solid black',
borderBottomColor: '#C4C6CB',
width: 20,
},
};
const textColumn = {
headerStyle: {
padding: '12px 20px',
},
cellStyle: {
padding: '12px 20px',
},
width: 'auto',
};
const findingColumn = [
{
title: 'Severity',
field: FINDING_FIELD.SEVERITY,
...textColumn,
},
{
title: 'Finding',
field: FINDING_FIELD.FINDINGS,
...textColumn,
},
{
title: 'Origin',
field: FINDING_FIELD.ORIGIN,
...textColumn,
},
{
title: 'Detection Date',
field: FINDING_FIELD.DETECTION_DATE,
...textColumn,
},
{
title: 'Status',
field: FINDING_FIELD.STATUS,
...textColumn,
},
{
title: 'Issue Tracking',
field: FINDING_FIELD.ISSUE_TRACKING,
...textColumn,
},
{
title: 'Scan Engine',
field: FINDING_FIELD.SCAN_ENGINE,
...textColumn,
},
];
const issueStatusLabel = {
[FindingIssueStatus.CREATED]: 'Issue Created',
[FindingIssueStatus.UNREVIEWED]: 'Unreviewed',
[FindingIssueStatus.SUPPRESSED]: 'Suppressed',
[FindingIssueStatus.REVIEWED]: 'Reviewed',
};
const issueStatusColor = {
[FindingIssueStatus.CREATED]: TagColor.ACTIVE,
[FindingIssueStatus.UNREVIEWED]: TagColor.NEUTRAL,
[FindingIssueStatus.SUPPRESSED]: TagColor.SUCCESS,
[FindingIssueStatus.REVIEWED]: TagColor.HIGH,
};
export const findingTableColumnSchema = findingColumn.map(rowData => {
return {
title: (
<Typography style={classes.title} variant="subtitle2" color="textPrimary">
{rowData.title}
</Typography>
),
field: rowData.field,
width: rowData.width,
headerStyle: rowData.headerStyle,
cellStyle: rowData.cellStyle,
render: (row: TableRowFindingProps): React.ReactNode => {
const value = getObjValue(row, rowData.field) as string | number;
switch (rowData.field) {
case FINDING_FIELD.SEVERITY: {
return (
<Tag
label={tagSeverityNameMap[row.level]}
color={tagSeverityColorMap[row.level]}
shapeVariant="square"
width="80px"
/>
);
}
case FINDING_FIELD.DETECTION_DATE: {
return (
<Typography variant="body2" style={classes.data}>
{dateTimeFormat(value)}
</Typography>
);
}
case FINDING_FIELD.STATUS: {
return (
<Tooltip
isAlwaysVisible={false}
tooltipContent={
<Tag
label={issueStatusLabel[row.issue.status]}
color={issueStatusColor[row.issue.status]}
width="auto"
/>
}
>
<Tag
label={issueStatusLabel[row.issue.status]}
color={issueStatusColor[row.issue.status]}
width="110px"
/>
</Tooltip>
);
}
case FINDING_FIELD.ISSUE_TRACKING: {
if (row.issue.issueStatus) {
return (
<FindingTableIssueTrackingTooltip
reporter={row.issue.reporter}
creationDate={row.issue?.creationDate}
ticketName={row.issue?.ticketName}
link={row.issue?.link}
issue={row.name}
issueStatus={row.issue.issueStatus}
/>
);
}
return <div style={classes.empty} />;
}
case FINDING_FIELD.SCAN_ENGINE: {
return (
<Tag
label={tagEngineNameMap[row.kind]}
color={tagEngineColorMap[row.kind]}
width="auto"
fontWeight={500}
/>
);
}
case FINDING_FIELD.ORIGIN: {
return (
<Tooltip
tooltipContent={
<Typography
component="span"
display="block"
style={{
textTransform: 'none',
lineHeight: '16px',
padding: '8px',
}}
align="center"
variant="overline"
>
{value}
</Typography>
}
>
<Typography
component="span"
style={{ ...classes.ellipsis, ...classes.ellipsisrtl }}
variant="body2"
>
{value}
</Typography>
</Tooltip>
);
}
default: {
return (
<Tooltip
tooltipContent={
<Typography
component="span"
display="block"
style={{
textTransform: 'none',
lineHeight: '16px',
padding: '8px',
}}
align="center"
variant="overline"
>
{value}
</Typography>
}
>
<Typography
component="span"
style={classes.ellipsis}
variant="body2"
>
{value}
</Typography>
</Tooltip>
);
}
}
},
};
});

View File

@ -0,0 +1 @@
export { FindingTable } from './FindingTable';

View File

@ -0,0 +1 @@
export { TabProvider as Tab } from './TabProvider';

View File

@ -0,0 +1,7 @@
import { plugin } from './plugin';
describe('mend', () => {
it('should export plugin', () => {
expect(plugin).toBeDefined();
});
});

View File

@ -0,0 +1,33 @@
import {
createApiFactory,
createPlugin,
createRoutableExtension,
discoveryApiRef,
} from '@backstage/core-plugin-api';
import { mendApiRef, MendClient } from './api';
import { rootRouteRef } from './routes';
export const plugin = createPlugin({
id: 'mend-plugin',
routes: {
root: rootRouteRef,
},
apis: [
createApiFactory({
api: mendApiRef,
deps: {
discoveryApi: discoveryApiRef,
},
factory: ({ discoveryApi }) => new MendClient({ discoveryApi }),
}),
],
});
export const Page = plugin.provide(
createRoutableExtension({
name: 'mend-page',
component: () => import('./App').then(m => m.App),
mountPoint: rootRouteRef,
}),
);

View File

@ -0,0 +1,64 @@
import { useQuery } from '@tanstack/react-query';
import { Finding } from '../models';
import { post } from '../api';
import { Query } from './queries.types';
export enum FINDING_QUERY_KEY {
GET_FINDINGS = 'GET_FINDINGS',
}
type FindingRequestData = {
uid?: string;
} & Query;
type FindingSuccessResponseData = {
clientName: string;
clientUrl: string;
findingList: Finding[];
projectName: string;
projectUuid: string;
};
export type FindingData = {
findingData?: FindingSuccessResponseData;
findingDataError: Error | null;
findingDataLoading: boolean;
};
const getFindingData = async ({
signal,
fetchApi,
uid,
connectApi,
}: FindingRequestData) => {
const url = await connectApi.discoveryApi.getBaseUrl('mend');
return await post<FindingSuccessResponseData>(fetchApi, `${url}/finding`, {
body: {
uid: uid,
},
signal,
});
};
export function useFindingData({
fetchApi,
uid,
connectApi,
}: Omit<FindingRequestData, 'signal'>) {
const {
data: findingData,
error: findingDataError,
isLoading: findingDataLoading,
} = useQuery({
queryKey: [FINDING_QUERY_KEY.GET_FINDINGS, uid],
queryFn: ({ signal }) =>
getFindingData({ fetchApi, signal, uid, connectApi }),
enabled: !!uid,
});
return {
findingData,
findingDataError,
findingDataLoading,
};
}

View File

@ -0,0 +1,2 @@
export { type FindingData, useFindingData } from './finding.queries';
export { type ProjectData, useProjectData } from './project.queries';

View File

@ -0,0 +1,47 @@
import { useQuery } from '@tanstack/react-query';
import { Project } from '../models';
import { get } from '../api';
import { Query } from './queries.types';
export enum PROJECT_QUERY_KEY {
GET_PROJECT = 'GET_PROJECT',
}
type ProjectSuccessResponseData = {
clientName: string;
clientUrl: string;
projectList: Project[];
};
export type ProjectData = {
projectData?: ProjectSuccessResponseData;
projectDataError: Error | null;
projectDataLoading: boolean;
};
const getProjectData = async ({ signal, fetchApi, connectApi }: Query) => {
const url = await connectApi.discoveryApi.getBaseUrl('mend');
return await get<ProjectSuccessResponseData>(fetchApi, `${url}/project`, {
signal,
});
};
export function useProjectData({
fetchApi,
connectApi,
}: Omit<Query, 'signal'>) {
const {
data: projectData,
error: projectDataError,
isLoading: projectDataLoading,
} = useQuery({
queryKey: [PROJECT_QUERY_KEY.GET_PROJECT],
queryFn: ({ signal }) => getProjectData({ fetchApi, signal, connectApi }),
});
return {
projectData,
projectDataError,
projectDataLoading,
};
}

View File

@ -0,0 +1,7 @@
import { MendApi } from '../api';
export type Query = {
connectApi: MendApi;
fetchApi: typeof fetch;
signal: AbortSignal;
};

View File

@ -0,0 +1,5 @@
import { createRouteRef } from '@backstage/core-plugin-api';
export const rootRouteRef = createRouteRef({
id: 'root',
});

View File

@ -0,0 +1 @@
import '@testing-library/jest-dom';

View File

@ -0,0 +1,28 @@
export const dateTimeFormat = (date: number | string, locales = 'en-US') => {
if (!date) return '';
return new Date(date).toLocaleDateString(locales, {
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
hour12: true,
});
};
export const numberToShortText = (num: number = 0): string => {
if (num >= 1e12) {
return `${(num / 1e12).toFixed(1).replace(/\.0$/, '')}T`;
} else if (num >= 1e9) {
return `${(num / 1e9).toFixed(1).replace(/\.0$/, '')}B`;
} else if (num >= 1e6) {
return `${(num / 1e6).toFixed(1).replace(/\.0$/, '')}M`;
} else if (num >= 1e3) {
return `${(num / 1e3).toFixed(1).replace(/\.0$/, '')}K`;
}
return num.toString();
};
export const getObjValue = (t: Record<string, any>, path: string): unknown =>
path.split('.').reduce((r, k) => r?.[k], t);

View File

@ -0,0 +1,18 @@
{
"extends": "@backstage/cli/config/tsconfig.json",
"include": [
"packages/*/src",
"plugins/*/src",
"plugins/*/dev",
"plugins/*/migrations"
],
"files": ["node_modules/@backstage/cli/asset-types/asset-types.d.ts"],
"exclude": ["node_modules"],
"compilerOptions": {
"outDir": "dist-types",
"rootDir": ".",
"lib": ["DOM", "DOM.Iterable", "ScriptHost", "ES2022"],
"target": "ES2022",
"useUnknownInCatchVariables": false
}
}

26683
workspaces/mend/yarn.lock Normal file

File diff suppressed because it is too large Load Diff