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:
parent
32ffb060be
commit
270ac98dd7
|
|
@ -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)
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
.git
|
||||
.yarn/cache
|
||||
.yarn/install-state.gz
|
||||
node_modules
|
||||
packages/*/src
|
||||
packages/*/node_modules
|
||||
plugins
|
||||
*.local.yaml
|
||||
|
|
@ -0,0 +1 @@
|
|||
playwright.config.ts
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
module.exports = {
|
||||
root: true,
|
||||
};
|
||||
|
|
@ -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/
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
dist
|
||||
dist-types
|
||||
coverage
|
||||
.vscode
|
||||
.eslintrc.js
|
||||
|
|
@ -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 |
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"version": "1.29.0"
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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)!
|
||||
|
|
@ -0,0 +1 @@
|
|||
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
|
||||
|
|
@ -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).
|
||||
|
|
@ -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)
|
||||
```
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
export type Config = {
|
||||
mend: {
|
||||
/**
|
||||
* @visibility secret
|
||||
*/
|
||||
activationKey: string;
|
||||
/**
|
||||
* @visibility backend
|
||||
*/
|
||||
baseUrl: string;
|
||||
};
|
||||
};
|
||||
|
|
@ -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();
|
||||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
export * from './service/router';
|
||||
export { mendPlugin as default } from './plugin';
|
||||
export {
|
||||
mendReadPermission,
|
||||
mendConditions,
|
||||
createMendProjectConditionalDecision,
|
||||
} from './permission';
|
||||
|
|
@ -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));
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
export { RESOURCE_TYPE, mendReadPermission } from './permissions';
|
||||
export {
|
||||
createMendProjectConditionalDecision,
|
||||
mendConditions,
|
||||
permissionIntegrationRouter,
|
||||
transformConditions,
|
||||
} from './conditions';
|
||||
export { rules, type FilterProps } from './rules';
|
||||
|
|
@ -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];
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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',
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
|
@ -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();
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
};
|
||||
};
|
||||
|
|
@ -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' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export {};
|
||||
|
|
@ -0,0 +1 @@
|
|||
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
|
||||
|
|
@ -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.
|
||||
|
||||

|
||||
|
||||
### Findings Overview
|
||||
|
||||
This view presents the project's security findings and detailed statistics derived from these findings.
|
||||
|
||||

|
||||
|
||||
### 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.
|
||||
|
||||

|
||||
|
||||
**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).
|
||||
|
|
@ -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)
|
||||
```
|
||||
|
|
@ -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
|
||||
|
|
@ -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();
|
||||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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' }}
|
||||
/>
|
||||
);
|
||||
|
|
@ -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" />;
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { StatisticsBar } from './StatisticsBar';
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -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%)';
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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} />;
|
||||
},
|
||||
);
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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';
|
||||
|
|
@ -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} />,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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}`}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { TablePagination } from './TablePagination';
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
export {
|
||||
Table,
|
||||
type TableRowProjectProps,
|
||||
type TableRowFindingProps,
|
||||
} from './Table';
|
||||
|
|
@ -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>
|
||||
</>
|
||||
),
|
||||
};
|
||||
|
|
@ -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);
|
||||
}, []);
|
||||
};
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export { Page as MendPage, plugin } from './plugin';
|
||||
export { Sidebar as MendSidebar } from './components';
|
||||
export { Tab as MendTab } from './pages/tab';
|
||||
|
|
@ -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;
|
||||
};
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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"
|
||||
/>
|
||||
);
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { ProjectTable } from './ProjectTable';
|
||||
|
|
@ -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,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { Overview } from './Overview';
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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"
|
||||
/>
|
||||
);
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { FindingTable } from './FindingTable';
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { TabProvider as Tab } from './TabProvider';
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import { plugin } from './plugin';
|
||||
|
||||
describe('mend', () => {
|
||||
it('should export plugin', () => {
|
||||
expect(plugin).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
}),
|
||||
);
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export { type FindingData, useFindingData } from './finding.queries';
|
||||
export { type ProjectData, useProjectData } from './project.queries';
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import { MendApi } from '../api';
|
||||
|
||||
export type Query = {
|
||||
connectApi: MendApi;
|
||||
fetchApi: typeof fetch;
|
||||
signal: AbortSignal;
|
||||
};
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { createRouteRef } from '@backstage/core-plugin-api';
|
||||
|
||||
export const rootRouteRef = createRouteRef({
|
||||
id: 'root',
|
||||
});
|
||||
|
|
@ -0,0 +1 @@
|
|||
import '@testing-library/jest-dom';
|
||||
|
|
@ -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);
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue