Add scanner UI

Signed-off-by: AllForNothing <sshijun@vmware.com>
This commit is contained in:
AllForNothing 2019-10-10 17:42:45 +08:00 committed by sshijun
parent 0076f23195
commit c2e30b4bad
67 changed files with 3270 additions and 485 deletions

1
.gitignore vendored
View File

@ -15,6 +15,7 @@ src/common/dao/dao.test
jobservice/test
src/portal/coverage/
src/portal/lib/coverage/
src/portal/dist/
src/portal/html-report/
src/portal/node_modules/

View File

@ -4,6 +4,7 @@ export * from "./service/index";
export * from "./error-handler/index";
export * from "./shared/shared.const";
export * from "./shared/shared.utils";
export * from "./shared/shared.module";
export * from "./utils";
export * from "./log/index";
export * from "./filter/index";

View File

@ -30,6 +30,7 @@ import { UserPermissionDefaultService, UserPermissionService } from "../service/
import { USERSTATICPERMISSION } from "../service/permission-static";
import { of } from "rxjs";
import { delay } from 'rxjs/operators';
import { BrowserAnimationsModule } from "@angular/platform-browser/animations";
class RouterStub {
@ -161,7 +162,8 @@ describe('RepositoryComponent (inline template)', () => {
TestBed.configureTestingModule({
imports: [
SharedModule,
RouterTestingModule
RouterTestingModule,
BrowserAnimationsModule
],
declarations: [
RepositoryComponent,

View File

@ -64,7 +64,7 @@ export interface Tag extends Base {
author: string;
created: Date;
signature?: string;
scan_overview?: VulnerabilitySummary;
scan_overview?: ScanOverview;
labels: Label[];
push_time?: string;
pull_time?: string;
@ -290,25 +290,43 @@ export enum VulnerabilitySeverity {
export interface VulnerabilityBase {
id: string;
severity: VulnerabilitySeverity;
severity: string;
package: string;
version: string;
}
export interface VulnerabilityItem extends VulnerabilityBase {
link: string;
fixedVersion: string;
links: string[];
fix_version: string;
layer?: string;
description: string;
}
export interface VulnerabilitySummary {
image_digest?: string;
scan_status: string;
job_id?: number;
severity: VulnerabilitySeverity;
components: VulnerabilityComponents;
update_time: Date; // Use as complete timestamp
report_id?: string;
mime_type?: string;
scan_status?: string;
severity?: string;
duration?: number;
summary?: SeveritySummary;
start_time?: Date;
end_time?: Date;
}
export interface SeveritySummary {
total: number;
summary: {[key: string]: number};
}
export interface VulnerabilityDetail {
"application/vnd.scanner.adapter.vuln.report.harbor+json; version=1.0"?: VulnerabilityReport;
}
export interface VulnerabilityReport {
vulnerabilities?: VulnerabilityItem[];
}
export interface ScanOverview {
"application/vnd.scanner.adapter.vuln.report.harbor+json; version=1.0"?: VulnerabilitySummary;
}
export interface VulnerabilityComponents {

View File

@ -1,13 +1,14 @@
import { HttpClient } from "@angular/common/http";
import { HttpClient, HttpHeaders } from "@angular/common/http";
import { Injectable, Inject } from "@angular/core";
import { SERVICE_CONFIG, IServiceConfig } from "../service.config";
import { buildHttpRequestOptions, HTTP_JSON_OPTIONS } from "../utils";
import { buildHttpRequestOptions, DEFAULT_SUPPORTED_MIME_TYPE, HTTP_JSON_OPTIONS } from "../utils";
import { RequestQueryParams } from "./RequestQueryParams";
import { VulnerabilityItem, VulnerabilitySummary } from "./interface";
import { VulnerabilityDetail, VulnerabilitySummary } from "./interface";
import { map, catchError } from "rxjs/operators";
import { Observable, of, throwError as observableThrowError } from "rxjs";
/**
* Get the vulnerabilities scanning results for the specified tag.
*
@ -46,7 +47,7 @@ export abstract class ScanningResultService {
tagId: string,
queryParams?: RequestQueryParams
):
| Observable<VulnerabilityItem[]>;
| Observable<any>;
/**
* Start a new vulnerability scanning
@ -106,17 +107,22 @@ export class ScanningResultDefaultService extends ScanningResultService {
tagId: string,
queryParams?: RequestQueryParams
):
| Observable<VulnerabilityItem[]> {
| Observable<any> {
if (!repoName || repoName.trim() === "" || !tagId || tagId.trim() === "") {
return observableThrowError("Bad argument");
}
let httpOptions = buildHttpRequestOptions(queryParams);
let requestHeaders = httpOptions.headers as HttpHeaders;
// Change the accept header to the supported report mime types
httpOptions.headers = requestHeaders.set("Accept", DEFAULT_SUPPORTED_MIME_TYPE);
return this.http
.get(
`${this._baseUrl}/${repoName}/tags/${tagId}/vulnerability/details`,
buildHttpRequestOptions(queryParams)
`${this._baseUrl}/${repoName}/tags/${tagId}/scan`,
httpOptions
)
.pipe(map(response => response as VulnerabilityItem[])
.pipe(map(response => response as VulnerabilityDetail)
, catchError(error => observableThrowError(error)));
}

View File

@ -32,45 +32,18 @@
<label class="detail-label">{{'TAG.DOCKER_VERSION' | translate }}</label>
<div class="image-details" [title]="tagDetails.docker_version">{{tagDetails.docker_version}}</div>
</section>
<section class="detail-row">
<section class="detail-row" *ngIf="hasCve">
<label class="detail-label">{{'TAG.SCAN_COMPLETION_TIME' | translate }}</label>
<div class="image-details" [title]="scanCompletedDatetime | date">{{scanCompletedDatetime | date}}</div>
</section>
</section>
</div>
</div>
<div *ngIf="withClair" class="col-md-4 col-sm-6">
<div class="vulnerability">
<hbr-vulnerability-bar [repoName]="repositoryId" [tagId]="tagDetails.name" [summary]="tagDetails.scan_overview"></hbr-vulnerability-bar>
</div>
<div class="flex-block vulnerabilities-info">
<div class="second-column">
<div class="row-flex">
<div class="icon-position">
<clr-icon shape="error" size="24" class="is-error"></clr-icon>
</div>
<span class="detail-count">{{highCount}}</span> {{packageText(highCount) | translate}} {{haveText(highCount) | translate}} {{'VULNERABILITY.SEVERITY.HIGH' | translate }} {{suffixForHigh | translate }}
</div>
<div class="second-row row-flex">
<div class="icon-position">
<clr-icon shape="exclamation-triangle" size="24" class="tip-icon-medium"></clr-icon>
</div>
<span class="detail-count">{{mediumCount}}</span> {{packageText(mediumCount) | translate}} {{haveText(mediumCount) | translate}} {{'VULNERABILITY.SEVERITY.MEDIUM' | translate }} {{suffixForMedium | translate }}
</div>
<div class="second-row row-flex">
<div class="icon-position">
<clr-icon shape="play" size="22" class="tip-icon-low rotate-90"></clr-icon>
</div>
<span class="detail-count">{{lowCount}}</span> {{packageText(lowCount) | translate}} {{haveText(lowCount) | translate}} {{'VULNERABILITY.SEVERITY.LOW' | translate }} {{suffixForLow | translate }}
</div>
<div class="second-row row-flex">
<div class="icon-position">
<clr-icon shape="help" size="20"></clr-icon>
</div>
<span class="detail-count">{{unknownCount}}</span> {{packageText(unknownCount) | translate}} {{haveText(unknownCount) | translate}} {{'VULNERABILITY.SEVERITY.UNKNOWN' | translate }} {{suffixForUnknown | translate }}
</div>
</div>
<div class="col-md-4 col-sm-6">
<div class="vulnerability" [hidden]="hasCve">
<hbr-vulnerability-bar [repoName]="repositoryId" [tagId]="tagDetails.name" [summary]="vulnerabilitySummary"></hbr-vulnerability-bar>
</div>
<histogram-chart *ngIf="hasCve" class="margin-top-5px" [metadata]="passMetadataToChart()" [isWhiteBackground]="true"></histogram-chart>
</div>
<div *ngIf="!withAdmiral && tagDetails?.labels?.length">
<div class="third-column detail-title">{{'TAG.LABELS' | translate }}</div>
@ -83,7 +56,7 @@
</div>
</section>
<clr-tabs>
<clr-tab *ngIf="hasVulnerabilitiesListPermission && withClair">
<clr-tab *ngIf="hasVulnerabilitiesListPermission">
<button clrTabLink [clrTabLinkInOverflow]="false" class="btn btn-link nav-link" id="tag-vulnerability" [class.active]='isCurrentTabLink("tag-vulnerability")' type="button" (click)='tabLinkClick("tag-vulnerability")'>{{'REPOSITORY.VULNERABILITY' | translate}}</button>
<clr-tab-content id="content1" *clrIfActive="true">
<hbr-vulnerabilities-grid [repositoryId]="repositoryId" [projectId]="projectId" [tagId]="tagId"></hbr-vulnerabilities-grid>

View File

@ -75,8 +75,6 @@ $size24:24px;
margin-left: 36px;
}
.vulnerability{
margin-left: 50px;
margin-top: -12px;
margin-bottom: 20px;}
.vulnerabilities-info {
@ -151,6 +149,8 @@ $size24:24px;
.tip-icon-low{
color:yellow;
}
.margin-top-5px {
margin-top: 5px;
}

View File

@ -21,7 +21,7 @@ import {
ScanningResultDefaultService
} from "../service/index";
import { FilterComponent } from "../filter/index";
import { VULNERABILITY_SCAN_STATUS } from "../utils";
import { VULNERABILITY_SCAN_STATUS, VULNERABILITY_SEVERITY } from "../utils";
import { VULNERABILITY_DIRECTIVES } from "../vulnerability-scanning/index";
import { LabelPieceComponent } from "../label-piece/label-piece.component";
import { ChannelService } from "../channel/channel.service";
@ -43,29 +43,15 @@ describe("TagDetailComponent (inline template)", () => {
let vulSpy: jasmine.Spy;
let manifestSpy: jasmine.Spy;
let mockVulnerability: VulnerabilitySummary = {
scan_status: VULNERABILITY_SCAN_STATUS.finished,
severity: 5,
update_time: new Date(),
components: {
scan_status: VULNERABILITY_SCAN_STATUS.SUCCESS,
severity: "High",
end_time: new Date(),
summary: {
total: 124,
summary: [
{
severity: 1,
count: 90
},
{
severity: 3,
count: 10
},
{
severity: 4,
count: 10
},
{
severity: 5,
count: 13
}
]
summary: {
"High": 5,
"Low": 5
}
}
};
let mockTag: Tag = {
@ -80,7 +66,9 @@ describe("TagDetailComponent (inline template)", () => {
author: "steven",
created: new Date("2016-11-08T22:41:15.912313785Z"),
signature: null,
scan_overview: mockVulnerability,
scan_overview: {
"application/vnd.scanner.adapter.vuln.report.harbor+json; version=1.0": mockVulnerability
},
labels: []
};
@ -141,13 +129,13 @@ describe("TagDetailComponent (inline template)", () => {
id: "CVE-2016-" + (8859 + i),
severity:
i % 2 === 0
? VulnerabilitySeverity.HIGH
: VulnerabilitySeverity.MEDIUM,
? VULNERABILITY_SEVERITY.HIGH
: VULNERABILITY_SEVERITY.MEDIUM,
package: "package_" + i,
link: "https://security-tracker.debian.org/tracker/CVE-2016-4484",
links: ["https://security-tracker.debian.org/tracker/CVE-2016-4484"],
layer: "layer_" + i,
version: "4." + i + ".0",
fixedVersion: "4." + i + ".11",
fix_version: "4." + i + ".11",
description: "Mock data"
};
mockData.push(res);

View File

@ -1,12 +1,13 @@
import { Component, Input, Output, EventEmitter, OnInit } from "@angular/core";
import { TagService, Tag, VulnerabilitySeverity } from "../service/index";
import { TagService, Tag, VulnerabilitySeverity, VulnerabilitySummary } from "../service/index";
import { ErrorHandler } from "../error-handler/index";
import { Label } from "../service/interface";
import { forkJoin } from "rxjs";
import { UserPermissionService } from "../service/permission.service";
import { USERSTATICPERMISSION } from "../service/permission-static";
import { ChannelService } from "../channel/channel.service";
import { DEFAULT_SUPPORTED_MIME_TYPE, VULNERABILITY_SCAN_STATUS, VULNERABILITY_SEVERITY } from "../utils";
const TabLinkContentMap: { [index: string]: string } = {
"tag-history": "history",
@ -26,7 +27,7 @@ export class TagDetailComponent implements OnInit {
_lowCount: number = 0;
_unknownCount: number = 0;
labels: Label;
vulnerabilitySummary: VulnerabilitySummary;
@Input()
tagId: string;
@Input()
@ -73,35 +74,15 @@ export class TagDetailComponent implements OnInit {
}
this.getTagPermissions(this.projectId);
this.channel.tagDetail$.subscribe(tag => {
this.getTagDetails(tag);
this.getTagDetails(tag);
});
}
getTagDetails(tagDetails): void {
getTagDetails(tagDetails: Tag): void {
this.tagDetails = tagDetails;
if (
this.tagDetails &&
this.tagDetails.scan_overview &&
this.tagDetails.scan_overview.components &&
this.tagDetails.scan_overview.components.summary
) {
this.tagDetails.scan_overview.components.summary.forEach(item => {
switch (item.severity) {
case VulnerabilitySeverity.UNKNOWN:
this._unknownCount += item.count;
break;
case VulnerabilitySeverity.LOW:
this._lowCount += item.count;
break;
case VulnerabilitySeverity.MEDIUM:
this._mediumCount += item.count;
break;
case VulnerabilitySeverity.HIGH:
this._highCount += item.count;
break;
default:
break;
}
});
if (tagDetails
&& tagDetails.scan_overview
&& tagDetails.scan_overview[DEFAULT_SUPPORTED_MIME_TYPE]) {
this.vulnerabilitySummary = tagDetails.scan_overview[DEFAULT_SUPPORTED_MIME_TYPE];
}
}
onBack(): void {
@ -127,26 +108,58 @@ export class TagDetailComponent implements OnInit {
? this.tagDetails.author
: "TAG.ANONYMITY";
}
public get highCount(): number {
return this._highCount;
private getCountByLevel(level: string): number {
if (this.vulnerabilitySummary && this.vulnerabilitySummary.summary
&& this.vulnerabilitySummary.summary.summary) {
return this.vulnerabilitySummary.summary.summary[level];
}
return 0;
}
/**
* count of critical level vulnerabilities
*/
get criticalCount(): number {
return this.getCountByLevel(VULNERABILITY_SEVERITY.CRITICAL);
}
public get mediumCount(): number {
return this._mediumCount;
/**
* count of high level vulnerabilities
*/
get highCount(): number {
return this.getCountByLevel(VULNERABILITY_SEVERITY.HIGH);
}
public get lowCount(): number {
return this._lowCount;
/**
* count of medium level vulnerabilities
*/
get mediumCount(): number {
return this.getCountByLevel(VULNERABILITY_SEVERITY.MEDIUM);
}
public get unknownCount(): number {
return this._unknownCount;
/**
* count of low level vulnerabilities
*/
get lowCount(): number {
return this.getCountByLevel(VULNERABILITY_SEVERITY.LOW);
}
/**
* count of unknown vulnerabilities
*/
get unknownCount(): number {
return this.getCountByLevel(VULNERABILITY_SEVERITY.UNKNOWN);
}
/**
* count of negligible vulnerabilities
*/
get negligibleCount(): number {
return this.getCountByLevel(VULNERABILITY_SEVERITY.NEGLIGIBLE);
}
get hasCve(): boolean {
return this.vulnerabilitySummary
&& this.vulnerabilitySummary.scan_status === VULNERABILITY_SCAN_STATUS.SUCCESS;
}
public get scanCompletedDatetime(): Date {
return this.tagDetails && this.tagDetails.scan_overview
? this.tagDetails.scan_overview.update_time
&& this.tagDetails.scan_overview[DEFAULT_SUPPORTED_MIME_TYPE]
? this.tagDetails.scan_overview[DEFAULT_SUPPORTED_MIME_TYPE].end_time
: null;
}
@ -208,4 +221,38 @@ export class TagDetailComponent implements OnInit {
error => this.errorHandler.error(error)
);
}
passMetadataToChart() {
return [
{
text: 'VULNERABILITY.SEVERITY.CRITICAL',
value: this.criticalCount ? this.criticalCount : 0,
color: 'red'
},
{
text: 'VULNERABILITY.SEVERITY.HIGH',
value: this.highCount ? this.highCount : 0,
color: '#e64524'
},
{
text: 'VULNERABILITY.SEVERITY.MEDIUM',
value: this.mediumCount ? this.mediumCount : 0,
color: 'orange'
},
{
text: 'VULNERABILITY.SEVERITY.LOW',
value: this.lowCount ? this.lowCount : 0,
color: '#007CBB'
},
{
text: 'VULNERABILITY.SEVERITY.NEGLIGIBLE',
value: this.negligibleCount ? this.negligibleCount : 0,
color: 'green'
},
{
text: 'VULNERABILITY.SEVERITY.UNKNOWN',
value: this.unknownCount ? this.unknownCount : 0,
color: 'grey'
},
];
}
}

View File

@ -55,23 +55,23 @@
<div class="col-lg-12 col-md-12 col-sm-12 col-xs-12">
<clr-datagrid [clrDgLoading]="loading" class="datagrid-top" [class.embeded-datagrid]="isEmbedded" [(clrDgSelected)]="selectedRow">
<clr-dg-action-bar>
<button type="button" class="btn btn-sm btn-secondary" [disabled]="!(canScanNow(selectedRow) && selectedRow.length==1)" (click)="scanNow(selectedRow)"><clr-icon shape="shield-check" size="16"></clr-icon>&nbsp;{{'VULNERABILITY.SCAN_NOW' | translate}}</button>
<button [clrLoading]="scanBtnState" type="button" class="btn btn-sm btn-secondary" [disabled]="!(canScanNow(selectedRow) && selectedRow.length==1 && hasEnabledScanner)" (click)="scanNow(selectedRow)"><clr-icon shape="shield-check" size="16"></clr-icon>&nbsp;{{'VULNERABILITY.SCAN_NOW' | translate}}</button>
<button type="button" class="btn btn-sm btn-secondary" [disabled]="!(selectedRow.length==1)" (click)="showDigestId(selectedRow)"><clr-icon shape="copy" size="16"></clr-icon>&nbsp;{{'REPOSITORY.COPY_DIGEST_ID' | translate}}</button>
<clr-dropdown *ngIf="!withAdmiral">
<button type="button" class="btn btn-sm btn-secondary" clrDropdownTrigger [disabled]="!(selectedRow.length==1)||!hasAddLabelImagePermission" (click)="addLabels(selectedRow)"><clr-icon shape="plus" size="16"></clr-icon>{{'REPOSITORY.ADD_LABELS' | translate}}</button>
<clr-dropdown-menu clrPosition="bottom-left" *clrIfOpen>
<clr-dropdown>
<div class="filter-grid">
<label class="dropdown-header">{{'REPOSITORY.ADD_LABEL_TO_IMAGE' | translate}}</label>
<div class="form-group"><input clrInput type="text" placeholder="Filter labels" [(ngModel)]="stickName" (keyup)="handleStickInputFilter()"></div>
<div [hidden]='imageStickLabels.length' class="no-labels">{{'LABEL.NO_LABELS' | translate }}</div>
<div [hidden]='!imageStickLabels.length' class="has-label">
<button type="button" class="dropdown-item" *ngFor='let label of imageStickLabels' [hidden]='!label.show' (click)="stickLabel(label)">
<div class="filter-grid">
<label class="dropdown-header">{{'REPOSITORY.ADD_LABEL_TO_IMAGE' | translate}}</label>
<div class="form-group"><input clrInput type="text" placeholder="Filter labels" [(ngModel)]="stickName" (keyup)="handleStickInputFilter()"></div>
<div [hidden]='imageStickLabels.length' class="no-labels">{{'LABEL.NO_LABELS' | translate }}</div>
<div [hidden]='!imageStickLabels.length' class="has-label">
<button type="button" class="dropdown-item" *ngFor='let label of imageStickLabels' [hidden]='!label.show' (click)="stickLabel(label)">
<clr-icon shape="check" class='pull-left' [hidden]='!label.iconsShow'></clr-icon>
<div class='labelDiv'><hbr-label-piece [label]="label.label" [labelWidth]="130"></hbr-label-piece></div>
</button>
</div>
</div>
</div>
</clr-dropdown>
</clr-dropdown-menu>
</clr-dropdown>
@ -81,7 +81,7 @@
<clr-dg-column class="flex-max-width" [clrDgField]="'name'">{{'REPOSITORY.TAG' | translate}}</clr-dg-column>
<clr-dg-column [clrDgField]="'size'">{{'REPOSITORY.SIZE' | translate}}</clr-dg-column>
<clr-dg-column>{{'REPOSITORY.PULL_COMMAND' | translate}}</clr-dg-column>
<clr-dg-column *ngIf="withClair">{{'REPOSITORY.VULNERABILITY' | translate}}</clr-dg-column>
<clr-dg-column>{{'REPOSITORY.VULNERABILITY' | translate}}</clr-dg-column>
<clr-dg-column *ngIf="withNotary">{{'REPOSITORY.SIGNED' | translate}}</clr-dg-column>
<clr-dg-column>{{'REPOSITORY.AUTHOR' | translate}}</clr-dg-column>
<clr-dg-column [clrDgSortBy]="createdComparator">{{'REPOSITORY.CREATED' | translate}}</clr-dg-column>
@ -98,8 +98,8 @@
<clr-dg-cell class="truncated" title="docker pull {{registryUrl}}/{{repoName}}:{{t.name}}">
<hbr-copy-input #copyInput (onCopyError)="onCpError($event)" iconMode="true" defaultValue="docker pull {{registryUrl}}/{{repoName}}:{{t.name}}"></hbr-copy-input>
</clr-dg-cell>
<clr-dg-cell *ngIf="withClair">
<hbr-vulnerability-bar [tagStatus]="t.scan_overview?.scan_status" [repoName]="repoName" [tagId]="t.name" [summary]="t.scan_overview"></hbr-vulnerability-bar>
<clr-dg-cell>
<hbr-vulnerability-bar [repoName]="repoName" [tagId]="t.name" [summary]="handleScanOverview(t.scan_overview)"></hbr-vulnerability-bar>
</clr-dg-cell>
<clr-dg-cell *ngIf="withNotary" [ngSwitch]="t.signature !== null">
<clr-icon shape="check-circle" *ngSwitchCase="true" size="20" class="color-green"></clr-icon>

View File

@ -21,7 +21,6 @@
.embeded-datagrid {
width: 98%;
float: right;
/*add for issue #2688*/
}
.hidden-tag {

View File

@ -1,5 +1,5 @@
import { ComponentFixture, TestBed, async } from "@angular/core/testing";
import { DebugElement } from "@angular/core";
import { CUSTOM_ELEMENTS_SCHEMA, DebugElement } from "@angular/core";
import { SharedModule } from "../shared/shared.module";
import { ConfirmationDialogComponent } from "../confirmation-dialog/confirmation-dialog.component";
@ -23,8 +23,11 @@ import { LabelDefaultService, LabelService } from "../service/label.service";
import { UserPermissionService, UserPermissionDefaultService } from "../service/permission.service";
import { USERSTATICPERMISSION } from "../service/permission-static";
import { OperationService } from "../operation/operation.service";
import { Observable, of } from "rxjs";
import { of } from "rxjs";
import { delay } from "rxjs/operators";
import { BrowserAnimationsModule } from "@angular/platform-browser/animations";
import { HttpClientTestingModule } from "@angular/common/http/testing";
import { HttpClient } from "@angular/common/http";
describe("TagComponent (inline template)", () => {
@ -35,7 +38,11 @@ describe("TagComponent (inline template)", () => {
let spy: jasmine.Spy;
let spyLabels: jasmine.Spy;
let spyLabels1: jasmine.Spy;
let spyScanner: jasmine.Spy;
let scannerMock = {
disabled: false,
name: "Clair"
};
let mockTags: Tag[] = [
{
"digest": "sha256:e5c82328a509aeb7c18c1d7fb36633dc638fcf433f651bdcda59c1cc04d3ee55",
@ -108,7 +115,12 @@ describe("TagComponent (inline template)", () => {
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [
SharedModule
SharedModule,
BrowserAnimationsModule,
HttpClientTestingModule
],
schemas: [
CUSTOM_ELEMENTS_SCHEMA
],
declarations: [
TagComponent,
@ -129,9 +141,9 @@ describe("TagComponent (inline template)", () => {
{ provide: ScanningResultService, useClass: ScanningResultDefaultService },
{ provide: LabelService, useClass: LabelDefaultService },
{ provide: UserPermissionService, useClass: UserPermissionDefaultService },
{ provide: OperationService }
{ provide: OperationService },
]
});
}).compileComponents();
}));
beforeEach(() => {
@ -154,7 +166,9 @@ describe("TagComponent (inline template)", () => {
tagService = fixture.debugElement.injector.get(TagService);
spy = spyOn(tagService, "getTags").and.returnValues(of(mockTags).pipe(delay(0)));
userPermissionService = fixture.debugElement.injector.get(UserPermissionService);
let http: HttpClient;
http = fixture.debugElement.injector.get(HttpClient);
spyScanner = spyOn(http, "get").and.returnValue(of(scannerMock));
spyOn(userPermissionService, "getPermission")
.withArgs(comp.projectId, USERSTATICPERMISSION.REPOSITORY_TAG_LABEL.KEY, USERSTATICPERMISSION.REPOSITORY_TAG_LABEL.VALUE.CREATE )
.and.returnValue(of(mockHasAddLabelImagePermission))
@ -176,6 +190,10 @@ describe("TagComponent (inline template)", () => {
expect(spy.calls.any).toBeTruthy();
}));
it("should load project scanner", async(() => {
expect(spyScanner.calls.count()).toEqual(1);
}));
it("should load and render data", () => {
fixture.detectChanges();
fixture.whenStable().then(() => {

View File

@ -12,43 +12,38 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import {
Component,
OnInit,
ViewChild,
Input,
Output,
EventEmitter,
AfterViewInit,
ChangeDetectorRef,
ElementRef, AfterViewInit
Component,
ElementRef,
EventEmitter,
Input,
OnInit,
Output,
ViewChild
} from "@angular/core";
import { Subject, forkJoin } from "rxjs";
import { debounceTime, distinctUntilChanged, finalize } from 'rxjs/operators';
import { forkJoin, Observable, Subject, throwError as observableThrowError } from "rxjs";
import { catchError, debounceTime, distinctUntilChanged, finalize, map } from 'rxjs/operators';
import { TranslateService } from "@ngx-translate/core";
import { State, Comparator } from "../service/interface";
import { Comparator, Label, State, Tag, TagClickEvent } from "../service/interface";
import { TagService, RetagService, VulnerabilitySeverity, RequestQueryParams } from "../service/index";
import { RequestQueryParams, RetagService, TagService, VulnerabilitySeverity } from "../service/index";
import { ErrorHandler } from "../error-handler/error-handler";
import { ChannelService } from "../channel/index";
import {
ConfirmationTargets,
ConfirmationState,
ConfirmationButtons
} from "../shared/shared.const";
import { ConfirmationButtons, ConfirmationState, ConfirmationTargets } from "../shared/shared.const";
import { ConfirmationDialogComponent } from "../confirmation-dialog/confirmation-dialog.component";
import { ConfirmationMessage } from "../confirmation-dialog/confirmation-message";
import { ConfirmationAcknowledgement } from "../confirmation-dialog/confirmation-state-message";
import { Label, Tag, TagClickEvent, RetagRequest } from "../service/interface";
import {
CustomComparator,
calculatePage,
clone,
CustomComparator,
DEFAULT_PAGE_SIZE, DEFAULT_SUPPORTED_MIME_TYPE,
doFiltering,
doSorting,
VULNERABILITY_SCAN_STATUS,
DEFAULT_PAGE_SIZE,
clone,
} from "../utils";
import { CopyInputComponent } from "../push-image/copy-input.component";
@ -58,9 +53,10 @@ import { USERSTATICPERMISSION } from "../service/permission-static";
import { operateChanges, OperateInfo, OperationState } from "../operation/operate";
import { OperationService } from "../operation/operation.service";
import { ImageNameInputComponent } from "../image-name-input/image-name-input.component";
import { map, catchError } from "rxjs/operators";
import { errorHandler as errorHandFn } from "../shared/shared.utils";
import { Observable, throwError as observableThrowError } from "rxjs";
import { HttpClient } from "@angular/common/http";
import { ClrLoadingState } from "@clr/angular";
export interface LabelState {
iconsShow: boolean;
label: Label;
@ -152,6 +148,8 @@ export class TagComponent implements OnInit, AfterViewInit {
hasRetagImagePermission: boolean;
hasDeleteImagePermission: boolean;
hasScanImagePermission: boolean;
hasEnabledScanner: boolean;
scanBtnState: ClrLoadingState = ClrLoadingState.DEFAULT;
constructor(
private errorHandler: ErrorHandler,
private tagService: TagService,
@ -161,7 +159,8 @@ export class TagComponent implements OnInit, AfterViewInit {
private translateService: TranslateService,
private ref: ChangeDetectorRef,
private operationService: OperationService,
private channel: ChannelService
private channel: ChannelService,
private http: HttpClient
) { }
ngOnInit() {
@ -169,6 +168,7 @@ export class TagComponent implements OnInit, AfterViewInit {
this.errorHandler.error("Project ID cannot be unset.");
return;
}
this.getProjectScanner();
if (!this.repoName) {
this.errorHandler.error("Repo name cannot be unset.");
return;
@ -529,17 +529,6 @@ export class TagComponent implements OnInit, AfterViewInit {
.subscribe(items => {
// To keep easy use for vulnerability bar
items.forEach((t: Tag) => {
if (!t.scan_overview) {
t.scan_overview = {
scan_status: VULNERABILITY_SCAN_STATUS.stopped,
severity: VulnerabilitySeverity.UNKNOWN,
update_time: new Date(),
components: {
total: 0,
summary: []
}
};
}
if (t.signature !== null) {
signatures.push(t.name);
}
@ -722,28 +711,21 @@ export class TagComponent implements OnInit, AfterViewInit {
// Get vulnerability scanning status
scanStatus(t: Tag): string {
if (t && t.scan_overview && t.scan_overview.scan_status) {
return t.scan_overview.scan_status;
if (t) {
let so = this.handleScanOverview(t.scan_overview);
if (so && so.scan_status) {
return so.scan_status;
}
}
return VULNERABILITY_SCAN_STATUS.unknown;
return VULNERABILITY_SCAN_STATUS.NOT_SCANNED;
}
existObservablePackage(t: Tag): boolean {
return t.scan_overview &&
t.scan_overview.components &&
t.scan_overview.components.total &&
t.scan_overview.components.total > 0 ? true : false;
}
// Whether show the 'scan now' menu
canScanNow(t: Tag[]): boolean {
if (!this.withClair) { return false; }
if (!this.hasScanImagePermission) { return false; }
let st: string = this.scanStatus(t[0]);
return st !== VULNERABILITY_SCAN_STATUS.pending &&
st !== VULNERABILITY_SCAN_STATUS.running;
return st !== VULNERABILITY_SCAN_STATUS.PENDING &&
st !== VULNERABILITY_SCAN_STATUS.RUNNING;
}
getImagePermissionRule(projectId: number): void {
let hasAddLabelImagePermission = this.userPermissionService.getPermission(projectId, USERSTATICPERMISSION.REPOSITORY_TAG_LABEL.KEY,
@ -776,4 +758,26 @@ export class TagComponent implements OnInit, AfterViewInit {
onCpError($event: any): void {
this.copyInput.setPullCommendShow();
}
getProjectScanner(): void {
this.hasEnabledScanner = false;
this.scanBtnState = ClrLoadingState.LOADING;
this.http.get(`/api/projects/${this.projectId}/scanner`)
.pipe(map(response => response as any))
.pipe(catchError(error => observableThrowError(error)))
.subscribe(response => {
if (response && "{}" !== JSON.stringify(response) && !response.disable
&& response.health) {
this.hasEnabledScanner = true;
}
this.scanBtnState = ClrLoadingState.SUCCESS;
}, error => {
this.scanBtnState = ClrLoadingState.ERROR;
});
}
handleScanOverview(scanOverview: any) {
if (scanOverview) {
return scanOverview[DEFAULT_SUPPORTED_MIME_TYPE];
}
return null;
}
}

View File

@ -225,16 +225,35 @@ export class CustomComparator<T> implements Comparator<T> {
*/
export const DEFAULT_PAGE_SIZE: number = 15;
/**
* The default supported mime type
*/
export const DEFAULT_SUPPORTED_MIME_TYPE = "application/vnd.scanner.adapter.vuln.report.harbor+json; version=1.0";
/**
* The state of vulnerability scanning
*/
export const VULNERABILITY_SCAN_STATUS = {
unknown: "n/a",
pending: "pending",
running: "running",
error: "error",
stopped: "stopped",
finished: "finished"
// front-end status
NOT_SCANNED: "Not Scanned",
// back-end status
PENDING: "Pending",
RUNNING: "Running",
ERROR: "Error",
STOPPED: "Stopped",
SUCCESS: "Success",
SCHEDULED: "Scheduled"
};
/**
* The severity of vulnerability scanning
*/
export const VULNERABILITY_SEVERITY = {
NEGLIGIBLE: "Negligible",
UNKNOWN: "Unknown",
LOW: "Low",
MEDIUM: "Medium",
HIGH: "High",
CRITICAL: "Critical"
};
/**

View File

@ -0,0 +1 @@
<canvas class="canvas" #barChart> HTML5 canvas not supported </canvas>

View File

@ -0,0 +1,28 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { HistogramChartComponent } from './histogram-chart.component';
import { TranslateModule } from "@ngx-translate/core";
describe('HistogramChartComponent', () => {
let component: HistogramChartComponent;
let fixture: ComponentFixture<HistogramChartComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [
TranslateModule.forRoot()
],
declarations: [ HistogramChartComponent ],
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(HistogramChartComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,132 @@
import {
AfterViewInit,
ChangeDetectionStrategy,
Component,
DoCheck,
ElementRef,
Input,
OnInit,
ViewChild
} from '@angular/core';
import { TranslateService } from "@ngx-translate/core";
import { forkJoin } from "rxjs";
@Component({
selector: 'histogram-chart',
templateUrl: './histogram-chart.component.html',
styleUrls: ['./histogram-chart.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class HistogramChartComponent implements OnInit, AfterViewInit, DoCheck {
@Input()
metadata: Array<{
text: string,
value: number,
color: string
}> = [];
translatedTextArr: Array<string> = [];
@Input()
isWhiteBackground: boolean = false;
max: number;
scale: number;
hasViewInit: boolean = false;
@ViewChild('barChart', { static: false }) barChart: ElementRef;
public context: CanvasRenderingContext2D;
constructor(private translate: TranslateService) { }
ngOnInit() {
this.translateText();
}
ngAfterViewInit(): void {
this.hasViewInit = true;
this.initChart();
}
ngDoCheck() {
if (this.hasViewInit) {
this.initChart();
}
}
translateText() {
if (this.metadata && this.metadata.length > 0) {
let textArr = [];
this.metadata.forEach(item => {
textArr.push(this.translate.get(item.text));
});
forkJoin(textArr).subscribe(
(res: string[]) => {
this.translatedTextArr = res;
if (this.hasViewInit) {
this.initChart();
}
}
);
}
}
initChart() {
if (this.barChart && this.metadata && this.metadata.length > 0) {
this.barChart.nativeElement.width = "240";
this.barChart.nativeElement.height = 25 + 20 * this.metadata.length + "";
this.context = this.barChart.nativeElement.getContext('2d');
this.getMax();
if (this.isWhiteBackground) {
this.context.fillStyle = "#000";
} else {
this.context.fillStyle = "#fff";
}
this.drawLine(50, 0, 50, 5 + this.metadata.length * 20);
this.drawLine(50, 5 + this.metadata.length * 20, 250, 5 + this.metadata.length * 20);
this.drawLine(90, 5 + this.metadata.length * 20, 90, 2 + this.metadata.length * 20);
this.drawLine(130, 5 + this.metadata.length * 20, 130, 2 + this.metadata.length * 20);
this.drawLine(170, 5 + this.metadata.length * 20, 170, 2 + this.metadata.length * 20);
this.drawLine(210, 5 + this.metadata.length * 20, 210, 2 + this.metadata.length * 20);
this.context.font = "12px";
this.context.textAlign = "center";
this.context.fillText(this.scale.toString(), 90, this.metadata.length * 20 + 18, 50);
this.context.fillText((2 * this.scale).toString(), 130, this.metadata.length * 20 + 18, 50);
this.context.fillText((3 * this.scale).toString(), 170, this.metadata.length * 20 + 18, 50);
this.context.fillText((4 * this.scale).toString(), 210, this.metadata.length * 20 + 18, 50);
this.metadata.forEach((item, index) => {
this.drawBar(index, item.color, item.value);
});
}
}
drawBar(index: number, color: string, value: number) {
this.context.textBaseline = "middle";
this.context.textAlign = "left";
this.context.fillStyle = color;
this.context.fillRect(50, 5 + index * 20, value / this.scale * 40, 15);
this.context.fillText(value.toString(), (value / this.scale * 40) + 53, 12 + index * 20, 37);
this.context.textAlign = "right";
let text = "";
if (this.translatedTextArr && this.translatedTextArr.length > 0) {
text = this.translatedTextArr[index];
} else {
text = this.metadata[index].text;
}
this.context.fillText(text, 47, 12 + index * 20, 47);
}
drawLine(x, y, X, Y) {
this.context.beginPath();
this.context.moveTo(x, y);
this.context.lineTo(X, Y);
if (this.isWhiteBackground) {
this.context.strokeStyle = "#000";
} else {
this.context.strokeStyle = "#fff";
}
this.context.stroke();
this.context.closePath();
}
getMax() {
let count = 1;
if (this.metadata && this.metadata.length > 0) {
this.metadata.forEach(item => {
if (item.value > count) {
count = item.value;
}
});
}
this.max = count;
this.scale = Math.ceil(count / 4);
}
}

View File

@ -2,13 +2,19 @@ import { Type } from "@angular/core";
import { ResultGridComponent } from './result-grid.component';
import { ResultBarChartComponent } from './result-bar-chart.component';
import { ResultTipComponent } from './result-tip.component';
import { HistogramChartComponent } from "./histogram-chart/histogram-chart.component";
import { ResultTipHistogramComponent } from "./result-tip-histogram/result-tip-histogram.component";
export * from './result-tip.component';
export * from "./result-grid.component";
export * from './result-bar-chart.component';
export * from './histogram-chart/histogram-chart.component';
export * from './result-tip-histogram/result-tip-histogram.component';
export const VULNERABILITY_DIRECTIVES: Type<any>[] = [
ResultGridComponent,
ResultTipComponent,
ResultBarChartComponent
ResultBarChartComponent,
HistogramChartComponent,
ResultTipHistogramComponent
];

View File

@ -1,7 +1,4 @@
<div class="bar-wrapper">
<div *ngIf="stopped" class="bar-state">
<span class="label">{{'VULNERABILITY.STATE.STOPPED' | translate}}</span>
</div>
<div *ngIf="queued" class="bar-state">
<span class="label label-orange">{{'VULNERABILITY.STATE.QUEUED' | translate}}</span>
</div>
@ -16,10 +13,9 @@
<div class="progress loop loop-height"><progress></progress></div>
</div>
<div *ngIf="completed" class="bar-state bar-state-chart">
<hbr-vulnerability-summary-chart [summary]="summary"></hbr-vulnerability-summary-chart>
<hbr-result-tip-histogram [vulnerabilitySummary]="summary"></hbr-result-tip-histogram>
</div>
<div *ngIf="unknown" class="bar-state">
<clr-icon shape="warning" class="is-warning" size="24"></clr-icon>
<span class="unknow-text">{{'VULNERABILITY.STATE.UNKNOWN' | translate}}</span>
<div *ngIf="otherStatus" class="bar-state">
<span class="label">{{'VULNERABILITY.STATE.OTHER_STATUS' | translate}}</span>
</div>
</div>

View File

@ -16,6 +16,8 @@ import { ErrorHandler } from '../error-handler/index';
import { SharedModule } from '../shared/shared.module';
import { VULNERABILITY_SCAN_STATUS } from '../utils';
import { ChannelService } from '../channel/index';
import { ResultTipHistogramComponent } from "./result-tip-histogram/result-tip-histogram.component";
import { HistogramChartComponent } from "./histogram-chart/histogram-chart.component";
describe('ResultBarChartComponent (inline template)', () => {
let component: ResultBarChartComponent;
@ -25,24 +27,15 @@ describe('ResultBarChartComponent (inline template)', () => {
vulnerabilityScanningBaseEndpoint: "/api/vulnerability/testing"
};
let mockData: VulnerabilitySummary = {
scan_status: VULNERABILITY_SCAN_STATUS.finished,
severity: 5,
update_time: new Date(),
components: {
scan_status: VULNERABILITY_SCAN_STATUS.SUCCESS,
severity: "High",
end_time: new Date(),
summary: {
total: 124,
summary: [{
severity: 1,
count: 90
}, {
severity: 3,
count: 10
}, {
severity: 4,
count: 10
}, {
severity: 5,
count: 13
}]
summary: {
"High": 5,
"Low": 5
}
}
};
@ -53,7 +46,9 @@ describe('ResultBarChartComponent (inline template)', () => {
],
declarations: [
ResultBarChartComponent,
ResultTipComponent],
ResultTipComponent,
ResultTipHistogramComponent,
HistogramChartComponent],
providers: [
ErrorHandler,
ChannelService,
@ -62,7 +57,7 @@ describe('ResultBarChartComponent (inline template)', () => {
{ provide: ScanningResultService, useValue: ScanningResultDefaultService },
{ provide: JobLogService, useValue: JobLogDefaultService}
]
});
}).compileComponents();
}));
@ -83,21 +78,19 @@ describe('ResultBarChartComponent (inline template)', () => {
expect(serviceConfig.vulnerabilityScanningBaseEndpoint).toEqual("/api/vulnerability/testing");
});
it('should show "not scanned" if status is STOPPED', async(() => {
component.summary.scan_status = VULNERABILITY_SCAN_STATUS.stopped;
it('should show "not scanned" if status is STOPPED', () => {
component.summary.scan_status = VULNERABILITY_SCAN_STATUS.STOPPED;
fixture.detectChanges();
fixture.whenStable().then(() => {
fixture.detectChanges();
let el: HTMLElement = fixture.nativeElement.querySelector('span');
expect(el).toBeTruthy();
expect(el.textContent).toEqual('VULNERABILITY.STATE.STOPPED');
expect(el.textContent).toEqual('VULNERABILITY.STATE.OTHER_STATUS');
});
}));
});
it('should show progress if status is SCANNING', async(() => {
component.summary.scan_status = VULNERABILITY_SCAN_STATUS.running;
it('should show progress if status is SCANNING', () => {
component.summary.scan_status = VULNERABILITY_SCAN_STATUS.RUNNING;
fixture.detectChanges();
fixture.whenStable().then(() => {
@ -106,12 +99,11 @@ describe('ResultBarChartComponent (inline template)', () => {
let el: HTMLElement = fixture.nativeElement.querySelector('.progress');
expect(el).toBeTruthy();
});
}));
});
it('should show QUEUED if status is QUEUED', async(() => {
component.summary.scan_status = VULNERABILITY_SCAN_STATUS.pending;
it('should show QUEUED if status is QUEUED', () => {
component.summary.scan_status = VULNERABILITY_SCAN_STATUS.PENDING;
fixture.detectChanges();
fixture.whenStable().then(() => {
fixture.detectChanges();
@ -122,19 +114,17 @@ describe('ResultBarChartComponent (inline template)', () => {
expect(el2.textContent).toEqual('VULNERABILITY.STATE.QUEUED');
});
}));
});
it('should show summary bar chart if status is COMPLETED', async(() => {
component.summary.scan_status = VULNERABILITY_SCAN_STATUS.finished;
it('should show summary bar chart if status is COMPLETED', () => {
component.summary.scan_status = VULNERABILITY_SCAN_STATUS.SUCCESS;
fixture.detectChanges();
fixture.whenStable().then(() => {
fixture.detectChanges();
let el: HTMLElement = fixture.nativeElement.querySelector('.bar-block-none');
let el: HTMLElement = fixture.nativeElement.querySelector('hbr-result-tip-histogram');
expect(el).not.toBeNull();
expect(el.style.width).toEqual("73px");
});
}));
});
});

View File

@ -4,11 +4,10 @@ import {
OnInit,
OnDestroy,
ChangeDetectorRef,
ViewChild
} from '@angular/core';
import { Subscription , timer} from "rxjs";
import { VULNERABILITY_SCAN_STATUS } from '../utils';
import { clone, DEFAULT_SUPPORTED_MIME_TYPE, VULNERABILITY_SCAN_STATUS } from '../utils';
import {
VulnerabilitySummary,
TagService,
@ -19,7 +18,7 @@ import { ErrorHandler } from '../error-handler/index';
import { ChannelService } from '../channel/index';
import { JobLogService } from "../service/index";
const STATE_CHECK_INTERVAL: number = 2000; // 2s
const STATE_CHECK_INTERVAL: number = 3000; // 3s
const RETRY_TIMES: number = 3;
@Component({
@ -29,7 +28,6 @@ const RETRY_TIMES: number = 3;
})
export class ResultBarChartComponent implements OnInit, OnDestroy {
@Input() repoName: string = "";
@Input() tagStatus: string = "";
@Input() tagId: string = "";
@Input() summary: VulnerabilitySummary;
onSubmitting: boolean = false;
@ -48,8 +46,9 @@ export class ResultBarChartComponent implements OnInit, OnDestroy {
) { }
ngOnInit(): void {
if ((this.tagStatus === VULNERABILITY_SCAN_STATUS.running || this.tagStatus === VULNERABILITY_SCAN_STATUS.pending)
&& !this.stateCheckTimer) {
if ((this.status === VULNERABILITY_SCAN_STATUS.RUNNING ||
this.status === VULNERABILITY_SCAN_STATUS.PENDING) &&
!this.stateCheckTimer) {
// Avoid duplicated subscribing
this.stateCheckTimer = timer(0, STATE_CHECK_INTERVAL).subscribe(() => {
this.getSummary();
@ -78,41 +77,37 @@ export class ResultBarChartComponent implements OnInit, OnDestroy {
if (this.summary && this.summary.scan_status) {
return this.summary.scan_status;
}
return VULNERABILITY_SCAN_STATUS.stopped;
return VULNERABILITY_SCAN_STATUS.NOT_SCANNED;
}
public get completed(): boolean {
return this.status === VULNERABILITY_SCAN_STATUS.finished;
return this.status === VULNERABILITY_SCAN_STATUS.SUCCESS;
}
public get error(): boolean {
return this.status === VULNERABILITY_SCAN_STATUS.error;
return this.status === VULNERABILITY_SCAN_STATUS.ERROR;
}
public get queued(): boolean {
return this.status === VULNERABILITY_SCAN_STATUS.pending;
return this.status === VULNERABILITY_SCAN_STATUS.PENDING;
}
public get scanning(): boolean {
return this.status === VULNERABILITY_SCAN_STATUS.running;
return this.status === VULNERABILITY_SCAN_STATUS.RUNNING;
}
public get stopped(): boolean {
return this.status === VULNERABILITY_SCAN_STATUS.stopped;
}
public get unknown(): boolean {
return this.status === VULNERABILITY_SCAN_STATUS.unknown;
public get otherStatus(): boolean {
return !(this.completed || this.error || this.queued || this.scanning);
}
scanNow(): void {
if (this.onSubmitting) {
// Avoid duplicated submitting
console.log("duplicated submit");
return;
}
if (!this.repoName || !this.tagId) {
console.log("bad repository or tag");
return;
}
@ -124,10 +119,7 @@ export class ResultBarChartComponent implements OnInit, OnDestroy {
// Forcely change status to queued after successful submitting
this.summary = {
scan_status: VULNERABILITY_SCAN_STATUS.pending,
severity: null,
components: null,
update_time: null
scan_status: VULNERABILITY_SCAN_STATUS.PENDING,
};
// Forcely refresh view
@ -154,8 +146,9 @@ export class ResultBarChartComponent implements OnInit, OnDestroy {
this.tagService.getTag(this.repoName, this.tagId)
.subscribe((t: Tag) => {
// To keep the same summary reference, use value copy.
this.copyValue(t.scan_overview);
if (t.scan_overview) {
this.copyValue(t.scan_overview[DEFAULT_SUPPORTED_MIME_TYPE]);
}
// Forcely refresh view
this.forceRefreshView(1000);
@ -183,11 +176,7 @@ export class ResultBarChartComponent implements OnInit, OnDestroy {
copyValue(newVal: VulnerabilitySummary): void {
if (!this.summary || !newVal || !newVal.scan_status) { return; }
this.summary.scan_status = newVal.scan_status;
this.summary.job_id = newVal.job_id;
this.summary.severity = newVal.severity;
this.summary.components = newVal.components;
this.summary.update_time = newVal.update_time;
this.summary = clone(newVal);
}
forceRefreshView(duration: number): void {
@ -203,8 +192,7 @@ export class ResultBarChartComponent implements OnInit, OnDestroy {
}
}, duration);
}
viewLog(): string {
return this.jobLogService.getScanJobBaseUrl() + "/" + this.summary.job_id + "/log";
return `/api/repositories/${this.repoName}/tags/${this.tagId}/scan/${this.summary.report_id}/log`;
}
}

View File

@ -1,51 +1,65 @@
<div class="row result-row">
<div>
<div class="row flex-items-xs-right rightPos">
<div>
<div class="row flex-items-xs-right rightPos">
<div class="flex-xs-middle option-right">
<hbr-filter [withDivider]="true" filterPlaceholder="{{'VULNERABILITY.PLACEHOLDER' | translate}}" (filterEvt)="filterVulnerabilities($event)"></hbr-filter>
<span class="refresh-btn" (click)="refresh()"><clr-icon shape="refresh"></clr-icon></span>
<hbr-filter [withDivider]="true" filterPlaceholder="{{'VULNERABILITY.PLACEHOLDER' | translate}}" (filterEvt)="filterVulnerabilities($event)"></hbr-filter>
<span class="refresh-btn" (click)="refresh()"><clr-icon shape="refresh"></clr-icon></span>
</div>
</div>
</div>
<div class="col-lg-12 col-md-12 col-sm-12 col-xs-12">
<clr-datagrid>
<clr-dg-action-bar>
</div>
<div class="col-lg-12 col-md-12 col-sm-12 col-xs-12">
<clr-datagrid [clrDgLoading]="loading">
<clr-dg-action-bar>
<button type="button" class="btn btn-sm btn-secondary" [disabled]="!hasScanImagePermission" (click)="scanNow()"><clr-icon shape="shield-check" size="16"></clr-icon>&nbsp;{{'VULNERABILITY.SCAN_NOW' | translate}}</button>
</clr-dg-action-bar>
<clr-dg-column [clrDgField]="'id'">{{'VULNERABILITY.GRID.COLUMN_ID' | translate}}</clr-dg-column>
<clr-dg-column [clrDgField]="'severity'">{{'VULNERABILITY.GRID.COLUMN_SEVERITY' | translate}}</clr-dg-column>
<clr-dg-column [clrDgField]="'package'">{{'VULNERABILITY.GRID.COLUMN_PACKAGE' | translate}}</clr-dg-column>
<clr-dg-column [clrDgField]="'version'">{{'VULNERABILITY.GRID.COLUMN_VERSION' | translate}}</clr-dg-column>
<clr-dg-column [clrDgField]="'fixedVersion'">{{'VULNERABILITY.GRID.COLUMN_FIXED' | translate}}</clr-dg-column>
</clr-dg-action-bar>
<clr-dg-column [clrDgField]="'id'">{{'VULNERABILITY.GRID.COLUMN_ID' | translate}}</clr-dg-column>
<clr-dg-column [clrDgField]="'severity'">{{'VULNERABILITY.GRID.COLUMN_SEVERITY' | translate}}</clr-dg-column>
<clr-dg-column [clrDgField]="'package'">{{'VULNERABILITY.GRID.COLUMN_PACKAGE' | translate}}</clr-dg-column>
<clr-dg-column [clrDgField]="'version'">{{'VULNERABILITY.GRID.COLUMN_VERSION' | translate}}</clr-dg-column>
<clr-dg-column [clrDgField]="'fix_version'">{{'VULNERABILITY.GRID.COLUMN_FIXED' | translate}}</clr-dg-column>
<clr-dg-placeholder>{{'VULNERABILITY.CHART.TOOLTIPS_TITLE_ZERO' | translate}}</clr-dg-placeholder>
<clr-dg-row *clrDgItems="let res of scanningResults">
<clr-dg-cell><a href="{{res.link}}" target="_blank">{{res.id}}</a></clr-dg-cell>
<clr-dg-cell [ngSwitch]="res.severity">
<span *ngSwitchCase="5" class="label label-danger">{{severityText(res.severity) | translate}}</span>
<span *ngSwitchCase="4" class="label label-medium">{{severityText(res.severity) | translate}}</span>
<span *ngSwitchCase="3" class="label label-low">{{severityText(res.severity) | translate}}</span>
<span *ngSwitchCase="1" class="label">{{severityText(res.severity) | translate}}</span>
<clr-dg-placeholder>{{'VULNERABILITY.CHART.TOOLTIPS_TITLE_ZERO' | translate}}</clr-dg-placeholder>
<clr-dg-row *clrDgItems="let res of scanningResults">
<clr-dg-cell>
<span *ngIf="!res.links">{{res.id}}</span>
<a *ngIf="res.links && res.links.length === 1" href="{{res.links[0]}}" target="_blank">{{res.id}}</a>
<span *ngIf="res.links && res.links.length > 1">
{{res.id}}
<clr-signpost>
<clr-signpost-content *clrIfOpen>
<div class="mt-5px" *ngFor="let link of res.links">
<a href="{{link}}" target="_blank">{{link}}</a>
</div>
</clr-signpost-content>
</clr-signpost>
</span>
</clr-dg-cell>
<clr-dg-cell [ngSwitch]="res.severity">
<span *ngSwitchCase="'Critical'" class="label label-critical">{{severityText(res.severity) | translate}}</span>
<span *ngSwitchCase="'High'" class="label label-danger">{{severityText(res.severity) | translate}}</span>
<span *ngSwitchCase="'Medium'" class="label label-medium">{{severityText(res.severity) | translate}}</span>
<span *ngSwitchCase="'Low'" class="label label-low">{{severityText(res.severity) | translate}}</span>
<span *ngSwitchCase="'Negligible'" class="label label-negligible">{{severityText(res.severity) | translate}}</span>
<span *ngSwitchCase="'Unknown'" class="label label-unknown">{{severityText(res.severity) | translate}}</span>
<span *ngSwitchDefault>{{severityText(res.severity) | translate}}</span>
</clr-dg-cell>
<clr-dg-cell>{{res.package}}</clr-dg-cell>
<clr-dg-cell>{{res.version}}</clr-dg-cell>
<clr-dg-cell>
<div *ngIf="res.fixedVersion; else elseBlock">
<clr-icon shape="wrench" class="is-success" size="20"></clr-icon>&nbsp;<span class="font-color-green">{{res.fixedVersion}}</span>
</div>
<ng-template #elseBlock>{{res.fixedVersion}}</ng-template>
</clr-dg-cell>
<clr-dg-row-detail *clrIfExpanded>
{{'VULNERABILITY.GRID.COLUMN_DESCRIPTION' | translate}}: {{res.description}}
</clr-dg-row-detail>
</clr-dg-row>
</clr-dg-cell>
<clr-dg-cell>{{res.package}}</clr-dg-cell>
<clr-dg-cell>{{res.version}}</clr-dg-cell>
<clr-dg-cell>
<div *ngIf="res.fix_version; else elseBlock">
<clr-icon shape="wrench" class="is-success" size="20"></clr-icon>&nbsp;<span class="font-color-green">{{res.fix_version}}</span>
</div>
<ng-template #elseBlock>{{res.fix_version}}</ng-template>
</clr-dg-cell>
<clr-dg-row-detail *clrIfExpanded>
{{'VULNERABILITY.GRID.COLUMN_DESCRIPTION' | translate}}: {{res.description}}
</clr-dg-row-detail>
</clr-dg-row>
<clr-dg-footer>
<span *ngIf="pagination.totalItems">{{pagination.firstItem + 1}} - {{pagination.lastItem + 1}} {{'VULNERABILITY.GRID.FOOT_OF' | translate}}</span>
{{pagination.totalItems}} {{'VULNERABILITY.GRID.FOOT_ITEMS' | translate}}
<clr-dg-pagination #pagination [clrDgPageSize]="25" [clrDgTotalItems]="scanningResults.length"></clr-dg-pagination>
</clr-dg-footer>
</clr-datagrid>
</div>
</div>
<clr-dg-footer>
<span *ngIf="pagination.totalItems">{{pagination.firstItem + 1}} - {{pagination.lastItem + 1}} {{'VULNERABILITY.GRID.FOOT_OF' | translate}}</span> {{pagination.totalItems}} {{'VULNERABILITY.GRID.FOOT_ITEMS' | translate}}
<clr-dg-pagination #pagination [clrDgPageSize]="25" [clrDgTotalItems]="scanningResults.length"></clr-dg-pagination>
</clr-dg-footer>
</clr-datagrid>
</div>
</div>

View File

@ -1,5 +1,5 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { VulnerabilityItem, VulnerabilitySeverity } from '../service/index';
import { VulnerabilityItem } from '../service/index';
import { NoopAnimationsModule } from "@angular/platform-browser/animations";
import { ResultGridComponent } from './result-grid.component';
import { ScanningResultService, ScanningResultDefaultService } from '../service/scanning.service';
@ -11,6 +11,7 @@ import {ChannelService} from "../channel/channel.service";
import { UserPermissionService, UserPermissionDefaultService } from "../service/permission.service";
import { USERSTATICPERMISSION } from "../service/permission-static";
import { of } from "rxjs";
import { DEFAULT_SUPPORTED_MIME_TYPE, VULNERABILITY_SEVERITY } from "../utils";
describe('ResultGridComponent (inline template)', () => {
let component: ResultGridComponent;
let fixture: ComponentFixture<ResultGridComponent>;
@ -49,19 +50,21 @@ describe('ResultGridComponent (inline template)', () => {
serviceConfig = TestBed.get(SERVICE_CONFIG);
scanningService = fixture.debugElement.injector.get(ScanningResultService);
let mockData: VulnerabilityItem[] = [];
let mockData: any = {};
mockData[DEFAULT_SUPPORTED_MIME_TYPE] = {};
mockData[DEFAULT_SUPPORTED_MIME_TYPE].vulnerabilities = [];
for (let i = 0; i < 30; i++) {
let res: VulnerabilityItem = {
id: "CVE-2016-" + (8859 + i),
severity: i % 2 === 0 ? VulnerabilitySeverity.HIGH : VulnerabilitySeverity.MEDIUM,
severity: i % 2 === 0 ? VULNERABILITY_SEVERITY.HIGH : VULNERABILITY_SEVERITY.MEDIUM,
package: "package_" + i,
link: "https://security-tracker.debian.org/tracker/CVE-2016-4484",
links: ["https://security-tracker.debian.org/tracker/CVE-2016-4484"],
layer: "layer_" + i,
version: '4.' + i + ".0",
fixedVersion: '4.' + i + '.11',
fix_version: '4.' + i + '.11',
description: "Mock data"
};
mockData.push(res);
mockData[DEFAULT_SUPPORTED_MIME_TYPE].vulnerabilities.push(res);
}
spy = spyOn(scanningService, 'getVulnerabilityScanningResults')
@ -107,10 +110,6 @@ describe('ResultGridComponent (inline template)', () => {
fixture.detectChanges();
fixture.whenStable().then(() => {
fixture.detectChanges();
// let de: DebugElement = fixture.debugElement.query(del => del.classes['datagrid-cell']);
// expect(de).toBeTruthy();
// let el: HTMLElement = de.nativeElement;
let el: HTMLElement = fixture.nativeElement.querySelector('.datagrid-cell a');
expect(el).toBeTruthy();
expect(el.textContent.trim()).toEqual('CVE-2016-8859');

View File

@ -1,8 +1,7 @@
import { Component, OnInit, Input } from '@angular/core';
import {
ScanningResultService,
VulnerabilityItem,
VulnerabilitySeverity
VulnerabilityItem
} from '../service/index';
import { ErrorHandler } from '../error-handler/index';
import { forkJoin } from "rxjs";
@ -10,6 +9,10 @@ import { forkJoin } from "rxjs";
import { ChannelService } from "../channel/channel.service";
import { UserPermissionService } from "../service/permission.service";
import { USERSTATICPERMISSION } from "../service/permission-static";
import { DEFAULT_SUPPORTED_MIME_TYPE, VULNERABILITY_SEVERITY } from '../utils';
import { finalize } from "rxjs/operators";
@Component({
selector: 'hbr-vulnerabilities-grid',
templateUrl: './result-grid.component.html',
@ -18,7 +21,7 @@ import { USERSTATICPERMISSION } from "../service/permission-static";
export class ResultGridComponent implements OnInit {
scanningResults: VulnerabilityItem[] = [];
dataCache: VulnerabilityItem[] = [];
loading: boolean = false;
@Input() tagId: string;
@Input() repositoryId: string;
@Input() projectId: number;
@ -40,11 +43,17 @@ export class ResultGridComponent implements OnInit {
}
loadResults(repositoryId: string, tagId: string): void {
this.loading = true;
this.scanningService.getVulnerabilityScanningResults(repositoryId, tagId)
.subscribe((results: VulnerabilityItem[]) => {
this.dataCache = results;
if (results) {
this.scanningResults = this.dataCache.filter((item: VulnerabilityItem) => item.id !== '');
.pipe(finalize(() => this.loading = false))
.subscribe((results) => {
if (results && results[DEFAULT_SUPPORTED_MIME_TYPE]) {
let report = results[DEFAULT_SUPPORTED_MIME_TYPE];
if (report.vulnerabilities) {
this.dataCache = report.vulnerabilities;
this.scanningResults = this.dataCache.filter((item: VulnerabilityItem) => item.id !== '');
return;
}
}
}, error => { this.errorHandler.error(error); });
}
@ -62,17 +71,19 @@ export class ResultGridComponent implements OnInit {
this.loadResults(this.repositoryId, this.tagId);
}
severityText(severity: VulnerabilitySeverity): string {
severityText(severity: string): string {
switch (severity) {
case VulnerabilitySeverity.HIGH:
case VULNERABILITY_SEVERITY.CRITICAL:
return 'VULNERABILITY.SEVERITY.CRITICAL';
case VULNERABILITY_SEVERITY.HIGH:
return 'VULNERABILITY.SEVERITY.HIGH';
case VulnerabilitySeverity.MEDIUM:
case VULNERABILITY_SEVERITY.MEDIUM:
return 'VULNERABILITY.SEVERITY.MEDIUM';
case VulnerabilitySeverity.LOW:
case VULNERABILITY_SEVERITY.LOW:
return 'VULNERABILITY.SEVERITY.LOW';
case VulnerabilitySeverity.NONE:
case VULNERABILITY_SEVERITY.NEGLIGIBLE:
return 'VULNERABILITY.SEVERITY.NEGLIGIBLE';
case VulnerabilitySeverity.UNKNOWN:
case VULNERABILITY_SEVERITY.UNKNOWN:
return 'VULNERABILITY.SEVERITY.UNKNOWN';
default:
return 'UNKNOWN';

View File

@ -0,0 +1,55 @@
<div class="tip-wrapper tip-position width-210">
<clr-tooltip>
<div clrTooltipTrigger class="tip-block">
<ng-container *ngIf="!isNone">
<div *ngIf="criticalCount > 0" class="tip-wrapper bar-block-critical shadow-critical width-30">{{criticalCount}}</div>
<div *ngIf="highCount > 0" class="margin-left-5 tip-wrapper bar-block-high shadow-high width-30">{{highCount}}</div>
<div *ngIf="mediumCount > 0" class="margin-left-5 tip-wrapper bar-block-medium shadow-medium width-30">{{mediumCount}}</div>
<div *ngIf="lowCount > 0" class="margin-left-5 tip-wrapper bar-block-low shadow-low width-30">{{lowCount}}</div>
<div *ngIf="negligibleCount > 0" class="margin-left-5 tip-wrapper bar-block-none shadow-none width-30">{{negligibleCount}}</div>
<div *ngIf="unknownCount > 0" class="margin-left-5 tip-wrapper bar-block-unknown shadow-unknown width-30">{{unknownCount}}</div>
</ng-container>
<div *ngIf="isNone" class="margin-left-5 tip-wrapper bar-block-none shadow-none width-150">{{'VULNERABILITY.NO_VULNERABILITY' | translate }}</div>
</div>
<clr-tooltip-content class="w-800" [clrPosition]="'right'" [clrSize]="'lg'" *clrIfOpen>
<div class="bar-tooltip-font-larger">
<ng-container *ngIf="isCritical">
<clr-icon shape="exclamation-circle" class="is-error" size="32"></clr-icon>
<span>{{'VULNERABILITY.OVERALL_SEVERITY' | translate }} <span class="font-weight-600">{{'VULNERABILITY.SEVERITY.CRITICAL' | translate | titlecase }}</span></span>
</ng-container>
<ng-container *ngIf="isHigh">
<clr-icon shape="exclamation-triangle" class="is-error" size="32"></clr-icon>
<span>{{'VULNERABILITY.OVERALL_SEVERITY' | translate }} <span class="font-weight-600">{{'VULNERABILITY.SEVERITY.HIGH' | translate | titlecase }}</span></span>
</ng-container>
<ng-container *ngIf="isMedium">
<clr-icon shape="minus-circle" class="tip-icon-medium" size="30"></clr-icon>
<span>{{'VULNERABILITY.OVERALL_SEVERITY' | translate }} <span class="font-weight-600">{{'VULNERABILITY.SEVERITY.MEDIUM' | translate | titlecase}}</span></span>
</ng-container>
<ng-container *ngIf="isLow">
<clr-icon shape="info-circle" class="tip-icon-low" size="32"></clr-icon>
<span>{{'VULNERABILITY.OVERALL_SEVERITY' | translate }} <span class="font-weight-600">{{'VULNERABILITY.SEVERITY.LOW' | translate | titlecase }}</span></span>
</ng-container>
<ng-container *ngIf="isUnknown">
<clr-icon shape="help" size="24" class="help-icon"></clr-icon>
<span>{{'VULNERABILITY.OVERALL_SEVERITY' | translate }} <span class="font-weight-600">{{'VULNERABILITY.SEVERITY.UNKNOWN' | translate | titlecase }}</span></span>
</ng-container>
<ng-container *ngIf="isNegligible">
<clr-icon shape="circle" class="is-success" size="32"></clr-icon>
<span>{{'VULNERABILITY.OVERALL_SEVERITY' | translate }} <span class="font-weight-600">{{'VULNERABILITY.SEVERITY.NEGLIGIBLE' | translate | titlecase }}</span></span>
</ng-container>
<ng-container *ngIf="isNone">
<clr-icon shape="check-circle" class="is-success" size="32"></clr-icon>
<span>{{'VULNERABILITY.NO_VULNERABILITY' | translate }}</span>
</ng-container>
</div>
<hr>
<div class="bar-summary bar-tooltip-fon" *ngIf="!isNone">
<histogram-chart [isWhiteBackground]="false" [metadata]="passMetadataToChart()"></histogram-chart>
</div>
<div>
<span class="bar-scanning-time">{{'VULNERABILITY.CHART.SCANNING_TIME' | translate}} </span>
<span>{{completeTimestamp | date:'short'}}</span>
</div>
</clr-tooltip-content>
</clr-tooltip>
</div>

View File

@ -0,0 +1,222 @@
.bar-wrapper {
width: 144px;
height: 12px;
}
.bar-state {
text-align: center;
.unknow-text {
margin-left: -5px;
}
}
.bar-state-chart {
margin-top: 2px;
.loop-height {
height: 2px;
}
}
.bar-state-error {
position: relative;
top: -4px;
}
.error-text {
position: relative;
top: 1px;
margin-left: -5px;
cursor: pointer;
}
.scanning-button {
height: 24px;
margin-top: 0;
margin-bottom: 0;
vertical-align: middle;
top: -12px;
position: relative;
}
.tip-wrapper {
display: inline-block;
height: 15px;
color: #fff;
text-align: center;
font-size: 10px;
line-height: 15px;
}
.tip-position {
margin-left: -4px;
}
.tip-block {
margin-left: -3px;
}
.bar-block-critical {
background-color: red;
}
.bar-block-high {
background-color: #e64524;
}
.bar-block-medium {
background-color: orange;
}
.bar-block-low {
background-color: #007CBB;
}
.bar-block-none {
background-color: green;
}
.bar-block-unknown {
background-color: grey;
}
.bar-tooltip-font {
font-size: 13px;
color: #ffffff;
}
.bar-tooltip-font-title {
font-weight: 600;
}
.bar-summary {
margin-top: 12px;
text-align: left;
}
.bar-scanning-time {
margin-top: 12px;
}
.bar-summary-item {
margin-top: 3px;
margin-bottom: 3px;
span {
:nth-child(1) {
width: 30px;
text-align: center;
display: inline-block;
}
:nth-child(2) {
width: 28px;
display: inline-block;
}
}
}
.option-right {
padding-right: 16px;
}
.refresh-btn {
cursor: pointer;
:hover {
color: #007CBB;
}
}
.label.label-medium {
background-color: #ffe4a9;
border: 1px solid orange;
color: orange;
}
.tip-icon-medium {
color: orange;
}
.label.label-low {
background: rgba(251, 255, 0, 0.38);
color: #c5c50b;
border: 1px solid #e6e63f;
}
.tip-icon-low {
color: #007CBB;
}
.font-color-green {
color: green;
}
.bar-tooltip-font-larger {
span {
font-size: 16px;
vertical-align: middle
}
}
hr {
border-bottom: 0;
border-color: #aaa;
margin: 6px -10px;
}
.font-weight-600 {
font-weight: 600;
}
.result-row {
position: relative;
}
.help-icon {
margin-left: 3px;
}
.ml-3px {
margin-left: 3px;
}
.shadow-critical {
box-shadow: 1px -1px 1px red;
}
.shadow-high {
box-shadow: 1px -1px 1px #e64524;
}
.shadow-medium {
box-shadow: 1px -1px 1px orange;
}
.shadow-low {
box-shadow: 1px -1px 1px #007CBB;
}
.shadow-none {
box-shadow: 1px -1px 1px green;
}
.shadow-unknown {
box-shadow: 1px -1px 1px gray;
}
.w-360 {
width: 360px !important;
}
.margin-left-5 {
margin-left: 5px;
}
.width-30 {
width: 30px;
}
.width-210 {
width: 210px;
}
.width-150 {
width: 150px;
}

View File

@ -0,0 +1,38 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ResultTipHistogramComponent } from './result-tip-histogram.component';
import { ClarityModule } from "@clr/angular";
import { TranslateModule, TranslateService } from "@ngx-translate/core";
import { BrowserAnimationsModule } from "@angular/platform-browser/animations";
import { HistogramChartComponent } from "..";
describe('ResultTipHistogramComponent', () => {
let component: ResultTipHistogramComponent;
let fixture: ComponentFixture<ResultTipHistogramComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [
BrowserAnimationsModule,
ClarityModule,
TranslateModule.forRoot()
],
providers: [
TranslateService
],
declarations: [
ResultTipHistogramComponent,
HistogramChartComponent
]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(ResultTipHistogramComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,176 @@
import { Component, Input, OnInit } from '@angular/core';
import { VulnerabilitySummary } from "../../service";
import { VULNERABILITY_SCAN_STATUS, VULNERABILITY_SEVERITY } from "../../utils";
import { TranslateService } from "@ngx-translate/core";
@Component({
selector: 'hbr-result-tip-histogram',
templateUrl: './result-tip-histogram.component.html',
styleUrls: ['./result-tip-histogram.component.scss']
})
export class ResultTipHistogramComponent implements OnInit {
_tipTitle: string = "";
@Input() vulnerabilitySummary: VulnerabilitySummary = {
scan_status: VULNERABILITY_SCAN_STATUS.NOT_SCANNED,
severity: "",
};
constructor(private translate: TranslateService) { }
ngOnInit(): void {
let key = "VULNERABILITY.SEVERITY.UNKNOWN";
switch (this.vulnerabilitySummary.severity) {
case VULNERABILITY_SEVERITY.CRITICAL:
key = "VULNERABILITY.SEVERITY.CRITICAL";
break;
case VULNERABILITY_SEVERITY.HIGH:
key = "VULNERABILITY.SEVERITY.HIGH";
break;
case VULNERABILITY_SEVERITY.MEDIUM:
key = "VULNERABILITY.SEVERITY.MEDIUM";
break;
case VULNERABILITY_SEVERITY.LOW:
key = "VULNERABILITY.SEVERITY.LOW";
break;
case VULNERABILITY_SEVERITY.NEGLIGIBLE:
key = "VULNERABILITY.SEVERITY.NEGLIGIBLE";
break;
default:
break;
}
this.translate.get(key).subscribe( (res: string) => {
this._tipTitle = res;
});
}
get tipTitle(): string {
return this._tipTitle;
}
get total(): number {
if (this.vulnerabilitySummary &&
this.vulnerabilitySummary.summary) {
return this.vulnerabilitySummary.summary.total;
}
return 0;
}
get sevSummary(): {[key: string]: number} {
if (this.vulnerabilitySummary &&
this.vulnerabilitySummary.summary) {
return this.vulnerabilitySummary.summary.summary;
}
return null;
}
get criticalCount(): number {
if (this.sevSummary) {
return this.sevSummary[VULNERABILITY_SEVERITY.CRITICAL];
}
return 0;
}
get highCount(): number {
if (this.sevSummary) {
return this.sevSummary[VULNERABILITY_SEVERITY.HIGH];
}
return 0;
}
get mediumCount(): number {
if (this.sevSummary) {
return this.sevSummary[VULNERABILITY_SEVERITY.MEDIUM];
}
return 0;
}
get lowCount(): number {
if (this.sevSummary) {
return this.sevSummary[VULNERABILITY_SEVERITY.LOW];
}
return 0;
}
get unknownCount(): number {
if (this.vulnerabilitySummary && this.vulnerabilitySummary.summary
&& this.vulnerabilitySummary.summary.summary) {
return this.vulnerabilitySummary.summary.summary[VULNERABILITY_SEVERITY.UNKNOWN];
}
return 0;
}
get negligibleCount(): number {
if (this.sevSummary) {
return this.sevSummary[VULNERABILITY_SEVERITY.NEGLIGIBLE];
}
return 0;
}
get completeTimestamp(): Date {
return this.vulnerabilitySummary && this.vulnerabilitySummary.end_time ? this.vulnerabilitySummary.end_time : new Date();
}
get isCritical(): boolean {
return this.vulnerabilitySummary && VULNERABILITY_SEVERITY.CRITICAL === this.vulnerabilitySummary.severity;
}
get isHigh(): boolean {
return this.vulnerabilitySummary && VULNERABILITY_SEVERITY.HIGH === this.vulnerabilitySummary.severity;
}
get isMedium(): boolean {
return this.vulnerabilitySummary && VULNERABILITY_SEVERITY.MEDIUM === this.vulnerabilitySummary.severity;
}
get isLow(): boolean {
return this.vulnerabilitySummary && VULNERABILITY_SEVERITY.LOW === this.vulnerabilitySummary.severity;
}
get isUnknown(): boolean {
return this.vulnerabilitySummary && VULNERABILITY_SEVERITY.UNKNOWN === this.vulnerabilitySummary.severity;
}
get isNegligible(): boolean {
return this.vulnerabilitySummary && VULNERABILITY_SEVERITY.NEGLIGIBLE === this.vulnerabilitySummary.severity;
}
get isNone(): boolean {
return this.total === 0;
}
passMetadataToChart() {
return [
{
text: 'VULNERABILITY.SEVERITY.CRITICAL',
value: this.criticalCount ? this.criticalCount : 0,
color: 'red'
},
{
text: 'VULNERABILITY.SEVERITY.HIGH',
value: this.highCount ? this.highCount : 0,
color: '#e64524'
},
{
text: 'VULNERABILITY.SEVERITY.MEDIUM',
value: this.mediumCount ? this.mediumCount : 0,
color: 'orange'
},
{
text: 'VULNERABILITY.SEVERITY.LOW',
value: this.lowCount ? this.lowCount : 0,
color: '#007CBB'
},
{
text: 'VULNERABILITY.SEVERITY.NEGLIGIBLE',
value: this.negligibleCount ? this.negligibleCount : 0,
color: 'green'
},
{
text: 'VULNERABILITY.SEVERITY.UNKNOWN',
value: this.unknownCount ? this.unknownCount : 0,
color: 'grey'
},
];
}
}

View File

@ -15,24 +15,15 @@ describe('ResultTipComponent (inline template)', () => {
vulnerabilityScanningBaseEndpoint: "/api/vulnerability/testing"
};
let mockData: VulnerabilitySummary = {
scan_status: VULNERABILITY_SCAN_STATUS.finished,
severity: 5,
update_time: new Date(),
components: {
scan_status: VULNERABILITY_SCAN_STATUS.SUCCESS,
severity: "High",
end_time: new Date(),
summary: {
total: 124,
summary: [{
severity: 1,
count: 90
}, {
severity: 3,
count: 10
}, {
severity: 4,
count: 10
}, {
severity: 5,
count: 13
}]
summary: {
"High": 5,
"Low": 5
}
}
};
@ -66,10 +57,10 @@ describe('ResultTipComponent (inline template)', () => {
fixture.detectChanges();
let el: HTMLElement = fixture.nativeElement.querySelector('.bar-block-none');
expect(el).not.toBeNull();
expect(el.style.width).toEqual("73px");
expect(el.style.width).toEqual("0px");
let el2: HTMLElement = fixture.nativeElement.querySelector('.bar-block-high');
expect(el2).not.toBeNull();
expect(el2.style.width).toEqual("10px");
expect(el2.style.width).toEqual("0px");
});
}));

View File

@ -1,7 +1,5 @@
import { Component, Input, OnInit } from '@angular/core';
import { VulnerabilitySummary, VulnerabilitySeverity } from '../service/index';
import { TranslateService } from '@ngx-translate/core';
import { VULNERABILITY_SCAN_STATUS } from '../utils';
export const MIN_TIP_WIDTH = 5;
@ -24,13 +22,9 @@ export class ResultTipComponent implements OnInit {
packagesWithVul: number = 0;
@Input() summary: VulnerabilitySummary = {
scan_status: VULNERABILITY_SCAN_STATUS.unknown,
severity: VulnerabilitySeverity.UNKNOWN,
update_time: new Date(),
components: {
total: 0,
summary: []
}
scan_status: VULNERABILITY_SCAN_STATUS.NOT_SCANNED,
severity: "",
end_time: new Date(),
};
get scanLevel() {
@ -51,56 +45,9 @@ export class ResultTipComponent implements OnInit {
return level;
}
constructor(private translate: TranslateService) { }
constructor() { }
ngOnInit(): void {
this.totalPackages = this.summary && this.summary.components ? this.summary.components.total : 0;
if (this.summary && this.summary.components && this.summary.components.summary) {
this.summary.components.summary.forEach(item => {
if (item.severity !== VulnerabilitySeverity.NONE) {
this.packagesWithVul += item.count;
}
switch (item.severity) {
case VulnerabilitySeverity.UNKNOWN:
this._unknownCount += item.count;
break;
case VulnerabilitySeverity.NONE:
this._noneCount += item.count;
break;
case VulnerabilitySeverity.LOW:
this._lowCount += item.count;
break;
case VulnerabilitySeverity.MEDIUM:
this._mediumCount += item.count;
break;
case VulnerabilitySeverity.HIGH:
this._highCount += item.count;
break;
default:
break;
}
});
}
this.translate.get(this.packageText(this.totalPackages)).subscribe((p1: string) => {
this.translate.get(this.unitText(this.packagesWithVul)).subscribe((vul: string) => {
if (this.totalPackages === 0) {
this.translate.get('VULNERABILITY.CHART.TOOLTIPS_TITLE_ZERO').subscribe( (res: string) => {
this._tipTitle = res;
});
} else {
let messageKey = 'VULNERABILITY.CHART.TOOLTIPS_TITLE_SINGULAR';
if (this.packagesWithVul > 1) {
messageKey = 'VULNERABILITY.CHART.TOOLTIPS_TITLE';
}
this.translate.get(messageKey, {
totalVulnerability: this.packagesWithVul,
totalPackages: this.totalPackages,
package: p1,
vulnerability: vul
}).subscribe((res: string) => this._tipTitle = res);
}
});
});
}
tipWidth(severity: VulnerabilitySeverity): string {
@ -169,7 +116,7 @@ export class ResultTipComponent implements OnInit {
}
public get completeTimestamp(): Date {
return this.summary && this.summary.update_time ? this.summary.update_time : new Date();
return this.summary && this.summary.end_time ? this.summary.end_time : new Date();
}
public get hasHigh(): boolean {

View File

@ -1,6 +1,6 @@
.bar-wrapper {
width: 120px;
height: 12px;
width: 210px;
height: 15px;
}
.bar-state {
text-align: center !important;
@ -48,7 +48,7 @@
margin-left: -3px;
}
.bar-block-high {
background-color: #e62700;
background-color: #e64524;
}
.bar-block-medium {
background-color: orange;
@ -100,19 +100,10 @@
color: #007CBB;
}
.label.label-medium{
background-color: #ffe4a9;
border: 1px solid orange;
color: orange;
}
.tip-icon-medium {
color: orange;
}
.label.label-low{
background: rgba(251, 255, 0, 0.38);
color: #c5c50b;
border: 1px solid #e6e63f;
}
.tip-icon-low {
color: yellow;
}
@ -145,3 +136,39 @@ hr{
.help-icon {
margin-left: 3px;
}
.mt-3px {
margin-top: 5px;
}
.label-critical {
background:red;
color:#621501;
border:1px solid #f8b5b4;
}
.label-danger {
background:#e64524!important;
color:#621501!important;
border:1px solid #f8b5b4!important;
}
.label-medium {
background-color: orange;
color:#621501;
border:1px solid #f8b5b4;
}
.label-low {
background: #007CBB;
color:#cab6b1;
border:1px solid #f8b5b4;
}
.label-negligible {
background-color: green;
color:#bad7ba;
border:1px solid #f8b5b4;
}
.label-unknown {
background-color: grey;
color:#bad7ba;
border:1px solid #f8b5b4;
}

View File

@ -38,6 +38,12 @@
<project-quotas [(allConfig)]="allConfig" (refreshAllconfig)="refreshAllconfig()"></project-quotas>
</clr-tab-content>
</clr-tab>
<clr-tab>
<button id="config-scanners" clrTabLink>{{'SCANNER.SCANNERS' | translate}}</button>
<clr-tab-content id="scanners" *clrIfActive>
<config-scanner></config-scanner>
</clr-tab-content>
</clr-tab>
</clr-tabs>
</div>
</div>

View File

@ -22,20 +22,31 @@ import { ConfirmMessageHandler } from "./config.msg.utils";
import { ConfigurationAuthComponent } from "./auth/config-auth.component";
import { ConfigurationEmailComponent } from "./email/config-email.component";
import { RobotApiRepository } from "../project/robot-account/robot.api.repository";
import { ConfigurationScannerComponent } from "./scanner/config-scanner.component";
import { NewScannerModalComponent } from "./scanner/new-scanner-modal/new-scanner-modal.component";
import { NewScannerFormComponent } from "./scanner/new-scanner-form/new-scanner-form.component";
import { ConfigScannerService } from "./scanner/config-scanner.service";
import { ScannerMetadataComponent } from "./scanner/scanner-metadata/scanner-metadata.component";
@NgModule({
imports: [CoreModule, SharedModule],
declarations: [
ConfigurationComponent,
ConfigurationAuthComponent,
ConfigurationEmailComponent
],
exports: [ConfigurationComponent],
providers: [
ConfigurationService,
ConfirmMessageHandler,
RobotApiRepository
]
imports: [CoreModule, SharedModule],
declarations: [
ConfigurationComponent,
ConfigurationAuthComponent,
ConfigurationEmailComponent,
ConfigurationScannerComponent,
NewScannerModalComponent,
NewScannerFormComponent,
ScannerMetadataComponent
],
exports: [ConfigurationComponent],
providers: [
ConfigurationService,
ConfirmMessageHandler,
RobotApiRepository,
ConfigScannerService,
]
})
export class ConfigurationModule {}
export class ConfigurationModule {
}

View File

@ -0,0 +1,76 @@
<div class="row">
<div>
<clr-datagrid [clrDgLoading]="onGoing" [(clrDgSingleSelected)]="selectedRow">
<clr-dg-action-bar>
<div class="clr-row">
<div class="clr-col-7">
<button type="button" class="btn btn-sm btn-secondary" (click)="addNewScanner()">
<clr-icon shape="plus" size="16"></clr-icon>
{{'SCANNER.NEW_SCANNER' | translate}}
</button>
<button id="set-default" [disabled]="!(selectedRow && !selectedRow.is_default && !selectedRow.disabled)"
class="btn btn-sm btn-secondary"
(click)="setAsDefault()">{{'SCANNER.SET_AS_DEFAULT' | translate}}</button>
<clr-dropdown [clrCloseMenuOnItemClick]="false" class="btn btn-sm btn-link" clrDropdownTrigger>
<span>{{'MEMBER.ACTION' | translate}}<clr-icon class="clr-icon" shape="caret down"></clr-icon></span>
<clr-dropdown-menu *clrIfOpen>
<button clrDropdownItem
(click)="changeStat()"
[disabled]="!(selectedRow && !selectedRow.is_default)">
<span *ngIf="selectedRow && selectedRow.disabled">{{'BUTTON.ENABLE' | translate}}</span>
<span *ngIf="!(selectedRow && selectedRow.disabled)">{{'BUTTON.DISABLE' | translate}}</span>
</button>
<button clrDropdownItem
(click)="editScanner()"
class="btn btn-sm btn-secondary" [disabled]="!selectedRow">
{{'BUTTON.EDIT' | translate}}
</button>
<button clrDropdownItem
(click)="deleteScanners()"
class="btn btn-sm btn-secondary" [disabled]="!selectedRow">
{{'BUTTON.DELETE' | translate}}
</button>
</clr-dropdown-menu>
</clr-dropdown>
</div>
<div class="clr-col-5">
<div class="action-head-pos">
<span (click)="getScanners()" class="refresh-btn">
<clr-icon shape="refresh" [hidden]="onGoing"></clr-icon>
</span>
</div>
</div>
</div>
</clr-dg-action-bar>
<clr-dg-column class="width-240" [clrDgField]="'name'">{{'SCANNER.NAME' | translate}}</clr-dg-column>
<clr-dg-column class="width-240" [clrDgField]="'url'">{{'SCANNER.ENDPOINT' | translate}}</clr-dg-column>
<clr-dg-column>{{'SCANNER.HEALTH' | translate}}</clr-dg-column>
<clr-dg-column>{{'SCANNER.DISABLED' | translate}}</clr-dg-column>
<clr-dg-column>{{'SCANNER.AUTH' | translate}}</clr-dg-column>
<clr-dg-placeholder>
{{'SCANNER.NO_SCANNER' | translate}}
</clr-dg-placeholder>
<clr-dg-row *clrDgItems="let scanner of scanners" [clrDgItem]="scanner">
<clr-dg-cell>
<span>{{scanner.name}}</span>
<span *ngIf="scanner.is_default" class="label label-info ml-1">{{'SCANNER.DEFAULT' | translate}}</span>
</clr-dg-cell>
<clr-dg-cell>{{scanner.url}}</clr-dg-cell>
<clr-dg-cell>
<span *ngIf="scanner.health;else elseBlock" class="label label-success">{{'SCANNER.HEALTHY' | translate}}</span>
<ng-template #elseBlock>
<span class="label label-danger">{{'SCANNER.UNHEALTHY' | translate}}</span>
</ng-template>
</clr-dg-cell>
<clr-dg-cell>{{scanner.disabled}}</clr-dg-cell>
<clr-dg-cell>{{scanner.auth?scanner.auth:'None'}}</clr-dg-cell>
<scanner-metadata *clrIfExpanded [uid]="scanner.uuid" ngProjectAs="clr-dg-row-detail"></scanner-metadata>
</clr-dg-row>
<clr-dg-footer>
<span *ngIf="scanners?.length > 0">1 - {{scanners?.length}} {{'WEBHOOK.OF' | translate}} </span> {{scanners?.length}} {{'WEBHOOK.ITEMS' | translate}}
<clr-dg-pagination [clrDgPageSize]="10"></clr-dg-pagination>
</clr-dg-footer>
</clr-datagrid>
</div>
<new-scanner-modal (notify)="addSuccess()"></new-scanner-modal>
</div>

View File

@ -0,0 +1,24 @@
.action-head-pos {
padding-right: 18px;
height: 24px;
display: flex;
justify-content: flex-end;
}
.refresh-btn {
cursor: pointer;
margin-top: 7px;
}
.clr-icon {
color: #0079b8;
margin-top: 0;
}
.color-b {
color: #bbb;
}
.margin-left-5 {
margin-left: 5px;
}
.width-240 {
min-width: 240px !important;
}

View File

@ -0,0 +1,84 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { BrowserAnimationsModule } from "@angular/platform-browser/animations";
import { ClarityModule } from "@clr/angular";
import { of } from "rxjs";
import { ErrorHandler } from "@harbor/ui";
import { ConfigurationScannerComponent } from "./config-scanner.component";
import { ConfigScannerService } from "./config-scanner.service";
import { MessageHandlerService } from "../../shared/message-handler/message-handler.service";
import { ConfirmationDialogService } from "../../shared/confirmation-dialog/confirmation-dialog.service";
import { SharedModule } from "../../shared/shared.module";
import { ScannerMetadataComponent } from "./scanner-metadata/scanner-metadata.component";
import { NewScannerModalComponent } from "./new-scanner-modal/new-scanner-modal.component";
import { NewScannerFormComponent } from "./new-scanner-form/new-scanner-form.component";
import { TranslateService } from "@ngx-translate/core";
describe('ConfigurationScannerComponent', () => {
let mockScannerMetadata = {
scanner: {
name: 'test1',
vendor: 'clair',
version: '1.0.1',
},
capabilities: [{
consumes_mime_types: ['consumes_mime_types'],
produces_mime_types: ['consumes_mime_types']
}]
};
let mockScanner1 = {
name: 'test1',
description: 'just a sample',
version: '1.0.0',
url: 'http://168.0.0.1'
};
let component: ConfigurationScannerComponent;
let fixture: ComponentFixture<ConfigurationScannerComponent>;
let fakedConfigScannerService = {
getScannerMetadata() {
return of(mockScannerMetadata);
},
getScanners() {
return of([mockScanner1]);
}
};
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [
SharedModule,
BrowserAnimationsModule,
ClarityModule,
],
declarations: [
ConfigurationScannerComponent,
ScannerMetadataComponent,
NewScannerModalComponent,
NewScannerFormComponent
],
providers: [
ErrorHandler,
MessageHandlerService,
ConfirmationDialogService,
TranslateService,
{ provide: ConfigScannerService, useValue: fakedConfigScannerService },
]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(ConfigurationScannerComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
expect(component.scanners.length).toBe(1);
});
it('should be clickable', () => {
component.selectedRow = mockScanner1;
fixture.detectChanges();
fixture.whenStable().then(() => {
let el: HTMLElement = fixture.nativeElement.querySelector('#set-default');
expect(el.getAttribute('disable')).toBeFalsy();
});
});
});

View File

@ -0,0 +1,149 @@
import { Component, ViewChild, OnInit, OnDestroy } from "@angular/core";
import { Scanner } from "./scanner";
import { NewScannerModalComponent } from "./new-scanner-modal/new-scanner-modal.component";
import { ConfigScannerService } from "./config-scanner.service";
import { clone, ErrorHandler } from "@harbor/ui";
import { finalize } from "rxjs/operators";
import { MessageHandlerService } from "../../shared/message-handler/message-handler.service";
import { ConfirmationButtons, ConfirmationState, ConfirmationTargets } from "../../shared/shared.const";
import { ConfirmationDialogService } from "../../shared/confirmation-dialog/confirmation-dialog.service";
import { ConfirmationMessage } from '../../shared/confirmation-dialog/confirmation-message';
@Component({
selector: 'config-scanner',
templateUrl: "config-scanner.component.html",
styleUrls: ['./config-scanner.component.scss', '../config.component.scss']
})
export class ConfigurationScannerComponent implements OnInit, OnDestroy {
scanners: Scanner[] = [];
selectedRow: Scanner;
onGoing: boolean = false;
@ViewChild(NewScannerModalComponent, {static: false})
newScannerDialog: NewScannerModalComponent;
deletionSubscription: any;
constructor(
private configScannerService: ConfigScannerService,
private errorHandler: ErrorHandler,
private msgHandler: MessageHandlerService,
private deletionDialogService: ConfirmationDialogService,
) {}
ngOnInit() {
if (!this.deletionSubscription) {
this.deletionSubscription = this.deletionDialogService.confirmationConfirm$.subscribe(confirmed => {
if (confirmed &&
confirmed.source === ConfirmationTargets.SCANNER &&
confirmed.state === ConfirmationState.CONFIRMED) {
this.configScannerService.deleteScanners(confirmed.data)
.subscribe(response => {
this.msgHandler.showSuccess("Delete Success");
this.getScanners();
}, error => {
this.errorHandler.error(error);
});
}
});
}
this.getScanners();
}
ngOnDestroy(): void {
if (this.deletionSubscription) {
this.deletionSubscription.unsubscribe();
this.deletionSubscription = null;
}
}
getScanners() {
this.onGoing = true;
this.configScannerService.getScanners()
.pipe(finalize(() => this.onGoing = false))
.subscribe(response => {
this.scanners = response;
}, error => {
this.errorHandler.error(error);
});
}
addNewScanner(): void {
this.newScannerDialog.open();
this.newScannerDialog.isEdit = false;
this.newScannerDialog.newScannerFormComponent.isEdit = false;
}
addSuccess() {
this.getScanners();
}
changeStat() {
if (this.selectedRow) {
let scanner: Scanner = clone(this.selectedRow);
scanner.disabled = !scanner.disabled;
this.configScannerService.updateScanner(scanner)
.subscribe(response => {
this.msgHandler.showSuccess("Update Success");
this.getScanners();
}, error => {
this.errorHandler.error(error);
});
}
}
setAsDefault() {
if (this.selectedRow) {
this.configScannerService.setAsDefault(this.selectedRow.uuid)
.subscribe(response => {
this.msgHandler.showSuccess("Update Success");
this.getScanners();
}, error => {
this.errorHandler.error(error);
});
}
}
deleteScanners() {
if (this.selectedRow) {
// Confirm deletion
let msg: ConfirmationMessage = new ConfirmationMessage(
"Confirm Scanner deletion",
"SCANNER.DELETION_SUMMARY",
this.selectedRow.name,
[this.selectedRow],
ConfirmationTargets.SCANNER,
ConfirmationButtons.DELETE_CANCEL
);
this.deletionDialogService.openComfirmDialog(msg);
}
}
editScanner() {
if (this.selectedRow) {
this.newScannerDialog.open();
let resetValue: object = {};
resetValue['name'] = this.selectedRow.name;
resetValue['description'] = this.selectedRow.description;
resetValue['url'] = this.selectedRow.url;
resetValue['skipCertVerify'] = this.selectedRow.skip_certVerify;
if (this.selectedRow.auth === 'Basic') {
resetValue['auth'] = 'Basic';
let username: string = this.selectedRow.access_credential.split(":")[0];
let password: string = this.selectedRow.access_credential.split(":")[1];
resetValue['accessCredential'] = {
username: username,
password: password
};
} else if (this.selectedRow.auth === 'Bearer') {
resetValue['auth'] = 'Bearer';
resetValue['accessCredential'] = {
token: this.selectedRow.access_credential
};
} else if (this.selectedRow.auth === 'APIKey') {
resetValue['auth'] = 'APIKey';
resetValue['accessCredential'] = {
apiKey: this.selectedRow.access_credential
};
} else {
resetValue['auth'] = 'None';
}
this.newScannerDialog.newScannerFormComponent.newScannerForm.reset(resetValue);
this.newScannerDialog.isEdit = true;
this.newScannerDialog.newScannerFormComponent.isEdit = true;
this.newScannerDialog.uid = this.selectedRow.uuid;
this.newScannerDialog.originValue = clone(resetValue);
this.newScannerDialog.newScannerFormComponent.originValue = clone(resetValue);
this.newScannerDialog.editScanner = clone(this.selectedRow);
}
}
}

View File

@ -0,0 +1,20 @@
import { TestBed, inject } from '@angular/core/testing';
import { SharedModule } from "../../shared/shared.module";
import { ConfigScannerService } from "./config-scanner.service";
describe('TagService', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [
SharedModule
],
providers: [
ConfigScannerService
]
});
});
it('should be initialized', inject([ConfigScannerService], (service: ConfigScannerService) => {
expect(service).toBeTruthy();
}));
});

View File

@ -0,0 +1,70 @@
import {Injectable} from "@angular/core";
import {Scanner} from "./scanner";
import { forkJoin, Observable, throwError as observableThrowError } from "rxjs";
import { catchError, map } from "rxjs/operators";
import { HttpClient } from "@angular/common/http";
import { ScannerMetadata } from "./scanner-metadata";
@Injectable()
export class ConfigScannerService {
constructor( private http: HttpClient) {}
getScannersByName(name: string): Observable<Scanner[]> {
return this.http.get(`/api/scanners?ex_name=${name}`)
.pipe(catchError(error => observableThrowError(error)))
.pipe(map(response => response as Scanner[]));
}
getScannersByEndpointUrl(endpointUrl: string): Observable<Scanner[]> {
return this.http.get(`/api/scanners?ex_url=${endpointUrl}`)
.pipe(catchError(error => observableThrowError(error)))
.pipe(map(response => response as Scanner[]));
}
testEndpointUrl(testValue: any): Observable<any> {
return this.http.post(`/api/scanners/ping`, testValue)
.pipe(catchError(error => observableThrowError(error)));
}
addScanner(scanner: Scanner): Observable<any> {
return this.http.post('/api/scanners', scanner )
.pipe(catchError(error => observableThrowError(error)));
}
getScanners(): Observable<Scanner[]> {
return this.http.get('/api/scanners')
.pipe(map(response => response as Scanner[]))
.pipe(catchError(error => observableThrowError(error)));
}
updateScanner(scanner: Scanner): Observable<any> {
return this.http.put(`/api/scanners/${scanner.uuid}`, scanner )
.pipe(catchError(error => observableThrowError(error)));
}
deleteScanner(scanner: Scanner): Observable<any> {
return this.http.delete(`/api/scanners/${scanner.uuid}`)
.pipe(catchError(error => observableThrowError(error)));
}
deleteScanners(scanners: Scanner[]): Observable<any> {
let observableLists: any[] = [];
if (scanners && scanners.length > 0) {
scanners.forEach(scanner => {
observableLists.push(this.deleteScanner(scanner));
});
return forkJoin(...observableLists);
}
}
getProjectScanner(projectId: number): Observable<Scanner> {
return this.http.get(`/api/projects/${projectId}/scanner`)
.pipe(map(response => response as Scanner))
.pipe(catchError(error => observableThrowError(error)));
}
updateProjectScanner(projectId: number , uid: string): Observable<any> {
return this.http.put(`/api/projects/${projectId}/scanner` , {uuid: uid})
.pipe(catchError(error => observableThrowError(error)));
}
getScannerMetadata(uid: string): Observable<ScannerMetadata> {
return this.http.get(`/api/scanners/${uid}/metadata`)
.pipe(map(response => response as ScannerMetadata))
.pipe(catchError(error => observableThrowError(error)));
}
setAsDefault(uid: string): Observable<any> {
return this.http.patch(`/api/scanners/${uid}`, {is_default: true} )
.pipe(catchError(error => observableThrowError(error)));
}
}

View File

@ -0,0 +1,124 @@
<div>
<form [formGroup]="newScannerForm" class="clr-form clr-form-horizontal">
<div class="clr-form-control">
<label class="required clr-control-label">{{"SCANNER.NAME" | translate}}</label>
<div class="clr-control-container" [class.clr-error]="!isNameValid">
<div class="clr-input-wrapper">
<input autocomplete="off" #name formControlName="name" class="clr-input width-312"
type="text"
id="scanner-name">
<clr-icon class="clr-validate-icon" shape="exclamation-circle"></clr-icon>
<span class="spinner spinner-inline" [hidden]="!checkOnGoing"></span>
</div>
<clr-control-error *ngIf="!isNameValid">
{{nameTooltip | translate}}
</clr-control-error>
</div>
</div>
<div class="clr-form-control">
<label class="clr-control-label">{{"SCANNER.DESCRIPTION" | translate}}</label>
<div class="clr-control-container">
<textarea autocomplete="off" formControlName="description" class="clr-textarea width-312" type="text"
id="description">
</textarea>
</div>
</div>
<div class="clr-form-control">
<label class="required clr-control-label">{{"SCANNER.ENDPOINT" | translate}}</label>
<div class="clr-control-container" [class.clr-error]="!isEndpointValid || showEndpointError">
<div class="clr-input-wrapper">
<input (focus)="showEndpointError=false" (blur)="checkEndpointUrl()" #endpointUrl placeholder="http(s)://192.168.1.1" autocomplete="off" formControlName="url"
class="clr-input width-312" type="text" id="scanner-endpoint">
<clr-icon class="clr-validate-icon" shape="exclamation-circle"></clr-icon>
<span class="spinner spinner-inline" [hidden]="!checkEndpointOnGoing"></span>
</div>
<clr-control-error *ngIf="!isEndpointValid || showEndpointError">
{{endpointTooltip | translate}}
</clr-control-error>
</div>
</div>
<div class="clr-form-control">
<label class="clr-control-label">{{"SCANNER.AUTH" | translate}}</label>
<div class="clr-control-container">
<div class="clr-select-wrapper">
<select formControlName="auth" class="clr-select width-312" id="scanner-authorization">
<option value="None">{{"SCANNER.NONE" | translate}}</option>
<option value="Basic">{{"SCANNER.BASIC" | translate}}</option>
<option value="Bearer">{{"SCANNER.BEARER" | translate}}</option>
<option value="APIKey">{{"SCANNER.API_KEY" | translate}}</option>
</select>
</div>
</div>
</div>
<ng-container formGroupName="accessCredential">
<div class="clr-form-control" *ngIf="auth==='Basic'">
<label class="required clr-control-label">{{"SCANNER.USERNAME" | translate}}</label>
<div class="clr-control-container" [class.clr-error]="!isUserNameValid">
<div class="clr-input-wrapper">
<input formControlName="username" autocomplete="off"
class="clr-input width-312" type="text" id="scanner-username">
<clr-icon class="clr-validate-icon" shape="exclamation-circle"></clr-icon>
</div>
<clr-control-error *ngIf="!isUserNameValid">
{{"SCANNER.USERNAME_REQUIRED" | translate}}
</clr-control-error>
</div>
</div>
<div class="clr-form-control" *ngIf="auth==='Basic'">
<label class="required clr-control-label">{{"SCANNER.PASSWORD" | translate}}</label>
<div class="clr-control-container" [class.clr-error]="!isPasswordValid">
<div class="clr-input-wrapper">
<input formControlName="password" autocomplete="off"
class="clr-input width-312" type="password" id="scanner-password">
<clr-icon class="clr-validate-icon" shape="exclamation-circle"></clr-icon>
</div>
<clr-control-error *ngIf="!isPasswordValid">
{{"SCANNER.PASSWORD_REQUIRED" | translate}}
</clr-control-error>
</div>
</div>
<div class="clr-form-control" *ngIf="auth==='Bearer'">
<label class="required clr-control-label">{{"SCANNER.TOKEN" | translate}}</label>
<div class="clr-control-container" [class.clr-error]="!isTokenValid">
<div class="clr-input-wrapper">
<input formControlName="token" autocomplete="off"
class="clr-input width-312" type="text" id="scanner-token">
<clr-icon class="clr-validate-icon" shape="exclamation-circle"></clr-icon>
</div>
<clr-control-error *ngIf="!isTokenValid">
{{"SCANNER.TOKEN_REQUIRED" | translate}}
</clr-control-error>
</div>
</div>
<div class="clr-form-control" *ngIf="auth==='APIKey'">
<label class="required clr-control-label">{{"SCANNER.API_KEY" | translate}}</label>
<div class="clr-control-container" [class.clr-error]="!isApiKeyValid">
<div class="clr-input-wrapper">
<input formControlName="apiKey" autocomplete="off"
class="clr-input width-312" type="text" id="scanner-apiKey">
<clr-icon class="clr-validate-icon" shape="exclamation-circle"></clr-icon>
</div>
<clr-control-error *ngIf="!isApiKeyValid">
{{"SCANNER.API_KEY_REQUIRED" | translate}}
</clr-control-error>
</div>
</div>
</ng-container>
<div class="clr-form-control">
<label class="clr-control-label">{{"SCANNER.SKIP" | translate}}
<clr-tooltip>
<clr-icon clrTooltipTrigger shape="info-circle" size="24"></clr-icon>
<clr-tooltip-content clrPosition="top-right" clrSize="lg" *clrIfOpen>
{{'SCANNER.SKIP_CERT_VERIFY' | translate}}
</clr-tooltip-content>
</clr-tooltip>
</label>
<div class="clr-control-container padding-top-3">
<div class="clr-checkbox-wrapper">
<input clrCheckbox formControlName="skipCertVerify"
type="checkbox" id="scanner-skipCertVerify">
</div>
</div>
</div>
</form>
</div>

View File

@ -0,0 +1,6 @@
.width-312 {
width: 312px;
}
.padding-top-3 {
padding-top: 3px;
}

View File

@ -0,0 +1,102 @@
import { async, ComponentFixture, ComponentFixtureAutoDetect, TestBed } from '@angular/core/testing';
import { NewScannerFormComponent } from "./new-scanner-form.component";
import { FormBuilder } from "@angular/forms";
import { BrowserAnimationsModule } from "@angular/platform-browser/animations";
import { ClarityModule } from "@clr/angular";
import { SharedModule } from "../../../shared/shared.module";
import { ConfigScannerService } from "../config-scanner.service";
import { of } from "rxjs";
import { TranslateService } from "@ngx-translate/core";
describe('NewScannerFormComponent', () => {
let mockScanner1 = {
name: 'test1',
description: 'just a sample',
version: '1.0.0',
url: 'http://168.0.0.1'
};
let component: NewScannerFormComponent;
let fixture: ComponentFixture<NewScannerFormComponent>;
let fakedConfigScannerService = {
getScannersByName() {
return of([mockScanner1]);
}
};
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [
SharedModule,
BrowserAnimationsModule,
ClarityModule,
],
declarations: [ NewScannerFormComponent ],
providers: [
FormBuilder,
TranslateService,
{ provide: ConfigScannerService, useValue: fakedConfigScannerService },
// open auto detect
{ provide: ComponentFixtureAutoDetect, useValue: true }
]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(NewScannerFormComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should creat', () => {
expect(component).toBeTruthy();
});
it('should show "name is required"', () => {
let nameInput = fixture.nativeElement.querySelector('#scanner-name');
nameInput.value = "";
nameInput.dispatchEvent(new Event('input'));
nameInput.blur();
nameInput.dispatchEvent(new Event('blur'));
let el = fixture.nativeElement.querySelector('clr-control-error');
expect(el).toBeTruthy();
});
it('name should be valid', () => {
let nameInput = fixture.nativeElement.querySelector('#scanner-name');
nameInput.value = "test2";
nameInput.dispatchEvent(new Event('input'));
nameInput.blur();
nameInput.dispatchEvent(new Event('blur'));
setTimeout(() => {
let el = fixture.nativeElement.querySelector('clr-control-error');
expect(el).toBeFalsy();
}, 900);
});
it('endpoint url should be valid', () => {
let nameInput = fixture.nativeElement.querySelector('#scanner-name');
nameInput.value = "test2";
let urlInput = fixture.nativeElement.querySelector('#scanner-endpoint');
urlInput.value = "http://168.0.0.1";
urlInput.dispatchEvent(new Event('input'));
urlInput.blur();
urlInput.dispatchEvent(new Event('blur'));
setTimeout(() => {
let el = fixture.nativeElement.querySelector('clr-control-error');
expect(el).toBeFalsy();
}, 900);
});
it('auth should be valid', () => {
let authInput = fixture.nativeElement.querySelector('#scanner-authorization');
authInput.value = "Basic";
authInput.dispatchEvent(new Event('change'));
let usernameInput = fixture.nativeElement.querySelector('#scanner-username');
let passwordInput = fixture.nativeElement.querySelector('#scanner-password');
expect(usernameInput).toBeTruthy();
expect(passwordInput).toBeTruthy();
usernameInput.value = "user";
passwordInput.value = "12345";
usernameInput.dispatchEvent(new Event('input'));
passwordInput.dispatchEvent(new Event('input'));
let el = fixture.nativeElement.querySelector('clr-control-error');
expect(el).toBeFalsy();
});
});

View File

@ -0,0 +1,197 @@
import {
AfterViewInit,
Component,
ElementRef,
OnDestroy,
OnInit,
ViewChild
} from "@angular/core";
import { FormBuilder, FormGroup, Validators } from "@angular/forms";
import { fromEvent } from "rxjs";
import { debounceTime, distinctUntilChanged, filter, finalize, map, switchMap } from "rxjs/operators";
import { ConfigScannerService } from "../config-scanner.service";
@Component({
selector: 'new-scanner-form',
templateUrl: 'new-scanner-form.component.html',
styleUrls: ['new-scanner-form.component.scss']
})
export class NewScannerFormComponent implements OnInit, AfterViewInit, OnDestroy {
checkOnGoing: boolean = false;
newScannerForm: FormGroup = this.fb.group({
name: this.fb.control("",
[Validators.required, Validators.pattern(/^[a-z0-9]+(?:[._-][a-z0-9]+)*$/)]),
description: this.fb.control(""),
url: this.fb.control("",
[Validators.required,
Validators.pattern(/^http[s]?:\/\//)]),
auth: this.fb.control(""),
accessCredential: this.fb.group({
username: this.fb.control("", Validators.required),
password: this.fb.control("", Validators.required),
token: this.fb.control("", Validators.required),
apiKey: this.fb.control("", Validators.required)
}),
skipCertVerify: this.fb.control(false)
});
checkNameSubscribe: any;
checkEndpointUrlSubscribe: any;
nameTooltip: string;
endpointTooltip: string;
isNameExisting: boolean = false;
checkEndpointOnGoing: boolean = false;
isEndpointUrlExisting: boolean = false;
showEndpointError: boolean = false;
originValue: any;
isEdit: boolean;
@ViewChild('name', {static: false}) scannerName: ElementRef;
@ViewChild('endpointUrl', {static: false}) scannerEndpointUrl: ElementRef;
constructor(private fb: FormBuilder, private scannerService: ConfigScannerService) {
}
ngAfterViewInit(): void {
if (!this.checkNameSubscribe) {
this.checkNameSubscribe = fromEvent(this.scannerName.nativeElement, 'input').pipe(
map((e: any) => e.target.value),
filter(name => {
if (this.isEdit && this.originValue && this.originValue.name === name) {
return false;
}
return this.newScannerForm.get('name').valid && name.length > 1;
}),
debounceTime(500),
distinctUntilChanged(),
switchMap((name) => {
this.isNameExisting = false;
this.checkOnGoing = true;
return this.scannerService.getScannersByName(name)
.pipe(finalize(() => this.checkOnGoing = false));
})).subscribe(response => {
if (response && response.length > 0) {
response.forEach(s => {
if (s.name === this.newScannerForm.get('name').value) {
this.isNameExisting = true;
return;
}
});
}
}, error => {
this.isNameExisting = false;
});
}
if (!this.checkEndpointUrlSubscribe) {
this.checkEndpointUrlSubscribe = fromEvent(this.scannerEndpointUrl.nativeElement, 'input').pipe(
map((e: any) => e.target.value),
filter(endpointUrl => {
if (this.isEdit && this.originValue && this.originValue.url === endpointUrl) {
return false;
}
return this.newScannerForm.get('url').valid && endpointUrl.length > 6;
}),
debounceTime(800),
distinctUntilChanged(),
switchMap((endpointUrl) => {
this.isEndpointUrlExisting = false;
this.checkEndpointOnGoing = true;
return this.scannerService.getScannersByEndpointUrl(endpointUrl)
.pipe(finalize(() => this.checkEndpointOnGoing = false));
})).subscribe(response => {
if (response && response.length > 0) {
response.forEach(s => {
if (s.url === this.newScannerForm.get('url').value) {
this.isEndpointUrlExisting = true;
return;
}
});
}
}, error => {
this.isEndpointUrlExisting = false;
});
}
}
ngOnInit(): void {
}
ngOnDestroy() {
if (this.checkNameSubscribe) {
this.checkNameSubscribe.unsubscribe();
this.checkNameSubscribe = null;
}
if (this.checkEndpointUrlSubscribe) {
this.checkEndpointUrlSubscribe.unsubscribe();
this.checkEndpointUrlSubscribe = null;
}
}
get isNameValid(): boolean {
if (!(this.newScannerForm.get('name').dirty || this.newScannerForm.get('name').touched)) {
return true;
}
if (this.checkOnGoing) {
return true;
}
if (this.isNameExisting) {
this.nameTooltip = 'NAME_EXISTS';
return false;
}
if (this.newScannerForm.get('name').errors && this.newScannerForm.get('name').errors.required) {
this.nameTooltip = 'NAME_REQUIRED';
return false;
}
if (this.newScannerForm.get('name').errors && this.newScannerForm.get('name').errors.pattern) {
this.nameTooltip = 'NAME_REX';
return false;
}
return true;
}
get isEndpointValid(): boolean {
if (!(this.newScannerForm.get('url').dirty || this.newScannerForm.get('url').touched)) {
return true;
}
if (this.checkEndpointOnGoing) {
return true;
}
if (this.isEndpointUrlExisting) {
this.endpointTooltip = 'ENDPOINT_EXISTS';
return false;
}
if (this.newScannerForm.get('url').errors && this.newScannerForm.get('url').errors.required) {
this.endpointTooltip = 'ENDPOINT_REQUIRED';
return false;
}
// skip here, validate when onblur
if (this.newScannerForm.get('url').errors && this.newScannerForm.get('url').errors.pattern) {
return true;
}
return true;
}
// validate endpointUrl when onblur
checkEndpointUrl() {
if (this.newScannerForm.get('url').errors && this.newScannerForm.get('url').errors.pattern) {
this.endpointTooltip = "ILLEGAL_ENDPOINT";
this.showEndpointError = true;
}
}
get auth(): string {
return this.newScannerForm.get('auth').value;
}
get isUserNameValid(): boolean {
return !(this.newScannerForm.get('accessCredential').get('username').invalid
&& (this.newScannerForm.get('accessCredential').get('username').dirty
|| this.newScannerForm.get('accessCredential').get('username').touched));
}
get isPasswordValid(): boolean {
return !(this.newScannerForm.get('accessCredential').get('password').invalid
&& (this.newScannerForm.get('accessCredential').get('password').dirty
|| this.newScannerForm.get('accessCredential').get('password').touched));
}
get isTokenValid(): boolean {
return !(this.newScannerForm.get('accessCredential').get('token').invalid
&& (this.newScannerForm.get('accessCredential').get('token').dirty
|| this.newScannerForm.get('accessCredential').get('token').touched));
}
get isApiKeyValid(): boolean {
return !(this.newScannerForm.get('accessCredential').get('apiKey').invalid
&& (this.newScannerForm.get('accessCredential').get('apiKey').dirty
|| this.newScannerForm.get('accessCredential').get('apiKey').touched));
}
}

View File

@ -0,0 +1,14 @@
<clr-modal [(clrModalOpen)]="opened" [clrModalStaticBackdrop]="true" [clrModalClosable]="false">
<h3 *ngIf="!isEdit" class="modal-title">{{'SCANNER.ADD_SCANNER' | translate}}</h3>
<h3 *ngIf="isEdit" class="modal-title">{{'SCANNER.EDIT_SCANNER' | translate}}</h3>
<div class="modal-body body-format">
<inline-alert class="modal-title"></inline-alert>
<new-scanner-form></new-scanner-form>
</div>
<div class="modal-footer">
<button id="button-test" type="button" [clrLoading]="checkBtnState" class="btn btn-outline" (click)="onTestEndpoint()" [disabled]="!canTestEndpoint">{{'SCANNER.TEST_CONNECTION' | translate}}</button>
<button id="button-cancel" type="button" class="btn btn-outline" (click)="close()">{{'BUTTON.CANCEL' | translate}}</button>
<button id="button-add" *ngIf="!isEdit" type="button" [clrLoading]="saveBtnState" class="btn btn-primary" [disabled]="!valid" (click)="create()">{{'BUTTON.ADD' | translate}}</button>
<button id="button-save" *ngIf="isEdit" type="button" [clrLoading]="saveBtnState" class="btn btn-primary" [disabled]="!validForSaving" (click)="save()">{{'BUTTON.SAVE' | translate}}</button>
</div>
</clr-modal>

View File

@ -0,0 +1,144 @@
import { async, ComponentFixture, ComponentFixtureAutoDetect, TestBed } from '@angular/core/testing';
import { ClrLoadingState } from "@clr/angular";
import { ConfigScannerService } from "../config-scanner.service";
import { NewScannerModalComponent } from "./new-scanner-modal.component";
import { MessageHandlerService } from "../../../shared/message-handler/message-handler.service";
import { NewScannerFormComponent } from "../new-scanner-form/new-scanner-form.component";
import { FormBuilder } from "@angular/forms";
import { of, Subscription } from "rxjs";
import { delay } from "rxjs/operators";
import { SharedModule } from "@harbor/ui";
import { SharedModule as AppSharedModule } from "../../../shared/shared.module";
describe('NewScannerModalComponent', () => {
let component: NewScannerModalComponent;
let fixture: ComponentFixture<NewScannerModalComponent>;
let mockScanner1 = {
name: 'test1',
description: 'just a sample',
url: 'http://168.0.0.1',
auth: "",
};
let fakedConfigScannerService = {
getScannersByName() {
return of([mockScanner1]);
},
testEndpointUrl() {
return of(true).pipe(delay(200));
},
addScanner() {
return of(true).pipe(delay(200));
},
updateScanner() {
return of(true).pipe(delay(200));
}
};
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [
SharedModule,
AppSharedModule
],
declarations: [
NewScannerFormComponent,
NewScannerModalComponent,
],
providers: [
MessageHandlerService,
{ provide: ConfigScannerService, useValue: fakedConfigScannerService },
FormBuilder,
// open auto detect
{ provide: ComponentFixtureAutoDetect, useValue: true }
]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(NewScannerModalComponent);
component = fixture.componentInstance;
component.opened = true;
component.newScannerFormComponent.checkNameSubscribe = new Subscription();
component.newScannerFormComponent.checkEndpointUrlSubscribe = new Subscription();
fixture.detectChanges();
});
it('should creat', () => {
expect(component).toBeTruthy();
});
it('should be add mode', () => {
component.isEdit = false;
fixture.detectChanges();
let el = fixture.nativeElement.querySelector('#button-add');
expect(el).toBeTruthy();
});
it('should be edit mode', () => {
component.isEdit = true;
fixture.detectChanges();
let el = fixture.nativeElement.querySelector('#button-save');
expect(el).toBeTruthy();
// set origin value
component.originValue = mockScanner1;
component.editScanner = {};
// input same value to origin
fixture.nativeElement.querySelector('#scanner-name').value = "test2";
fixture.nativeElement.querySelector('#description').value = "just a sample";
fixture.nativeElement.querySelector('#scanner-endpoint').value = "http://168.0.0.1";
fixture.nativeElement.querySelector('#scanner-authorization').value = "";
fixture.nativeElement.querySelector('#scanner-name').dispatchEvent(new Event('input'));
fixture.nativeElement.querySelector('#description').dispatchEvent(new Event('input'));
fixture.nativeElement.querySelector('#scanner-endpoint').dispatchEvent(new Event('input'));
fixture.nativeElement.querySelector('#scanner-authorization').dispatchEvent(new Event('input'));
// save button should not be disabled
expect(component.validForSaving).toBeTruthy();
fixture.nativeElement.querySelector('#scanner-name').value = "test3";
fixture.nativeElement.querySelector('#scanner-name').dispatchEvent(new Event('input'));
fixture.detectChanges();
expect(component.validForSaving).toBeTruthy();
el.click();
el.dispatchEvent(new Event('click'));
setTimeout(() => {
expect(component.opened).toBeFalsy();
}, 300);
});
it('test connection button should not be disabled', () => {
let nameInput = fixture.nativeElement.querySelector('#scanner-name');
nameInput.value = "test2";
nameInput.dispatchEvent(new Event('input'));
let urlInput = fixture.nativeElement.querySelector('#scanner-endpoint');
urlInput.value = "http://168.0.0.1";
urlInput.dispatchEvent(new Event('input'));
expect(component.canTestEndpoint).toBeTruthy();
let el = fixture.nativeElement.querySelector('#button-test');
el.click();
el.dispatchEvent(new Event('click'));
expect(component.checkBtnState).toBe(ClrLoadingState.LOADING);
setTimeout(() => {
expect(component.checkBtnState).toBe(ClrLoadingState.SUCCESS);
}, 300);
});
it('add button should not be disabled', () => {
fixture.nativeElement.querySelector('#scanner-name').value = "test2";
fixture.nativeElement.querySelector('#scanner-endpoint').value = "http://168.0.0.1";
let authInput = fixture.nativeElement.querySelector('#scanner-authorization');
authInput.value = "Basic";
authInput.dispatchEvent(new Event('change'));
let usernameInput = fixture.nativeElement.querySelector('#scanner-username');
let passwordInput = fixture.nativeElement.querySelector('#scanner-password');
expect(usernameInput).toBeTruthy();
expect(passwordInput).toBeTruthy();
usernameInput.value = "user";
passwordInput.value = "12345";
usernameInput.dispatchEvent(new Event('input'));
passwordInput.dispatchEvent(new Event('input'));
let el = fixture.nativeElement.querySelector('#button-add');
expect(component.valid).toBeFalsy();
el.click();
el.dispatchEvent(new Event('click'));
setTimeout(() => {
expect(component.opened).toBeFalsy();
}, 300);
});
});

View File

@ -0,0 +1,229 @@
import { Component, EventEmitter, Output, ViewChild } from '@angular/core';
import { Scanner } from "../scanner";
import { NewScannerFormComponent } from "../new-scanner-form/new-scanner-form.component";
import { ConfigScannerService } from "../config-scanner.service";
import { ClrLoadingState } from "@clr/angular";
import { finalize } from "rxjs/operators";
import { InlineAlertComponent } from "../../../shared/inline-alert/inline-alert.component";
import { MessageHandlerService } from "../../../shared/message-handler/message-handler.service";
@Component({
selector: "new-scanner-modal",
templateUrl: "new-scanner-modal.component.html",
styleUrls: ['../../../common.scss']
})
export class NewScannerModalComponent {
testMap: any = {};
opened: boolean = false;
@Output() notify = new EventEmitter<Scanner>();
@ViewChild(NewScannerFormComponent, {static: true})
newScannerFormComponent: NewScannerFormComponent;
checkBtnState: ClrLoadingState = ClrLoadingState.DEFAULT;
saveBtnState: ClrLoadingState = ClrLoadingState.DEFAULT;
onTesting: boolean = false;
onSaving: boolean = false;
isEdit: boolean = false;
originValue: any;
uid: string;
editScanner: Scanner;
@ViewChild(InlineAlertComponent, { static: false }) inlineAlert: InlineAlertComponent;
constructor(
private configScannerService: ConfigScannerService,
private msgHandler: MessageHandlerService
) {}
open(): void {
// reset
this.opened = true;
this.inlineAlert.close();
this.testMap = {};
this.newScannerFormComponent.showEndpointError = false;
this.newScannerFormComponent.newScannerForm.reset({auth: "None"});
}
close(): void {
this.opened = false;
}
create(): void {
this.onSaving = true;
this.saveBtnState = ClrLoadingState.LOADING;
let scanner: Scanner = new Scanner();
let value = this.newScannerFormComponent.newScannerForm.value;
scanner.name = value.name;
scanner.description = value.description;
scanner.url = value.url;
if (value.auth === "None") {
scanner.auth = "";
} else if (value.auth === "Basic") {
scanner.auth = value.auth;
scanner.access_credential = value.accessCredential.username + ":" + value.accessCredential.password;
} else if (value.auth === "APIKey") {
scanner.auth = value.auth;
scanner.access_credential = value.accessCredential.apiKey;
} else {
scanner.auth = value.auth;
scanner.access_credential = value.accessCredential.token;
}
scanner.skip_certVerify = !!value.skipCertVerify;
this.configScannerService.addScanner(scanner)
.pipe(finalize(() => this.onSaving = false))
.subscribe(response => {
this.close();
this.msgHandler.showSuccess("ADD_SUCCESS");
this.notify.emit();
this.saveBtnState = ClrLoadingState.SUCCESS;
}, error => {
this.inlineAlert.showInlineError(error);
this.saveBtnState = ClrLoadingState.ERROR;
});
}
get hasPassedTest(): boolean {
return this.testMap[this.newScannerFormComponent.newScannerForm.get('url').value];
}
get canTestEndpoint(): boolean {
return !this.onTesting
&& this.newScannerFormComponent
&& !this.newScannerFormComponent.checkOnGoing
&& this.newScannerFormComponent.newScannerForm.get('name').valid
&& !this.newScannerFormComponent.checkEndpointOnGoing
&& this.newScannerFormComponent.newScannerForm.get('url').valid;
}
get valid(): boolean {
if (this.onSaving
|| this.newScannerFormComponent.isNameExisting
|| this.newScannerFormComponent.isEndpointUrlExisting
|| this.onTesting
|| !this.newScannerFormComponent
|| this.newScannerFormComponent.checkOnGoing
|| this.newScannerFormComponent.checkEndpointOnGoing) {
return false;
}
if (this.newScannerFormComponent.newScannerForm.get('name').invalid) {
return false;
}
if (this.newScannerFormComponent.newScannerForm.get('url').invalid) {
return false;
}
if (this.newScannerFormComponent.newScannerForm.get('auth').value === "Basic") {
return this.newScannerFormComponent.newScannerForm.get('accessCredential').get('username').valid
&& this.newScannerFormComponent.newScannerForm.get('accessCredential').get('password').valid;
}
if (this.newScannerFormComponent.newScannerForm.get('auth').value === "Bearer") {
return this.newScannerFormComponent.newScannerForm.get('accessCredential').get('token').valid;
}
if (this.newScannerFormComponent.newScannerForm.get('auth').value === "APIKey") {
return this.newScannerFormComponent.newScannerForm.get('accessCredential').get('apiKey').valid;
}
return true;
}
get validForSaving() {
return this.valid && this.hasChange();
}
hasChange(): boolean {
if (this.originValue.name !== this.newScannerFormComponent.newScannerForm.get('name').value) {
return true;
}
if (this.originValue.description !== this.newScannerFormComponent.newScannerForm.get('description').value) {
return true;
}
if (this.originValue.url !== this.newScannerFormComponent.newScannerForm.get('url').value) {
return true;
}
if (this.originValue.auth !== this.newScannerFormComponent.newScannerForm.get('auth').value) {
return true;
}
if (this.originValue.skipCertVerify !== this.newScannerFormComponent.newScannerForm.get('skipCertVerify').value) {
return true;
}
if (this.originValue.auth === "Basic") {
if (this.originValue.accessCredential.username !==
this.newScannerFormComponent.newScannerForm.get('accessCredential').get('username').value) {
return true;
}
if (this.originValue.accessCredential.password !==
this.newScannerFormComponent.newScannerForm.get('accessCredential').get('password').value) {
return true;
}
}
if (this.originValue.auth === "Bearer") {
if (this.originValue.accessCredential.token !==
this.newScannerFormComponent.newScannerForm.get('accessCredential').get('token').value) {
return true;
}
}
if (this.originValue.auth === "APIKey") {
if (this.originValue.accessCredential.apiKey !==
this.newScannerFormComponent.newScannerForm.get('accessCredential').get('apiKey').value) {
return true;
}
}
return false;
}
onTestEndpoint() {
this.onTesting = true;
this.checkBtnState = ClrLoadingState.LOADING;
let scanner: Scanner = new Scanner();
let value = this.newScannerFormComponent.newScannerForm.value;
scanner.name = value.name;
scanner.description = value.description;
scanner.url = value.url;
if (value.auth === "None") {
scanner.auth = "";
} else if (value.auth === "Basic") {
scanner.auth = value.auth;
scanner.access_credential = value.accessCredential.username + ":" + value.accessCredential.password;
} else if (value.auth === "APIKey") {
scanner.auth = value.auth;
scanner.access_credential = value.accessCredential.apiKey;
} else {
scanner.auth = value.auth;
scanner.access_credential = value.accessCredential.token;
}
scanner.skip_certVerify = !!value.skipCertVerify;
this.configScannerService.testEndpointUrl(scanner)
.pipe(finalize(() => this.onTesting = false))
.subscribe(response => {
this.inlineAlert.showInlineSuccess({
message: "TEST_PASS"
});
this.checkBtnState = ClrLoadingState.SUCCESS;
this.testMap[this.newScannerFormComponent.newScannerForm.get('url').value] = true;
}, error => {
this.inlineAlert.showInlineError({
message: "TEST_FAILED"
});
this.checkBtnState = ClrLoadingState.ERROR;
});
}
save() {
this.onSaving = true;
this.saveBtnState = ClrLoadingState.LOADING;
let value = this.newScannerFormComponent.newScannerForm.value;
this.editScanner.name = value.name;
this.editScanner.description = value.description;
this.editScanner.url = value.url;
if (value.auth === "None") {
this.editScanner.auth = "";
} else if (value.auth === "Basic") {
this.editScanner.auth = value.auth;
this.editScanner.access_credential = value.accessCredential.username + ":" + value.accessCredential.password;
} else if (value.auth === "APIKey") {
this.editScanner.auth = value.auth;
this.editScanner.access_credential = value.accessCredential.apiKey;
} else {
this.editScanner.auth = value.auth;
this.editScanner.access_credential = value.accessCredential.token;
}
this.editScanner.skip_certVerify = !!value.skipCertVerify;
this.editScanner.uuid = this.uid;
this.configScannerService.updateScanner(this.editScanner)
.pipe(finalize(() => this.onSaving = false))
.subscribe(response => {
this.close();
this.msgHandler.showSuccess("UPDATE_SUCCESS");
this.notify.emit();
this.saveBtnState = ClrLoadingState.SUCCESS;
}, error => {
this.inlineAlert.showInlineError(error);
this.saveBtnState = ClrLoadingState.ERROR;
});
}
}

View File

@ -0,0 +1,16 @@
export class ScannerMetadata {
scanner?: {
name?: string;
vendor?: string;
version?: string;
};
capabilities?: [{
consumes_mime_types?: Array<string>;
produces_mime_types?: Array<string>;
}];
properties?: {
[key: string]: string;
};
constructor() {
}
}

View File

@ -0,0 +1,60 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { BrowserAnimationsModule } from "@angular/platform-browser/animations";
import { ClarityModule } from "@clr/angular";
import { SharedModule } from "../../../shared/shared.module";
import { ConfigScannerService } from "../config-scanner.service";
import { of } from "rxjs";
import { ScannerMetadataComponent } from "./scanner-metadata.component";
import { ErrorHandler } from "@harbor/ui";
describe('ScannerMetadataComponent', () => {
let mockScannerMetadata = {
scanner: {
name: 'test1',
vendor: 'clair',
version: '1.0.1',
},
capabilities: [{
consumes_mime_types: ['consumes_mime_types'],
produces_mime_types: ['consumes_mime_types']
}]
};
let component: ScannerMetadataComponent;
let fixture: ComponentFixture<ScannerMetadataComponent>;
let fakedConfigScannerService = {
getScannerMetadata() {
return of(mockScannerMetadata);
}
};
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [
SharedModule,
BrowserAnimationsModule,
ClarityModule,
],
declarations: [
ScannerMetadataComponent
],
providers: [
ErrorHandler,
{ provide: ConfigScannerService, useValue: fakedConfigScannerService },
]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(ScannerMetadataComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should get metadata', () => {
fixture.whenStable().then(() => {
let el: HTMLElement = fixture.nativeElement.querySelector('#scannerMetadata-name');
expect(el.textContent).toEqual('test1');
});
});
});

View File

@ -0,0 +1,50 @@
import {
Component, Inject, Input, LOCALE_ID,
OnInit
} from "@angular/core";
import { ConfigScannerService } from "../config-scanner.service";
import { finalize } from "rxjs/operators";
import { ErrorHandler } from "@harbor/ui";
import { ScannerMetadata } from "../scanner-metadata";
import { DatePipe } from "@angular/common";
@Component({
selector: 'scanner-metadata',
templateUrl: 'scanner-metadata.html',
styleUrls: ['./scanner-metadata.scss']
})
export class ScannerMetadataComponent implements OnInit {
@Input() uid: string;
loading: boolean = false;
scannerMetadata: ScannerMetadata;
constructor(private configScannerService: ConfigScannerService,
private errorHandler: ErrorHandler,
@Inject(LOCALE_ID) private _locale: string) {
}
ngOnInit(): void {
this.loading = true;
this.configScannerService.getScannerMetadata(this.uid)
.pipe(finalize(() => this.loading = false))
.subscribe(response => {
this.scannerMetadata = response;
}, error => {
this.errorHandler.error(error);
});
}
parseDate(str: string): string {
try {
if (str === new Date(str).toISOString()) {
return new DatePipe(this._locale).transform(str, 'short');
}
} catch (e) {
return str;
}
return str;
}
toString(arr: string[]) {
if (arr && arr.length > 0) {
return "[" + arr.join(" , ") + "]";
}
return arr;
}
}

View File

@ -0,0 +1,31 @@
<div [clrLoading]="loading" class="ml-2">
<div class="clr-control-label">{{'SCANNER.SCANNER_COLON' | translate}}</div>
<div class="ml-1">
<span>{{'SCANNER.NAME_COLON' | translate}}</span>
<span id="scannerMetadata-name" class="ml-1">{{scannerMetadata?.scanner?.name}}</span>
</div>
<div class="ml-1">
<span>{{'SCANNER.VENDOR_COLON' | translate}}</span>
<span class="ml-1">{{scannerMetadata?.scanner?.vendor}}</span>
</div>
<div class="ml-1">
<span>{{'SCANNER.VERSION_COLON' | translate}}</span>
<span class="ml-1">{{scannerMetadata?.scanner?.version}}</span>
</div>
<div class="clr-control-label">{{'SCANNER.CAPABILITIES' | translate}}</div>
<div class="ml-1">
<span>{{'SCANNER.CONSUMES_MIME_TYPES_COLON' | translate}}</span>
<span class="ml-1" [innerHTML]="toString(scannerMetadata?.capabilities[0]?.consumes_mime_types)"></span>
</div>
<div class="ml-1">
<span>{{'SCANNER.PRODUCTS_MIME_TYPES_COLON' | translate}}</span>
<span class="ml-1" [innerHTML]="toString(scannerMetadata?.capabilities[0]?.produces_mime_types)"></span>
</div>
<div class="clr-control-label">{{'SCANNER.PROPERTIES' | translate}}</div>
<div class="ml-1" *ngFor="let item of scannerMetadata?.properties | keyvalue">
<span>{{item?.key}}:</span>
<span class="ml-1">{{parseDate(item?.value)}}</span>
</div>
</div>

View File

@ -0,0 +1,19 @@
export class Scanner {
name?: string;
description?: string;
uuid?: string;
url?: string;
auth?: string;
access_credential?: string;
scanner?: string;
disabled?: boolean;
is_default?: boolean;
skip_certVerify?: boolean;
create_time?: any;
update_time?: any;
vendor?: string;
version?: string;
health?: boolean;
constructor() {
}
}

View File

@ -62,6 +62,7 @@ import { LicenseComponent } from './license/license.component';
import { SummaryComponent } from './project/summary/summary.component';
import { TagRetentionComponent } from './project/tag-retention/tag-retention.component';
import { USERSTATICPERMISSION } from '@harbor/ui';
import { ScannerComponent } from "./project/scanner/scanner.component";
const harborRoutes: Routes = [
{ path: '', redirectTo: 'harbor', pathMatch: 'full' },
@ -285,6 +286,16 @@ const harborRoutes: Routes = [
}
},
component: WebhookComponent
},
{
path: 'scanner',
data: {
permissionParam: {
resource: USERSTATICPERMISSION.CONFIGURATION.KEY,
action: USERSTATICPERMISSION.CONFIGURATION.VALUE.READ
}
},
component: ScannerComponent
}
]
},

View File

@ -31,6 +31,9 @@
<li class="nav-item" *ngIf="hasWebhookListPermission">
<a class="nav-link" routerLink="webhook" routerLinkActive="active">{{'PROJECT_DETAIL.WEBHOOKS' | translate}}</a>
</li>
<li class="nav-item" *ngIf="isSessionValid && (hasConfigurationListPermission)">
<a class="nav-link" routerLink="scanner" routerLinkActive="active">{{'SCANNER.SCANNER' | translate}}</a>
</li>
<li class="nav-item" *ngIf="isSessionValid && (hasConfigurationListPermission)">
<a class="nav-link" routerLink="configs" routerLinkActive="active">{{'PROJECT_DETAIL.CONFIG' | translate}}</a>
</li>

View File

@ -47,6 +47,8 @@ import { WebhookService } from './webhook/webhook.service';
import { WebhookComponent } from './webhook/webhook.component';
import { AddWebhookComponent } from './webhook/add-webhook/add-webhook.component';
import { AddWebhookFormComponent } from './webhook/add-webhook-form/add-webhook-form.component';
import { ScannerComponent } from "./scanner/scanner.component";
import { ConfigScannerService } from "../config/scanner/config-scanner.service";
@NgModule({
imports: [
@ -76,9 +78,10 @@ import { AddWebhookFormComponent } from './webhook/add-webhook-form/add-webhook-
WebhookComponent,
AddWebhookComponent,
AddWebhookFormComponent,
ScannerComponent,
],
exports: [ProjectComponent, ListProjectComponent],
providers: [ProjectRoutingResolver, MemberService, RobotService, TagRetentionService, WebhookService]
providers: [ProjectRoutingResolver, MemberService, RobotService, TagRetentionService, WebhookService, ConfigScannerService]
})
export class ProjectModule {

View File

@ -0,0 +1,97 @@
<div *ngIf="loading" class="clr-row mt-2 center">
<span class="spinner spinner-md"></span>
</div>
<div *ngIf="!loading" class="clr-form clr-form-horizontal">
<div class="clr-form-control">
<label class="clr-control-label name">{{'SCANNER.SCANNER' | translate}}</label>
<div class="clr-control-container">
<button *ngIf="scanners && scanners.length > 0" id="edit-scanner" class="btn btn-link edit" (click)="open()">{{'SCANNER.EDIT' | translate}}</button>
<label *ngIf="!(scanners && scanners.length > 0)" class="name">{{'SCANNER.NOT_AVAILABLE' | translate}}</label>
</div>
</div>
<ng-container *ngIf="scanner">
<div class="clr-form-control">
<label for="select-full" class="clr-control-label">{{'SCANNER.NAME' | translate}}</label>
<div class="clr-control-container">
<div class="clr-input-wrapper">
<div class="clr-input-wrapper">
<span id="scanner-name" class="scanner-name">{{scanner?.name}}</span>
<span *ngIf="scanner?.disabled" class="label label-warning ml-1">{{'SCANNER.DISABLED' | translate}}</span>
<span *ngIf="scanner?.health" class="label label-success ml-1">{{'SCANNER.HEALTHY' | translate}}</span>
<span *ngIf="!scanner?.health" class="label label-danger ml-1">{{'SCANNER.Unhealthy' | translate}}</span>
</div>
</div>
</div>
</div>
<div class="clr-form-control">
<label class="clr-control-label">{{'SCANNER.ENDPOINT' | translate}}</label>
<div class="clr-control-container">
<div class="clr-input-wrapper">
<input [ngModel]="scanner?.url" readonly class="clr-input width-240" type="text" id="scanner-endpoint"
autocomplete="off">
</div>
</div>
</div>
<div class="clr-form-control" *ngIf="scanner?.scanner">
<label class="clr-control-label">{{'SCANNER.ADAPTER' | translate}}</label>
<div class="clr-control-container">
<div class="clr-input-wrapper">
<input [ngModel]="scanner?.scanner" readonly class="clr-input width-240" type="text" id="scanner-scanner"
autocomplete="off">
</div>
</div>
</div>
<div class="clr-form-control" *ngIf="scanner?.vendor">
<label class="clr-control-label">{{'SCANNER.VENDOR' | translate}}</label>
<div class="clr-control-container">
<div class="clr-input-wrapper">
<input [ngModel]="scanner?.vendor" readonly class="clr-input width-240" type="text" id="scanner-vendor"
autocomplete="off">
</div>
</div>
</div>
<div class="clr-form-control" *ngIf="scanner?.version">
<label class="clr-control-label">{{'SCANNER.VERSION' | translate}}</label>
<div class="clr-control-container">
<div class="clr-input-wrapper">
<input [ngModel]="scanner?.version" readonly class="clr-input width-240" type="text" id="scanner-version"
autocomplete="off">
</div>
</div>
</div>
</ng-container>
</div>
<clr-modal [clrModalSize]="'xl'" [(clrModalOpen)]="opened" [clrModalStaticBackdrop]="true" [clrModalClosable]="false">
<h3 class="modal-title">{{'SCANNER.SELECT_SCANNER' | translate}}</h3>
<div class="modal-body body-format">
<inline-alert class="modal-title"></inline-alert>
<clr-datagrid [(clrDgSingleSelected)]="selectedScanner">
<clr-dg-column [clrDgField]="'name'">{{'SCANNER.NAME' | translate}}</clr-dg-column>
<clr-dg-column [clrDgField]="'url'">{{'SCANNER.ENDPOINT' | translate}}</clr-dg-column>
<clr-dg-column>{{'SCANNER.HEALTH' | translate}}</clr-dg-column>
<clr-dg-column>{{'SCANNER.DEFAULT' | translate}}</clr-dg-column>
<clr-dg-column>{{'SCANNER.AUTH' | translate}}</clr-dg-column>
<clr-dg-row *clrDgItems="let scanner of scanners" [clrDgItem]="scanner">
<clr-dg-cell>{{scanner.name}}</clr-dg-cell>
<clr-dg-cell>{{scanner.url}}</clr-dg-cell>
<clr-dg-cell>
<span *ngIf="scanner.health" class="label label-success">{{'SCANNER.HEALTHY' | translate}}</span>
<span *ngIf="!scanner.health" class="label label-danger">{{'SCANNER.UNHEALTHY' | translate}}</span>
</clr-dg-cell>
<clr-dg-cell>
<span *ngIf="scanner.is_default" class="label label-info">{{scanner.is_default}}</span>
<span *ngIf="!scanner.is_default">{{scanner.is_default}}</span>
</clr-dg-cell>
<clr-dg-cell>{{scanner.auth?scanner.auth:('SCANNER.NONE'|translate)}}</clr-dg-cell>
</clr-dg-row>
<clr-dg-footer>
<span *ngIf="scanners?.length > 0">1 - {{scanners?.length}} {{'WEBHOOK.OF' | translate}} </span> {{scanners?.length}} {{'WEBHOOK.ITEMS' | translate}}
<clr-dg-pagination [clrDgPageSize]="10"></clr-dg-pagination>
</clr-dg-footer>
</clr-datagrid>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline" (click)="close()">{{'BUTTON.CANCEL' | translate}}</button>
<button type="button" [clrLoading]="saveBtnState" class="btn btn-primary" [disabled]="!valid" (click)="save()">{{'BUTTON.OK' | translate}}</button>
</div>
</clr-modal>

View File

@ -0,0 +1,20 @@
.scanner-name {
height: 1rem;
color: #000;
display: inline-block;
padding: 0 .25rem;
max-height: 1rem;
font-size: .541667rem;
}
.edit {
margin-top: -5px;
margin-left: -8px;
font-size: 0.58rem;
}
.width-240 {
width: 240px;
}
.center {
justify-content: center;
align-items: center;
}

View File

@ -0,0 +1,77 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { BrowserAnimationsModule } from "@angular/platform-browser/animations";
import { ClarityModule } from "@clr/angular";
import { of } from "rxjs";
import { TranslateService } from "@ngx-translate/core";
import { MessageHandlerService } from "../../shared/message-handler/message-handler.service";
import { ErrorHandler } from "@harbor/ui";
import { ScannerComponent } from "./scanner.component";
import { ConfigScannerService } from "../../config/scanner/config-scanner.service";
import { SharedModule } from "../../shared/shared.module";
import { ActivatedRoute } from "@angular/router";
xdescribe('ScannerComponent', () => {
let mockScanner1 = {
name: 'test1',
description: 'just a sample',
version: '1.0.0',
url: 'http://168.0.0.1'
};
let component: ScannerComponent;
let fixture: ComponentFixture<ScannerComponent>;
let fakedConfigScannerService = {
getProjectScanner() {
return of(mockScanner1);
},
getScanners() {
return of([mockScanner1]);
}
};
let fakedRoute = {
snapshot: {
parent: {
params: {
id: 1
}
}
}
};
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [
SharedModule,
BrowserAnimationsModule,
ClarityModule,
],
declarations: [ ScannerComponent ],
providers: [
TranslateService,
MessageHandlerService,
ErrorHandler,
{provide: ActivatedRoute, useValue: fakedRoute},
{ provide: ConfigScannerService, useValue: fakedConfigScannerService },
]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(ScannerComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should creat', () => {
expect(component).toBeTruthy();
});
it('should get scanner and render', () => {
fixture.whenStable().then(() => {
let el: HTMLElement = fixture.nativeElement.querySelector('#scanner-name');
expect(el.textContent.trim).toEqual('test1');
});
});
it('should get scanners and edit button is available', () => {
fixture.whenStable().then(() => {
let el: HTMLElement = fixture.nativeElement.querySelector('#edit-scanner');
expect(el).toBeTruthy();
});
});
});

View File

@ -0,0 +1,108 @@
// Copyright Project Harbor Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component, OnInit, ViewChild } from "@angular/core";
import { ConfigScannerService } from "../../config/scanner/config-scanner.service";
import { Scanner } from "../../config/scanner/scanner";
import { MessageHandlerService } from "../../shared/message-handler/message-handler.service";
import { ErrorHandler } from "@harbor/ui";
import { ActivatedRoute } from "@angular/router";
import { ClrLoadingState } from "@clr/angular";
import { InlineAlertComponent } from "../../shared/inline-alert/inline-alert.component";
import { finalize } from "rxjs/operators";
@Component({
selector: 'scanner',
templateUrl: './scanner.component.html',
styleUrls: ['./scanner.component.scss']
})
export class ScannerComponent implements OnInit {
loading: boolean = false;
scanners: Scanner[];
scanner: Scanner;
projectId: number;
opened: boolean = false;
selectedScanner: Scanner;
saveBtnState: ClrLoadingState = ClrLoadingState.DEFAULT;
onSaving: boolean = false;
@ViewChild(InlineAlertComponent, { static: false }) inlineAlert: InlineAlertComponent;
constructor( private configScannerService: ConfigScannerService,
private msgHandler: MessageHandlerService,
private errorHandler: ErrorHandler,
private route: ActivatedRoute,
) {
}
ngOnInit() {
this.projectId = +this.route.snapshot.parent.params['id'];
this.init();
}
init() {
this.getScanner();
this.getScanners();
}
getScanner() {
this.configScannerService.getProjectScanner(this.projectId)
.subscribe(response => {
if (response && "{}" !== JSON.stringify(response)) {
this.scanner = response;
}
}, error => {
this.errorHandler.error(error);
});
}
getScanners() {
this.loading = true;
this.configScannerService.getScanners()
.pipe(finalize(() => this.loading = false))
.subscribe(response => {
if (response && response.length > 0) {
this.scanners = response.filter(scanner => {
return !scanner.disabled;
});
}
}, error => {
this.errorHandler.error(error);
});
}
close() {
this.opened = false;
this.selectedScanner = null;
}
open() {
this.opened = true;
this.inlineAlert.close();
this.scanners.forEach(s => {
if (this.scanner && s.uuid === this.scanner.uuid) {
this.selectedScanner = s;
}
});
}
get valid(): boolean {
return this.selectedScanner
&& !(this.scanner && this.scanner.uuid === this.selectedScanner.uuid);
}
save() {
this.saveBtnState = ClrLoadingState.LOADING;
this.configScannerService.updateProjectScanner(this.projectId, this.selectedScanner.uuid)
.subscribe(response => {
this.close();
this.msgHandler.showSuccess('Update Success');
this.getScanner();
this.saveBtnState = ClrLoadingState.SUCCESS;
}, error => {
this.inlineAlert.showInlineError(error);
this.saveBtnState = ClrLoadingState.ERROR;
});
}
}

View File

@ -41,7 +41,8 @@ export const enum ConfirmationTargets {
CONFIG_TAB,
HELM_CHART,
HELM_CHART_VERSION,
WEBHOOK
WEBHOOK,
SCANNER
}
export const enum ActionType {

View File

@ -922,11 +922,10 @@
},
"VULNERABILITY": {
"STATE": {
"STOPPED": "Not Scanned",
"OTHER_STATUS": "Not Scanned",
"QUEUED": "Queued",
"ERROR": "View Log",
"SCANNING": "Scanning",
"UNKNOWN": "Unknown"
"SCANNING": "Scanning"
},
"GRID": {
"PLACEHOLDER": "We couldn't find any scanning results!",
@ -947,6 +946,7 @@
"TOOLTIPS_TITLE_ZERO": "No recognizable vulnerability package found"
},
"SEVERITY": {
"CRITICAL": "Critical",
"HIGH": "High",
"MEDIUM": "Medium",
"LOW": "Low",
@ -1224,6 +1224,62 @@
"DAYS_LARGE": "Parameter \"DAYS\" is too large",
"EXECUTION_TYPE": "Execution Type",
"ACTION": "ACTION"
},
"SCANNER": {
"DELETION_SUMMARY": "Do you want to delete scanner {{param}}?",
"SKIP_CERT_VERIFY": "Check this box to skip certificate verification when the remote registry uses a self-signed or untrusted certificate.",
"NAME": "Name",
"NAME_EXISTS": "Name already exists",
"NAME_REQUIRED": "Name is required",
"NAME_REX": "Name should be at least 2 characters long with lower case characters, numbers and ._- and must be start with characters or numbers.",
"DESCRIPTION": "Description",
"ENDPOINT": "Endpoint",
"ENDPOINT_EXISTS": "EndpointUrl already exists",
"ENDPOINT_REQUIRED": "EndpointUrl is required",
"ILLEGAL_ENDPOINT": "EndpointUrl is illegal",
"AUTH": "Authorization",
"NONE": "None",
"BASIC": "Basic",
"BEARER": "Bearer",
"API_KEY": "APIKey",
"USERNAME": "Username",
"USERNAME_REQUIRED": "Username is required",
"PASSWORD": "Password",
"PASSWORD_REQUIRED": "Password is required",
"TOKEN": "Token",
"TOKEN_REQUIRED": "Token is required",
"API_KEY_REQUIRED": "APIKey is required",
"SKIP": "Skip Certificate Verification",
"ADD_SCANNER": "Add Scanner",
"EDIT_SCANNER": "Edit Scanner",
"TEST_CONNECTION": "TEST CONNECTION",
"ADD_SUCCESS": "Added successfully",
"TEST_PASS": "Test passed",
"TEST_FAILED": "Test failed",
"UPDATE_SUCCESS": "Updated successfully",
"SCANNER_COLON": "Scanner:",
"NAME_COLON": "Name:",
"VENDOR_COLON": "Vendor:",
"VERSION_COLON": "Version:",
"CAPABILITIES": "Capabilities",
"CONSUMES_MIME_TYPES_COLON": "Consumes Mime Types:",
"PRODUCTS_MIME_TYPES_COLON": "Produces Mime Types:",
"PROPERTIES": "Properties",
"NEW_SCANNER": "NEW SCANNER",
"SET_AS_DEFAULT": "SET AS DEFAULT",
"HEALTH": "Health",
"DISABLED": "Disabled",
"NO_SCANNER": "Can not find any scanner",
"DEFAULT": "Default",
"HEALTHY": "Healthy",
"UNHEALTHY": "Unhealthy",
"SCANNERS": "Scanners",
"SCANNER": "Scanner",
"EDIT": "Edit",
"NOT_AVAILABLE": "Not Available",
"ADAPTER": "Adapter",
"VENDOR": "Vendor",
"VERSION": "Version",
"SELECT_SCANNER": "Select Scanner"
}
}

View File

@ -921,11 +921,10 @@
},
"VULNERABILITY": {
"STATE": {
"STOPPED": "Not Scanned",
"OTHER_STATUS": "Not Scanned",
"QUEUED": "Queued",
"ERROR": "View Log",
"SCANNING": "Scanning",
"UNKNOWN": "Unknown"
"SCANNING": "Scanning"
},
"GRID": {
"PLACEHOLDER": "We couldn't find any scanning results!",
@ -946,6 +945,7 @@
"TOOLTIPS_TITLE_ZERO": "No se encontró ningún paquete de vulnerabilidad reconocible"
},
"SEVERITY": {
"CRITICAL": "Critical",
"HIGH": "High",
"MEDIUM": "Medium",
"LOW": "Low",
@ -1221,6 +1221,62 @@
"DAYS_LARGE": "Parameter \"DAYS\" is too large",
"EXECUTION_TYPE": "Execution Type",
"ACTION": "ACTION"
},
"SCANNER": {
"DELETION_SUMMARY": "Do you want to delete scanner {{param}}?",
"SKIP_CERT_VERIFY": "Check this box to skip certificate verification when the remote registry uses a self-signed or untrusted certificate.",
"NAME": "Name",
"NAME_EXISTS": "Name already exists",
"NAME_REQUIRED": "Name is required",
"NAME_REX": "Name should be at least 2 characters long with lower case characters, numbers and ._- and must be start with characters or numbers.",
"DESCRIPTION": "Description",
"ENDPOINT": "Endpoint",
"ENDPOINT_EXISTS": "EndpointUrl already exists",
"ENDPOINT_REQUIRED": "EndpointUrl is required",
"ILLEGAL_ENDPOINT": "EndpointUrl is illegal",
"AUTH": "Authorization",
"NONE": "None",
"BASIC": "Basic",
"BEARER": "Bearer",
"API_KEY": "APIKey",
"USERNAME": "Username",
"USERNAME_REQUIRED": "Username is required",
"PASSWORD": "Password",
"PASSWORD_REQUIRED": "Password is required",
"TOKEN": "Token",
"TOKEN_REQUIRED": "Token is required",
"API_KEY_REQUIRED": "APIKey is required",
"SKIP": "Skip Certificate Verification",
"ADD_SCANNER": "Add Scanner",
"EDIT_SCANNER": "Edit Scanner",
"TEST_CONNECTION": "TEST CONNECTION",
"ADD_SUCCESS": "Added successfully",
"TEST_PASS": "Test passed",
"TEST_FAILED": "Test failed",
"UPDATE_SUCCESS": "Updated successfully",
"SCANNER_COLON": "Scanner:",
"NAME_COLON": "Name:",
"VENDOR_COLON": "Vendor:",
"VERSION_COLON": "Version:",
"CAPABILITIES": "Capabilities",
"CONSUMES_MIME_TYPES_COLON": "Consumes Mime Types:",
"PRODUCTS_MIME_TYPES_COLON": "Produces Mime Types:",
"PROPERTIES": "Properties",
"NEW_SCANNER": "NEW SCANNER",
"SET_AS_DEFAULT": "SET AS DEFAULT",
"HEALTH": "Health",
"DISABLED": "Disabled",
"NO_SCANNER": "Can not find any scanner",
"DEFAULT": "Default",
"HEALTHY": "Healthy",
"UNHEALTHY": "Unhealthy",
"SCANNERS": "Scanners",
"SCANNER": "Scanner",
"EDIT": "Edit",
"NOT_AVAILABLE": "Not Available",
"ADAPTER": "Adapter",
"VENDOR": "Vendor",
"VERSION": "Version",
"SELECT_SCANNER": "Select Scanner"
}
}

View File

@ -895,11 +895,10 @@
},
"VULNERABILITY": {
"STATE": {
"STOPPED": "Non Analysé",
"OTHER_STATUS": "Non Analysé",
"QUEUED": "En fil d'attente",
"ERROR": "Voir le Log",
"SCANNING": "En cours d'analyse",
"UNKNOWN": "Inconnu"
"SCANNING": "En cours d'analyse"
},
"GRID": {
"PLACEHOLDER": "Nous n'avons pas trouvé de résultats d'analyse !",
@ -920,6 +919,7 @@
"TOOLTIPS_TITLE_ZERO": "Aucun paquet de vulnérabilité connue trouvé"
},
"SEVERITY": {
"CRITICAL": "Critique",
"HIGH": "Haut",
"MEDIUM": "Moyen",
"LOW": "Bas",
@ -1193,6 +1193,62 @@
"DAYS_LARGE": "Parameter \"DAYS\" is too large",
"EXECUTION_TYPE": "Execution Type",
"ACTION": "ACTION"
},
"SCANNER": {
"DELETION_SUMMARY": "Do you want to delete scanner {{param}}?",
"SKIP_CERT_VERIFY": "Check this box to skip certificate verification when the remote registry uses a self-signed or untrusted certificate.",
"NAME": "Name",
"NAME_EXISTS": "Name already exists",
"NAME_REQUIRED": "Name is required",
"NAME_REX": "Name should be at least 2 characters long with lower case characters, numbers and ._- and must be start with characters or numbers.",
"DESCRIPTION": "Description",
"ENDPOINT": "Endpoint",
"ENDPOINT_EXISTS": "EndpointUrl already exists",
"ENDPOINT_REQUIRED": "EndpointUrl is required",
"ILLEGAL_ENDPOINT": "EndpointUrl is illegal",
"AUTH": "Authorization",
"NONE": "None",
"BASIC": "Basic",
"BEARER": "Bearer",
"API_KEY": "APIKey",
"USERNAME": "Username",
"USERNAME_REQUIRED": "Username is required",
"PASSWORD": "Password",
"PASSWORD_REQUIRED": "Password is required",
"TOKEN": "Token",
"TOKEN_REQUIRED": "Token is required",
"API_KEY_REQUIRED": "APIKey is required",
"SKIP": "Skip Certificate Verification",
"ADD_SCANNER": "Add Scanner",
"EDIT_SCANNER": "Edit Scanner",
"TEST_CONNECTION": "TEST CONNECTION",
"ADD_SUCCESS": "Added successfully",
"TEST_PASS": "Test passed",
"TEST_FAILED": "Test failed",
"UPDATE_SUCCESS": "Updated successfully",
"SCANNER_COLON": "Scanner:",
"NAME_COLON": "Name:",
"VENDOR_COLON": "Vendor:",
"VERSION_COLON": "Version:",
"CAPABILITIES": "Capabilities",
"CONSUMES_MIME_TYPES_COLON": "Consumes Mime Types:",
"PRODUCTS_MIME_TYPES_COLON": "Produces Mime Types:",
"PROPERTIES": "Properties",
"NEW_SCANNER": "NEW SCANNER",
"SET_AS_DEFAULT": "SET AS DEFAULT",
"HEALTH": "Health",
"DISABLED": "Disabled",
"NO_SCANNER": "Can not find any scanner",
"DEFAULT": "Default",
"HEALTHY": "Healthy",
"UNHEALTHY": "Unhealthy",
"SCANNERS": "Scanners",
"SCANNER": "Scanner",
"EDIT": "Edit",
"NOT_AVAILABLE": "Not Available",
"ADAPTER": "Adapter",
"VENDOR": "Vendor",
"VERSION": "Version",
"SELECT_SCANNER": "Select Scanner"
}
}

View File

@ -916,11 +916,10 @@
},
"VULNERABILITY": {
"STATE": {
"STOPPED": "Não analisado",
"OTHER_STATUS": "Não analisado",
"QUEUED": "Solicitado",
"ERROR": "Visualizar Log",
"SCANNING": "Analisando",
"UNKNOWN": "Desconhecido"
"SCANNING": "Analisando"
},
"GRID": {
"PLACEHOLDER": "Não foi possível encontrar nenhum resultado de análise!",
@ -941,6 +940,7 @@
"TOOLTIPS_TITLE_ZERO": "Nenhum pacote vulnerável reconhecido foi encontrado"
},
"SEVERITY": {
"CRITICAL": "Crítico",
"HIGH": "Alta",
"MEDIUM": "Média",
"LOW": "Baixa",
@ -1218,7 +1218,63 @@
"DAYS_LARGE": "Parameter \"DAYS\" is too large",
"EXECUTION_TYPE": "Execution Type",
"ACTION": "ACTION"
},
"SCANNER": {
"DELETION_SUMMARY": "Do you want to delete scanner {{param}}?",
"SKIP_CERT_VERIFY": "Check this box to skip certificate verification when the remote registry uses a self-signed or untrusted certificate.",
"NAME": "Name",
"NAME_EXISTS": "Name already exists",
"NAME_REQUIRED": "Name is required",
"NAME_REX": "Name should be at least 2 characters long with lower case characters, numbers and ._- and must be start with characters or numbers.",
"DESCRIPTION": "Description",
"ENDPOINT": "Endpoint",
"ENDPOINT_EXISTS": "EndpointUrl already exists",
"ENDPOINT_REQUIRED": "EndpointUrl is required",
"ILLEGAL_ENDPOINT": "EndpointUrl is illegal",
"AUTH": "Authorization",
"NONE": "None",
"BASIC": "Basic",
"BEARER": "Bearer",
"API_KEY": "APIKey",
"USERNAME": "Username",
"USERNAME_REQUIRED": "Username is required",
"PASSWORD": "Password",
"PASSWORD_REQUIRED": "Password is required",
"TOKEN": "Token",
"TOKEN_REQUIRED": "Token is required",
"API_KEY_REQUIRED": "APIKey is required",
"SKIP": "Skip Certificate Verification",
"ADD_SCANNER": "Add Scanner",
"EDIT_SCANNER": "Edit Scanner",
"TEST_CONNECTION": "TEST CONNECTION",
"ADD_SUCCESS": "Added successfully",
"TEST_PASS": "Test passed",
"TEST_FAILED": "Test failed",
"UPDATE_SUCCESS": "Updated successfully",
"SCANNER_COLON": "Scanner:",
"NAME_COLON": "Name:",
"VENDOR_COLON": "Vendor:",
"VERSION_COLON": "Version:",
"CAPABILITIES": "Capabilities",
"CONSUMES_MIME_TYPES_COLON": "Consumes Mime Types:",
"PRODUCTS_MIME_TYPES_COLON": "Produces Mime Types:",
"PROPERTIES": "Properties",
"NEW_SCANNER": "NEW SCANNER",
"SET_AS_DEFAULT": "SET AS DEFAULT",
"HEALTH": "Health",
"DISABLED": "Disabled",
"NO_SCANNER": "Can not find any scanner",
"DEFAULT": "Default",
"HEALTHY": "Healthy",
"UNHEALTHY": "Unhealthy",
"SCANNERS": "Scanners",
"SCANNER": "Scanner",
"EDIT": "Edit",
"NOT_AVAILABLE": "Not Available",
"ADAPTER": "Adapter",
"VENDOR": "Vendor",
"VERSION": "Version",
"SELECT_SCANNER": "Select Scanner"
}
}

View File

@ -921,11 +921,10 @@
},
"VULNERABILITY": {
"STATE": {
"STOPPED": "Taranmadı",
"OTHER_STATUS": "Taranmadı",
"QUEUED": "Sıraya alındı",
"ERROR": "Günlüğü Görüntüle",
"SCANNING": "Taranıyor",
"UNKNOWN": "Bilinmeyen"
"SCANNING": "Taranıyor"
},
"GRID": {
"PLACEHOLDER": "Herhangi bir tarama sonucu bulamadık!",
@ -946,6 +945,7 @@
"TOOLTIPS_TITLE_ZERO": "Tanınabilir bir güvenlik açığı paketi bulunamadı"
},
"SEVERITY": {
"CRITICAL": "Kritik",
"HIGH": "Yüksek",
"MEDIUM": "Orta",
"LOW": "Düşük",
@ -1223,6 +1223,62 @@
"DAYS_LARGE": "Parameter \"DAYS\" is too large",
"EXECUTION_TYPE": "Execution Type",
"ACTION": "ACTION"
},
"SCANNER": {
"DELETION_SUMMARY": "Do you want to delete scanner {{param}}?",
"SKIP_CERT_VERIFY": "Check this box to skip certificate verification when the remote registry uses a self-signed or untrusted certificate.",
"NAME": "Name",
"NAME_EXISTS": "Name already exists",
"NAME_REQUIRED": "Name is required",
"NAME_REX": "Name should be at least 2 characters long with lower case characters, numbers and ._- and must be start with characters or numbers.",
"DESCRIPTION": "Description",
"ENDPOINT": "Endpoint",
"ENDPOINT_EXISTS": "EndpointUrl already exists",
"ENDPOINT_REQUIRED": "EndpointUrl is required",
"ILLEGAL_ENDPOINT": "EndpointUrl is illegal",
"AUTH": "Authorization",
"NONE": "None",
"BASIC": "Basic",
"BEARER": "Bearer",
"API_KEY": "APIKey",
"USERNAME": "Username",
"USERNAME_REQUIRED": "Username is required",
"PASSWORD": "Password",
"PASSWORD_REQUIRED": "Password is required",
"TOKEN": "Token",
"TOKEN_REQUIRED": "Token is required",
"API_KEY_REQUIRED": "APIKey is required",
"SKIP": "Skip Certificate Verification",
"ADD_SCANNER": "Add Scanner",
"EDIT_SCANNER": "Edit Scanner",
"TEST_CONNECTION": "TEST CONNECTION",
"ADD_SUCCESS": "Added successfully",
"TEST_PASS": "Test passed",
"TEST_FAILED": "Test failed",
"UPDATE_SUCCESS": "Updated successfully",
"SCANNER_COLON": "Scanner:",
"NAME_COLON": "Name:",
"VENDOR_COLON": "Vendor:",
"VERSION_COLON": "Version:",
"CAPABILITIES": "Capabilities",
"CONSUMES_MIME_TYPES_COLON": "Consumes Mime Types:",
"PRODUCTS_MIME_TYPES_COLON": "Produces Mime Types:",
"PROPERTIES": "Properties",
"NEW_SCANNER": "NEW SCANNER",
"SET_AS_DEFAULT": "SET AS DEFAULT",
"HEALTH": "Health",
"DISABLED": "Disabled",
"NO_SCANNER": "Can not find any scanner",
"DEFAULT": "Default",
"HEALTHY": "Healthy",
"UNHEALTHY": "Unhealthy",
"SCANNERS": "Scanners",
"SCANNER": "Scanner",
"EDIT": "Edit",
"NOT_AVAILABLE": "Not Available",
"ADAPTER": "Adapter",
"VENDOR": "Vendor",
"VERSION": "Version",
"SELECT_SCANNER": "Select Scanner"
}
}

View File

@ -921,11 +921,10 @@
},
"VULNERABILITY": {
"STATE": {
"STOPPED": "未扫描",
"OTHER_STATUS": "未扫描",
"QUEUED": "已入队列",
"ERROR": "查看日志",
"SCANNING": "扫描中",
"UNKNOWN": "未知"
"SCANNING": "扫描中"
},
"GRID": {
"PLACEHOLDER": "没有扫描结果!",
@ -946,6 +945,7 @@
"TOOLTIPS_TITLE_ZERO": "没有发现可识别的漏洞包"
},
"SEVERITY": {
"CRITICAL": "危急",
"HIGH": "严重",
"MEDIUM": "中等",
"LOW": "较低",
@ -1220,6 +1220,62 @@
"DAYS_LARGE": "参数“天数”太大",
"EXECUTION_TYPE": "执行类型",
"ACTION": "操作"
},
"SCANNER": {
"DELETION_SUMMARY": "确定删除扫描器{{param}}",
"SKIP_CERT_VERIFY": "当远程注册使用自签或不可信证书时可勾选此项以跳过证书认证。",
"NAME": "名称",
"NAME_EXISTS": "名称已存在",
"NAME_REQUIRED": "名称为必填项",
"NAME_REX": "名称由小写字符、数字和._-组成且至少2个字符并以字符或者数字开头。",
"DESCRIPTION": "描述",
"ENDPOINT": "地址",
"ENDPOINT_EXISTS": "地址已存在",
"ENDPOINT_REQUIRED": "地址为必填项",
"ILLEGAL_ENDPOINT": "非法地址",
"AUTH": "Authorization",
"NONE": "None",
"BASIC": "Basic",
"BEARER": "Bearer",
"API_KEY": "APIKey",
"USERNAME": "用户名",
"USERNAME_REQUIRED": "用户名为必填项",
"PASSWORD": "密码",
"PASSWORD_REQUIRED": "密码为必填项",
"TOKEN": "令牌",
"TOKEN_REQUIRED": "令牌为必填项",
"API_KEY_REQUIRED": "APIKey为必填项",
"SKIP": "跳过证书认证",
"ADD_SCANNER": "添加扫描器",
"EDIT_SCANNER": "编辑扫描器",
"TEST_CONNECTION": "测试连接",
"ADD_SUCCESS": "添加成功",
"TEST_PASS": "测试成功",
"TEST_FAILED": "测试失败",
"UPDATE_SUCCESS": "更新成功",
"SCANNER_COLON": "扫描器:",
"NAME_COLON": "Name:",
"VENDOR_COLON": "Vendor:",
"VERSION_COLON": "Version:",
"CAPABILITIES": "Capabilities",
"CONSUMES_MIME_TYPES_COLON": "Consumes Mime Types:",
"PRODUCTS_MIME_TYPES_COLON": "Produces Mime Types:",
"PROPERTIES": "Properties",
"NEW_SCANNER": "新建扫描器",
"SET_AS_DEFAULT": "设为默认",
"HEALTH": "健康",
"DISABLED": "禁用",
"NO_SCANNER": "暂无记录",
"DEFAULT": "默认",
"HEALTHY": "健康",
"UNHEALTHY": "不健康",
"SCANNERS": "扫描器",
"SCANNER": "扫描器",
"EDIT": "编辑",
"NOT_AVAILABLE": "不可用",
"ADAPTER": "适配器",
"VENDOR": "供应商",
"VERSION": "版本",
"SELECT_SCANNER": "选择扫描器"
}
}