Skip to content

Commit

Permalink
NAS-131139 / 25.04 / Updates to Running toggle in services (#10695)
Browse files Browse the repository at this point in the history
  • Loading branch information
undsoft authored Sep 19, 2024
1 parent 42f0d70 commit db283e7
Show file tree
Hide file tree
Showing 96 changed files with 709 additions and 396 deletions.
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { HarnessLoader } from '@angular/cdk/testing';
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { MatSlideToggle } from '@angular/material/slide-toggle';
import { MatSlideToggleHarness } from '@angular/material/slide-toggle/testing';
import { Spectator } from '@ngneat/spectator';
import { createComponentFactory } from '@ngneat/spectator/jest';
Expand Down Expand Up @@ -38,7 +39,8 @@ describe('IxCellToggleComponent', () => {
await toggle.toggle();
expect(await toggle.isChecked()).toBe(false);

expect(spectator.component.onRowToggle).toHaveBeenCalledWith({ booleanField: true }, false);
expect(spectator.component.onRowToggle)
.toHaveBeenCalledWith({ booleanField: true }, false, expect.any(MatSlideToggle));
});

it('gets aria label correctly', async () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { MatSlideToggleChange } from '@angular/material/slide-toggle';
import { MatSlideToggle, MatSlideToggleChange } from '@angular/material/slide-toggle';
import { Observable } from 'rxjs';
import { Role } from 'app/enums/role.enum';
import { Column, ColumnComponent } from 'app/modules/ix-table/interfaces/column-component.class';
Expand All @@ -11,15 +11,15 @@ import { Column, ColumnComponent } from 'app/modules/ix-table/interfaces/column-
})
export class IxCellToggleComponent<T> extends ColumnComponent<T> {
requiredRoles: Role[];
onRowToggle: (row: T, checked: boolean) => void;
onRowToggle: (row: T, checked: boolean, toggle: MatSlideToggle) => void;
dynamicRequiredRoles: (row: T) => Observable<Role[]>;

get checked(): boolean {
return this.value as boolean;
}

onSlideToggleChanged(event: MatSlideToggleChange): void {
this.onRowToggle(this.row(), event.checked);
this.onRowToggle(this.row(), event.checked, event.source);
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!-- TODO: This is essentially ix-cell-toggle, but it's hard to reuse the component here. -->
@if (service()) {
<mat-slide-toggle
*ixRequiresRoles="requiredRoles()"
color="primary"
[aria-label]="isRunning() ? ('Stop service' | translate) : ('Start service' | translate)"
[ixTest]="['service', 'running', service().name, 'toggle']"
[checked]="isRunning()"
(change)="onSlideToggleChanged($event)"
(click)="$event.stopPropagation()"
></mat-slide-toggle>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { MatSlideToggleHarness } from '@angular/material/slide-toggle/testing';
import { createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest';
import { of } from 'rxjs';
import { mockAuth } from 'app/core/testing/utils/mock-auth.utils';
import { mockCall, mockWebSocket } from 'app/core/testing/utils/mock-websocket.utils';
import { ServiceName } from 'app/enums/service-name.enum';
import { ServiceStatus } from 'app/enums/service-status.enum';
import { IscsiGlobalSession } from 'app/interfaces/iscsi-global-config.interface';
import { ServiceRow } from 'app/interfaces/service.interface';
import { DialogService } from 'app/modules/dialog/dialog.service';
import {
ServiceStateColumnComponent,
} from 'app/pages/services/components/service-state-column/service-state-column.component';
import { IscsiService } from 'app/services/iscsi.service';
import { WebSocketService } from 'app/services/ws.service';

describe('ServiceStateColumnComponent', () => {
let spectator: Spectator<ServiceStateColumnComponent>;
let toggle: MatSlideToggleHarness;
const service = {
service: ServiceName.Cifs,
state: ServiceStatus.Running,
} as ServiceRow;
const createComponent = createComponentFactory({
component: ServiceStateColumnComponent,
providers: [
mockProvider(IscsiService, {
getGlobalSessions: jest.fn(() => of([])),
}),
mockWebSocket([
mockCall('service.start', true),
mockCall('service.stop', true),
]),
mockProvider(DialogService, {
confirm: jest.fn(() => of(true)),
}),
mockAuth(),
],
});

beforeEach(async () => {
spectator = createComponent();
spectator.component.setRow(service);

const loader = TestbedHarnessEnvironment.loader(spectator.fixture);
toggle = await loader.getHarness(MatSlideToggleHarness);
});

it('shows whether service is currently running or not', async () => {
expect(await toggle.isChecked()).toBe(true);

spectator.component.setRow({ ...service, state: ServiceStatus.Stopped });
expect(await toggle.isChecked()).toBe(false);
});

describe('stopping a service', () => {
it('asks for confirmation when user tries to stop a service', async () => {
await toggle.toggle();

expect(spectator.inject(DialogService).confirm).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Stop SMB?',
}),
);
});

it('asks for a different confirmation when user tries to stop iSCSI and there are active sessions', async () => {
spectator
.inject(IscsiService)
.getGlobalSessions
.mockReturnValueOnce(of([{ target: '123' }] as IscsiGlobalSession[]));

spectator.component.setRow({ ...service, service: ServiceName.Iscsi });

await toggle.toggle();

expect(spectator.inject(DialogService).confirm).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining('There is an active iSCSI connection.'),
}),
);
});

it('stops the service when user confirms', async () => {
await toggle.toggle();

expect(spectator.inject(WebSocketService).call).toHaveBeenCalledWith('service.stop', [service.service, { silent: false }]);
});
});

describe('starting a service', () => {
it('starts the service when user changes toggle on a non-running service', async () => {
spectator.component.setRow({ ...service, state: ServiceStatus.Stopped });

await toggle.toggle();

expect(spectator.inject(WebSocketService).call).toHaveBeenCalledWith('service.start', [service.service, { silent: false }]);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import {
ChangeDetectionStrategy, Component, computed, inject,
} from '@angular/core';
import { MatSlideToggle, MatSlideToggleChange } from '@angular/material/slide-toggle';
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
import { TranslateService } from '@ngx-translate/core';
import { Observable } from 'rxjs';
import { filter, switchMap, tap } from 'rxjs/operators';
import { ServiceName, serviceNames } from 'app/enums/service-name.enum';
import { ServiceStatus } from 'app/enums/service-status.enum';
import { ServiceRow } from 'app/interfaces/service.interface';
import { DialogService } from 'app/modules/dialog/dialog.service';
import { ColumnComponent } from 'app/modules/ix-table/interfaces/column-component.class';
import { AppLoaderService } from 'app/modules/loader/app-loader.service';
import { SnackbarService } from 'app/modules/snackbar/services/snackbar.service';
import { ErrorHandlerService } from 'app/services/error-handler.service';
import { IscsiService } from 'app/services/iscsi.service';
import { ServicesService } from 'app/services/services.service';
import { WebSocketService } from 'app/services/ws.service';

@UntilDestroy()
@Component({
selector: 'ix-service-state-column',
templateUrl: './service-state-column.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ServiceStateColumnComponent extends ColumnComponent<ServiceRow> {
protected service = computed(() => this.row());

protected readonly requiredRoles = computed(() => {
return this.servicesService.getRolesRequiredToManage(this.service().service);
});

protected readonly isRunning = computed(() => this.service().state === ServiceStatus.Running);

private servicesService = inject(ServicesService);
private ws = inject(WebSocketService);
private dialogService = inject(DialogService);
private translate = inject(TranslateService);
private loader = inject(AppLoaderService);
private errorHandler = inject(ErrorHandlerService);
private snackbar = inject(SnackbarService);
private iscsiService = inject(IscsiService);

private serviceName = computed(() => {
return serviceNames.has(this.service().service)
? serviceNames.get(this.service().service)
: this.service().service;
});

protected onSlideToggleChanged(event: MatSlideToggleChange): void {
const toggle = event.source;

if (this.isRunning()) {
this.confirmStop().pipe(
tap((confirmed) => {
if (!confirmed) {
toggle.checked = true;
}
}),
filter(Boolean),
untilDestroyed(this),
).subscribe(() => {
this.stopService(toggle);
});
} else {
this.startService(toggle);
}
}

private confirmStop(): Observable<boolean> {
if (this.service().service === ServiceName.Iscsi) {
return this.confirmStopIscsiService();
}

const serviceName = this.serviceName();
return this.dialogService.confirm({
title: this.translate.instant('Alert'),
message: this.translate.instant('Stop {serviceName}?', { serviceName }),
hideCheckbox: true,
buttonText: this.translate.instant('Stop'),
});
}

private stopService(toggle: MatSlideToggle): void {
this.ws.call('service.stop', [this.service().service, { silent: false }]).pipe(
this.loader.withLoader(),
untilDestroyed(this),
).subscribe({
next: () => this.snackbar.success(this.translate.instant('Service stopped')),
error: (error) => {
this.errorHandler.showErrorModal(error);
toggle.checked = true;
},
});
}

private startService(toggle: MatSlideToggle): void {
this.ws.call('service.start', [this.service().service, { silent: false }]).pipe(
this.loader.withLoader(),
untilDestroyed(this),
).subscribe({
next: () => this.snackbar.success(this.translate.instant('Service started')),
error: (error) => {
this.errorHandler.showErrorModal(error);
toggle.checked = false;
},
});
}

private confirmStopIscsiService(): Observable<boolean> {
const serviceName = this.serviceName();

return this.iscsiService.getGlobalSessions().pipe(
switchMap((sessions) => {
let message = this.translate.instant('Stop {serviceName}?', { serviceName });
if (sessions.length) {
const connectionsMessage = this.translate.instant('{n, plural, one {There is an active iSCSI connection.} other {There are # active iSCSI connections}}', { n: sessions.length });
const stopMessage = this.translate.instant('Stop the {serviceName} service and close these connections?', { serviceName });
message = `<font color="red">${connectionsMessage}</font><br>${stopMessage}`;
}

return this.dialogService.confirm({
title: this.translate.instant('Alert'),
message,
hideCheckbox: true,
buttonText: this.translate.instant('Stop'),
});
}),
untilDestroyed(this),
);
}
}
6 changes: 6 additions & 0 deletions src/app/pages/services/services.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ import { ServiceSmartComponent } from 'app/pages/services/components/service-sma
import { ServiceSmbComponent } from 'app/pages/services/components/service-smb/service-smb.component';
import { ServiceSnmpComponent } from 'app/pages/services/components/service-snmp/service-snmp.component';
import { ServiceSshComponent } from 'app/pages/services/components/service-ssh/service-ssh.component';
import {
ServiceStateColumnComponent,
} from 'app/pages/services/components/service-state-column/service-state-column.component';
import { ServiceUpsComponent } from 'app/pages/services/components/service-ups/service-ups.component';
import { ServicesComponent } from 'app/pages/services/services.component';
import { IscsiService } from 'app/services/iscsi.service';
Expand Down Expand Up @@ -59,6 +62,9 @@ describe('ServicesComponent', () => {
SearchInput1Component,
IxTableModule,
],
declarations: [
ServiceStateColumnComponent,
],
providers: [
mockAuth(),
mockWebSocket([
Expand Down
Loading

0 comments on commit db283e7

Please sign in to comment.