Enhance webhook UI (#18291)

1. Add execution list for a certain webhook policy
  2. Add task list for a certain execution

Signed-off-by: AllForNothing <sshijun@vmware.com>
This commit is contained in:
Shijun Sun 2023-03-07 17:12:18 +08:00 committed by GitHub
parent 5c0266e719
commit ba9078f463
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
37 changed files with 1624 additions and 253 deletions

View File

@ -1,12 +1,7 @@
<div class="replication-tasks mt-1">
<div class="replication-tasks mt-2">
<section class="overview-section">
<div class="title-wrapper">
<div>
<div>
<a (click)="onBack()" class="onBack"
><{{ 'P2P_PROVIDER.P2P_PROVIDER' | translate }}</a
>
</div>
<div class="title-block">
<div>
<h2 class="custom-h2 h2-style">

View File

@ -2,12 +2,6 @@
.overview-section {
.title-wrapper {
/* stylelint-disable */
.onBack{
color: #007cbb;
font-size: 14px;
cursor: pointer;
}
.title-block {
display: flex;
align-items: center;

View File

@ -328,15 +328,7 @@ export class TaskListComponent implements OnInit, OnDestroy {
}
);
}
onBack(): void {
this.router.navigate([
'harbor',
'projects',
`${this.projectId}`,
'p2p-provider',
'policies',
]);
}
// refresh icon
refreshTasks(): void {
this.currentPage = 1;

View File

@ -2,6 +2,19 @@
<a *ngIf="hasSignedIn" (click)="backToProject()" class="backStyle">{{
'PROJECT_DETAIL.PROJECTS' | translate
}}</a>
<ng-container *ngIf="hasSignedIn && isWebhookTaskListPage()">
<span class="back-icon"><</span>
<a (click)="backToWebhook()" class="backStyle">{{
'PROJECT_DETAIL.WEBHOOKS' | translate
}}</a>
</ng-container>
<ng-container *ngIf="hasSignedIn && isP2pPreheatTaskListPage()">
<span class="back-icon"><</span>
<a (click)="backToP2pPreheat()" class="backStyle">{{
'P2P_PROVIDER.P2P_PROVIDER' | translate
}}</a>
</ng-container>
<a *ngIf="!hasSignedIn" [routerLink]="['/harbor', 'sign-in']" class="backStyle">
{{ 'SEARCH.BACK' | translate }}</a
>

View File

@ -48,6 +48,7 @@ import {
EventService,
HarborEvent,
} from '../../../services/event-service/event.service';
import { RouteConfigId } from '../../../route-reuse-strategy/harbor-route-reuse-strategy';
@Component({
selector: 'project-detail',
@ -472,4 +473,39 @@ export class ProjectDetailComponent
}
});
}
backToWebhook() {
this.router.navigate([
'harbor',
'projects',
`${this.projectId}`,
'webhook',
]);
}
isWebhookTaskListPage(): boolean {
return (
this.route?.firstChild?.firstChild?.snapshot.data[
'routeConfigId'
] === RouteConfigId.WEBHOOK_TASKS_PAGE
);
}
backToP2pPreheat(): void {
this.router.navigate([
'harbor',
'projects',
`${this.projectId}`,
'p2p-provider',
'policies',
]);
}
isP2pPreheatTaskListPage(): boolean {
return (
this.route?.firstChild?.firstChild?.snapshot.data[
'routeConfigId'
] === RouteConfigId.P2P_TASKS_PAGE
);
}
}

View File

@ -101,6 +101,23 @@
</option>
</select>
</clr-select-container>
<!-- payload format -->
<clr-select-container *ngIf="webhook.targets[0].type === 'http'">
<label>{{ 'WEBHOOK.PAYLOAD_FORMAT' | translate }}</label>
<select
class="width-238"
clrSelect
name="payloadFormat"
id="payload-format"
[(ngModel)]="webhook.targets[0].payload_format"
[disabled]="checking">
<option
*ngFor="let item of getPayLoadFormats()"
value="{{ item }}">
{{ getI18nKey(item) | translate }}
</option>
</select>
</clr-select-container>
<div class="clr-form-control">
<label class="clr-control-label required">{{

View File

@ -15,12 +15,18 @@ import {
finalize,
switchMap,
} from 'rxjs/operators';
import { ProjectWebhookService } from '../webhook.service';
import {
PAYLOAD_FORMATS,
PAYLOAD_FORMAT_I18N_MAP,
ProjectWebhookService,
} from '../webhook.service';
import { compareValue } from '../../../../shared/units/utils';
import { InlineAlertComponent } from '../../../../shared/components/inline-alert/inline-alert.component';
import { WebhookService } from '../../../../../../ng-swagger-gen/services/webhook.service';
import { WebhookPolicy } from '../../../../../../ng-swagger-gen/models/webhook-policy';
import { Subject, Subscription } from 'rxjs';
import { SupportedWebhookEventTypes } from '../../../../../../ng-swagger-gen/models/supported-webhook-event-types';
import { PayloadFormatType } from '../../../../../../ng-swagger-gen/models/payload-format-type';
@Component({
selector: 'add-webhook-form',
@ -40,6 +46,7 @@ export class AddWebhookFormComponent implements OnInit, OnDestroy {
type: 'http',
address: '',
skip_cert_verify: true,
payload_format: PAYLOAD_FORMATS[0],
},
],
};
@ -51,7 +58,7 @@ export class AddWebhookFormComponent implements OnInit, OnDestroy {
@ViewChild('webhookForm', { static: true }) currentForm: NgForm;
@ViewChild(InlineAlertComponent) inlineAlert: InlineAlertComponent;
@Input()
metadata: any;
metadata: SupportedWebhookEventTypes;
@Output() notify = new EventEmitter<WebhookPolicy>();
checkNameOnGoing: boolean = false;
isNameExisting: boolean = false;
@ -204,4 +211,28 @@ export class AddWebhookFormComponent implements OnInit, OnDestroy {
eventTypeToText(eventType: string): string {
return this.projectWebhookService.eventTypeToText(eventType);
}
getPayLoadFormats(): PayloadFormatType[] {
if (
this.metadata?.payload_formats?.length &&
this.webhook.targets[0].type
) {
for (let i = 0; i < this.metadata.payload_formats.length; i++) {
if (
this.metadata.payload_formats[i].notify_type ===
this.webhook.targets[0].type
) {
return this.metadata.payload_formats[i].formats;
}
}
}
return [];
}
getI18nKey(v: string): string {
if (v && PAYLOAD_FORMAT_I18N_MAP[v]) {
return PAYLOAD_FORMAT_I18N_MAP[v];
}
return v;
}
}

View File

@ -1,3 +1,4 @@
:host::ng-deep.modal-dialog {
width: 30rem;
height: 35rem;
}

View File

@ -7,6 +7,7 @@ import {
} from '@angular/core';
import { AddWebhookFormComponent } from '../add-webhook-form/add-webhook-form.component';
import { WebhookPolicy } from '../../../../../../ng-swagger-gen/models/webhook-policy';
import { SupportedWebhookEventTypes } from '../../../../../../ng-swagger-gen/models/supported-webhook-event-types';
@Component({
selector: 'add-webhook',
@ -22,7 +23,7 @@ export class AddWebhookComponent {
@Input() projectId: number;
webhook: WebhookPolicy;
@Input()
metadata: any;
metadata: SupportedWebhookEventTypes;
@ViewChild(AddWebhookFormComponent)
addWebhookFormComponent: AddWebhookFormComponent;
@Output() notify = new EventEmitter<WebhookPolicy>();

View File

@ -0,0 +1,120 @@
<div class="col-lg-12 col-md-12 col-sm-12 col-xs-12">
<clr-datagrid
[clrDgLoading]="loading"
(clrDgRefresh)="clrLoadExecutions($event, true, null)">
<clr-dg-action-bar class="mt-2">
<div class="flex-end">
<h4 class="mt-0">
{{ 'P2P_PROVIDER.EXECUTIONS' | translate }}
</h4>
<span class="refresh-btn">
<clr-icon
shape="refresh"
(click)="refreshExecutions(true, null)"
[hidden]="loading"></clr-icon>
</span>
</div>
</clr-dg-action-bar>
<clr-dg-column [clrDgField]="'id'">{{
'REPLICATION.ID' | translate
}}</clr-dg-column>
<clr-dg-column>{{
'JOB_SERVICE_DASHBOARD.VENDOR_TYPE' | translate
}}</clr-dg-column>
<clr-dg-column [clrDgSortBy]="'status'">{{
'DESTINATION.STATUS' | translate
}}</clr-dg-column>
<clr-dg-column [clrDgSortBy]="'status'">{{
'WEBHOOK.EVENT_TYPE' | translate
}}</clr-dg-column>
<clr-dg-column>{{ 'WEBHOOK.PAYLOAD_DATA' | translate }}</clr-dg-column>
<clr-dg-column [clrDgSortBy]="'start_time'">{{
'REPLICATION.CREATION_TIME' | translate
}}</clr-dg-column>
<clr-dg-column [clrDgSortBy]="'end_time'">{{
'REPLICATION.END_TIME' | translate
}}</clr-dg-column>
<clr-dg-placeholder>{{
'P2P_PROVIDER.JOB_PLACEHOLDER' | translate
}}</clr-dg-placeholder>
<clr-dg-row
*ngFor="let execution of executions"
[clrDgItem]="execution">
<clr-dg-cell>
<a href="javascript:void(0)" (click)="goToLink(execution.id)">{{
execution.id
}}</a>
</clr-dg-cell>
<clr-dg-cell>
{{ execution.vendor_type }}
</clr-dg-cell>
<clr-dg-cell
>{{ execution.status }}
<clr-tooltip>
<clr-icon
*ngIf="execution.status_message"
clrTooltipTrigger
shape="info-circle"
size="20"></clr-icon>
<clr-tooltip-content
[clrPosition]="'left'"
clrSize="md"
*clrIfOpen>
<span>{{ execution.status_message }}</span>
</clr-tooltip-content>
</clr-tooltip></clr-dg-cell
>
<clr-dg-cell>
<span class="label-flex">
{{ eventTypeToText(execution?.extra_attrs?.event_type) }}
</span>
</clr-dg-cell>
<clr-dg-cell class="flex">
<clr-signpost>
<a class="btn btn-link link-normal" clrSignpostTrigger>{{
toString(toJson(execution?.extra_attrs?.payload))
}}</a>
<clr-signpost-content
class="pre"
[clrPosition]="'top-middle'"
*clrIfOpen>
<hbr-copy-input
[iconMode]="true"
[defaultValue]="
toString(execution?.extra_attrs?.payload)
"></hbr-copy-input>
<pre
[innerHTML]="
toJson(execution?.extra_attrs?.payload)
| json
| markdown
"></pre>
</clr-signpost-content>
</clr-signpost>
</clr-dg-cell>
<clr-dg-cell>{{
execution.start_time | harborDatetime: 'short'
}}</clr-dg-cell>
<clr-dg-cell>{{
execution.end_time | harborDatetime: 'short'
}}</clr-dg-cell>
</clr-dg-row>
<clr-dg-footer>
<clr-dg-pagination
#pagination
[(clrDgPage)]="currentPage"
[clrDgPageSize]="pageSize"
[clrDgTotalItems]="total">
<clr-dg-page-size [clrPageSizeOptions]="[15, 25, 50]">{{
'PAGINATION.PAGE_SIZE' | translate
}}</clr-dg-page-size>
<span *ngIf="total"
>{{ pagination.firstItem + 1 }} -
{{ pagination.lastItem + 1 }}
{{ 'REPLICATION.OF' | translate }}</span
>
{{ total }} {{ 'REPLICATION.ITEMS' | translate }}
</clr-dg-pagination>
</clr-dg-footer>
</clr-datagrid>
</div>

View File

@ -0,0 +1,48 @@
.link-normal {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
max-width: 10rem;
text-transform: unset;
}
.flex {
display: flex;
}
.pre {
min-width: 25rem;
max-width: 40rem;
}
.flex-end {
display: flex;
justify-content: space-between;
}
.refresh-btn {
margin-right: 1rem;
margin-bottom: 0.5rem;
cursor: pointer;
}
.refresh-btn:hover {
color: #007CBB;
}
pre {
border: none;
}
.label-flex {
width: 9rem;
letter-spacing: 0;
display: block;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
border: 1px solid;
border-radius: 9px;
padding: 0 7px 0 10px;
text-align: center;
}

View File

@ -0,0 +1,144 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ExecutionsComponent } from './executions.component';
import { SharedTestingModule } from '../../../../shared/shared.module';
import { WebhookService } from '../../../../../../ng-swagger-gen/services/webhook.service';
import { HttpHeaders, HttpResponse } from '@angular/common/http';
import { Execution } from '../../../../../../ng-swagger-gen/models/execution';
import { of } from 'rxjs';
import { WebhookPolicy } from '../../../../../../ng-swagger-gen/models/webhook-policy';
import { delay } from 'rxjs/operators';
import { ProjectWebhookService } from '../webhook.service';
describe('ExecutionsComponent', () => {
let component: ExecutionsComponent;
let fixture: ComponentFixture<ExecutionsComponent>;
const mockedExecutions: Execution[] = [
{
end_time: '2023-02-28T03:54:01Z',
extra_attrs: {
event_data: {
replication: {
artifact_type: 'image',
authentication_type: 'basic',
dest_resource: {
endpoint: 'https://nightly-oidc.harbor.io',
namespace: 'library',
registry_type: 'harbor',
},
execution_timestamp: 1677556395,
harbor_hostname: 'nightly-oidc.harbor.io',
job_status: 'Success',
override_mode: true,
policy_creator: 'admin',
src_resource: {
endpoint: 'https://hub.docker.com',
namespace: 'library',
registry_name: 'docker hub',
registry_type: 'docker-hub',
},
successful_artifact: [
{
name_tag: 'redis [1 item(s) in total]',
status: 'Success',
type: 'image',
},
],
trigger_type: 'MANUAL',
},
},
occur_at: 1677556415,
operator: 'MANUAL',
type: 'REPLICATION',
},
id: 30,
metrics: { success_task_count: 1, task_count: 1 },
start_time: '2023-02-28T03:53:35Z',
status: 'Success',
trigger: 'EVENT',
vendor_id: 2,
vendor_type: 'WEBHOOK',
},
{
end_time: '2023-02-28T03:53:40Z',
extra_attrs: {
event_data: {
repository: {
date_created: 1677556403,
name: 'redis',
namespace: 'library',
repo_full_name: 'library/redis',
repo_type: 'public',
},
resources: [
{
digest: 'sha256:6a59f1cbb8d28ac484176d52c473494859a512ddba3ea62a547258cf16c9b3ae',
resource_url:
'nightly-oidc.harbor.io/library/redis:latest',
tag: 'latest',
},
],
},
occur_at: 1677556415,
operator: 'harbor-jobservice',
type: 'PUSH_ARTIFACT',
},
id: 28,
metrics: { success_task_count: 1, task_count: 1 },
start_time: '2023-02-28T03:53:35Z',
status: 'Success',
trigger: 'EVENT',
vendor_id: 2,
vendor_type: 'WEBHOOK',
},
];
const mockedWebhookService = {
ListExecutionsOfWebhookPolicyResponse() {
return of(
new HttpResponse<Array<Execution>>({
headers: new HttpHeaders({
'x-total-count': '2',
}),
body: mockedExecutions,
})
).pipe(delay(0));
},
};
const mockedWebhookPolicy: WebhookPolicy = {
id: 1,
project_id: 1,
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [SharedTestingModule],
declarations: [ExecutionsComponent],
providers: [
{
provide: WebhookService,
useValue: mockedWebhookService,
},
ProjectWebhookService,
],
}).compileComponents();
fixture = TestBed.createComponent(ExecutionsComponent);
component = fixture.componentInstance;
component.selectedWebhook = mockedWebhookPolicy;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should render execution list and no timeout', async () => {
await fixture.whenStable();
fixture.detectChanges();
const rows = fixture.nativeElement.querySelectorAll('clr-dg-row');
expect(rows.length).toEqual(2);
expect(component.timeout).toBeFalsy();
});
});

View File

@ -0,0 +1,175 @@
import { Component, Input, OnDestroy } from '@angular/core';
import { WebhookPolicy } from '../../../../../../ng-swagger-gen/models/webhook-policy';
import { finalize } from 'rxjs/operators';
import { Router } from '@angular/router';
import { WebhookService } from '../../../../../../ng-swagger-gen/services/webhook.service';
import {
getPageSizeFromLocalStorage,
getSortingString,
PageSizeMapKeys,
setPageSizeToLocalStorage,
} from '../../../../shared/units/utils';
import { Execution } from '../../../../../../ng-swagger-gen/models/execution';
import { ClrDatagridStateInterface } from '@clr/angular';
import { MessageHandlerService } from '../../../../shared/services/message-handler.service';
import {
EXECUTION_STATUS,
TIME_OUT,
} from '../../p2p-provider/p2p-provider.service';
import { ProjectWebhookService } from '../webhook.service';
@Component({
selector: 'app-executions',
templateUrl: './executions.component.html',
styleUrls: ['./executions.component.scss'],
})
export class ExecutionsComponent implements OnDestroy {
@Input()
selectedWebhook: WebhookPolicy;
executions: Execution[] = [];
loading: boolean = true;
currentPage: number = 1;
pageSize: number = getPageSizeFromLocalStorage(
PageSizeMapKeys.WEBHOOK_EXECUTIONS_COMPONENT
);
total: number = 0;
state: ClrDatagridStateInterface;
timeout: any;
constructor(
private webhookService: WebhookService,
private messageHandlerService: MessageHandlerService,
private router: Router,
private projectWebhookService: ProjectWebhookService
) {}
ngOnDestroy(): void {
this.clearLoop();
}
clrLoadExecutions(
state: ClrDatagridStateInterface,
withLoading: boolean,
policyId: number
) {
if (state) {
this.state = state;
}
if (state && state.page) {
this.pageSize = state.page.size;
setPageSizeToLocalStorage(
PageSizeMapKeys.WEBHOOK_EXECUTIONS_COMPONENT,
this.pageSize
);
}
let q: string;
if (state && state.filters && state.filters.length) {
q = encodeURIComponent(
`${state.filters[0].property}=~${state.filters[0].value}`
);
}
let sort: string;
if (state && state.sort && state.sort.by) {
sort = getSortingString(state);
} else {
// sort by start_time desc by default
sort = `-start_time`;
}
if (withLoading) {
this.loading = true;
}
this.webhookService
.ListExecutionsOfWebhookPolicyResponse({
webhookPolicyId: policyId ? policyId : this.selectedWebhook.id,
projectNameOrId: this.selectedWebhook.project_id.toString(),
pageSize: this.pageSize,
page: this.currentPage,
sort: sort,
q: q,
})
.pipe(
finalize(() => {
this.loading = false;
})
)
.subscribe({
next: res => {
if (res.headers) {
let xHeader: string = res.headers.get('X-Total-Count');
if (xHeader) {
this.total = parseInt(xHeader, 0);
}
}
this.executions = res.body || [];
this.setLoop();
},
error: err => {
this.messageHandlerService.error(err);
},
});
}
goToLink(id: number) {
const linkUrl = [
'harbor',
'projects',
`${this.selectedWebhook.project_id}`,
'webhook',
`${this.selectedWebhook.id}`,
'executions',
`${id}`,
'tasks',
];
this.router.navigate(linkUrl);
}
toString(v: any) {
if (v) {
return JSON.stringify(v);
}
return '';
}
toJson(v: any) {
if (v) {
return JSON.parse(v);
}
return null;
}
refreshExecutions(shouldReset: boolean, policyId: number) {
if (shouldReset) {
this.executions = [];
this.currentPage = 1;
}
this.clrLoadExecutions(this.state, true, policyId);
}
clearLoop() {
if (this.timeout) {
clearTimeout(this.timeout);
this.timeout = null;
}
}
setLoop() {
this.clearLoop();
if (this.executions && this.executions.length) {
for (let i = 0; i < this.executions.length; i++) {
if (this.willChangStatus(this.executions[i].status)) {
if (!this.timeout) {
this.timeout = setTimeout(() => {
this.clrLoadExecutions(this.state, false, null);
}, TIME_OUT);
}
}
}
}
}
willChangStatus(status: string): boolean {
return (
status === EXECUTION_STATUS.PENDING ||
status === EXECUTION_STATUS.RUNNING ||
status === EXECUTION_STATUS.SCHEDULED
);
}
eventTypeToText(eventType: any): string {
return this.projectWebhookService.eventTypeToText(eventType);
}
}

View File

@ -1,40 +0,0 @@
<h4>{{ 'WEBHOOK.LAST_TRIGGER' | translate }}</h4>
<clr-datagrid>
<clr-dg-column [clrDgField]="'event_type'">{{
'WEBHOOK.TYPE' | translate
}}</clr-dg-column>
<clr-dg-column>{{ 'WEBHOOK.STATUS' | translate }}</clr-dg-column>
<clr-dg-column>{{ 'WEBHOOK.LAST_TRIGGERED' | translate }}</clr-dg-column>
<clr-dg-row *clrDgItems="let item of lastTriggers">
<clr-dg-cell>{{ eventTypeToText(item.event_type) }}</clr-dg-cell>
<clr-dg-cell>
<div *ngIf="item?.enabled" class="icon-wrap">
<clr-icon
shape="check-circle"
size="20"
class="is-success enabled-icon"></clr-icon>
<span>{{ 'WEBHOOK.ENABLED' | translate }}</span>
</div>
<div *ngIf="!item?.enabled" class="icon-wrap">
<clr-icon
shape="exclamation-triangle"
size="20"
class="is-warning"></clr-icon>
<span>{{ 'WEBHOOK.DISABLED' | translate }}</span>
</div>
</clr-dg-cell>
<clr-dg-cell>{{
item.last_trigger_time | harborDatetime: 'short'
}}</clr-dg-cell>
</clr-dg-row>
<clr-dg-placeholder>
{{ 'WEBHOOK.NO_TRIGGER' | translate }}
</clr-dg-placeholder>
<clr-dg-footer>
<span *ngIf="lastTriggers.length"
>1 - {{ lastTriggers.length }} {{ 'WEBHOOK.OF' | translate }}
</span>
{{ lastTriggers.length }} {{ 'WEBHOOK.ITEMS' | translate }}
<clr-dg-pagination [clrDgPageSize]="10"></clr-dg-pagination>
</clr-dg-footer>
</clr-datagrid>

View File

@ -1,63 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { SharedTestingModule } from '../../../../shared/shared.module';
import { LastTriggerComponent } from './last-trigger.component';
import { SimpleChange } from '@angular/core';
import { ProjectWebhookService } from '../webhook.service';
import { WebhookLastTrigger } from '../../../../../../ng-swagger-gen/models/webhook-last-trigger';
describe('LastTriggerComponent', () => {
const mokedTriggers: WebhookLastTrigger[] = [
{
policy_name: 'http',
enabled: true,
event_type: 'pullImage',
creation_time: null,
last_trigger_time: null,
},
{
policy_name: 'slack',
enabled: true,
event_type: 'pullImage',
creation_time: null,
last_trigger_time: null,
},
];
const mockWebhookService = {
eventTypeToText(eventType: string) {
return eventType;
},
};
let component: LastTriggerComponent;
let fixture: ComponentFixture<LastTriggerComponent>;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [SharedTestingModule],
declarations: [LastTriggerComponent],
providers: [
{
provide: ProjectWebhookService,
useValue: mockWebhookService,
},
],
});
});
beforeEach(() => {
fixture = TestBed.createComponent(LastTriggerComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should render one row', async () => {
component.inputLastTriggers = mokedTriggers;
component.webhookName = 'slack';
component.ngOnChanges({
inputLastTriggers: new SimpleChange([], mokedTriggers, true),
});
fixture.detectChanges();
await fixture.whenStable();
const rows = fixture.nativeElement.getElementsByTagName('clr-dg-row');
expect(rows.length).toEqual(1);
});
});

View File

@ -1,28 +0,0 @@
import { Component, Input, OnChanges, SimpleChanges } from '@angular/core';
import { ProjectWebhookService } from '../webhook.service';
import { WebhookLastTrigger } from '../../../../../../ng-swagger-gen/models/webhook-last-trigger';
@Component({
selector: 'last-trigger',
templateUrl: 'last-trigger.component.html',
styleUrls: ['./last-trigger.component.scss'],
})
export class LastTriggerComponent implements OnChanges {
@Input() inputLastTriggers: WebhookLastTrigger[];
@Input() webhookName: string;
lastTriggers: WebhookLastTrigger[] = [];
constructor(private webhookService: ProjectWebhookService) {}
ngOnChanges(changes: SimpleChanges): void {
if (changes && changes['inputLastTriggers']) {
this.lastTriggers = [];
this.inputLastTriggers.forEach(item => {
if (this.webhookName === item.policy_name) {
this.lastTriggers.push(item);
}
});
}
}
eventTypeToText(eventType: string): string {
return this.webhookService.eventTypeToText(eventType);
}
}

View File

@ -0,0 +1,172 @@
<div class="replication-tasks mt-3">
<section class="overview-section">
<div class="title-wrapper">
<div>
<div class="title-block">
<div class="first-item">
<h2 class="custom-h2 h2-style mt-0">
{{ 'P2P_PROVIDER.EXECUTIONS' | translate }}
</h2>
<span class="id-divider"></span>
<h2 class="custom-h2 h2-style mt-0">
{{ executionId }}
</h2>
</div>
<div>
<div
class="status-progress"
*ngIf="execution && isInProgress()">
<span class="spinner spinner-inline"></span>
<span>{{
'REPLICATION.IN_PROGRESS' | translate
}}</span>
</div>
<div
class="status-success"
*ngIf="execution && isSuccess()">
<clr-icon
size="18"
shape="success-standard"
class="color-green"></clr-icon>
<span>{{ 'REPLICATION.SUCCESS' | translate }}</span>
</div>
<div
class="status-failed"
*ngIf="execution && isFailed()">
<clr-icon
size="18"
shape="error-standard"
class="color-red"></clr-icon>
<span>{{ 'REPLICATION.FAILURE' | translate }}</span>
</div>
</div>
</div>
</div>
</div>
<div class="execution-block">
<div class="executions-detail">
<div>
<label
>{{ 'REPLICATION.CREATION_TIME' | translate }} :</label
>
<span>{{ startTime() | harborDatetime: 'short' }}</span>
</div>
</div>
<div class="flex-block">
<section class="execution-detail-label">
<section class="detail-row">
<div class="num-success common-style"></div>
<label class="detail-span">{{
'REPLICATION.SUCCESS' | translate
}}</label>
<div class="execution-details">{{ successNum() }}</div>
</section>
<section class="detail-row">
<div class="num-failed common-style"></div>
<label class="detail-span">{{
'REPLICATION.FAILURE' | translate
}}</label>
<div class="execution-details">{{ failedNum() }}</div>
</section>
<section class="detail-row">
<div class="num-progress common-style"></div>
<label class="detail-span">{{
'REPLICATION.IN_PROGRESS' | translate
}}</label>
<div class="execution-details">{{ progressNum() }}</div>
</section>
<section class="detail-row">
<div class="num-stopped common-style"></div>
<label class="detail-span">{{
'REPLICATION.STOPPED' | translate
}}</label>
<div class="execution-details">{{ stoppedNum() }}</div>
</section>
</section>
</div>
</div>
</section>
<div class="tasks-detail">
<clr-datagrid
(clrDgRefresh)="clrLoadTasks($event, true)"
[clrDgLoading]="loading">
<clr-dg-action-bar>
<div class="flex-end">
<h3 class="modal-title mt-0">
{{ 'P2P_PROVIDER.TASKS' | translate }}
</h3>
<span class="refresh-btn">
<clr-icon
shape="refresh"
(click)="refreshTasks()"
[hidden]="loading"></clr-icon>
</span>
</div>
</clr-dg-action-bar>
<clr-dg-column [clrDgField]="'id'">{{
'REPLICATION.TASK_ID' | translate
}}</clr-dg-column>
<clr-dg-column>{{ 'P2P_PROVIDER.ID' | translate }}</clr-dg-column>
<clr-dg-column [clrDgSortBy]="'status'">{{
'DESTINATION.STATUS' | translate
}}</clr-dg-column>
<clr-dg-column [clrDgSortBy]="'creation_time'">{{
'DESTINATION.CREATION_TIME' | translate
}}</clr-dg-column>
<clr-dg-column>{{ 'REPLICATION.LOGS' | translate }}</clr-dg-column>
<clr-dg-placeholder>{{
'P2P_PROVIDER.TASKS_PLACEHOLDER' | translate
}}</clr-dg-placeholder>
<clr-dg-row *ngFor="let t of tasks" [clrDgItem]="t">
<clr-dg-cell>{{ t.id }}</clr-dg-cell>
<clr-dg-cell>{{ t.execution_id }}</clr-dg-cell>
<clr-dg-cell
>{{ t.status
}}<clr-tooltip>
<clr-icon
*ngIf="t.status_message"
clrTooltipTrigger
shape="info-circle"
size="20"></clr-icon>
<clr-tooltip-content
[clrPosition]="'left'"
clrSize="md"
*clrIfOpen>
<span>{{ t.status_message }}</span>
</clr-tooltip-content>
</clr-tooltip></clr-dg-cell
>
<clr-dg-cell>{{
t.creation_time | harborDatetime: 'short'
}}</clr-dg-cell>
<clr-dg-cell>
<a
rel="noopener noreferrer"
target="_blank"
[href]="viewLog(t.id)"
*ngIf="t.status !== 'Initialized'">
<clr-icon shape="list"></clr-icon>
</a>
</clr-dg-cell>
</clr-dg-row>
<clr-dg-footer>
<clr-dg-pagination
#pagination
[(clrDgPage)]="currentPage"
[clrDgPageSize]="pageSize"
[clrDgTotalItems]="totalCount">
<clr-dg-page-size [clrPageSizeOptions]="[15, 25, 50]">{{
'PAGINATION.PAGE_SIZE' | translate
}}</clr-dg-page-size>
<span *ngIf="totalCount"
>{{ pagination.firstItem + 1 }} -
{{ pagination.lastItem + 1 }}
{{ 'REPLICATION.OF' | translate }}</span
>
{{ totalCount }} {{ 'REPLICATION.ITEMS' | translate }}
</clr-dg-pagination>
</clr-dg-footer>
</clr-datagrid>
</div>
</div>

View File

@ -0,0 +1,174 @@
.replication-tasks {
.overview-section {
.title-wrapper {
/* stylelint-disable */
.onBack{
color: #007cbb;
font-size: 14px;
cursor: pointer;
}
.title-block {
display: flex;
align-items: center;
>div:first-child {
min-width: 384px;
margin-right: 5px;
}
>div:nth-child(2) {
width: 140px;
span {
color: #007cbb;
font-size: 12px;
margin-left: 10px;
}
}
.id-divider {
display: inline-block;
height: 25px;
width: 2px;
background-color: #ccc;
margin: 0 20px;
}
.h2-style {
display: inline-block;
}
}
}
.execution-block {
margin-top: 24px;
display: flex;
flex-wrap: wrap;
.execution-detail-label {
margin-right: 10px;
text-align: left;
.detail-row {
display: flex;
height: 27px;
.common-style {
width: 12px;
height: 12px;
margin-top: 16px;
}
.num-success {
background-color: #308700;
}
.num-failed {
background-color: #C92101;
}
.num-progress {
background-color: #1C5898;
}
.num-stopped {
background-color: #A1A1A1;
}
.detail-span {
flex:0 0 100px;
margin: 10px 0 0 10px;
}
.execution-details {
width: 200px;
margin: 8px 35px;
}
}
}
.executions-detail {
width: 391px;
label {
display: inline-block;
width: 120px;
}
>div {
margin-top: 10px;
}
}
}
}
.tasks-detail {
margin-top: 65px;
.action-select {
padding-right: 18px;
height: 24px;
display: flex;
justify-content: flex-end;
.filter-tag {
float: left;
margin-top: 8px;
}
.refresh-btn {
cursor: pointer;
margin-top: 7px;
}
.refresh-btn:hover {
color: #007CBB;
}
}
clr-datagrid {
.resource-width {
width: 150px;
}
}
}
}
.margin-top-16px {
margin-top: 16px;
}
.flex-end {
display: flex;
justify-content: space-between;
}
.refresh-btn {
margin-top: 6px;
cursor: pointer;
}
.filter-tag {
margin-top: 7px;
}
.button-stop {
margin-top: 16px;
margin-left: -5px;
}
.refresh-btn {
margin-right: 1rem;
margin-bottom: 0.5rem;
cursor: pointer;
}
.flex {
display: flex;
}
.pre {
min-width: 25rem;
max-width: 100%;
}

View File

@ -0,0 +1,181 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TasksComponent } from './tasks.component';
import { SharedTestingModule } from '../../../../shared/shared.module';
import { Execution } from '../../../../../../ng-swagger-gen/models/execution';
import { HttpHeaders, HttpResponse } from '@angular/common/http';
import { Task } from '../../../../../../ng-swagger-gen/models/task';
import { of } from 'rxjs';
import { WebhookService } from '../../../../../../ng-swagger-gen/services/webhook.service';
import { ActivatedRoute } from '@angular/router';
import { delay } from 'rxjs/operators';
describe('TasksComponent', () => {
let component: TasksComponent;
let fixture: ComponentFixture<TasksComponent>;
const mockedExecutions: Execution[] = [
{
end_time: '2023-02-28T03:54:01Z',
extra_attrs: {
event_data: {
replication: {
artifact_type: 'image',
authentication_type: 'basic',
dest_resource: {
endpoint: 'https://nightly-oidc.harbor.io',
namespace: 'library',
registry_type: 'harbor',
},
execution_timestamp: 1677556395,
harbor_hostname: 'nightly-oidc.harbor.io',
job_status: 'Success',
override_mode: true,
policy_creator: 'admin',
src_resource: {
endpoint: 'https://hub.docker.com',
namespace: 'library',
registry_name: 'docker hub',
registry_type: 'docker-hub',
},
successful_artifact: [
{
name_tag: 'redis [1 item(s) in total]',
status: 'Success',
type: 'image',
},
],
trigger_type: 'MANUAL',
},
},
occur_at: 1677556415,
operator: 'MANUAL',
type: 'REPLICATION',
},
id: 30,
metrics: { success_task_count: 1, task_count: 1 },
start_time: '2023-02-28T03:53:35Z',
status: 'Success',
trigger: 'EVENT',
vendor_id: 2,
vendor_type: 'WEBHOOK',
},
];
const tasks: Array<Task> = [
{
creation_time: '2023-02-28T03:53:35Z',
end_time: '2023-02-28T03:54:01Z',
execution_id: 30,
extra_attrs: {
event_data: {
replication: {
artifact_type: 'image',
authentication_type: 'basic',
dest_resource: {
endpoint: 'https://nightly-oidc.harbor.io',
namespace: 'library',
registry_type: 'harbor',
},
execution_timestamp: 1677556395,
harbor_hostname: 'nightly-oidc.harbor.io',
job_status: 'Success',
override_mode: true,
policy_creator: 'admin',
src_resource: {
endpoint: 'https://hub.docker.com',
namespace: 'library',
registry_name: 'docker hub',
registry_type: 'docker-hub',
},
successful_artifact: [
{
name_tag: 'redis [1 item(s) in total]',
status: 'Success',
type: 'image',
},
],
trigger_type: 'MANUAL',
},
},
occur_at: 1677556415,
operator: 'MANUAL',
type: 'REPLICATION',
},
id: 30,
run_count: 1,
start_time: '2023-02-28T03:53:35Z',
status: 'Success',
update_time: '2023-02-28T03:54:01Z',
},
];
const mockedWebhookService = {
ListExecutionsOfWebhookPolicy() {
return of(mockedExecutions).pipe(delay(0));
},
ListTasksOfWebhookExecutionResponse() {
return of(
new HttpResponse<Array<Task>>({
headers: new HttpHeaders({
'x-total-count': '1',
}),
body: tasks,
})
).pipe(delay(0));
},
};
const mockActivatedRoute = {
snapshot: {
params: {
policyId: 1,
executionId: 1,
},
parent: {
parent: {
params: { id: 1 },
},
},
},
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [SharedTestingModule],
declarations: [TasksComponent],
providers: [
{
provide: WebhookService,
useValue: mockedWebhookService,
},
{ provide: ActivatedRoute, useValue: mockActivatedRoute },
],
}).compileComponents();
fixture = TestBed.createComponent(TasksComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should render task list and no timeout', async () => {
await fixture.whenStable();
fixture.detectChanges();
const rows = fixture.nativeElement.querySelectorAll('clr-dg-row');
expect(rows.length).toEqual(1);
expect(component.timeoutForTaskList).toBeFalsy();
expect(component.executionTimeout).toBeFalsy();
});
it('should show success state', async () => {
await fixture.whenStable();
fixture.detectChanges();
const successState =
fixture.nativeElement.querySelectorAll('.status-success');
expect(successState).toBeTruthy();
expect(component.timeoutForTaskList).toBeFalsy();
});
});

View File

@ -0,0 +1,284 @@
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { finalize } from 'rxjs/operators';
import {
EXECUTION_STATUS,
TIME_OUT,
} from '../../p2p-provider/p2p-provider.service';
import { WebhookService } from '../../../../../../ng-swagger-gen/services/webhook.service';
import { Execution } from '../../../../../../ng-swagger-gen/models/execution';
import { MessageHandlerService } from '../../../../shared/services/message-handler.service';
import {
getPageSizeFromLocalStorage,
getSortingString,
PageSizeMapKeys,
setPageSizeToLocalStorage,
} from '../../../../shared/units/utils';
import { ClrDatagridStateInterface } from '@clr/angular';
import { Task } from 'ng-swagger-gen/models/task';
@Component({
selector: 'app-tasks',
templateUrl: './tasks.component.html',
styleUrls: ['./tasks.component.scss'],
})
export class TasksComponent implements OnInit {
projectId: number;
policyId: number;
tasks: Task[] = [];
executionId: number;
loading: boolean = true;
inProgress: boolean = false;
execution: Execution;
executionTimeout: any;
currentPage: number = 1;
pageSize: number = getPageSizeFromLocalStorage(
PageSizeMapKeys.WEBHOOK_TASKS_COMPONENT
);
totalCount: number;
state: ClrDatagridStateInterface;
timeoutForTaskList: any;
constructor(
private route: ActivatedRoute,
private router: Router,
private webhookService: WebhookService,
private messageHandlerService: MessageHandlerService
) {}
ngOnInit(): void {
this.projectId = +this.route.snapshot.parent.parent.params['id'];
this.policyId = +this.route.snapshot.params['policyId'];
this.executionId = +this.route.snapshot.params['executionId'];
if (this.executionId) {
this.getExecutionDetail(true);
}
}
ngOnDestroy(): void {
if (this.executionTimeout) {
clearTimeout(this.executionTimeout);
this.executionTimeout = null;
}
if (this.timeoutForTaskList) {
clearTimeout(this.timeoutForTaskList);
this.timeoutForTaskList = null;
}
}
getExecutionDetail(withLoading: boolean): void {
if (withLoading) {
this.inProgress = true;
}
if (this.executionId) {
this.webhookService
.ListExecutionsOfWebhookPolicy({
webhookPolicyId: this.policyId,
projectNameOrId: this.projectId.toString(),
q: encodeURIComponent(`id=${this.executionId}`),
})
.pipe(finalize(() => (this.inProgress = false)))
.subscribe({
next: res => {
if (res?.length) {
this.execution = res[0];
if (
!this.execution ||
this.execution.status ===
EXECUTION_STATUS.PENDING ||
this.execution.status ===
EXECUTION_STATUS.RUNNING ||
this.execution.status ===
EXECUTION_STATUS.SCHEDULED
) {
if (this.executionTimeout) {
clearTimeout(this.executionTimeout);
this.executionTimeout = null;
}
if (!this.executionTimeout) {
this.executionTimeout = setTimeout(() => {
this.getExecutionDetail(false);
}, TIME_OUT);
}
}
}
},
error: error => {
this.messageHandlerService.error(error);
},
});
}
}
clrLoadTasks(state: ClrDatagridStateInterface, withLoading: boolean) {
if (state) {
this.state = state;
}
if (state && state.page) {
this.pageSize = state.page.size;
setPageSizeToLocalStorage(
PageSizeMapKeys.P2P_TASKS_COMPONENT,
this.pageSize
);
}
if (withLoading) {
this.loading = true;
}
let q: string;
if (state && state.filters && state.filters.length) {
q = encodeURIComponent(
`${state.filters[0].property}=~${state.filters[0].value}`
);
}
let sort: string;
if (state && state.sort && state.sort.by) {
sort = getSortingString(state);
} else {
// sort by creation_time desc by default
sort = `-creation_time`;
}
if (withLoading) {
this.loading = true;
}
this.webhookService
.ListTasksOfWebhookExecutionResponse({
projectNameOrId: this.projectId.toString(),
webhookPolicyId: this.policyId,
executionId: +this.executionId,
page: this.currentPage,
pageSize: this.pageSize,
sort: sort,
q: q,
})
.pipe(
finalize(() => {
this.loading = false;
})
)
.subscribe({
next: res => {
if (res.headers) {
let xHeader: string = res.headers.get('x-total-count');
if (xHeader) {
this.totalCount = parseInt(xHeader, 0);
}
}
this.tasks = res.body;
this.setLoop();
},
error: error => {
this.messageHandlerService.error(error);
},
});
}
setLoop() {
if (this.timeoutForTaskList) {
clearTimeout(this.timeoutForTaskList);
this.timeoutForTaskList = null;
}
if (this.tasks && this.tasks.length) {
for (let i = 0; i < this.tasks.length; i++) {
if (this.willChangStatus(this.tasks[i].status)) {
if (!this.timeoutForTaskList) {
this.timeoutForTaskList = setTimeout(() => {
this.clrLoadTasks(this.state, false);
}, TIME_OUT);
}
}
}
}
}
willChangStatus(status: string): boolean {
return (
status === EXECUTION_STATUS.PENDING ||
status === EXECUTION_STATUS.RUNNING ||
status === EXECUTION_STATUS.SCHEDULED
);
}
refreshTasks() {
this.clrLoadTasks(this.state, true);
}
viewLog(taskId: number | string): string {
return `/api/v2.0/projects/${this.projectId}/webhook/policies/${this.policyId}/executions/${this.executionId}/tasks/${taskId}/log`;
}
isInProgress(): boolean {
return this.execution && this.willChangStatus(this.execution.status);
}
isSuccess(): boolean {
return (
this.execution && this.execution.status === EXECUTION_STATUS.SUCCESS
);
}
isFailed(): boolean {
return (
this.execution &&
(this.execution.status === EXECUTION_STATUS.ERROR ||
this.execution.status === EXECUTION_STATUS.STOPPED)
);
}
trigger(): string {
return this.execution && this.execution.trigger
? this.execution.trigger
: '';
}
startTime(): string {
return this.execution && this.execution.start_time
? this.execution.start_time
: null;
}
successNum(): number {
if (this.execution && this.execution.metrics) {
return this.execution.metrics.success_task_count
? this.execution.metrics.success_task_count
: 0;
}
return 0;
}
failedNum(): number {
if (this.execution && this.execution.metrics) {
return this.execution.metrics.error_task_count
? this.execution.metrics.error_task_count
: 0;
}
return 0;
}
progressNum(): number {
if (this.execution && this.execution.metrics) {
const num: number =
(this.execution.metrics.pending_task_count
? this.execution.metrics.pending_task_count
: 0) +
(this.execution.metrics.running_task_count
? this.execution.metrics.running_task_count
: 0) +
(this.execution.metrics.scheduled_task_count
? this.execution.metrics.scheduled_task_count
: 0);
return num ? num : 0;
}
return 0;
}
stoppedNum(): number {
if (this.execution && this.execution.metrics) {
return this.execution.metrics.stopped_task_count
? this.execution.metrics.stopped_task_count
: 0;
}
return 0;
}
toString(v: any) {
if (v) {
return JSON.stringify(v);
}
return '';
}
}

View File

@ -3,7 +3,8 @@
<clr-datagrid
(clrDgRefresh)="getWebhooks($event)"
[clrDgLoading]="loadingWebhookList"
[(clrDgSelected)]="selectedRow">
[(clrDgSingleSelected)]="selectedRow"
(clrDgSingleSelectedChange)="refreshExecutions($event)">
<clr-dg-action-bar>
<div class="clr-row">
<div class="clr-col-7">
@ -32,15 +33,12 @@
clrDropdownItem
(click)="switchWebhookStatus()"
[disabled]="
!(
selectedRow && selectedRow.length === 1
) || !hasUpdatePermission
!selectedRow || !hasUpdatePermission
">
<span id="toggle-webhook">
<span
*ngIf="
selectedRow[0] &&
!selectedRow[0].enabled
selectedRow && !selectedRow.enabled
">
<clr-icon
class="margin-top-2"
@ -53,8 +51,8 @@
<span
*ngIf="
!(
selectedRow[0] &&
!selectedRow[0].enabled
selectedRow &&
!selectedRow.enabled
)
">
<clr-icon
@ -73,9 +71,7 @@
(click)="editWebhook()"
class="btn btn-secondary"
[disabled]="
!(
selectedRow && selectedRow.length === 1
) || !hasUpdatePermission
!selectedRow || !hasUpdatePermission
">
<clr-icon
class="margin-top-0"
@ -93,8 +89,7 @@
(click)="deleteWebhook()"
class="btn btn-secondary"
[disabled]="
!(selectedRow && selectedRow.length >= 1) ||
!hasUpdatePermission
!selectedRow || !hasUpdatePermission
">
<clr-icon
class="margin-top-0"
@ -130,6 +125,9 @@
'WEBHOOK.ENABLED' | translate
}}</clr-dg-column>
<clr-dg-column>{{ 'WEBHOOK.NOTIFY_TYPE' | translate }}</clr-dg-column>
<clr-dg-column>{{
'WEBHOOK.PAYLOAD_FORMAT' | translate
}}</clr-dg-column>
<clr-dg-column class="min-width-340">{{
'WEBHOOK.TARGET' | translate
}}</clr-dg-column>
@ -160,6 +158,9 @@
</div>
</clr-dg-cell>
<clr-dg-cell>{{ w?.targets[0].type }}</clr-dg-cell>
<clr-dg-cell>{{
getI18nKey(w?.targets[0].payload_format) | translate
}}</clr-dg-cell>
<clr-dg-cell>{{ w?.targets[0].address }}</clr-dg-cell>
<clr-dg-cell class="event-types">
<div class="cell" *ngIf="w?.event_types?.length">
@ -200,13 +201,6 @@
w.creation_time | harborDatetime: 'short'
}}</clr-dg-cell>
<clr-dg-cell>{{ w.description }}</clr-dg-cell>
<clr-dg-row-detail *clrIfExpanded>
<last-trigger
class="w-100"
[webhookName]="w.name"
*clrIfExpanded
[inputLastTriggers]="lastTriggers"></last-trigger>
</clr-dg-row-detail>
</clr-dg-row>
<clr-dg-footer>
<clr-dg-pagination
@ -227,6 +221,11 @@
</clr-dg-footer>
</clr-datagrid>
</div>
<app-executions
[selectedWebhook]="selectedRow"
*ngIf="selectedRow"></app-executions>
<add-webhook
(notify)="success()"
[metadata]="metadata"

View File

@ -99,3 +99,11 @@
.width-120 {
width: 120px;
}
.refresh-btn {
cursor: pointer;
}
.refresh-btn:hover {
color: #007CBB;
}

View File

@ -16,6 +16,8 @@ import { WebhookPolicy } from '../../../../../ng-swagger-gen/models/webhook-poli
import { WebhookService } from '../../../../../ng-swagger-gen/services/webhook.service';
import { HttpHeaders, HttpResponse } from '@angular/common/http';
import { Registry } from '../../../../../ng-swagger-gen/models/registry';
import { ExecutionsComponent } from './excutions/executions.component';
import { Execution } from '../../../../../ng-swagger-gen/models/execution';
describe('WebhookComponent', () => {
let component: WebhookComponent;
@ -48,6 +50,7 @@ describe('WebhookComponent', () => {
type: 'http',
auth_header: null,
skip_cert_verify: true,
payload_format: 'cloudevent',
},
],
event_types: ['projectQuota'],
@ -65,9 +68,6 @@ describe('WebhookComponent', () => {
GetSupportedEventTypes() {
return of(mockedMetadata).pipe(delay(0));
},
LastTrigger() {
return of([]).pipe(delay(0));
},
ListWebhookPoliciesOfProjectResponse() {
const response: HttpResponse<Array<Registry>> = new HttpResponse<
Array<Registry>
@ -82,6 +82,16 @@ describe('WebhookComponent', () => {
UpdateWebhookPolicyOfProject() {
return of(true);
},
ListExecutionsOfWebhookPolicyResponse() {
return of(
new HttpResponse<Array<Execution>>({
headers: new HttpHeaders({
'x-total-count': '2',
}),
body: [],
})
).pipe(delay(0));
},
};
const mockActivatedRoute = {
RouterparamMap: of({ get: key => 'value' }),
@ -115,6 +125,7 @@ describe('WebhookComponent', () => {
AddWebhookFormComponent,
InlineAlertComponent,
ConfirmationDialogComponent,
ExecutionsComponent,
],
providers: [
{
@ -162,7 +173,7 @@ describe('WebhookComponent', () => {
});
it('should open edit modal', async () => {
component.webhookList[0].name = 'test';
component.selectedRow[0] = component.webhookList[0];
component.selectedRow = component.webhookList[0];
component.editWebhook();
fixture.detectChanges();
await fixture.whenStable();
@ -178,7 +189,7 @@ describe('WebhookComponent', () => {
});
it('should disable webhook', async () => {
await fixture.whenStable();
component.selectedRow[0] = component.webhookList[0];
component.selectedRow = component.webhookList[0];
component.webhookList[0].enabled = true;
component.switchWebhookStatus();
fixture.detectChanges();
@ -195,7 +206,7 @@ describe('WebhookComponent', () => {
it('should enable webhook', async () => {
await fixture.whenStable();
component.webhookList[0].enabled = false;
component.selectedRow[0] = component.webhookList[0];
component.selectedRow = component.webhookList[0];
component.switchWebhookStatus();
fixture.detectChanges();
await fixture.whenStable();
@ -207,4 +218,13 @@ describe('WebhookComponent', () => {
fixture.nativeElement.querySelector('.modal-body');
expect(bodyEnable).toBeFalsy();
});
it('should show executions', async () => {
await fixture.whenStable();
component.selectedRow = component.webhookList[0];
fixture.detectChanges();
await fixture.whenStable();
const executions =
fixture.nativeElement.querySelector('app-executions');
expect(executions).toBeTruthy();
});
});

View File

@ -13,10 +13,10 @@
// limitations under the License.
import { finalize } from 'rxjs/operators';
import { TranslateService } from '@ngx-translate/core';
import { Component, OnInit, ViewChild } from '@angular/core';
import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core';
import { AddWebhookComponent } from './add-webhook/add-webhook.component';
import { AddWebhookFormComponent } from './add-webhook-form/add-webhook-form.component';
import { ActivatedRoute } from '@angular/router';
import { ActivatedRoute, NavigationEnd, Router } from '@angular/router';
import { MessageHandlerService } from '../../../shared/services/message-handler.service';
import { Project } from '../project';
import {
@ -26,7 +26,7 @@ import {
PageSizeMapKeys,
setPageSizeToLocalStorage,
} from '../../../shared/units/utils';
import { forkJoin, Observable } from 'rxjs';
import { forkJoin, Observable, Subscription } from 'rxjs';
import {
UserPermissionService,
USERSTATICPERMISSION,
@ -40,30 +40,38 @@ import {
} from '../../../shared/entities/shared.const';
import { ConfirmationMessage } from '../../global-confirmation-dialog/confirmation-message';
import { WebhookService } from '../../../../../ng-swagger-gen/services/webhook.service';
import { WebhookLastTrigger } from '../../../../../ng-swagger-gen/models/webhook-last-trigger';
import { WebhookPolicy } from '../../../../../ng-swagger-gen/models/webhook-policy';
import { ProjectWebhookService } from './webhook.service';
import {
PAYLOAD_FORMATS,
PAYLOAD_FORMAT_I18N_MAP,
ProjectWebhookService,
} from './webhook.service';
import { ExecutionsComponent } from './excutions/executions.component';
import {
EventService,
HarborEvent,
} from '../../../services/event-service/event.service';
import { SupportedWebhookEventTypes } from '../../../../../ng-swagger-gen/models/supported-webhook-event-types';
// The route path which will display this component
const URL_TO_DISPLAY: RegExp = /^\/harbor\/projects\/(\d+)\/webhook$/;
@Component({
templateUrl: './webhook.component.html',
styleUrls: ['./webhook.component.scss'],
})
export class WebhookComponent implements OnInit {
export class WebhookComponent implements OnInit, OnDestroy {
@ViewChild(AddWebhookComponent)
addWebhookComponent: AddWebhookComponent;
@ViewChild(AddWebhookFormComponent)
addWebhookFormComponent: AddWebhookFormComponent;
@ViewChild('confirmationDialogComponent')
confirmationDialogComponent: ConfirmationDialogComponent;
lastTriggers: WebhookLastTrigger[] = [];
projectId: number;
projectName: string;
selectedRow: WebhookPolicy[] = [];
selectedRow: WebhookPolicy;
webhookList: WebhookPolicy[] = [];
metadata: any;
metadata: SupportedWebhookEventTypes;
loadingMetadata: boolean = true;
loadingWebhookList: boolean = true;
loadingTriggers: boolean = false;
hasCreatPermission: boolean = false;
hasUpdatePermission: boolean = false;
page: number = 1;
@ -72,16 +80,45 @@ export class WebhookComponent implements OnInit {
);
total: number = 0;
state: ClrDatagridStateInterface;
@ViewChild(ExecutionsComponent)
executionsComponent: ExecutionsComponent;
routerSub: Subscription;
scrollSub: Subscription;
scrollTop: number;
constructor(
private route: ActivatedRoute,
private translate: TranslateService,
private webhookService: WebhookService,
private projectWebhookService: ProjectWebhookService,
private messageHandlerService: MessageHandlerService,
private userPermissionService: UserPermissionService
private userPermissionService: UserPermissionService,
private router: Router,
private event: EventService
) {}
ngOnInit() {
if (!this.scrollSub) {
this.scrollSub = this.event.subscribe(HarborEvent.SCROLL, v => {
if (v && URL_TO_DISPLAY.test(v.url)) {
this.scrollTop = v.scrollTop;
}
});
}
if (!this.routerSub) {
this.routerSub = this.router.events.subscribe(e => {
if (e instanceof NavigationEnd) {
if (e && URL_TO_DISPLAY.test(e.url)) {
// Into view
this.event.publish(
HarborEvent.SCROLL_TO_POSITION,
this.scrollTop
);
} else {
this.event.publish(HarborEvent.SCROLL_TO_POSITION, 0);
}
}
});
}
this.projectId = +this.route.snapshot.parent.parent.params['id'];
let resolverData = this.route.snapshot.parent.parent.data;
if (resolverData) {
@ -91,6 +128,18 @@ export class WebhookComponent implements OnInit {
this.getData();
this.getPermissions();
}
ngOnDestroy(): void {
if (this.routerSub) {
this.routerSub.unsubscribe();
this.routerSub = null;
}
if (this.scrollSub) {
this.scrollSub.unsubscribe();
this.scrollSub = null;
}
}
getPermissions() {
const permissionsList: Observable<boolean>[] = [];
permissionsList.push(
@ -119,14 +168,13 @@ export class WebhookComponent implements OnInit {
refresh() {
this.page = 1;
this.total = 0;
this.selectedRow = [];
this.selectedRow = null;
this.getWebhooks(this.state);
this.getData();
}
getData() {
this.getMetadata();
this.getLastTriggers();
this.selectedRow = [];
this.selectedRow = null;
}
getMetadata() {
this.loadingMetadata = true;
@ -162,23 +210,6 @@ export class WebhookComponent implements OnInit {
);
}
getLastTriggers() {
this.loadingTriggers = true;
this.webhookService
.LastTrigger({
projectNameOrId: this.projectId.toString(),
})
.pipe(finalize(() => (this.loadingTriggers = false)))
.subscribe(
response => {
this.lastTriggers = response;
},
error => {
this.messageHandlerService.handleError(error);
}
);
}
getWebhooks(state?: ClrDatagridStateInterface) {
if (state) {
this.state = state;
@ -235,22 +266,22 @@ export class WebhookComponent implements OnInit {
let content = '';
this.translate
.get(
!this.selectedRow[0].enabled
!this.selectedRow.enabled
? 'WEBHOOK.ENABLED_WEBHOOK_SUMMARY'
: 'WEBHOOK.DISABLED_WEBHOOK_SUMMARY',
{ name: this.selectedRow[0].name }
{ name: this.selectedRow.name }
)
.subscribe(res => {
content = res;
let message = new ConfirmationMessage(
!this.selectedRow[0].enabled
!this.selectedRow.enabled
? 'WEBHOOK.ENABLED_WEBHOOK_TITLE'
: 'WEBHOOK.DISABLED_WEBHOOK_TITLE',
content,
'',
{},
ConfirmationTargets.WEBHOOK,
!this.selectedRow[0].enabled
!this.selectedRow.enabled
? ConfirmationButtons.ENABLE_CANCEL
: ConfirmationButtons.DISABLE_CANCEL
);
@ -268,9 +299,9 @@ export class WebhookComponent implements OnInit {
this.webhookService
.UpdateWebhookPolicyOfProject({
projectNameOrId: this.projectId.toString(),
webhookPolicyId: this.selectedRow[0].id,
policy: Object.assign({}, this.selectedRow[0], {
enabled: !this.selectedRow[0].enabled,
webhookPolicyId: this.selectedRow.id,
policy: Object.assign({}, this.selectedRow, {
enabled: !this.selectedRow.enabled,
}),
})
.subscribe(
@ -282,23 +313,19 @@ export class WebhookComponent implements OnInit {
}
);
} else {
const observableLists: Observable<any>[] = [];
message.data.forEach(item => {
observableLists.push(
this.webhookService.DeleteWebhookPolicyOfProject({
projectNameOrId: this.projectId.toString(),
webhookPolicyId: item.id,
})
);
});
forkJoin(...observableLists).subscribe(
response => {
this.refresh();
},
error => {
this.messageHandlerService.handleError(error);
}
);
this.webhookService
.DeleteWebhookPolicyOfProject({
projectNameOrId: this.projectId.toString(),
webhookPolicyId: message.data.id,
})
.subscribe({
next: res => {
this.refresh();
},
error: err => {
this.messageHandlerService.handleError(err);
},
});
}
}
}
@ -309,12 +336,12 @@ export class WebhookComponent implements OnInit {
this.addWebhookComponent.isEdit = true;
this.addWebhookComponent.addWebhookFormComponent.isModify = true;
this.addWebhookComponent.addWebhookFormComponent.webhook = clone(
this.selectedRow[0]
this.selectedRow
);
this.addWebhookComponent.addWebhookFormComponent.originValue =
clone(this.selectedRow[0]);
clone(this.selectedRow);
this.addWebhookComponent.addWebhookFormComponent.webhook.event_types =
clone(this.selectedRow[0].event_types);
clone(this.selectedRow.event_types);
}
}
@ -328,6 +355,7 @@ export class WebhookComponent implements OnInit {
this.addWebhookComponent.addWebhookFormComponent.isModify = false;
this.addWebhookComponent.addWebhookFormComponent.currentForm.reset({
notifyType: this.metadata.notify_type[0],
payloadFormat: PAYLOAD_FORMATS[0],
});
this.addWebhookComponent.addWebhookFormComponent.webhook = {
enabled: true,
@ -337,6 +365,7 @@ export class WebhookComponent implements OnInit {
type: 'http',
address: '',
skip_cert_verify: true,
payload_format: PAYLOAD_FORMATS[0],
},
],
};
@ -350,9 +379,7 @@ export class WebhookComponent implements OnInit {
deleteWebhook() {
const names: string[] = [];
this.selectedRow.forEach(item => {
names.push(item.name);
});
names.push(this.selectedRow.name);
let content = '';
this.translate
.get('WEBHOOK.DELETE_WEBHOOK_SUMMARY', { names: names.join(',') })
@ -370,4 +397,16 @@ export class WebhookComponent implements OnInit {
eventTypeToText(eventType: string): string {
return this.projectWebhookService.eventTypeToText(eventType);
}
refreshExecutions(e: WebhookPolicy) {
if (e) {
this.executionsComponent?.refreshExecutions(true, e.id);
}
}
getI18nKey(v: string) {
if (v && PAYLOAD_FORMAT_I18N_MAP[v]) {
return PAYLOAD_FORMAT_I18N_MAP[v];
}
return v;
}
}

View File

@ -2,23 +2,37 @@ import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { SharedModule } from '../../../shared/shared.module';
import { WebhookComponent } from './webhook.component';
import { LastTriggerComponent } from './last-trigger/last-trigger.component';
import { AddWebhookFormComponent } from './add-webhook-form/add-webhook-form.component';
import { AddWebhookComponent } from './add-webhook/add-webhook.component';
import { ProjectWebhookService } from './webhook.service';
import { ExecutionsComponent } from './excutions/executions.component';
import { TasksComponent } from './tasks/tasks.component';
import { RouteConfigId } from '../../../route-reuse-strategy/harbor-route-reuse-strategy';
const routes: Routes = [
{
path: ':policyId/executions/:executionId/tasks',
component: TasksComponent,
data: {
routeConfigId: RouteConfigId.WEBHOOK_TASKS_PAGE,
},
},
{
path: '',
component: WebhookComponent,
data: {
reuse: true,
routeConfigId: RouteConfigId.WEBHOOK_POLICIES_PAGE,
},
},
];
@NgModule({
declarations: [
WebhookComponent,
LastTriggerComponent,
AddWebhookFormComponent,
AddWebhookComponent,
ExecutionsComponent,
TasksComponent,
],
imports: [RouterModule.forChild(routes), SharedModule],
providers: [ProjectWebhookService],

View File

@ -29,6 +29,13 @@ const EVENT_TYPES_TEXT_MAP = {
TAG_RETENTION: 'Tag retention finished',
};
export const PAYLOAD_FORMATS: string[] = ['Default', 'CloudEvents'];
export const PAYLOAD_FORMAT_I18N_MAP = {
[PAYLOAD_FORMATS[0]]: 'SCANNER.DEFAULT',
[PAYLOAD_FORMATS[1]]: 'WEBHOOK.CLOUD_EVENT',
};
@Injectable()
export class ProjectWebhookService {
constructor() {}

View File

@ -17,6 +17,8 @@ export enum RouteConfigId {
REPLICATION_TASKS_PAGE = 'ReplicationTasksComponent',
P2P_POLICIES_PAGE = 'PolicyComponent',
P2P_TASKS_PAGE = 'P2pTaskListComponent',
WEBHOOK_POLICIES_PAGE = 'WebhookComponent',
WEBHOOK_TASKS_PAGE = 'WebhookTasksComponent',
}
// should not reuse the routes that meet these RegExps
const ShouldNotReuseRouteRegExps: RegExp[] = [
@ -75,6 +77,15 @@ export class HarborRouteReuseStrategy implements RouteReuseStrategy {
) {
this.shouldDeleteCache = false;
}
// action 3: from webhook tasks list page to WebhookComponent page
if (
curr.routeConfig.data.routeConfigId ===
RouteConfigId.WEBHOOK_TASKS_PAGE &&
future.routeConfig.data.routeConfigId ===
RouteConfigId.WEBHOOK_POLICIES_PAGE
) {
this.shouldDeleteCache = false;
}
}
}

View File

@ -965,6 +965,8 @@ export enum PageSizeMapKeys {
TAG_RETENTION_COMPONENT = 'TagRetentionComponent',
PROJECT_ROBOT_COMPONENT = 'ProjectRobotAccountComponent',
WEBHOOK_COMPONENT = 'WebhookComponent',
WEBHOOK_EXECUTIONS_COMPONENT = 'Webhook_Execution_Component',
WEBHOOK_TASKS_COMPONENT = 'Webhook_Tasks_Component',
PROJECT_AUDIT_LOG_COMPONENT = 'ProjectAuditLogComponent',
SYSTEM_RECENT_LOG_COMPONENT = 'SystemRecentLogComponent',
SYSTEM_USER_COMPONENT = 'SystemUserComponent',

View File

@ -428,7 +428,10 @@
"NAME_REQUIRED": "Name ist erforderlich",
"NOTIFY_TYPE": "Benachrichtigungstyp",
"EVENT_TYPE": "Event Typ",
"EVENT_TYPE_REQUIRED": "Mindestens ein Event Typ ist erforderlich"
"EVENT_TYPE_REQUIRED": "Mindestens ein Event Typ ist erforderlich",
"PAYLOAD_FORMAT": "Payload Format",
"CLOUD_EVENT": "CloudEvents",
"PAYLOAD_DATA": "Payload Data"
},
"GROUP": {
"GROUP": "Gruppe",

View File

@ -428,7 +428,10 @@
"NAME_REQUIRED": "Name is required",
"NOTIFY_TYPE": "Notify Type",
"EVENT_TYPE": "Event Type",
"EVENT_TYPE_REQUIRED": "Require at least one event type"
"EVENT_TYPE_REQUIRED": "Require at least one event type",
"PAYLOAD_FORMAT": "Payload Format",
"CLOUD_EVENT": "CloudEvents",
"PAYLOAD_DATA": "Payload Data"
},
"GROUP": {
"GROUP": "Group",

View File

@ -429,7 +429,10 @@
"NAME_REQUIRED": "Name is required",
"NOTIFY_TYPE": "Notify Type",
"EVENT_TYPE": "Event Type",
"EVENT_TYPE_REQUIRED": "Require at least one event type"
"EVENT_TYPE_REQUIRED": "Require at least one event type",
"PAYLOAD_FORMAT": "Payload Format",
"CLOUD_EVENT": "CloudEvents",
"PAYLOAD_DATA": "Payload Data"
},
"GROUP": {
"GROUP": "Group",

View File

@ -420,7 +420,10 @@
"NAME_REQUIRED": "Le nom est requis",
"NOTIFY_TYPE": "Type de notification",
"EVENT_TYPE": "Types d'évènement",
"EVENT_TYPE_REQUIRED": "Au moins un type d'évènement est nécessaire"
"EVENT_TYPE_REQUIRED": "Au moins un type d'évènement est nécessaire",
"PAYLOAD_FORMAT": "Payload Format",
"CLOUD_EVENT": "CloudEvents",
"PAYLOAD_DATA": "Payload Data"
},
"GROUP": {
"Group": "Groupe",

View File

@ -460,7 +460,10 @@
"NAME_REQUIRED": "Nome é obrigatório",
"NOTIFY_TYPE": "Tipo de Notificação",
"EVENT_TYPE": "Tipo de Evento",
"EVENT_TYPE_REQUIRED": "Pelo menos um tipo de evento é obrigatório"
"EVENT_TYPE_REQUIRED": "Pelo menos um tipo de evento é obrigatório",
"PAYLOAD_FORMAT": "Payload Format",
"CLOUD_EVENT": "CloudEvents",
"PAYLOAD_DATA": "Payload Data"
},
"AUDIT_LOG": {
"USERNAME": "Nome do usuário",

View File

@ -428,7 +428,10 @@
"NAME_REQUIRED": "Name is required",
"NOTIFY_TYPE": "Notify Type",
"EVENT_TYPE": "Event Type",
"EVENT_TYPE_REQUIRED": "Require at least one event type"
"EVENT_TYPE_REQUIRED": "Require at least one event type",
"PAYLOAD_FORMAT": "Payload Format",
"CLOUD_EVENT": "CloudEvents",
"PAYLOAD_DATA": "Payload Data"
},
"GROUP": {
"GROUP": "Grup",

View File

@ -427,7 +427,10 @@
"NAME_REQUIRED": "名称为必填项",
"NOTIFY_TYPE": "通知类型",
"EVENT_TYPE": "事件类型",
"EVENT_TYPE_REQUIRED": "请至少选择一种事件类型"
"EVENT_TYPE_REQUIRED": "请至少选择一种事件类型",
"PAYLOAD_FORMAT": "载荷形式",
"CLOUD_EVENT": "CloudEvents",
"PAYLOAD_DATA": "载荷数据"
},
"GROUP": {
"GROUP": "组",

View File

@ -425,7 +425,10 @@
"NAME_REQUIRED": "名稱為必填項",
"NOTIFY_TYPE": "通知類型",
"EVENT_TYPE": "事件類型",
"EVENT_TYPE_REQUIRED": "請至少選擇一種事件類型"
"EVENT_TYPE_REQUIRED": "請至少選擇一種事件類型",
"PAYLOAD_FORMAT": "Payload Format",
"CLOUD_EVENT": "CloudEvents",
"PAYLOAD_DATA": "Payload Data"
},
"GROUP":{
"GROUP":"組",