Skip to content

Commit

Permalink
NAS-131138 / 25.04 / Add dialog for Shutdown/Restart Reason (#10821)
Browse files Browse the repository at this point in the history
* NAS-131138: Add dialog for Shutdown/Restart Reason

* NAS-131138: Add custom reason option

---------

Co-authored-by: Boris Vasilenko <[email protected]>
  • Loading branch information
bvasilenko and bvasilenko authored Oct 16, 2024
1 parent d9ab633 commit 6e2d054
Show file tree
Hide file tree
Showing 96 changed files with 2,174 additions and 208 deletions.
4 changes: 2 additions & 2 deletions src/app/interfaces/api/api-job-directory.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,8 +168,8 @@ export interface ApiJobDirectory {
'support.new_ticket': { params: [CreateNewTicket]; response: NewTicketResponse };

// System
'system.reboot': { params: { delay?: number }; response: void };
'system.shutdown': { params: { delay?: number }; response: void };
'system.reboot': { params: { delay?: number; reason?: string }; response: void };
'system.shutdown': { params: { delay?: number; reason?: string }; response: void };
'system.security.update': { params: [SystemSecurityConfig]; response: void };

// SystemDataset
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { HarnessLoader } from '@angular/cdk/testing';
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { MatDialog } from '@angular/material/dialog';
import { MatMenuHarness } from '@angular/material/menu/testing';
import { Router } from '@angular/router';
import { createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest';
import { of } from 'rxjs';
import { DialogService } from 'app/modules/dialog/dialog.service';
import { PowerMenuComponent } from 'app/modules/layout/topbar/power-menu/power-menu.component';
import { RebootOrShutdownDialogComponent } from 'app/modules/layout/topbar/reboot-or-shutdown-dialog/reboot-or-shutdown-dialog.component';
import { AuthService } from 'app/services/auth/auth.service';

describe('PowerMenuComponent', () => {
Expand All @@ -22,6 +24,11 @@ describe('PowerMenuComponent', () => {
confirm: jest.fn(() => of(true)),
}),
mockProvider(Router),
mockProvider(MatDialog, {
open: jest.fn(() => ({
afterClosed: jest.fn(() => of('reason')),
})),
}),
],
});

Expand All @@ -36,19 +43,26 @@ describe('PowerMenuComponent', () => {
const restart = await menu.getItems({ text: /Restart$/ });
await restart[0].click();

expect(spectator.inject(DialogService).confirm).toHaveBeenCalledWith(expect.objectContaining({
message: 'Restart the system?',
}));
expect(spectator.inject(Router).navigate).toHaveBeenCalledWith(['/system-tasks/reboot'], { skipLocationChange: true });
expect(spectator.inject(MatDialog).open).toHaveBeenCalledWith(RebootOrShutdownDialogComponent, {
width: '400px',
});
expect(spectator.inject(Router).navigate).toHaveBeenCalledWith(['/system-tasks/reboot'], {
skipLocationChange: true,
queryParams: { reason: 'reason' },
});
});

it('has a Shutdown menu item that shuts down system after confirmation', async () => {
const shutdown = await menu.getItems({ text: /Shut Down$/ });
await shutdown[0].click();

expect(spectator.inject(DialogService).confirm).toHaveBeenCalledWith(expect.objectContaining({
message: 'Shut down the system?',
}));
expect(spectator.inject(Router).navigate).toHaveBeenCalledWith(['/system-tasks/shutdown'], { skipLocationChange: true });
expect(spectator.inject(MatDialog).open).toHaveBeenCalledWith(RebootOrShutdownDialogComponent, {
width: '400px',
data: true,
});
expect(spectator.inject(Router).navigate).toHaveBeenCalledWith(['/system-tasks/shutdown'], {
skipLocationChange: true,
queryParams: { reason: 'reason' },
});
});
});
39 changes: 21 additions & 18 deletions src/app/modules/layout/topbar/power-menu/power-menu.component.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { MatIconButton } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { MatMenuTrigger, MatMenu, MatMenuItem } from '@angular/material/menu';
import { MatTooltip } from '@angular/material/tooltip';
import { Router } from '@angular/router';
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
import { TranslateService, TranslateModule } from '@ngx-translate/core';
import { TranslateModule } from '@ngx-translate/core';
import { filter } from 'rxjs/operators';
import { RequiresRolesDirective } from 'app/directives/requires-roles/requires-roles.directive';
import { UiSearchDirective } from 'app/directives/ui-search.directive';
import { Role } from 'app/enums/role.enum';
import { helptextTopbar } from 'app/helptext/topbar';
import { DialogService } from 'app/modules/dialog/dialog.service';
import { IxIconComponent } from 'app/modules/ix-icon/ix-icon.component';
import { powerMenuElements } from 'app/modules/layout/topbar/power-menu/power-menu.elements';
import { RebootOrShutdownDialogComponent } from 'app/modules/layout/topbar/reboot-or-shutdown-dialog/reboot-or-shutdown-dialog.component';
import { TestDirective } from 'app/modules/test-id/test.directive';

@UntilDestroy()
Expand Down Expand Up @@ -40,34 +41,36 @@ export class PowerMenuComponent {
protected searchableElements = powerMenuElements;

constructor(
private translate: TranslateService,
private dialogService: DialogService,
private matDialog: MatDialog,
private router: Router,
) { }

onReboot(): void {
this.dialogService.confirm({
title: this.translate.instant('Restart'),
message: this.translate.instant('Restart the system?'),
buttonText: this.translate.instant('Restart'),
}).pipe(
this.matDialog.open(RebootOrShutdownDialogComponent, {
width: '400px',
}).afterClosed().pipe(
filter(Boolean),
untilDestroyed(this),
).subscribe(() => {
this.router.navigate(['/system-tasks/reboot'], { skipLocationChange: true });
).subscribe((reason: string) => {
this.router.navigate(['/system-tasks/reboot'], {
skipLocationChange: true,
queryParams: { reason },
});
});
}

onShutdown(): void {
this.dialogService.confirm({
title: this.translate.instant('Shut down'),
message: this.translate.instant('Shut down the system?'),
buttonText: this.translate.instant('Shut Down'),
}).pipe(
this.matDialog.open(RebootOrShutdownDialogComponent, {
width: '400px',
data: true,
}).afterClosed().pipe(
filter(Boolean),
untilDestroyed(this),
).subscribe(() => {
this.router.navigate(['/system-tasks/shutdown'], { skipLocationChange: true });
).subscribe((reason: string) => {
this.router.navigate(['/system-tasks/shutdown'], {
skipLocationChange: true,
queryParams: { reason },
});
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<h3 mat-dialog-title>{{ title }}</h3>

<div mat-dialog-content>
<ix-select
[formControl]="form.controls.reason"
[label]="'Reason' | translate"
[options]="reasonOptions$"
[required]="true"
></ix-select>
@if(form.controls.customReason.enabled) {
<ix-input
[formControl]="form.controls.customReason"
[label]="'Custom Reason' | translate"
[required]="true"
></ix-input>
}
</div>

<ix-form-actions mat-dialog-actions class="form-actions">
<ix-checkbox
class="confirm"
[formControl]="form.controls.confirm"
[label]="'Confirm' | translate"
[required]="true"
></ix-checkbox>

<button mat-button type="button" matDialogClose ixTest="cancel">
{{ 'Cancel' | translate }}
</button>

<button
mat-button
type="submit"
color="primary"
ixTest="submit"
[disabled]="form.invalid"
(click)="onSubmit()"
>
{{ buttonText }}
</button>
</ix-form-actions>
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.confirm {
margin-right: auto;
padding-right: 10px;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core';
import { ReactiveFormsModule, Validators } from '@angular/forms';
import { MatButton } from '@angular/material/button';
import {
MatDialogRef, MatDialogContent, MatDialogActions, MatDialogTitle,
MAT_DIALOG_DATA,
MatDialogModule,
} from '@angular/material/dialog';
import { FormBuilder } from '@ngneat/reactive-forms';
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { Observable, of } from 'rxjs';
import { SelectOption } from 'app/interfaces/option.interface';
import { IxCheckboxComponent } from 'app/modules/forms/ix-forms/components/ix-checkbox/ix-checkbox.component';
import { IxInputComponent } from 'app/modules/forms/ix-forms/components/ix-input/ix-input.component';
import { IxSelectComponent } from 'app/modules/forms/ix-forms/components/ix-select/ix-select.component';
import { TestDirective } from 'app/modules/test-id/test.directive';

const customReasonValue = 'CUSTOM_REASON_VALUE';

@UntilDestroy()
@Component({
selector: 'ix-reboot-or-shutdown-dialog',
templateUrl: './reboot-or-shutdown-dialog.component.html',
styleUrls: ['./reboot-or-shutdown-dialog.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [
MatDialogTitle,
MatDialogContent,
MatDialogActions,
MatDialogModule,
MatButton,
TranslateModule,
TestDirective,
ReactiveFormsModule,
IxCheckboxComponent,
IxSelectComponent,
IxInputComponent,
],
})
export class RebootOrShutdownDialogComponent {
form = this.fb.group({
confirm: [false, Validators.requiredTrue],
reason: ['', Validators.required],
customReason: ['', Validators.required],
});

readonly reasonOptions$: Observable<SelectOption[]> = of([
{
label: this.translate.instant('Custom Reason'),
value: customReasonValue,
},
{
label: this.translate.instant('System Update'),
tooltip: this.translate.instant('Applying important system or security updates.'),
value: 'Applying important system or security updates.',
},
{
label: this.translate.instant('Hardware Change'),
tooltip: this.translate.instant('Adding, removing, or changing hardware components.'),
value: 'Adding, removing, or changing hardware components.',
},
{
label: this.translate.instant('Troubleshooting Issues'),
tooltip: this.translate.instant('Required reset to fix system operation issues.'),
value: 'Required reset to fix system operation issues.',
},
{
label: this.translate.instant('Power Outage'),
tooltip: this.translate.instant('Unexpected power loss necessitating a reboot.'),
value: 'Unexpected power loss necessitating a reboot.',
},
{
label: this.translate.instant('Maintenance Window'),
tooltip: this.translate.instant('Regularly scheduled system checks and updates.'),
value: 'Regularly scheduled system checks and updates.',
},
{
label: this.translate.instant('System Overload'),
tooltip: this.translate.instant('High usage necessitating a system reset.'),
value: 'High usage necessitating a system reset.',
},
{
label: this.translate.instant('Software Installation'),
tooltip: this.translate.instant('Required reboot after new software installation.'),
value: 'Required reboot after new software installation.',
},
{
label: this.translate.instant('Performance Optimization'),
tooltip: this.translate.instant('Restart to improve system performance speed.'),
value: 'Restart to improve system performance speed.',
},
{
label: this.translate.instant('Network Reset'),
tooltip: this.translate.instant('Restart to re-establish network connections.'),
value: 'Restart to re-establish network connections.',
},
{
label: this.translate.instant('System Freeze'),
tooltip: this.translate.instant('Unresponsive system necessitating a forced reboot.'),
value: 'Unresponsive system necessitating a forced reboot.',
},
]);

get title(): string {
return this.isShutdown
? this.translate.instant('Shut down')
: this.translate.instant('Restart');
}

get buttonText(): string {
return this.isShutdown
? this.translate.instant('Shut Down')
: this.translate.instant('Restart');
}

constructor(
public dialogRef: MatDialogRef<RebootOrShutdownDialogComponent>,
private fb: FormBuilder,
private translate: TranslateService,
@Inject(MAT_DIALOG_DATA) public isShutdown = false,
) {
this.form.controls.reason.valueChanges.pipe(untilDestroyed(this)).subscribe((reason) => {
if (reason === customReasonValue) {
this.form.controls.customReason.enable();
} else {
this.form.controls.customReason.disable();
}
});
}

onSubmit(): void {
const formValue = this.form.value;
const reason = formValue.reason === customReasonValue ? formValue.customReason : formValue.reason;
this.dialogRef.close(reason);
}
}
7 changes: 5 additions & 2 deletions src/app/pages/system-tasks/reboot/reboot.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Location } from '@angular/common';
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
import { MatCard, MatCardContent } from '@angular/material/card';
import { MatDialog } from '@angular/material/dialog';
import { Router } from '@angular/router';
import { ActivatedRoute, Router } from '@angular/router';
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
import { TranslateModule } from '@ngx-translate/core';
import { DialogService } from 'app/modules/dialog/dialog.service';
Expand Down Expand Up @@ -33,6 +33,7 @@ export class RebootComponent implements OnInit {
protected ws: WebSocketService,
private wsManager: WebSocketConnectionService,
protected router: Router,
private route: ActivatedRoute,
private errorHandler: ErrorHandlerService,
protected loader: AppLoaderService,
protected dialogService: DialogService,
Expand All @@ -42,11 +43,13 @@ export class RebootComponent implements OnInit {
}

ngOnInit(): void {
const reason = this.route.snapshot.queryParamMap.get('reason');

// Replace URL so that we don't reboot again if page is refreshed.
this.location.replaceState('/signin');

this.matDialog.closeAll();
this.ws.job('system.reboot').pipe(untilDestroyed(this)).subscribe({
this.ws.job('system.reboot', { reason }).pipe(untilDestroyed(this)).subscribe({
error: (error: unknown) => { // error on reboot
this.dialogService.error(this.errorHandler.parseError(error))
.pipe(untilDestroyed(this))
Expand Down
7 changes: 5 additions & 2 deletions src/app/pages/system-tasks/shutdown/shutdown.component.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Location } from '@angular/common';
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
import { MatCard, MatCardContent } from '@angular/material/card';
import { Router } from '@angular/router';
import { ActivatedRoute, Router } from '@angular/router';
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
import { TranslateModule } from '@ngx-translate/core';
import { DialogService } from 'app/modules/dialog/dialog.service';
Expand Down Expand Up @@ -32,15 +32,18 @@ export class ShutdownComponent implements OnInit {
private wsManager: WebSocketConnectionService,
private errorHandler: ErrorHandlerService,
protected router: Router,
private route: ActivatedRoute,
protected dialogService: DialogService,
private location: Location,
) {}

ngOnInit(): void {
const reason = this.route.snapshot.queryParamMap.get('reason');

// Replace URL so that we don't shutdown again if page is refreshed.
this.location.replaceState('/signin');

this.ws.job('system.shutdown', {}).pipe(untilDestroyed(this)).subscribe({
this.ws.job('system.shutdown', { reason }).pipe(untilDestroyed(this)).subscribe({
error: (error: unknown) => { // error on shutdown
this.dialogService.error(this.errorHandler.parseError(error))
.pipe(untilDestroyed(this))
Expand Down
Loading

0 comments on commit 6e2d054

Please sign in to comment.