i18n-emptyStateTitle
emptyStateMessage="Set up your first gateway group to start using NVMe over Fabrics. This will allow you to create high-performance block storage with NVMe/TCP protocol."
i18n-emptyStateMessage>
+ <cd-table-actions class="table-actions"
+ [permission]="permission"
+ [selection]="selection"
+ [tableActions]="tableActions">
+ </cd-table-actions>
</cd-table>
</ng-container>
gap="4">
@if (gateway.running > 0) {
- <span>
- <cd-icon type="success"></cd-icon>
+ <span [ngClass]="gateway.error > 0 ? 'cds-mr-3' : ''">
+ <cd-icon type="success"></cd-icon>
<span class="cds-ml-3">{{ gateway.running }}</span>
</span>
- }
+ }
- @if (gateway.error > 0) {
- <span class="cds-ml-3">
- <cd-icon type="error"></cd-icon>
+ @if (gateway.error > 0) {
+ <span>
+ <cd-icon type="error"></cd-icon>
<span class="cds-ml-3">{{ gateway.error }}</span>
</span>
- }
-
-
+ }
</div>
</ng-template>
+<ng-template #deleteTpl
+ let-groupName="groupName"
+ let-subsystemCount="subsystemCount">
+ @if (subsystemCount > 0) {
+ <cd-alert-panel type="warning"
+ spacingClass="cds-mb-3">
+ <h1 class="cds--type-heading-compact-01"
+ i18n>Cannot delete gateway group</h1>
+ <p class="cds--type-body-compact-01"
+ i18n>This gateway group includes dependent NVMe-oF resources. Remove the associated initiators and gateway targets before proceeding with deletion.</p>
+ </cd-alert-panel>
+ }
+</ng-template>
-import { Component, OnInit, TemplateRef, ViewChild } from '@angular/core';
+import { Component, OnInit, TemplateRef, ViewChild, ViewEncapsulation } from '@angular/core';
import { BehaviorSubject, forkJoin, Observable, of } from 'rxjs';
-import { catchError, map, switchMap } from 'rxjs/operators';
+import { catchError, map, switchMap, tap } from 'rxjs/operators';
import { GatewayGroup, NvmeofService } from '~/app/shared/api/nvmeof.service';
import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
import { TableComponent } from '~/app/shared/datatable/table/table.component';
import { Icons, IconSize } from '~/app/shared/enum/icons.enum';
import { NvmeofGatewayGroup } from '~/app/shared/models/nvmeof';
import { CephServiceSpec } from '~/app/shared/models/service.interface';
+import { ModalCdsService } from '~/app/shared/services/modal-cds.service';
+import { CephServiceService } from '~/app/shared/api/ceph-service.service';
+import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
+import { DeleteConfirmationModalComponent } from '~/app/shared/components/delete-confirmation-modal/delete-confirmation-modal.component';
+import { FinishedTask } from '~/app/shared/models/finished-task';
+import { DeletionImpact } from '~/app/shared/enum/delete-confirmation-modal-impact.enum';
+import { NotificationService } from '~/app/shared/services/notification.service';
+import { NotificationType } from '~/app/shared/enum/notification-type.enum';
@Component({
selector: 'cd-nvmeof-gateway-group',
templateUrl: './nvmeof-gateway-group.component.html',
styleUrls: ['./nvmeof-gateway-group.component.scss'],
- standalone: false
+ standalone: false,
+ encapsulation: ViewEncapsulation.None
})
export class NvmeofGatewayGroupComponent implements OnInit {
@ViewChild(TableComponent, { static: true })
@ViewChild('gatewayStatusTpl', { static: true })
gatewayStatusTpl: TemplateRef<any>;
+ @ViewChild('deleteTpl', { static: true })
+ deleteTpl: TemplateRef<any>;
+
permission: Permission;
tableActions: CdTableAction[];
columns: CdTableColumn[] = [];
constructor(
public actionLabels: ActionLabelsI18n,
private authStorageService: AuthStorageService,
- private nvmeofService: NvmeofService
+ private nvmeofService: NvmeofService,
+ public modalService: ModalCdsService,
+ private cephServiceService: CephServiceService,
+ public taskWrapper: TaskWrapperService,
+ private notificationService: NotificationService
) {}
ngOnInit(): void {
}
];
+ const createAction: CdTableAction = {
+ permission: 'create',
+ icon: Icons.add,
+ name: this.actionLabels.CREATE,
+ canBePrimary: (selection: CdTableSelection) => !selection.hasSelection
+ };
+
+ const deleteAction: CdTableAction = {
+ permission: 'delete',
+ icon: Icons.destroy,
+ click: () => this.deleteGatewayGroupModal(),
+ name: this.actionLabels.DELETE,
+ canBePrimary: (selection: CdTableSelection) => selection.hasMultiSelection
+ };
+ this.tableActions = [createAction, deleteAction];
this.gatewayGroup$ = this.subject.pipe(
switchMap(() =>
this.nvmeofService.listGatewayGroups().pipe(
switchMap((gatewayGroups: GatewayGroup[][]) => {
const groups = gatewayGroups?.[0] ?? [];
+ if (groups.length === 0) {
+ return of([]);
+ }
return forkJoin(
- groups.map((group: NvmeofGatewayGroup) =>
- this.nvmeofService.listSubsystems(group.spec.group).pipe(
- catchError(() => of([])),
+ groups.map((group: NvmeofGatewayGroup) => {
+ const isRunning = (group.status?.running ?? 0) > 0;
+ const subsystemsObservable = isRunning
+ ? this.nvmeofService
+ .listSubsystems(group.spec.group)
+ .pipe(catchError(() => of([])))
+ : of([]);
+
+ return subsystemsObservable.pipe(
map((subs) => ({
...group,
name: group.spec?.group,
running: group.status?.running ?? 0,
error: (group.status?.size ?? 0) - (group.status?.running ?? 0)
},
-
subSystemCount: Array.isArray(subs) ? subs.length : 0,
gateWayNode: group.placement?.hosts?.length ?? 0,
created: group.status?.created ? new Date(group.status.created) : null
}))
- )
- )
+ );
+ })
);
}),
catchError((error) => {
updateSelection(selection: CdTableSelection): void {
this.selection = selection;
}
+
+ deleteGatewayGroupModal() {
+ const selectedGroup = this.selection.first();
+ if (!selectedGroup) {
+ return;
+ }
+ const {
+ service_name: serviceName,
+ spec: { group }
+ } = selectedGroup;
+
+ const disableForm = selectedGroup.subSystemCount > 0 || !group;
+
+ this.modalService.show(DeleteConfirmationModalComponent, {
+ impact: DeletionImpact.high,
+ itemDescription: $localize`gateway group`,
+ bodyTemplate: this.deleteTpl,
+ itemNames: [selectedGroup.spec.group],
+ bodyContext: {
+ disableForm,
+ subsystemCount: selectedGroup.subSystemCount
+ },
+ submitActionObservable: () => {
+ return this.taskWrapper
+ .wrapTaskAroundCall({
+ task: new FinishedTask('nvmeof/gateway/delete', { group: selectedGroup.spec.group }),
+ call: this.cephServiceService.delete(serviceName)
+ })
+ .pipe(
+ tap(() => {
+ this.table.refreshBtn();
+ }),
+ catchError((error) => {
+ this.table.refreshBtn();
+ this.notificationService.show(
+ NotificationType.error,
+ $localize`${`Failed to delete gateway group ${selectedGroup.spec.group}: ${error.message}`}`
+ );
+ return of(null);
+ })
+ );
+ }
+ });
+ }
}
novalidate>
<cd-alert-panel *ngIf="infoMessage"
type="info"
- spacingClass="mb-3"
+ spacingClass="cds-mb-3"
i18n>
<p>{{ infoMessage }}</p>
</cd-alert-panel>
<ng-container *ngTemplateOutlet="bodyTemplate; context: bodyContext"></ng-container>
<div class="question">
<span *ngIf="itemNames; else noNames">
- <p *ngIf="itemNames.length === 1; else manyNames"
- i18n>Are you sure that you want to {{ actionDescription | lowercase }} <strong>{{ itemNames[0] }}</strong>?</p>
+ <ng-container *ngIf="itemNames.length === 1; else manyNames">
+ <p *ngIf="impact == impactEnum.medium"
+ i18n>Are you sure that you want to {{ actionDescription | lowercase }} <strong>{{ itemNames[0] }}</strong>?</p>
+ </ng-container>
<ng-template #manyNames>
<p i18n>Are you sure that you want to {{ actionDescription | lowercase }} the selected items?</p>
<ul>
<li *ngFor="let itemName of itemNames"><strong>{{ itemName }}</strong></li>
</ul>
- </ng-template >
+ </ng-template>
</span>
<ng-template #noNames>
- <p i18n>Are you sure that you want to {{ actionDescription | lowercase }} the selected {{ itemDescription }}?</p>
+ <p *ngIf="impact == impactEnum.medium"
+ i18n>Are you sure that you want to {{ actionDescription | lowercase }} the selected {{ itemDescription }}?</p>
</ng-template>
<ng-container *ngTemplateOutlet="childFormGroupTemplate; context:{form:deletionForm}"></ng-container>
<div class="form-item">
i18n>Yes, I am sure.</cds-checkbox>
</ng-container>
<ng-template #highImpactDeletion>
- <cds-text-label label="Resource Name"
+ <p i18n
+ class="cds--type-body-01">
+ Deleting <strong>{{ itemNames[0] }}</strong> will remove all associated {{itemDescription}} and may disrupt traffic routing for
+ services relying on it. This action cannot be undone.
+ </p>
+ <ng-template #labelTemplate>
+ <span i18n>Type <strong>{{ itemNames[0] }}</strong> to confirm (required)</span>
+ </ng-template>
+ <cds-text-label [labelTemplate]="labelTemplate"
labelInputID="resource_name"
- cdRequiredField="Resource Name"
- [invalid]="!deletionForm.controls.confirmInput.valid && deletionForm.controls.confirmInput.dirty"
+ [cdRequiredField]="'Type ' + itemNames[0] + ' to confirm'"
+ [invalid]="!deletionForm.controls.confirmInput.valid && deletionForm.controls.confirmInput.touched"
[invalidText]="ResourceError"
- i18n
- i18n-label>Resource Name
+ [disabled]="deletionForm.controls.confirmInput.disabled">
<input cdsText
type="text"
- placeholder="Enter resource name to delete"
+ [placeholder]="'Name of resource'"
id="resource_name"
formControlName="confirmInput"
- i18n-placeholder/>
+ [attr.disabled]="deletionForm.controls.confirmInput.disabled ? true : null"
+ i18n-placeholder
+ autofocus/>
</cds-text-label>
<ng-template #ResourceError>
- <span *ngIf="deletionForm.showError('confirmInput', formDir, 'required')"
+ <span *ngIf="deletionForm.controls.confirmInput.hasError('required')"
class="invalid-feedback">
<ng-container i18n>This field is required.</ng-container>
</span>
- <span *ngIf="deletionForm.showError('confirmInput', formDir, 'matchResource')"
+ <span *ngIf="deletionForm.controls.confirmInput.hasError('matchResource')"
class="invalid-feedback">
<ng-container i18n>Enter the correct resource name.</ng-container>
</span>
[form]="deletionForm"
[submitText]="(actionDescription | titlecase) + ' ' + itemDescription"
[modalForm]="true"
+ [disabled]="(submitDisabled$ | async) || deletionForm.disabled || (impact === impactEnum.high ? !deletionForm.controls.confirmInput.value : !deletionForm.controls.confirmation.value)"
[submitBtnType]="(actionDescription === 'delete' || actionDescription === 'remove') ? 'danger' : 'primary'"></cd-form-button-panel>
</cds-modal>
-
<ng-template #deletionHeading>
+ @if (impact === impactEnum.high) {
+ <h2 cdsModalHeaderLabel
+ i18n>
+ {{(actionDescription | titlecase) + ' ' + itemDescription}}
+ </h2>
+ <h2 class="cds--type-heading-03"
+ cdsModalHeaderHeading
+ i18n>
+ Confirm delete
+ </h2>
+ } @else {
<h3 cdsModalHeaderHeading
i18n>
- {{ actionDescription | titlecase }} {{ itemDescription }}
+ {{(actionDescription | titlecase) + ' ' + itemDescription}}
</h3>
+ }
</ng-template>
}
.modal-body label {
- font-weight: bold;
+ font-weight: normal;
}
.modal-body .question .form-check {
import { AlertPanelComponent } from '../alert-panel/alert-panel.component';
import { LoadingPanelComponent } from '../loading-panel/loading-panel.component';
import { DeleteConfirmationModalComponent } from './delete-confirmation-modal.component';
-import { ModalService, PlaceholderService } from 'carbon-components-angular';
+import { ModalService, PlaceholderService, CheckboxModule } from 'carbon-components-angular';
import { ModalCdsService } from '../../services/modal-cds.service';
import { DeletionImpact } from '../../enum/delete-confirmation-modal-impact.enum';
AlertPanelComponent
],
schemas: [NO_ERRORS_SCHEMA],
- imports: [ReactiveFormsModule, MockModule, DirectivesModule],
+ imports: [ReactiveFormsModule, MockModule, DirectivesModule, CheckboxModule],
providers: [
ModalService,
PlaceholderService,
import { Component, Inject, OnInit, Optional, TemplateRef, ViewChild } from '@angular/core';
import { UntypedFormControl, AbstractControl, ValidationErrors, Validators } from '@angular/forms';
-import { Observable } from 'rxjs';
+import { Observable, of } from 'rxjs';
+import { map, startWith } from 'rxjs/operators';
import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
import { SubmitButtonComponent } from '../submit-button/submit-button.component';
import { BaseModal } from 'carbon-components-angular';
import { CdValidators } from '../../forms/cd-validators';
import { DeletionImpact } from '../../enum/delete-confirmation-modal-impact.enum';
+import { DeleteConfirmationBodyContext } from '../../models/delete-confirmation.model';
@Component({
selector: 'cd-deletion-modal',
impactEnum = DeletionImpact;
childFormGroup: CdFormGroup;
childFormGroupTemplate: TemplateRef<any>;
-
+ submitDisabled$: Observable<boolean> = of(false);
constructor(
@Optional() @Inject('impact') public impact: DeletionImpact,
@Optional() @Inject('itemDescription') public itemDescription: 'entry',
@Optional() @Inject('submitAction') public submitAction?: Function,
@Optional() @Inject('backAction') public backAction?: Function,
@Optional() @Inject('bodyTemplate') public bodyTemplate?: TemplateRef<any>,
- @Optional() @Inject('bodyContext') public bodyContext?: object,
+ @Optional() @Inject('subHeading') public subHeading?: string,
+ @Optional()
+ @Inject('bodyContext')
+ public bodyContext?: DeleteConfirmationBodyContext,
@Optional() @Inject('infoMessage') public infoMessage?: string,
@Optional()
@Inject('submitActionObservable')
)
]
}),
- confirmInput: new UntypedFormControl('', [
- CdValidators.composeIf({ impact: this.impactEnum.high }, [
- this.matchResourceName.bind(this),
- Validators.required
- ])
- ])
+ confirmInput: new UntypedFormControl('', {
+ validators: [
+ CdValidators.composeIf({ impact: this.impactEnum.high }, [
+ this.matchResourceName.bind(this),
+ Validators.required
+ ])
+ ]
+ })
};
+
if (this.childFormGroup) {
controls['child'] = this.childFormGroup;
}
if (!(this.submitAction || this.submitActionObservable)) {
throw new Error('No submit action defined');
}
+ if (this.bodyContext?.disableForm) {
+ this.toggleFormControls(this.bodyContext?.disableForm);
+ return;
+ }
+
+ if (this.impact === this.impactEnum.high && this.itemNames?.[0]) {
+ const target = String(this.itemNames[0]);
+ const confirmControl = this.deletionForm.controls.confirmInput;
+
+ this.submitDisabled$ = confirmControl.valueChanges.pipe(
+ startWith(confirmControl.value),
+ map((value: string) => value !== target)
+ );
+ }
}
matchResourceName(control: AbstractControl): ValidationErrors | null {
+ if (!control.value) {
+ return null;
+ }
+
if (this.itemNames && control.value !== String(this.itemNames?.[0])) {
return { matchResource: true };
}
stopLoadingSpinner() {
this.deletionForm.setErrors({ cdSubmitButton: true });
}
+
+ toggleFormControls(disableForm = false) {
+ if (disableForm) {
+ this.deletionForm.disable();
+ this.deletionForm.setErrors({ disabledByContext: true });
+ this.submitDisabled$ = of(true);
+ } else {
+ this.deletionForm.enable();
+ this.deletionForm.setErrors(null);
+ }
+ }
}
--- /dev/null
+export interface DeleteConfirmationBodyContext {
+ warningMessage?: string;
+ disableForm?: boolean;
+ inputLabel?: string;
+ inputPlaceholder?: string;
+}
this.iscsiTarget(metadata)
),
// nvmeof
+ 'nvmeof/gateway/delete': this.newTaskMessage(this.commonOperations.delete, (metadata) =>
+ this.nvmeofGateway(metadata)
+ ),
'nvmeof/subsystem/create': this.newTaskMessage(this.commonOperations.create, (metadata) =>
this.nvmeofSubsystem(metadata)
),
nvmeofSubsystem(metadata: any) {
return $localize`subsystem '${metadata.nqn}'`;
}
-
+ nvmeofGateway(metadata: any) {
+ return $localize`Gateway group '${metadata.group}'`;
+ }
nvmeofListener(metadata: any) {
return $localize`listener '${metadata.host_name} for subsystem ${metadata.nqn}`;
}
.cds-ml-3 {
margin-left: layout.$spacing-03;
}
+
+.cds-mr-3 {
+ margin-right: layout.$spacing-03;
+}
+
+.cds-mb-2 {
+ margin-bottom: layout.$spacing-02;
+}
+
+.cds-mb-3 {
+ margin-bottom: layout.$spacing-03;
+}