Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

import {EventEmitter} from '@angular/core';
import {ComponentFixture, fakeAsync, flush, TestBed, tick, waitForAsync} from '@angular/core/testing';
import {MatDialogRef} from '@angular/material/dialog';
import {MatDialog, MatDialogRef} from '@angular/material/dialog';
import {BrowserModule} from '@angular/platform-browser';
import {NoopAnimationsModule} from '@angular/platform-browser/animations';
import {Router} from '@angular/router';
Expand Down Expand Up @@ -42,7 +42,7 @@ import {ProjectMockService} from '@test/services/project-mock';
import {RouterStub} from '@test/services/router-stubs';
import {SettingsMockService} from '@test/services/settings-mock';
import {UserMockService} from '@test/services/user-mock';
import {Subject} from 'rxjs';
import {of, Subject} from 'rxjs';
import {AlibabaProviderSettingsComponent} from '../edit-provider-settings/alibaba-provider-settings/component';
import {AWSProviderSettingsComponent} from '../edit-provider-settings/aws-provider-settings/component';
import {AzureProviderSettingsComponent} from '../edit-provider-settings/azure-provider-settings/component';
Expand Down Expand Up @@ -89,6 +89,7 @@ describe('EditClusterComponent', () => {
],
providers: [
{provide: DatacenterService, useClass: DatacenterMockService},
{provide: MatDialog, useValue: {open: jest.fn().mockReturnValue({afterClosed: () => of(undefined)})}},
{provide: MatDialogRef, useClass: MatDialogRefMock},
{provide: ClusterService, useValue: clusterServiceMock},
{provide: AppConfigService, useClass: AppConfigMockService},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@

import {ChangeDetectorRef, Component, Input, OnDestroy, OnInit} from '@angular/core';
import {FormBuilder, FormControl, FormGroup, Validators} from '@angular/forms';
import {MatDialogRef} from '@angular/material/dialog';
import {MatDialog, MatDialogRef} from '@angular/material/dialog';
import {MatSelectChange} from '@angular/material/select';
import {ClusterBackupService} from '@app/core/services/cluster-backup';
import {UserClusterConfigService} from '@app/core/services/user-cluster-config';
import {DynamicModule} from '@app/dynamic/module-registry';
Expand Down Expand Up @@ -58,7 +59,7 @@ import {IPV4_IPV6_CIDR_PATTERN} from '@shared/validators/others';
import {KmValidators} from '@shared/validators/validators';
import _ from 'lodash';
import {Observable, Subject} from 'rxjs';
import {map, switchMap, take, takeUntil, tap} from 'rxjs/operators';
import {filter, map, switchMap, take, takeUntil, tap} from 'rxjs/operators';

enum Controls {
Name = 'name',
Expand Down Expand Up @@ -127,6 +128,7 @@ export class EditClusterComponent implements OnInit, OnDestroy {
enforcedAuditWebhookSettings: AuditLoggingWebhookBackend;
backupStorageLocationsList: BackupStorageLocation[];
backupStorageLocationLabel: BSLListState = BSLListState.Ready;
readonly createBackupStorageLocationOptionValue = '__create_backup_storage_location__';
isAllowedIPRangeSupported: boolean;
readonly isEnterpriseEdition = DynamicModule.isEnterpriseEdition;
readonly CLUSTER_DEFAULT_NODE_SELECTOR_NAMESPACE = CLUSTER_DEFAULT_NODE_SELECTOR_NAMESPACE;
Expand Down Expand Up @@ -157,6 +159,7 @@ export class EditClusterComponent implements OnInit, OnDestroy {
private readonly _builder: FormBuilder,
private readonly _clusterService: ClusterService,
private readonly _datacenterService: DatacenterService,
private readonly _matDialog: MatDialog,
private readonly _matDialogRef: MatDialogRef<EditClusterComponent>,
private readonly _notificationService: NotificationService,
private readonly _settingsService: SettingsService,
Expand Down Expand Up @@ -529,11 +532,48 @@ export class EditClusterComponent implements OnInit, OnDestroy {
.listBackupStorageLocation(projectID)
.pipe(takeUntil(this._unsubscribe))
.subscribe(cbslList => {
this.backupStorageLocationsList = cbslList;
this.backupStorageLocationLabel = cbslList.length ? BSLListState.Ready : BSLListState.Empty;
this.backupStorageLocationsList = cbslList.filter(bsl => this._isBackupStorageLocationAvailable(bsl));
this.backupStorageLocationLabel = this.backupStorageLocationsList.length
? BSLListState.Ready
: BSLListState.Empty;

const backupStorageLocationControl = this.form.get(Controls.BackupStorageLocation);
if (
backupStorageLocationControl &&
!this.backupStorageLocationsList.some(bsl => bsl.name === backupStorageLocationControl.value)
) {
backupStorageLocationControl.reset();
}
});
}

async onBackupStorageLocationSelectionChange(event: MatSelectChange<string>): Promise<void> {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Async/await is working fine, but since the rest of the component uses RxJS pipelines, I think it’s better to stay consistent with that approach.

if (event.value !== this.createBackupStorageLocationOptionValue) {
return;
}

const backupStorageLocationControl = this.form.get(Controls.BackupStorageLocation);
const previousValue = this.cluster.spec?.backupConfig?.backupStorageLocation?.name ?? '';
backupStorageLocationControl.setValue(previousValue, {emitEvent: false});

const AddBackupStorageLocationDialogComponent = await DynamicModule.AddBackupStorageLocationDialogComponent;
if (!AddBackupStorageLocationDialogComponent) {
return;
}
this._matDialog
.open(AddBackupStorageLocationDialogComponent, {
data: {projectID: this.projectID},
})
.afterClosed()
.pipe(take(1), filter(Boolean))
Comment on lines +558 to +568
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe something like this would be more consistent with the rest of the code.

Suggested change
const AddBackupStorageLocationDialogComponent = await DynamicModule.AddBackupStorageLocationDialogComponent;
if (!AddBackupStorageLocationDialogComponent) {
return;
}
this._matDialog
.open(AddBackupStorageLocationDialogComponent, {
data: {projectID: this.projectID},
})
.afterClosed()
.pipe(take(1), filter(Boolean))
from(DynamicModule.AddBackupStorageLocationDialogComponent)
.pipe(
filter(Boolean),
switchMap(component =>
this._matDialog.open(component, {data: {projectID: this.projectID}}).afterClosed()
),
take(1),
filter(Boolean),
)

.subscribe(() => this._getCBSL(this.projectID));
}

private _isBackupStorageLocationAvailable(bsl: BackupStorageLocation): boolean {
const status = bsl.status?.phase || bsl.spec?.status || '';
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i don't think we need to check for status cause it will never be available

Suggested change
const status = bsl.status?.phase || bsl.spec?.status || '';
return bsl.status?.phase.toLowerCase() === 'available'

return status.toLowerCase() === 'available';
}

getObservable(): Observable<Cluster> {
const patch: ClusterPatch = {
name: this.form.get(Controls.Name).value,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -391,22 +391,18 @@
</mat-checkbox>
}

@if (datacenter?.spec?.provider === NodeProvider.OPENSTACK) {
<mat-checkbox [formControlName]="Controls.RouterReconciliation">
Skip Router Reconciliation
</mat-checkbox>
}

@if (!!form.get(Controls.ClusterBackup).value && isclusterBackupEnabled) {
<mat-form-field class="bsl-field">
<mat-label>{{backupStorageLocationLabel}}</mat-label>
<mat-select [formControlName]="Controls.BackupStorageLocation"
(selectionChange)="onBackupStorageLocationSelectionChange($event)"
disableOptionCentering
kmValueChangedIndicator>
@for (bsl of backupStorageLocationsList; track bsl) {
<mat-option [value]="bsl.name">{{bsl.displayName}}
</mat-option>
}
<mat-option [value]="createBackupStorageLocationOptionValue">+ Create Backup Storage Location</mat-option>
</mat-select>
@if (form.get(Controls.BackupStorageLocation).hasError('required')) {
<mat-error>
Expand All @@ -416,6 +412,12 @@
</mat-form-field>
}

@if (datacenter?.spec?.provider === NodeProvider.OPENSTACK) {
<mat-checkbox [formControlName]="Controls.RouterReconciliation">
Skip Router Reconciliation
</mat-checkbox>
}

<km-label-form title="Labels"
(labelsChange)="onLabelsChange($event)"
[labels]="labels"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
// END OF TERMS AND CONDITIONS

import {Component, Inject, OnDestroy, OnInit} from '@angular/core';
import {FormBuilder, FormGroup, Validators} from '@angular/forms';
import {AbstractControl, FormBuilder, FormGroup, ValidationErrors, ValidatorFn, Validators} from '@angular/forms';
import {MAT_DIALOG_DATA, MatDialogRef} from '@angular/material/dialog';
import {ClusterBackupService} from '@app/core/services/cluster-backup';
import {NotificationService} from '@app/core/services/notification';
Expand Down Expand Up @@ -64,6 +64,7 @@ enum Controls {
})
export class AddBackupStorageLocationDialogComponent implements OnInit, OnDestroy {
private readonly _unsubscribe = new Subject<void>();
private readonly _dnsLabelRegex = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this can be change to include the . and then we don't need to use _dnsNameValidator() method.
we can add
export const DNS_VALIDATOR = Validators.pattern(/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/i)

to the shared/validators/others.ts file

readonly Controls = Controls;
readonly veleroChecksumAlgorithms = Object.values(VeleroChecksumAlgorithm);
form: FormGroup;
Expand Down Expand Up @@ -108,12 +109,18 @@ export class AddBackupStorageLocationDialogComponent implements OnInit, OnDestro
this._config.bslObject?.spec.backupSyncPeriod ?? '0',
CBSL_SYNC_PERIOD
),
[Controls.Region]: this._builder.control(this._config.bslObject?.spec.config?.region ?? ''),
[Controls.Endpoints]: this._builder.control(this._config.bslObject?.spec.config?.s3Url ?? ''),
[Controls.Region]: this._builder.control(this._config.bslObject?.spec.config?.region ?? '', [
this._dnsNameValidator(),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
this._dnsNameValidator(),
DNS_VALIDATOR,

]),
[Controls.Endpoints]: this._builder.control(this._config.bslObject?.spec.config?.s3Url ?? '', [
this._endpointURLValidator(),
]),
[Controls.ChecksumAlgorithm]: this._builder.control(this._config.bslObject?.spec.config?.checksumAlgorithm ?? ''),
[Controls.AddCustomConfig]: this._builder.control(false),
});

this._updateRegionAndEndpointValidators(this.form.get(Controls.AddCustomConfig).value);

if (this._config.bslObject) {
this.form.get(Controls.Name).disable();
} else {
Expand All @@ -132,6 +139,7 @@ export class AddBackupStorageLocationDialogComponent implements OnInit, OnDestro
.get(Controls.AddCustomConfig)
.valueChanges.pipe(takeUntil(this._unsubscribe))
.subscribe((value: boolean) => {
this._updateRegionAndEndpointValidators(value);
let config: BackupStorageLocationConfig;
if (this._config.bslObject?.name) {
config = this._config.bslObject.spec.config;
Expand Down Expand Up @@ -196,7 +204,7 @@ export class AddBackupStorageLocationDialogComponent implements OnInit, OnDestro
this.form.get(Controls.BackupSyncPeriod).value === '' ? null : this.form.get(Controls.BackupSyncPeriod).value,
config: {
region: this.form.get(Controls.Region).value,
s3Url: this.form.get(Controls.Endpoints).value,
s3Url: this.form.get(Controls.Endpoints).value?.trim(),
checksumAlgorithm: this.form.get(Controls.ChecksumAlgorithm).value || '',
},
provider: SupportedBSLProviders.AWS,
Expand All @@ -217,4 +225,64 @@ export class AddBackupStorageLocationDialogComponent implements OnInit, OnDestro
}
return bsl;
}

private _dnsNameValidator(): ValidatorFn {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We usually add validators in src/app/shared/validators

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We do have a file shared/validators/others.ts where have defined all regex in case needed in other place IMO, regex that is reusable can be put into this file

let me wdyt?

Copy link
Copy Markdown
Contributor

@ahmadhamzh ahmadhamzh Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

well after another look i don't think we need this method just to repeat the same regex check.
we can update the regex value to include the .

return (control: AbstractControl): ValidationErrors | null => {
const value = control.value?.trim();
if (!value) {
return null;
}

const labels = value.split('.');
return labels.every(label => this._dnsLabelRegex.test(label)) ? null : {invalidDnsName: true};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just to double confirm?

values like

  • us-east-1 ✅
  • us east 1 ❌
  • us-east-1- ❌
  • _us-east-1 ❌

are not valid DNS

};
}

private _endpointURLValidator(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const value = control.value?.trim();
if (!value) {
return null;
}

if (!/^https?:\/\//i.test(value)) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to double confirm if following values are not valid test value?

  • http://minio.local. ❌ (FQDN trailing dot rejected before)
  • https://[::1]:9000 (IPv6) ❌
  • https://s3_internal.lan:9000 (underscore host) ❌

return {invalidEndpointUrl: true};
}

try {
const url = new URL(value);
if (!['http:', 'https:'].includes(url.protocol)) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think if we have this check then we don't need the the previous condition if (!/^https?:\/\//i.test(value)) cause i think both do the same check, right ?

return {invalidEndpointUrl: true};
}

if (!url.hostname) {
return {invalidEndpointUrl: true};
}

const labels = url.hostname.split('.');
return labels.every(label => this._dnsLabelRegex.test(label)) ? null : {invalidEndpointUrl: true};
} catch {
return {invalidEndpointUrl: true};
}
};
}

private _updateRegionAndEndpointValidators(addCustomConfig: boolean): void {
const regionControl = this.form.get(Controls.Region);
const endpointControl = this.form.get(Controls.Endpoints);

const regionValidators = [this._dnsNameValidator()];
const endpointValidators = [this._endpointURLValidator()];

if (!addCustomConfig) {
regionValidators.unshift(Validators.required);
endpointValidators.unshift(Validators.required);
}

regionControl.setValidators(regionValidators);
endpointControl.setValidators(endpointValidators);

regionControl.updateValueAndValidity({emitEvent: false});
endpointControl.updateValueAndValidity({emitEvent: false});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
matInput
required>
<mat-hint>The bucket in which to store backups.</mat-hint>
@if (form.get(Controls.Name).hasError('required')) {
@if (form.get(Controls.Bucket).hasError('required')) {
<mat-error>
<strong>Required</strong>
</mat-error>
Expand All @@ -69,12 +69,14 @@
<input [formControlName]="Controls.AccessKeyId"
matInput
kmInputPassword>
<mat-hint>Optional. Leave empty for anonymous S3 access.</mat-hint>
</mat-form-field>
<mat-form-field>
<mat-label>Secret Access Key</mat-label>
<input [formControlName]="Controls.SecretAccessKey"
matInput
kmInputPassword>
<mat-hint>Optional. Leave empty for anonymous S3 access.</mat-hint>
</mat-form-field>
<mat-form-field subscriptSizing="dynamic">
<mat-label>Backup Sync Period</mat-label>
Expand Down Expand Up @@ -103,12 +105,32 @@
<input [formControlName]="Controls.Region"
matInput>
<mat-hint>The AWS region where the bucket is located.</mat-hint>
@if (form.get(Controls.Region).hasError('required')) {
<mat-error>
<strong>Required</strong>
</mat-error>
}
@if (form.get(Controls.Region).hasError('invalidDnsName')) {
<mat-error>
Region must be a valid DNS name.
</mat-error>
}
</mat-form-field>
<mat-form-field>
<mat-label>Endpoints</mat-label>
<mat-label>Endpoint URL</mat-label>
<input [formControlName]="Controls.Endpoints"
matInput>
<mat-hint>Specify the AWS S3 URL here.</mat-hint>
@if (form.get(Controls.Endpoints).hasError('required')) {
<mat-error>
<strong>Required</strong>
</mat-error>
}
@if (form.get(Controls.Endpoints).hasError('invalidEndpointUrl')) {
<mat-error>
Endpoint URL must start with http:// or https:// and contain a valid DNS host.
</mat-error>
}
</mat-form-field>
<mat-form-field>
<mat-label>Default Checksum Algorithm</mat-label>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {MatDialog, MatDialogConfig} from '@angular/material/dialog';
import {MatPaginator} from '@angular/material/paginator';
import {MatSort} from '@angular/material/sort';
import {MatTableDataSource} from '@angular/material/table';
import {MatTooltip} from '@angular/material/tooltip';
import {ProjectService} from '@app/core/services/project';
import {BackupStorageLocation, BackupType} from '@app/shared/entity/backup';
import {Project} from '@app/shared/entity/project';
Expand All @@ -46,6 +47,7 @@ import {DISABLED_TOOLTIP_MESSAGE} from '@app/shared/constants/common';
})
export class BackupStorageLocationsListComponent implements OnInit, OnDestroy {
private readonly _unsubscribe = new Subject<void>();
private readonly _copiedStatusMessageByBSL = new Map<string, boolean>();
private _selectedProject: Project;
private _user: Member;
private _currentGroupConfig: GroupConfig;
Expand Down Expand Up @@ -109,6 +111,25 @@ export class BackupStorageLocationsListComponent implements OnInit, OnDestroy {
return getClusterBackupHealthStatus(phase).icon;
}

getStatusTooltip(bsl: BackupStorageLocation): string {
return this._copiedStatusMessageByBSL.get(bsl.name) ? 'Error message copied' : bsl.status?.message;
}

canCopyStatusMessage(bsl: BackupStorageLocation): boolean {
return !this._isBSLAvailable(bsl) && !!bsl.status?.message;
}

copyStatusMessage(bsl: BackupStorageLocation, tooltip: MatTooltip): void {
if (!this.canCopyStatusMessage(bsl)) {
return;
}

navigator.clipboard.writeText(bsl.status.message);
this._copiedStatusMessageByBSL.set(bsl.name, true);
tooltip.message = 'Error message copied';
tooltip.show(0);
}

addBackupStorageLocation(): void {
const config: MatDialogConfig = {
data: {
Expand Down Expand Up @@ -173,4 +194,9 @@ export class BackupStorageLocationsListComponent implements OnInit, OnDestroy {
this.dataSource.data = this.backupStorageLocations;
});
}

private _isBSLAvailable(bsl: BackupStorageLocation): boolean {
const phase = bsl.status?.phase || '';
return phase.toLowerCase() === 'available';
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,11 @@
class="km-header-cell"></th>
<td mat-cell
*matCellDef="let element">
<i [matTooltip]="element.status?.message"
<i [matTooltip]="getStatusTooltip(element)"
#statusTooltip="matTooltip"
[ngClass]="getStatusIcon(element.status?.phase)"
[class.km-pointer]="canCopyStatusMessage(element)"
(click)="copyStatusMessage(element, statusTooltip)"
class="km-vertical-center"></i>
</td>
</ng-container>
Expand Down
Loading