mirror of
https://github.com/goharbor/harbor.git
synced 2024-12-23 00:57:44 +01:00
Refactor repo and tag view with components in harbor-ui lib
This commit is contained in:
parent
8e20e66f8c
commit
44e208f027
@ -30,7 +30,7 @@ import { ConfirmationDialogComponent } from '../confirmation-dialog/confirmation
|
||||
import { ConfirmationMessage } from '../confirmation-dialog/confirmation-message';
|
||||
import { ConfirmationAcknowledgement } from '../confirmation-dialog/confirmation-state-message';
|
||||
import { Subscription } from 'rxjs/Subscription';
|
||||
import { Tag } from '../service/interface';
|
||||
import { Tag, TagClickEvent } from '../service/interface';
|
||||
|
||||
@Component({
|
||||
selector: 'hbr-repository-stackview',
|
||||
@ -44,7 +44,7 @@ export class RepositoryStackviewComponent implements OnInit {
|
||||
|
||||
@Input() hasSignedIn: boolean;
|
||||
@Input() hasProjectAdminRole: boolean;
|
||||
@Output() tagClickEvent = new EventEmitter<Tag>();
|
||||
@Output() tagClickEvent = new EventEmitter<TagClickEvent>();
|
||||
|
||||
lastFilteredRepoName: string;
|
||||
repositories: Repository[];
|
||||
@ -132,7 +132,7 @@ export class RepositoryStackviewComponent implements OnInit {
|
||||
this.retrieve();
|
||||
}
|
||||
|
||||
watchTagClickEvt(tag: Tag): void {
|
||||
this.tagClickEvent.emit(tag);
|
||||
watchTagClickEvt(tagClickEvt: TagClickEvent): void {
|
||||
this.tagClickEvent.emit(tagClickEvt);
|
||||
}
|
||||
}
|
@ -182,4 +182,10 @@ export interface VulnerabilitySummary {
|
||||
package_With_low?: number;
|
||||
package_with_unknown?: number;
|
||||
complete_timestamp: Date;
|
||||
}
|
||||
|
||||
export interface TagClickEvent {
|
||||
project_id: string | number;
|
||||
repository_name: string;
|
||||
tag_name: string;
|
||||
}
|
@ -22,7 +22,7 @@ import { ConfirmationDialogComponent } from '../confirmation-dialog/confirmation
|
||||
import { ConfirmationMessage } from '../confirmation-dialog/confirmation-message';
|
||||
import { ConfirmationAcknowledgement } from '../confirmation-dialog/confirmation-state-message';
|
||||
|
||||
import { Tag } from '../service/interface';
|
||||
import { Tag, TagClickEvent } from '../service/interface';
|
||||
|
||||
import { TAG_TEMPLATE } from './tag.component.html';
|
||||
import { TAG_STYLE } from './tag.component.css';
|
||||
@ -51,7 +51,7 @@ export class TagComponent implements OnInit {
|
||||
@Input() withNotary: boolean;
|
||||
|
||||
@Output() refreshRepo = new EventEmitter<boolean>();
|
||||
@Output() tagClickEvent = new EventEmitter<Tag>();
|
||||
@Output() tagClickEvent = new EventEmitter<TagClickEvent>();
|
||||
|
||||
tags: Tag[];
|
||||
|
||||
@ -169,7 +169,12 @@ export class TagComponent implements OnInit {
|
||||
|
||||
onTagClick(tag: Tag): void {
|
||||
if (tag) {
|
||||
this.tagClickEvent.emit(tag);
|
||||
let evt: TagClickEvent = {
|
||||
project_id: this.projectId,
|
||||
repository_name: this.repoName,
|
||||
tag_name: tag.name
|
||||
};
|
||||
this.tagClickEvent.emit(evt);
|
||||
}
|
||||
}
|
||||
}
|
@ -32,7 +32,7 @@
|
||||
"clarity-icons": "^0.9.0",
|
||||
"clarity-ui": "^0.9.0",
|
||||
"core-js": "^2.4.1",
|
||||
"harbor-ui": "^0.1.83",
|
||||
"harbor-ui": "^0.1.85",
|
||||
"intl": "^1.2.5",
|
||||
"mutationobserver-shim": "^0.3.2",
|
||||
"ngx-clipboard": "^8.0.2",
|
||||
|
@ -12,7 +12,7 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
import { Project } from '../../project/project';
|
||||
import { Repository } from '../../repository/repository';
|
||||
import { Repository } from 'harbor-ui';
|
||||
|
||||
export class SearchResults {
|
||||
constructor(){
|
||||
|
@ -26,7 +26,7 @@ import { DestinationPageComponent } from './replication/destination/destination-
|
||||
|
||||
import { ProjectDetailComponent } from './project/project-detail/project-detail.component';
|
||||
|
||||
import { RepositoryComponent } from './repository/repository.component';
|
||||
import { RepositoryPageComponent } from './repository/repository-page.component';
|
||||
import { TagRepositoryComponent } from './repository/tag-repository/tag-repository.component';
|
||||
import { ReplicationPageComponent } from './replication/replication-page.component';
|
||||
import { MemberComponent } from './project/member/member.component';
|
||||
@ -48,6 +48,8 @@ import { LeavingConfigRouteDeactivate } from './shared/route/leaving-config-deac
|
||||
|
||||
import { MemberGuard } from './shared/route/member-guard-activate.service';
|
||||
|
||||
import { TagDetailPageComponent } from './repository/tag-detail/tag-detail-page.component';
|
||||
|
||||
const harborRoutes: Routes = [
|
||||
{ path: '', redirectTo: 'harbor', pathMatch: 'full' },
|
||||
{ path: 'reset_password', component: ResetPasswordComponent },
|
||||
@ -108,20 +110,24 @@ const harborRoutes: Routes = [
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'repository',
|
||||
component: RepositoryComponent
|
||||
path: 'repositories',
|
||||
component: RepositoryPageComponent
|
||||
},
|
||||
{
|
||||
path: 'replication',
|
||||
path: 'repositories/:repo/tags/:tag',
|
||||
component: TagDetailPageComponent
|
||||
},
|
||||
{
|
||||
path: 'replications',
|
||||
component: ReplicationPageComponent,
|
||||
canActivate: [SystemAdminGuard]
|
||||
},
|
||||
{
|
||||
path: 'member',
|
||||
path: 'members',
|
||||
component: MemberComponent
|
||||
},
|
||||
{
|
||||
path: 'log',
|
||||
path: 'logs',
|
||||
component: AuditLogComponent
|
||||
}
|
||||
]
|
||||
|
@ -68,7 +68,7 @@ export class ListProjectComponent {
|
||||
goToLink(proId: number): void {
|
||||
this.searchTrigger.closeSearch(true);
|
||||
|
||||
let linkUrl = ['harbor', 'projects', proId, 'repository'];
|
||||
let linkUrl = ['harbor', 'projects', proId, 'repositories'];
|
||||
this.router.navigate(linkUrl);
|
||||
}
|
||||
|
||||
|
@ -3,19 +3,19 @@
|
||||
|
||||
<h1 class="sub-header-title">{{currentProject.name}} <span class="role-label" *ngIf="isMember">{{roleName | translate}}</span></h1>
|
||||
<nav class="subnav sub-nav-bg-color">
|
||||
<ul class="nav">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" routerLink="repository" routerLinkActive="active">{{'PROJECT_DETAIL.REPOSITORIES' | translate}}</a>
|
||||
</li>
|
||||
<li class="nav-item" *ngIf="isSystemAdmin || isMember">
|
||||
<a class="nav-link" routerLink="member" routerLinkActive="active">{{'PROJECT_DETAIL.USERS' | translate}}</a>
|
||||
</li>
|
||||
<li class="nav-item" *ngIf="isSystemAdmin || isMember">
|
||||
<a class="nav-link" routerLink="log" routerLinkActive="active">{{'PROJECT_DETAIL.LOGS' | translate}}</a>
|
||||
</li>
|
||||
<li class="nav-item" *ngIf="isSessionValid && isSystemAdmin">
|
||||
<a class="nav-link" routerLink="replication" routerLinkActive="active">{{'PROJECT_DETAIL.REPLICATION' | translate}}</a>
|
||||
</li>
|
||||
</ul>
|
||||
<ul class="nav">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" routerLink="repositories" routerLinkActive="active">{{'PROJECT_DETAIL.REPOSITORIES' | translate}}</a>
|
||||
</li>
|
||||
<li class="nav-item" *ngIf="isSystemAdmin || isMember">
|
||||
<a class="nav-link" routerLink="members" routerLinkActive="active">{{'PROJECT_DETAIL.USERS' | translate}}</a>
|
||||
</li>
|
||||
<li class="nav-item" *ngIf="isSystemAdmin || isMember">
|
||||
<a class="nav-link" routerLink="logs" routerLinkActive="active">{{'PROJECT_DETAIL.LOGS' | translate}}</a>
|
||||
</li>
|
||||
<li class="nav-item" *ngIf="isSessionValid && isSystemAdmin">
|
||||
<a class="nav-link" routerLink="replications" routerLinkActive="active">{{'PROJECT_DETAIL.REPLICATION' | translate}}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<router-outlet></router-outlet>
|
@ -24,40 +24,44 @@ export class ProjectRoutingResolver implements Resolve<Project>{
|
||||
|
||||
constructor(
|
||||
private sessionService: SessionService,
|
||||
private projectService: ProjectService,
|
||||
private router: Router) {}
|
||||
private projectService: ProjectService,
|
||||
private router: Router) { }
|
||||
|
||||
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Promise<Project> {
|
||||
let projectId = route.params['id'];
|
||||
//Support both parameters and query parameters
|
||||
let projectId = route.params['id'];
|
||||
if (!projectId) {
|
||||
projectId = route.queryParams['project_id'];
|
||||
}
|
||||
return this.projectService
|
||||
.getProject(projectId)
|
||||
.toPromise()
|
||||
.then((project: Project)=> {
|
||||
if(project) {
|
||||
let currentUser = this.sessionService.getCurrentUser();
|
||||
if(currentUser) {
|
||||
let projectMembers = this.sessionService.getProjectMembers();
|
||||
if(projectMembers) {
|
||||
let currentMember = projectMembers.find(m=>m.user_id === currentUser.user_id);
|
||||
if(currentMember) {
|
||||
project.is_member = true;
|
||||
project.has_project_admin_role = (currentMember.role_name === 'projectAdmin');
|
||||
project.role_name = currentMember.role_name;
|
||||
}
|
||||
}
|
||||
if(currentUser.has_admin_role === 1) {
|
||||
project.has_project_admin_role = true;
|
||||
}
|
||||
}
|
||||
return project;
|
||||
} else {
|
||||
this.router.navigate(['/harbor', 'projects']);
|
||||
return null;
|
||||
}
|
||||
}).catch(error=>{
|
||||
this.router.navigate(['/harbor', 'projects']);
|
||||
return null;
|
||||
});
|
||||
|
||||
}
|
||||
.getProject(projectId)
|
||||
.toPromise()
|
||||
.then((project: Project) => {
|
||||
if (project) {
|
||||
let currentUser = this.sessionService.getCurrentUser();
|
||||
if (currentUser) {
|
||||
let projectMembers = this.sessionService.getProjectMembers();
|
||||
if (projectMembers) {
|
||||
let currentMember = projectMembers.find(m => m.user_id === currentUser.user_id);
|
||||
if (currentMember) {
|
||||
project.is_member = true;
|
||||
project.has_project_admin_role = (currentMember.role_name === 'projectAdmin');
|
||||
project.role_name = currentMember.role_name;
|
||||
}
|
||||
}
|
||||
if (currentUser.has_admin_role === 1) {
|
||||
project.has_project_admin_role = true;
|
||||
}
|
||||
}
|
||||
return project;
|
||||
} else {
|
||||
this.router.navigate(['/harbor', 'projects']);
|
||||
return null;
|
||||
}
|
||||
}).catch(error => {
|
||||
this.router.navigate(['/harbor', 'projects']);
|
||||
return null;
|
||||
});
|
||||
|
||||
}
|
||||
}
|
@ -1,17 +0,0 @@
|
||||
<clr-datagrid (clrDgRefresh)="refresh($event)">
|
||||
<clr-dg-column>{{'REPOSITORY.NAME' | translate}}</clr-dg-column>
|
||||
<clr-dg-column>{{'REPOSITORY.TAGS_COUNT' | translate}}</clr-dg-column>
|
||||
<clr-dg-column>{{'REPOSITORY.PULL_COUNT' | translate}}</clr-dg-column>
|
||||
<clr-dg-row *clrDgItems="let r of repositories" [clrDgItem]='r'>
|
||||
<clr-dg-action-overflow [hidden]="!hasProjectAdminRole">
|
||||
<button class="action-item" (click)="deleteRepo(r.name)">{{'REPOSITORY.DELETE' | translate}}</button>
|
||||
</clr-dg-action-overflow>
|
||||
<clr-dg-cell><a href="javascript:void(0)" (click)="gotoLink(projectId || r.project_id, r.name || r.repository_name)">{{r.name || r.repository_name}}</a></clr-dg-cell>
|
||||
<clr-dg-cell>{{r.tags_count}}</clr-dg-cell>
|
||||
<clr-dg-cell>{{r.pull_count}}</clr-dg-cell>
|
||||
</clr-dg-row>
|
||||
<clr-dg-footer>
|
||||
{{(repositories ? repositories.length : 0)}} {{'REPOSITORY.ITEMS' | translate}}
|
||||
<clr-dg-pagination [clrDgPageSize]="15"></clr-dg-pagination>
|
||||
</clr-dg-footer>
|
||||
</clr-datagrid>
|
@ -1,65 +0,0 @@
|
||||
// Copyright (c) 2017 VMware, Inc. All Rights Reserved.
|
||||
//
|
||||
// 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, Input, Output, EventEmitter, OnInit, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { Repository } from '../repository';
|
||||
import { State } from 'clarity-angular';
|
||||
|
||||
import { SearchTriggerService } from '../../base/global-search/search-trigger.service';
|
||||
|
||||
@Component({
|
||||
selector: 'list-repository',
|
||||
templateUrl: 'list-repository.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class ListRepositoryComponent implements OnInit {
|
||||
|
||||
@Input() projectId: number;
|
||||
@Input() repositories: Repository[];
|
||||
|
||||
@Output() delete = new EventEmitter<string>();
|
||||
@Output() paginate = new EventEmitter<State>();
|
||||
|
||||
@Input() hasProjectAdminRole: boolean;
|
||||
|
||||
pageOffset: number = 1;
|
||||
|
||||
constructor(
|
||||
private router: Router,
|
||||
private searchTrigger: SearchTriggerService,
|
||||
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 {
|
||||
this.searchTrigger.closeSearch(true);
|
||||
|
||||
let linkUrl = ['harbor', 'tags', projectId, repoName];
|
||||
this.router.navigate(linkUrl);
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,3 @@
|
||||
<div style="margin-top: 24px;">
|
||||
<hbr-repository-stackview [projectId]="projectId" [hasSignedIn]="hasSignedIn" [hasProjectAdminRole]="hasProjectAdminRole" (tagClickEvent)="watchTagClickEvent($event)"></hbr-repository-stackview>
|
||||
</div>
|
51
src/ui_ng/src/app/repository/repository-page.component.ts
Normal file
51
src/ui_ng/src/app/repository/repository-page.component.ts
Normal file
@ -0,0 +1,51 @@
|
||||
// Copyright (c) 2017 VMware, Inc. All Rights Reserved.
|
||||
//
|
||||
// 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 } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
|
||||
import { Project } from '../project/project';
|
||||
import { SessionService } from '../shared/session.service';
|
||||
|
||||
import { TagClickEvent } from 'harbor-ui';
|
||||
|
||||
@Component({
|
||||
selector: 'repository',
|
||||
templateUrl: 'repository-page.component.html'
|
||||
})
|
||||
export class RepositoryPageComponent implements OnInit {
|
||||
projectId: number;
|
||||
hasProjectAdminRole: boolean;
|
||||
hasSignedIn: boolean;
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private session: SessionService,
|
||||
private router: Router
|
||||
) {
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.projectId = this.route.snapshot.parent.params['id'];
|
||||
let resolverData = this.route.snapshot.parent.data;
|
||||
if (resolverData) {
|
||||
this.hasProjectAdminRole = (<Project>resolverData['projectResolver']).has_project_admin_role;
|
||||
}
|
||||
this.hasSignedIn = this.session.getCurrentUser() !== null;
|
||||
}
|
||||
|
||||
watchTagClickEvent(tagEvt: TagClickEvent): void {
|
||||
let linkUrl = ['harbor', 'projects', tagEvt.project_id, 'repositories', tagEvt.repository_name, 'tags', tagEvt.tag_name];
|
||||
this.router.navigate(linkUrl);
|
||||
}
|
||||
}
|
@ -1,5 +0,0 @@
|
||||
.option-right {
|
||||
padding-right: 16px;
|
||||
margin-top: 32px;
|
||||
margin-bottom: 12px;
|
||||
}
|
@ -1,15 +0,0 @@
|
||||
<div class="row">
|
||||
<div class="col-lg-12 col-md-12 col-sm-12 col-xs-12">
|
||||
<div class="row flex-items-xs-right option-right">
|
||||
<div class="flex-xs-middle">
|
||||
<hbr-filter [withDivider]="true" filterPlaceholder="{{'REPOSITORY.FILTER_FOR_REPOSITORIES' | translate}}" (filter)="doSearchRepoNames($event)"></hbr-filter>
|
||||
<a href="javascript:void(0)" (click)="refresh()">
|
||||
<clr-icon shape="refresh"></clr-icon>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-12 col-md-12 col-sm-12 col-xs-12">
|
||||
<list-repository [projectId]="projectId" [repositories]="changedRepositories" (delete)="deleteRepo($event)" [hasProjectAdminRole]="hasProjectAdminRole" (paginate)="retrieve($event)"></list-repository>
|
||||
</div>
|
||||
</div>
|
@ -1,126 +0,0 @@
|
||||
// Copyright (c) 2017 VMware, Inc. All Rights Reserved.
|
||||
//
|
||||
// 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, OnDestroy } from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
|
||||
import { RepositoryService } from './repository.service';
|
||||
import { Repository } from './repository';
|
||||
|
||||
import { MessageHandlerService } from '../shared/message-handler/message-handler.service';
|
||||
import { ConfirmationState, ConfirmationTargets, ConfirmationButtons } from '../shared/shared.const';
|
||||
|
||||
|
||||
import { ConfirmationDialogService } from '../shared/confirmation-dialog/confirmation-dialog.service';
|
||||
import { ConfirmationMessage } from '../shared/confirmation-dialog/confirmation-message';
|
||||
import { Subscription } from 'rxjs/Subscription';
|
||||
|
||||
import { State } from 'clarity-angular';
|
||||
|
||||
import { Project } from '../project/project';
|
||||
|
||||
@Component({
|
||||
selector: 'repository',
|
||||
templateUrl: 'repository.component.html',
|
||||
styleUrls: ['./repository.component.css']
|
||||
})
|
||||
export class RepositoryComponent implements OnInit {
|
||||
changedRepositories: Repository[];
|
||||
|
||||
projectId: number;
|
||||
|
||||
lastFilteredRepoName: string;
|
||||
|
||||
totalPage: number;
|
||||
totalRecordCount: number;
|
||||
|
||||
hasProjectAdminRole: boolean;
|
||||
|
||||
subscription: Subscription;
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private repositoryService: RepositoryService,
|
||||
private messageHandlerService: MessageHandlerService,
|
||||
private deletionDialogService: ConfirmationDialogService
|
||||
) {
|
||||
this.subscription = this.deletionDialogService
|
||||
.confirmationConfirm$
|
||||
.subscribe(
|
||||
message => {
|
||||
if (message &&
|
||||
message.source === ConfirmationTargets.REPOSITORY &&
|
||||
message.state === ConfirmationState.CONFIRMED) {
|
||||
let repoName = message.data;
|
||||
this.repositoryService
|
||||
.deleteRepository(repoName)
|
||||
.subscribe(
|
||||
response => {
|
||||
this.refresh();
|
||||
this.messageHandlerService.showSuccess('REPOSITORY.DELETED_REPO_SUCCESS');
|
||||
},
|
||||
error => this.messageHandlerService.handleError(error)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.projectId = this.route.snapshot.parent.params['id'];
|
||||
let resolverData = this.route.snapshot.parent.data;
|
||||
if(resolverData) {
|
||||
this.hasProjectAdminRole = (<Project>resolverData['projectResolver']).has_project_admin_role;
|
||||
}
|
||||
this.lastFilteredRepoName = '';
|
||||
this.retrieve();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
if (this.subscription) {
|
||||
this.subscription.unsubscribe();
|
||||
}
|
||||
}
|
||||
|
||||
retrieve(state?: State) {
|
||||
this.repositoryService
|
||||
.listRepositories(this.projectId, this.lastFilteredRepoName)
|
||||
.subscribe(
|
||||
response => {
|
||||
this.changedRepositories = response.json();
|
||||
},
|
||||
error => this.messageHandlerService.handleError(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.deletionDialogService.openComfirmDialog(message);
|
||||
}
|
||||
|
||||
refresh() {
|
||||
this.retrieve();
|
||||
}
|
||||
}
|
@ -16,12 +16,10 @@ import { RouterModule } from '@angular/router';
|
||||
|
||||
import { SharedModule } from '../shared/shared.module';
|
||||
|
||||
import { RepositoryComponent } from './repository.component';
|
||||
import { ListRepositoryComponent } from './list-repository/list-repository.component';
|
||||
import { RepositoryPageComponent } from './repository-page.component';
|
||||
import { TagRepositoryComponent } from './tag-repository/tag-repository.component';
|
||||
import { TopRepoComponent } from './top-repo/top-repo.component';
|
||||
|
||||
import { RepositoryService } from './repository.service';
|
||||
import { TagDetailPageComponent } from './tag-detail/tag-detail-page.component';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
@ -29,12 +27,16 @@ import { RepositoryService } from './repository.service';
|
||||
RouterModule
|
||||
],
|
||||
declarations: [
|
||||
RepositoryComponent,
|
||||
ListRepositoryComponent,
|
||||
RepositoryPageComponent,
|
||||
TagRepositoryComponent,
|
||||
TopRepoComponent
|
||||
TopRepoComponent,
|
||||
TagDetailPageComponent
|
||||
],
|
||||
exports: [RepositoryComponent, ListRepositoryComponent, TopRepoComponent],
|
||||
providers: [RepositoryService]
|
||||
exports: [
|
||||
RepositoryPageComponent,
|
||||
TopRepoComponent,
|
||||
TagDetailPageComponent
|
||||
],
|
||||
providers: []
|
||||
})
|
||||
export class RepositoryModule { }
|
@ -1,62 +0,0 @@
|
||||
// Copyright (c) 2017 VMware, Inc. All Rights Reserved.
|
||||
//
|
||||
// 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 { Injectable } from '@angular/core';
|
||||
import { Http, URLSearchParams, Response } from '@angular/http';
|
||||
|
||||
import { Repository } from './repository';
|
||||
import { Tag } from './tag';
|
||||
|
||||
import { Observable } from 'rxjs/Observable'
|
||||
import 'rxjs/add/observable/of';
|
||||
import 'rxjs/add/operator/mergeMap';
|
||||
|
||||
@Injectable()
|
||||
export class RepositoryService {
|
||||
|
||||
constructor(private http: Http){}
|
||||
|
||||
listRepositories(projectId: number, repoName: string, page?: number, pageSize?: number): Observable<any> {
|
||||
let params = new URLSearchParams();
|
||||
if(page && pageSize) {
|
||||
params.set('page', page + '');
|
||||
params.set('page_size', pageSize + '');
|
||||
}
|
||||
return this.http
|
||||
.get(`/api/repositories?project_id=${projectId}&q=${repoName}`, {search: params})
|
||||
.map(response=>response)
|
||||
.catch(error=>Observable.throw(error));
|
||||
}
|
||||
|
||||
listTags(repoName: string): Observable<Tag[]> {
|
||||
return this.http
|
||||
.get(`/api/repositories/${repoName}/tags`)
|
||||
.map(response=>response.json())
|
||||
.catch(error=>Observable.throw(error));
|
||||
}
|
||||
|
||||
deleteRepository(repoName: string): Observable<any> {
|
||||
return this.http
|
||||
.delete(`/api/repositories/${repoName}`)
|
||||
.map(response=>response.status)
|
||||
.catch(error=>Observable.throw(error));
|
||||
}
|
||||
|
||||
deleteRepoByTag(repoName: string, tag: string): Observable<any> {
|
||||
return this.http
|
||||
.delete(`/api/repositories/${repoName}/tags/${tag}`)
|
||||
.map(response=>response.status)
|
||||
.catch(error=>Observable.throw(error));
|
||||
}
|
||||
|
||||
}
|
@ -1,45 +0,0 @@
|
||||
// Copyright (c) 2017 VMware, Inc. All Rights Reserved.
|
||||
//
|
||||
// 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.
|
||||
/*
|
||||
{
|
||||
"id": "2",
|
||||
"name": "library/mysql",
|
||||
"owner_id": 1,
|
||||
"project_id": 1,
|
||||
"description": "",
|
||||
"pull_count": 0,
|
||||
"star_count": 0,
|
||||
"tags_count": 1,
|
||||
"creation_time": "2017-02-14T09:22:58Z",
|
||||
"update_time": "0001-01-01T00:00:00Z"
|
||||
}
|
||||
*/
|
||||
|
||||
export class Repository {
|
||||
id: number;
|
||||
name: string;
|
||||
owner_id: number;
|
||||
project_id: number;
|
||||
description: string;
|
||||
pull_count: number;
|
||||
start_count: number;
|
||||
tags_count: number;
|
||||
creation_time: Date;
|
||||
update_time: Date;
|
||||
|
||||
constructor(name: string, tags_count: number) {
|
||||
this.name = name;
|
||||
this.tags_count = tags_count;
|
||||
}
|
||||
}
|
@ -0,0 +1,3 @@
|
||||
<div style="margin-top: 24px;">
|
||||
<hbr-tag-detail (backEvt)="goBack($event)" [tagId]="tagId" [repositoryId]="repositoryId"></hbr-tag-detail>
|
||||
</div>
|
@ -0,0 +1,41 @@
|
||||
// Copyright (c) 2017 VMware, Inc. All Rights Reserved.
|
||||
//
|
||||
// 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 } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'repository',
|
||||
templateUrl: 'tag-detail-page.component.html'
|
||||
})
|
||||
export class TagDetailPageComponent implements OnInit {
|
||||
tagId: string;
|
||||
repositoryId: string;
|
||||
projectId: string | number;
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private router: Router
|
||||
) {
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.repositoryId = this.route.snapshot.params["repo"];
|
||||
this.tagId = this.route.snapshot.params["tag"];
|
||||
this.projectId = this.route.snapshot.parent.params["id"];
|
||||
}
|
||||
|
||||
goBack(tag: string): void {
|
||||
this.router.navigate(["harbor", "projects", this.projectId, "repositories"]);
|
||||
}
|
||||
}
|
@ -1,3 +1,3 @@
|
||||
.sub-header-title {
|
||||
margin-top: 12px;
|
||||
}
|
||||
margin-top: 12px;
|
||||
}
|
@ -1,45 +1,5 @@
|
||||
|
||||
<a *ngIf="hasSignedIn" [routerLink]="['/harbor', 'projects', projectId, 'repository']">< {{'REPOSITORY.REPOSITORIES' | translate}}</a>
|
||||
<a *ngIf="!hasSignedIn" [routerLink]="['/harbor', 'sign-in']">< {{'SEARCH.BACK' | translate}}</a>
|
||||
|
||||
<clr-modal [(clrModalOpen)]="showTagManifestOpened" [clrModalStaticBackdrop]="staticBackdrop" [clrModalClosable]="closable">
|
||||
<h3 class="modal-title">{{ manifestInfoTitle | translate }}</h3>
|
||||
<div class="modal-body">
|
||||
<div class="row col-md-12">
|
||||
<textarea rows="3" (click)="selectAndCopy($event)">{{digestId}}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-primary" (click)="showTagManifestOpened = false">{{'BUTTON.OK' | translate}}</button>
|
||||
</div>
|
||||
</clr-modal>
|
||||
|
||||
<h2 class="sub-header-title">{{repoName}}</h2>
|
||||
<clr-datagrid>
|
||||
<clr-dg-column>{{'REPOSITORY.TAG' | translate}}</clr-dg-column>
|
||||
<clr-dg-column>{{'REPOSITORY.PULL_COMMAND' | 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>{{'REPOSITORY.CREATED' | translate}}</clr-dg-column>
|
||||
<clr-dg-column>{{'REPOSITORY.DOCKER_VERSION' | translate}}</clr-dg-column>
|
||||
<clr-dg-column>{{'REPOSITORY.ARCHITECTURE' | translate}}</clr-dg-column>
|
||||
<clr-dg-column>{{'REPOSITORY.OS' | translate}}</clr-dg-column>
|
||||
<clr-dg-row *clrDgItems="let t of tags">
|
||||
<clr-dg-action-overflow>
|
||||
<button class="action-item" (click)="showDigestId(t)">{{'REPOSITORY.COPY_DIGEST_ID' | translate}}</button>
|
||||
<button class="action-item" [hidden]="!hasProjectAdminRole" (click)="deleteTag(t)">{{'REPOSITORY.DELETE' | translate}}</button>
|
||||
</clr-dg-action-overflow>
|
||||
<clr-dg-cell>{{t.name}}</clr-dg-cell>
|
||||
<clr-dg-cell>docker pull {{registryUrl}}/{{repoName}}:{{t.name}}</clr-dg-cell>
|
||||
<clr-dg-cell *ngIf="withNotary">
|
||||
<clr-icon *ngIf="t.signature" shape="check" style="color: #1D5100;"></clr-icon>
|
||||
<clr-icon *ngIf="!t.signature" shape="close" style="color: #C92100;"></clr-icon>
|
||||
</clr-dg-cell>
|
||||
<clr-dg-cell>{{t.author}}</clr-dg-cell>
|
||||
<clr-dg-cell>{{t.created | date: 'short'}}</clr-dg-cell>
|
||||
<clr-dg-cell>{{t.docker_version}}</clr-dg-cell>
|
||||
<clr-dg-cell>{{t.architecture}}</clr-dg-cell>
|
||||
<clr-dg-cell>{{t.os}}</clr-dg-cell>
|
||||
</clr-dg-row>
|
||||
<clr-dg-footer>{{tags ? tags.length : 0}} {{'REPOSITORY.ITEMS' | translate}}</clr-dg-footer>
|
||||
</clr-datagrid>
|
||||
<div>
|
||||
<a *ngIf="hasSignedIn" [routerLink]="['/harbor', 'projects', projectId, 'repositories']">< {{'REPOSITORY.REPOSITORIES' | translate}}</a>
|
||||
<a *ngIf="!hasSignedIn" [routerLink]="['/harbor', 'sign-in']">< {{'SEARCH.BACK' | translate}}</a>
|
||||
<hbr-tag (tagClickEvent)="watchTagClickEvt($event)" [repoName]="repoName" [registryUrl]="registryUrl" [withNotary]="withNotary" [hasSignedIn]="hasSignedIn" [hasProjectAdminRole]="hasProjectAdminRole" [projectId]="projectId" [isEmbedded]="false"></hbr-tag>
|
||||
</div>
|
@ -11,92 +11,38 @@
|
||||
// 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, OnDestroy, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
|
||||
import { RepositoryService } from '../repository.service';
|
||||
import { MessageHandlerService } from '../../shared/message-handler/message-handler.service';
|
||||
import { ConfirmationTargets, ConfirmationState, ConfirmationButtons } from '../../shared/shared.const';
|
||||
|
||||
import { ConfirmationDialogService } from '../../shared/confirmation-dialog/confirmation-dialog.service';
|
||||
import { ConfirmationMessage } from '../../shared/confirmation-dialog/confirmation-message';
|
||||
|
||||
import { Subscription } from 'rxjs/Subscription';
|
||||
|
||||
import { Tag } from '../tag';
|
||||
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { AppConfigService } from '../../app-config.service';
|
||||
|
||||
import { SessionService } from '../../shared/session.service';
|
||||
|
||||
import { TagClickEvent } from 'harbor-ui';
|
||||
import { Project } from '../../project/project';
|
||||
|
||||
@Component({
|
||||
selector: 'tag-repository',
|
||||
templateUrl: 'tag-repository.component.html',
|
||||
styleUrls: ['./tag-repository.component.css'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
styleUrls: ['./tag-repository.component.css']
|
||||
})
|
||||
export class TagRepositoryComponent implements OnInit, OnDestroy {
|
||||
export class TagRepositoryComponent implements OnInit {
|
||||
|
||||
projectId: number;
|
||||
repoName: string;
|
||||
|
||||
hasProjectAdminRole: boolean = false;
|
||||
|
||||
tags: Tag[];
|
||||
registryUrl: string;
|
||||
withNotary: boolean;
|
||||
|
||||
hasSignedIn: boolean;
|
||||
|
||||
showTagManifestOpened: boolean;
|
||||
manifestInfoTitle: string;
|
||||
digestId: string;
|
||||
staticBackdrop: boolean = true;
|
||||
closable: boolean = false;
|
||||
|
||||
selectAll: boolean = false;
|
||||
|
||||
subscription: Subscription;
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private messageHandlerService: MessageHandlerService,
|
||||
private deletionDialogService: ConfirmationDialogService,
|
||||
private repositoryService: RepositoryService,
|
||||
private router: Router,
|
||||
private appConfigService: AppConfigService,
|
||||
private session: SessionService,
|
||||
private ref: ChangeDetectorRef){
|
||||
this.subscription = this.deletionDialogService.confirmationConfirm$.subscribe(
|
||||
message => {
|
||||
if (message &&
|
||||
message.source === ConfirmationTargets.TAG
|
||||
&& message.state === ConfirmationState.CONFIRMED) {
|
||||
let tag = message.data;
|
||||
if (tag) {
|
||||
if (tag.signed) {
|
||||
return;
|
||||
} else {
|
||||
this.repositoryService
|
||||
.deleteRepoByTag(this.repoName, tag.name)
|
||||
.subscribe(
|
||||
response => {
|
||||
this.retrieve();
|
||||
this.messageHandlerService.showSuccess('REPOSITORY.DELETED_TAG_SUCCESS');
|
||||
},
|
||||
error => this.messageHandlerService.handleError(error)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
private session: SessionService) {
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.hasSignedIn = (this.session.getCurrentUser() !== null);
|
||||
let resolverData = this.route.snapshot.data;
|
||||
if(resolverData) {
|
||||
if (resolverData) {
|
||||
this.hasProjectAdminRole = (<Project>resolverData['projectResolver']).has_project_admin_role;
|
||||
}
|
||||
this.projectId = this.route.snapshot.params['id'];
|
||||
@ -104,60 +50,10 @@ export class TagRepositoryComponent implements OnInit, OnDestroy {
|
||||
|
||||
this.registryUrl = this.appConfigService.getConfig().registry_url;
|
||||
this.withNotary = this.appConfigService.getConfig().with_notary;
|
||||
this.retrieve();
|
||||
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
if (this.subscription) {
|
||||
this.subscription.unsubscribe();
|
||||
}
|
||||
}
|
||||
|
||||
retrieve() {
|
||||
this.tags = [];
|
||||
this.repositoryService
|
||||
.listTags(this.repoName)
|
||||
.subscribe(
|
||||
tags => this.tags = tags,
|
||||
error => this.messageHandlerService.handleError(error));
|
||||
let hnd = setInterval(()=>this.ref.markForCheck(), 100);
|
||||
setTimeout(()=>clearInterval(hnd), 1000);
|
||||
}
|
||||
|
||||
deleteTag(tag: Tag) {
|
||||
if (tag) {
|
||||
let titleKey: string, summaryKey: string, content: string, buttons: ConfirmationButtons;
|
||||
if (tag.signature) {
|
||||
titleKey = 'REPOSITORY.DELETION_TITLE_TAG_DENIED';
|
||||
summaryKey = 'REPOSITORY.DELETION_SUMMARY_TAG_DENIED';
|
||||
buttons = ConfirmationButtons.CLOSE;
|
||||
content = 'notary -s https://' + this.registryUrl + ':4443 -d ~/.docker/trust remove -p ' + this.registryUrl + '/' + this.repoName + ' ' + tag.name;
|
||||
} else {
|
||||
titleKey = 'REPOSITORY.DELETION_TITLE_TAG';
|
||||
summaryKey = 'REPOSITORY.DELETION_SUMMARY_TAG';
|
||||
buttons = ConfirmationButtons.DELETE_CANCEL;
|
||||
content = tag.name;
|
||||
}
|
||||
let message = new ConfirmationMessage(
|
||||
titleKey,
|
||||
summaryKey,
|
||||
content,
|
||||
tag,
|
||||
ConfirmationTargets.TAG,
|
||||
buttons);
|
||||
this.deletionDialogService.openComfirmDialog(message);
|
||||
}
|
||||
}
|
||||
|
||||
showDigestId(tag: Tag) {
|
||||
if(tag) {
|
||||
this.manifestInfoTitle = 'REPOSITORY.COPY_DIGEST_ID';
|
||||
this.digestId = tag.digest;
|
||||
this.showTagManifestOpened = true;
|
||||
}
|
||||
}
|
||||
selectAndCopy($event: any) {
|
||||
$event.target.select();
|
||||
watchTagClickEvt(tagEvt: TagClickEvent): void {
|
||||
let linkUrl = ['harbor', 'projects', tagEvt.project_id, 'repositories', tagEvt.repository_name, 'tags', tagEvt.tag_name];
|
||||
this.router.navigate(linkUrl);
|
||||
}
|
||||
}
|
@ -1,23 +0,0 @@
|
||||
// Copyright (c) 2017 VMware, Inc. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
export class Tag {
|
||||
digest: string;
|
||||
name: string;
|
||||
architecture: string;
|
||||
os: string;
|
||||
docker_version: string;
|
||||
author: string;
|
||||
created: Date;
|
||||
signature?: {[key: string]: any | any[]}
|
||||
}
|
@ -17,7 +17,7 @@ import { errorHandler } from '../../shared/shared.utils';
|
||||
import { AlertType, ListMode } from '../../shared/shared.const';
|
||||
import { MessageHandlerService } from '../../shared/message-handler/message-handler.service';
|
||||
import { TopRepoService } from './top-repository.service';
|
||||
import { Repository } from '../repository';
|
||||
import { Repository } from 'harbor-ui';
|
||||
|
||||
@Component({
|
||||
selector: 'top-repo',
|
||||
|
@ -15,7 +15,7 @@ import { Injectable } from '@angular/core';
|
||||
import { Headers, Http, RequestOptions } from '@angular/http';
|
||||
import 'rxjs/add/operator/toPromise';
|
||||
|
||||
import { Repository } from '../repository';
|
||||
import { Repository } from 'harbor-ui';
|
||||
|
||||
export const topRepoEndpoint = "/api/repositories/top";
|
||||
/**
|
||||
|
@ -13,7 +13,7 @@
|
||||
// limitations under the License.
|
||||
import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core';
|
||||
import { Router, NavigationExtras } from '@angular/router';
|
||||
import { Repository } from '../../repository/repository';
|
||||
import { Repository } from 'harbor-ui';
|
||||
import { State } from 'clarity-angular';
|
||||
|
||||
import { SearchTriggerService } from '../../base/global-search/search-trigger.service';
|
||||
|
@ -330,6 +330,7 @@
|
||||
"OS": "OS",
|
||||
"SHOW_DETAILS": "Show Details",
|
||||
"REPOSITORIES": "Repositories",
|
||||
"OF": "of",
|
||||
"ITEMS": "item(s)",
|
||||
"POP_REPOS": "Popular Repositories",
|
||||
"DELETED_REPO_SUCCESS": "Deleted repository successfully.",
|
||||
|
@ -331,6 +331,7 @@
|
||||
"OS": "SO",
|
||||
"SHOW_DETAILS": "Mostrar Detalles",
|
||||
"REPOSITORIES": "Repositorios",
|
||||
"OF": "of",
|
||||
"ITEMS": "elemento(s)",
|
||||
"POP_REPOS": "Repositorios Populares",
|
||||
"DELETED_REPO_SUCCESS": "Repositorio eliminado satisfactoriamente.",
|
||||
|
@ -330,6 +330,7 @@
|
||||
"OS": "操作系统",
|
||||
"SHOW_DETAILS": "显示详细",
|
||||
"REPOSITORIES": "镜像仓库",
|
||||
"OF": "共计",
|
||||
"ITEMS": "条记录",
|
||||
"POP_REPOS": "受欢迎的镜像仓库",
|
||||
"DELETED_REPO_SUCCESS": "成功删除镜像仓库。",
|
||||
|
Loading…
Reference in New Issue
Block a user