Allows listing the subsystems per gateway group.
Using carbon combobox component for the selector.
Adds unit tests for switcher and updates existing.
Fixes https://tracker.ceph.com/issues/68129
Signed-off-by: Afreen Misbah <afreen23.git@gmail.com>
import {
ButtonModule,
CheckboxModule,
+ ComboBoxModule,
DatePickerModule,
GridModule,
IconModule,
SelectModule,
NumberModule,
ModalModule,
- DatePickerModule
+ DatePickerModule,
+ ComboBoxModule
],
declarations: [
RbdListComponent,
import { CdValidators } from '~/app/shared/forms/cd-validators';
import { DimlessBinaryPipe } from '~/app/shared/pipes/dimless-binary.pipe';
import { HostService } from '~/app/shared/api/host.service';
-import { DaemonService } from '~/app/shared/api/daemon.service';
import { map } from 'rxjs/operators';
import { forkJoin } from 'rxjs';
+import { CephServiceSpec } from '~/app/shared/models/service.interface';
@Component({
selector: 'cd-nvmeof-listeners-form',
listenerForm: CdFormGroup;
subsystemNQN: string;
hosts: Array<object> = null;
+ group: string;
constructor(
public actionLabels: ActionLabelsI18n,
private route: ActivatedRoute,
public activeModal: NgbActiveModal,
public formatterService: FormatterService,
- public dimlessBinaryPipe: DimlessBinaryPipe,
- private daemonService: DaemonService
+ public dimlessBinaryPipe: DimlessBinaryPipe
) {
this.permission = this.authStorageService.getPermissions().nvmeof;
this.hostPermission = this.authStorageService.getPermissions().hosts;
setHosts() {
forkJoin({
- daemons: this.daemonService.list(['nvmeof']),
+ gwGroups: this.nvmeofService.listGatewayGroups(),
hosts: this.hostService.getAllHosts()
})
.pipe(
- map(({ daemons, hosts }) => {
- const hostNamesFromDaemon = daemons.map((daemon: any) => daemon.hostname);
- return hosts.filter((host: any) => hostNamesFromDaemon.includes(host.hostname));
+ map(({ gwGroups, hosts }) => {
+ // Find the gateway hosts in current group
+ const selectedGwGroup: CephServiceSpec = gwGroups?.[0]?.find(
+ (gwGroup: CephServiceSpec) => gwGroup?.spec?.group === this.group
+ );
+ const gatewayHosts: string[] = selectedGwGroup?.placement?.hosts;
+ // Return the gateway hosts in current group with their metadata
+ return gatewayHosts
+ ? hosts.filter((host: any) => gatewayHosts.includes(host.hostname))
+ : [];
})
)
.subscribe((nvmeofHosts: any[]) => {
this.createForm();
this.action = this.actionLabels.CREATE;
this.route.params.subscribe((params: { subsystem_nqn: string }) => {
- this.subsystemNQN = params.subsystem_nqn;
+ this.subsystemNQN = params?.subsystem_nqn;
+ });
+ this.route.queryParams.subscribe((params) => {
+ this.group = params?.['group'];
});
this.setHosts();
}
component.listenerForm.setErrors({ cdSubmitButton: true });
},
complete: () => {
- this.router.navigate([this.pageURL, { outlets: { modal: null } }]);
+ this.router.navigate([this.pageURL, { outlets: { modal: null } }], {
+ queryParams: { group: this.group }
+ });
}
});
}
export class NvmeofListenersListComponent implements OnInit, OnChanges {
@Input()
subsystemNQN: string;
+ @Input()
+ group: string;
listenerColumns: any;
tableActions: CdTableAction[];
permission: 'create',
icon: Icons.add,
click: () =>
- this.router.navigate([
- BASE_URL,
- { outlets: { modal: [URLVerbs.CREATE, this.subsystemNQN, 'listener'] } }
- ]),
+ this.router.navigate(
+ [BASE_URL, { outlets: { modal: [URLVerbs.CREATE, this.subsystemNQN, 'listener'] } }],
+ { queryParams: { group: this.group } }
+ ),
canBePrimary: (selection: CdTableSelection) => !selection.hasSelection
},
{
<a ngbNavLink
i18n>Listeners</a>
<ng-template ngbNavContent>
- <cd-nvmeof-listeners-list [subsystemNQN]="subsystemNQN">
+ <cd-nvmeof-listeners-list [subsystemNQN]="subsystemNQN"
+ [group]="group">
</cd-nvmeof-listeners-list>
</ng-template>
</ng-container>
export class NvmeofSubsystemsDetailsComponent implements OnChanges {
@Input()
selection: NvmeofSubsystem;
+ @Input()
+ group: NvmeofSubsystem;
selectedItem: any;
data: any;
let form: CdFormGroup;
let formHelper: FormHelper;
const mockTimestamp = 1720693470789;
+ const mockGroupName = 'default';
beforeEach(async () => {
spyOn(Date, 'now').and.returnValue(mockTimestamp);
form = component.subsystemForm;
formHelper = new FormHelper(form);
fixture.detectChanges();
+ component.group = mockGroupName;
});
it('should create', () => {
expect(nvmeofService.createSubsystem).toHaveBeenCalledWith({
nqn: expectedNqn,
max_namespaces: MAX_NAMESPACE,
- enable_ha: true
+ enable_ha: true,
+ gw_group: mockGroupName
});
});
import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
import { FinishedTask } from '~/app/shared/models/finished-task';
-import { Router } from '@angular/router';
+import { ActivatedRoute, Router } from '@angular/router';
import { MAX_NAMESPACE, NvmeofService } from '~/app/shared/api/nvmeof.service';
@Component({
resource: string;
pageURL: string;
defaultMaxNamespace: number = MAX_NAMESPACE;
+ group: string;
constructor(
private authStorageService: AuthStorageService,
public activeModal: NgbActiveModal,
private nvmeofService: NvmeofService,
private taskWrapperService: TaskWrapperService,
- private router: Router
+ private router: Router,
+ private route: ActivatedRoute
) {
this.permission = this.authStorageService.getPermissions().nvmeof;
this.resource = $localize`Subsystem`;
);
ngOnInit() {
+ this.route.queryParams.subscribe((params) => {
+ this.group = params?.['group'];
+ });
this.createForm();
this.action = this.actionLabels.CREATE;
}
)
],
asyncValidators: [
- CdValidators.unique(this.nvmeofService.isSubsystemPresent, this.nvmeofService)
+ CdValidators.unique(
+ this.nvmeofService.isSubsystemPresent,
+ this.nvmeofService,
+ null,
+ null,
+ this.group
+ )
]
}),
max_namespaces: new UntypedFormControl(this.defaultMaxNamespace, {
const request = {
nqn,
- max_namespaces,
- enable_ha: true
+ enable_ha: true,
+ gw_group: this.group,
+ max_namespaces
};
if (!max_namespaces) {
component.subsystemForm.setErrors({ cdSubmitButton: true });
},
complete: () => {
- this.router.navigate([this.pageURL, { outlets: { modal: null } }]);
+ this.router.navigate([this.pageURL, { outlets: { modal: null } }], {
+ queryParams: { group: this.group }
+ });
}
});
}
<cd-nvmeof-tabs></cd-nvmeof-tabs>
+
+<div class="pb-3"
+ cdsCol
+ [columnNumbers]="{md: 4}">
+ <cds-combo-box
+ type="single"
+ label="Selected Gateway Group"
+ i18n-placeholder
+ placeholder="Enter group"
+ [items]="gwGroups"
+ (selected)="onGroupSelection($event)"
+ (clear)="onGroupClear()">
+ <cds-dropdown-list></cds-dropdown-list>
+ </cds-combo-box>
+</div>
+
<legend i18n>
Subsystems
<cd-help-text>
</div>
<cd-nvmeof-subsystems-details *cdTableDetail
- [selection]="expandedRow">
+ [selection]="expandedRow"
+ [group]="group">
</cd-nvmeof-subsystems-details>
</cd-table>
<router-outlet name="modal"></router-outlet>
import { NvmeofSubsystemsComponent } from './nvmeof-subsystems.component';
import { NvmeofTabsComponent } from '../nvmeof-tabs/nvmeof-tabs.component';
import { NvmeofSubsystemsDetailsComponent } from '../nvmeof-subsystems-details/nvmeof-subsystems-details.component';
+import { ComboBoxModule, GridModule } from 'carbon-components-angular';
const mockSubsystems = [
{
}
];
+const mockGroups = [
+ [
+ {
+ service_name: 'nvmeof.rbd.default',
+ service_type: 'nvmeof',
+ unmanaged: false,
+ spec: {
+ group: 'default'
+ }
+ },
+ {
+ service_name: 'nvmeof.rbd.foo',
+ service_type: 'nvmeof',
+ unmanaged: false,
+ spec: {
+ group: 'foo'
+ }
+ }
+ ],
+ 2
+];
+
class MockNvmeOfService {
listSubsystems() {
return of(mockSubsystems);
}
+
+ listGatewayGroups() {
+ return of(mockGroups);
+ }
}
class MockAuthStorageService {
NvmeofTabsComponent,
NvmeofSubsystemsDetailsComponent
],
- imports: [HttpClientModule, RouterTestingModule, SharedModule],
+ imports: [HttpClientModule, RouterTestingModule, SharedModule, ComboBoxModule, GridModule],
providers: [
{ provide: NvmeofService, useClass: MockNvmeOfService },
{ provide: AuthStorageService, useClass: MockAuthStorageService },
tick();
expect(component.subsystems).toEqual(mockSubsystems);
}));
+
+ it('should load gateway groups correctly', () => {
+ expect(component.gwGroups.length).toBe(2);
+ });
+
+ it('should set first group as default initially', () => {
+ expect(component.group).toBe(mockGroups[0][0].spec.group);
+ });
});
import { Component, OnInit } from '@angular/core';
-import { Router } from '@angular/router';
+import { ActivatedRoute, Router } from '@angular/router';
import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants';
import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
import { NvmeofService } from '~/app/shared/api/nvmeof.service';
import { ModalCdsService } from '~/app/shared/services/modal-cds.service';
+import { CephServiceSpec } from '~/app/shared/models/service.interface';
+
+type ComboBoxItem = {
+ content: string;
+ selected?: boolean;
+};
const BASE_URL = 'block/nvmeof/subsystems';
selection = new CdTableSelection();
tableActions: CdTableAction[];
subsystemDetails: any[];
+ gwGroups: ComboBoxItem[] = [];
+ group: string = null;
constructor(
private nvmeofService: NvmeofService,
public actionLabels: ActionLabelsI18n,
private router: Router,
private modalService: ModalCdsService,
- private taskWrapper: TaskWrapperService
+ private taskWrapper: TaskWrapperService,
+ private route: ActivatedRoute
) {
super();
this.permission = this.authStorageService.getPermissions().nvmeof;
}
ngOnInit() {
+ this.route.queryParams.subscribe((params) => {
+ if (params?.['group']) this.onGroupSelection({ content: params?.['group'] });
+ });
+ this.getGatewayGroups();
this.subsystemsColumns = [
{
name: $localize`NQN`,
name: this.actionLabels.CREATE,
permission: 'create',
icon: Icons.add,
- click: () => this.router.navigate([BASE_URL, { outlets: { modal: [URLVerbs.CREATE] } }]),
+ click: () =>
+ this.router.navigate([BASE_URL, { outlets: { modal: [URLVerbs.CREATE] } }], {
+ queryParams: { group: this.group }
+ }),
canBePrimary: (selection: CdTableSelection) => !selection.hasSelection
},
{
];
}
+ // Subsystems
updateSelection(selection: CdTableSelection) {
this.selection = selection;
}
getSubsystems() {
this.nvmeofService
- .listSubsystems()
+ .listSubsystems(this.group)
.subscribe((subsystems: NvmeofSubsystem[] | NvmeofSubsystem) => {
if (Array.isArray(subsystems)) this.subsystems = subsystems;
else this.subsystems = [subsystems];
submitActionObservable: () =>
this.taskWrapper.wrapTaskAroundCall({
task: new FinishedTask('nvmeof/subsystem/delete', { nqn: subsystem.nqn }),
- call: this.nvmeofService.deleteSubsystem(subsystem.nqn)
+ call: this.nvmeofService.deleteSubsystem(subsystem.nqn, this.group)
})
});
}
+
+ // Gateway groups
+ onGroupSelection(selected: ComboBoxItem) {
+ selected.selected = true;
+ this.group = selected.content;
+ this.getSubsystems();
+ }
+
+ onGroupClear() {
+ this.group = null;
+ this.getSubsystems();
+ }
+
+ getGatewayGroups() {
+ this.nvmeofService.listGatewayGroups().subscribe((response: CephServiceSpec[][]) => {
+ if (response?.[0].length) {
+ this.gwGroups = response[0].map((group: CephServiceSpec) => {
+ return {
+ content: group?.spec?.group
+ };
+ });
+ }
+ // Select first group if no group is selected
+ if (!this.group && this.gwGroups.length) this.onGroupSelection(this.gwGroups[0]);
+ });
+ }
}
describe('NvmeofService', () => {
let service: NvmeofService;
let httpTesting: HttpTestingController;
+ const mockGroupName = 'default';
+ const mockNQN = 'nqn.2001-07.com.ceph:1721041732363';
configureTestBed({
providers: [NvmeofService],
expect(service).toBeTruthy();
});
+ it('should call listGatewayGroups', () => {
+ service.listGatewayGroups().subscribe();
+ const req = httpTesting.expectOne('api/nvmeof/gateway/group');
+ expect(req.request.method).toBe('GET');
+ });
+
it('should call listGateways', () => {
service.listGateways().subscribe();
const req = httpTesting.expectOne('api/nvmeof/gateway');
expect(req.request.method).toBe('GET');
});
+ it('should call listSubsystems', () => {
+ service.listSubsystems(mockGroupName).subscribe();
+ const req = httpTesting.expectOne(`api/nvmeof/subsystem?gw_group=${mockGroupName}`);
+ expect(req.request.method).toBe('GET');
+ });
+
it('should call getSubsystem', () => {
- service.getSubsystem('nqn.2001-07.com.ceph:1721041732363').subscribe();
- const req = httpTesting.expectOne('api/nvmeof/subsystem/nqn.2001-07.com.ceph:1721041732363');
+ service.getSubsystem(mockNQN, mockGroupName).subscribe();
+ const req = httpTesting.expectOne(`api/nvmeof/subsystem/${mockNQN}?gw_group=${mockGroupName}`);
expect(req.request.method).toBe('GET');
});
it('should call createSubsystem', () => {
const request = {
- nqn: 'nqn.2001-07.com.ceph:1721041732363',
+ nqn: mockNQN,
enable_ha: true,
- initiators: '*'
+ initiators: '*',
+ gw_group: mockGroupName
};
service.createSubsystem(request).subscribe();
const req = httpTesting.expectOne('api/nvmeof/subsystem');
expect(req.request.method).toBe('POST');
});
+ it('should call deleteSubsystem', () => {
+ service.deleteSubsystem(mockNQN, mockGroupName).subscribe();
+ const req = httpTesting.expectOne(`api/nvmeof/subsystem/${mockNQN}?gw_group=${mockGroupName}`);
+ expect(req.request.method).toBe('DELETE');
+ });
+
it('should call getInitiators', () => {
- service.getInitiators('nqn.2001-07.com.ceph:1721041732363').subscribe();
- const req = httpTesting.expectOne(
- 'api/nvmeof/subsystem/nqn.2001-07.com.ceph:1721041732363/host'
- );
+ service.getInitiators(mockNQN).subscribe();
+ const req = httpTesting.expectOne(`api/nvmeof/subsystem/${mockNQN}/host`);
expect(req.request.method).toBe('GET');
});
});
export class NvmeofService {
constructor(private http: HttpClient) {}
+ // Gateway groups
+ listGatewayGroups() {
+ return this.http.get(`${API_PATH}/gateway/group`);
+ }
+
// Gateways
listGateways() {
return this.http.get(`${API_PATH}/gateway`);
}
// Subsystems
- listSubsystems() {
- return this.http.get(`${API_PATH}/subsystem`);
+ listSubsystems(group: string) {
+ return this.http.get(`${API_PATH}/subsystem?gw_group=${group}`);
}
- getSubsystem(subsystemNQN: string) {
- return this.http.get(`${API_PATH}/subsystem/${subsystemNQN}`);
+ getSubsystem(subsystemNQN: string, group: string) {
+ return this.http.get(`${API_PATH}/subsystem/${subsystemNQN}?gw_group=${group}`);
}
- createSubsystem(request: { nqn: string; max_namespaces?: number; enable_ha: boolean }) {
+ createSubsystem(request: {
+ nqn: string;
+ enable_ha: boolean;
+ gw_group: string;
+ max_namespaces?: number;
+ }) {
return this.http.post(`${API_PATH}/subsystem`, request, { observe: 'response' });
}
- deleteSubsystem(subsystemNQN: string) {
- return this.http.delete(`${API_PATH}/subsystem/${subsystemNQN}`, {
+ deleteSubsystem(subsystemNQN: string, group: string) {
+ return this.http.delete(`${API_PATH}/subsystem/${subsystemNQN}?gw_group=${group}`, {
observe: 'response'
});
}
- isSubsystemPresent(subsystemNqn: string): Observable<boolean> {
- return this.getSubsystem(subsystemNqn).pipe(
+ isSubsystemPresent(subsystemNqn: string, group: string): Observable<boolean> {
+ return this.getSubsystem(subsystemNqn, group).pipe(
mapTo(true),
catchError((e) => {
e?.preventDefault();