Add immutable tag in project detail

Signed-off-by: Yogi_Wang <yawang@vmware.com>
This commit is contained in:
Yang Wang (c) 2019-10-08 12:18:56 +08:00 committed by Yogi_Wang
parent 5c5e475da4
commit bfe19711db
22 changed files with 1435 additions and 152 deletions

3
.gitignore vendored
View File

@ -33,7 +33,7 @@ src/portal/typings/
src/portal/src/**/*.js
src/portal/src/**/*.js.map
src/portal/lib/coverage
**/npm*.log
**/*ngsummary.json
@ -42,3 +42,4 @@ src/portal/src/**/*.js.map
**/dist
**/.bin
src/core/conf/app.conf

View File

@ -61,6 +61,7 @@ import { OidcOnboardComponent } from './oidc-onboard/oidc-onboard.component';
import { LicenseComponent } from './license/license.component';
import { SummaryComponent } from './project/summary/summary.component';
import { TagRetentionComponent } from './project/tag-retention/tag-retention.component';
import { ImmutableTagComponent } from './project/immutable-tag/immutable-tag.component';
import { USERSTATICPERMISSION } from '@harbor/ui';
import { ScannerComponent } from "./project/scanner/scanner.component";
@ -277,6 +278,16 @@ const harborRoutes: Routes = [
},
component: TagRetentionComponent
},
{
path: 'immutable-tag',
data: {
permissionParam: {
resource: USERSTATICPERMISSION.TAG_RETENTION.KEY,
action: USERSTATICPERMISSION.TAG_RETENTION.VALUE.READ
}
},
component: ImmutableTagComponent
},
{
path: 'webhook',
data: {

View File

@ -0,0 +1,67 @@
<clr-modal [(clrModalOpen)]="addRuleOpened" [clrModalStaticBackdrop]="true" [clrModalClosable]="true" [clrModalSize]="'lg'">
<h3 class="modal-title" *ngIf="isAdd">{{'TAG_RETENTION.ADD_TITLE' | translate}}</h3>
<h3 class="modal-title" *ngIf="!isAdd">{{'TAG_RETENTION.EDIT_TITLE' | translate}}</h3>
<div class="modal-body no-scrolling">
<inline-alert class="modal-title"></inline-alert>
<p class="color-97">{{'TAG_RETENTION.ADD_SUBTITLE' | translate}}</p>
<div class="height-72">
<div class="clr-row mt-1">
<div class="clr-col-4">
<span>{{'TAG_RETENTION.IN_REPOSITORIES' | translate}}</span>
</div>
<div class="clr-col-3">
<div class="clr-select-wrapper w-100">
<select id="scope_selectors" [(ngModel)]="repoSelect" class="clr-select w-100">
<option *ngFor="let d of metadata?.scope_selectors[0]?.decorations"
value="{{d}}">{{getI18nKey(d)|translate}}</option>
</select>
</div>
</div>
<div class="clr-col-5">
<div class="w-100">
<input required id="scope-input" [(ngModel)]="repositories" class="clr-input w-100">
</div>
</div>
</div>
<div class="clr-row">
<div class="clr-col-4"></div>
<div class="clr-col-8">
<span>{{'TAG_RETENTION.REP_SEPARATOR' | translate}}</span>
</div>
</div>
</div>
<div class="height-72">
<div class="clr-row">
<div class="clr-col-4">
<label>{{'TAG_RETENTION.TAGS' | translate}}</label>
</div>
<div class="clr-col-3">
<div class="clr-select-wrapper w-100">
<select id="tag_selectors" [(ngModel)]="tagsSelect" class="clr-select w-100">
<option *ngFor="let d of metadata?.tag_selectors[0]?.decorations"
value="{{d}}">{{getI18nKey(d)|translate}}</option>
</select>
</div>
</div>
<div class="clr-col-5">
<div class="w-100">
<input required id="tag-input" [(ngModel)]="tagsInput" class="clr-input w-100">
</div>
</div>
</div>
<div class="clr-row">
<div class="clr-col-4"></div>
<div class="clr-col-8">
<span>{{'TAG_RETENTION.TAG_SEPARATOR' | translate}}</span>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline" id="close-btn" (click)="cancel()">{{'BUTTON.CANCEL' | translate}}</button>
<button [disabled]="canNotAdd()" type="button" id="add-edit-btn" class="btn btn-primary" (click)="add()">
<span *ngIf="isAdd">{{'BUTTON.ADD' | translate}}</span>
<span *ngIf="!isAdd">{{'BUTTON.SAVE' | translate}}</span>
</button>
</div>
</clr-modal>

View File

@ -0,0 +1,7 @@
.color-97 {
color: #979797;
}
.height-72 {
height: 72px;
}

View File

@ -0,0 +1,115 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ClarityModule } from '@clr/angular';
import { FormsModule } from '@angular/forms';
import { AddRuleComponent } from './add-rule.component';
import { CUSTOM_ELEMENTS_SCHEMA, EventEmitter } from '@angular/core';
import { TranslateModule } from '@ngx-translate/core';
import { ImmutableTagService } from '../immutable-tag.service';
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { NoopAnimationsModule } from "@angular/platform-browser/animations";
import { InlineAlertComponent } from "../../../shared/inline-alert/inline-alert.component";
import { ImmutableRetentionRule } from "../../tag-retention/retention";
import { compareValue } from "@harbor/ui";
describe('AddRuleComponent', () => {
let component: AddRuleComponent;
let fixture: ComponentFixture<AddRuleComponent>;
let mockRule = {
"id": 1,
"project_id": 1,
"disabled": false,
"priority": 0,
"action": "immutable",
"template": "immutable_template",
"tag_selectors": [
{
"kind": "doublestar",
"decoration": "matches",
"pattern": "**"
}
],
"scope_selectors": {
"repository": [
{
"kind": "doublestar",
"decoration": "repoMatches",
"pattern": "**"
}
]
}
};
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [AddRuleComponent, InlineAlertComponent],
schemas: [
CUSTOM_ELEMENTS_SCHEMA
],
imports: [
ClarityModule,
FormsModule,
NoopAnimationsModule,
HttpClientTestingModule,
TranslateModule.forRoot()
],
providers: [
ImmutableTagService
]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(AddRuleComponent);
component = fixture.componentInstance;
fixture.detectChanges();
component.addRuleOpened = true;
component.repoSelect = mockRule.scope_selectors.repository[0].decoration;
component.repositories = mockRule.scope_selectors.repository[0].pattern.replace(/[{}]/g, "");
component.tagsSelect = mockRule.tag_selectors[0].decoration;
component.tagsInput = mockRule.tag_selectors[0].pattern.replace(/[{}]/g, "");
component.clickAdd = new EventEmitter<ImmutableRetentionRule>();
component.rules = [];
component.isAdd = true;
component.open();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it("should rightly display default repositories and tag", async(() => {
fixture.detectChanges();
fixture.whenStable().then(() => {
fixture.detectChanges();
let elRep: HTMLInputElement = fixture.nativeElement.querySelector("#scope-input");
expect(elRep).toBeTruthy();
expect(elRep.value.trim()).toEqual("**");
let elTag: HTMLInputElement = fixture.nativeElement.querySelector("#tag-input");
expect(elTag).toBeTruthy();
expect(elTag.value.trim()).toEqual("**");
});
}));
it("should rightly close", async(() => {
fixture.detectChanges();
let elRep: HTMLButtonElement = fixture.nativeElement.querySelector("#close-btn");
elRep.dispatchEvent(new Event('click'));
elRep.click();
fixture.whenStable().then(() => {
fixture.detectChanges();
expect(component.addRuleOpened).toEqual(false);
});
}));
it("should be validating repeat rule ", async(() => {
fixture.detectChanges();
component.rules = [mockRule];
const elRep: HTMLButtonElement = fixture.nativeElement.querySelector("#add-edit-btn");
elRep.dispatchEvent(new Event('click'));
elRep.click();
fixture.whenStable().then(() => {
fixture.detectChanges();
const elRep1: HTMLSpanElement = fixture.nativeElement.querySelector(".alert-text");
expect(elRep1).toBeTruthy();
});
}));
});

View File

@ -0,0 +1,177 @@
import {
Component,
OnInit,
OnDestroy,
Output,
EventEmitter, ViewChild, Input,
} from "@angular/core";
import { ImmutableRetentionRule, RuleMetadate } from "../../tag-retention/retention";
import { compareValue } from "@harbor/ui";
import { ImmutableTagService } from "../immutable-tag.service";
import { InlineAlertComponent } from "../../../shared/inline-alert/inline-alert.component";
const EXISTING_RULE = "TAG_RETENTION.EXISTING_RULE";
const INVALID_RULE = "TAG_RETENTION.INVALID_RULE";
@Component({
selector: 'app-add-rule',
templateUrl: './add-rule.component.html',
styleUrls: ['./add-rule.component.scss']
})
export class AddRuleComponent implements OnInit, OnDestroy {
addRuleOpened: boolean = false;
@Output() clickAdd = new EventEmitter<ImmutableRetentionRule>();
@Input() rules: ImmutableRetentionRule[];
@Input() projectId: number;
metadata: RuleMetadate = new RuleMetadate();
rule: ImmutableRetentionRule = new ImmutableRetentionRule(this.projectId);
isAdd: boolean = true;
editRuleOrigin: ImmutableRetentionRule;
onGoing: boolean = false;
@ViewChild(InlineAlertComponent, { static: false }) inlineAlert: InlineAlertComponent;
constructor(private immutableTagService: ImmutableTagService) {
}
ngOnInit(): void {
}
ngOnDestroy(): void {
}
get repoSelect() {
if (this.rule && this.rule.scope_selectors && this.rule.scope_selectors.repository[0]) {
return this.rule.scope_selectors.repository[0].decoration;
}
return "";
}
set repoSelect(repoSelect) {
if (this.rule && this.rule.scope_selectors && this.rule.scope_selectors.repository[0]) {
this.rule.scope_selectors.repository[0].decoration = repoSelect;
}
}
set repositories(repositories) {
if (this.rule && this.rule.scope_selectors && this.rule.scope_selectors.repository
&& this.rule.scope_selectors.repository[0] && this.rule.scope_selectors.repository[0].pattern) {
if (repositories.indexOf(",") !== -1) {
this.rule.scope_selectors.repository[0].pattern = "{" + repositories + "}";
} else {
this.rule.scope_selectors.repository[0].pattern = repositories;
}
}
}
get repositories() {
if (this.rule && this.rule.scope_selectors && this.rule.scope_selectors.repository
&& this.rule.scope_selectors.repository[0] && this.rule.scope_selectors.repository[0].pattern) {
return this.rule.scope_selectors.repository[0].pattern.replace(/[{}]/g, "");
}
return "";
}
get tagsSelect() {
if (this.rule && this.rule.tag_selectors && this.rule.tag_selectors[0]) {
return this.rule.tag_selectors[0].decoration;
}
return "";
}
set tagsSelect(tagsSelect) {
if (this.rule && this.rule.tag_selectors && this.rule.tag_selectors[0]) {
this.rule.tag_selectors[0].decoration = tagsSelect;
}
}
set tagsInput(tagsInput) {
if (this.rule && this.rule.tag_selectors && this.rule.tag_selectors[0] && this.rule.tag_selectors[0].pattern) {
if (tagsInput.indexOf(",") !== -1) {
this.rule.tag_selectors[0].pattern = "{" + tagsInput + "}";
} else {
this.rule.tag_selectors[0].pattern = tagsInput;
}
}
}
get tagsInput() {
if (this.rule && this.rule.tag_selectors && this.rule.tag_selectors[0] && this.rule.tag_selectors[0].pattern) {
return this.rule.tag_selectors[0].pattern.replace(/[{}]/g, "");
}
return "";
}
canNotAdd(): boolean {
if (this.onGoing) {
return true;
}
if (!this.isAdd && compareValue(this.editRuleOrigin, this.rule)) {
return true;
}
return !(
this.rule && this.rule.scope_selectors && this.rule.scope_selectors.repository
&& this.rule.scope_selectors.repository[0] && this.rule.scope_selectors.repository[0].pattern
&& this.rule.scope_selectors.repository[0].pattern.replace(/[{}]/g, "")
&& this.rule.tag_selectors && this.rule.tag_selectors[0] && this.rule.tag_selectors[0].pattern
&& this.rule.tag_selectors[0].pattern.replace(/[{}]/g, ""));
}
open() {
this.addRuleOpened = true;
this.inlineAlert.alertClose = true;
this.onGoing = false;
}
close() {
this.addRuleOpened = false;
}
cancel() {
this.close();
}
add() {
// remove whitespaces
this.rule.scope_selectors.repository[0].pattern = this.rule.scope_selectors.repository[0].pattern.replace(/\s+/g, "");
this.rule.tag_selectors[0].pattern = this.rule.tag_selectors[0].pattern.replace(/\s+/g, "");
if (this.rule.scope_selectors.repository[0].decoration !== "repoMatches"
&& this.rule.scope_selectors.repository[0].pattern.indexOf("**") !== -1) {
this.inlineAlert.showInlineError(INVALID_RULE);
return;
}
if (this.isExistingRule()) {
this.inlineAlert.showInlineError(EXISTING_RULE);
return;
}
this.clickAdd.emit(this.rule);
}
isExistingRule(): boolean {
if (this.rules && this.rules.length > 0) {
for (let i = 0; i < this.rules.length; i++) {
if (this.isSameRule(this.rules[i])) {
return true;
}
}
}
return false;
}
isSameRule(rule: ImmutableRetentionRule): boolean {
if (this.rule.scope_selectors.repository[0].decoration !== rule.scope_selectors.repository[0].decoration) {
return false;
}
if (this.rule.scope_selectors.repository[0].pattern !== rule.scope_selectors.repository[0].pattern) {
return false;
}
if (this.rule.tag_selectors[0].decoration !== rule.tag_selectors[0].decoration) {
return false;
}
return this.rule.tag_selectors[0].pattern === rule.tag_selectors[0].pattern;
}
getI18nKey(str: string) {
return this.immutableTagService.getI18nKey(str);
}
}

View File

@ -0,0 +1,63 @@
<div class="clr-row pt-1 fw8">
<div class="clr-col">
<label class="label-left font-size-54">{{'TAG_RETENTION.RETENTION_RULES' | translate}}</label><span class="badge badge-3 ml-5">{{rules?.length ? rules?.length : 0}}/15</span>
<span *ngIf="loadingRule" class="spinner spinner-inline ml-2">Loading...</span>
</div>
</div>
<div class="clr-row pt-1">
<div class="clr-col">
<ul *ngIf="rules?.length > 0" class="list-unstyled">
<li class="rule" *ngFor="let rule of rules;let i = index;">
<div class="clr-row">
<div class="clr-col-2 flex-150">
<div class="dropdown" [ngClass]="{open:ruleIndex===i}">
<button (click)="openEditor(i)" id="{{'action'+i}}" class="dropdown-toggle btn btn-link btn-sm">
{{'TAG_RETENTION.ACTION' | translate}}
<clr-icon shape="caret down"></clr-icon>
</button>
<div class="dropdown-menu">
<button *ngIf="!rule?.disabled" type="button" id="{{'disable-btn'+i}}" class="dropdown-item" (click)="toggleDisable(rule,true)">{{'TAG_RETENTION.DISABLE' | translate}}</button>
<button *ngIf="rule?.disabled" type="button" class="dropdown-item" (click)="toggleDisable(rule,false)">{{'TAG_RETENTION.ENABLE' | translate}}</button>
<button type="button" id="{{'edit-btn'+i}}" class="dropdown-item" (click)="editRuleByIndex(i)">{{'TAG_RETENTION.EDIT' | translate}}</button>
<button type="button" class="dropdown-item" id="{{'delete-btn'+i}}" (click)="deleteRule(rule.id)">{{'TAG_RETENTION.DELETE' | translate}}</button>
</div>
</div>
</div>
<div class="clr-col">
<span>
<clr-icon *ngIf="!rule?.disabled" class="color-green" shape="success-standard"></clr-icon>
<clr-icon id="{{'disable-icon'+i}}" *ngIf="rule?.disabled" class="color-red" shape="error-standard"></clr-icon>
</span>
<span class="rule-name ml-5">
<span>{{'TAG_RETENTION.IN_REPOSITORIES' | translate}}</span>
<span>{{getI18nKey(rule?.scope_selectors?.repository[0]?.decoration)|translate}}</span>
<span>{{formatPattern(rule?.scope_selectors?.repository[0]?.pattern)}}</span>
<span>,</span>
<span>{{'TAG_RETENTION.LOWER_TAGS' | translate}}</span>
<span>{{getI18nKey(rule?.tag_selectors[0]?.decoration)|translate}}</span>
<span id="{{'tag-selectors-patten'+i}}">{{formatPattern(rule?.tag_selectors[0]?.pattern)}}</span>
<ng-container *ngIf="rule?.tag_selectors[1]?.pattern && rule?.tag_selectors[1]?.pattern">
<span class="color-97">{{'TAG_RETENTION.AND' | translate}}</span>
<span>{{'TAG_RETENTION.LOWER_LABELS' | translate}}</span>
<span>{{getI18nKey(rule?.tag_selectors[1]?.decoration)|translate}}</span>
<span>{{rule?.tag_selectors[1]?.pattern}}</span>
</ng-container>
</span>
</div>
</div>
</li>
</ul>
<div class="v-center clr-row" [ngClass]="{'pt-1':rules?.length > 0}">
<div class="clr-col-2 flex-150"></div>
<div class="clr-col-2">
<button [disabled]="rules?.length >= 15" id="add-rule" class="btn btn-primary btn-sm" (click)="openAddRule()">{{'TAG_RETENTION.ADD_RULE' | translate}}</button>
</div>
<div class="clr-col-8 color-97 font-size-54">
{{'TAG_RETENTION.ADD_RULE_HELP_1' | translate}}
</div>
</div>
</div>
</div>
<app-add-rule #addRule [rules]="rules" [projectId]="projectId" (clickAdd)="clickAdd($event)"></app-add-rule>
<div class="backdrop-transparent" (click)="ruleIndex = -1" *ngIf="ruleIndex !== -1"></div>

View File

@ -0,0 +1,399 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { InlineAlertComponent } from "../../shared/inline-alert/inline-alert.component";
import { ImmutableTagComponent } from './immutable-tag.component';
import { ClarityModule } from '@clr/angular';
import { FormsModule } from '@angular/forms';
import { AddRuleComponent } from './add-rule/add-rule.component';
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { TranslateModule } from '@ngx-translate/core';
import { ImmutableTagService } from './immutable-tag.service';
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { NoopAnimationsModule } from "@angular/platform-browser/animations";
import { ActivatedRoute } from '@angular/router';
import { of, throwError } from 'rxjs';
import { ErrorHandler, DefaultErrorHandler, clone } from '@harbor/ui';
describe('ImmutableTagComponent', () => {
let component: ImmutableTagComponent;
let addRuleComponent: AddRuleComponent;
let immutableTagService: ImmutableTagService;
let errorHandler: ErrorHandler;
let fixture: ComponentFixture<ImmutableTagComponent>;
let fixtureAddrule: ComponentFixture<AddRuleComponent>;
let mockMetadata = {
"templates": [
{
"rule_template": "latestPushedK",
"display_text": "the most recently pushed # images",
"action": "retain",
"params": [
{
"type": "int",
"unit": "COUNT",
"required": true
}
]
},
{
"rule_template": "latestPulledN",
"display_text": "the most recently pulled # images",
"action": "retain",
"params": [
{
"type": "int",
"unit": "COUNT",
"required": true
}
]
},
{
"rule_template": "nDaysSinceLastPush",
"display_text": "pushed within the last # days",
"action": "retain",
"params": [
{
"type": "int",
"unit": "DAYS",
"required": true
}
]
},
{
"rule_template": "nDaysSinceLastPull",
"display_text": "pulled within the last # days",
"action": "retain",
"params": [
{
"type": "int",
"unit": "DAYS",
"required": true
}
]
},
{
"rule_template": "always",
"display_text": "always",
"action": "retain",
"params": []
}
],
"scope_selectors": [
{
"display_text": "Repositories",
"kind": "doublestar",
"decorations": [
"repoMatches",
"repoExcludes"
]
}
],
"tag_selectors": [
{
"display_text": "Tags",
"kind": "doublestar",
"decorations": [
"matches",
"excludes"
]
}
]
};
let mockRules =
[
{
"id": 1,
"project_id": 1,
"disabled": false,
"priority": 0,
"action": "immutable",
"template": "immutable_template",
"tag_selectors": [
{
"kind": "doublestar",
"decoration": "matches",
"pattern": "**"
}
],
"scope_selectors": {
"repository": [
{
"kind": "doublestar",
"decoration": "repoMatches",
"pattern": "**"
}
]
}
}, {
"id": 2,
"project_id": 1,
"disabled": false,
"priority": 0,
"action": "immutable",
"template": "immutable_template",
"tag_selectors": [
{
"kind": "doublestar",
"decoration": "matches",
"pattern": "44"
}
],
"scope_selectors": {
"repository": [
{
"kind": "doublestar",
"decoration": "repoMatches",
"pattern": "**"
}
]
}
},
{
"id": 3,
"project_id": 1,
"disabled": false,
"priority": 0,
"action": "immutable",
"template": "immutable_template",
"tag_selectors": [
{
"kind": "doublestar",
"decoration": "matches",
"pattern": "555"
}
],
"scope_selectors": {
"repository": [
{
"kind": "doublestar",
"decoration": "repoMatches",
"pattern": "**"
}
]
}
},
{
"id": 4,
"project_id": 1,
"disabled": false,
"priority": 0,
"action": "immutable",
"template": "immutable_template",
"tag_selectors": [
{
"kind": "doublestar",
"decoration": "matches",
"pattern": "fff**"
}
],
"scope_selectors": {
"repository": [
{
"kind": "doublestar",
"decoration": "repoMatches",
"pattern": "**ggg"
}
]
}
}
];
let cloneRule = clone(mockRules[0]);
cloneRule.tag_selectors[0].pattern = 'rep';
let cloneRuleNoId = clone(mockRules[0]);
cloneRuleNoId.id = null;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ImmutableTagComponent, AddRuleComponent, InlineAlertComponent],
schemas: [
CUSTOM_ELEMENTS_SCHEMA
],
imports: [
NoopAnimationsModule,
ClarityModule,
FormsModule,
HttpClientTestingModule,
TranslateModule.forRoot()
],
providers: [
ImmutableTagService,
{
provide: ActivatedRoute, useValue: {
paramMap: of({ get: (key) => 'value' }),
snapshot: {
parent: {
params: { id: 1 }
},
data: 1
}
}
},
{ provide: ErrorHandler, useClass: DefaultErrorHandler }
]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(ImmutableTagComponent);
fixtureAddrule = TestBed.createComponent(AddRuleComponent);
component = fixture.componentInstance;
addRuleComponent = fixtureAddrule.componentInstance;
addRuleComponent.open = () => {
return null;
};
component.projectId = 1;
component.addRuleComponent = TestBed.createComponent(AddRuleComponent).componentInstance;
component.addRuleComponent = TestBed.createComponent(AddRuleComponent).componentInstance;
component.addRuleComponent.open = () => {
return null;
};
component.addRuleComponent.inlineAlert = TestBed.createComponent(InlineAlertComponent).componentInstance;
immutableTagService = fixture.debugElement.injector.get(ImmutableTagService);
errorHandler = fixture.debugElement.injector.get(ErrorHandler);
spyOn(immutableTagService, "getRetentionMetadata")
.and.returnValue(of(mockMetadata, throwError('error')));
spyOn(immutableTagService, "getRules")
.withArgs(component.projectId)
.and.returnValue(of(mockRules))
.withArgs(0)
.and.returnValue(throwError('error'));
spyOn(immutableTagService, "updateRule")
.withArgs(component.projectId, mockRules[0])
.and.returnValue(of(null))
.withArgs(component.projectId, cloneRule)
.and.returnValue(of(null));
spyOn(immutableTagService, "deleteRule")
.withArgs(component.projectId, mockRules[3].id)
.and.returnValue(of(null));
spyOn(immutableTagService, "createRule")
.withArgs(component.projectId, cloneRuleNoId)
.and.returnValue(of(null))
.withArgs(0, cloneRuleNoId)
.and.returnValue(throwError({error: { message: 'error'}}));
spyOn(immutableTagService, "getProjectInfo")
.withArgs(component.projectId)
.and.returnValue(of(null));
spyOn(errorHandler, "error")
.and.returnValue(null);
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it("should show some rules in page", async(() => {
fixture.detectChanges();
fixture.whenStable().then(() => {
fixture.detectChanges();
let elRep: HTMLLIElement[] = fixture.nativeElement.querySelectorAll(".rule");
expect(elRep).toBeTruthy();
expect(elRep.length).toEqual(4);
});
}));
it("should show error in list rule", async(() => {
fixture.detectChanges();
component.projectId = 0;
component.getRules();
fixture.whenStable().then(() => {
fixture.detectChanges();
component.projectId = 1;
});
}));
it("should toggle disable and enable", async(() => {
fixture.detectChanges();
let elRep: HTMLButtonElement = fixture.nativeElement.querySelector("#action0");
elRep.dispatchEvent(new Event('click'));
elRep.click();
let elRepDisable: HTMLButtonElement = fixture.nativeElement.querySelector("#disable-btn0");
expect(elRepDisable).toBeTruthy();
elRepDisable.dispatchEvent(new Event('click'));
elRepDisable.click();
mockRules[0].disabled = true;
fixture.whenStable().then(() => {
fixture.detectChanges();
let elRepDisableIcon: HTMLButtonElement = fixture.nativeElement.querySelector("#disable-icon0");
expect(elRepDisableIcon).toBeTruthy();
});
}));
it("should be deleted", async(() => {
fixture.detectChanges();
let elRep: HTMLButtonElement = fixture.nativeElement.querySelector("#action0");
elRep.dispatchEvent(new Event('click'));
elRep.click();
let elRepDisable: HTMLButtonElement = fixture.nativeElement.querySelector("#delete-btn3");
expect(elRepDisable).toBeTruthy();
elRepDisable.dispatchEvent(new Event('click'));
elRepDisable.click();
let rule = mockRules.pop();
fixture.whenStable().then(() => {
fixture.detectChanges();
let elRepRule: HTMLLIElement[] = fixture.nativeElement.querySelectorAll(".rule");
expect(elRepRule.length).toEqual(3);
mockRules.push(rule);
});
}));
it("should be add rule", async(() => {
fixture.detectChanges();
component.clickAdd(cloneRuleNoId);
mockRules.push(cloneRuleNoId);
fixture.whenStable().then(() => {
fixture.detectChanges();
let elRepRule: HTMLLIElement[] = fixture.nativeElement.querySelectorAll(".rule");
expect(elRepRule.length).toEqual(5);
mockRules.pop();
});
}));
it("should be add rule error", async(() => {
fixture.detectChanges();
component.projectId = 0;
component.clickAdd(cloneRuleNoId);
// mockRules.push(cloneRuleNoId);
fixture.whenStable().then(() => {
fixture.detectChanges();
component.projectId = 1;
let elRepRule: HTMLLIElement[] = fixture.nativeElement.querySelectorAll(".rule");
expect(elRepRule.length).toEqual(4);
// mockRules.pop();
});
}));
it("should be edit rule ", async(() => {
fixture.detectChanges();
component.clickAdd(cloneRule);
mockRules[0].tag_selectors[0].pattern = 'rep';
fixture.whenStable().then(() => {
fixture.detectChanges();
let elRepRule: HTMLLIElement = fixture.nativeElement.querySelector("#tag-selectors-patten0");
expect(elRepRule.textContent).toEqual('rep');
mockRules[0].tag_selectors[0].pattern = '**';
});
}));
it("should be edit rule with no add", async(() => {
fixture.detectChanges();
component.addRuleComponent.isAdd = false;
component.clickAdd(cloneRule);
mockRules[0].tag_selectors[0].pattern = 'rep';
fixture.whenStable().then(() => {
fixture.detectChanges();
let elRepRule: HTMLLIElement = fixture.nativeElement.querySelector("#tag-selectors-patten0");
expect(elRepRule.textContent).toEqual('rep');
mockRules[0].tag_selectors[0].pattern = '**';
component.addRuleComponent.isAdd = true;
});
}));
});

View File

@ -0,0 +1,169 @@
import { Component, OnInit, ViewChild } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { AddRuleComponent } from "./add-rule/add-rule.component";
import { ImmutableTagService } from "./immutable-tag.service";
import { ImmutableRetentionRule } from "../tag-retention/retention";
import { clone, ErrorHandler } from "@harbor/ui";
import { finalize } from "rxjs/operators";
@Component({
selector: 'app-immutable-tag',
templateUrl: './immutable-tag.component.html',
styleUrls: ['./immutable-tag.component.scss']
})
export class ImmutableTagComponent implements OnInit {
projectId: number;
selectedItem: any = null;
ruleIndex: number = -1;
index: number = -1;
rules: ImmutableRetentionRule[] = [];
editIndex: number;
loadingRule: boolean = false;
@ViewChild('addRule', { static: false }) addRuleComponent: AddRuleComponent;
constructor(
private route: ActivatedRoute,
private immutableTagService: ImmutableTagService,
public errorHandler: ErrorHandler,
) {
}
ngOnInit() {
this.projectId = +this.route.snapshot.parent.params['id'];
this.getRules();
this.getMetadata();
}
getMetadata() {
this.immutableTagService.getRetentionMetadata().subscribe(
response => {
this.addRuleComponent.metadata = response;
}, error => {
this.errorHandler.error(error);
});
}
getRules() {
this.immutableTagService.getRules(this.projectId).subscribe(
response => {
this.rules = response as ImmutableRetentionRule[];
this.loadingRule = false;
}, error => {
this.errorHandler.error(error);
this.loadingRule = false;
});
}
editRuleByIndex(index) {
this.editIndex = index;
this.addRuleComponent.rule = clone(this.rules[index]);
this.addRuleComponent.editRuleOrigin = clone(this.rules[index]);
this.addRuleComponent.open();
this.addRuleComponent.isAdd = false;
this.ruleIndex = -1;
}
toggleDisable(rule, isActionDisable) {
rule.disabled = isActionDisable;
this.ruleIndex = -1;
this.loadingRule = true;
this.immutableTagService.updateRule(this.projectId, rule).subscribe(
response => {
this.getRules();
}, error => {
this.loadingRule = false;
this.errorHandler.error(error);
});
}
deleteRule(ruleId) {
// // if rules is empty, clear schedule.
this.ruleIndex = -1;
this.loadingRule = true;
this.immutableTagService.deleteRule(this.projectId, ruleId).subscribe(
response => {
this.getRules();
}, error => {
this.loadingRule = false;
this.errorHandler.error(error);
});
}
openAddRule() {
this.addRuleComponent.open();
this.addRuleComponent.isAdd = true;
this.addRuleComponent.rule = new ImmutableRetentionRule(this.projectId);
}
openEditor(index) {
if (this.ruleIndex !== index) {
this.ruleIndex = index;
} else {
this.ruleIndex = -1;
}
}
refreshAfterCreatRetention() {
this.immutableTagService.getProjectInfo(this.projectId).subscribe(
response => {
this.getRules();
}, error => {
this.loadingRule = false;
this.errorHandler.error(error);
});
}
clickAdd(rule) {
this.loadingRule = true;
this.addRuleComponent.onGoing = true;
if (this.addRuleComponent.isAdd) {
if (!rule.id) {
this.immutableTagService.createRule(this.projectId, rule)
.pipe(finalize(() => this.addRuleComponent.onGoing = false)).subscribe(
response => {
this.refreshAfterCreatRetention();
this.addRuleComponent.close();
}, error => {
if (error && error.error && error.error.message) {
error = this.immutableTagService.getI18nKey(error.error.message);
}
this.addRuleComponent.inlineAlert.showInlineError(error);
this.loadingRule = false;
});
} else {
this.immutableTagService.updateRule(this.projectId, rule)
.pipe(finalize(() => this.addRuleComponent.onGoing = false)).subscribe(
response => {
this.getRules();
this.addRuleComponent.close();
}, error => {
this.loadingRule = false;
if (error && error.error && error.error.message) {
error = this.immutableTagService.getI18nKey(error.error.message);
}
this.addRuleComponent.inlineAlert.showInlineError(error);
});
}
} else {
this.immutableTagService.updateRule(this.projectId, rule)
.pipe(finalize(() => this.addRuleComponent.onGoing = false)).subscribe(
response => {
this.getRules();
this.addRuleComponent.close();
}, error => {
if (error && error.error && error.error.message) {
error = this.immutableTagService.getI18nKey(error.error.message);
}
this.addRuleComponent.inlineAlert.showInlineError(error);
this.loadingRule = false;
});
}
}
formatPattern(pattern: string): string {
return pattern.replace(/[{}]/g, "");
}
getI18nKey(str: string) {
return this.immutableTagService.getI18nKey(str);
}
}

View File

@ -0,0 +1,25 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { SharedModule } from '../../shared/shared.module';
import { ImmutableTagComponent } from './immutable-tag.component';
import { ImmutableTagService } from './immutable-tag.service';
import { TranslateModule } from '@ngx-translate/core';
import { AddRuleComponent } from './add-rule/add-rule.component';
@NgModule({
declarations: [ImmutableTagComponent, AddRuleComponent],
imports: [
CommonModule,
SharedModule,
TranslateModule
],
exports: [
],
providers: [
ImmutableTagService
]
})
export class ImmutableTagModule { }

View File

@ -0,0 +1,127 @@
import { ImmutableTagService } from './immutable-tag.service';
import { TestBed, inject } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
describe('ImmutableTagService', () => {
beforeEach(() => TestBed.configureTestingModule({
providers: [ImmutableTagService],
imports: [
HttpClientTestingModule
]
}));
it('should be created', () => {
const service: ImmutableTagService = TestBed.get(ImmutableTagService);
expect(service).toBeTruthy();
});
it('should get rules',
inject(
[HttpTestingController, ImmutableTagService],
(httpMock: HttpTestingController, immutableTagService: ImmutableTagService) => {
const mockRules =
[
{
"id": 1,
"project_id": 1,
"disabled": false,
"priority": 0,
"action": "immutable",
"template": "immutable_template",
"tag_selectors": [
{
"kind": "doublestar",
"decoration": "matches",
"pattern": "**"
}
],
"scope_selectors": {
"repository": [
{
"kind": "doublestar",
"decoration": "repoMatches",
"pattern": "**"
}
]
}
}, {
"id": 2,
"project_id": 1,
"disabled": false,
"priority": 0,
"action": "immutable",
"template": "immutable_template",
"tag_selectors": [
{
"kind": "doublestar",
"decoration": "matches",
"pattern": "44"
}
],
"scope_selectors": {
"repository": [
{
"kind": "doublestar",
"decoration": "repoMatches",
"pattern": "**"
}
]
}
},
{
"id": 3,
"project_id": 1,
"disabled": false,
"priority": 0,
"action": "immutable",
"template": "immutable_template",
"tag_selectors": [
{
"kind": "doublestar",
"decoration": "matches",
"pattern": "555"
}
],
"scope_selectors": {
"repository": [
{
"kind": "doublestar",
"decoration": "repoMatches",
"pattern": "**"
}
]
}
},
{
"id": 4,
"project_id": 1,
"disabled": false,
"priority": 0,
"action": "immutable",
"template": "immutable_template",
"tag_selectors": [
{
"kind": "doublestar",
"decoration": "matches",
"pattern": "fff**"
}
],
"scope_selectors": {
"repository": [
{
"kind": "doublestar",
"decoration": "repoMatches",
"pattern": "**ggg"
}
]
}
}
];
immutableTagService.getRules(1).subscribe((res) => {
expect(res).toEqual(mockRules);
});
}
)
);
});

View File

@ -0,0 +1,87 @@
import { Injectable } from '@angular/core';
import { HttpClient } from "@angular/common/http";
import { ImmutableRetentionRule, RuleMetadate } from "../tag-retention/retention";
import { Observable, throwError as observableThrowError } from "rxjs";
import { map, catchError } from "rxjs/operators";
import { Project } from "../project";
import { HTTP_JSON_OPTIONS } from "@harbor/ui";
@Injectable()
export class ImmutableTagService {
private I18nMap: object = {
"retain": "ACTION_RETAIN",
"lastXDays": "RULE_NAME_1",
"latestActiveK": "RULE_NAME_2",
"latestPushedK": "RULE_NAME_3",
"latestPulledN": "RULE_NAME_4",
"always": "RULE_NAME_5",
"nDaysSinceLastPull": "RULE_NAME_6",
"nDaysSinceLastPush": "RULE_NAME_7",
"the images from the last # days": "RULE_TEMPLATE_1",
"the most recent active # images": "RULE_TEMPLATE_2",
"the most recently pushed # images": "RULE_TEMPLATE_3",
"the most recently pulled # images": "RULE_TEMPLATE_4",
"pulled within the last # days": "RULE_TEMPLATE_6",
"pushed within the last # days": "RULE_TEMPLATE_7",
"repoMatches": "MAT",
"repoExcludes": "EXC",
"matches": "MAT",
"excludes": "EXC",
"withLabels": "WITH",
"withoutLabels": "WITHOUT",
"COUNT": "UNIT_COUNT",
"DAYS": "UNIT_DAY",
"none": "NONE",
"nothing": "NONE",
"Parameters nDaysSinceLastPull is too large": "DAYS_LARGE",
"Parameters nDaysSinceLastPush is too large": "DAYS_LARGE",
"Parameters latestPushedK is too large": "COUNT_LARGE",
"Parameters latestPulledN is too large": "COUNT_LARGE"
};
constructor(
private http: HttpClient,
) {
}
getI18nKey(str: string): string {
if (this.I18nMap[str.trim()]) {
return "TAG_RETENTION." + this.I18nMap[str.trim()];
}
return str;
}
getRetentionMetadata(): Observable<RuleMetadate> {
return this.http.get(`/api/retentions/metadatas`)
.pipe(map(response => response as RuleMetadate))
.pipe(catchError(error => observableThrowError(error)));
}
getRules(projectId): Observable<ImmutableRetentionRule[]> {
return this.http.get(`/api/projects/${projectId}/immutabletagrules`)
.pipe(map(response => response as ImmutableRetentionRule[]))
.pipe(catchError(error => observableThrowError(error)));
}
createRule(projectId: number, retention: ImmutableRetentionRule) {
return this.http.post(`/api/projects/${projectId}/immutabletagrules`, retention)
.pipe(catchError(error => observableThrowError(error)));
}
updateRule(projectId, immutabletagrule: ImmutableRetentionRule) {
return this.http.put(`/api/projects/${projectId}/immutabletagrules/${immutabletagrule.id}`, immutabletagrule)
.pipe(catchError(error => observableThrowError(error)));
}
deleteRule(projectId, ruleId) {
return this.http.delete(`/api/projects/${projectId}/immutabletagrules/${ruleId}`, HTTP_JSON_OPTIONS)
.pipe(catchError(error => observableThrowError(error)));
}
getProjectInfo(projectId) {
return this.http.get(`/api/projects/${projectId}`)
.pipe(map(response => response as Project))
.pipe(catchError(error => observableThrowError(error)));
}
}

View File

@ -28,6 +28,9 @@
<li class="nav-item" *ngIf="hasTagRetentionPermission">
<a class="nav-link" routerLink="tag-retention" routerLinkActive="active">{{'TAG_RETENTION.TAG_RETENTION' | translate}}</a>
</li>
<li class="nav-item" *ngIf="hasTagRetentionPermission">
<a class="nav-link" routerLink="immutable-tag" routerLinkActive="active">{{'PROJECT_DETAIL.IMMUTABLE_TAG' | translate}}</a>
</li>
<li class="nav-item" *ngIf="hasWebhookListPermission">
<a class="nav-link" routerLink="webhook" routerLinkActive="active">{{'PROJECT_DETAIL.WEBHOOKS' | translate}}</a>
</li>

View File

@ -18,6 +18,7 @@ import { SharedModule } from '../shared/shared.module';
import { RepositoryModule } from '../repository/repository.module';
import { ReplicationModule } from '../replication/replication.module';
import { SummaryModule } from './summary/summary.module';
import { ImmutableTagModule } from './immutable-tag/immutable-tag.module';
import { LogModule } from '../log/log.module';
import { ProjectComponent } from './project.component';
@ -58,7 +59,8 @@ import { ConfigScannerService } from "../config/scanner/config-scanner.service";
LogModule,
RouterModule,
HelmChartModule,
SummaryModule
SummaryModule,
ImmutableTagModule
],
declarations: [
ProjectComponent,

View File

@ -11,8 +11,19 @@
// 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 Retention {
export class BaseRetention {
algorithm: string;
scope: {
level: string,
ref: number;
};
cap: number;
constructor() {
this.algorithm = "or";
}
}
export class Retention extends BaseRetention {
rules: Array<Rule>;
trigger: {
kind: string;
@ -21,15 +32,9 @@ export class Retention {
cron: string;
}
};
scope: {
level: string,
ref: number;
};
cap: number;
constructor() {
super();
this.rules = [];
this.algorithm = "or";
this.trigger = {
kind: "Schedule",
references: {},
@ -40,13 +45,12 @@ export class Retention {
}
}
export class Rule {
export class BaseRule {
disabled: boolean;
template: string;
id: number;
priority: number;
action: string;
template: string;
params: object;
tag_selectors: Array<Selector>;
scope_selectors: {
repository: Array<Selector>;
@ -55,7 +59,6 @@ export class Rule {
constructor() {
this.disabled = false;
this.action = "retain";
this.params = {};
this.scope_selectors = {
repository: [
{
@ -75,6 +78,27 @@ export class Rule {
}
}
export class ImmutableRetentionRule extends BaseRule {
project_id: number;
constructor(project_id) {
super();
this.project_id = project_id;
this.priority = 0;
this.action = 'immutable';
this.template = 'immutable_template';
}
}
// rule for tag-retention
export class Rule extends BaseRule {
params: object;
constructor() {
super();
this.params = {};
}
}
export class Selector {
kind: string;
decoration: string;

View File

@ -247,7 +247,8 @@
"CONFIG": "Configuration",
"HELMCHART": "Helm Charts",
"ROBOT_ACCOUNTS": "Robot Accounts",
"WEBHOOKS": "Webhooks"
"WEBHOOKS": "Webhooks",
"IMMUTABLE_TAG": "Immutable Tag"
},
"PROJECT_CONFIG": {
"REGISTRY": "Project registry",

View File

@ -248,7 +248,8 @@
"CONFIG": "Configuración",
"HELMCHART": "Helm Charts",
"ROBOT_ACCOUNTS": "Robot Accounts",
"WEBHOOKS": "Webhooks"
"WEBHOOKS": "Webhooks",
"IMMUTABLE_TAG": "Immutable Tag"
},
"PROJECT_CONFIG": {
"REGISTRY": "Registro de proyectos",

View File

@ -241,7 +241,8 @@
"CONFIG": "Configuration",
"HELMCHART": "Helm Charts",
"ROBOT_ACCOUNTS": "Robot Accounts",
"WEBHOOKS": "Webhooks"
"WEBHOOKS": "Webhooks",
"IMMUTABLE_TAG": "Immutable Tag"
},
"PROJECT_CONFIG": {
"REGISTRY": "Dépôt du Projet",

View File

@ -245,7 +245,8 @@
"CONFIG": "Configuração",
"HELMCHART": "Helm Charts",
"ROBOT_ACCOUNTS": "Robot Accounts",
"WEBHOOKS": "Webhooks"
"WEBHOOKS": "Webhooks",
"IMMUTABLE_TAG": "Immutable Tag"
},
"PROJECT_CONFIG": {
"REGISTRY": "Registro do Projeto",

View File

@ -247,7 +247,8 @@
"CONFIG": "Ayarlar",
"HELMCHART": "Helm Tabloları",
"ROBOT_ACCOUNTS": "Robot Hesapları",
"WEBHOOKS": "Ağ Kancaları"
"WEBHOOKS": "Ağ Kancaları",
"IMMUTABLE_TAG": "Immutable Tag"
},
"PROJECT_CONFIG": {
"REGISTRY": "Proje kaydı",

View File

@ -246,7 +246,8 @@
"CONFIG": "配置管理",
"HELMCHART": "Helm Charts",
"ROBOT_ACCOUNTS": "机器人账户",
"WEBHOOKS": "Webhooks"
"WEBHOOKS": "Webhooks",
"IMMUTABLE_TAG": "不变的Tag"
},
"PROJECT_CONFIG": {
"REGISTRY": "项目仓库",