Add clearances ui (#16941)

Add audit log purge and forwarding ui

Signed-off-by: AllForNothing <sshijun@vmware.com>
This commit is contained in:
Shijun Sun 2022-06-07 15:18:36 +08:00 committed by GitHub
parent 7ecd4a3f29
commit bf317d0b26
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
52 changed files with 2178 additions and 642 deletions

View File

@ -116,12 +116,12 @@ const routes: Routes = [
).then(m => m.ProjectQuotasModule),
},
{
path: 'gc',
path: 'clearing-job',
canActivate: [SystemAdminGuard],
loadChildren: () =>
import('./left-side-nav/gc-page/gc.module').then(
m => m.GcModule
),
import(
'./left-side-nav/clearing-job/clearing-job.module'
).then(m => m.ClearingJobModule),
},
{
path: 'configs',

View File

@ -160,15 +160,12 @@
<a
clrVerticalNavLink
*ngIf="hasAdminRole"
routerLink="/harbor/gc"
routerLink="/harbor/clearing-job"
routerLinkActive="active">
<clr-icon
shape="trash"
clrVerticalNavIcon></clr-icon>
{{
'SIDE_NAV.SYSTEM_MGMT.GARBAGE_COLLECTION'
| translate
}}
{{ 'CLEARANCES.CLEARANCES' | translate }}
</a>
<a
clrVerticalNavLink

View File

@ -0,0 +1,64 @@
<h5 class="history-header font-style mt-3" id="history-header">
{{ 'CLEARANCES.PURGE_HISTORY' | translate }}
</h5>
<span class="refresh-btn" (click)="refresh()">
<clr-icon shape="refresh"></clr-icon>
</span>
<clr-datagrid [clrDgLoading]="loading" (clrDgRefresh)="getJobs(true, $event)">
<clr-dg-column [clrDgField]="'id'">{{
'GC.JOB_ID' | translate
}}</clr-dg-column>
<clr-dg-column>{{ 'GC.TRIGGER_TYPE' | translate }}</clr-dg-column>
<clr-dg-column>{{ 'TAG_RETENTION.DRY_RUN' | translate }}</clr-dg-column>
<clr-dg-column>{{ 'STATUS' | translate }}</clr-dg-column>
<clr-dg-column>{{ 'CREATION_TIME' | translate }}</clr-dg-column>
<clr-dg-column [clrDgSortBy]="'update_time'">{{
'UPDATE_TIME' | translate
}}</clr-dg-column>
<clr-dg-column>{{ 'LOGS' | translate }}</clr-dg-column>
<clr-dg-row *ngFor="let job of jobs" [clrDgItem]="job">
<clr-dg-cell>{{ job.id }}</clr-dg-cell>
<clr-dg-cell>{{
(job.schedule?.type
? 'SCHEDULE.' + job.schedule?.type.toUpperCase()
: ''
) | translate
}}</clr-dg-cell>
<clr-dg-cell>{{
isDryRun(job?.job_parameters) | translate
}}</clr-dg-cell>
<clr-dg-cell>{{
job.job_status.toUpperCase() | translate
}}</clr-dg-cell>
<clr-dg-cell>{{
job.creation_time | harborDatetime: 'medium'
}}</clr-dg-cell>
<clr-dg-cell>{{
job.update_time | harborDatetime: 'medium'
}}</clr-dg-cell>
<clr-dg-cell>
<a
rel="noopener noreferrer"
target="_blank"
[href]="getLogLink(job.id)">
<clr-icon shape="list"></clr-icon>
</a>
</clr-dg-cell>
</clr-dg-row>
<clr-dg-footer>
<clr-dg-pagination
#pagination
[clrDgPageSize]="pageSize"
[(clrDgPage)]="page"
[clrDgTotalItems]="total">
<clr-dg-page-size [clrPageSizeOptions]="[5, 25, 50]">{{
'PAGINATION.PAGE_SIZE' | translate
}}</clr-dg-page-size>
<span *ngIf="total"
>{{ pagination.firstItem + 1 }} - {{ pagination.lastItem + 1 }}
{{ 'DESTINATION.OF' | translate }}</span
>
{{ total }} {{ 'DESTINATION.ITEMS' | translate }}
</clr-dg-pagination>
</clr-dg-footer>
</clr-datagrid>

View File

@ -0,0 +1,106 @@
import {
ComponentFixture,
ComponentFixtureAutoDetect,
fakeAsync,
TestBed,
tick,
} from '@angular/core/testing';
import { of } from 'rxjs';
import { SharedTestingModule } from '../../../../../shared/shared.module';
import { HttpHeaders, HttpResponse } from '@angular/common/http';
import { CURRENT_BASE_HREF } from '../../../../../shared/units/utils';
import { delay } from 'rxjs/operators';
import { PurgeHistoryComponent } from './purge-history.component';
import { ExecHistory } from '../../../../../../../ng-swagger-gen/models/exec-history';
import { PurgeService } from 'ng-swagger-gen/services/purge.service';
describe('GcHistoryComponent', () => {
let component: PurgeHistoryComponent;
let fixture: ComponentFixture<PurgeHistoryComponent>;
const mockJobs: ExecHistory[] = [
{
id: 1,
job_name: 'test',
job_kind: 'manual',
schedule: null,
job_status: 'pending',
job_parameters: '{"dry_run":true}',
creation_time: null,
update_time: null,
},
{
id: 2,
job_name: 'test',
job_kind: 'manual',
schedule: null,
job_status: 'finished',
job_parameters: '{"dry_run":true}',
creation_time: null,
update_time: null,
},
];
const fakedPurgeService = {
count: 0,
getPurgeHistoryResponse() {
if (this.count === 0) {
this.count += 1;
const response: HttpResponse<Array<ExecHistory>> =
new HttpResponse<Array<ExecHistory>>({
headers: new HttpHeaders({
'x-total-count': [mockJobs[0]].length.toString(),
}),
body: [mockJobs[0]],
});
return of(response).pipe(delay(0));
} else {
this.count += 1;
const response: HttpResponse<Array<ExecHistory>> =
new HttpResponse<Array<ExecHistory>>({
headers: new HttpHeaders({
'x-total-count': [mockJobs[1]].length.toString(),
}),
body: [mockJobs[1]],
});
return of(response).pipe(delay(0));
}
},
};
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [PurgeHistoryComponent],
imports: [SharedTestingModule],
providers: [
{ provide: PurgeService, useValue: fakedPurgeService },
// open auto detect
{ provide: ComponentFixtureAutoDetect, useValue: true },
],
});
});
beforeEach(() => {
fixture = TestBed.createComponent(PurgeHistoryComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
afterEach(() => {
if (component && component.timerDelay) {
component.timerDelay.unsubscribe();
component.timerDelay = null;
}
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should retry getting jobs', fakeAsync(() => {
tick(10000);
fixture.detectChanges();
fixture.whenStable().then(() => {
expect(component.jobs[0].job_status).toEqual('finished');
});
}));
it('should return right log link', () => {
expect(component.getLogLink('1')).toEqual(
`${CURRENT_BASE_HREF}/system/purgeaudit/1/log`
);
});
});

View File

@ -0,0 +1,137 @@
import { Component, OnDestroy } from '@angular/core';
import { ClrDatagridStateInterface } from '@clr/angular';
import { GCHistory } from 'ng-swagger-gen/models/gchistory';
import { finalize, Subscription, timer } from 'rxjs';
import { REFRESH_TIME_DIFFERENCE } from 'src/app/shared/entities/shared.const';
import { ErrorHandler } from 'src/app/shared/units/error-handler/error-handler';
import {
CURRENT_BASE_HREF,
getPageSizeFromLocalStorage,
getSortingString,
PageSizeMapKeys,
setPageSizeToLocalStorage,
} from 'src/app/shared/units/utils';
import { PurgeService } from '../../../../../../../ng-swagger-gen/services/purge.service';
import { JOB_STATUS, NO, YES } from '../../clearing-job-interfact';
@Component({
selector: 'app-purge-history',
templateUrl: './purge-history.component.html',
styleUrls: ['./purge-history.component.scss'],
})
export class PurgeHistoryComponent implements OnDestroy {
jobs: Array<GCHistory> = [];
loading: boolean = true;
timerDelay: Subscription;
pageSize: number = getPageSizeFromLocalStorage(
PageSizeMapKeys.GC_HISTORY_COMPONENT,
5
);
page: number = 1;
total: number = 0;
state: ClrDatagridStateInterface;
constructor(
private purgeService: PurgeService,
private errorHandler: ErrorHandler
) {}
refresh() {
this.page = 1;
this.total = 0;
this.getJobs(true);
}
getJobs(withLoading: boolean, state?: ClrDatagridStateInterface) {
if (state) {
this.state = state;
}
if (state && state.page) {
this.pageSize = state.page.size;
setPageSizeToLocalStorage(
PageSizeMapKeys.GC_HISTORY_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);
}
if (withLoading) {
this.loading = true;
}
this.purgeService
.getPurgeHistoryResponse({
page: this.page,
pageSize: this.pageSize,
q: q,
sort: sort,
})
.pipe(finalize(() => (this.loading = false)))
.subscribe(
res => {
// Get total count
if (res.headers) {
const xHeader: string =
res.headers.get('X-Total-Count');
if (xHeader) {
this.total = parseInt(xHeader, 0);
}
this.jobs = res.body;
}
// to avoid some jobs not finished.
if (!this.timerDelay) {
this.timerDelay = timer(
REFRESH_TIME_DIFFERENCE,
REFRESH_TIME_DIFFERENCE
).subscribe(() => {
let count: number = 0;
this.jobs.forEach(job => {
if (
job.job_status === JOB_STATUS.PENDING ||
job.job_status === JOB_STATUS.RUNNING
) {
count++;
}
});
if (count > 0) {
this.getJobs(false, this.state);
} else {
this.timerDelay.unsubscribe();
this.timerDelay = null;
}
});
}
},
error => {
this.errorHandler.error(error);
this.loading = false;
}
);
}
isDryRun(param: string): string {
if (param) {
const paramObj: any = JSON.parse(param);
if (paramObj && paramObj.dry_run) {
return YES;
}
}
return NO;
}
ngOnDestroy() {
if (this.timerDelay) {
this.timerDelay.unsubscribe();
this.timerDelay = null;
}
}
getLogLink(id): string {
return `${CURRENT_BASE_HREF}/system/purgeaudit/${id}/log`;
}
}

View File

@ -0,0 +1,211 @@
<div [hidden]="!loadingGcStatus" class="clr-row mt-2 center">
<span class="spinner spinner-md"></span>
</div>
<div [hidden]="loadingGcStatus">
<div class="clr-row mt-1">
<div class="clr-col-2 flex-200 font-style">
{{ 'WEBHOOK.STATUS' | translate }}
</div>
<div class="clr-col">
<div class="clr-row">
<div class="clr-col-4">
<span class="mr-1 font-style">{{
'CLEARANCES.LAST_COMPLETED' | translate
}}</span>
<span class="mr-3">
<span
*ngIf="loadingLastCompletedTime"
class="spinner spinner-inline"></span>
<ng-container *ngIf="!loadingLastCompletedTime">
<span *ngIf="!lastCompletedTime">{{
'SCHEDULE.NONE' | translate
}}</span>
<span *ngIf="lastCompletedTime"
>{{ lastCompletedTime | harborDatetime
}}<span *ngIf="isDryRun"
>({{
'TAG_RETENTION.DRY_RUN' | translate
}})</span
></span
>
</ng-container>
</span>
</div>
<div class="clr-col">
<span class="mr-1 font-style" *ngIf="nextScheduledTime">{{
'CLEARANCES.NEXT_SCHEDULED_TIME' | translate
}}</span>
<span *ngIf="nextScheduledTime">{{
nextScheduledTime | harborDatetime
}}</span>
</div>
</div>
</div>
</div>
<div class="cron-selection">
<cron-selection
[externalValidation]="
!(purgeForm.invalid || !(selectedOperations?.length > 0))
"
[labelCurrent]="getLabelCurrent"
[labelEdit]="getLabelCurrent"
[originCron]="originCron"
(inputvalue)="saveGcSchedule($event)"></cron-selection>
</div>
<form #purgeForm="ngForm" class="clr-form clr-form-horizontal p-0">
<div class="clr-form-control mt-0">
<span class="required font-style flex-200"
>{{ 'CLEARANCES.KEEP_IN' | translate }}
<clr-tooltip>
<clr-icon
clrTooltipTrigger
shape="info-circle"
size="24"></clr-icon>
<clr-tooltip-content
clrPosition="top-right"
clrSize="lg"
*clrIfOpen>
<span>{{
'CLEARANCES.KEEP_IN_TOOLTIP' | translate
}}</span>
</clr-tooltip-content>
</clr-tooltip></span
>
<div
class="clr-control-container input-width"
[class.clr-error]="
(retentionTimeNgModel.dirty ||
retentionTimeNgModel.touched) &&
retentionTimeNgModel.invalid
">
<div class="flex">
<div class="clr-input-wrapper">
<input
[disabled]="dryRunOnGoing"
class="clr-input"
name="retentionTime"
type="text"
#retentionTimeNgModel="ngModel"
autocomplete="off"
[(ngModel)]="retentionTime"
required
pattern="^[\-1-9]{1}[0-9]*$"
id="retentionTime"
size="20" />
<clr-icon
class="clr-validate-icon"
shape="exclamation-circle"></clr-icon>
<clr-control-error
*ngIf="
(retentionTimeNgModel.dirty ||
retentionTimeNgModel.touched) &&
retentionTimeNgModel.invalid
">
{{ 'CLEARANCES.KEEP_IN_ERROR' | translate }}
</clr-control-error>
</div>
<div class="clr-select-wrapper unit-select">
<select
[(ngModel)]="retentionUnit"
[ngModelOptions]="{ standalone: true }"
id="expiration-type"
class="clr-select">
<option value="days">
{{ 'CLEARANCES.DAYS' | translate }}
</option>
<option value="hours">
{{ 'CLEARANCES.HOURS' | translate }}
</option>
</select>
</div>
</div>
</div>
</div>
<div
class="clr-form-control"
[ngClass]="{
'mt-08': !(
(retentionTimeNgModel.dirty ||
retentionTimeNgModel.touched) &&
retentionTimeNgModel.invalid
)
}">
<span class="font-style required flex-200"
>{{ 'CLEARANCES.INCLUDED_OPERATIONS' | translate
}}<clr-tooltip>
<clr-icon
clrTooltipTrigger
shape="info-circle"
size="24"></clr-icon>
<clr-tooltip-content
clrPosition="top-right"
clrSize="lg"
*clrIfOpen>
<span>{{
'CLEARANCES.INCLUDED_OPERATION_TOOLTIP' | translate
}}</span>
</clr-tooltip-content>
</clr-tooltip></span
>
<div
class="clr-control-container clr-control-inline"
[class.clr-error]="!(selectedOperations?.length > 0)">
<div
class="clr-checkbox-wrapper"
*ngFor="let item of operations">
<input
type="checkbox"
id="{{ item }}"
name="operations"
value="{{ item }}"
class="clr-checkbox"
(change)="setOperation(item)"
[checked]="hasOperation(item)" />
<label for="{{ item }}" class="clr-control-label">{{
operationsToText(item) | translate
}}</label>
</div>
<div
class="clr-subtext-wrapper"
*ngIf="!(selectedOperations?.length > 0)">
<clr-icon
class="clr-validate-icon"
shape="exclamation-circle"></clr-icon>
<span class="clr-subtext">{{
'CLEARANCES.INCLUDED_OPERATION_ERROR' | translate
}}</span>
</div>
</div>
</div>
</form>
<div class="clr-row">
<div class="clr-col-2 flex-200">
<button
id="gc-now"
class="btn btn-primary gc-start-btn"
(click)="gcNow()"
[disabled]="
disableGC ||
purgeForm.invalid ||
!(selectedOperations?.length > 0)
">
{{ 'CLEARANCES.PURGE_NOW' | translate }}
</button>
</div>
<div class="clr-col">
<button
id="gc-dry-run"
class="btn btn-outline gc-start-btn"
(click)="dryRun()"
[disabled]="
dryRunOnGoing ||
purgeForm.invalid ||
!(selectedOperations?.length > 0)
">
{{ 'TAG_RETENTION.WHAT_IF_RUN' | translate }}
</button>
</div>
</div>
<app-purge-history></app-purge-history>
</div>

View File

@ -0,0 +1,36 @@
.cron-selection {
display: flex;
align-items: center;
}
.gc-start-btn {
width:150px;
margin-top: 35px;
}
.flex-200 {
flex: 0 0 200px;
max-width: 200px;
}
.mt-05 {
margin-top: 0.5rem;
}
.font-weight-400 {
font-weight: 400;
}
.center {
justify-content: center;
align-items: center;
}
.flex {
display: flex;
}
.mt-08 {
margin-top: 0.8rem;
}
.unit-select {
padding-top: 1px;
margin-left: 2rem;
}

View File

@ -0,0 +1,72 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ErrorHandler } from '../../../../../shared/units/error-handler';
import { CronScheduleComponent } from '../../../../../shared/components/cron-schedule';
import { CronTooltipComponent } from '../../../../../shared/components/cron-schedule';
import { of } from 'rxjs';
import { SharedTestingModule } from '../../../../../shared/shared.module';
import { SetJobComponent } from './set-job.component';
import { PurgeService } from 'ng-swagger-gen/services/purge.service';
import { NO_ERRORS_SCHEMA } from '@angular/core';
describe('GcComponent', () => {
let component: SetJobComponent;
let fixture: ComponentFixture<SetJobComponent>;
let purgeService: PurgeService;
let mockSchedule = [];
const fakedErrorHandler = {
error(error) {
return error;
},
info(info) {
return info;
},
};
let spySchedule: jasmine.Spy;
let spyGcNow: jasmine.Spy;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [SharedTestingModule],
declarations: [
SetJobComponent,
CronScheduleComponent,
CronTooltipComponent,
],
providers: [{ provide: ErrorHandler, useValue: fakedErrorHandler }],
schemas: [NO_ERRORS_SCHEMA],
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(SetJobComponent);
component = fixture.componentInstance;
purgeService = fixture.debugElement.injector.get(PurgeService);
spySchedule = spyOn(purgeService, 'getPurgeSchedule').and.returnValues(
of(mockSchedule as any)
);
spyGcNow = spyOn(purgeService, 'createPurgeSchedule').and.returnValues(
of(null)
);
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should get schedule and job', () => {
expect(spySchedule.calls.count()).toEqual(1);
});
it('should trigger gcNow', () => {
const ele: HTMLButtonElement =
fixture.nativeElement.querySelector('#gc-now');
ele.click();
fixture.detectChanges();
expect(spyGcNow.calls.count()).toEqual(1);
});
it('should trigger dry run', () => {
const ele: HTMLButtonElement =
fixture.nativeElement.querySelector('#gc-dry-run');
ele.click();
fixture.detectChanges();
expect(spyGcNow.calls.count()).toEqual(1);
});
});

View File

@ -0,0 +1,307 @@
import { Component, ViewChild, OnInit, OnDestroy } from '@angular/core';
import { ErrorHandler } from '../../../../../shared/units/error-handler';
import { CronScheduleComponent } from '../../../../../shared/components/cron-schedule';
import { OriginCron } from '../../../../../shared/services';
import { finalize } from 'rxjs/operators';
import { ScheduleType } from '../../../../../shared/entities/shared.const';
import { GcComponent } from '../../gc-page/gc/gc.component';
import { PurgeService } from '../../../../../../../ng-swagger-gen/services/purge.service';
import { ExecHistory } from '../../../../../../../ng-swagger-gen/models/exec-history';
import {
JOB_STATUS,
REFRESH_STATUS_TIME_DIFFERENCE,
RETENTION_OPERATIONS,
RETENTION_OPERATIONS_I18N_MAP,
RetentionTimeUnit,
} from '../../clearing-job-interfact';
import { clone } from '../../../../../shared/units/utils';
import { PurgeHistoryComponent } from '../history/purge-history.component';
const ONE_MINUTE: number = 60000;
const ONE_DAY: number = 24;
@Component({
selector: 'app-set-job',
templateUrl: './set-job.component.html',
styleUrls: ['./set-job.component.scss'],
})
export class SetJobComponent implements OnInit, OnDestroy {
originCron: OriginCron;
disableGC: boolean = false;
getLabelCurrent = 'CLEARANCES.SCHEDULE_TO_PURGE';
loadingGcStatus = false;
@ViewChild(CronScheduleComponent)
CronScheduleComponent: CronScheduleComponent;
dryRunOnGoing: boolean = false;
lastCompletedTime: string;
loadingLastCompletedTime: boolean = false;
isDryRun: boolean = false;
nextScheduledTime: string;
statusTimeout: any;
retentionTime: number;
retentionUnit: string = RetentionTimeUnit.DAYS;
operations: string[] = clone(RETENTION_OPERATIONS);
selectedOperations: string[] = clone(RETENTION_OPERATIONS);
@ViewChild(PurgeHistoryComponent)
purgeHistoryComponent: PurgeHistoryComponent;
constructor(
private purgeService: PurgeService,
private errorHandler: ErrorHandler
) {}
ngOnInit() {
this.getCurrentSchedule(true);
this.getStatus();
}
ngOnDestroy() {
if (this.statusTimeout) {
clearTimeout(this.statusTimeout);
this.statusTimeout = null;
}
}
// get the latest non-dry-run execution to get the status
getStatus() {
this.loadingLastCompletedTime = true;
this.purgeService
.getPurgeHistory({
page: 1,
pageSize: 1,
sort: '-update_time',
})
.subscribe(res => {
if (res?.length) {
this.isDryRun = JSON.parse(res[0]?.job_parameters).dry_run;
this.lastCompletedTime = res[0]?.update_time;
if (
res[0]?.job_status === JOB_STATUS.RUNNING ||
res[0]?.job_status === JOB_STATUS.PENDING
) {
this.statusTimeout = setTimeout(() => {
this.getStatus();
}, REFRESH_STATUS_TIME_DIFFERENCE);
} else {
this.loadingLastCompletedTime = false;
}
}
});
}
getCurrentSchedule(withLoading: boolean) {
if (withLoading) {
this.loadingGcStatus = true;
}
this.purgeService
.getPurgeSchedule()
.pipe(
finalize(() => {
this.loadingGcStatus = false;
})
)
.subscribe({
next: schedule => {
this.initSchedule(schedule);
},
error: error => {
this.errorHandler.error(error);
},
});
}
initSchedule(purgeHistory: ExecHistory) {
if ((purgeHistory?.schedule as any)?.next_scheduled_time) {
this.nextScheduledTime = (
purgeHistory.schedule as any
)?.next_scheduled_time;
}
if (purgeHistory && purgeHistory.schedule) {
this.originCron = {
type: purgeHistory.schedule.type,
cron: purgeHistory.schedule.cron,
};
if (purgeHistory && purgeHistory.job_parameters) {
const obj = JSON.parse(purgeHistory.job_parameters);
if (obj?.include_operations) {
this.selectedOperations =
obj?.include_operations?.split(',');
} else {
this.selectedOperations = [];
}
if (
obj?.audit_retention_hour > ONE_DAY &&
obj?.audit_retention_hour % ONE_DAY === 0
) {
this.retentionTime = obj?.audit_retention_hour / ONE_DAY;
this.retentionUnit = RetentionTimeUnit.DAYS;
} else {
this.retentionTime = obj?.audit_retention_hour;
this.retentionUnit = RetentionTimeUnit.HOURS;
}
} else {
this.retentionTime = null;
this.selectedOperations = clone(RETENTION_OPERATIONS);
this.retentionUnit = RetentionTimeUnit.DAYS;
}
} else {
this.originCron = {
type: ScheduleType.NONE,
cron: '',
};
}
}
gcNow(): void {
this.disableGC = true;
setTimeout(() => {
this.enableGc();
}, ONE_MINUTE);
const retentionTime: number =
this.retentionUnit === RetentionTimeUnit.DAYS
? this.retentionTime * 24
: this.retentionTime;
this.purgeService
.createPurgeSchedule({
schedule: {
parameters: {
audit_retention_hour: +retentionTime,
include_operations: this.selectedOperations.join(','),
dry_run: false,
},
schedule: {
type: ScheduleType.MANUAL,
},
},
})
.subscribe({
next: response => {
this.errorHandler.info('CLEARANCES.PURGE_NOW_SUCCESS');
this.refresh();
},
error: error => {
this.errorHandler.error(error);
},
});
}
dryRun() {
this.dryRunOnGoing = true;
const retentionTime: number =
this.retentionUnit === RetentionTimeUnit.DAYS
? this.retentionTime * 24
: this.retentionTime;
this.purgeService
.createPurgeSchedule({
schedule: {
parameters: {
audit_retention_hour: +retentionTime,
include_operations: this.selectedOperations.join(','),
dry_run: true,
},
schedule: {
type: ScheduleType.MANUAL,
},
},
})
.pipe(finalize(() => (this.dryRunOnGoing = false)))
.subscribe({
next: response => {
this.errorHandler.info('GC.DRY_RUN_SUCCESS');
this.refresh();
},
error: error => {
this.errorHandler.error(error);
},
});
}
private enableGc() {
this.disableGC = false;
}
saveGcSchedule(cron: string) {
const retentionTime: number =
this.retentionUnit === RetentionTimeUnit.DAYS
? this.retentionTime * 24
: this.retentionTime;
if (this.originCron && this.originCron.type === ScheduleType.NONE) {
// no schedule, then create
this.purgeService
.createPurgeSchedule({
schedule: {
parameters: {
audit_retention_hour: +retentionTime,
include_operations:
this.selectedOperations.join(','),
dry_run: false,
},
schedule: {
type: GcComponent.getScheduleType(cron),
cron: cron,
},
},
})
.subscribe({
next: response => {
this.errorHandler.info(
'CLEARANCES.PURGE_SCHEDULE_RESET'
);
this.CronScheduleComponent.resetSchedule();
this.getCurrentSchedule(false); // refresh schedule
},
error: error => {
this.errorHandler.error(error);
},
});
} else {
this.purgeService
.updatePurgeSchedule({
schedule: {
parameters: {
audit_retention_hour: +retentionTime,
include_operations:
this.selectedOperations.join(','),
dry_run: false,
},
schedule: {
type: GcComponent.getScheduleType(cron),
cron: cron,
},
},
})
.subscribe({
next: response => {
this.errorHandler.info(
'CLEARANCES.PURGE_SCHEDULE_RESET'
);
this.CronScheduleComponent.resetSchedule();
this.getCurrentSchedule(false); // refresh schedule
},
error: error => {
this.errorHandler.error(error);
},
});
}
}
hasOperation(operation: string): boolean {
return this.selectedOperations?.indexOf(operation) !== -1;
}
operationsToText(operation: string): string {
if (RETENTION_OPERATIONS_I18N_MAP[operation]) {
return RETENTION_OPERATIONS_I18N_MAP[operation];
}
return operation;
}
setOperation(operation: string) {
if (this.selectedOperations.indexOf(operation) === -1) {
this.selectedOperations.push(operation);
} else {
this.selectedOperations.splice(
this.selectedOperations.findIndex(item => item === operation),
1
);
}
}
refresh() {
this.getStatus();
this.purgeHistoryComponent?.refresh();
}
}

View File

@ -0,0 +1,22 @@
export enum RetentionTimeUnit {
HOURS = 'hours',
DAYS = 'days',
}
export const RETENTION_OPERATIONS = ['create', 'delete', 'pull'];
export const RETENTION_OPERATIONS_I18N_MAP = {
pull: 'AUDIT_LOG.PULL',
create: 'AUDIT_LOG.CREATE',
delete: 'AUDIT_LOG.DELETE',
};
export const JOB_STATUS = {
PENDING: 'Pending',
RUNNING: 'Running',
};
export const YES: string = 'TAG_RETENTION.YES';
export const NO: string = 'TAG_RETENTION.NO';
export const REFRESH_STATUS_TIME_DIFFERENCE: number = 5000;

View File

@ -0,0 +1,21 @@
<h2 class="custom-h2" sub-header-title>
{{ 'CLEARANCES.CLEARANCES' | translate }}
</h2>
<nav class="mt-1">
<ul class="nav">
<li class="nav-item">
<a class="nav-link" routerLink="gc" routerLinkActive="active">{{
'CONFIG.GC' | translate
}}</a>
</li>
<li class="nav-item">
<a
class="nav-link"
routerLink="audit-log-purge"
routerLinkActive="active"
>{{ 'CLEARANCES.AUDIT_LOG' | translate }}</a
>
</li>
</ul>
</nav>
<router-outlet></router-outlet>

View File

@ -1,30 +1,21 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { SessionService } from '../../../shared/services/session.service';
import { GcPageComponent } from './gc-page.component';
import { ClearingJobComponent } from './clearing-job.component';
import { SharedTestingModule } from '../../../shared/shared.module';
describe('GcPageComponent', () => {
let component: GcPageComponent;
let fixture: ComponentFixture<GcPageComponent>;
let fakeSessionService = {
getCurrentUser: function () {
return { has_admin_role: true };
},
};
let component: ClearingJobComponent;
let fixture: ComponentFixture<ClearingJobComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [GcPageComponent],
declarations: [ClearingJobComponent],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
imports: [SharedTestingModule],
providers: [
{ provide: SessionService, useValue: fakeSessionService },
],
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(GcPageComponent);
fixture = TestBed.createComponent(ClearingJobComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});

View File

@ -0,0 +1,11 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-clearing-job',
templateUrl: './clearing-job.component.html',
styleUrls: ['./clearing-job.component.scss'],
})
export class ClearingJobComponent {
inProgress: boolean = true;
constructor() {}
}

View File

@ -0,0 +1,41 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { GcComponent } from './gc-page/gc/gc.component';
import { GcHistoryComponent } from './gc-page/gc/gc-history/gc-history.component';
import { SharedModule } from '../../../shared/shared.module';
import { SetJobComponent } from './audit-log-purge/set-job/set-job.component';
import { ClearingJobComponent } from './clearing-job.component';
import { PurgeHistoryComponent } from './audit-log-purge/history/purge-history.component';
const routes: Routes = [
{
path: '',
component: ClearingJobComponent,
children: [
{
path: 'gc',
component: GcComponent,
},
{
path: 'audit-log-purge',
component: SetJobComponent,
},
{
path: '',
redirectTo: 'gc',
pathMatch: 'full',
},
],
},
];
@NgModule({
imports: [SharedModule, RouterModule.forChild(routes)],
declarations: [
GcComponent,
GcHistoryComponent,
ClearingJobComponent,
SetJobComponent,
PurgeHistoryComponent,
],
})
export class ClearingJobModule {}

View File

@ -1,4 +1,4 @@
<h5 class="history-header" id="history-header">
<h5 class="history-header mt-3 font-style" id="history-header">
{{ 'GC.JOB_HISTORY' | translate }}
</h5>
<span class="refresh-btn" (click)="refresh()">
@ -51,7 +51,7 @@
[clrDgPageSize]="pageSize"
[(clrDgPage)]="page"
[clrDgTotalItems]="total">
<clr-dg-page-size [clrPageSizeOptions]="[15, 25, 50]">{{
<clr-dg-page-size [clrPageSizeOptions]="[5, 25, 50]">{{
'PAGINATION.PAGE_SIZE' | translate
}}</clr-dg-page-size>
<span *ngIf="total"

View File

@ -0,0 +1,13 @@
.history-header {
margin:20px 0 6px 0;
display: inline-block;
width: 97%;
}
.refresh-btn {
cursor: pointer;
&:hover {
color: #007CBB;
}
}

View File

@ -7,12 +7,12 @@ import {
} from '@angular/core/testing';
import { of } from 'rxjs';
import { GcHistoryComponent } from './gc-history.component';
import { SharedTestingModule } from '../../../../../shared/shared.module';
import { GCHistory } from '../../../../../../../ng-swagger-gen/models/gchistory';
import { SharedTestingModule } from '../../../../../../shared/shared.module';
import { GCHistory } from '../../../../../../../../ng-swagger-gen/models/gchistory';
import { HttpHeaders, HttpResponse } from '@angular/common/http';
import { Registry } from '../../../../../../../ng-swagger-gen/models/registry';
import { GcService } from '../../../../../../../ng-swagger-gen/services/gc.service';
import { CURRENT_BASE_HREF } from '../../../../../shared/units/utils';
import { Registry } from '../../../../../../../../ng-swagger-gen/models/registry';
import { GcService } from '../../../../../../../../ng-swagger-gen/services/gc.service';
import { CURRENT_BASE_HREF } from '../../../../../../shared/units/utils';
import { delay } from 'rxjs/operators';
describe('GcHistoryComponent', () => {

View File

@ -1,25 +1,19 @@
import { Component, OnDestroy } from '@angular/core';
import { ErrorHandler } from '../../../../../shared/units/error-handler';
import { ErrorHandler } from '../../../../../../shared/units/error-handler';
import { Subscription, timer } from 'rxjs';
import { REFRESH_TIME_DIFFERENCE } from '../../../../../shared/entities/shared.const';
import { GcService } from '../../../../../../../ng-swagger-gen/services/gc.service';
import { REFRESH_TIME_DIFFERENCE } from '../../../../../../shared/entities/shared.const';
import { GcService } from '../../../../../../../../ng-swagger-gen/services/gc.service';
import {
CURRENT_BASE_HREF,
getPageSizeFromLocalStorage,
getSortingString,
PageSizeMapKeys,
setPageSizeToLocalStorage,
} from '../../../../../shared/units/utils';
} from '../../../../../../shared/units/utils';
import { ClrDatagridStateInterface } from '@clr/angular';
import { finalize } from 'rxjs/operators';
import { GCHistory } from '../../../../../../../ng-swagger-gen/models/gchistory';
const JOB_STATUS = {
PENDING: 'Pending',
RUNNING: 'Running',
};
const YES: string = 'TAG_RETENTION.YES';
const NO: string = 'TAG_RETENTION.NO';
import { GCHistory } from '../../../../../../../../ng-swagger-gen/models/gchistory';
import { JOB_STATUS, NO, YES } from '../../../clearing-job-interfact';
@Component({
selector: 'gc-history',
@ -31,7 +25,8 @@ export class GcHistoryComponent implements OnDestroy {
loading: boolean = true;
timerDelay: Subscription;
pageSize: number = getPageSizeFromLocalStorage(
PageSizeMapKeys.GC_HISTORY_COMPONENT
PageSizeMapKeys.GC_HISTORY_COMPONENT,
5
);
page: number = 1;
total: number = 0;

View File

@ -0,0 +1,97 @@
<div [hidden]="!loadingGcStatus" class="clr-row mt-2 center">
<span class="spinner spinner-md"></span>
</div>
<div [hidden]="loadingGcStatus">
<div class="clr-row mt-1">
<div class="clr-col-2 flex-200 font-style">
{{ 'WEBHOOK.STATUS' | translate }}
</div>
<div class="clr-col">
<div class="clr-row">
<div class="clr-col-4">
<span class="mr-1 font-style">{{
'CLEARANCES.LAST_COMPLETED' | translate
}}</span>
<span class="mr-3">
<span
*ngIf="loadingLastCompletedTime"
class="spinner spinner-inline"></span>
<ng-container *ngIf="!loadingLastCompletedTime">
<span *ngIf="!lastCompletedTime">{{
'SCHEDULE.NONE' | translate
}}</span>
<span *ngIf="lastCompletedTime"
>{{ lastCompletedTime | harborDatetime
}}<span *ngIf="isDryRun"
>({{
'TAG_RETENTION.DRY_RUN' | translate
}})</span
></span
>
</ng-container>
</span>
</div>
<div class="clr-col">
<span class="mr-1 font-style" *ngIf="nextScheduledTime">{{
'CLEARANCES.NEXT_SCHEDULED_TIME' | translate
}}</span>
<span *ngIf="nextScheduledTime">{{
nextScheduledTime | harborDatetime
}}</span>
</div>
</div>
</div>
</div>
<div class="cron-selection">
<cron-selection
[labelCurrent]="getLabelCurrent"
[labelEdit]="getLabelCurrent"
[originCron]="originCron"
(inputvalue)="saveGcSchedule($event)"></cron-selection>
</div>
<div class="clr-row">
<div class="clr-col-2 flex-200"></div>
<div class="clr-col">
<span class="explain">{{ 'GC.EXPLAIN' | translate }}</span>
</div>
</div>
<div class="clr-row">
<div class="clr-col-2 flex-200"></div>
<div class="clr-col">
<clr-toggle-container class="mt-05">
<clr-toggle-wrapper>
<input
type="checkbox"
clrToggle
name="delete_untagged"
id="delete_untagged"
[(ngModel)]="shouldDeleteUntagged" />
<label class="font-weight-400" for="delete_untagged">{{
'GC.DELETE_UNTAGGED' | translate
}}</label>
</clr-toggle-wrapper>
</clr-toggle-container>
</div>
</div>
<div class="clr-row">
<div class="clr-col-2 flex-200">
<button
id="gc-now"
class="btn btn-primary gc-start-btn"
(click)="gcNow()"
[disabled]="disableGC">
{{ 'GC.GC_NOW' | translate }}
</button>
</div>
<div class="clr-col">
<button
id="gc-dry-run"
class="btn btn-outline gc-start-btn"
(click)="dryRun()"
[disabled]="dryRunOnGoing">
{{ 'TAG_RETENTION.WHAT_IF_RUN' | translate }}
</button>
</div>
</div>
<gc-history></gc-history>
</div>

View File

@ -1,5 +1,4 @@
.cron-selection {
margin-top: 1rem;
display: flex;
align-items: center;
}
@ -26,3 +25,7 @@
font-weight: 100;
font-size: 10px;
}
.center {
justify-content: center;
align-items: center;
}

View File

@ -1,12 +1,13 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { GcComponent } from './gc.component';
import { ErrorHandler } from '../../../../shared/units/error-handler';
import { CronScheduleComponent } from '../../../../shared/components/cron-schedule';
import { CronTooltipComponent } from '../../../../shared/components/cron-schedule';
import { ErrorHandler } from '../../../../../shared/units/error-handler';
import { CronScheduleComponent } from '../../../../../shared/components/cron-schedule';
import { CronTooltipComponent } from '../../../../../shared/components/cron-schedule';
import { of } from 'rxjs';
import { SharedTestingModule } from '../../../../shared/shared.module';
import { GcService } from '../../../../../../ng-swagger-gen/services/gc.service';
import { ScheduleType } from '../../../../shared/entities/shared.const';
import { SharedTestingModule } from '../../../../../shared/shared.module';
import { GcService } from '../../../../../../../ng-swagger-gen/services/gc.service';
import { ScheduleType } from '../../../../../shared/entities/shared.const';
import { NO_ERRORS_SCHEMA } from '@angular/core';
describe('GcComponent', () => {
let component: GcComponent;
@ -23,6 +24,7 @@ describe('GcComponent', () => {
};
let spySchedule: jasmine.Spy;
let spyGcNow: jasmine.Spy;
let spyStatus: jasmine.Spy;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [SharedTestingModule],
@ -32,6 +34,7 @@ describe('GcComponent', () => {
CronTooltipComponent,
],
providers: [{ provide: ErrorHandler, useValue: fakedErrorHandler }],
schemas: [NO_ERRORS_SCHEMA],
}).compileComponents();
});
@ -46,6 +49,20 @@ describe('GcComponent', () => {
spyGcNow = spyOn(gcRepoService, 'createGCSchedule').and.returnValues(
of(null)
);
spyStatus = spyOn(gcRepoService, 'getGCHistory').and.returnValues(
of([
{
id: 1,
job_name: 'test',
job_kind: 'manual',
schedule: null,
job_status: 'finished',
job_parameters: '{"dry_run":true}',
creation_time: null,
update_time: null,
},
])
);
fixture.detectChanges();
});
it('should create', () => {

View File

@ -1,17 +1,16 @@
import {
Component,
Output,
EventEmitter,
ViewChild,
OnInit,
} from '@angular/core';
import { ErrorHandler } from '../../../../shared/units/error-handler';
import { CronScheduleComponent } from '../../../../shared/components/cron-schedule';
import { OriginCron } from '../../../../shared/services';
import { Component, ViewChild, OnInit, OnDestroy } from '@angular/core';
import { ErrorHandler } from '../../../../../shared/units/error-handler';
import { CronScheduleComponent } from '../../../../../shared/components/cron-schedule';
import { OriginCron } from '../../../../../shared/services';
import { finalize } from 'rxjs/operators';
import { GcService } from '../../../../../../ng-swagger-gen/services/gc.service';
import { GCHistory } from '../../../../../../ng-swagger-gen/models/gchistory';
import { ScheduleType } from '../../../../shared/entities/shared.const';
import { GcService } from '../../../../../../../ng-swagger-gen/services/gc.service';
import { GCHistory } from '../../../../../../../ng-swagger-gen/models/gchistory';
import { ScheduleType } from '../../../../../shared/entities/shared.const';
import { GcHistoryComponent } from './gc-history/gc-history.component';
import {
JOB_STATUS,
REFRESH_STATUS_TIME_DIFFERENCE,
} from '../../clearing-job-interfact';
const ONE_MINUTE = 60000;
@ -20,16 +19,22 @@ const ONE_MINUTE = 60000;
templateUrl: './gc.component.html',
styleUrls: ['./gc.component.scss'],
})
export class GcComponent implements OnInit {
export class GcComponent implements OnInit, OnDestroy {
originCron: OriginCron;
disableGC: boolean = false;
getLabelCurrent = 'GC.CURRENT_SCHEDULE';
@Output() loadingGcStatus = new EventEmitter<boolean>();
loadingGcStatus = false;
@ViewChild(CronScheduleComponent)
CronScheduleComponent: CronScheduleComponent;
shouldDeleteUntagged: boolean;
dryRunOnGoing: boolean = false;
lastCompletedTime: string;
loadingLastCompletedTime: boolean = false;
isDryRun: boolean = false;
nextScheduledTime: string;
statusTimeout: any;
@ViewChild(GcHistoryComponent) gcHistoryComponent: GcHistoryComponent;
constructor(
private gcService: GcService,
private errorHandler: ErrorHandler
@ -37,15 +42,47 @@ export class GcComponent implements OnInit {
ngOnInit() {
this.getCurrentSchedule();
this.getStatus();
}
ngOnDestroy() {
if (this.statusTimeout) {
clearTimeout(this.statusTimeout);
this.statusTimeout = null;
}
}
// get the latest non-dry-run execution to get the status
getStatus() {
this.loadingLastCompletedTime = true;
this.gcService
.getGCHistory({
page: 1,
pageSize: 1,
sort: '-update_time',
})
.subscribe(res => {
if (res?.length) {
this.isDryRun = JSON.parse(res[0]?.job_parameters).dry_run;
this.lastCompletedTime = res[0]?.update_time;
if (
res[0]?.job_status === JOB_STATUS.RUNNING ||
res[0]?.job_status === JOB_STATUS.PENDING
) {
this.statusTimeout = setTimeout(() => {
this.getStatus();
}, REFRESH_STATUS_TIME_DIFFERENCE);
} else {
this.loadingLastCompletedTime = false;
}
}
});
}
getCurrentSchedule() {
this.loadingGcStatus.emit(true);
this.loadingGcStatus = true;
this.gcService
.getGCSchedule()
.pipe(
finalize(() => {
this.loadingGcStatus.emit(false);
this.loadingGcStatus = false;
})
)
.subscribe(
@ -59,6 +96,11 @@ export class GcComponent implements OnInit {
}
private initSchedule(gcHistory: GCHistory) {
if ((gcHistory?.schedule as any)?.next_scheduled_time) {
this.nextScheduledTime = (
gcHistory.schedule as any
)?.next_scheduled_time;
}
if (gcHistory && gcHistory.schedule) {
this.originCron = {
type: gcHistory.schedule.type,
@ -97,14 +139,15 @@ export class GcComponent implements OnInit {
},
},
})
.subscribe(
response => {
.subscribe({
next: response => {
this.errorHandler.info('GC.MSG_SUCCESS');
this.refresh();
},
error => {
error: error => {
this.errorHandler.error(error);
}
);
},
});
}
dryRun() {
@ -122,14 +165,15 @@ export class GcComponent implements OnInit {
},
})
.pipe(finalize(() => (this.dryRunOnGoing = false)))
.subscribe(
response => {
.subscribe({
next: response => {
this.errorHandler.info('GC.DRY_RUN_SUCCESS');
this.refresh();
},
error => {
error: error => {
this.errorHandler.error(error);
}
);
},
});
}
private enableGc() {
@ -137,7 +181,7 @@ export class GcComponent implements OnInit {
}
saveGcSchedule(cron: string) {
if (this.originCron && this.originCron.type !== ScheduleType.NONE) {
if (this.originCron && this.originCron.type === ScheduleType.NONE) {
// no schedule, then create
this.gcService
.createGCSchedule({
@ -206,4 +250,8 @@ export class GcComponent implements OnInit {
}
return ScheduleType.NONE;
}
refresh() {
this.getStatus();
this.gcHistoryComponent?.refresh();
}
}

View File

@ -27,6 +27,16 @@
{{ 'CONFIG.EMAIL' | translate }}
</button>
</li>
<li role="presentation" class="nav-item">
<button
id="config-security"
class="btn btn-link nav-link"
type="button"
routerLink="security"
routerLinkActive="active">
{{ 'HELM_CHART.SECURITY' | translate }}
</button>
</li>
<li role="presentation" class="nav-item">
<button
id="config-system"

View File

@ -20,6 +20,7 @@ import { ConfigurationEmailComponent } from './email/config-email.component';
import { SystemSettingsComponent } from './system/system-settings.component';
import { RouterModule, Routes } from '@angular/router';
import { ConfigService } from './config.service';
import { SecurityComponent } from './security/security.component';
const routes: Routes = [
{
@ -34,6 +35,10 @@ const routes: Routes = [
path: 'email',
component: ConfigurationEmailComponent,
},
{
path: 'security',
component: SecurityComponent,
},
{
path: 'setting',
component: SystemSettingsComponent,
@ -53,6 +58,7 @@ const routes: Routes = [
ConfigurationAuthComponent,
ConfigurationEmailComponent,
SystemSettingsComponent,
SecurityComponent,
],
providers: [ConfigService],
})

View File

@ -10,7 +10,7 @@ import { ConfigureService } from 'ng-swagger-gen/services/configure.service';
import { clone } from '../../../shared/units/utils';
import { MessageHandlerService } from '../../../shared/services/message-handler.service';
import { finalize } from 'rxjs/operators';
import { Subscription } from 'rxjs';
import { Observable, Subscription } from 'rxjs';
const fakePass = 'aWpLOSYkIzJTTU4wMDkx';
@ -116,4 +116,10 @@ export class ConfigService {
);
this.confirmService.openComfirmDialog(msg);
}
saveConfiguration(changes: any): Observable<any> {
return this.configureService.updateConfigurations({
configurations: changes,
});
}
}

View File

@ -108,6 +108,8 @@ export class Configuration {
cfg_expiration: NumberValueItem;
oidc_groups_claim: StringValueItem;
oidc_admin_group: StringValueItem;
audit_log_forward_endpoint: StringValueItem;
skip_audit_log_database: BoolValueItem;
public constructor() {
this.auth_mode = new StringValueItem('db_auth', true);
this.project_creation_restriction = new StringValueItem(
@ -178,6 +180,8 @@ export class Configuration {
this.oidc_user_claim = new StringValueItem('', true);
this.count_per_project = new NumberValueItem(-1, true);
this.storage_per_project = new NumberValueItem(-1, true);
this.audit_log_forward_endpoint = new StringValueItem('', true);
this.skip_audit_log_database = new BoolValueItem(false, true);
}
}

View File

@ -0,0 +1,156 @@
<form class="clr-form clr-form-horizontal">
<section>
<div class="clr-form-control d-f">
<label class="clr-control-label">{{
'CVE_ALLOWLIST.DEPLOYMENT_SECURITY' | translate
}}</label>
<div class="form-content w-100">
<div class="font-size-13">
<div class="mt-05">
<span class="title font-size-13">{{
'CVE_ALLOWLIST.CVE_ALLOWLIST' | translate
}}</span>
</div>
<div class="mt-05">
<span>{{
'CVE_ALLOWLIST.SYS_ALLOWLIST_EXPLAIN' | translate
}}</span>
</div>
<div class="mt-05">
<span>{{ 'CVE_ALLOWLIST.ADD_SYS' | translate }}</span>
</div>
<div class="mt-05" *ngIf="hasExpired">
<span class="label label-warning">{{
'CVE_ALLOWLIST.WARNING_SYS' | translate
}}</span>
</div>
</div>
<div class="clr-row width-90per">
<div class="position-relative pl-05">
<div>
<button
id="show-add-modal-button"
(click)="showAddModal = !showAddModal"
class="btn btn-link">
{{ 'CVE_ALLOWLIST.ADD' | translate }}
</button>
</div>
<div
class="add-modal add-modal-dark"
*ngIf="showAddModal">
<clr-icon
(click)="showAddModal = false"
class="float-lg-right margin-top-4"
shape="window-close"></clr-icon>
<div>
<clr-textarea-container
class="flex-direction-column">
<label>{{
'CVE_ALLOWLIST.ENTER' | translate
}}</label>
<textarea
id="allowlist-textarea"
class="w-100 font-italic"
clrTextarea
[(ngModel)]="cveIds"
name="cveIds"></textarea>
<clr-control-helper>{{
'CVE_ALLOWLIST.HELP' | translate
}}</clr-control-helper>
</clr-textarea-container>
</div>
<div>
<button
id="add-to-system"
[disabled]="isDisabled()"
(click)="addToSystemAllowlist()"
class="btn btn-link">
{{ 'CVE_ALLOWLIST.ADD' | translate }}
</button>
</div>
</div>
<ul class="allowlist-window">
<li
*ngIf="systemAllowlist?.items?.length < 1"
class="none">
{{ 'CVE_ALLOWLIST.NONE' | translate }}
</li>
<li
*ngFor="
let item of systemAllowlist?.items;
let i = index
">
<a
href="javascript:void(0)"
(click)="goToDetail(item.cve_id)"
>{{ item.cve_id }}</a
>
<a
class="float-lg-right"
href="javascript:void(0)"
(click)="deleteItem(i)">
<clr-icon shape="times-circle"></clr-icon>
</a>
</li>
</ul>
</div>
<div class="clr-col padding-top-8 ml-1">
<div class="clr-row expire-data">
<label class="bottom-line clr-col-2">{{
'CVE_ALLOWLIST.EXPIRES_AT' | translate
}}</label>
<div>
<input
#dateInput
placeholder="{{
'CVE_ALLOWLIST.NEVER_EXPIRES'
| translate
}}"
readonly
type="date"
[(clrDate)]="expiresDate"
newFormLayout="true" />
</div>
</div>
<div class="clr-row">
<label class="clr-col-2"></label>
<clr-checkbox-wrapper>
<input
[checked]="neverExpires"
[(ngModel)]="neverExpires"
type="checkbox"
clrCheckbox
name="neverExpires"
id="neverExpires" />
<label>
{{
'CVE_ALLOWLIST.NEVER_EXPIRES'
| translate
}}
</label>
</clr-checkbox-wrapper>
</div>
</div>
</div>
</div>
</div>
</section>
</form>
<div>
<button
type="button"
id="security_save"
class="btn btn-primary"
(click)="save()"
[disabled]="!hasAllowlistChanged || inProgress">
{{ 'BUTTON.SAVE' | translate }}
</button>
<button
type="button"
id="security_cancel"
class="btn btn-outline"
(click)="cancel()"
[disabled]="!hasAllowlistChanged || inProgress">
{{ 'BUTTON.CANCEL' | translate }}
</button>
</div>

View File

@ -0,0 +1,95 @@
.clr-form-horizontal {
.clr-form-control {
& >.clr-control-label {
width: 14rem;
}
}
.flex-direction-column {
flex-direction: column;
}
}
.bottom-line {
display: flex;
flex-direction: column-reverse;
}
.expire-data {
min-width: 12.5rem;
margin-top: -1rem;
}
.position-relative {
position: relative;
}
.pl-05 {
padding-left: 0.5rem;
}
.mt-1 {
margin-top: 1rem;
}
.d-f {
display: flex;
}
.font-size-13 {
font-size: 13px;
}
.mt-05 {
margin-bottom: 0.5rem;
}
.title {
font-weight: bold;
}
.margin-top-4 {
margin-top: 4px;
}
.allowlist-window {
border: 1px solid #ccc;
border-radius: 3px;
padding: 12px;
height: 224px;
width: 222px;
overflow-y: auto;
li {
height: 24px;
line-height: 24px;
list-style-type: none;
}
}
.width-90per {
width: 90%;
}
.none {
color: #ccc;
}
.color-0079bb {
color: #0079bb;
}
.padding-top-8 {
padding-top: 8px;
}
.padding-left-80 {
padding-left: 80px;
}
.add-modal {
position: absolute;
padding: 0 8px;
background-color: rgb(238, 238, 238);
input {
width: 100%;
border: 1px solid;
}
button {
float: right;
}
}

View File

@ -0,0 +1,75 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { SystemInfoService } from '../../../../shared/services';
import { ErrorHandler } from '../../../../shared/units/error-handler';
import { of } from 'rxjs';
import { SharedTestingModule } from '../../../../shared/shared.module';
import { SecurityComponent } from './security.component';
describe('SecurityComponent', () => {
let component: SecurityComponent;
let fixture: ComponentFixture<SecurityComponent>;
const mockedAllowlist = {
id: 1,
project_id: 1,
expires_at: null,
items: [{ cve_id: 'CVE-2019-1234' }],
};
const fakedSystemInfoService = {
getSystemAllowlist() {
return of(mockedAllowlist);
},
updateSystemAllowlist() {
return of(true);
},
};
const fakedErrorHandler = {
info() {
return null;
},
};
beforeEach(() => {
TestBed.configureTestingModule({
imports: [SharedTestingModule],
providers: [
{ provide: ErrorHandler, useValue: fakedErrorHandler },
{
provide: SystemInfoService,
useValue: fakedSystemInfoService,
},
],
declarations: [SecurityComponent],
});
});
beforeEach(() => {
fixture = TestBed.createComponent(SecurityComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('cancel button and save button should enable', async () => {
component.systemAllowlist.items.push({ cve_id: 'CVE-2019-456' });
fixture.detectChanges();
await fixture.whenStable();
const cancel: HTMLButtonElement =
fixture.nativeElement.querySelector('#security_cancel');
expect(cancel.disabled).toBeFalse();
const save: HTMLButtonElement =
fixture.nativeElement.querySelector('#security_save');
expect(save.disabled).toBeFalse();
});
it('save button should works', async () => {
component.systemAllowlist.items[0].cve_id = 'CVE-2019-789';
fixture.detectChanges();
await fixture.whenStable();
const save: HTMLButtonElement =
fixture.nativeElement.querySelector('#security_save');
save.click();
fixture.detectChanges();
await fixture.whenStable();
expect(component.systemAllowlistOrigin.items[0].cve_id).toEqual(
'CVE-2019-789'
);
});
});

View File

@ -0,0 +1,221 @@
import {
Component,
ElementRef,
OnDestroy,
OnInit,
ViewChild,
} from '@angular/core';
import { clone, compareValue } from '../../../../shared/units/utils';
import { ErrorHandler } from '../../../../shared/units/error-handler';
import {
ConfirmationState,
ConfirmationTargets,
} from '../../../../shared/entities/shared.const';
import {
SystemCVEAllowlist,
SystemInfoService,
} from '../../../../shared/services';
import { Subscription } from 'rxjs';
import { finalize } from 'rxjs/operators';
import { ConfirmationDialogService } from '../../../global-confirmation-dialog/confirmation-dialog.service';
import { ConfirmationMessage } from '../../../global-confirmation-dialog/confirmation-message';
const ONE_THOUSAND: number = 1000;
const CVE_DETAIL_PRE_URL = `https://nvd.nist.gov/vuln/detail/`;
const TARGET_BLANK = '_blank';
@Component({
selector: 'app-security',
templateUrl: './security.component.html',
styleUrls: ['./security.component.scss'],
})
export class SecurityComponent implements OnInit, OnDestroy {
onGoing = false;
systemAllowlist: SystemCVEAllowlist;
systemAllowlistOrigin: SystemCVEAllowlist;
cveIds: string;
showAddModal: boolean = false;
@ViewChild('dateInput') dateInput: ElementRef;
private _confirmSub: Subscription;
constructor(
private errorHandler: ErrorHandler,
private systemInfoService: SystemInfoService,
private confirmService: ConfirmationDialogService
) {}
ngOnInit() {
this.getSystemAllowlist();
this.subscribeConfirmation();
}
ngOnDestroy() {
if (this._confirmSub) {
this._confirmSub.unsubscribe();
this._confirmSub = null;
}
}
save(): void {
if (!compareValue(this.systemAllowlistOrigin, this.systemAllowlist)) {
this.onGoing = true;
this.systemInfoService
.updateSystemAllowlist(this.systemAllowlist)
.pipe(finalize(() => (this.onGoing = false)))
.subscribe({
next: res => {
this.systemAllowlistOrigin = clone(
this.systemAllowlist
);
this.errorHandler.info('CONFIG.SAVE_SUCCESS');
},
error: err => {
this.errorHandler.error(err);
},
});
} else {
// Inprop situation, should not come here
console.error('Save abort because nothing changed');
}
}
get inProgress(): boolean {
return this.onGoing;
}
cancel(): void {
if (!compareValue(this.systemAllowlistOrigin, this.systemAllowlist)) {
const msg = new ConfirmationMessage(
'CONFIG.CONFIRM_TITLE',
'CONFIG.CONFIRM_SUMMARY',
'',
null,
ConfirmationTargets.CONFIG
);
this.confirmService.openComfirmDialog(msg);
} else {
// Invalid situation, should not come here
console.error('Nothing changed');
}
}
subscribeConfirmation() {
this._confirmSub = this.confirmService.confirmationConfirm$.subscribe(
confirmation => {
if (
confirmation &&
confirmation.state === ConfirmationState.CONFIRMED
) {
if (
!compareValue(
this.systemAllowlistOrigin,
this.systemAllowlist
)
) {
this.systemAllowlist = clone(
this.systemAllowlistOrigin
);
}
}
}
);
}
getSystemAllowlist() {
this.onGoing = true;
this.systemInfoService.getSystemAllowlist().subscribe(
systemAllowlist => {
this.onGoing = false;
if (!systemAllowlist.items) {
systemAllowlist.items = [];
}
if (!systemAllowlist.expires_at) {
systemAllowlist.expires_at = null;
}
this.systemAllowlist = systemAllowlist;
this.systemAllowlistOrigin = clone(systemAllowlist);
},
error => {
this.onGoing = false;
console.error(
'An error occurred during getting systemAllowlist'
);
}
);
}
deleteItem(index: number) {
this.systemAllowlist.items.splice(index, 1);
}
addToSystemAllowlist() {
// remove duplication and add to systemAllowlist
let map = {};
this.systemAllowlist.items.forEach(item => {
map[item.cve_id] = true;
});
this.cveIds.split(/[\n,]+/).forEach(id => {
let cveObj: any = {};
cveObj.cve_id = id.trim();
if (!map[cveObj.cve_id]) {
map[cveObj.cve_id] = true;
this.systemAllowlist.items.push(cveObj);
}
});
// clear modal and close modal
this.cveIds = null;
this.showAddModal = false;
}
get hasAllowlistChanged(): boolean {
return !compareValue(this.systemAllowlistOrigin, this.systemAllowlist);
}
isDisabled(): boolean {
let str = this.cveIds;
return !(str && str.trim());
}
get expiresDate() {
if (this.systemAllowlist && this.systemAllowlist.expires_at) {
return new Date(this.systemAllowlist.expires_at * ONE_THOUSAND);
}
return null;
}
set expiresDate(date) {
if (this.systemAllowlist && date) {
this.systemAllowlist.expires_at = Math.floor(
date.getTime() / ONE_THOUSAND
);
}
}
get neverExpires(): boolean {
return !(this.systemAllowlist && this.systemAllowlist.expires_at);
}
set neverExpires(flag) {
if (flag) {
this.systemAllowlist.expires_at = null;
this.systemInfoService.resetDateInput(this.dateInput);
} else {
this.systemAllowlist.expires_at = Math.floor(
new Date().getTime() / ONE_THOUSAND
);
}
}
get hasExpired(): boolean {
if (
this.systemAllowlistOrigin &&
this.systemAllowlistOrigin.expires_at
) {
return (
new Date().getTime() >
this.systemAllowlistOrigin.expires_at * ONE_THOUSAND
);
}
return false;
}
goToDetail(cveId) {
window.open(CVE_DETAIL_PRE_URL + `${cveId}`, TARGET_BLANK);
}
}

View File

@ -23,6 +23,7 @@
clrSelect
id="proCreation"
name="proCreation"
class="pro-creation"
[(ngModel)]="currentConfig.project_creation_restriction.value"
[disabled]="
disabled(currentConfig.project_creation_restriction)
@ -189,142 +190,7 @@
(ngModelChange)="setRepoReadOnlyValue($event)" />
</clr-checkbox-wrapper>
</clr-checkbox-container>
<div class="clr-form-control d-f">
<label class="clr-control-label">{{
'CVE_ALLOWLIST.DEPLOYMENT_SECURITY' | translate
}}</label>
<div class="form-content">
<div class="font-size-13">
<div class="mt-05">
<span class="title font-size-13">{{
'CVE_ALLOWLIST.CVE_ALLOWLIST' | translate
}}</span>
</div>
<div class="mt-05">
<span>{{
'CVE_ALLOWLIST.SYS_ALLOWLIST_EXPLAIN' | translate
}}</span>
</div>
<div class="mt-05">
<span>{{ 'CVE_ALLOWLIST.ADD_SYS' | translate }}</span>
</div>
<div class="mt-05" *ngIf="hasExpired">
<span class="label label-warning">{{
'CVE_ALLOWLIST.WARNING_SYS' | translate
}}</span>
</div>
</div>
<div class="clr-row width-90per">
<div class="position-relative pl-05">
<div>
<button
id="show-add-modal-button"
(click)="showAddModal = !showAddModal"
class="btn btn-link">
{{ 'CVE_ALLOWLIST.ADD' | translate }}
</button>
</div>
<div
class="add-modal add-modal-dark"
*ngIf="showAddModal">
<clr-icon
(click)="showAddModal = false"
class="float-lg-right margin-top-4"
shape="window-close"></clr-icon>
<div>
<clr-textarea-container
class="flex-direction-column">
<label>{{
'CVE_ALLOWLIST.ENTER' | translate
}}</label>
<textarea
id="allowlist-textarea"
class="w-100 font-italic"
clrTextarea
[(ngModel)]="cveIds"
name="cveIds"></textarea>
<clr-control-helper>{{
'CVE_ALLOWLIST.HELP' | translate
}}</clr-control-helper>
</clr-textarea-container>
</div>
<div>
<button
id="add-to-system"
[disabled]="isDisabled()"
(click)="addToSystemAllowlist()"
class="btn btn-link">
{{ 'CVE_ALLOWLIST.ADD' | translate }}
</button>
</div>
</div>
<ul class="allowlist-window">
<li
*ngIf="systemAllowlist?.items?.length < 1"
class="none">
{{ 'CVE_ALLOWLIST.NONE' | translate }}
</li>
<li
*ngFor="
let item of systemAllowlist?.items;
let i = index
">
<a
href="javascript:void(0)"
(click)="goToDetail(item.cve_id)"
>{{ item.cve_id }}</a
>
<a
class="float-lg-right"
href="javascript:void(0)"
(click)="deleteItem(i)">
<clr-icon shape="times-circle"></clr-icon>
</a>
</li>
</ul>
</div>
<div class="clr-col padding-top-8">
<div class="clr-row expire-data">
<label class="bottom-line clr-col-4">{{
'CVE_ALLOWLIST.EXPIRES_AT' | translate
}}</label>
<div>
<input
#dateInput
placeholder="{{
'CVE_ALLOWLIST.NEVER_EXPIRES'
| translate
}}"
readonly
type="date"
[(clrDate)]="expiresDate"
newFormLayout="true" />
</div>
</div>
<div class="clr-row">
<label class="clr-col-4"></label>
<clr-checkbox-wrapper>
<input
[checked]="neverExpires"
[(ngModel)]="neverExpires"
type="checkbox"
clrCheckbox
name="neverExpires"
id="neverExpires" />
<label>
{{
'CVE_ALLOWLIST.NEVER_EXPIRES'
| translate
}}
</label>
</clr-checkbox-wrapper>
</div>
</div>
</div>
</div>
</div>
<clr-checkbox-container>
<clr-checkbox-container class="center">
<label for="webhookNotificationEnabled"
>{{ 'CONFIG.WEBHOOK_NOTIFICATION_ENABLED' | translate }}
<clr-tooltip>
@ -353,6 +219,67 @@
[disabled]="!currentConfig.notification_enable.editable" />
</clr-checkbox-wrapper>
</clr-checkbox-container>
<clr-input-container>
<label for="auditLogForwardEndpoint">
{{ 'CLEARANCES.FORWARD_ENDPOINT' | translate }}
<clr-tooltip>
<clr-icon
clrTooltipTrigger
shape="info-circle"
size="24"></clr-icon>
<clr-tooltip-content
clrPosition="top-right"
clrSize="lg"
*clrIfOpen>
<span>{{
'CLEARANCES.FORWARD_ENDPOINT_TOOLTIP' | translate
}}</span>
</clr-tooltip-content>
</clr-tooltip>
</label>
<input
clrInput
name="auditLogForwardEndpoint"
type="text"
[(ngModel)]="currentConfig.audit_log_forward_endpoint.value"
id="auditLogForwardEndpoint"
size="20"
(input)="checkAuditLogForwardEndpoint($event)"
[disabled]="
!currentConfig?.audit_log_forward_endpoint?.editable
" />
</clr-input-container>
<clr-checkbox-container class="center">
<label for="skipAuditLogDatabase"
>{{ 'CLEARANCES.SKIP_DATABASE' | translate }}
<clr-tooltip>
<clr-icon
clrTooltipTrigger
shape="info-circle"
size="24"></clr-icon>
<clr-tooltip-content
clrPosition="top-right"
clrSize="lg"
*clrIfOpen>
<span>{{
'CLEARANCES.SKIP_DATABASE_TOOLTIP' | translate
}}</span>
</clr-tooltip-content>
</clr-tooltip>
</label>
<clr-checkbox-wrapper>
<input
type="checkbox"
clrCheckbox
name="skipAuditLogDatabase"
id="skipAuditLogDatabase"
[(ngModel)]="currentConfig.skip_audit_log_database.value"
[disabled]="
!currentConfig.skip_audit_log_database?.editable ||
!currentConfig.audit_log_forward_endpoint?.value
" />
</clr-checkbox-wrapper>
</clr-checkbox-container>
</section>
</form>
<div>
@ -361,10 +288,7 @@
id="config_system_save"
class="btn btn-primary"
(click)="save()"
[disabled]="
((!isValid() || !hasChanges()) && !hasAllowlistChanged) ||
inProgress
">
[disabled]="!isValid() || !hasChanges() || inProgress">
{{ 'BUTTON.SAVE' | translate }}
</button>
<button
@ -372,10 +296,7 @@
id="config_system_cancel"
class="btn btn-outline"
(click)="cancel()"
[disabled]="
((!isValid() || !hasChanges()) && !hasAllowlistChanged) ||
inProgress
">
[disabled]="!isValid() || !hasChanges() || inProgress">
{{ 'BUTTON.CANCEL' | translate }}
</button>
</div>

View File

@ -6,7 +6,7 @@
.clr-form-horizontal {
.clr-form-control {
& >.clr-control-label {
width: 12rem;
width: 14rem;
}
}
.flex-direction-column {
@ -136,3 +136,13 @@
.margin-top-3px {
margin-top: 3px;
}
.center {
display: flex;
align-items: center;
}
.clr-input {
width: 12rem;
}
.pro-creation {
width: 12rem;
}

View File

@ -1,41 +1,21 @@
import {
ComponentFixture,
ComponentFixtureAutoDetect,
TestBed,
} from '@angular/core/testing';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { SystemSettingsComponent } from './system-settings.component';
import { SystemInfoService } from '../../../../shared/services';
import { ErrorHandler } from '../../../../shared/units/error-handler';
import { of } from 'rxjs';
import { Configuration, StringValueItem } from '../config';
import { Configuration, NumberValueItem, StringValueItem } from '../config';
import { SharedTestingModule } from '../../../../shared/shared.module';
import { ConfigService } from '../config.service';
import { AppConfigService } from '../../../../services/app-config.service';
describe('SystemSettingsComponent', () => {
let component: SystemSettingsComponent;
let fixture: ComponentFixture<SystemSettingsComponent>;
const mockedAllowlist = {
id: 1,
project_id: 1,
expires_at: null,
items: [{ cve_id: 'CVE-2019-1234' }],
};
const fakedSystemInfoService = {
getSystemAllowlist() {
return of(mockedAllowlist);
},
getSystemInfo() {
return of({});
},
updateSystemAllowlist() {
return of(true);
},
};
const fakedErrorHandler = {
info() {
return null;
},
};
const fakeConfigService = {
config: new Configuration(),
getConfig() {
@ -53,6 +33,9 @@ describe('SystemSettingsComponent', () => {
confirmUnsavedChanges() {},
updateConfig() {},
resetConfig() {},
saveConfiguration() {
return of(null);
},
};
const fakedAppConfigService = {
getConfig() {
@ -69,12 +52,6 @@ describe('SystemSettingsComponent', () => {
{ provide: AppConfigService, useValue: fakedAppConfigService },
{ provide: ConfigService, useValue: fakeConfigService },
{ provide: ErrorHandler, useValue: fakedErrorHandler },
{
provide: SystemInfoService,
useValue: fakedSystemInfoService,
},
// open auto detect
{ provide: ComponentFixtureAutoDetect, useValue: true },
],
declarations: [SystemSettingsComponent],
});
@ -82,46 +59,30 @@ describe('SystemSettingsComponent', () => {
beforeEach(() => {
fixture = TestBed.createComponent(SystemSettingsComponent);
component = fixture.componentInstance;
component.currentConfig.auth_mode = new StringValueItem(
'db_auth',
false
);
fixture.detectChanges();
fixture.autoDetectChanges(true);
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('cancel button should works', () => {
const spy: jasmine.Spy = spyOn(
fakeConfigService,
'confirmUnsavedChanges'
).and.returnValue(undefined);
component.systemAllowlist.items.push({ cve_id: 'CVE-2019-456' });
const readOnly: HTMLElement =
fixture.nativeElement.querySelector('#repoReadOnly');
readOnly.click();
fixture.detectChanges();
it('cancel button should work', () => {
const spy: jasmine.Spy = spyOn(component, 'cancel').and.returnValue(
undefined
);
const cancel: HTMLButtonElement = fixture.nativeElement.querySelector(
'#config_system_cancel'
);
cancel.click();
fixture.detectChanges();
cancel.dispatchEvent(new Event('click'));
expect(spy.calls.count()).toEqual(1);
});
it('save button should works', () => {
component.systemAllowlist.items[0].cve_id = 'CVE-2019-789';
const readOnly: HTMLElement =
fixture.nativeElement.querySelector('#repoReadOnly');
readOnly.click();
fixture.detectChanges();
it('save button should work', () => {
const input = fixture.nativeElement.querySelector('#robotNamePrefix');
input.value = 'test';
input.dispatchEvent(new Event('input'));
const save: HTMLButtonElement = fixture.nativeElement.querySelector(
'#config_system_save'
);
save.click();
fixture.detectChanges();
expect(component.systemAllowlistOrigin.items[0].cve_id).toEqual(
'CVE-2019-789'
);
save.dispatchEvent(new Event('click'));
expect(input.value).toEqual('test');
});
});

View File

@ -1,32 +1,15 @@
import { Component, ElementRef, OnInit, ViewChild } from '@angular/core';
import { Component, OnInit, ViewChild } from '@angular/core';
import { NgForm } from '@angular/forms';
import { Configuration } from '../config';
import {
clone,
compareValue,
CURRENT_BASE_HREF,
getChanges,
isEmpty,
} from '../../../../shared/units/utils';
import { ErrorHandler } from '../../../../shared/units/error-handler';
import {
ConfirmationState,
ConfirmationTargets,
} from '../../../../shared/entities/shared.const';
import { ConfirmationAcknowledgement } from '../../../global-confirmation-dialog/confirmation-state-message';
import {
SystemCVEAllowlist,
SystemInfo,
SystemInfoService,
} from '../../../../shared/services';
import { forkJoin } from 'rxjs';
import { ConfigurationService } from '../../../../services/config.service';
import { ConfigService } from '../config.service';
import { AppConfigService } from '../../../../services/app-config.service';
const ONE_THOUSAND: number = 1000;
const CVE_DETAIL_PRE_URL = `https://nvd.nist.gov/vuln/detail/`;
const TARGET_BLANK = '_blank';
import { finalize } from 'rxjs/operators';
@Component({
selector: 'system-settings',
@ -36,11 +19,6 @@ const TARGET_BLANK = '_blank';
export class SystemSettingsComponent implements OnInit {
onGoing = false;
downloadLink: string;
systemAllowlist: SystemCVEAllowlist;
systemAllowlistOrigin: SystemCVEAllowlist;
cveIds: string;
showAddModal: boolean = false;
systemInfo: SystemInfo;
get currentConfig(): Configuration {
return this.conf.getConfig();
}
@ -49,7 +27,18 @@ export class SystemSettingsComponent implements OnInit {
this.conf.setConfig(cfg);
}
@ViewChild('systemConfigFrom') systemSettingsForm: NgForm;
@ViewChild('dateInput') dateInput: ElementRef;
constructor(
private appConfigService: AppConfigService,
private errorHandler: ErrorHandler,
private conf: ConfigService
) {
this.downloadLink = CURRENT_BASE_HREF + '/systeminfo/getcert';
}
ngOnInit() {
this.conf.resetConfig();
}
get editable(): boolean {
return (
@ -121,7 +110,9 @@ export class SystemSettingsComponent implements OnInit {
prop === 'project_creation_restriction' ||
prop === 'robot_token_duration' ||
prop === 'notification_enable' ||
prop === 'robot_name_prefix'
prop === 'robot_name_prefix' ||
prop === 'audit_log_forward_endpoint' ||
prop === 'skip_audit_log_database'
) {
changes[prop] = allChanges[prop];
}
@ -153,82 +144,41 @@ export class SystemSettingsComponent implements OnInit {
*/
public save(): void {
let changes = this.getChanges();
if (
!isEmpty(changes) ||
!compareValue(this.systemAllowlistOrigin, this.systemAllowlist)
) {
if (!isEmpty(changes)) {
this.onGoing = true;
let observables = [];
if (!isEmpty(changes)) {
observables.push(this.configService.saveConfiguration(changes));
}
if (
!compareValue(this.systemAllowlistOrigin, this.systemAllowlist)
) {
observables.push(
this.systemInfoService.updateSystemAllowlist(
this.systemAllowlist
)
);
}
forkJoin(observables).subscribe(
result => {
this.onGoing = false;
if (!isEmpty(changes)) {
// API should return the updated configurations here
// Unfortunately API does not do that
// To refresh the view, we can clone the original data copy
// or force refresh by calling service.
// HERE we choose force way
this.conf.updateConfig();
// Reload bootstrap option
this.appConfigService.load().subscribe(
() => {},
error =>
console.error(
'Failed to reload bootstrap option with error: ',
error
)
);
}
if (
!compareValue(
this.systemAllowlistOrigin,
this.systemAllowlist
)
) {
this.systemAllowlistOrigin = clone(
this.systemAllowlist
);
}
this.errorHandler.info('CONFIG.SAVE_SUCCESS');
},
error => {
this.onGoing = false;
this.errorHandler.error(error);
}
);
this.conf
.saveConfiguration(changes)
.pipe(finalize(() => (this.onGoing = false)))
.subscribe({
next: result => {
if (!isEmpty(changes)) {
// API should return the updated configurations here
// Unfortunately API does not do that
// To refresh the view, we can clone the original data copy
// or force refresh by calling service.
// HERE we choose force way
this.conf.updateConfig();
// Reload bootstrap option
this.appConfigService.load().subscribe(
() => {},
error =>
console.error(
'Failed to reload bootstrap option with error: ',
error
)
);
}
this.errorHandler.info('CONFIG.SAVE_SUCCESS');
},
error: error => {
this.errorHandler.error(error);
},
});
} else {
// Inprop situation, should not come here
console.error('Save abort because nothing changed');
}
}
confirmCancel(ack: ConfirmationAcknowledgement): void {
if (
ack &&
ack.source === ConfirmationTargets.CONFIG &&
ack.state === ConfirmationState.CONFIRMED
) {
this.conf.resetConfig();
if (
!compareValue(this.systemAllowlistOrigin, this.systemAllowlist)
) {
this.systemAllowlist = clone(this.systemAllowlistOrigin);
}
}
}
public get inProgress(): boolean {
return this.onGoing || this.conf.getLoadingConfigStatus();
}
@ -241,10 +191,7 @@ export class SystemSettingsComponent implements OnInit {
*/
public cancel(): void {
let changes = this.getChanges();
if (
!isEmpty(changes) ||
!compareValue(this.systemAllowlistOrigin, this.systemAllowlist)
) {
if (!isEmpty(changes)) {
this.conf.confirmUnsavedChanges(changes);
} else {
// Invalid situation, should not come here
@ -252,129 +199,9 @@ export class SystemSettingsComponent implements OnInit {
}
}
constructor(
private appConfigService: AppConfigService,
private configService: ConfigurationService,
private errorHandler: ErrorHandler,
private systemInfoService: SystemInfoService,
private conf: ConfigService
) {
this.downloadLink = CURRENT_BASE_HREF + '/systeminfo/getcert';
}
ngOnInit() {
this.conf.resetConfig();
this.getSystemAllowlist();
this.getSystemInfo();
}
getSystemInfo() {
this.systemInfoService.getSystemInfo().subscribe(
systemInfo => (this.systemInfo = systemInfo),
error => this.errorHandler.error(error)
);
}
getSystemAllowlist() {
this.onGoing = true;
this.systemInfoService.getSystemAllowlist().subscribe(
systemAllowlist => {
this.onGoing = false;
if (!systemAllowlist.items) {
systemAllowlist.items = [];
}
if (!systemAllowlist.expires_at) {
systemAllowlist.expires_at = null;
}
this.systemAllowlist = systemAllowlist;
this.systemAllowlistOrigin = clone(systemAllowlist);
},
error => {
this.onGoing = false;
console.error(
'An error occurred during getting systemAllowlist'
);
// this.errorHandler.error(error);
}
);
}
deleteItem(index: number) {
this.systemAllowlist.items.splice(index, 1);
}
addToSystemAllowlist() {
// remove duplication and add to systemAllowlist
let map = {};
this.systemAllowlist.items.forEach(item => {
map[item.cve_id] = true;
});
this.cveIds.split(/[\n,]+/).forEach(id => {
let cveObj: any = {};
cveObj.cve_id = id.trim();
if (!map[cveObj.cve_id]) {
map[cveObj.cve_id] = true;
this.systemAllowlist.items.push(cveObj);
}
});
// clear modal and close modal
this.cveIds = null;
this.showAddModal = false;
}
get hasAllowlistChanged(): boolean {
return !compareValue(this.systemAllowlistOrigin, this.systemAllowlist);
}
isDisabled(): boolean {
let str = this.cveIds;
return !(str && str.trim());
}
get expiresDate() {
if (this.systemAllowlist && this.systemAllowlist.expires_at) {
return new Date(this.systemAllowlist.expires_at * ONE_THOUSAND);
checkAuditLogForwardEndpoint(e: any) {
if (!e?.target?.value) {
this.currentConfig.skip_audit_log_database.value = false;
}
return null;
}
set expiresDate(date) {
if (this.systemAllowlist && date) {
this.systemAllowlist.expires_at = Math.floor(
date.getTime() / ONE_THOUSAND
);
}
}
get neverExpires(): boolean {
return !(this.systemAllowlist && this.systemAllowlist.expires_at);
}
set neverExpires(flag) {
if (flag) {
this.systemAllowlist.expires_at = null;
this.systemInfoService.resetDateInput(this.dateInput);
} else {
this.systemAllowlist.expires_at = Math.floor(
new Date().getTime() / ONE_THOUSAND
);
}
}
get hasExpired(): boolean {
if (
this.systemAllowlistOrigin &&
this.systemAllowlistOrigin.expires_at
) {
return (
new Date().getTime() >
this.systemAllowlistOrigin.expires_at * ONE_THOUSAND
);
}
return false;
}
goToDetail(cveId) {
window.open(CVE_DETAIL_PRE_URL + `${cveId}`, TARGET_BLANK);
}
}

View File

@ -1,33 +0,0 @@
<div class="row">
<div class="col-lg-12 col-md-12 col-sm-12 col-xs-12">
<h2 class="custom-h2 gc-title">
{{ 'CONFIG.GC' | translate
}}<span
*ngIf="inProgress"
class="spinner spinner-inline ml-1 v-mid"></span>
</h2>
<clr-tabs>
<clr-tab *ngIf="hasAdminRole">
<button id="config-gc" clrTabLink>
{{ 'CONFIG.GC' | translate }}
</button>
<ng-template [(clrIfActive)]="gcActive">
<clr-tab-content id="gc" *ngIf="hasAdminRole">
<gc-config
(loadingGcStatus)="getGcStatus($event)"></gc-config>
</clr-tab-content>
</ng-template>
</clr-tab>
<clr-tab *ngIf="hasAdminRole">
<button id="gc-log" clrTabLink>
{{ 'CONFIG.HISTORY' | translate }}
</button>
<ng-template [(clrIfActive)]="historyActive">
<clr-tab-content id="history" *ngIf="hasAdminRole">
<gc-history></gc-history>
</clr-tab-content>
</ng-template>
</clr-tab>
</clr-tabs>
</div>
</div>

View File

@ -1,6 +0,0 @@
.gc-title {
display: inline-block;
}
.v-mid {
vertical-align: middle;
}

View File

@ -1,23 +0,0 @@
import { Component } from '@angular/core';
import { SessionService } from '../../../shared/services/session.service';
@Component({
selector: 'app-gc-page',
templateUrl: './gc-page.component.html',
styleUrls: ['./gc-page.component.scss'],
})
export class GcPageComponent {
inProgress: boolean = true;
constructor(private session: SessionService) {}
public get hasAdminRole(): boolean {
return (
this.session.getCurrentUser() &&
this.session.getCurrentUser().has_admin_role
);
}
getGcStatus(status: boolean) {
this.inProgress = status;
}
}

View File

@ -1,18 +0,0 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { GcPageComponent } from './gc-page.component';
import { GcComponent } from './gc/gc.component';
import { GcHistoryComponent } from './gc/gc-history/gc-history.component';
import { SharedModule } from '../../../shared/shared.module';
const routes: Routes = [
{
path: '',
component: GcPageComponent,
},
];
@NgModule({
imports: [SharedModule, RouterModule.forChild(routes)],
declarations: [GcPageComponent, GcComponent, GcHistoryComponent],
})
export class GcModule {}

View File

@ -1,52 +0,0 @@
<div class="cron-selection">
<cron-selection
[labelCurrent]="getLabelCurrent"
#CronScheduleComponent
[labelEdit]="getLabelCurrent"
[originCron]="originCron"
(inputvalue)="saveGcSchedule($event)"></cron-selection>
</div>
<div class="clr-row">
<div class="clr-col-2 flex-200"></div>
<div class="clr-col">
<span class="explain">{{ 'GC.EXPLAIN' | translate }}</span>
</div>
</div>
<div class="clr-row">
<div class="clr-col-2 flex-200"></div>
<div class="clr-col">
<clr-toggle-container class="mt-05">
<clr-toggle-wrapper>
<input
type="checkbox"
clrToggle
name="delete_untagged"
id="delete_untagged"
[(ngModel)]="shouldDeleteUntagged" />
<label class="font-weight-400" for="delete_untagged">{{
'GC.DELETE_UNTAGGED' | translate
}}</label>
</clr-toggle-wrapper>
</clr-toggle-container>
</div>
</div>
<div class="clr-row">
<div class="clr-col-2 flex-200">
<button
id="gc-now"
class="btn btn-primary gc-start-btn"
(click)="gcNow()"
[disabled]="disableGC">
{{ 'GC.GC_NOW' | translate }}
</button>
</div>
<div class="clr-col">
<button
id="gc-dry-run"
class="btn btn-outline gc-start-btn"
(click)="dryRun()"
[disabled]="dryRunOnGoing">
{{ 'TAG_RETENTION.WHAT_IF_RUN' | translate }}
</button>
</div>
</div>

View File

@ -136,7 +136,11 @@
</label>
</div>
</div>
<button class="btn btn-link" (click)="save()" id="config-save">
<button
class="btn btn-link"
[disabled]="!externalValidation"
(click)="save()"
id="config-save">
{{ 'BUTTON.SAVE' | translate }}
</button>
<button class="btn btn-link" (click)="isEditMode = false">

View File

@ -5,8 +5,6 @@
.font-style {
display: inline-block;
color: #000;
font-size: .541667rem;
}
span.required {

View File

@ -25,6 +25,7 @@ const PREFIX: string = '0 ';
styleUrls: ['./cron-schedule.component.scss'],
})
export class CronScheduleComponent implements OnChanges {
@Input() externalValidation: boolean = true; //extra check
@Input() isInlineModel: boolean = false;
@Input() originCron: OriginCron;
@Input() labelEdit: string;
@ -46,7 +47,7 @@ export class CronScheduleComponent implements OnChanges {
ngOnChanges(changes: SimpleChanges): void {
let cronChange: SimpleChange = changes['originCron'];
if (cronChange.currentValue) {
if (cronChange?.currentValue) {
this.originScheduleType = cronChange.currentValue.type;
this.oriCron = cronChange.currentValue.cron;
}

View File

@ -1727,5 +1727,28 @@
"CO_SIGN": "Cosign",
"NOTARY": "Notary",
"PLACEHOLDER": "Es konnten keine Anhänge gefunden werden!"
},
"CLEARANCES": {
"CLEARANCES": "Clean Up",
"AUDIT_LOG": "Log Rotation",
"LAST_COMPLETED": "Last completed",
"NEXT_SCHEDULED_TIME": "Next scheduled time",
"SCHEDULE_TO_PURGE": "Schedule to purge",
"KEEP_IN": "Keep records in",
"KEEP_IN_TOOLTIP": "Keep the records in this interval",
"KEEP_IN_ERROR": "This filed is required",
"DAYS": "Days",
"HOURS": "Hours",
"INCLUDED_OPERATIONS": "Included operations",
"INCLUDED_OPERATION_TOOLTIP": "Remove audit logs for the selected operations",
"INCLUDED_OPERATION_ERROR": "Please select at lease one operation",
"PURGE_NOW": "PURGE NOW",
"PURGE_NOW_SUCCESS": "Purge triggered successfully",
"PURGE_SCHEDULE_RESET": "Purge schedule has been reset",
"PURGE_HISTORY": "Purge History",
"FORWARD_ENDPOINT": "Audit Log Forward Endpoint",
"FORWARD_ENDPOINT_TOOLTIP": "Forward audit logs to the syslog endpoint, for example: harbor-log:10514",
"SKIP_DATABASE": "Skip Audit Log Database",
"SKIP_DATABASE_TOOLTIP": "Skip to log audit log in the database, only available when audit log forward endpoint is configured"
}
}

View File

@ -1727,5 +1727,28 @@
"CO_SIGN": "Cosign",
"NOTARY": "Notary",
"PLACEHOLDER": "We couldn't find any accessories!"
},
"CLEARANCES": {
"CLEARANCES": "Clean Up",
"AUDIT_LOG": "Log Rotation",
"LAST_COMPLETED": "Last completed",
"NEXT_SCHEDULED_TIME": "Next scheduled time",
"SCHEDULE_TO_PURGE": "Schedule to purge",
"KEEP_IN": "Keep records in",
"KEEP_IN_TOOLTIP": "Keep the records in this interval",
"KEEP_IN_ERROR": "This filed is required",
"DAYS": "Days",
"HOURS": "Hours",
"INCLUDED_OPERATIONS": "Included operations",
"INCLUDED_OPERATION_TOOLTIP": "Remove audit logs for the selected operations",
"INCLUDED_OPERATION_ERROR": "Please select at lease one operation",
"PURGE_NOW": "PURGE NOW",
"PURGE_NOW_SUCCESS": "Purge triggered successfully",
"PURGE_SCHEDULE_RESET": "Purge schedule has been reset",
"PURGE_HISTORY": "Purge History",
"FORWARD_ENDPOINT": "Audit Log Forward Endpoint",
"FORWARD_ENDPOINT_TOOLTIP": "Forward audit logs to the syslog endpoint, for example: harbor-log:10514",
"SKIP_DATABASE": "Skip Audit Log Database",
"SKIP_DATABASE_TOOLTIP": "Skip to log audit log in the database, only available when audit log forward endpoint is configured"
}
}

View File

@ -1726,5 +1726,28 @@
"CO_SIGN": "Cosign",
"NOTARY": "Notary",
"PLACEHOLDER": "We couldn't find any accessories!"
},
"CLEARANCES": {
"CLEARANCES": "Clean Up",
"AUDIT_LOG": "Log Rotation",
"LAST_COMPLETED": "Last completed",
"NEXT_SCHEDULED_TIME": "Next scheduled time",
"SCHEDULE_TO_PURGE": "Schedule to purge",
"KEEP_IN": "Keep records in",
"KEEP_IN_TOOLTIP": "Keep the records in this interval",
"KEEP_IN_ERROR": "This filed is required",
"DAYS": "Days",
"HOURS": "Hours",
"INCLUDED_OPERATIONS": "Included operations",
"INCLUDED_OPERATION_TOOLTIP": "Remove audit logs for the selected operations",
"INCLUDED_OPERATION_ERROR": "Please select at lease one operation",
"PURGE_NOW": "PURGE NOW",
"PURGE_NOW_SUCCESS": "Purge triggered successfully",
"PURGE_SCHEDULE_RESET": "Purge schedule has been reset",
"PURGE_HISTORY": "Purge History",
"FORWARD_ENDPOINT": "Audit Log Forward Endpoint",
"FORWARD_ENDPOINT_TOOLTIP": "Forward audit logs to the syslog endpoint, for example: harbor-log:10514",
"SKIP_DATABASE": "Skip Audit Log Database",
"SKIP_DATABASE_TOOLTIP": "Skip to log audit log in the database, only available when audit log forward endpoint is configured"
}
}

View File

@ -1696,5 +1696,28 @@
"CO_SIGN": "Cosign",
"NOTARY": "Notary",
"PLACEHOLDER": "Nous n'avons trouvé aucun accessoire !"
},
"CLEARANCES": {
"CLEARANCES": "Clean Up",
"AUDIT_LOG": "Log Rotation",
"LAST_COMPLETED": "Last completed",
"NEXT_SCHEDULED_TIME": "Next scheduled time",
"SCHEDULE_TO_PURGE": "Schedule to purge",
"KEEP_IN": "Keep records in",
"KEEP_IN_TOOLTIP": "Keep the records in this interval",
"KEEP_IN_ERROR": "This filed is required",
"DAYS": "Days",
"HOURS": "Hours",
"INCLUDED_OPERATIONS": "Included operations",
"INCLUDED_OPERATION_TOOLTIP": "Remove audit logs for the selected operations",
"INCLUDED_OPERATION_ERROR": "Please select at lease one operation",
"PURGE_NOW": "PURGE NOW",
"PURGE_NOW_SUCCESS": "Purge triggered successfully",
"PURGE_SCHEDULE_RESET": "Purge schedule has been reset",
"PURGE_HISTORY": "Purge History",
"FORWARD_ENDPOINT": "Audit Log Forward Endpoint",
"FORWARD_ENDPOINT_TOOLTIP": "Forward audit logs to the syslog endpoint, for example: harbor-log:10514",
"SKIP_DATABASE": "Skip Audit Log Database",
"SKIP_DATABASE_TOOLTIP": "Skip to log audit log in the database, only available when audit log forward endpoint is configured"
}
}

View File

@ -1723,5 +1723,28 @@
"CO_SIGN": "Cosign",
"NOTARY": "Notary",
"PLACEHOLDER": "We couldn't find any accessories!"
},
"CLEARANCES": {
"CLEARANCES": "Clean Up",
"AUDIT_LOG": "Log Rotation",
"LAST_COMPLETED": "Last completed",
"NEXT_SCHEDULED_TIME": "Next scheduled time",
"SCHEDULE_TO_PURGE": "Schedule to purge",
"KEEP_IN": "Keep records in",
"KEEP_IN_TOOLTIP": "Keep the records in this interval",
"KEEP_IN_ERROR": "This filed is required",
"DAYS": "Days",
"HOURS": "Hours",
"INCLUDED_OPERATIONS": "Included operations",
"INCLUDED_OPERATION_TOOLTIP": "Remove audit logs for the selected operations",
"INCLUDED_OPERATION_ERROR": "Please select at lease one operation",
"PURGE_NOW": "PURGE NOW",
"PURGE_NOW_SUCCESS": "Purge triggered successfully",
"PURGE_SCHEDULE_RESET": "Purge schedule has been reset",
"PURGE_HISTORY": "Purge History",
"FORWARD_ENDPOINT": "Audit Log Forward Endpoint",
"FORWARD_ENDPOINT_TOOLTIP": "Forward audit logs to the syslog endpoint, for example: harbor-log:10514",
"SKIP_DATABASE": "Skip Audit Log Database",
"SKIP_DATABASE_TOOLTIP": "Skip to log audit log in the database, only available when audit log forward endpoint is configured"
}
}

View File

@ -1727,5 +1727,28 @@
"CO_SIGN": "Cosign",
"NOTARY": "Notary",
"PLACEHOLDER": "We couldn't find any accessories!"
},
"CLEARANCES": {
"CLEARANCES": "Clean Up",
"AUDIT_LOG": "Log Rotation",
"LAST_COMPLETED": "Last completed",
"NEXT_SCHEDULED_TIME": "Next scheduled time",
"SCHEDULE_TO_PURGE": "Schedule to purge",
"KEEP_IN": "Keep records in",
"KEEP_IN_TOOLTIP": "Keep the records in this interval",
"KEEP_IN_ERROR": "This filed is required",
"DAYS": "Days",
"HOURS": "Hours",
"INCLUDED_OPERATIONS": "Included operations",
"INCLUDED_OPERATION_TOOLTIP": "Remove audit logs for the selected operations",
"INCLUDED_OPERATION_ERROR": "Please select at lease one operation",
"PURGE_NOW": "PURGE NOW",
"PURGE_NOW_SUCCESS": "Purge triggered successfully",
"PURGE_SCHEDULE_RESET": "Purge schedule has been reset",
"PURGE_HISTORY": "Purge History",
"FORWARD_ENDPOINT": "Audit Log Forward Endpoint",
"FORWARD_ENDPOINT_TOOLTIP": "Forward audit logs to the syslog endpoint, for example: harbor-log:10514",
"SKIP_DATABASE": "Skip Audit Log Database",
"SKIP_DATABASE_TOOLTIP": "Skip to log audit log in the database, only available when audit log forward endpoint is configured"
}
}

View File

@ -1725,5 +1725,28 @@
"CO_SIGN": "Cosign",
"NOTARY": "Notary",
"PLACEHOLDER": "未发现任何附件!"
},
"CLEARANCES": {
"CLEARANCES": "清理服务",
"AUDIT_LOG": "日志轮替",
"LAST_COMPLETED": "最近完成时间",
"NEXT_SCHEDULED_TIME": "下次执行时间",
"SCHEDULE_TO_PURGE": "当前定时任务",
"KEEP_IN": "保留记录",
"KEEP_IN_TOOLTIP": "保留指定时间内的日志记录",
"KEEP_IN_ERROR": "此项为必填项",
"DAYS": "天",
"HOURS": "小时",
"INCLUDED_OPERATIONS": "包含操作",
"INCLUDED_OPERATION_TOOLTIP": "删除指定操作类型的日志",
"INCLUDED_OPERATION_ERROR": "请至少选择一种操作类型",
"PURGE_NOW": "立即清理",
"PURGE_NOW_SUCCESS": "触发清理成功",
"PURGE_SCHEDULE_RESET": "清理计划已被重置",
"PURGE_HISTORY": "清理历史",
"FORWARD_ENDPOINT": "日志转发端点",
"FORWARD_ENDPOINT_TOOLTIP": "将日志转发到指定的 syslog 端点例如harbor-log:10514",
"SKIP_DATABASE": "跳过日志数据库",
"SKIP_DATABASE_TOOLTIP": "开启此项将不会在数据库中记录日志,需先配置日志转发端点"
}
}

View File

@ -1718,5 +1718,28 @@
"CO_SIGN": "Cosign",
"NOTARY": "Notary",
"PLACEHOLDER": "We couldn't find any accessories!"
},
"CLEARANCES": {
"CLEARANCES": "Clean Up",
"AUDIT_LOG": "Log Rotation",
"LAST_COMPLETED": "Last completed",
"NEXT_SCHEDULED_TIME": "Next scheduled time",
"SCHEDULE_TO_PURGE": "Schedule to purge",
"KEEP_IN": "Keep records in",
"KEEP_IN_TOOLTIP": "Keep the records in this interval",
"KEEP_IN_ERROR": "This filed is required",
"DAYS": "Days",
"HOURS": "Hours",
"INCLUDED_OPERATIONS": "Included operations",
"INCLUDED_OPERATION_TOOLTIP": "Remove audit logs for the selected operations",
"INCLUDED_OPERATION_ERROR": "Please select at lease one operation",
"PURGE_NOW": "PURGE NOW",
"PURGE_NOW_SUCCESS": "Purge triggered successfully",
"PURGE_SCHEDULE_RESET": "Purge schedule has been reset",
"PURGE_HISTORY": "Purge History",
"FORWARD_ENDPOINT": "Audit Log Forward Endpoint",
"FORWARD_ENDPOINT_TOOLTIP": "Forward audit logs to the syslog endpoint, for example: harbor-log:10514",
"SKIP_DATABASE": "Skip Audit Log Database",
"SKIP_DATABASE_TOOLTIP": "Skip to log audit log in the database, only available when audit log forward endpoint is configured"
}
}