diff --git a/src/ui_ng/lib/package.json b/src/ui_ng/lib/package.json index 002c69650..f810fdf0e 100644 --- a/src/ui_ng/lib/package.json +++ b/src/ui_ng/lib/package.json @@ -1,6 +1,6 @@ { "name": "harbor-ui", - "version": "0.5.0", + "version": "0.6.0", "description": "Harbor shared UI components based on Clarity and Angular4", "scripts": { "start": "ng serve --host 0.0.0.0 --port 4500 --proxy-config proxy.config.json", diff --git a/src/ui_ng/lib/pkg/package.json b/src/ui_ng/lib/pkg/package.json index 54e8d5c9b..c00f2df0b 100644 --- a/src/ui_ng/lib/pkg/package.json +++ b/src/ui_ng/lib/pkg/package.json @@ -1,6 +1,6 @@ { "name": "harbor-ui", - "version": "0.5.13", + "version": "0.6.0", "description": "Harbor shared UI components based on Clarity and Angular4", "author": "VMware", "module": "index.js", diff --git a/src/ui_ng/lib/src/harbor-library.module.ts b/src/ui_ng/lib/src/harbor-library.module.ts index 658272643..dca5fb101 100644 --- a/src/ui_ng/lib/src/harbor-library.module.ts +++ b/src/ui_ng/lib/src/harbor-library.module.ts @@ -6,7 +6,7 @@ import { ENDPOINT_DIRECTIVES } from './endpoint/index'; import { REPOSITORY_DIRECTIVES } from './repository/index'; import { REPOSITORY_STACKVIEW_DIRECTIVES } from './repository-stackview/index'; -import { LIST_REPOSITORY_DIRECTIVES } from './list-repository/index'; +import { REPOSITORY_LISTVIEW_DIRECTIVES } from './repository-listview/index'; import { TAG_DIRECTIVES } from './tag/index'; import { REPLICATION_DIRECTIVES } from './replication/index'; @@ -157,7 +157,7 @@ export function initConfig(translateInitializer: TranslateServiceInitializer, co ENDPOINT_DIRECTIVES, REPOSITORY_DIRECTIVES, REPOSITORY_STACKVIEW_DIRECTIVES, - LIST_REPOSITORY_DIRECTIVES, + REPOSITORY_LISTVIEW_DIRECTIVES, TAG_DIRECTIVES, CREATE_EDIT_ENDPOINT_DIRECTIVES, CONFIRMATION_DIALOG_DIRECTIVES, @@ -178,7 +178,7 @@ export function initConfig(translateInitializer: TranslateServiceInitializer, co ENDPOINT_DIRECTIVES, REPOSITORY_DIRECTIVES, REPOSITORY_STACKVIEW_DIRECTIVES, - LIST_REPOSITORY_DIRECTIVES, + REPOSITORY_LISTVIEW_DIRECTIVES, TAG_DIRECTIVES, CREATE_EDIT_ENDPOINT_DIRECTIVES, CONFIRMATION_DIALOG_DIRECTIVES, diff --git a/src/ui_ng/lib/src/list-repository/index.ts b/src/ui_ng/lib/src/list-repository/index.ts deleted file mode 100644 index f2fb2166c..000000000 --- a/src/ui_ng/lib/src/list-repository/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Type } from '@angular/core'; -import { ListRepositoryComponent } from './list-repository.component'; - - -export const LIST_REPOSITORY_DIRECTIVES: Type[] = [ - ListRepositoryComponent -]; \ No newline at end of file diff --git a/src/ui_ng/lib/src/list-repository/list-repository.component.css.ts b/src/ui_ng/lib/src/list-repository/list-repository.component.css.ts deleted file mode 100644 index e383c8147..000000000 --- a/src/ui_ng/lib/src/list-repository/list-repository.component.css.ts +++ /dev/null @@ -1 +0,0 @@ -export const LIST_REPOSITORY_STYLE = ``; \ No newline at end of file diff --git a/src/ui_ng/lib/src/list-repository/list-repository.component.html.ts b/src/ui_ng/lib/src/list-repository/list-repository.component.html.ts deleted file mode 100644 index f0b814696..000000000 --- a/src/ui_ng/lib/src/list-repository/list-repository.component.html.ts +++ /dev/null @@ -1,20 +0,0 @@ -export const LIST_REPOSITORY_TEMPLATE = ` - - {{'REPOSITORY.NAME' | translate}} - {{'REPOSITORY.TAGS_COUNT' | translate}} - {{'REPOSITORY.PULL_COUNT' | translate}} - {{'REPOSITORY.PLACEHOLDER' | translate }} - - - - - {{r.name}} - {{r.tags_count}} - {{r.pull_count}} - - - {{pagination.firstItem + 1}} - {{pagination.lastItem + 1}} {{'REPOSITORY.OF' | translate}} - {{pagination.totalItems}}{{'REPOSITORY.ITEMS' | translate}} - - -`; \ No newline at end of file diff --git a/src/ui_ng/lib/src/list-repository/list-repository.component.spec.ts b/src/ui_ng/lib/src/list-repository/list-repository.component.spec.ts deleted file mode 100644 index c0fb63953..000000000 --- a/src/ui_ng/lib/src/list-repository/list-repository.component.spec.ts +++ /dev/null @@ -1,78 +0,0 @@ - -import { ComponentFixture, TestBed, async } from '@angular/core/testing'; -import { By } from '@angular/platform-browser'; -import { DebugElement } from '@angular/core'; -import { Router } from '@angular/router'; - -import { SharedModule } from '../shared/shared.module'; -import { ConfirmationDialogComponent } from '../confirmation-dialog/confirmation-dialog.component'; -import { ListRepositoryComponent } from './list-repository.component'; -import { Repository, RepositoryItem } from '../service/interface'; - -import { SERVICE_CONFIG, IServiceConfig } from '../service.config'; - -class RouterStub { - navigateByUrl(url: string) { return url; } -} - -describe('ListRepositoryComponent (inline template)', () => { - let comp: ListRepositoryComponent; - let fixture: ComponentFixture; - - let mockData: RepositoryItem[] = [ - { - "id": 11, - "name": "library/busybox", - "project_id": 1, - "description": "", - "pull_count": 0, - "star_count": 0, - "tags_count": 1 - }, - { - "id": 12, - "name": "library/nginx", - "project_id": 1, - "description": "", - "pull_count": 0, - "star_count": 0, - "tags_count": 1 - } - ]; - - beforeEach(async(() => { - TestBed.configureTestingModule({ - imports: [ - SharedModule - ], - declarations: [ - ListRepositoryComponent, - ConfirmationDialogComponent - ], - providers: [ - { provide: Router, useClass: RouterStub }, - { provide: SERVICE_CONFIG, useValue: {} } - ] - }); - })); - - beforeEach(() => { - fixture = TestBed.createComponent(ListRepositoryComponent); - comp = fixture.componentInstance; - }); - - it('should load and render data', async(() => { - fixture.detectChanges(); - comp.repositories = mockData; - fixture.whenStable().then(() => { - fixture.detectChanges(); - expect(comp.repositories).toBeTruthy(); - let de: DebugElement = fixture.debugElement.query(By.css('datagrid-cell')); - fixture.detectChanges(); - expect(de).toBeTruthy(); - let el: HTMLElement = de.nativeElement; - expect(el).toBeTruthy(); - expect(el.textContent).toEqual('library/busybox'); - }); - })); -}); \ No newline at end of file diff --git a/src/ui_ng/lib/src/list-repository/list-repository.component.ts b/src/ui_ng/lib/src/list-repository/list-repository.component.ts deleted file mode 100644 index 5596111a3..000000000 --- a/src/ui_ng/lib/src/list-repository/list-repository.component.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { Component, Input, Output, EventEmitter, ChangeDetectorRef, ChangeDetectionStrategy } from '@angular/core'; -import { Router } from '@angular/router'; - -import { State, Comparator } from 'clarity-angular'; -import { RepositoryItem } from '../service/interface'; - -import { LIST_REPOSITORY_TEMPLATE } from './list-repository.component.html'; - -import { CustomComparator } from '../utils'; - -@Component({ - selector: 'hbr-list-repository', - template: LIST_REPOSITORY_TEMPLATE, - changeDetection: ChangeDetectionStrategy.OnPush -}) -export class ListRepositoryComponent { - - @Input() urlPrefix: string; - @Input() projectId: number; - @Input() repositories: RepositoryItem[]; - - @Output() delete = new EventEmitter(); - @Output() paginate = new EventEmitter(); - - @Input() hasProjectAdminRole: boolean; - - pageOffset: number = 1; - - pullCountComparator: Comparator = new CustomComparator('pull_count', 'number'); - - tagsCountComparator: Comparator = new CustomComparator('tags_count', 'number'); - - constructor( - private router: Router, - private ref: ChangeDetectorRef) { - let hnd = setInterval(()=>ref.markForCheck(), 100); - setTimeout(()=>clearInterval(hnd), 1000); - } - - ngOnInit() { } - - deleteRepo(repoName: string) { - this.delete.emit(repoName); - } - - refresh(state: State) { - if (this.repositories) { - this.paginate.emit(state); - } - } - - public gotoLink(projectId: number, repoName: string): void { - let linkUrl = [this.urlPrefix, 'tags', projectId, repoName]; - this.router.navigate(linkUrl); - } -} \ No newline at end of file diff --git a/src/ui_ng/lib/src/repository-listview/index.ts b/src/ui_ng/lib/src/repository-listview/index.ts new file mode 100644 index 000000000..651511113 --- /dev/null +++ b/src/ui_ng/lib/src/repository-listview/index.ts @@ -0,0 +1,6 @@ +import { Type } from '@angular/core'; +import { RepositoryListviewComponent } from './repository-listview.component'; + +export const REPOSITORY_LISTVIEW_DIRECTIVES: Type[] = [ + RepositoryListviewComponent +]; \ No newline at end of file diff --git a/src/ui_ng/lib/src/repository-listview/repository-listview.component.css.ts b/src/ui_ng/lib/src/repository-listview/repository-listview.component.css.ts new file mode 100644 index 000000000..82e323e66 --- /dev/null +++ b/src/ui_ng/lib/src/repository-listview/repository-listview.component.css.ts @@ -0,0 +1,2 @@ +export const REPOSITORY_LISTVIEW_STYLE = ` +`; \ No newline at end of file diff --git a/src/ui_ng/lib/src/repository-listview/repository-listview.component.html.ts b/src/ui_ng/lib/src/repository-listview/repository-listview.component.html.ts new file mode 100644 index 000000000..a3512c61f --- /dev/null +++ b/src/ui_ng/lib/src/repository-listview/repository-listview.component.html.ts @@ -0,0 +1,41 @@ +export const REPOSITORY_LISTVIEW_TEMPLATE = ` +
+
+
+
+
+ + + +
+
+
+
+ + {{'REPOSITORY.NAME' | translate}} + {{'REPOSITORY.TAGS_COUNT' | translate}} + {{'REPOSITORY.PULL_COUNT' | translate}} + {{'REPOSITORY.PLACEHOLDER' | translate }} + + + + + {{r.name}} + {{r.tags_count}} + {{r.pull_count}} + + + + + {{'CONFIG.SCANNING.DB_NOT_READY' | translate }} + + {{pagination.firstItem + 1}} - {{pagination.lastItem + 1}} {{'REPOSITORY.OF' | translate}} + {{pagination.totalItems}} {{'REPOSITORY.ITEMS' | translate}} + + + +
+
+ +
+`; \ No newline at end of file diff --git a/src/ui_ng/lib/src/repository-listview/repository-listview.component.spec.ts b/src/ui_ng/lib/src/repository-listview/repository-listview.component.spec.ts new file mode 100644 index 000000000..26de12a8c --- /dev/null +++ b/src/ui_ng/lib/src/repository-listview/repository-listview.component.spec.ts @@ -0,0 +1,170 @@ +import { ComponentFixture, TestBed, async } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { DebugElement } from '@angular/core'; + +import { RouterTestingModule } from '@angular/router/testing'; + +import { SharedModule } from '../shared/shared.module'; +import { ConfirmationDialogComponent } from '../confirmation-dialog/confirmation-dialog.component'; +import { RepositoryListviewComponent } from './repository-listview.component'; +import { TagComponent } from '../tag/tag.component'; +import { FilterComponent } from '../filter/filter.component'; + +import { ErrorHandler } from '../error-handler/error-handler'; +import { Repository, RepositoryItem, Tag, SystemInfo } from '../service/interface'; +import { SERVICE_CONFIG, IServiceConfig } from '../service.config'; +import { RepositoryService, RepositoryDefaultService } from '../service/repository.service'; +import { TagService, TagDefaultService } from '../service/tag.service'; +import { SystemInfoService, SystemInfoDefaultService } from '../service/system-info.service'; +import { VULNERABILITY_DIRECTIVES } from '../vulnerability-scanning/index'; +import { PUSH_IMAGE_BUTTON_DIRECTIVES } from '../push-image/index'; +import { INLINE_ALERT_DIRECTIVES } from '../inline-alert/index'; +import { JobLogViewerComponent } from '../job-log-viewer/index'; + +import { click } from '../utils'; + +describe('RepositoryComponentListview (inline template)', () => { + + let compRepo: RepositoryListviewComponent; + let fixtureRepo: ComponentFixture; + let repositoryService: RepositoryService; + let tagService: TagService; + let systemInfoService: SystemInfoService; + + let spyRepos: jasmine.Spy; + let spySystemInfo: jasmine.Spy; + + let mockSystemInfo: SystemInfo = { + "with_notary": true, + "with_admiral": false, + "admiral_endpoint": "NA", + "auth_mode": "db_auth", + "registry_url": "10.112.122.56", + "project_creation_restriction": "everyone", + "self_registration": true, + "has_ca_root": false, + "harbor_version": "v1.1.1-rc1-160-g565110d" + }; + + let mockRepoData: RepositoryItem[] = [ + { + "id": 1, + "name": "library/busybox", + "project_id": 1, + "description": "asdfsadf", + "pull_count": 0, + "star_count": 0, + "tags_count": 1 + }, + { + "id": 2, + "name": "library/nginx", + "project_id": 1, + "description": "asdf", + "pull_count": 0, + "star_count": 0, + "tags_count": 1 + } + ]; + + let mockRepo: Repository = { + metadata: {xTotalCount: 2}, + data: mockRepoData + }; + + let mockTagData: Tag[] = [ + { + "digest": "sha256:e5c82328a509aeb7c18c1d7fb36633dc638fcf433f651bdcda59c1cc04d3ee55", + "name": "1.11.5", + "size": "2049", + "architecture": "amd64", + "os": "linux", + "docker_version": "1.12.3", + "author": "NGINX Docker Maintainers \"docker-maint@nginx.com\"", + "created": new Date("2016-11-08T22:41:15.912313785Z"), + "signature": null + } + ]; + + let config: IServiceConfig = { + repositoryBaseEndpoint: '/api/repository/testing', + systemInfoEndpoint: '/api/systeminfo/testing', + targetBaseEndpoint: '/api/tag/testing' + }; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + SharedModule, + RouterTestingModule + ], + declarations: [ + RepositoryListviewComponent, + TagComponent, + ConfirmationDialogComponent, + FilterComponent, + VULNERABILITY_DIRECTIVES, + PUSH_IMAGE_BUTTON_DIRECTIVES, + INLINE_ALERT_DIRECTIVES, + JobLogViewerComponent + ], + providers: [ + ErrorHandler, + { provide: SERVICE_CONFIG, useValue: config }, + { provide: RepositoryService, useClass: RepositoryDefaultService }, + { provide: TagService, useClass: TagDefaultService }, + { provide: SystemInfoService, useClass: SystemInfoDefaultService } + ] + }); + })); + + beforeEach(() => { + fixtureRepo = TestBed.createComponent(RepositoryListviewComponent); + compRepo = fixtureRepo.componentInstance; + compRepo.projectId = 1; + compRepo.hasProjectAdminRole = true; + + repositoryService = fixtureRepo.debugElement.injector.get(RepositoryService); + systemInfoService = fixtureRepo.debugElement.injector.get(SystemInfoService); + + spyRepos = spyOn(repositoryService, 'getRepositories').and.returnValues(Promise.resolve(mockRepo)); + spySystemInfo = spyOn(systemInfoService, 'getSystemInfo').and.returnValues(Promise.resolve(mockSystemInfo)); + fixtureRepo.detectChanges(); + }); + + it('should create', () => { + expect(compRepo).toBeTruthy(); + }); + + it('should load and render data', async(() => { + fixtureRepo.detectChanges(); + + fixtureRepo.whenStable().then(() => { + fixtureRepo.detectChanges(); + + let deRepo: DebugElement = fixtureRepo.debugElement.query(By.css('datagrid-cell')); + expect(deRepo).toBeTruthy(); + let elRepo: HTMLElement = deRepo.nativeElement; + expect(elRepo).toBeTruthy(); + expect(elRepo.textContent).toEqual('library/busybox'); + }); + })); + + it('should filter data by keyword', async(() => { + fixtureRepo.detectChanges(); + + fixtureRepo.whenStable().then(() => { + fixtureRepo.detectChanges(); + + compRepo.doSearchRepoNames('nginx'); + fixtureRepo.detectChanges(); + let de: DebugElement[] = fixtureRepo.debugElement.queryAll(By.css('datagrid-cell')); + expect(de).toBeTruthy(); + expect(de.length).toEqual(1); + let el: HTMLElement = de[0].nativeElement; + expect(el).toBeTruthy(); + expect(el.textContent).toEqual('library/nginx'); + }); + })); + +}); diff --git a/src/ui_ng/lib/src/repository-listview/repository-listview.component.ts b/src/ui_ng/lib/src/repository-listview/repository-listview.component.ts new file mode 100644 index 000000000..12a380fa0 --- /dev/null +++ b/src/ui_ng/lib/src/repository-listview/repository-listview.component.ts @@ -0,0 +1,303 @@ +import { + Component, + Input, + Output, + OnInit, + ViewChild, + ChangeDetectionStrategy, + ChangeDetectorRef, + EventEmitter, OnChanges, SimpleChanges +} from '@angular/core'; +import { Router } from '@angular/router'; +import { TranslateService } from '@ngx-translate/core'; +import { Comparator } from 'clarity-angular'; + +import { REPOSITORY_LISTVIEW_TEMPLATE } from './repository-listview.component.html'; +import { REPOSITORY_LISTVIEW_STYLE } from './repository-listview.component.css'; + +import { + Repository, + SystemInfo, + SystemInfoService, + RepositoryService, + RequestQueryParams, + RepositoryItem, + TagService +} from '../service/index'; +import { ErrorHandler } from '../error-handler/error-handler'; + +import { toPromise, CustomComparator } from '../utils'; + +import { ConfirmationState, ConfirmationTargets, ConfirmationButtons } 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 { Subscription } from 'rxjs/Subscription'; +import { Tag, TagClickEvent } from '../service/interface'; + +import { State } from "clarity-angular"; +import { + DEFAULT_PAGE_SIZE, + calculatePage, + doFiltering, + doSorting +} from '../utils'; + +@Component({ + selector: 'hbr-repository-listview', + template: REPOSITORY_LISTVIEW_TEMPLATE, + styles: [REPOSITORY_LISTVIEW_STYLE], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class RepositoryListviewComponent implements OnChanges, OnInit { + signedCon: {[key: string]: any | string[]} = {}; + @Input() projectId: number; + @Input() projectName = 'unknown'; + @Input() urlPrefix: string; + + @Input() hasSignedIn: boolean; + @Input() hasProjectAdminRole: boolean; + @Output() tagClickEvent = new EventEmitter(); + + lastFilteredRepoName: string; + repositories: RepositoryItem[]; + systemInfo: SystemInfo; + + loading = true; + + @ViewChild('confirmationDialog') + confirmationDialog: ConfirmationDialogComponent; + + pullCountComparator: Comparator = new CustomComparator('pull_count', 'number'); + + tagsCountComparator: Comparator = new CustomComparator('tags_count', 'number'); + + pageSize: number = DEFAULT_PAGE_SIZE; + currentPage = 1; + totalCount = 0; + currentState: State; + + constructor( + private errorHandler: ErrorHandler, + private translateService: TranslateService, + private repositoryService: RepositoryService, + private systemInfoService: SystemInfoService, + private translate: TranslateService, + private tagService: TagService, + private ref: ChangeDetectorRef, + private router: Router) { } + + public get registryUrl(): string { + return this.systemInfo ? this.systemInfo.registry_url : ''; + } + + public get withNotary(): boolean { + return this.systemInfo ? this.systemInfo.with_notary : false; + } + + public get withClair(): boolean { + return this.systemInfo ? this.systemInfo.with_clair : false; + } + + public get isClairDBReady(): boolean { + return this.systemInfo && + this.systemInfo.clair_vulnerability_status && + this.systemInfo.clair_vulnerability_status.overall_last_update > 0; + } + + public get showDBStatusWarning(): boolean { + return this.withClair && !this.isClairDBReady; + } + + confirmDeletion(message: ConfirmationAcknowledgement) { + if (message && + message.source === ConfirmationTargets.REPOSITORY && + message.state === ConfirmationState.CONFIRMED) { + let repoName = message.data; + toPromise(this.repositoryService + .deleteRepository(repoName)) + .then( + response => { + this.refresh(); + let st: State = this.getStateAfterDeletion(); + if (!st) { + this.refresh(); + } else { + this.clrLoad(st); + } + this.translateService.get('REPOSITORY.DELETED_REPO_SUCCESS') + .subscribe(res => this.errorHandler.info(res)); + }).catch(error => { + if (error.status === '412') { + this.translateService.get('REPOSITORY.TAGS_SIGNED') + .subscribe(res => this.errorHandler.info(res)); + return; + } + this.errorHandler.error(error); + }); + } + } + + ngOnChanges(changes: SimpleChanges): void { + if (changes['projectId'] && changes['projectId'].currentValue) { + this.refresh(); + } + } + + ngOnInit(): void { + // Get system info for tag views + toPromise(this.systemInfoService.getSystemInfo()) + .then(systemInfo => this.systemInfo = systemInfo) + .catch(error => this.errorHandler.error(error)); + + this.lastFilteredRepoName = ''; + } + + doSearchRepoNames(repoName: string) { + this.lastFilteredRepoName = repoName; + this.currentPage = 1; + + let st: State = this.currentState; + if (!st) { + st = { page: {} }; + } + st.page.size = this.pageSize; + st.page.from = 0; + st.page.to = this.pageSize - 1; + this.clrLoad(st); + } + + saveSignatures(event: {[key: string]: string[]}): void { + Object.assign(this.signedCon, event); + } + + deleteRepo(repoName: string) { + if (this.signedCon[repoName]) { + this.signedDataSet(repoName); + } else { + this.getTagInfo(repoName).then(() => { + this.signedDataSet(repoName); + }); + } + } + getTagInfo(repoName: string): Promise { + // this.signedNameArr = []; + this.signedCon[repoName] = []; + return toPromise(this.tagService + .getTags(repoName)) + .then(items => { + items.forEach((t: Tag) => { + if (t.signature !== null) { + this.signedCon[repoName].push(t.name); + } + }); + }) + .catch(error => this.errorHandler.error(error)); + } + + signedDataSet(repoName: string): void { + let signature = ''; + if (this.signedCon[repoName].length === 0) { + this.confirmationDialogSet('REPOSITORY.DELETION_TITLE_REPO', signature, repoName, 'REPOSITORY.DELETION_SUMMARY_REPO', ConfirmationButtons.DELETE_CANCEL); + return; + } + signature = this.signedCon[repoName].join(','); + this.confirmationDialogSet('REPOSITORY.DELETION_TITLE_REPO_SIGNED', signature, repoName, 'REPOSITORY.DELETION_SUMMARY_REPO_SIGNED', ConfirmationButtons.CLOSE); + } + + confirmationDialogSet(summaryTitle: string, signature: string, repoName: string, summaryKey: string, button: ConfirmationButtons): void { + this.translate.get(summaryKey, + { + repoName: repoName, + signedImages: signature, + }) + .subscribe((res: string) => { + summaryKey = res; + let message = new ConfirmationMessage( + summaryTitle, + summaryKey, + repoName, + repoName, + ConfirmationTargets.REPOSITORY, + button); + this.confirmationDialog.open(message); + + let hnd = setInterval(() => this.ref.markForCheck(), 100); + setTimeout(() => clearInterval(hnd), 5000); + }); + } + + refresh() { + this.doSearchRepoNames(''); + } + + watchTagClickEvt(tagClickEvt: TagClickEvent): void { + this.tagClickEvent.emit(tagClickEvt); + } + + clrLoad(state: State): void { + //Keep it for future filtering and sorting + this.currentState = state; + + let pageNumber: number = calculatePage(state); + if (pageNumber <= 0) { pageNumber = 1; } + + //Pagination + let params: RequestQueryParams = new RequestQueryParams(); + params.set("page", '' + pageNumber); + params.set("page_size", '' + this.pageSize); + + this.loading = true; + + toPromise(this.repositoryService.getRepositories( + this.projectId, + this.lastFilteredRepoName, + params)) + .then((repo: Repository) => { + this.totalCount = repo.metadata.xTotalCount; + this.repositories = repo.data; + + this.signedCon = {}; + //Do filtering and sorting + this.repositories = doFiltering(this.repositories, state); + this.repositories = doSorting(this.repositories, state); + + this.loading = false; + }) + .catch(error => { + this.loading = false; + this.errorHandler.error(error); + }); + + //Force refresh view + let hnd = setInterval(() => this.ref.markForCheck(), 100); + setTimeout(() => clearInterval(hnd), 5000); + } + + getStateAfterDeletion(): State { + let total: number = this.totalCount - 1; + if (total <= 0) { return null; } + + let totalPages: number = Math.ceil(total / this.pageSize); + let targetPageNumber: number = this.currentPage; + + if (this.currentPage > totalPages) { + targetPageNumber = totalPages;//Should == currentPage -1 + } + + let st: State = this.currentState; + if (!st) { + st = { page: {} }; + } + st.page.size = this.pageSize; + st.page.from = (targetPageNumber - 1) * this.pageSize; + st.page.to = targetPageNumber * this.pageSize - 1; + + return st; + } + public gotoLink(projectId: number, repoName: string): void { + let linkUrl = [this.router.url, repoName]; + this.router.navigate(linkUrl); + } +} \ No newline at end of file diff --git a/src/ui_ng/lib/src/repository/index.ts b/src/ui_ng/lib/src/repository/index.ts index 652c04f42..4019f3a19 100644 --- a/src/ui_ng/lib/src/repository/index.ts +++ b/src/ui_ng/lib/src/repository/index.ts @@ -1,7 +1,6 @@ import { Type } from '@angular/core'; import { RepositoryComponent } from './repository.component'; - export const REPOSITORY_DIRECTIVES: Type[] = [ RepositoryComponent ]; diff --git a/src/ui_ng/lib/src/repository/repository.component.css.ts b/src/ui_ng/lib/src/repository/repository.component.css.ts index f443c6a1b..7e4812f15 100644 --- a/src/ui_ng/lib/src/repository/repository.component.css.ts +++ b/src/ui_ng/lib/src/repository/repository.component.css.ts @@ -1,4 +1,46 @@ export const REPOSITORY_STYLE = `.option-right { padding-right: 16px; margin-bottom: 12px; -}`; \ No newline at end of file +} + +.arrow-back { + cursor: pointer; +} + +.arrow-block { + border-right: 2px solid #cccccc; + margin-right: 6px; + display: inline-flex; + padding: 6px 6px 6px 12px; +} + +.title-block { + display: inline-block; +} + +.title-wrapper { + padding-top: 12px; +} +.tag-name { + font-weight: 300; + font-size: 32px; +} + +pre { + white-space: pre-wrap; +} + +#info-edit-button { + margin-top: 0px; + margin-bottom: 12px; +} + +#images-container { + margin-top: 12px; +} + +harbor-tag { + position: relative; + top: 24px; +} +`; diff --git a/src/ui_ng/lib/src/repository/repository.component.html.ts b/src/ui_ng/lib/src/repository/repository.component.html.ts index e4b95d46e..a2d021aab 100644 --- a/src/ui_ng/lib/src/repository/repository.component.html.ts +++ b/src/ui_ng/lib/src/repository/repository.component.html.ts @@ -1,15 +1,48 @@ export const REPOSITORY_TEMPLATE = ` - -
-
-
-
- - -
+
+
+
+ +
+
+

{{repoName}}

-
- +
+ +
+
+ + +
+
+
+ +
+
+

{{'REPOSITORY.NO_INFO' | translate }}

+
{{ imageInfo }}
+ +
+
+ + +
+ +
+
+
+
+ +
+
-
`; \ No newline at end of file + +`; \ No newline at end of file diff --git a/src/ui_ng/lib/src/repository/repository.component.spec.ts b/src/ui_ng/lib/src/repository/repository.component.spec.ts index b91e33999..d88e5bcf2 100644 --- a/src/ui_ng/lib/src/repository/repository.component.spec.ts +++ b/src/ui_ng/lib/src/repository/repository.component.spec.ts @@ -1,19 +1,27 @@ -import { ComponentFixture, TestBed, async } from '@angular/core/testing'; +import { ComponentFixture, TestBed, async, } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { DebugElement } from '@angular/core'; -import { Router } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; import { SharedModule } from '../shared/shared.module'; import { ConfirmationDialogComponent } from '../confirmation-dialog/confirmation-dialog.component'; import { RepositoryComponent } from './repository.component'; -import { ListRepositoryComponent } from '../list-repository/list-repository.component'; +import { RepositoryListviewComponent } from '../repository-listview/repository-listview.component'; import { FilterComponent } from '../filter/filter.component'; +import { TagComponent } from '../tag/tag.component'; +import { VULNERABILITY_DIRECTIVES } from '../vulnerability-scanning/index'; +import { PUSH_IMAGE_BUTTON_DIRECTIVES } from '../push-image/index'; +import { INLINE_ALERT_DIRECTIVES } from '../inline-alert/index'; +import { JobLogViewerComponent } from '../job-log-viewer/index'; + import { ErrorHandler } from '../error-handler/error-handler'; -import { Repository, RepositoryItem } from '../service/interface'; +import { Repository, RepositoryItem, Tag, SystemInfo } from '../service/interface'; import { SERVICE_CONFIG, IServiceConfig } from '../service.config'; import { RepositoryService, RepositoryDefaultService } from '../service/repository.service'; import { SystemInfoService, SystemInfoDefaultService } from '../service/system-info.service'; +import { TagService, TagDefaultService } from '../service/tag.service'; +import { ChannelService } from '../channel/index'; class RouterStub { navigateByUrl(url: string) { return url; } @@ -21,72 +29,123 @@ class RouterStub { describe('RepositoryComponent (inline template)', () => { - let comp: RepositoryComponent; + let compRepo: RepositoryComponent; let fixture: ComponentFixture; let repositoryService: RepositoryService; - let spy: jasmine.Spy; + let systemInfoService: SystemInfoService; + let tagService: TagService; - let mockData: RepositoryItem[] = [ - { - "id": 11, - "name": "library/busybox", - "project_id": 1, - "description": "", - "pull_count": 0, - "star_count": 0, - "tags_count": 1 - }, - { - "id": 12, - "name": "library/nginx", - "project_id": 1, - "description": "", - "pull_count": 0, - "star_count": 0, - "tags_count": 1 - } - ]; - let mockRepo: Repository = { - metadata: { xTotalCount: 2 }, - data: mockData + let spyRepos: jasmine.Spy; + let spyTags: jasmine.Spy; + let spySystemInfo: jasmine.Spy; + + let mockSystemInfo: SystemInfo = { + 'with_notary': true, + 'with_admiral': false, + 'admiral_endpoint': 'NA', + 'auth_mode': 'db_auth', + 'registry_url': '10.112.122.56', + 'project_creation_restriction': 'everyone', + 'self_registration': true, + 'has_ca_root': false, + 'harbor_version': 'v1.1.1-rc1-160-g565110d' }; + let mockRepoData: RepositoryItem[] = [ + { + 'id': 1, + 'name': 'library/busybox', + 'project_id': 1, + 'description': 'asdfsadf', + 'pull_count': 0, + 'star_count': 0, + 'tags_count': 1 + }, + { + 'id': 2, + 'name': 'library/nginx', + 'project_id': 1, + 'description': 'asdf', + 'pull_count': 0, + 'star_count': 0, + 'tags_count': 1 + } + ]; + + let mockRepo: Repository = { + metadata: {xTotalCount: 2}, + data: mockRepoData + }; + + let mockTagData: Tag[] = [ + { + 'digest': 'sha256:e5c82328a509aeb7c18c1d7fb36633dc638fcf433f651bdcda59c1cc04d3ee55', + 'name': '1.11.5', + 'size': '2049', + 'architecture': 'amd64', + 'os': 'linux', + 'docker_version': '1.12.3', + 'author': 'NGINX Docker Maintainers \"docker-maint@nginx.com\"', + 'created': new Date('2016-11-08T22:41:15.912313785Z'), + 'signature': null + } + ]; + let config: IServiceConfig = { - repositoryBaseEndpoint: '/api/repository/testing' + repositoryBaseEndpoint: '/api/repository/testing', + systemInfoEndpoint: '/api/systeminfo/testing', + targetBaseEndpoint: '/api/tag/testing' }; beforeEach(async(() => { TestBed.configureTestingModule({ imports: [ - SharedModule + SharedModule, + RouterTestingModule ], declarations: [ RepositoryComponent, - ListRepositoryComponent, + RepositoryListviewComponent, ConfirmationDialogComponent, - FilterComponent + FilterComponent, + TagComponent, + VULNERABILITY_DIRECTIVES, + PUSH_IMAGE_BUTTON_DIRECTIVES, + INLINE_ALERT_DIRECTIVES, + JobLogViewerComponent, ], providers: [ ErrorHandler, { provide: SERVICE_CONFIG, useValue: config }, { provide: RepositoryService, useClass: RepositoryDefaultService }, { provide: SystemInfoService, useClass: SystemInfoDefaultService }, - { provide: Router, useClass: RouterStub } + { provide: TagService, useClass: TagDefaultService }, + { provide: ChannelService}, + ] }); })); beforeEach(() => { fixture = TestBed.createComponent(RepositoryComponent); - comp = fixture.componentInstance; - comp.projectId = 1; - comp.hasProjectAdminRole = true; + compRepo = fixture.componentInstance; + compRepo.projectId = 1; + compRepo.hasProjectAdminRole = true; + compRepo.repoName = 'library/nginx'; repositoryService = fixture.debugElement.injector.get(RepositoryService); + systemInfoService = fixture.debugElement.injector.get(SystemInfoService); + tagService = fixture.debugElement.injector.get(TagService); - spy = spyOn(repositoryService, 'getRepositories').and.returnValues(Promise.resolve(mockRepo)); + spyRepos = spyOn(repositoryService, 'getRepositories').and.returnValues(Promise.resolve(mockRepo)); + spySystemInfo = spyOn(systemInfoService, 'getSystemInfo').and.returnValues(Promise.resolve(mockSystemInfo)); + spyTags = spyOn(tagService, 'getTags').and.returnValues(Promise.resolve(mockTagData)); fixture.detectChanges(); }); + it('should create', () => { + expect(compRepo).toBeTruthy(); + }); + it('should load and render data', async(() => { fixture.detectChanges(); fixture.whenStable().then(() => { @@ -99,22 +158,4 @@ describe('RepositoryComponent (inline template)', () => { expect(el.textContent).toEqual('library/busybox'); }); })); - - it('should filter data by keyword', async(() => { - fixture.detectChanges(); - fixture.whenStable().then(() => { - fixture.detectChanges(); - comp.doSearchRepoNames('nginx'); - fixture.detectChanges(); - let de: DebugElement[] = fixture.debugElement.queryAll(By.css('datagrid-cell')); - fixture.detectChanges(); - expect(de).toBeTruthy(); - expect(de.length).toEqual(1); - let el: HTMLElement = de[0].nativeElement; - fixture.detectChanges(); - expect(el).toBeTruthy(); - expect(el.textContent).toEqual('library/nginx'); - }); - })); - -}); \ No newline at end of file +}); diff --git a/src/ui_ng/lib/src/repository/repository.component.ts b/src/ui_ng/lib/src/repository/repository.component.ts index c7f3ca9c8..c5d44dd88 100644 --- a/src/ui_ng/lib/src/repository/repository.component.ts +++ b/src/ui_ng/lib/src/repository/repository.component.ts @@ -11,20 +11,21 @@ // 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, Input } from '@angular/core'; +import { Component, OnInit, ViewChild, Input, Output, EventEmitter } from '@angular/core'; import { RepositoryService } from '../service/repository.service'; -import { Repository, RepositoryItem } from '../service/interface'; + +import { Repository, RepositoryItem, Tag, TagClickEvent, + SystemInfo, SystemInfoService, TagService } from '../service/index'; import { TranslateService } from '@ngx-translate/core'; import { ErrorHandler } from '../error-handler/error-handler'; -import { ConfirmationState, ConfirmationTargets, ConfirmationButtons } from '../shared/shared.const'; +import { 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 { Subscription } from 'rxjs/Subscription'; import { State } from 'clarity-angular'; @@ -33,81 +34,172 @@ import { toPromise } from '../utils'; import { REPOSITORY_TEMPLATE } from './repository.component.html'; import { REPOSITORY_STYLE } from './repository.component.css'; +const TabLinkContentMap: {[index: string]: string} = { + 'repo-info': 'info', + 'repo-image': 'image' +}; + @Component({ selector: 'hbr-repository', template: REPOSITORY_TEMPLATE, styles: [REPOSITORY_STYLE] }) export class RepositoryComponent implements OnInit { - changedRepositories: RepositoryItem[]; - + signedCon: {[key: string]: any | string[]} = {}; @Input() projectId: number; - @Input() urlPrefix: string; + @Input() projectName: string; + @Input() repoName: string; + @Input() hasSignedIn: boolean; @Input() hasProjectAdminRole: boolean; - lastFilteredRepoName: string; + @Output() tagClickEvent = new EventEmitter(); + @Output() backEvt: EventEmitter = new EventEmitter(); + + onGoing = false; + withNotary = false; + withClair = true; + editing = false; + inProgress = true; + currentTabID = 'repo-image'; + changedRepositories: RepositoryItem[]; + systemInfo: SystemInfo; + + imageInfo: string; + orgImageInfo: string; + + timerHandler: any; @ViewChild('confirmationDialog') - confirmationDialog: ConfirmationDialogComponent; + confirmationDlg: ConfirmationDialogComponent; constructor( private errorHandler: ErrorHandler, private repositoryService: RepositoryService, - private translateService: TranslateService - ) {} + private systemInfoService: SystemInfoService, + private tagService: TagService, + private translate: TranslateService, + ) { } - confirmDeletion(message: ConfirmationAcknowledgement) { - if (message && - message.source === ConfirmationTargets.REPOSITORY && - message.state === ConfirmationState.CONFIRMED) { - let repoName = message.data; - toPromise(this.repositoryService - .deleteRepository(repoName)) - .then( - response => { - this.refresh(); - this.translateService.get('REPOSITORY.DELETED_REPO_SUCCESS') - .subscribe(res=>this.errorHandler.info(res)); - }).catch(error => this.errorHandler.error(error)); - } + public get registryUrl(): string { + return this.systemInfo ? this.systemInfo.registry_url : ''; } - + ngOnInit(): void { - if(!this.projectId) { + if (!this.projectId) { this.errorHandler.error('Project ID cannot be unset.'); return; } - this.lastFilteredRepoName = ''; this.retrieve(); + this.inProgress = false; } retrieve(state?: State) { - toPromise(this.repositoryService - .getRepositories(this.projectId, this.lastFilteredRepoName)) + toPromise(this.repositoryService.getRepositories(this.projectId, this.repoName)) .then( response => { - this.changedRepositories = response.data; - }, - error => this.errorHandler.error(error)); + if (response.metadata.xTotalCount > 0) { + this.orgImageInfo = response.data[0].description; + this.imageInfo = response.data[0].description; + } + }) + .catch(error => this.errorHandler.error(error)); } - doSearchRepoNames(repoName: string) { - this.lastFilteredRepoName = repoName; - this.retrieve(); - } - - deleteRepo(repoName: string) { - let message = new ConfirmationMessage( - 'REPOSITORY.DELETION_TITLE_REPO', - 'REPOSITORY.DELETION_SUMMARY_REPO', - repoName, - repoName, - ConfirmationTargets.REPOSITORY, - ConfirmationButtons.DELETE_CANCEL); - this.confirmationDialog.open(message); + saveSignatures(event: {[key: string]: string[]}): void { + Object.assign(this.signedCon, event); } refresh() { this.retrieve(); } -} \ No newline at end of file + + watchTagClickEvt(tagClickEvt: TagClickEvent): void { + this.tagClickEvent.emit(tagClickEvt); + } + + isCurrentTabLink(tabID: string): boolean { + return this.currentTabID === tabID; + } + + isCurrentTabContent(ContentID: string): boolean { + return TabLinkContentMap[this.currentTabID] === ContentID; + } + + tabLinkClick(tabID: string) { + this.currentTabID = tabID; + } + + getTagInfo(repoName: string): Promise { + // this.signedNameArr = []; + this.signedCon[repoName] = []; + return toPromise(this.tagService + .getTags(repoName)) + .then(items => { + items.forEach((t: Tag) => { + if (t.signature !== null) { + this.signedCon[repoName].push(t.name); + } + }); + }) + .catch(error => this.errorHandler.error(error)); + } + + goBack() { + this.backEvt.emit(this.projectId); + } + + hasChanges() { + return this.imageInfo !== this.orgImageInfo; + } + + reset(): void { + this.imageInfo = this.orgImageInfo; + } + + hasInfo() { + return this.imageInfo && this.imageInfo.length > 0; + } + + editInfo() { + this.editing = true; + } + + saveInfo() { + if (!this.hasChanges()) { + return; + } + this.onGoing = true; + toPromise(this.repositoryService.updateRepositoryDescription(this.repoName, this.imageInfo)) + .then(() => { + this.onGoing = false; + this.translate.get('CONFIG.SAVE_SUCCESS').subscribe((res: string) => { + this.errorHandler.info(res); + }); + this.editing = false; + this.refresh(); + }) + .catch(error => { + this.onGoing = false; + this.errorHandler.error(error); + }); + } + + cancelInfo() { + let msg = new ConfirmationMessage( + 'CONFIG.CONFIRM_TITLE', + 'CONFIG.CONFIRM_SUMMARY', + '', + {}, + ConfirmationTargets.CONFIG + ); + this.confirmationDlg.open(msg); + } + + confirmCancel(ack: ConfirmationAcknowledgement): void { + this.editing = false; + if (ack && ack.source === ConfirmationTargets.CONFIG && + ack.state === ConfirmationState.CONFIRMED) { + this.reset(); + } + } +} diff --git a/src/ui_ng/lib/src/service/repository.service.ts b/src/ui_ng/lib/src/service/repository.service.ts index f64df9cf2..546955271 100644 --- a/src/ui_ng/lib/src/service/repository.service.ts +++ b/src/ui_ng/lib/src/service/repository.service.ts @@ -1,7 +1,7 @@ import { Observable } from 'rxjs/Observable'; import { RequestQueryParams } from './RequestQueryParams'; import { Repository, RepositoryItem } from './interface'; -import { Injectable, Inject } from "@angular/core"; +import { Injectable, Inject } from '@angular/core'; import 'rxjs/add/observable/of'; import { Http } from '@angular/http'; import { SERVICE_CONFIG, IServiceConfig } from '../service.config'; @@ -10,7 +10,7 @@ import { buildHttpRequestOptions, HTTP_JSON_OPTIONS } from '../utils'; /** * Define service methods for handling the repository related things. * Loose couple with project module. - * + * * @export * @abstract * @class RepositoryService @@ -22,24 +22,37 @@ export abstract class RepositoryService { * If pagination needed, set the following parameters in queryParams: * 'page': current page, * 'page_size': page size. - * + * * @abstract * @param {(number | string)} projectId * @param {string} repositoryName * @param {RequestQueryParams} [queryParams] * @returns {(Observable | Promise | Repository)} - * + * * @memberOf RepositoryService */ - abstract getRepositories(projectId: number | string, repositoryName?: string, queryParams?: RequestQueryParams): Observable | Promise | Repository; + abstract getRepositories(projectId: number | string, repositoryName?: string, queryParams?: RequestQueryParams): + Observable | Promise | Repository; + + /** + * Update description of specified repository. + * + * @abstract + * @param {number | string} projectId + * @param {string} repoName + * @returns {(Observable | Promise | Repository)} + * + * @memberOf RepositoryService + */ + abstract updateRepositoryDescription(repoName: string, description: string): Observable | Promise | any; /** * DELETE the specified repository. - * + * * @abstract * @param {string} repositoryName * @returns {(Observable | Promise | any)} - * + * * @memberOf RepositoryService */ abstract deleteRepository(repositoryName: string): Observable | Promise | any; @@ -47,7 +60,7 @@ export abstract class RepositoryService { /** * Implement default service for repository. - * + * * @export * @class RepositoryDefaultService * @extends {RepositoryService} @@ -61,21 +74,22 @@ export class RepositoryDefaultService extends RepositoryService { super(); } - public getRepositories(projectId: number | string, repositoryName?: string, queryParams?: RequestQueryParams): Observable | Promise | Repository { + public getRepositories(projectId: number | string, repositoryName?: string, queryParams?: RequestQueryParams): + Observable | Promise | Repository { if (!projectId) { - return Promise.reject("Bad argument"); + return Promise.reject('Bad argument'); } if (!queryParams) { queryParams = new RequestQueryParams(); } - queryParams.set('project_id', "" + projectId); + queryParams.set('project_id', '' + projectId); if (repositoryName && repositoryName.trim() !== '') { queryParams.set('q', repositoryName); } - let url: string = this.config.repositoryBaseEndpoint ? this.config.repositoryBaseEndpoint : "/api/repositories"; + let url: string = this.config.repositoryBaseEndpoint ? this.config.repositoryBaseEndpoint : '/api/repositories'; return this.http.get(url, buildHttpRequestOptions(queryParams)).toPromise() .then(response => { let result: Repository = { @@ -84,7 +98,7 @@ export class RepositoryDefaultService extends RepositoryService { }; if (response && response.headers) { - let xHeader: string = response.headers.get("X-Total-Count"); + let xHeader: string = response.headers.get('X-Total-Count'); if (xHeader) { result.metadata.xTotalCount = parseInt(xHeader, 0); } @@ -103,6 +117,20 @@ export class RepositoryDefaultService extends RepositoryService { .catch(error => Promise.reject(error)); } + public updateRepositoryDescription(repositoryName: string, description: string, + queryParams?: RequestQueryParams): Observable | Promise | any { + + if (!queryParams) { + queryParams = new RequestQueryParams(); + } + + let baseUrl: string = this.config.repositoryBaseEndpoint ? this.config.repositoryBaseEndpoint : '/api/repositories'; + let url = `${baseUrl}/${repositoryName}`; + return this.http.put(url, {'description': description }, HTTP_JSON_OPTIONS).toPromise() + .then(response => response) + .catch(error => Promise.reject(error)); + } + public deleteRepository(repositoryName: string): Observable | Promise | any { if (!repositoryName) { return Promise.reject('Bad argument'); @@ -112,6 +140,6 @@ export class RepositoryDefaultService extends RepositoryService { return this.http.delete(url, HTTP_JSON_OPTIONS).toPromise() .then(response => response) - .catch(error => { Promise.reject(error) }); + .catch(error => { Promise.reject(error); }); } -} \ No newline at end of file +} diff --git a/src/ui_ng/lib/src/shared/shared.const.ts b/src/ui_ng/lib/src/shared/shared.const.ts index 9a60f623f..bd858757b 100644 --- a/src/ui_ng/lib/src/shared/shared.const.ts +++ b/src/ui_ng/lib/src/shared/shared.const.ts @@ -62,7 +62,8 @@ export const CommonRoutes = { export const enum ConfirmationState { NA, CONFIRMED, CANCEL -} +}; + export const enum ConfirmationButtons { CONFIRM_CANCEL, YES_NO, DELETE_CANCEL, CLOSE -} \ No newline at end of file +}; diff --git a/src/ui_ng/lib/src/tag/tag-detail.component.html.ts b/src/ui_ng/lib/src/tag/tag-detail.component.html.ts index 1c41f60ac..0b92947bd 100644 --- a/src/ui_ng/lib/src/tag/tag-detail.component.html.ts +++ b/src/ui_ng/lib/src/tag/tag-detail.component.html.ts @@ -7,7 +7,7 @@ export const TAG_DETAIL_HTML: string = `
- {{tagDetails.name}} +

{{tagDetails.name}}

{{'TAG.CREATION_TIME_PREFIX' | translate }} {{tagDetails.created | date }} {{'TAG.CREATOR_PREFIX' | translate }} {{author | translate}} diff --git a/src/ui_ng/lib/src/tag/tag.component.css.ts b/src/ui_ng/lib/src/tag/tag.component.css.ts index e969fbe86..e6c99682f 100644 --- a/src/ui_ng/lib/src/tag/tag.component.css.ts +++ b/src/ui_ng/lib/src/tag/tag.component.css.ts @@ -1,4 +1,17 @@ export const TAG_STYLE = ` +.option-right { + padding-right: 18px; + padding-bottom: 6px; +} + +.refresh-btn { + cursor: pointer; +} + +.refresh-btn:hover { + color: #007CBB; +} + .sub-header-title { margin: 12px 0; } @@ -20,18 +33,6 @@ export const TAG_STYLE = ` display: none; } -:host >>> .datagrid .datagrid-body { - background-color: #eee; -} - -:host >>> .datagrid .datagrid-head .datagrid-row { - background-color: #eee; -} - -:host >>> .datagrid .datagrid-body .datagrid-row-master { - background-color: #eee; -} - .truncated { display: inline-block; overflow: hidden; diff --git a/src/ui_ng/lib/src/tag/tag.component.html.ts b/src/ui_ng/lib/src/tag/tag.component.html.ts index a0720a00b..4cbeb40fe 100644 --- a/src/ui_ng/lib/src/tag/tag.component.html.ts +++ b/src/ui_ng/lib/src/tag/tag.component.html.ts @@ -12,50 +12,60 @@ export const TAG_TEMPLATE = `
- -

{{repoName}}

- - {{'REPOSITORY.TAG' | translate}} - {{'REPOSITORY.SIZE' | translate}} - {{'REPOSITORY.PULL_COMMAND' | translate}} - {{'REPOSITORY.VULNERABILITY' | translate}} - {{'REPOSITORY.SIGNED' | translate}} - {{'REPOSITORY.AUTHOR' | translate}} - {{'REPOSITORY.CREATED' | translate}} - {{'REPOSITORY.DOCKER_VERSION' | translate}} - {{'TGA.PLACEHOLDER' | translate }} - - - - - - - - {{t.name}} - {{t.name}} - - {{t.size}} - - - - - - - - - - - - {{'REPOSITORY.NOTARY_IS_UNDETERMINED' | translate}} - - - {{t.author}} - {{t.created | date: 'short'}} - {{t.docker_version}} - - - {{pagination.firstItem + 1}} - {{pagination.lastItem + 1}} {{'REPOSITORY.OF' | translate}} - {{pagination.totalItems}} {{'REPOSITORY.ITEMS' | translate}}     - - -`; +
+
+
+
+ + +
+
+
+
+ + {{'REPOSITORY.TAG' | translate}} + {{'REPOSITORY.SIZE' | translate}} + {{'REPOSITORY.PULL_COMMAND' | translate}} + {{'REPOSITORY.VULNERABILITY' | translate}} + {{'REPOSITORY.SIGNED' | translate}} + {{'REPOSITORY.AUTHOR' | translate}} + {{'REPOSITORY.CREATED' | translate}} + {{'REPOSITORY.DOCKER_VERSION' | translate}} + {{'TGA.PLACEHOLDER' | translate }} + + + + + + + + {{t.name}} + {{t.name}} + + {{t.size}} + + + + + + + + + + + + {{'REPOSITORY.NOTARY_IS_UNDETERMINED' | translate}} + + + {{t.author}} + {{t.created | date: 'short'}} + {{t.docker_version}} + + + {{pagination.firstItem + 1}} - {{pagination.lastItem + 1}} {{'REPOSITORY.OF' | translate}} + {{pagination.totalItems}} {{'REPOSITORY.ITEMS' | translate}}     + + + +
+
`; diff --git a/src/ui_ng/lib/src/tag/tag.component.ts b/src/ui_ng/lib/src/tag/tag.component.ts index c0fe3f087..6dd274657 100644 --- a/src/ui_ng/lib/src/tag/tag.component.ts +++ b/src/ui_ng/lib/src/tag/tag.component.ts @@ -23,7 +23,7 @@ import { ElementRef } from '@angular/core'; -import { TagService, VulnerabilitySeverity } from '../service/index'; +import { TagService, VulnerabilitySeverity, RequestQueryParams } from '../service/index'; import { ErrorHandler } from '../error-handler/error-handler'; import { ChannelService } from '../channel/index'; import { @@ -44,13 +44,17 @@ import { TAG_STYLE } from './tag.component.css'; import { toPromise, CustomComparator, - VULNERABILITY_SCAN_STATUS + calculatePage, + doFiltering, + doSorting, + VULNERABILITY_SCAN_STATUS, + DEFAULT_PAGE_SIZE } from '../utils'; import { TranslateService } from '@ngx-translate/core'; import { State, Comparator } from 'clarity-angular'; -import {CopyInputComponent} from "../push-image/copy-input.component"; +import {CopyInputComponent} from '../push-image/copy-input.component'; @Component({ selector: 'hbr-tag', @@ -60,6 +64,7 @@ import {CopyInputComponent} from "../push-image/copy-input.component"; }) export class TagComponent implements OnInit { + signedCon: {[key: string]: any | string[]} = {}; @Input() projectId: number; @Input() repoName: string; @Input() isEmbedded: boolean; @@ -82,6 +87,7 @@ export class TagComponent implements OnInit { digestId: string; staticBackdrop: boolean = true; closable: boolean = false; + lastFilteredTagName: string; createdComparator: Comparator = new CustomComparator('created', 'date'); @@ -94,6 +100,10 @@ export class TagComponent implements OnInit { @ViewChild('digestTarget') textInput: ElementRef; @ViewChild('copyInput') copyInput: CopyInputComponent; + pageSize: number = DEFAULT_PAGE_SIZE; + currentPage = 1; + totalCount = 0; + currentState: State; constructor( private errorHandler: ErrorHandler, @@ -136,6 +146,61 @@ export class TagComponent implements OnInit { } this.retrieve(); + this.lastFilteredTagName = ''; + } + + doSearchTagNames(tagName: string) { + this.lastFilteredTagName = tagName; + this.currentPage = 1; + + let st: State = this.currentState; + if (!st) { + st = { page: {} }; + } + st.page.size = this.pageSize; + st.page.from = 0; + st.page.to = this.pageSize - 1; + st.filters = [{property: 'name', value: this.lastFilteredTagName}]; + this.clrLoad(st); + } + + clrLoad(state: State): void { + // Keep it for future filtering and sorting + this.currentState = state; + + let pageNumber: number = calculatePage(state); + if (pageNumber <= 0) { pageNumber = 1; } + + // Pagination + let params: RequestQueryParams = new RequestQueryParams(); + params.set('page', '' + pageNumber); + params.set('page_size', '' + this.pageSize); + + this.loading = true; + + toPromise(this.tagService.getTags( + this.repoName, + params)) + .then((tags: Tag[]) => { + this.signedCon = {}; + // Do filtering and sorting + this.tags = doFiltering(tags, state); + this.tags = doSorting(this.tags, state); + + this.loading = false; + }) + .catch(error => { + this.loading = false; + this.errorHandler.error(error); + }); + + // Force refresh view + let hnd = setInterval(() => this.ref.markForCheck(), 100); + setTimeout(() => clearInterval(hnd), 5000); + } + + refresh() { + this.doSearchTagNames(''); } retrieve() { @@ -146,7 +211,7 @@ export class TagComponent implements OnInit { toPromise(this.tagService .getTags(this.repoName)) .then(items => { - //To keep easy use for vulnerability bar + // To keep easy use for vulnerability bar items.forEach((t: Tag) => { if (!t.scan_overview) { t.scan_overview = { @@ -163,7 +228,7 @@ export class TagComponent implements OnInit { signatures.push(t.name); } - //size + // size t.size = this.sizeTransform(t.size); }); this.tags = items; @@ -243,20 +308,20 @@ export class TagComponent implements OnInit { onSuccess($event: any): void { this.copyFailed = false; - //Directly close dialog + // Directly close dialog this.showTagManifestOpened = false; } onError($event: any): void { - //Show error + // Show error this.copyFailed = true; - //Select all text + // Select all text if (this.textInput) { this.textInput.nativeElement.select(); } } - //Get vulnerability scanning status + // Get vulnerability scanning status scanStatus(t: Tag): string { if (t && t.scan_overview && t.scan_overview.scan_status) { return t.scan_overview.scan_status; @@ -272,7 +337,7 @@ export class TagComponent implements OnInit { t.scan_overview.components.total > 0 ? true : false; } - //Whether show the 'scan now' menu + // Whether show the 'scan now' menu canScanNow(t: Tag): boolean { if (!this.withClair) { return false; } if (!this.hasProjectAdminRole) { return false; } @@ -282,14 +347,14 @@ export class TagComponent implements OnInit { st !== VULNERABILITY_SCAN_STATUS.running; } - //Trigger scan + // Trigger scan scanNow(tagId: string): void { if (tagId) { - this.channel.publishScanEvent(this.repoName + "/" + tagId); + this.channel.publishScanEvent(this.repoName + '/' + tagId); } } - //pull command + // pull command onCpError($event: any): void { this.copyInput.setPullCommendShow(); } diff --git a/src/ui_ng/package.json b/src/ui_ng/package.json index 45c377d47..0e801791f 100644 --- a/src/ui_ng/package.json +++ b/src/ui_ng/package.json @@ -31,7 +31,7 @@ "clarity-icons": "^0.9.8", "clarity-ui": "^0.9.8", "core-js": "^2.4.1", - "harbor-ui": "0.5.27", + "harbor-ui": "0.6.0", "intl": "^1.2.5", "mutationobserver-shim": "^0.3.2", "ngx-cookie": "^1.0.0", diff --git a/src/ui_ng/src/app/harbor-routing.module.ts b/src/ui_ng/src/app/harbor-routing.module.ts index ed9f876ba..fa01bf976 100644 --- a/src/ui_ng/src/app/harbor-routing.module.ts +++ b/src/ui_ng/src/app/harbor-routing.module.ts @@ -106,6 +106,14 @@ const harborRoutes: Routes = [ projectResolver: ProjectRoutingResolver } }, + { + path: 'projects/:id/repositories/:repo', + component: TagRepositoryComponent, + canActivate: [MemberGuard], + resolve: { + projectResolver: ProjectRoutingResolver + } + }, { path: 'projects/:id', component: ProjectDetailComponent, @@ -118,6 +126,10 @@ const harborRoutes: Routes = [ path: 'repositories', component: RepositoryPageComponent }, + { + path: 'repositories/:repo/tags', + component: TagRepositoryComponent, + }, { path: 'repositories/:repo/tags/:tag', component: TagDetailPageComponent diff --git a/src/ui_ng/src/app/repository/repository-page.component.html b/src/ui_ng/src/app/repository/repository-page.component.html index 34e6bd8d0..3cce6acdd 100644 --- a/src/ui_ng/src/app/repository/repository-page.component.html +++ b/src/ui_ng/src/app/repository/repository-page.component.html @@ -1,3 +1,3 @@
- +
\ No newline at end of file diff --git a/src/ui_ng/src/app/repository/repository-page.component.ts b/src/ui_ng/src/app/repository/repository-page.component.ts index edd9921b7..eed90d4c1 100644 --- a/src/ui_ng/src/app/repository/repository-page.component.ts +++ b/src/ui_ng/src/app/repository/repository-page.component.ts @@ -48,7 +48,7 @@ export class RepositoryPageComponent implements OnInit { } watchTagClickEvent(tagEvt: TagClickEvent): void { - let linkUrl = ['harbor', 'projects', tagEvt.project_id, 'repositories', tagEvt.repository_name, 'tags', tagEvt.tag_name]; + let linkUrl = ['harbor', 'projects', tagEvt.project_id, 'repositories', tagEvt.repository_name]; this.router.navigate(linkUrl); } -} \ No newline at end of file +}; diff --git a/src/ui_ng/src/app/repository/tag-repository/tag-repository.component.html b/src/ui_ng/src/app/repository/tag-repository/tag-repository.component.html index 3305a4db7..45772b591 100644 --- a/src/ui_ng/src/app/repository/tag-repository/tag-repository.component.html +++ b/src/ui_ng/src/app/repository/tag-repository/tag-repository.component.html @@ -1,5 +1,3 @@ \ No newline at end of file diff --git a/src/ui_ng/src/app/repository/tag-repository/tag-repository.component.ts b/src/ui_ng/src/app/repository/tag-repository/tag-repository.component.ts index 4286bf6de..9adc20588 100644 --- a/src/ui_ng/src/app/repository/tag-repository/tag-repository.component.ts +++ b/src/ui_ng/src/app/repository/tag-repository/tag-repository.component.ts @@ -38,11 +38,16 @@ export class TagRepositoryComponent implements OnInit { } ngOnInit() { + this.projectId = this.route.snapshot.params['id']; + if (!this.projectId) { + this.projectId = this.route.snapshot.parent.params['id']; + }; + // let resolverData = this.route.snapshot.parent.data; let resolverData = this.route.snapshot.data; + if (resolverData) { this.hasProjectAdminRole = (resolverData['projectResolver']).has_project_admin_role; } - this.projectId = this.route.snapshot.params['id']; this.repoName = this.route.snapshot.params['repo']; this.registryUrl = this.appConfigService.getConfig().registry_url; @@ -64,4 +69,8 @@ export class TagRepositoryComponent implements OnInit { let linkUrl = ['harbor', 'projects', tagEvt.project_id, 'repositories', tagEvt.repository_name, 'tags', tagEvt.tag_name]; this.router.navigate(linkUrl); } + + goBack(tag: string): void { + this.router.navigate(["harbor", "projects", this.projectId, "repositories"]); + } } \ No newline at end of file diff --git a/src/ui_ng/src/i18n/lang/en-us-lang.json b/src/ui_ng/src/i18n/lang/en-us-lang.json index d0a7a1c75..767418f11 100644 --- a/src/ui_ng/src/i18n/lang/en-us-lang.json +++ b/src/ui_ng/src/i18n/lang/en-us-lang.json @@ -32,7 +32,8 @@ "YES": "YES", "NO": "NO", "NEGATIVE": "NEGATIVE", - "COPY": "COPY" + "COPY": "COPY", + "EDIT": "EDIT" }, "TOOLTIP": { "EMAIL": "Email should be a valid email address like name@example.com.", @@ -364,7 +365,10 @@ "DELETED_TAG_SUCCESS": "Deleted tag successfully.", "COPY": "Copy", "NOTARY_IS_UNDETERMINED": "Cannot determine the signature of this tag.", - "PLACEHOLDER": "We couldn't find any repositories!" + "PLACEHOLDER": "We couldn't find any repositories!", + "INFO": "Info", + "NO_INFO": "No description info for this repository", + "IMAGE": "Images" }, "ALERT": { "FORM_CHANGE_CONFIRMATION": "Some changes are not saved yet. Do you want to cancel?" @@ -554,7 +558,8 @@ "SCAN_COMPLETION_TIME": "Scan Completed", "IMAGE_VULNERABILITIES": "Image Vulnerabilities", "PLACEHOLDER": "We couldn't find any tags!", - "COPY_ERROR": "Copy failed, please try to manually copy." + "COPY_ERROR": "Copy failed, please try to manually copy.", + "FILTER_FOR_TAGS": "Filter Tags" }, "UNKNOWN_ERROR": "Unknown errors have occurred. Please try again later.", "UNAUTHORIZED_ERROR": "Your session is invalid or has expired. You need to sign in to continue your action.", diff --git a/src/ui_ng/src/i18n/lang/es-es-lang.json b/src/ui_ng/src/i18n/lang/es-es-lang.json index a3ee89d89..1df8b0236 100644 --- a/src/ui_ng/src/i18n/lang/es-es-lang.json +++ b/src/ui_ng/src/i18n/lang/es-es-lang.json @@ -32,7 +32,8 @@ "YES": "SI", "NO": "NO", "NEGATIVE": "NEGATIVO", - "COPY": "COPY" + "COPY": "COPY", + "EDIT": "EDITAR" }, "TOOLTIP": { "EMAIL": "El email debe ser una dirección válida como nombre@ejemplo.com.", @@ -365,7 +366,10 @@ "DELETED_TAG_SUCCESS": "Etiqueta eliminada satisfactoriamente.", "COPY": "Copiar", "NOTARY_IS_UNDETERMINED": "Cannot determine the signature of this tag.", - "PLACEHOLDER": "We couldn't find any repositories!" + "PLACEHOLDER": "We couldn't find any repositories!", + "INFO": "Información", + "NO_INFO": "Sin información de descripción para este repositorio", + "IMAGE": "Imágenes" }, "ALERT": { "FORM_CHANGE_CONFIRMATION": "Algunos cambios no se han guardado aún. ¿Quiere cancelar?" @@ -553,7 +557,8 @@ "SCAN_COMPLETION_TIME": "Scan Completed", "IMAGE_VULNERABILITIES": "Image Vulnerabilities", "PLACEHOLDER": "We couldn't find any tags!", - "COPY_ERROR": "Copy failed, please try to manually copy." + "COPY_ERROR": "Copy failed, please try to manually copy.", + "FILTER_FOR_TAGS": "Etiquetas de filtro" }, "UNKNOWN_ERROR": "Ha ocurrido un error desconocido. Por favor, inténtelo de nuevo más tarde.", "UNAUTHORIZED_ERROR": "La sesión no es válida o ha caducado. Necesita identificarse de nuevo para llevar a cabo esa acción.", diff --git a/src/ui_ng/src/i18n/lang/zh-cn-lang.json b/src/ui_ng/src/i18n/lang/zh-cn-lang.json index ad3c0037c..015b25f3c 100644 --- a/src/ui_ng/src/i18n/lang/zh-cn-lang.json +++ b/src/ui_ng/src/i18n/lang/zh-cn-lang.json @@ -32,7 +32,8 @@ "YES": "是", "NO": "否", "NEGATIVE": "否", - "COPY": "拷贝" + "COPY": "拷贝", + "EDIT": "编辑" }, "TOOLTIP": { "EMAIL": "请使用正确的邮箱地址,比如name@example.com。", @@ -364,7 +365,10 @@ "DELETED_TAG_SUCCESS": "成功删除镜像标签。", "COPY": "复制", "NOTARY_IS_UNDETERMINED": "无法确定镜像标签签名。", - "PLACEHOLDER": "未发现任何镜像库!" + "PLACEHOLDER": "未发现任何镜像库!", + "INFO": "描述信息", + "NO_INFO": "此镜像仓库没有描述信息", + "IMAGE": "镜像" }, "ALERT": { "FORM_CHANGE_CONFIRMATION": "表单内容改变,确认是否取消?" @@ -425,7 +429,7 @@ "TOKEN_EXPIRATION": "由令牌服务创建的令牌的过期时间(分钟),默认为30分钟。", "PRO_CREATION_RESTRICTION": "用来确定哪些用户有权限创建项目,默认为’所有人‘,设置为’仅管理员‘则只有管理员可以创建项目。", "ROOT_CERT_DOWNLOAD": "下载镜像库根证书.", - "SCANNING_POLICY": "基于不同需求设置镜像扫描策略。‘无’:不设置任何策略;‘每日定时’:每天在设置的时间定时执行扫描。". + "SCANNING_POLICY": "基于不同需求设置镜像扫描策略。‘无’:不设置任何策略;‘每日定时’:每天在设置的时间定时执行扫描。", "VERIFY_CERT": "检查来自LDAP服务端的证书" }, "LDAP": { @@ -521,7 +525,7 @@ "SEVERITY": { "HIGH": "严重", "MEDIUM": "中等", - "LOW": "一般", + "LOW": "较低", "NEGLIGIBLE": "可忽略", "UNKNOWN": "未知", "NONE": "无" @@ -554,7 +558,8 @@ "SCAN_COMPLETION_TIME": "扫描完成时间", "IMAGE_VULNERABILITIES": "镜像缺陷", "PLACEHOLDER": "未发现任何标签!", - "COPY_ERROR": "拷贝失败,请尝试手动拷贝。" + "COPY_ERROR": "拷贝失败,请尝试手动拷贝。", + "FILTER_FOR_TAGS": "过滤项目" }, "UNKNOWN_ERROR": "发生未知错误,请稍后再试。", "UNAUTHORIZED_ERROR": "会话无效或者已经过期, 请重新登录以继续。", diff --git a/tests/resources/Harbor-Pages/Project.robot b/tests/resources/Harbor-Pages/Project.robot index ad88260f2..134ab8376 100644 --- a/tests/resources/Harbor-Pages/Project.robot +++ b/tests/resources/Harbor-Pages/Project.robot @@ -169,6 +169,18 @@ Do Log Advanced Search ${rc} = Get Matching Xpath Count //audit-log//clr-dg-row Should Be Equal As Integers ${rc} 0 +Go Into Repo + [Arguments] ${repoName} + Sleep 2 + Click Element xpath=//*[@id="search_input"] + Sleep 2 + Input Text xpath=//*[@id="search_input"] ${repoName} + Sleep 8 + Wait Until Page Contains ${repoName} + Click Element xpath=//*[@id="results"]/list-repository-ro/clr-datagrid/div/div/div/div/div[2]/clr-dg-row/clr-dg-row-master/clr-dg-cell[1]/a + Sleep 2 + Capture Page Screenshot gointo_${repoName}.png + Expand Repo [Arguments] ${projectname} Click Element //repository//clr-dg-row-master[contains(.,'${projectname}')]//button/clr-icon @@ -180,6 +192,26 @@ Scan Repo Click Element //hbr-tag//clr-dg-row-master[contains(.,'${tagname}')]//clr-dg-action-overflow//button[contains(.,'Scan')] Sleep 15 +Edit Repo Info + Click Element //*[@id="repo-info"] + Sleep 1 + Page Should Contain Element //*[@id="info"]/form/div[2]/h3 + # Cancel input + Click Element xpath=//*[@id="info-edit-button"]/button + Input Text xpath=//*[@id="info"]/form/div[2]/textarea test_description_info + Click Element xpath=//*[@id="info"]/form/div[3]/button[2] + Sleep 1 + Click Element xpath=//*[@id="info"]/form/confirmation-dialog/clr-modal/div/div[1]/div/div[1]/div/div[3]/button[2] + Sleep 1 + Page Should Contain Element //*[@id="info"]/form/div[2]/h3 + # Confirm input + Click Element xpath=//*[@id="info-edit-button"]/button + Input Text xpath=//*[@id="info"]/form/div[2]/textarea test_description_info + Click Element xpath=//*[@id="info"]/form/div[3]/button[1] + Sleep 1 + Page Should Contain test_description_info + Capture Page Screenshot RepoInfo.png + Summary Chart Should Display [Arguments] ${tagname} Page Should Contain Element //clr-dg-row-master[contains(.,'${tagname}')]//hbr-vulnerability-bar//hbr-vulnerability-summary-chart diff --git a/tests/robot-cases/Group0-BAT/BAT.robot b/tests/robot-cases/Group0-BAT/BAT.robot index 9ce626c82..7ca931c65 100644 --- a/tests/robot-cases/Group0-BAT/BAT.robot +++ b/tests/robot-cases/Group0-BAT/BAT.robot @@ -247,15 +247,15 @@ Test Case - Create An Replication Rule New Endpoint Create An New Rule With New Endpoint policy_name=test_policy_${d} policy_description=test_description destination_name=test_destination_name_${d} destination_url=test_destination_url_${d} destination_username=test_destination_username destination_password=test_destination_password Close Browser -Test Case - Scan A Tag +Test Case - Scan A Tag In The Repo Init Chrome Driver ${d}= get current date result_format=%m%s Create An New Project With New User url=${HARBOR_URL} username=tester${d} email=tester${d}@vmware.com realname=tester${d} newPassword=Test1@34 comment=harbor projectname=project${d} public=false Push Image ${ip} tester${d} Test1@34 project${d} hello-world - Go Into Project project${d} - Expand Repo project${d} + Go Into Repo project${d}/hello-world Scan Repo latest Summary Chart Should Display latest + Edit Repo Info Close Browser Test Case - Manage Project Member