diff --git a/README.md b/README.md index 94618ab3295..75fce5ad460 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ NOTE: Pull requests are also generated as Docker images and can be used for test # Development requirements - yarn >= 1.12 - - Node.js >= 8.9 + - Node.js >= 14 - Running TrueNAS CORE or TrueNAS SCALE Nightly Machine (VM is fine) diff --git a/src/app/enums/v-dev-type.enum.ts b/src/app/enums/v-dev-type.enum.ts index 9e652b0f1f3..abda22f4445 100644 --- a/src/app/enums/v-dev-type.enum.ts +++ b/src/app/enums/v-dev-type.enum.ts @@ -16,6 +16,7 @@ export enum TopologyItemType { Raidz1 = 'RAIDZ1', Raidz2 = 'RAIDZ2', Raidz3 = 'RAIDZ3', + Draid = 'DRAID', L2Cache = 'L2CACHE', Replacing = 'REPLACING', } diff --git a/src/app/helpers/options.helper.spec.ts b/src/app/helpers/options.helper.spec.ts index 3addccd70d4..9603effba7d 100644 --- a/src/app/helpers/options.helper.spec.ts +++ b/src/app/helpers/options.helper.spec.ts @@ -1,5 +1,15 @@ import { TranslateService } from '@ngx-translate/core'; -import { mapToOptions } from 'app/helpers/options.helper'; +import { generateOptionsRange, mapToOptions } from 'app/helpers/options.helper'; + +describe('generateOptionsRange', () => { + it('generates a range of options based on range of numbers', () => { + expect(generateOptionsRange(1, 3)).toStrictEqual([ + { label: '1', value: 1 }, + { label: '2', value: 2 }, + { label: '3', value: 3 }, + ]); + }); +}); describe('mapToOptions', () => { it('converts JS Map to an array of options while invoking translation on labels', () => { diff --git a/src/app/helpers/options.helper.ts b/src/app/helpers/options.helper.ts index 7003e3ff14b..fcce06a2237 100644 --- a/src/app/helpers/options.helper.ts +++ b/src/app/helpers/options.helper.ts @@ -15,3 +15,10 @@ export function findLabelsByValue(options: Option[]): (value: string) => string return selectedOption?.label; }; } + +export function generateOptionsRange(start: number, end: number): Option[] { + return Array.from({ length: end - start + 1 }, (_, index) => { + const value = start + index; + return { label: String(value), value }; + }); +} diff --git a/src/app/helptext/storage/volumes/manager/manager.ts b/src/app/helptext/storage/volumes/manager/manager.ts index 001d5af6831..0ad4d5cad07 100644 --- a/src/app/helptext/storage/volumes/manager/manager.ts +++ b/src/app/helptext/storage/volumes/manager/manager.ts @@ -81,10 +81,12 @@ export default { be sized to X GiB for each X TiB of general storage.'), exported_pool_warning: T('This disk is part of the exported pool {pool}. Adding this disk to a new or other existing pools will make {pool} unable to import. You will lose any and all data in {pool}. Please make sure you have backed up any sensitive data in {pool} before reusing/repurposing this disk.'), - dRaidTooltip: T('dRAID is a ZFS feature that boosts resilver speed, load distribution, and disk space efficiency. \nOpt for dRAID over RAID-Z when handling large-capacity drives and extensive disk environments for enhanced performance.'), + dRaidTooltip: T('dRAID is a ZFS feature that boosts resilver speed and load distribution. Due to fixed strip width disk space efficiency may be substantially worse with small files. \nOpt for dRAID over RAID-Z when handling large-capacity drives and extensive disk environments for enhanced performance.'), stripeTooltip: T('Each disk stores data. A stripe requires at least one disk and has no data redundancy.'), mirrorTooltip: T('Data is identical in each disk. A mirror requires at least two disks, provides the most redundancy, and has the least capacity.'), raidz1Tooltip: T('Uses one disk for parity while all other disks store data. RAIDZ1 requires at least three disks. RAIDZ is a traditional ZFS data protection scheme. \nChoose RAIDZ over dRAID when managing a smaller set of drives, where simplicity of setup and predictable disk usage are primary considerations.'), raidz2Tooltip: T('Uses two disks for parity while all other disks store data. RAIDZ2 requires at least four disks. RAIDZ is a traditional ZFS data protection scheme. \nChoose RAIDZ over dRAID when managing a smaller set of drives, where simplicity of setup and predictable disk usage are primary considerations.'), raidz3Tooltip: T('Uses three disks for parity while all other disks store data. RAIDZ3 requires at least five disks. RAIDZ is a traditional ZFS data protection scheme. \nChoose RAIDZ over dRAID when managing a smaller set of drives, where simplicity of setup and predictable disk usage are primary considerations.'), + + dRaidChildrenExplanation: T('The number of children must at the minimum accomodate the total number of disks required for the previous configuration options including parity drives.'), }; diff --git a/src/app/interfaces/pool.interface.ts b/src/app/interfaces/pool.interface.ts index e722c04e20a..34e2dddda1b 100644 --- a/src/app/interfaces/pool.interface.ts +++ b/src/app/interfaces/pool.interface.ts @@ -89,7 +89,7 @@ export interface UpdatePool { // TODO: Maybe replace first 5 keys with VdevType enum once old pool manager is removed. export interface UpdatePoolTopology { - data?: { type: CreateVdevLayout; disks: string[] }[]; + data?: DataPoolTopologyUpdate[]; special?: { type: CreateVdevLayout; disks: string[] }[]; dedup?: { type: CreateVdevLayout; disks: string[] }[]; cache?: { type: CreateVdevLayout; disks: string[] }[]; @@ -98,6 +98,15 @@ export interface UpdatePoolTopology { spares?: string[]; } +export interface DataPoolTopologyUpdate { + type: CreateVdevLayout; + disks: string[]; + draid_data_disks?: number; + draid_spare_disks?: number; +} + +export type UpdatePoolTopologyGroup = keyof UpdatePoolTopology; + export interface PoolAttachParams { target_vdev?: string; new_disk?: string; diff --git a/src/app/modules/ix-feedback/feedback-dialog/feedback-dialog.component.ts b/src/app/modules/ix-feedback/feedback-dialog/feedback-dialog.component.ts index 46e26cb4392..54d767c26db 100644 --- a/src/app/modules/ix-feedback/feedback-dialog/feedback-dialog.component.ts +++ b/src/app/modules/ix-feedback/feedback-dialog/feedback-dialog.component.ts @@ -45,7 +45,7 @@ export class FeedbackDialogComponent implements OnInit { constructor( private formBuilder: FormBuilder, - private slideIn: IxSlideInService, + private slideInService: IxSlideInService, private dialogRef: MatDialogRef, private feedbackService: IxFeedbackService, private store$: Store, @@ -75,7 +75,7 @@ export class FeedbackDialogComponent implements OnInit { openFileTicketForm(): void { this.dialogRef.close(); - this.slideIn.open(FileTicketFormComponent); + this.slideInService.open(FileTicketFormComponent); } onSubmit(): void { diff --git a/src/app/modules/ix-table2/components/ix-table-body/cells/ix-cell-date/ix-cell-date.component.html b/src/app/modules/ix-table2/components/ix-table-body/cells/ix-cell-date/ix-cell-date.component.html index d3645a7af3b..976843b0c53 100644 --- a/src/app/modules/ix-table2/components/ix-table-body/cells/ix-cell-date/ix-cell-date.component.html +++ b/src/app/modules/ix-table2/components/ix-table-body/cells/ix-cell-date/ix-cell-date.component.html @@ -1 +1 @@ -{{ value | formatDateTime:undefined:formatDate:formatTime }} +{{ value | formatDateTime }} diff --git a/src/app/modules/ix-table2/components/ix-table-body/cells/ix-cell-date/ix-cell-date.component.spec.ts b/src/app/modules/ix-table2/components/ix-table-body/cells/ix-cell-date/ix-cell-date.component.spec.ts index 2a5c5d49497..1d9d2d0aef0 100644 --- a/src/app/modules/ix-table2/components/ix-table-body/cells/ix-cell-date/ix-cell-date.component.spec.ts +++ b/src/app/modules/ix-table2/components/ix-table-body/cells/ix-cell-date/ix-cell-date.component.spec.ts @@ -32,12 +32,4 @@ describe('IxCellDateComponent', () => { it('shows default format datetime in template', () => { expect(spectator.element.textContent.trim()).toBe('2023-07-12 09:10:00'); }); - - it('shows custom format datetime in template', () => { - spectator.component.formatDate = 'yyyy/MM/dd'; - spectator.component.formatTime = 'HH.mm.ss'; - spectator.fixture.detectChanges(); - - expect(spectator.element.textContent.trim()).toBe('2023/07/12 09.10.00'); - }); }); diff --git a/src/app/modules/ix-table2/components/ix-table-body/cells/ix-cell-date/ix-cell-date.component.ts b/src/app/modules/ix-table2/components/ix-table-body/cells/ix-cell-date/ix-cell-date.component.ts index c774e286348..9f89e2f61fc 100644 --- a/src/app/modules/ix-table2/components/ix-table-body/cells/ix-cell-date/ix-cell-date.component.ts +++ b/src/app/modules/ix-table2/components/ix-table-body/cells/ix-cell-date/ix-cell-date.component.ts @@ -1,14 +1,15 @@ import { Component } from '@angular/core'; +import { ApiTimestamp } from 'app/interfaces/api-date.interface'; import { Column, ColumnComponent } from 'app/modules/ix-table2/interfaces/table-column.interface'; @Component({ templateUrl: './ix-cell-date.component.html', }) export class IxCellDateComponent extends ColumnComponent { - formatDate: string = undefined; - formatTime: string = undefined; - get value(): number | Date { + if ((this.row[this.propertyName] as ApiTimestamp)?.$date) { + return (this.row[this.propertyName] as ApiTimestamp).$date; + } return this.row[this.propertyName] as number | Date; } } diff --git a/src/app/modules/ix-table2/components/ix-table-pager-show-more/ix-table-pager-show-more.component.scss b/src/app/modules/ix-table2/components/ix-table-pager-show-more/ix-table-pager-show-more.component.scss index c901e68a282..8b3cce9f141 100644 --- a/src/app/modules/ix-table2/components/ix-table-pager-show-more/ix-table-pager-show-more.component.scss +++ b/src/app/modules/ix-table2/components/ix-table-pager-show-more/ix-table-pager-show-more.component.scss @@ -1,4 +1,8 @@ -:host { +:host:not(.collapsible) { + display: none; +} + +:host.collapsible { align-items: center; display: flex; justify-content: center; diff --git a/src/app/modules/ix-table2/components/ix-table-pager-show-more/ix-table-pager-show-more.component.ts b/src/app/modules/ix-table2/components/ix-table-pager-show-more/ix-table-pager-show-more.component.ts index 676a81ab1e7..a3ae304410d 100644 --- a/src/app/modules/ix-table2/components/ix-table-pager-show-more/ix-table-pager-show-more.component.ts +++ b/src/app/modules/ix-table2/components/ix-table-pager-show-more/ix-table-pager-show-more.component.ts @@ -1,5 +1,5 @@ import { - AfterContentChecked, ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnInit, + AfterContentChecked, ChangeDetectionStrategy, ChangeDetectorRef, Component, HostBinding, Input, OnInit, } from '@angular/core'; import { UntilDestroy } from '@ngneat/until-destroy'; import { ArrayDataProvider } from 'app/modules/ix-table2/array-data-provider'; @@ -19,7 +19,7 @@ export class IxTablePagerShowMoreComponent implements OnInit, AfterContentChe totalItems = 0; expanded = false; - get collapsible(): boolean { + @HostBinding('class.collapsible') get collapsible(): boolean { return this.totalItems > this.pageSize; } diff --git a/src/app/modules/scheduler/components/scheduler-modal/scheduler-modal.component.scss b/src/app/modules/scheduler/components/scheduler-modal/scheduler-modal.component.scss index e39f75a4f5d..ee609fe1bc5 100644 --- a/src/app/modules/scheduler/components/scheduler-modal/scheduler-modal.component.scss +++ b/src/app/modules/scheduler/components/scheduler-modal/scheduler-modal.component.scss @@ -10,6 +10,7 @@ .settings-column { margin-bottom: 10px; margin-top: 19px; + width: 100%; .no-spaces-hint { font-size: 85%; diff --git a/src/app/pages/account/users/user-form/user-form.component.spec.ts b/src/app/pages/account/users/user-form/user-form.component.spec.ts index 5b0708427c9..cc439e7abf2 100644 --- a/src/app/pages/account/users/user-form/user-form.component.spec.ts +++ b/src/app/pages/account/users/user-form/user-form.component.spec.ts @@ -125,7 +125,7 @@ describe('UserFormComponent', () => { const usernameInput = await loader.getHarness(IxInputHarness.with({ label: 'Username' })); await usernameInput.setValue('test'); - expect(await homeInput.getValue()).toBe('/mnt/users/test'); + expect(await homeInput.getValue()).toBe('/mnt/users'); }); it('checks download ssh key button is hidden', async () => { diff --git a/src/app/pages/account/users/user-form/user-form.component.ts b/src/app/pages/account/users/user-form/user-form.component.ts index b5dfdf50c09..f4fac1f0eea 100644 --- a/src/app/pages/account/users/user-form/user-form.component.ts +++ b/src/app/pages/account/users/user-form/user-form.component.ts @@ -8,7 +8,7 @@ import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; import _ from 'lodash'; import { - combineLatest, from, Observable, of, Subscription, + from, Observable, of, Subscription, } from 'rxjs'; import { debounceTime, filter, map, switchMap, take, @@ -416,14 +416,9 @@ export class UserFormComponent implements OnInit { ]]).pipe( filter((shares) => !!shares.length), map((shares) => shares[0].path), - switchMap((homeSharePath) => { - this.form.patchValue({ home: homeSharePath }); - - return combineLatest([of(homeSharePath), this.form.controls.username.valueChanges]); - }), untilDestroyed(this), - ).subscribe(([homeSharePath, username]) => { - this.form.patchValue({ home: `${homeSharePath}/${username}` }); + ).subscribe((homeSharePath) => { + this.form.patchValue({ home: homeSharePath }); }); } diff --git a/src/app/pages/apps/components/app-detail-view/app-detail-view.component.html b/src/app/pages/apps/components/app-detail-view/app-detail-view.component.html index 85fe15b24b1..02dc3fbfd36 100644 --- a/src/app/pages/apps/components/app-detail-view/app-detail-view.component.html +++ b/src/app/pages/apps/components/app-detail-view/app-detail-view.component.html @@ -27,7 +27,7 @@

{{ 'Screenshots' | translate }}

- +
diff --git a/src/app/pages/apps/components/installed-apps/app-containers-card/app-containers-card.component.html b/src/app/pages/apps/components/installed-apps/app-containers-card/app-containers-card.component.html index 04396252560..fab0840e8b7 100644 --- a/src/app/pages/apps/components/installed-apps/app-containers-card/app-containers-card.component.html +++ b/src/app/pages/apps/components/installed-apps/app-containers-card/app-containers-card.component.html @@ -56,7 +56,7 @@

{{ 'Containers' | translate }}

- - - -
diff --git a/src/app/pages/services/components/service-lldp/service-lldp.component.scss b/src/app/pages/services/components/service-lldp/service-lldp.component.scss index c0c1e368b6e..e44f3d0b297 100644 --- a/src/app/pages/services/components/service-lldp/service-lldp.component.scss +++ b/src/app/pages/services/components/service-lldp/service-lldp.component.scss @@ -1,6 +1,5 @@ .form-actions { - border-top: 1px solid var(--lines); - padding: 16px 11px; + padding: 0 11px; button { margin-right: 5px; diff --git a/src/app/pages/services/components/service-lldp/service-lldp.component.spec.ts b/src/app/pages/services/components/service-lldp/service-lldp.component.spec.ts index 5b16e4e3510..973b1783dc9 100644 --- a/src/app/pages/services/components/service-lldp/service-lldp.component.spec.ts +++ b/src/app/pages/services/components/service-lldp/service-lldp.component.spec.ts @@ -6,6 +6,8 @@ import { ActivatedRoute, Router } from '@angular/router'; import { createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest'; import { mockCall, mockWebsocket } from 'app/core/testing/utils/mock-websocket.utils'; import { LldpConfig } from 'app/interfaces/lldp-config.interface'; +import { IxSlideInRef } from 'app/modules/ix-forms/components/ix-slide-in/ix-slide-in-ref'; +import { SLIDE_IN_DATA } from 'app/modules/ix-forms/components/ix-slide-in/ix-slide-in.token'; import { IxFormsModule } from 'app/modules/ix-forms/ix-forms.module'; import { FormErrorHandlerService } from 'app/modules/ix-forms/services/form-error-handler.service'; import { IxFormHarness } from 'app/modules/ix-forms/testing/ix-form.harness'; @@ -40,6 +42,8 @@ describe('ServiceLldpComponent', () => { mockProvider(ActivatedRoute), mockProvider(DialogService), mockProvider(FormErrorHandlerService), + mockProvider(IxSlideInRef), + { provide: SLIDE_IN_DATA, useValue: undefined }, ], }); diff --git a/src/app/pages/services/components/service-lldp/service-lldp.component.ts b/src/app/pages/services/components/service-lldp/service-lldp.component.ts index fe15989d0cc..9cfac6287af 100644 --- a/src/app/pages/services/components/service-lldp/service-lldp.component.ts +++ b/src/app/pages/services/components/service-lldp/service-lldp.component.ts @@ -2,7 +2,7 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit, } from '@angular/core'; import { FormBuilder, Validators } from '@angular/forms'; -import { ActivatedRoute, Router } from '@angular/router'; +import { ActivatedRoute } from '@angular/router'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; import { TranslateService } from '@ngx-translate/core'; import { choicesToOptions } from 'app/helpers/operators/options.operators'; @@ -10,6 +10,7 @@ import helptext from 'app/helptext/services/components/service-lldp'; import { LldpConfigUpdate } from 'app/interfaces/lldp-config.interface'; import { WebsocketError } from 'app/interfaces/websocket-error.interface'; import { SimpleAsyncComboboxProvider } from 'app/modules/ix-forms/classes/simple-async-combobox-provider'; +import { IxSlideInRef } from 'app/modules/ix-forms/components/ix-slide-in/ix-slide-in-ref'; import { FormErrorHandlerService } from 'app/modules/ix-forms/services/form-error-handler.service'; import { SnackbarService } from 'app/modules/snackbar/services/snackbar.service'; import { DialogService } from 'app/services/dialog.service'; @@ -64,7 +65,6 @@ export class ServiceLldpComponent implements OnInit { locationProvider = new SimpleAsyncComboboxProvider(this.ws.call('lldp.country_choices').pipe(choicesToOptions())); constructor( - protected router: Router, protected route: ActivatedRoute, protected ws: WebSocketService, private errorHandler: ErrorHandlerService, @@ -74,6 +74,7 @@ export class ServiceLldpComponent implements OnInit { private translate: TranslateService, private dialogService: DialogService, private formErrorHandler: FormErrorHandlerService, + private slideInRef: IxSlideInRef, ) { } ngOnInit(): void { @@ -105,7 +106,7 @@ export class ServiceLldpComponent implements OnInit { next: () => { this.isFormLoading = false; this.snackbar.success(this.translate.instant('Service configuration saved')); - this.router.navigate(['/services']); + this.slideInRef.close(); this.cdr.markForCheck(); }, error: (error) => { diff --git a/src/app/pages/services/components/service-nfs/service-nfs.component.html b/src/app/pages/services/components/service-nfs/service-nfs.component.html index 54cf8b0caaf..d1427cb14a9 100644 --- a/src/app/pages/services/components/service-nfs/service-nfs.component.html +++ b/src/app/pages/services/components/service-nfs/service-nfs.component.html @@ -1,10 +1,7 @@ + + - -
{{ 'Save' | translate }} - -
{ afterClosed: () => of(), })), }), + mockProvider(IxSlideInRef), + { provide: SLIDE_IN_DATA, useValue: undefined }, ], }); diff --git a/src/app/pages/services/components/service-nfs/service-nfs.component.ts b/src/app/pages/services/components/service-nfs/service-nfs.component.ts index 8dfd4bb6442..6b8b1004ea4 100644 --- a/src/app/pages/services/components/service-nfs/service-nfs.component.ts +++ b/src/app/pages/services/components/service-nfs/service-nfs.component.ts @@ -3,7 +3,6 @@ import { } from '@angular/core'; import { FormBuilder, Validators } from '@angular/forms'; import { MatDialog } from '@angular/material/dialog'; -import { Router } from '@angular/router'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; import { TranslateService } from '@ngx-translate/core'; import { forkJoin, of } from 'rxjs'; @@ -13,6 +12,7 @@ import { choicesToOptions } from 'app/helpers/operators/options.operators'; import { mapToOptions } from 'app/helpers/options.helper'; import helptext from 'app/helptext/services/components/service-nfs'; import { WebsocketError } from 'app/interfaces/websocket-error.interface'; +import { IxSlideInRef } from 'app/modules/ix-forms/components/ix-slide-in/ix-slide-in-ref'; import { FormErrorHandlerService } from 'app/modules/ix-forms/services/form-error-handler.service'; import { rangeValidator, portRangeValidator } from 'app/modules/ix-forms/validators/range-validation/range-validation'; import { SnackbarService } from 'app/modules/snackbar/services/snackbar.service'; @@ -71,9 +71,9 @@ export class ServiceNfsComponent implements OnInit { private fb: FormBuilder, private translate: TranslateService, private dialogService: DialogService, - private router: Router, private snackbar: SnackbarService, private matDialog: MatDialog, + private slideInRef: IxSlideInRef, ) {} ngOnInit(): void { @@ -93,8 +93,8 @@ export class ServiceNfsComponent implements OnInit { next: () => { this.isFormLoading = false; this.snackbar.success(this.translate.instant('Service configuration saved')); + this.slideInRef.close(); this.cdr.markForCheck(); - this.router.navigate(['/services']); }, error: (error) => { this.isFormLoading = false; @@ -111,7 +111,6 @@ export class ServiceNfsComponent implements OnInit { next: (config) => { this.isAddSpnDisabled = !config.v4_krb; this.form.patchValue(config); - this.snackbar.success(this.translate.instant('Service configuration saved')); this.isFormLoading = false; this.cdr.markForCheck(); }, diff --git a/src/app/pages/services/components/service-smart/service-smart.component.html b/src/app/pages/services/components/service-smart/service-smart.component.html index e8925a1023e..1cd27581659 100644 --- a/src/app/pages/services/components/service-smart/service-smart.component.html +++ b/src/app/pages/services/components/service-smart/service-smart.component.html @@ -1,10 +1,7 @@ + + - - { mockProvider(FormErrorHandlerService), mockProvider(DialogService), mockProvider(Router), + mockProvider(IxSlideInRef), + { provide: SLIDE_IN_DATA, useValue: undefined }, ], }); diff --git a/src/app/pages/services/components/service-smart/service-smart.component.ts b/src/app/pages/services/components/service-smart/service-smart.component.ts index 35a9f74e004..342f24a594f 100644 --- a/src/app/pages/services/components/service-smart/service-smart.component.ts +++ b/src/app/pages/services/components/service-smart/service-smart.component.ts @@ -2,7 +2,6 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit, } from '@angular/core'; import { FormBuilder, Validators } from '@angular/forms'; -import { Router } from '@angular/router'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; import { TranslateService } from '@ngx-translate/core'; import { of } from 'rxjs'; @@ -10,6 +9,7 @@ import { SmartPowerMode } from 'app/enums/smart-power.mode'; import helptext from 'app/helptext/services/components/service-smart'; import { SmartConfigUpdate } from 'app/interfaces/smart-test.interface'; import { WebsocketError } from 'app/interfaces/websocket-error.interface'; +import { IxSlideInRef } from 'app/modules/ix-forms/components/ix-slide-in/ix-slide-in-ref'; import { FormErrorHandlerService } from 'app/modules/ix-forms/services/form-error-handler.service'; import { SnackbarService } from 'app/modules/snackbar/services/snackbar.service'; import { DialogService } from 'app/services/dialog.service'; @@ -56,8 +56,8 @@ export class ServiceSmartComponent implements OnInit { private fb: FormBuilder, private translate: TranslateService, private dialogService: DialogService, - private router: Router, private snackbar: SnackbarService, + private slideInRef: IxSlideInRef, ) {} ngOnInit(): void { @@ -88,8 +88,8 @@ export class ServiceSmartComponent implements OnInit { next: () => { this.isFormLoading = false; this.snackbar.success(this.translate.instant('Service configuration saved')); + this.slideInRef.close(); this.cdr.markForCheck(); - this.router.navigate(['/services']); }, error: (error) => { this.isFormLoading = false; diff --git a/src/app/pages/services/components/service-smb/service-smb.component.html b/src/app/pages/services/components/service-smb/service-smb.component.html index 1c44f1533f0..d21ae830769 100644 --- a/src/app/pages/services/components/service-smb/service-smb.component.html +++ b/src/app/pages/services/components/service-smb/service-smb.component.html @@ -1,10 +1,7 @@ + + - - - - - -
diff --git a/src/app/pages/services/components/service-snmp/service-snmp.component.scss b/src/app/pages/services/components/service-snmp/service-snmp.component.scss index bd90f42a925..147f42341f0 100644 --- a/src/app/pages/services/components/service-snmp/service-snmp.component.scss +++ b/src/app/pages/services/components/service-snmp/service-snmp.component.scss @@ -1,6 +1,5 @@ .form-actions { - border-top: 1px solid var(--lines); - padding: 16px 11px; + padding: 0 11px; button { margin-right: 5px; diff --git a/src/app/pages/services/components/service-snmp/service-snmp.component.spec.ts b/src/app/pages/services/components/service-snmp/service-snmp.component.spec.ts index 746da9eb152..c86a90a7063 100644 --- a/src/app/pages/services/components/service-snmp/service-snmp.component.spec.ts +++ b/src/app/pages/services/components/service-snmp/service-snmp.component.spec.ts @@ -5,6 +5,8 @@ import { MatButtonHarness } from '@angular/material/button/testing'; import { createRoutingFactory, mockProvider, Spectator } from '@ngneat/spectator/jest'; import { mockCall, mockWebsocket } from 'app/core/testing/utils/mock-websocket.utils'; import { SnmpConfig } from 'app/interfaces/snmp-config.interface'; +import { IxSlideInRef } from 'app/modules/ix-forms/components/ix-slide-in/ix-slide-in-ref'; +import { SLIDE_IN_DATA } from 'app/modules/ix-forms/components/ix-slide-in/ix-slide-in.token'; import { IxFormsModule } from 'app/modules/ix-forms/ix-forms.module'; import { IxFormHarness } from 'app/modules/ix-forms/testing/ix-form.harness'; import { DialogService } from 'app/services/dialog.service'; @@ -41,6 +43,8 @@ describe('ServiceSnmpComponent', () => { loglevel: 4, } as SnmpConfig), ]), + mockProvider(IxSlideInRef), + { provide: SLIDE_IN_DATA, useValue: undefined }, ], }); diff --git a/src/app/pages/services/components/service-snmp/service-snmp.component.ts b/src/app/pages/services/components/service-snmp/service-snmp.component.ts index 016b315e074..7350cd1b85b 100644 --- a/src/app/pages/services/components/service-snmp/service-snmp.component.ts +++ b/src/app/pages/services/components/service-snmp/service-snmp.component.ts @@ -2,13 +2,13 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit, } from '@angular/core'; import { FormBuilder, Validators } from '@angular/forms'; -import { Router } from '@angular/router'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; import { TranslateService } from '@ngx-translate/core'; import { of } from 'rxjs'; import helptext from 'app/helptext/services/components/service-snmp'; import { SnmpConfigUpdate } from 'app/interfaces/snmp-config.interface'; import { WebsocketError } from 'app/interfaces/websocket-error.interface'; +import { IxSlideInRef } from 'app/modules/ix-forms/components/ix-slide-in/ix-slide-in-ref'; import { FormErrorHandlerService } from 'app/modules/ix-forms/services/form-error-handler.service'; import { IxValidatorsService } from 'app/modules/ix-forms/services/ix-validators.service'; import { SnackbarService } from 'app/modules/snackbar/services/snackbar.service'; @@ -76,11 +76,11 @@ export class ServiceSnmpComponent implements OnInit { private dialogService: DialogService, private errorHandler: ErrorHandlerService, private cdr: ChangeDetectorRef, - private router: Router, private formErrorHandler: FormErrorHandlerService, private validation: IxValidatorsService, private snackbar: SnackbarService, private translate: TranslateService, + private slideInRef: IxSlideInRef, ) {} ngOnInit(): void { @@ -101,8 +101,9 @@ export class ServiceSnmpComponent implements OnInit { this.ws.call('snmp.update', [values as SnmpConfigUpdate]).pipe(untilDestroyed(this)).subscribe({ next: () => { this.isFormLoading = false; + this.snackbar.success(this.translate.instant('Service configuration saved')); + this.slideInRef.close(); this.cdr.markForCheck(); - this.router.navigate(['/services']); }, error: (error) => { this.isFormLoading = false; @@ -117,7 +118,6 @@ export class ServiceSnmpComponent implements OnInit { this.ws.call('snmp.config').pipe(untilDestroyed(this)).subscribe({ next: (config) => { this.isFormLoading = false; - this.snackbar.success(this.translate.instant('Service configuration saved')); this.form.patchValue(config); this.cdr.markForCheck(); }, diff --git a/src/app/pages/services/components/service-ssh/service-ssh.component.html b/src/app/pages/services/components/service-ssh/service-ssh.component.html index 1b50a1f3176..09291dc4425 100644 --- a/src/app/pages/services/components/service-ssh/service-ssh.component.html +++ b/src/app/pages/services/components/service-ssh/service-ssh.component.html @@ -1,10 +1,7 @@ + + - -
- - - -
diff --git a/src/app/pages/services/components/service-ups/service-ups.component.scss b/src/app/pages/services/components/service-ups/service-ups.component.scss index 1eef6416f6c..90e68c02f0f 100644 --- a/src/app/pages/services/components/service-ups/service-ups.component.scss +++ b/src/app/pages/services/components/service-ups/service-ups.component.scss @@ -1,6 +1,5 @@ .form-actions { - border-top: 1px solid var(--lines); - padding: 16px 11px; + padding: 0 11px; button { margin-right: 5px; @@ -8,7 +7,6 @@ } .two-columns { - border-top: 1px solid var(--lines); display: flex; > * { diff --git a/src/app/pages/services/components/service-ups/service-ups.component.spec.ts b/src/app/pages/services/components/service-ups/service-ups.component.spec.ts index 0e9073500d9..b680d765fa4 100644 --- a/src/app/pages/services/components/service-ups/service-ups.component.spec.ts +++ b/src/app/pages/services/components/service-ups/service-ups.component.spec.ts @@ -6,6 +6,8 @@ import { createRoutingFactory, mockProvider, Spectator } from '@ngneat/spectator import { mockCall, mockWebsocket } from 'app/core/testing/utils/mock-websocket.utils'; import { UpsConfig, UpsConfigUpdate } from 'app/interfaces/ups-config.interface'; import { IxComboboxHarness } from 'app/modules/ix-forms/components/ix-combobox/ix-combobox.harness'; +import { IxSlideInRef } from 'app/modules/ix-forms/components/ix-slide-in/ix-slide-in-ref'; +import { SLIDE_IN_DATA } from 'app/modules/ix-forms/components/ix-slide-in/ix-slide-in.token'; import { IxFormsModule } from 'app/modules/ix-forms/ix-forms.module'; import { FormErrorHandlerService } from 'app/modules/ix-forms/services/form-error-handler.service'; import { IxFormHarness } from 'app/modules/ix-forms/testing/ix-form.harness'; @@ -62,6 +64,8 @@ describe('ServiceUpsComponent', () => { ]), mockProvider(FormErrorHandlerService), mockProvider(DialogService), + mockProvider(IxSlideInRef), + { provide: SLIDE_IN_DATA, useValue: undefined }, ], }); diff --git a/src/app/pages/services/components/service-ups/service-ups.component.ts b/src/app/pages/services/components/service-ups/service-ups.component.ts index 41802f45f01..a520222a9c0 100644 --- a/src/app/pages/services/components/service-ups/service-ups.component.ts +++ b/src/app/pages/services/components/service-ups/service-ups.component.ts @@ -2,7 +2,6 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit, } from '@angular/core'; import { FormBuilder, Validators } from '@angular/forms'; -import { Router } from '@angular/router'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; import { TranslateService } from '@ngx-translate/core'; import { of } from 'rxjs'; @@ -12,6 +11,7 @@ import helptext from 'app/helptext/services/components/service-ups'; import { UpsConfigUpdate } from 'app/interfaces/ups-config.interface'; import { SimpleAsyncComboboxProvider } from 'app/modules/ix-forms/classes/simple-async-combobox-provider'; import { IxComboboxProvider } from 'app/modules/ix-forms/components/ix-combobox/ix-combobox-provider'; +import { IxSlideInRef } from 'app/modules/ix-forms/components/ix-slide-in/ix-slide-in-ref'; import { FormErrorHandlerService } from 'app/modules/ix-forms/services/form-error-handler.service'; import { SnackbarService } from 'app/modules/snackbar/services/snackbar.service'; import { DialogService } from 'app/services/dialog.service'; @@ -116,9 +116,9 @@ export class ServiceUpsComponent implements OnInit { private errorHandler: ErrorHandlerService, private fb: FormBuilder, private dialogService: DialogService, - private router: Router, private translate: TranslateService, private snackbar: SnackbarService, + private slideInRef: IxSlideInRef, ) {} ngOnInit(): void { @@ -178,8 +178,8 @@ export class ServiceUpsComponent implements OnInit { next: () => { this.isFormLoading = false; this.snackbar.success(this.translate.instant('Service configuration saved')); + this.slideInRef.close(); this.cdr.markForCheck(); - this.router.navigate(['/services']); }, error: (error) => { this.isFormLoading = false; diff --git a/src/app/pages/services/services.component.html b/src/app/pages/services/services.component.html index c54174cbad2..5e89c4a545d 100644 --- a/src/app/pages/services/services.component.html +++ b/src/app/pages/services/services.component.html @@ -31,11 +31,9 @@ [diameter]="30" [matTooltip]=" (service.state === ServiceStatus.Running - ? 'Stopping' - : 'Starting' + ? ('Stopping' | uppercase | translate) + : ('Starting' | uppercase | translate) ) - | uppercase - | translate " > @@ -75,6 +73,7 @@ matTooltipPosition="left" [ixTest]="[service.service, 'edit']" [attr.aria-label]="'Edit' | translate" + [attr.name]="service.name" [matTooltip]="'Configure' | translate" (click)="configureService(service)" > @@ -93,6 +92,5 @@ mat-row [ixTest]="service.service" > -
diff --git a/src/app/pages/services/services.component.spec.ts b/src/app/pages/services/services.component.spec.ts index 47e0a59c137..f765bc97069 100644 --- a/src/app/pages/services/services.component.spec.ts +++ b/src/app/pages/services/services.component.spec.ts @@ -17,6 +17,14 @@ import { ServiceRow } from 'app/interfaces/service.interface'; import { EntityModule } from 'app/modules/entity/entity.module'; import { IxTableModule } from 'app/modules/ix-tables/ix-table.module'; import { IxTableHarness } from 'app/modules/ix-tables/testing/ix-table.harness'; +import { ServiceFtpComponent } from 'app/pages/services/components/service-ftp/service-ftp.component'; +import { ServiceLldpComponent } from 'app/pages/services/components/service-lldp/service-lldp.component'; +import { ServiceNfsComponent } from 'app/pages/services/components/service-nfs/service-nfs.component'; +import { ServiceSmartComponent } from 'app/pages/services/components/service-smart/service-smart.component'; +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 { ServiceUpsComponent } from 'app/pages/services/components/service-ups/service-ups.component'; import { ServicesComponent } from 'app/pages/services/services.component'; import { DialogService } from 'app/services/dialog.service'; import { IscsiService } from 'app/services/iscsi.service'; @@ -83,15 +91,68 @@ describe('ServicesComponent', () => { expect(cells).toEqual(expectedRows); }); - it('should redirect to configure service page when edit button is pressed', async () => { + it('should redirect to configure iSCSI service page when edit button is pressed', async () => { const table = await loader.getHarness(IxTableHarness); - const firstRow = await table.getFirstRow(); - const serviceKey = [...serviceNames.entries()].find(([, value]) => value === firstRow.name)[0]; + const editButton = await table.getHarness(MatButtonHarness.with({ selector: '[name="iSCSI"]' })); + await editButton.click(); + + expect(spectator.inject(Router).navigate).toHaveBeenCalledWith(['/sharing', 'iscsi']); + }); - const editButton = await table.getHarness(MatButtonHarness.with({ selector: '[aria-label="Edit"]' })); + it('should open FTP configuration when edit button is pressed', async () => { + const table = await loader.getHarness(IxTableHarness); + const editButton = await table.getHarness(MatButtonHarness.with({ selector: '[name="FTP"]' })); await editButton.click(); + expect(spectator.inject(IxSlideInService).open).toHaveBeenCalledWith(ServiceFtpComponent, { wide: true }); + }); - expect(spectator.inject(Router).navigate).toHaveBeenCalledWith(['/services', serviceKey]); + it('should open NFS configuration when edit button is pressed', async () => { + const table = await loader.getHarness(IxTableHarness); + const editButton = await table.getHarness(MatButtonHarness.with({ selector: '[name="NFS"]' })); + await editButton.click(); + expect(spectator.inject(IxSlideInService).open).toHaveBeenCalledWith(ServiceNfsComponent, { wide: true }); + }); + + it('should open SNMP configuration when edit button is pressed', async () => { + const table = await loader.getHarness(IxTableHarness); + const editButton = await table.getHarness(MatButtonHarness.with({ selector: '[name="SNMP"]' })); + await editButton.click(); + expect(spectator.inject(IxSlideInService).open).toHaveBeenCalledWith(ServiceSnmpComponent, { wide: true }); + }); + + it('should open UPS configuration when edit button is pressed', async () => { + const table = await loader.getHarness(IxTableHarness); + const editButton = await table.getHarness(MatButtonHarness.with({ selector: '[name="UPS"]' })); + await editButton.click(); + expect(spectator.inject(IxSlideInService).open).toHaveBeenCalledWith(ServiceUpsComponent, { wide: true }); + }); + + it('should open SSH configuration when edit button is pressed', async () => { + const table = await loader.getHarness(IxTableHarness); + const editButton = await table.getHarness(MatButtonHarness.with({ selector: '[name="SSH"]' })); + await editButton.click(); + expect(spectator.inject(IxSlideInService).open).toHaveBeenCalledWith(ServiceSshComponent); + }); + + it('should open SMB configuration when edit button is pressed', async () => { + const table = await loader.getHarness(IxTableHarness); + const editButton = await table.getHarness(MatButtonHarness.with({ selector: '[name="SMB"]' })); + await editButton.click(); + expect(spectator.inject(IxSlideInService).open).toHaveBeenCalledWith(ServiceSmbComponent); + }); + + it('should open S.M.A.R.T. configuration when edit button is pressed', async () => { + const table = await loader.getHarness(IxTableHarness); + const editButton = await table.getHarness(MatButtonHarness.with({ selector: '[name="S.M.A.R.T."]' })); + await editButton.click(); + expect(spectator.inject(IxSlideInService).open).toHaveBeenCalledWith(ServiceSmartComponent); + }); + + it('should open LLDP configuration when edit button is pressed', async () => { + const table = await loader.getHarness(IxTableHarness); + const editButton = await table.getHarness(MatButtonHarness.with({ selector: '[name="LLDP"]' })); + await editButton.click(); + expect(spectator.inject(IxSlideInService).open).toHaveBeenCalledWith(ServiceLldpComponent); }); it('should change service enable state when slide is checked', async () => { diff --git a/src/app/pages/services/services.component.ts b/src/app/pages/services/services.component.ts index 3788244fbd9..1fba8a556bf 100644 --- a/src/app/pages/services/services.component.ts +++ b/src/app/pages/services/services.component.ts @@ -14,8 +14,17 @@ import { ServiceStatus } from 'app/enums/service-status.enum'; import { Service, ServiceRow } from 'app/interfaces/service.interface'; import { WebsocketError } from 'app/interfaces/websocket-error.interface'; import { EmptyService } from 'app/modules/ix-tables/services/empty.service'; +import { ServiceFtpComponent } from 'app/pages/services/components/service-ftp/service-ftp.component'; +import { ServiceLldpComponent } from 'app/pages/services/components/service-lldp/service-lldp.component'; +import { ServiceNfsComponent } from 'app/pages/services/components/service-nfs/service-nfs.component'; +import { ServiceSmartComponent } from 'app/pages/services/components/service-smart/service-smart.component'; +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 { ServiceUpsComponent } from 'app/pages/services/components/service-ups/service-ups.component'; import { DialogService } from 'app/services/dialog.service'; import { IscsiService } from 'app/services/iscsi.service'; +import { IxSlideInService } from 'app/services/ix-slide-in.service'; import { WebSocketService } from 'app/services/ws.service'; @UntilDestroy() @@ -49,6 +58,7 @@ export class ServicesComponent implements OnInit { private iscsiService: IscsiService, private cdr: ChangeDetectorRef, private emptyService: EmptyService, + private slideInService: IxSlideInService, ) {} ngOnInit(): void { @@ -226,11 +236,31 @@ export class ServicesComponent implements OnInit { case ServiceName.Iscsi: this.router.navigate(['/sharing', 'iscsi']); break; + case ServiceName.Ftp: + this.slideInService.open(ServiceFtpComponent, { wide: true }); + break; + case ServiceName.Nfs: + this.slideInService.open(ServiceNfsComponent, { wide: true }); + break; + case ServiceName.Snmp: + this.slideInService.open(ServiceSnmpComponent, { wide: true }); + break; + case ServiceName.Ups: + this.slideInService.open(ServiceUpsComponent, { wide: true }); + break; + case ServiceName.Ssh: + this.slideInService.open(ServiceSshComponent); + break; case ServiceName.Cifs: - this.router.navigate(['/services', 'smb']); + this.slideInService.open(ServiceSmbComponent); + break; + case ServiceName.Smart: + this.slideInService.open(ServiceSmartComponent); + break; + case ServiceName.Lldp: + this.slideInService.open(ServiceLldpComponent); break; default: - this.router.navigate(['/services', row.service]); break; } } diff --git a/src/app/pages/services/services.routing.ts b/src/app/pages/services/services.routing.ts index 5f583e3d4aa..439b16b04d6 100644 --- a/src/app/pages/services/services.routing.ts +++ b/src/app/pages/services/services.routing.ts @@ -1,13 +1,5 @@ import { ModuleWithProviders } from '@angular/core'; import { RouterModule, Routes } from '@angular/router'; -import { ServiceNfsComponent } from 'app/pages/services/components/service-nfs/service-nfs.component'; -import { ServiceSshComponent } from 'app/pages/services/components/service-ssh/service-ssh.component'; -import { ServiceFtpComponent } from './components/service-ftp/service-ftp.component'; -import { ServiceLldpComponent } from './components/service-lldp/service-lldp.component'; -import { ServiceSmartComponent } from './components/service-smart/service-smart.component'; -import { ServiceSmbComponent } from './components/service-smb/service-smb.component'; -import { ServiceSnmpComponent } from './components/service-snmp/service-snmp.component'; -import { ServiceUpsComponent } from './components/service-ups/service-ups.component'; import { ServicesComponent } from './services.component'; export const routes: Routes = [ @@ -16,46 +8,6 @@ export const routes: Routes = [ pathMatch: 'full', component: ServicesComponent, }, - { - data: { title: 'SSH', breadcrumb: 'SSH' }, - path: 'ssh', - component: ServiceSshComponent, - }, - { - data: { title: 'FTP', breadcrumb: 'FTP' }, - path: 'ftp', - component: ServiceFtpComponent, - }, - { - data: { title: 'LLDP', breadcrumb: 'LLDP' }, - path: 'lldp', - component: ServiceLldpComponent, - }, - { - data: { title: 'S.M.A.R.T.', breadcrumb: 'S.M.A.R.T.' }, - path: 'smartd', - component: ServiceSmartComponent, - }, - { - data: { title: 'NFS', breadcrumb: 'NFS' }, - path: 'nfs', - component: ServiceNfsComponent, - }, - { - data: { title: 'UPS', breadcrumb: 'UPS' }, - path: 'ups', - component: ServiceUpsComponent, - }, - { - data: { title: 'SMB', breadcrumb: 'SMB' }, - path: 'smb', - component: ServiceSmbComponent, - }, - { - data: { title: 'SNMP', breadcrumb: 'SNMP' }, - path: 'snmp', - component: ServiceSnmpComponent, - }, ]; export const routing: ModuleWithProviders = RouterModule.forChild(routes); diff --git a/src/app/pages/storage/components/dashboard-pool/topology-card/topology-card.component.ts b/src/app/pages/storage/components/dashboard-pool/topology-card/topology-card.component.ts index 9187b033b7e..c7cfc97e1ac 100644 --- a/src/app/pages/storage/components/dashboard-pool/topology-card/topology-card.component.ts +++ b/src/app/pages/storage/components/dashboard-pool/topology-card/topology-card.component.ts @@ -17,6 +17,7 @@ import { TopologyDisk, TopologyItem, } from 'app/interfaces/storage.interface'; +import { isDraidLayout } from 'app/pages/storage/modules/pool-manager/utils/topology.utils'; import { StorageService } from 'app/services/storage.service'; interface TopologyState { @@ -141,7 +142,14 @@ export class TopologyCardComponent implements OnInit, OnChanges { : this.disks?.find((disk) => disk.name === (vdevs[0] as TopologyDisk)?.disk)?.size; outputString = `${vdevs.length} x `; - outputString += vdevWidth ? `${type} | ${vdevWidth} wide | ` : ''; + // TODO: Needs to be translated. + if (vdevWidth) { + if (isDraidLayout(type)) { + outputString += `${type} | ${vdevWidth} children | `; + } else { + outputString += `${type} | ${vdevWidth} wide | `; + } + } if (size) { outputString += filesize(size, { standard: 'iec' }); diff --git a/src/app/pages/storage/modules/pool-manager/components/add-vdevs/store/add-vdevs-store.service.ts b/src/app/pages/storage/modules/pool-manager/components/add-vdevs/store/add-vdevs-store.service.ts index bf4cd7bb130..c9bb7a0891e 100644 --- a/src/app/pages/storage/modules/pool-manager/components/add-vdevs/store/add-vdevs-store.service.ts +++ b/src/app/pages/storage/modules/pool-manager/components/add-vdevs/store/add-vdevs-store.service.ts @@ -26,6 +26,7 @@ const initialState: AddVdevsState = { @Injectable() export class AddVdevsStore extends ComponentStore { readonly isLoading$ = this.select((state) => state.isLoading); + // TODO: Remove clone deep. readonly pool$ = this.select((state) => _.cloneDeep(state.pool)); readonly poolDisks$ = this.select((state) => state.poolDisks); diff --git a/src/app/pages/storage/modules/pool-manager/components/existing-configuration-preview/existing-configuration-preview.component.ts b/src/app/pages/storage/modules/pool-manager/components/existing-configuration-preview/existing-configuration-preview.component.ts index 9634eed966b..0beb88b864f 100644 --- a/src/app/pages/storage/modules/pool-manager/components/existing-configuration-preview/existing-configuration-preview.component.ts +++ b/src/app/pages/storage/modules/pool-manager/components/existing-configuration-preview/existing-configuration-preview.component.ts @@ -24,6 +24,8 @@ const defaultCategory: PoolManagerTopologyCategory = { treatDiskSizeAsMinimum: false, vdevs: [], hasCustomDiskSelection: false, + draidSpareDisks: null, + draidDataDisks: null, }; @UntilDestroy() @Component({ diff --git a/src/app/pages/storage/modules/pool-manager/components/pool-manager-wizard/components/layout-step/automated-disk-selection/automated-disk-selection.component.html b/src/app/pages/storage/modules/pool-manager/components/pool-manager-wizard/components/layout-step/automated-disk-selection/automated-disk-selection.component.html index 9e2e461d5b2..0dc93fde758 100644 --- a/src/app/pages/storage/modules/pool-manager/components/pool-manager-wizard/components/layout-step/automated-disk-selection/automated-disk-selection.component.html +++ b/src/app/pages/storage/modules/pool-manager/components/pool-manager-wizard/components/layout-step/automated-disk-selection/automated-disk-selection.component.html @@ -1,61 +1,27 @@ - -
- -
-
-
-

{{ 'Automated Disk Selection' | translate }}

- +
+ +
- + - - - - - -
-
-

{{ 'Advanced Options' | translate }}

- -

{{ 'Manual disk selection allows you to create VDEVs and add disks to those VDEVs individually.' | translate }}

- - -
-
-
+ + + diff --git a/src/app/pages/storage/modules/pool-manager/components/pool-manager-wizard/components/layout-step/automated-disk-selection/automated-disk-selection.component.scss b/src/app/pages/storage/modules/pool-manager/components/pool-manager-wizard/components/layout-step/automated-disk-selection/automated-disk-selection.component.scss index 51fbaa8e4a9..b58eba77230 100644 --- a/src/app/pages/storage/modules/pool-manager/components/pool-manager-wizard/components/layout-step/automated-disk-selection/automated-disk-selection.component.scss +++ b/src/app/pages/storage/modules/pool-manager/components/pool-manager-wizard/components/layout-step/automated-disk-selection/automated-disk-selection.component.scss @@ -1,26 +1,8 @@ -.content-container { - border: 1px solid var(--lines); - display: flex; - margin: 20px 0; - padding: 20px 0; - - .automated-disk-selection-container { - border-right: 1px solid var(--lines); - padding: 0 30px; - width: 100%; - } - - .advanced-options-container { - padding: 0 30px; - width: 100%; - } -} - .layout-container { max-width: 50%; padding: 0 30px; } -.manual-disk-selection { - margin-top: 25px; +.required-disks-hint { + padding-left: 30px; } diff --git a/src/app/pages/storage/modules/pool-manager/components/pool-manager-wizard/components/layout-step/automated-disk-selection/automated-disk-selection.component.spec.ts b/src/app/pages/storage/modules/pool-manager/components/pool-manager-wizard/components/layout-step/automated-disk-selection/automated-disk-selection.component.spec.ts index f388365d70a..ba5d0a4c50f 100644 --- a/src/app/pages/storage/modules/pool-manager/components/pool-manager-wizard/components/layout-step/automated-disk-selection/automated-disk-selection.component.spec.ts +++ b/src/app/pages/storage/modules/pool-manager/components/pool-manager-wizard/components/layout-step/automated-disk-selection/automated-disk-selection.component.spec.ts @@ -1,20 +1,22 @@ import { HarnessLoader } from '@angular/cdk/testing'; import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; -import { NgControl, ReactiveFormsModule } from '@angular/forms'; -import { MatButtonHarness } from '@angular/material/button/testing'; -import { FormBuilder } from '@ngneat/reactive-forms'; +import { ReactiveFormsModule } from '@angular/forms'; import { createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest'; -import { Subject, of } from 'rxjs'; -import { TiB } from 'app/constants/bytes.constant'; -import { DiskType } from 'app/enums/disk-type.enum'; +import { MockComponents } from 'ng-mocks'; +import { Subject } from 'rxjs'; import { CreateVdevLayout, VdevType } from 'app/enums/v-dev-type.enum'; import { UnusedDisk } from 'app/interfaces/storage.interface'; -import { IxCheckboxHarness } from 'app/modules/ix-forms/components/ix-checkbox/ix-checkbox.harness'; import { IxSelectHarness } from 'app/modules/ix-forms/components/ix-select/ix-select.harness'; import { IxFormsModule } from 'app/modules/ix-forms/ix-forms.module'; import { AutomatedDiskSelectionComponent, } from 'app/pages/storage/modules/pool-manager/components/pool-manager-wizard/components/layout-step/automated-disk-selection/automated-disk-selection.component'; +import { + DraidSelectionComponent, +} from 'app/pages/storage/modules/pool-manager/components/pool-manager-wizard/components/layout-step/automated-disk-selection/draid-selection/draid-selection.component'; +import { + NormalSelectionComponent, +} from 'app/pages/storage/modules/pool-manager/components/pool-manager-wizard/components/layout-step/automated-disk-selection/normal-selection/normal-selection.component'; import { PoolManagerStore } from 'app/pages/storage/modules/pool-manager/store/pool-manager.store'; describe('AutomatedDiskSelection', () => { @@ -25,62 +27,8 @@ describe('AutomatedDiskSelection', () => { const resetStep$ = new Subject(); let layoutSelect: IxSelectHarness; - let widthSelect: IxSelectHarness; - let vdevsSelect: IxSelectHarness; - let sizeSelect: IxSelectHarness; - const unusedDisks: UnusedDisk[] = [ - { - devname: 'sdo', - size: 12 * TiB, - type: DiskType.Hdd, - }, - { - devname: 'sdr', - size: 12 * TiB, - type: DiskType.Hdd, - }, - { - devname: 'sdq', - size: 12 * TiB, - type: DiskType.Hdd, - }, - { - devname: 'sdw', - size: 12 * TiB, - type: DiskType.Hdd, - }, - { - devname: 'sdt', - size: 12 * TiB, - type: DiskType.Hdd, - }, - { - devname: 'sdu', - size: 12 * TiB, - type: DiskType.Hdd, - }, - { - devname: 'sdh', - size: 12 * TiB, - type: DiskType.Hdd, - }, - { - devname: 'sdg', - size: 14 * TiB, - type: DiskType.Hdd, - }, - { - devname: 'sdj', - size: 14 * TiB, - type: DiskType.Hdd, - }, - { - devname: 'sdk', - size: 1 * TiB, - type: DiskType.Hdd, - }, - ] as UnusedDisk[]; + const inventory: UnusedDisk[] = [] as UnusedDisk[]; const createComponent = createComponentFactory({ component: AutomatedDiskSelectionComponent, @@ -88,28 +36,16 @@ describe('AutomatedDiskSelection', () => { ReactiveFormsModule, IxFormsModule, ], + declarations: [ + MockComponents( + NormalSelectionComponent, + DraidSelectionComponent, + ), + ], providers: [ - mockProvider(NgControl), - mockProvider(FormBuilder), mockProvider(PoolManagerStore, { startOver$, resetStep$, - getLayoutsForVdevType: jest.fn((vdevType: VdevType) => { - switch (vdevType) { - case VdevType.Cache: - return of([CreateVdevLayout.Stripe]); - case VdevType.Dedup: - return of([CreateVdevLayout.Mirror]); - case VdevType.Log: - return of([CreateVdevLayout.Mirror, CreateVdevLayout.Stripe]); - case VdevType.Spare: - return of([CreateVdevLayout.Stripe]); - case VdevType.Special: - return of([CreateVdevLayout.Mirror]); - default: - return of([...Object.values(CreateVdevLayout)]); - } - }), }), ], }); @@ -117,270 +53,67 @@ describe('AutomatedDiskSelection', () => { beforeEach(async () => { spectator = createComponent({ props: { + inventory, canChangeLayout: true, type: VdevType.Data, - inventory: [...unusedDisks], limitLayouts: Object.values(CreateVdevLayout), + isStepActive: false, }, }); loader = TestbedHarnessEnvironment.loader(spectator.fixture); - layoutSelect = await loader.getHarnessOrNull(IxSelectHarness.with({ label: 'Layout' })); - widthSelect = await loader.getHarness(IxSelectHarness.with({ label: 'Width' })); - vdevsSelect = await loader.getHarness(IxSelectHarness.with({ label: 'Number of VDEVs' })); - sizeSelect = await loader.getHarness(IxSelectHarness.with({ label: 'Disk Size' })); - }); - - it('updates width and vdev options when layout changes to mirror', async () => { - await layoutSelect.setValue('Mirror'); - await sizeSelect.setValue('12 TiB (HDD)'); - - expect(await widthSelect.getOptionLabels()) - .toStrictEqual(['2', '3', '4', '5', '6', '7']); - - await widthSelect.setValue('2'); - - expect(await vdevsSelect.getOptionLabels()).toStrictEqual(['1', '2', '3']); }); - it('updates width and vdev options when layout changes to Raidz1', async () => { - await layoutSelect.setValue('RAIDZ1'); - await sizeSelect.setValue('12 TiB (HDD)'); - - expect(await widthSelect.getOptionLabels()) - .toStrictEqual(['3', '4', '5', '6', '7']); - - await widthSelect.setValue('3'); - - expect(await vdevsSelect.getOptionLabels()) - .toStrictEqual(['1', '2']); - }); + it('shows NormalSelectionComponent for non-dRAID layouts', async () => { + let normalSelection = spectator.query(NormalSelectionComponent); + expect(normalSelection).not.toBeNull(); + expect(normalSelection.type).toBe(VdevType.Data); + expect(normalSelection.inventory).toBe(inventory); + expect(normalSelection.isStepActive).toBe(false); - it('updates width and vdev options when layout changes to Raidz2', async () => { - await layoutSelect.setValue('RAIDZ2'); - await sizeSelect.setValue('12 TiB (HDD)'); - - expect(await widthSelect.getOptionLabels()) - .toStrictEqual(['4', '5', '6', '7']); + await layoutSelect.setValue('Mirror'); - await widthSelect.setValue('4'); + normalSelection = spectator.query(NormalSelectionComponent); + expect(normalSelection).not.toBeNull(); + expect(normalSelection.layout).toBe(CreateVdevLayout.Mirror); - expect(await vdevsSelect.getOptionLabels()).toStrictEqual(['1']); + expect(spectator.query(DraidSelectionComponent)).toBeNull(); }); - it('updates width and vdev options when layout changes to Raidz3', async () => { - await layoutSelect.setValue('RAIDZ3'); - await sizeSelect.setValue('12 TiB (HDD)'); + it('shows DraidSelectionComponent for dRAID layouts', async () => { + await layoutSelect.setValue('dRAID2'); - expect(await widthSelect.getOptionLabels()) - .toStrictEqual(['5', '6', '7']); + const draidSelection = spectator.query(DraidSelectionComponent); + expect(draidSelection).not.toBeNull(); + expect(draidSelection.layout).toBe(CreateVdevLayout.Draid2); + expect(draidSelection.inventory).toBe(inventory); + expect(draidSelection.type).toBe(VdevType.Data); + expect(draidSelection.isStepActive).toBe(false); - await widthSelect.setValue('5'); - - expect(await vdevsSelect.getOptionLabels()).toStrictEqual(['1']); + expect(spectator.query(NormalSelectionComponent)).toBeNull(); }); - it('updates width and vdev options when layout changes to Stripe', async () => { - await layoutSelect.setValue('Stripe'); - await sizeSelect.setValue('12 TiB (HDD)'); - - expect(await widthSelect.getOptionLabels()) - .toStrictEqual(['1', '2', '3', '4', '5', '6', '7']); - - await widthSelect.setValue('1'); + it('doesnt let the layout change when canChangeLayout is false', async () => { + spectator.setInput('canChangeLayout', false); - expect(await vdevsSelect.getOptionLabels()) - .toStrictEqual(['1', '2', '3', '4', '5', '6', '7']); + layoutSelect = await loader.getHarnessOrNull(IxSelectHarness.with({ label: 'Layout' })); + expect(layoutSelect).toBeNull(); }); - it('updates the width options when layout changes after already selecting values', async () => { - await layoutSelect.setValue('Stripe'); - await sizeSelect.setValue('12 TiB (HDD)'); - - expect(await widthSelect.getOptionLabels()) - .toStrictEqual(['1', '2', '3', '4', '5', '6', '7']); - - await widthSelect.setValue('1'); - - expect(await vdevsSelect.getOptionLabels()) - .toStrictEqual(['1', '2', '3', '4', '5', '6', '7']); - + it('resets to default values when store emits a reset event', async () => { await layoutSelect.setValue('Mirror'); - expect(await widthSelect.getValue()).toBe(''); - expect(await widthSelect.getOptionLabels()) - .toStrictEqual(['2', '3', '4', '5', '6', '7']); - }); - - it('auto fills select when only one value is available', async () => { - spectator.component.isStepActive = true; - spectator.fixture.detectChanges(); - await layoutSelect.setValue('Stripe'); - await sizeSelect.setValue('1 TiB (HDD)'); - - expect(await widthSelect.getOptionLabels()).toStrictEqual(['1']); - - const widthValue = await widthSelect.getValue(); - expect(widthValue).toBe('1'); - - expect(await vdevsSelect.getOptionLabels()).toStrictEqual(['1']); - - const vdevsValue = await widthSelect.getValue(); - expect(vdevsValue).toBe('1'); - }); - - it('doesnt let the layout change', async () => { - spectator.setInput('canChangeLayout', false); - - const layout = await loader.getHarnessOrNull(IxSelectHarness.with({ label: 'Layout' })); - expect(layout).toBeNull(); - }); + startOver$.next(); - it('disables dependent fields until they are valid', async () => { - expect(await widthSelect.isDisabled()).toBeTruthy(); - expect(await vdevsSelect.isDisabled()).toBeTruthy(); - await layoutSelect.setValue('Mirror'); - expect(await vdevsSelect.isDisabled()).toBeTruthy(); - expect(await widthSelect.isDisabled()).toBeTruthy(); - await sizeSelect.setValue('12 TiB (HDD)'); - expect(await widthSelect.isDisabled()).toBeFalsy(); - expect(await vdevsSelect.isDisabled()).toBeTruthy(); - await widthSelect.setValue('2'); - expect(await widthSelect.isDisabled()).toBeFalsy(); - expect(await vdevsSelect.isDisabled()).toBeFalsy(); + expect(await layoutSelect.getValue()).toBe(''); }); - it('saves the topology layout on form updates', async () => { - const poolManagerStore = spectator.inject(PoolManagerStore); - + it('updates layout in store when it is changed', async () => { await layoutSelect.setValue('Mirror'); - expect(poolManagerStore.setAutomaticTopologyCategory).toHaveBeenLastCalledWith(VdevType.Data, { - layout: CreateVdevLayout.Mirror, - diskSize: null, - diskType: null, - width: undefined, - vdevsNumber: undefined, - treatDiskSizeAsMinimum: undefined, - }); - - await sizeSelect.setValue('12 TiB (HDD)'); - const checkValues = { - layout: CreateVdevLayout.Mirror, - diskSize: 12 * TiB, - diskType: DiskType.Hdd, - width: null as number, - vdevsNumber: undefined as number, - treatDiskSizeAsMinimum: false, - }; - - expect(poolManagerStore.setAutomaticTopologyCategory).toHaveBeenLastCalledWith(VdevType.Data, checkValues); - - await widthSelect.setValue('2'); - expect(poolManagerStore.setAutomaticTopologyCategory).toHaveBeenLastCalledWith(VdevType.Data, { - layout: CreateVdevLayout.Mirror, - diskSize: 12 * TiB, - diskType: DiskType.Hdd, - width: 2, - vdevsNumber: null, - treatDiskSizeAsMinimum: false, - }); - - await vdevsSelect.setValue('2'); - expect(poolManagerStore.setAutomaticTopologyCategory).toHaveBeenLastCalledWith(VdevType.Data, { - layout: CreateVdevLayout.Mirror, - diskSize: 12 * TiB, - diskType: DiskType.Hdd, - width: 2, - vdevsNumber: 2, - treatDiskSizeAsMinimum: false, - }); - const treatDiskSizeAsMinimumCheckbox = await loader.getHarness( - IxCheckboxHarness.with({ label: 'Treat Disk Size as Minimum' }), + expect(spectator.inject(PoolManagerStore).setTopologyCategoryLayout).toHaveBeenCalledWith( + VdevType.Data, + CreateVdevLayout.Mirror, ); - - await treatDiskSizeAsMinimumCheckbox.setValue(true); - expect(poolManagerStore.setAutomaticTopologyCategory).toHaveBeenLastCalledWith(VdevType.Data, { - layout: CreateVdevLayout.Mirror, - diskSize: 12 * TiB, - diskType: DiskType.Hdd, - width: 2, - vdevsNumber: 2, - treatDiskSizeAsMinimum: true, - }); - }); - - it('opens manual disk selection modal', async () => { - jest.spyOn(spectator.component.manualSelectionClicked, 'emit'); - const manualSelectionButton = await loader.getHarness(MatButtonHarness.with({ text: 'Manual Disk Selection' })); - await manualSelectionButton.click(); - expect(spectator.component.manualSelectionClicked.emit).toHaveBeenCalled(); - }); - - describe('treat Disk Size as minimum', () => { - it('updates width dropdown to include disks with larger size when checkbox is ticked', async () => { - await layoutSelect.setValue('Stripe'); - await sizeSelect.setValue('12 TiB (HDD)'); - - expect(await widthSelect.getOptionLabels()).toStrictEqual(['1', '2', '3', '4', '5', '6', '7']); - - const treatDiskSizeAsMinimumCheckbox = await loader.getHarness( - IxCheckboxHarness.with({ label: 'Treat Disk Size as Minimum' }), - ); - await treatDiskSizeAsMinimumCheckbox.setValue(true); - - expect(await widthSelect.getOptionLabels()).toStrictEqual(['1', '2', '3', '4', '5', '6', '7', '8', '9']); - }); - }); - - it('resets form if Start Over confirmed', async () => { - await layoutSelect.setValue('Stripe'); - await sizeSelect.setValue('12 TiB (HDD)'); - - const form = spectator.component.form; - - form.patchValue({ treatDiskSizeAsMinimum: true }); - - expect(form.value).toStrictEqual({ - layout: CreateVdevLayout.Stripe, - sizeAndType: [13194139533312, DiskType.Hdd], - treatDiskSizeAsMinimum: true, - width: null, - }); - - const store = spectator.inject(PoolManagerStore); - store.startOver$.next(); - - expect(form.value).toStrictEqual({ - layout: null, - sizeAndType: [null, null], - treatDiskSizeAsMinimum: false, - width: null, - }); - }); - - it('resets step if Reset Step message received from store', async () => { - await layoutSelect.setValue('Stripe'); - await sizeSelect.setValue('12 TiB (HDD)'); - - const form = spectator.component.form; - form.patchValue({ treatDiskSizeAsMinimum: true }); - - expect(form.value).toStrictEqual({ - layout: CreateVdevLayout.Stripe, - sizeAndType: [13194139533312, DiskType.Hdd], - treatDiskSizeAsMinimum: true, - width: null, - }); - - const store = spectator.inject(PoolManagerStore); - store.resetStep$.next(spectator.component.type); - - expect(form.value).toStrictEqual({ - layout: null, - sizeAndType: [null, null], - treatDiskSizeAsMinimum: false, - width: null, - }); }); }); diff --git a/src/app/pages/storage/modules/pool-manager/components/pool-manager-wizard/components/layout-step/automated-disk-selection/automated-disk-selection.component.ts b/src/app/pages/storage/modules/pool-manager/components/pool-manager-wizard/components/layout-step/automated-disk-selection/automated-disk-selection.component.ts index 5d2e94c22cb..ebb623dc859 100644 --- a/src/app/pages/storage/modules/pool-manager/components/pool-manager-wizard/components/layout-step/automated-disk-selection/automated-disk-selection.component.ts +++ b/src/app/pages/storage/modules/pool-manager/components/pool-manager-wizard/components/layout-step/automated-disk-selection/automated-disk-selection.component.ts @@ -4,26 +4,19 @@ import { EventEmitter, Input, OnChanges, - OnInit, Output, } from '@angular/core'; -import { FormBuilder, Validators } from '@angular/forms'; +import { FormControl, Validators } from '@angular/forms'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; -import filesize from 'filesize'; -import _ from 'lodash'; -import { - distinctUntilChanged, of, take, -} from 'rxjs'; -import { DiskType } from 'app/enums/disk-type.enum'; +import { merge, of } from 'rxjs'; +import { filter } from 'rxjs/operators'; import { CreateVdevLayout, vdevLayoutOptions, VdevType } from 'app/enums/v-dev-type.enum'; -import { Option, SelectOption } from 'app/interfaces/option.interface'; +import { SelectOption } from 'app/interfaces/option.interface'; import { IxSimpleChanges } from 'app/interfaces/simple-changes.interface'; import { UnusedDisk } from 'app/interfaces/storage.interface'; -import { DiskTypeSizeMap } from 'app/pages/storage/modules/pool-manager/interfaces/disk-type-size-map.interface'; -import { SizeAndType } from 'app/pages/storage/modules/pool-manager/interfaces/size-and-type.interface'; import { PoolManagerStore } from 'app/pages/storage/modules/pool-manager/store/pool-manager.store'; -import { getDiskTypeSizeMap } from 'app/pages/storage/modules/pool-manager/utils/get-disk-type-size-map.utils'; -import { minDisksPerLayout } from 'app/pages/storage/modules/pool-manager/utils/min-disks-per-layout.constant'; +import { hasDeepChanges, setValueIfNotSame } from 'app/pages/storage/modules/pool-manager/utils/form.utils'; +import { isDraidLayout } from 'app/pages/storage/modules/pool-manager/utils/topology.utils'; @UntilDestroy() @Component({ @@ -32,7 +25,7 @@ import { minDisksPerLayout } from 'app/pages/storage/modules/pool-manager/utils/ styleUrls: ['./automated-disk-selection.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class AutomatedDiskSelectionComponent implements OnInit, OnChanges { +export class AutomatedDiskSelectionComponent implements OnChanges { @Input() isStepActive: boolean; @Input() type: VdevType; @Input() inventory: UnusedDisk[] = []; @@ -41,309 +34,51 @@ export class AutomatedDiskSelectionComponent implements OnInit, OnChanges { @Output() manualSelectionClicked = new EventEmitter(); - form = this.formBuilder.group({ - layout: [CreateVdevLayout.Stripe, Validators.required], - sizeAndType: [[null, null] as SizeAndType, Validators.required], - width: [{ value: null as number, disabled: true }, Validators.required], - treatDiskSizeAsMinimum: [{ value: false, disabled: true }], - vdevsNumber: [{ value: null as number, disabled: true }, Validators.required], - }); - - protected compareSizeAndTypeWith = _.isEqual; + readonly layoutControl = new FormControl(null as CreateVdevLayout, Validators.required); protected vdevLayoutOptions$ = of[]>([]); - protected diskSizeAndTypeOptions$ = of([]); - protected widthOptions$ = of([]); - protected numberOptions$ = of([]); - - private minDisks = minDisksPerLayout; - private sizeDisksMap: DiskTypeSizeMap = { [DiskType.Hdd]: {}, [DiskType.Ssd]: {} }; constructor( - private formBuilder: FormBuilder, protected store: PoolManagerStore, - ) {} - - get selectedDiskSize(): number { - return this.form.controls.sizeAndType.value?.[0]; - } - - get isSizeSelected(): boolean { - return !!this.form.value.sizeAndType?.length - && !!this.form.value.sizeAndType[0] - && !!this.form.value.sizeAndType[1]; - } - - get isLayoutSelected(): boolean { - return !!this.form.value.layout; - } - - get isWidthSelected(): boolean { - return !!this.form.value.width; - } - - get selectedDiskType(): DiskType { - return this.form.controls.sizeAndType.value?.[1]; - } - - ngOnInit(): void { - this.initControls(); - - this.store.startOver$.pipe(untilDestroyed(this)).subscribe(() => { - this.resetToDefaults(); - }); - - this.store.resetStep$.pipe(untilDestroyed(this)).subscribe((vdevType: VdevType) => { - if (vdevType === this.type) { - this.resetToDefaults(); - } - }); - } - - resetToDefaults(): void { - this.form.reset({ - layout: this.canChangeLayout ? null : this.limitLayouts[0], - sizeAndType: [null, null], - width: null, - treatDiskSizeAsMinimum: false, - vdevsNumber: null, - }); + ) { + this.updateStoreOnChanges(); + this.listenForResetEvents(); } ngOnChanges(changes: IxSimpleChanges): void { - if ( - changes.inventory?.currentValue - && !_.isEqual(changes.inventory.currentValue, changes.inventory.previousValue) - ) { - this.updateLayoutOptionsFromLimitedLayouts(this.limitLayouts); - this.updateDiskSizeOptions(); - return; - } - if ( - changes.limitLayouts?.currentValue - && !_.isEqual(changes.limitLayouts.currentValue, changes.limitLayouts.previousValue) - ) { + if (hasDeepChanges(changes, 'limitLayouts')) { this.updateLayoutOptionsFromLimitedLayouts(changes.limitLayouts.currentValue); } } - updateLayoutOptionsFromLimitedLayouts(limitLayouts: CreateVdevLayout[]): void { - this.vdevLayoutOptions$ = of( - limitLayouts.map( - (layout) => ({ - label: Object.keys(CreateVdevLayout)[Object.values(CreateVdevLayout).indexOf(layout)], - value: layout, - }), - ), - ); - const isChangeLayoutFalse = this.canChangeLayout !== null - && this.canChangeLayout !== undefined - && !this.canChangeLayout; - const isValueSame = limitLayouts[0] === this.form.controls.layout.value; - if (isChangeLayoutFalse && limitLayouts.length && !isValueSame) { - this.form.controls.layout.setValue(limitLayouts[0]); - } - this.updateWidthOptions(); + protected get usesDraidLayout(): boolean { + return isDraidLayout(this.layoutControl.value); } - openManualDiskSelection(): void { - this.manualSelectionClicked.emit(); - } - - /** - * Dependency between selects as follows: - * size -> layout -> width -> number - */ - private initControls(): void { - this.form.controls.layout.valueChanges.pipe( - distinctUntilChanged(), - untilDestroyed(this), - ).subscribe((layout) => { - if (this.isSizeSelected && !!layout) { - if (this.form.controls.width.disabled) { - this.form.controls.width.enable(); - } - if (this.form.controls.treatDiskSizeAsMinimum.disabled) { - this.form.controls.treatDiskSizeAsMinimum.enable(); - } - if (this.isWidthSelected && this.form.controls.vdevsNumber.disabled) { - this.form.controls.vdevsNumber.enable(); - } - } - - this.updateWidthOptions(); - }); - - this.form.controls.sizeAndType.valueChanges.pipe( - distinctUntilChanged(), - untilDestroyed(this), - ).subscribe((sizeAndType) => { - if (sizeAndType?.length && this.isLayoutSelected) { - if (this.form.controls.width.disabled) { - this.form.controls.width.enable(); - } - if (this.form.controls.treatDiskSizeAsMinimum.disabled) { - this.form.controls.treatDiskSizeAsMinimum.enable(); - } - if (this.isWidthSelected && this.form.controls.vdevsNumber.disabled) { - this.form.controls.vdevsNumber.enable(); - } - } - - this.updateLayoutOptions(); - }); - - this.form.controls.width.valueChanges.pipe( - distinctUntilChanged(), - untilDestroyed(this), - ).subscribe((width) => { - if (this.isSizeSelected && this.isLayoutSelected && !!width && this.form.controls.vdevsNumber.disabled) { - this.form.controls.vdevsNumber.enable(); - } - this.updateNumberOptions(); - }); - - this.form.controls.treatDiskSizeAsMinimum.valueChanges.pipe(untilDestroyed(this)).subscribe(() => { - this.updateWidthOptions(); - }); - - this.form.valueChanges.pipe( - distinctUntilChanged(), - untilDestroyed(this), - ).subscribe(() => { - this.updateLayout(); + private updateStoreOnChanges(): void { + this.layoutControl.valueChanges.pipe(untilDestroyed(this)).subscribe((layout) => { + this.store.setTopologyCategoryLayout(this.type, layout); }); } - private updateLayout(): void { - const values = this.form.value; - this.store.setAutomaticTopologyCategory(this.type, { - layout: values.layout, - diskSize: this.selectedDiskSize, - diskType: this.selectedDiskType, - width: values.width, - vdevsNumber: values.vdevsNumber, - treatDiskSizeAsMinimum: values.treatDiskSizeAsMinimum, - }); - } - - private updateLayoutOptions(): void { - const layoutOptions = vdevLayoutOptions.filter((option) => { - return this.inventory.length >= this.minDisks[option.value]; - }); - - const isValueNull = this.form.controls.layout.value === null; - if (!isValueNull && !layoutOptions.some((option) => option.value === this.form.controls.layout.value)) { - this.form.controls.layout.setValue(this.canChangeLayout ? null : this.limitLayouts[0], { emitEvent: false }); - } - this.store.getLayoutsForVdevType(this.type) - .pipe( - take(1), - untilDestroyed(this), - ) - .subscribe({ - next: (allowedVdevTypes) => { - this.vdevLayoutOptions$ = of(layoutOptions.filter( - (layout) => !!allowedVdevTypes.includes(layout.value), - )); - this.updateWidthOptions(); - }, + private listenForResetEvents(): void { + merge( + this.store.startOver$, + this.store.resetStep$.pipe(filter((vdevType) => vdevType === this.type)), + ) + .pipe(untilDestroyed(this)) + .subscribe(() => { + this.layoutControl.setValue(this.canChangeLayout ? null : this.limitLayouts[0]); }); } - private updateDiskSizeOptions(): void { - this.sizeDisksMap = getDiskTypeSizeMap(this.inventory); - - const hddOptions = Object.keys(this.sizeDisksMap[DiskType.Hdd]) - .map((size): SelectOption => ({ - label: `${filesize(Number(size), { standard: 'iec' })} (HDD)`, - value: [Number(size), DiskType.Hdd], - })); - - const ssdOptions = Object.keys(this.sizeDisksMap[DiskType.Ssd]) - .map((size): SelectOption => ({ - label: `${filesize(Number(size), { standard: 'iec' })} (SSD)`, - value: [Number(size), DiskType.Ssd], - })); - - const options = [...hddOptions, ...ssdOptions].sort((a, b) => a.value[0] - b.value[0]); - - this.diskSizeAndTypeOptions$ = of(options); - - if (options.length === 1 && this.isStepActive) { - this.form.controls.sizeAndType.setValue(options[0].value, { emitEvent: false }); - } - - this.updateLayoutOptions(); - } - - private getNumberOfSuitableDisks(): number { - if (!this.form.controls.treatDiskSizeAsMinimum.value) { - return this.sizeDisksMap[this.selectedDiskType][this.selectedDiskSize]?.length; - } - - return this.inventory.filter((disk) => disk.size >= this.selectedDiskSize).length; - } - - private updateWidthOptions(): void { - if (!this.selectedDiskType || !this.selectedDiskSize) { - return; - } - const length = this.getNumberOfSuitableDisks(); - const minRequired = this.minDisks[this.form.controls.layout.value]; - let widthOptions: Option[]; - - if (length && minRequired && length >= minRequired) { - widthOptions = _.range(minRequired, length + 1).map((item) => ({ - label: `${item}`, - value: item, - })); - } else { - widthOptions = []; - } - - this.widthOptions$ = of(widthOptions); - const isValueNull = this.form.controls.width.value === null; - - if (!isValueNull && !widthOptions.some((option) => option.value === this.form.controls.width.value)) { - this.form.controls.width.setValue(null, { emitEvent: false }); - } - - if (widthOptions.length === 1 && this.isStepActive) { - this.form.controls.width.setValue(+widthOptions[0].value, { emitEvent: false }); - } - - this.updateNumberOptions(); - } - - private updateNumberOptions(): void { - if (!this.selectedDiskType || !this.selectedDiskSize) { - return; - } - - const width = this.form.controls.width.value; - const length = this.getNumberOfSuitableDisks(); - let nextNumberOptions: SelectOption[] = []; - - if (width && length) { - const maxNumber = Math.floor(length / width); - nextNumberOptions = Array.from({ length: maxNumber }).map((value, index) => ({ - label: `${index + 1}`, - value: index + 1, - })); - } else { - nextNumberOptions = []; - } - - this.numberOptions$ = of(nextNumberOptions); - const isValueNull = this.form.controls.vdevsNumber.value === null; - - if (!isValueNull && !nextNumberOptions.some((option) => option.value === this.form.controls.vdevsNumber.value)) { - this.form.controls.vdevsNumber.setValue(null, { emitEvent: false }); - } - - if (nextNumberOptions.length === 1 && this.isStepActive) { - this.form.controls.vdevsNumber.setValue(+nextNumberOptions[0].value, { emitEvent: false }); + private updateLayoutOptionsFromLimitedLayouts(limitLayouts: CreateVdevLayout[]): void { + const allowedLayouts = vdevLayoutOptions.filter((option) => limitLayouts.includes(option.value)); + this.vdevLayoutOptions$ = of(allowedLayouts); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-boolean-literal-compare + const cannotChangeLayout = this.canChangeLayout === false; + if (cannotChangeLayout && limitLayouts.length) { + setValueIfNotSame(this.layoutControl, limitLayouts[0]); } } } diff --git a/src/app/pages/storage/modules/pool-manager/components/pool-manager-wizard/components/layout-step/automated-disk-selection/disk-size-selects/disk-size-selects.component.html b/src/app/pages/storage/modules/pool-manager/components/pool-manager-wizard/components/layout-step/automated-disk-selection/disk-size-selects/disk-size-selects.component.html new file mode 100644 index 00000000000..59fbff9f536 --- /dev/null +++ b/src/app/pages/storage/modules/pool-manager/components/pool-manager-wizard/components/layout-step/automated-disk-selection/disk-size-selects/disk-size-selects.component.html @@ -0,0 +1,15 @@ + + + + + diff --git a/src/app/pages/storage/modules/pool-manager/components/pool-manager-wizard/components/layout-step/automated-disk-selection/disk-size-selects/disk-size-selects.component.spec.ts b/src/app/pages/storage/modules/pool-manager/components/pool-manager-wizard/components/layout-step/automated-disk-selection/disk-size-selects/disk-size-selects.component.spec.ts new file mode 100644 index 00000000000..8177996bcbe --- /dev/null +++ b/src/app/pages/storage/modules/pool-manager/components/pool-manager-wizard/components/layout-step/automated-disk-selection/disk-size-selects/disk-size-selects.component.spec.ts @@ -0,0 +1,143 @@ +import { HarnessLoader } from '@angular/cdk/testing'; +import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; +import { ReactiveFormsModule } from '@angular/forms'; +import { createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest'; +import { Subject } from 'rxjs'; +import { GiB } from 'app/constants/bytes.constant'; +import { DiskType } from 'app/enums/disk-type.enum'; +import { VdevType } from 'app/enums/v-dev-type.enum'; +import { UnusedDisk } from 'app/interfaces/storage.interface'; +import { IxCheckboxHarness } from 'app/modules/ix-forms/components/ix-checkbox/ix-checkbox.harness'; +import { IxSelectHarness } from 'app/modules/ix-forms/components/ix-select/ix-select.harness'; +import { IxFormsModule } from 'app/modules/ix-forms/ix-forms.module'; +import { + DiskSizeSelectsComponent, +} from 'app/pages/storage/modules/pool-manager/components/pool-manager-wizard/components/layout-step/automated-disk-selection/disk-size-selects/disk-size-selects.component'; +import { PoolManagerStore } from 'app/pages/storage/modules/pool-manager/store/pool-manager.store'; + +describe('DiskSizeSelectsComponent', () => { + let spectator: Spectator; + let loader: HarnessLoader; + let diskSizeSelect: IxSelectHarness; + let minimumCheckbox: IxCheckboxHarness; + const startOver$ = new Subject(); + const resetStep$ = new Subject(); + + const inventoryDisks = [ + { type: DiskType.Hdd, size: 10 * GiB, name: 'disk1' }, + { type: DiskType.Hdd, size: 10 * GiB, name: 'disk2' }, + { type: DiskType.Hdd, size: 20 * GiB, name: 'disk3' }, + { type: DiskType.Ssd, size: 20 * GiB, name: 'disk4' }, + ] as UnusedDisk[]; + + const createComponent = createComponentFactory({ + component: DiskSizeSelectsComponent, + imports: [ + IxFormsModule, + ReactiveFormsModule, + ], + providers: [ + mockProvider(PoolManagerStore, { + startOver$, + resetStep$, + }), + ], + }); + + beforeEach(async () => { + spectator = createComponent({ + props: { + type: VdevType.Spare, + inventory: inventoryDisks, + isStepActive: true, + }, + }); + loader = TestbedHarnessEnvironment.loader(spectator.fixture); + diskSizeSelect = await loader.getHarness(IxSelectHarness.with({ label: 'Disk Size' })); + minimumCheckbox = await loader.getHarness(IxCheckboxHarness.with({ label: 'Treat Disk Size as Minimum' })); + + jest.spyOn(spectator.component.disksSelected, 'emit'); + }); + + describe('disk type and size', () => { + it('shows dropdown with disk types and sizes', async () => { + const options = await diskSizeSelect.getOptionLabels(); + expect(options).toEqual(['10 GiB (HDD)', '20 GiB (HDD)', '20 GiB (SSD)']); + }); + + it('updates value in store when disk type/size is selected', async () => { + await diskSizeSelect.setValue('20 GiB (HDD)'); + + expect(spectator.inject(PoolManagerStore).setTopologyCategoryDiskSizes).toHaveBeenCalledWith( + VdevType.Spare, + { + diskType: DiskType.Hdd, + diskSize: 20 * GiB, + treatDiskSizeAsMinimum: false, + }, + ); + }); + + it('emits (disksSelected) when dropdown is updated', async () => { + await diskSizeSelect.setValue('10 GiB (HDD)'); + + expect(spectator.component.disksSelected.emit).toHaveBeenLastCalledWith([ + { type: DiskType.Hdd, size: 10 * GiB, name: 'disk1' }, + { type: DiskType.Hdd, size: 10 * GiB, name: 'disk2' }, + ]); + }); + }); + + describe('treat disk size as minimum', () => { + it('shows Treat disk size as minimum checkbox', () => { + expect(minimumCheckbox).toBeTruthy(); + }); + + it('updates value in store when Treat as minimum is changed', async () => { + await diskSizeSelect.setValue('20 GiB (HDD)'); + await minimumCheckbox.setValue(true); + + expect(spectator.inject(PoolManagerStore).setTopologyCategoryDiskSizes).toHaveBeenLastCalledWith( + VdevType.Spare, + { + diskSize: 20 * GiB, + diskType: DiskType.Hdd, + treatDiskSizeAsMinimum: true, + }, + ); + }); + + it('emits (disksSelected) when checkbox is ticked', async () => { + await diskSizeSelect.setValue('10 GiB (HDD)'); + await minimumCheckbox.setValue(true); + + expect(spectator.component.disksSelected.emit).toHaveBeenCalledWith(inventoryDisks); + }); + }); + + it('selects disk size and type if there only one option available', async () => { + const singleDisk = { type: DiskType.Hdd, size: 10 * GiB, name: 'disk1' } as UnusedDisk; + spectator.setInput('inventory', [singleDisk]); + + expect(await diskSizeSelect.getValue()).toBe('10 GiB (HDD)'); + expect(spectator.inject(PoolManagerStore).setTopologyCategoryDiskSizes).toHaveBeenCalledWith( + VdevType.Spare, + { + diskType: DiskType.Hdd, + diskSize: 10 * GiB, + treatDiskSizeAsMinimum: false, + }, + ); + expect(spectator.component.disksSelected.emit).toHaveBeenCalledWith([singleDisk]); + }); + + it('resets to default values when store emits a reset event', async () => { + await diskSizeSelect.setValue('10 GiB (HDD)'); + await minimumCheckbox.setValue(true); + + startOver$.next(); + + expect(await diskSizeSelect.getValue()).toBe(''); + expect(await minimumCheckbox.getValue()).toBe(false); + }); +}); diff --git a/src/app/pages/storage/modules/pool-manager/components/pool-manager-wizard/components/layout-step/automated-disk-selection/disk-size-selects/disk-size-selects.component.ts b/src/app/pages/storage/modules/pool-manager/components/pool-manager-wizard/components/layout-step/automated-disk-selection/disk-size-selects/disk-size-selects.component.ts new file mode 100644 index 00000000000..5d883edfb53 --- /dev/null +++ b/src/app/pages/storage/modules/pool-manager/components/pool-manager-wizard/components/layout-step/automated-disk-selection/disk-size-selects/disk-size-selects.component.ts @@ -0,0 +1,149 @@ +import { + ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output, +} from '@angular/core'; +import { FormBuilder, Validators } from '@angular/forms'; +import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; +import filesize from 'filesize'; +import _, { isEqual } from 'lodash'; +import { merge, of } from 'rxjs'; +import { filter } from 'rxjs/operators'; +import { DiskType } from 'app/enums/disk-type.enum'; +import { CreateVdevLayout, VdevType } from 'app/enums/v-dev-type.enum'; +import { SelectOption } from 'app/interfaces/option.interface'; +import { IxSimpleChanges } from 'app/interfaces/simple-changes.interface'; +import { UnusedDisk } from 'app/interfaces/storage.interface'; +import { DiskTypeSizeMap } from 'app/pages/storage/modules/pool-manager/interfaces/disk-type-size-map.interface'; +import { SizeAndType } from 'app/pages/storage/modules/pool-manager/interfaces/size-and-type.interface'; +import { PoolManagerStore } from 'app/pages/storage/modules/pool-manager/store/pool-manager.store'; +import { hasDeepChanges, setValueIfNotSame } from 'app/pages/storage/modules/pool-manager/utils/form.utils'; +import { getDiskTypeSizeMap } from 'app/pages/storage/modules/pool-manager/utils/get-disk-type-size-map.utils'; + +@UntilDestroy() +@Component({ + selector: 'ix-disk-size-dropdowns', + templateUrl: './disk-size-selects.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DiskSizeSelectsComponent implements OnChanges { + @Input({ required: true }) layout: CreateVdevLayout; + @Input({ required: true }) type: VdevType; + @Input({ required: true }) inventory: UnusedDisk[]; + @Input() isStepActive = false; + @Output() disksSelected = new EventEmitter(); + + protected diskSizeAndTypeOptions$ = of([]); + + protected sizeDisksMap: DiskTypeSizeMap = { [DiskType.Hdd]: {}, [DiskType.Ssd]: {} }; + protected compareSizeAndTypeWith = _.isEqual; + + protected form = this.formBuilder.group({ + sizeAndType: [[null, null] as SizeAndType, Validators.required], + treatDiskSizeAsMinimum: [{ value: false, disabled: true }], + }); + + constructor( + private formBuilder: FormBuilder, + private store: PoolManagerStore, + ) { + this.setControlRelations(); + this.updateStoreOnChanges(); + this.emitUpdatesOnChanges(); + this.listenForResetEvents(); + } + + get selectedDiskSize(): number { + return this.form.controls.sizeAndType.value?.[0]; + } + + get selectedDiskType(): DiskType { + return this.form.controls.sizeAndType.value?.[1]; + } + + ngOnChanges(changes: IxSimpleChanges): void { + if (hasDeepChanges(changes, 'inventory') || hasDeepChanges(changes, 'layout')) { + this.updateOptions(); + } + } + + private listenForResetEvents(): void { + merge( + this.store.startOver$, + this.store.resetStep$.pipe(filter((vdevType) => vdevType === this.type)), + ) + .pipe(untilDestroyed(this)) + .subscribe(() => { + this.form.setValue({ + sizeAndType: [null, null], + treatDiskSizeAsMinimum: false, + }); + }); + } + + private setControlRelations(): void { + this.form.controls.sizeAndType + .valueChanges + .pipe(filter(Boolean), untilDestroyed(this)) + .subscribe(() => { + this.form.controls.treatDiskSizeAsMinimum.enable(); + }); + } + + private updateStoreOnChanges(): void { + this.form.valueChanges.pipe(untilDestroyed(this)).subscribe(() => { + const values = this.form.value; + + this.store.setTopologyCategoryDiskSizes(this.type, { + diskSize: this.selectedDiskSize, + diskType: this.selectedDiskType, + treatDiskSizeAsMinimum: values.treatDiskSizeAsMinimum, + }); + }); + } + + private updateOptions(): void { + this.sizeDisksMap = getDiskTypeSizeMap(this.inventory); + + const hddOptions = Object.keys(this.sizeDisksMap[DiskType.Hdd]) + .map((size): SelectOption => ({ + label: `${filesize(Number(size), { standard: 'iec' })} (HDD)`, + value: [Number(size), DiskType.Hdd], + })); + + const ssdOptions = Object.keys(this.sizeDisksMap[DiskType.Ssd]) + .map((size): SelectOption => ({ + label: `${filesize(Number(size), { standard: 'iec' })} (SSD)`, + value: [Number(size), DiskType.Ssd], + })); + + const nextOptions = [...hddOptions, ...ssdOptions].sort((a, b) => a.value[0] - b.value[0]); + + this.diskSizeAndTypeOptions$ = of(nextOptions); + + if (!nextOptions.some((option) => isEqual(option.value, this.form.controls.sizeAndType.value))) { + setValueIfNotSame(this.form.controls.sizeAndType, [null, null]); + } + + if (nextOptions.length === 1 && this.isStepActive) { + setValueIfNotSame(this.form.controls.sizeAndType, nextOptions[0].value); + } + } + + private emitUpdatesOnChanges(): void { + this.form.valueChanges.pipe(untilDestroyed(this)).subscribe(() => { + const suitableDisks = this.getSuitableDisks(); + this.disksSelected.emit(suitableDisks); + }); + } + + private getSuitableDisks(): UnusedDisk[] { + if (!this.selectedDiskSize) { + return []; + } + + if (!this.form.controls.treatDiskSizeAsMinimum.value) { + return this.sizeDisksMap[this.selectedDiskType][this.selectedDiskSize]; + } + + return this.inventory.filter((disk) => disk.size >= this.selectedDiskSize); + } +} diff --git a/src/app/pages/storage/modules/pool-manager/components/pool-manager-wizard/components/layout-step/automated-disk-selection/draid-selection/draid-selection.component.html b/src/app/pages/storage/modules/pool-manager/components/pool-manager-wizard/components/layout-step/automated-disk-selection/draid-selection/draid-selection.component.html new file mode 100644 index 00000000000..fbf665a7fc5 --- /dev/null +++ b/src/app/pages/storage/modules/pool-manager/components/pool-manager-wizard/components/layout-step/automated-disk-selection/draid-selection/draid-selection.component.html @@ -0,0 +1,50 @@ +
+

{{ 'Automated Disk Selection' | translate }}

+
+
+ + + + + +
+ +
+ + +
+ {{ helptext.dRaidChildrenExplanation | translate }} +
+ + +
+
+
diff --git a/src/app/pages/storage/modules/pool-manager/components/pool-manager-wizard/components/layout-step/automated-disk-selection/draid-selection/draid-selection.component.scss b/src/app/pages/storage/modules/pool-manager/components/pool-manager-wizard/components/layout-step/automated-disk-selection/draid-selection/draid-selection.component.scss new file mode 100644 index 00000000000..025244805b3 --- /dev/null +++ b/src/app/pages/storage/modules/pool-manager/components/pool-manager-wizard/components/layout-step/automated-disk-selection/draid-selection/draid-selection.component.scss @@ -0,0 +1,25 @@ +.content-container { + border: 1px solid var(--lines); + margin: 20px 0; + padding: 20px 30px; +} + +.columns { + display: flex; + + .column { + border-right: 1px solid var(--lines); + flex: 1; + padding-right: 30px; + + &:last-of-type { + border-right-width: 0; + padding-left: 30px; + padding-right: 0; + } + } +} + +.explanation { + color: var(--fg2); +} diff --git a/src/app/pages/storage/modules/pool-manager/components/pool-manager-wizard/components/layout-step/automated-disk-selection/draid-selection/draid-selection.component.spec.ts b/src/app/pages/storage/modules/pool-manager/components/pool-manager-wizard/components/layout-step/automated-disk-selection/draid-selection/draid-selection.component.spec.ts new file mode 100644 index 00000000000..d430bb98bbe --- /dev/null +++ b/src/app/pages/storage/modules/pool-manager/components/pool-manager-wizard/components/layout-step/automated-disk-selection/draid-selection/draid-selection.component.spec.ts @@ -0,0 +1,235 @@ +import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; +import { ReactiveFormsModule } from '@angular/forms'; +import { createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest'; +import { Subject } from 'rxjs'; +import { GiB } from 'app/constants/bytes.constant'; +import { DiskType } from 'app/enums/disk-type.enum'; +import { CreateVdevLayout, VdevType } from 'app/enums/v-dev-type.enum'; +import { UnusedDisk } from 'app/interfaces/storage.interface'; +import { IxSelectHarness } from 'app/modules/ix-forms/components/ix-select/ix-select.harness'; +import { IxFormsModule } from 'app/modules/ix-forms/ix-forms.module'; +import { IxFormHarness } from 'app/modules/ix-forms/testing/ix-form.harness'; +import { + DiskSizeSelectsComponent, +} from 'app/pages/storage/modules/pool-manager/components/pool-manager-wizard/components/layout-step/automated-disk-selection/disk-size-selects/disk-size-selects.component'; +import { + DraidSelectionComponent, +} from 'app/pages/storage/modules/pool-manager/components/pool-manager-wizard/components/layout-step/automated-disk-selection/draid-selection/draid-selection.component'; +import { PoolManagerStore } from 'app/pages/storage/modules/pool-manager/store/pool-manager.store'; + +describe('DraidSelectionComponent', () => { + let spectator: Spectator; + let form: IxFormHarness; + + const startOver$ = new Subject(); + const resetStep$ = new Subject(); + + const createComponent = createComponentFactory({ + component: DraidSelectionComponent, + imports: [ + ReactiveFormsModule, + IxFormsModule, + ], + declarations: [ + DiskSizeSelectsComponent, + ], + providers: [ + mockProvider(PoolManagerStore, { + startOver$, + resetStep$, + }), + ], + }); + + beforeEach(async () => { + spectator = createComponent({ + props: { + type: VdevType.Spare, + layout: CreateVdevLayout.Draid1, + inventory: [ + { type: DiskType.Hdd, size: 10 * GiB, name: 'disk1' }, + { type: DiskType.Hdd, size: 10 * GiB, name: 'disk2' }, + { type: DiskType.Hdd, size: 10 * GiB, name: 'disk3' }, + { type: DiskType.Hdd, size: 10 * GiB, name: 'disk4' }, + { type: DiskType.Hdd, size: 20 * GiB, name: 'disk5' }, + { type: DiskType.Ssd, size: 20 * GiB, name: 'disk6' }, + { type: DiskType.Ssd, size: 30 * GiB, name: 'disk7' }, + { type: DiskType.Ssd, size: 30 * GiB, name: 'disk8' }, + ] as UnusedDisk[], + isStepActive: true, + }, + }); + form = await TestbedHarnessEnvironment.harnessForFixture(spectator.fixture, IxFormHarness); + }); + + it('keeps inputs disabled until disks are selected', async () => { + expect(await form.getDisabledState()).toEqual({ + 'Disk Size': false, + 'Treat Disk Size as Minimum': false, + Children: true, + 'Data Devices': true, + 'Distributed Hot Spares': true, + 'Number of VDEVs': true, + }); + + await form.fillForm({ + 'Disk Size': '10 GiB (HDD)', + }); + + expect(await form.getDisabledState()).toEqual({ + 'Disk Size': false, + 'Treat Disk Size as Minimum': false, + Children: false, + 'Data Devices': false, + 'Distributed Hot Spares': false, + 'Number of VDEVs': false, + }); + }); + + it('updates options in Data Devices dropdown when disks are selected', async () => { + await form.fillForm({ + 'Disk Size': '10 GiB (HDD)', + }); + + const dataDevices = await form.getControl('Data Devices') as IxSelectHarness; + expect(await dataDevices.getOptionLabels()).toEqual(['1', '2', '3']); + }); + + it('updates Spares and Children options when Data Devices are selected', async () => { + await form.fillForm({ + 'Disk Size': '10 GiB (HDD)', + }); + await form.fillForm({ + 'Data Devices': '2', + }); + + const spares = await form.getControl('Distributed Hot Spares') as IxSelectHarness; + expect(await spares.getOptionLabels()).toEqual(['0', '1']); + expect(await spares.getValue()).toBe('0'); + + const children = await form.getControl('Children') as IxSelectHarness; + expect(await children.getOptionLabels()).toEqual(['3', '4']); + }); + + it('updates Children when Spares are selected', async () => { + await form.fillForm({ + 'Disk Size': '10 GiB (HDD)', + }); + await form.fillForm({ + 'Data Devices': '2', + 'Distributed Hot Spares': '1', + }); + + const children = await form.getControl('Children') as IxSelectHarness; + expect(await children.getOptionLabels()).toEqual(['4']); + }); + + it('defaults Children to optimal number, but only once', async () => { + await form.fillForm({ + 'Disk Size': '10 GiB (HDD)', + }); + + await form.fillForm({ + 'Data Devices': '2', + }); + + const children = await form.getControl('Children') as IxSelectHarness; + expect(await children.getValue()).toBe('3'); + + await form.fillForm({ + 'Treat Disk Size as Minimum': true, + }); + expect(await children.getValue()).toBe('6'); + }); + + it('updates number of vdevs when Children are selected', async () => { + await form.fillForm({ + 'Disk Size': '10 GiB (HDD)', + }); + + await form.fillForm({ + 'Treat Disk Size as Minimum': true, + 'Data Devices': '2', + }); + + const vdevs = await form.getControl('Number of VDEVs') as IxSelectHarness; + expect(await vdevs.getOptionLabels()).toEqual(['1']); + + await form.fillForm({ + Children: '3', + }); + + expect(await vdevs.getOptionLabels()).toEqual(['1', '2']); + }); + + it('updates value in store when controls are updated', async () => { + await form.fillForm({ + 'Disk Size': '10 GiB (HDD)', + }); + + await form.fillForm({ + 'Treat Disk Size as Minimum': true, + 'Data Devices': '2', + 'Distributed Hot Spares': '1', + Children: '4', + 'Number of VDEVs': '2', + }); + + const store = spectator.inject(PoolManagerStore); + expect(store.setAutomaticTopologyCategory).toHaveBeenLastCalledWith( + VdevType.Spare, + { + draidDataDisks: 2, + draidSpareDisks: 1, + vdevsNumber: 2, + width: 4, + }, + ); + }); + + it('selects options in controls automatically when only one option is available', async () => { + await form.fillForm({ + 'Disk Size': '30 GiB (SSD)', + }); + + expect(await form.getValues()).toMatchObject({ + 'Data Devices': '1', + Children: '2', + 'Distributed Hot Spares': '0', + 'Number of VDEVs': '1', + }); + + const store = spectator.inject(PoolManagerStore); + expect(store.setAutomaticTopologyCategory).toHaveBeenLastCalledWith( + VdevType.Spare, + { + draidDataDisks: 1, + draidSpareDisks: 0, + vdevsNumber: 1, + width: 2, + }, + ); + }); + + it('resets to default values when store emits a reset event', async () => { + await form.fillForm({ + 'Disk Size': '30 GiB (SSD)', + }); + + expect(await form.getValues()).toMatchObject({ + 'Data Devices': '1', + Children: '2', + 'Distributed Hot Spares': '0', + 'Number of VDEVs': '1', + }); + + startOver$.next(); + + expect(await form.getValues()).toMatchObject({ + 'Data Devices': '', + Children: '', + 'Distributed Hot Spares': '', + 'Number of VDEVs': '', + }); + }); +}); diff --git a/src/app/pages/storage/modules/pool-manager/components/pool-manager-wizard/components/layout-step/automated-disk-selection/draid-selection/draid-selection.component.ts b/src/app/pages/storage/modules/pool-manager/components/pool-manager-wizard/components/layout-step/automated-disk-selection/draid-selection/draid-selection.component.ts new file mode 100644 index 00000000000..dae8e47fa9b --- /dev/null +++ b/src/app/pages/storage/modules/pool-manager/components/pool-manager-wizard/components/layout-step/automated-disk-selection/draid-selection/draid-selection.component.ts @@ -0,0 +1,260 @@ +import { + ChangeDetectionStrategy, Component, Input, OnChanges, OnInit, +} from '@angular/core'; +import { FormBuilder } from '@angular/forms'; +import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; +import _ from 'lodash'; +import { merge, of } from 'rxjs'; +import { filter } from 'rxjs/operators'; +import { CreateVdevLayout, VdevType } from 'app/enums/v-dev-type.enum'; +import { generateOptionsRange } from 'app/helpers/options.helper'; +import helptext from 'app/helptext/storage/volumes/manager/manager'; +import { Option, SelectOption } from 'app/interfaces/option.interface'; +import { IxSimpleChanges } from 'app/interfaces/simple-changes.interface'; +import { UnusedDisk } from 'app/interfaces/storage.interface'; +import { PoolManagerStore } from 'app/pages/storage/modules/pool-manager/store/pool-manager.store'; +import { + hasDeepChanges, + setValueIfNotSame, + unsetControlIfNoMatchingOption, +} from 'app/pages/storage/modules/pool-manager/utils/form.utils'; + +const parityDisksPerGroup = { + [CreateVdevLayout.Draid1]: 1, + [CreateVdevLayout.Draid2]: 2, + [CreateVdevLayout.Draid3]: 3, +}; + +const maxDisksInDraidGroup = 255; + +@UntilDestroy() +@Component({ + selector: 'ix-draid-selection', + templateUrl: './draid-selection.component.html', + styleUrls: ['./draid-selection.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DraidSelectionComponent implements OnInit, OnChanges { + @Input() type: VdevType; + @Input() layout: CreateVdevLayout.Draid1 | CreateVdevLayout.Draid2 | CreateVdevLayout.Draid3; + @Input() inventory: UnusedDisk[]; + @Input() isStepActive: boolean; + + readonly defaultDataDevicesPerGroup = 8; + + form = this.formBuilder.group({ + children: [null as number], + dataDevicesPerGroup: [this.defaultDataDevicesPerGroup], + spares: [0], + + vdevsNumber: [1], + }); + + protected dataDevicesPerGroupOptions$ = of([]); + protected sparesOptions$ = of([]); + protected vdevsNumberOptions$ = of([]); + protected widthOptions$ = of([]); + + /** + * Total number of disks to work with. + */ + private selectedDisks: UnusedDisk[] = []; + + readonly helptext = helptext; + + constructor( + private formBuilder: FormBuilder, + private store: PoolManagerStore, + ) {} + + get parityDevices(): number { + return parityDisksPerGroup[this.layout]; + } + + ngOnInit(): void { + this.updateControlOptionsOnChanges(); + this.updateStoreOnChanges(); + this.listenForResetEvents(); + } + + ngOnChanges(changes: IxSimpleChanges): void { + if (hasDeepChanges(changes, 'layout') || hasDeepChanges(changes, 'inventory')) { + this.updateDataDevicesOptions(); + this.updateDisabledStatuses(); + } + } + + protected onDisksSelected(disks: UnusedDisk[]): void { + this.selectedDisks = disks; + this.updateDataDevicesOptions(); + this.updateChildrenOptions(); + this.updateDisabledStatuses(); + } + + private listenForResetEvents(): void { + merge( + this.store.startOver$, + this.store.resetStep$.pipe(filter((vdevType) => vdevType === this.type)), + ) + .pipe(untilDestroyed(this)) + .subscribe(() => { + this.form.setValue({ + children: null, + dataDevicesPerGroup: this.defaultDataDevicesPerGroup, + spares: 0, + vdevsNumber: 1, + }); + }); + } + + private updateDisabledStatuses(): void { + const fields = ['dataDevicesPerGroup', 'children', 'spares', 'vdevsNumber'] as const; + fields.forEach((field) => { + if (this.selectedDisks.length) { + this.form.controls[field].enable({ emitEvent: false }); + } else { + this.form.controls[field].disable({ emitEvent: false }); + } + }); + } + + private updateControlOptionsOnChanges(): void { + this.form.controls.dataDevicesPerGroup.valueChanges.pipe(untilDestroyed(this)).subscribe(() => { + this.updateSparesOptions(); + }); + + this.form.controls.spares.valueChanges.pipe(untilDestroyed(this)).subscribe(() => { + this.updateChildrenOptions(); + }); + + this.form.controls.children.valueChanges.pipe(untilDestroyed(this)).subscribe(() => { + this.updateVdevsNumberOptions(); + }); + } + + private updateStoreOnChanges(): void { + this.form.valueChanges.pipe(untilDestroyed(this)).subscribe(() => { + const values = this.form.value; + + this.store.setAutomaticTopologyCategory(this.type, { + width: values.children, + draidDataDisks: values.dataDevicesPerGroup, + draidSpareDisks: values.spares, + vdevsNumber: values.vdevsNumber, + }); + }); + } + + private updateDataDevicesOptions(): void { + const maxPossibleGroups = this.selectedDisks.length - this.parityDevices; + let nextOptions: Option[] = []; + if (maxPossibleGroups) { + nextOptions = generateOptionsRange(1, maxPossibleGroups); + } + + unsetControlIfNoMatchingOption(this.form.controls.dataDevicesPerGroup, nextOptions); + + if (nextOptions.length === 1 && this.isStepActive) { + setValueIfNotSame( + this.form.controls.dataDevicesPerGroup, + Number(nextOptions[0].value), + ); + } + + this.dataDevicesPerGroupOptions$ = of(nextOptions); + this.updateSparesOptions(); + } + + private updateSparesOptions(): void { + const dataDevices = this.form.controls.dataDevicesPerGroup.value; + const maxPossibleSpares = this.selectedDisks.length - dataDevices - this.parityDevices; + let nextOptions: Option[] = []; + if (maxPossibleSpares >= 0) { + nextOptions = generateOptionsRange(0, maxPossibleSpares); + } + + if (!nextOptions.some((option) => option.value === this.form.controls.spares.value)) { + setValueIfNotSame( + this.form.controls.spares, + 0, + ); + } + + this.sparesOptions$ = of(nextOptions); + + this.updateChildrenOptions(); + } + + private updateChildrenOptions(): void { + const maxPossibleWidth = this.selectedDisks.length; + const dataDevices = this.form.controls.dataDevicesPerGroup.value; + const hotSpares = this.form.controls.spares.value; + const groupSize = Math.min(dataDevices + this.parityDevices, maxDisksInDraidGroup); + const maxGroups = Math.floor((maxPossibleWidth - hotSpares) / groupSize); + const optimalMaximum = maxGroups * groupSize + hotSpares; + + let nextOptions: Option[] = []; + if ((groupSize + hotSpares) <= maxPossibleWidth && dataDevices) { + nextOptions = _.range(1, maxGroups + 1).map((i) => { + const disks = i * groupSize + hotSpares; + return { + label: String(disks), + value: disks, + }; + }); + + if (maxPossibleWidth > optimalMaximum) { + nextOptions.push({ + label: String(maxPossibleWidth), + value: maxPossibleWidth, + }); + } + } + + unsetControlIfNoMatchingOption(this.form.controls.children, nextOptions); + + if (this.isStepActive) { + const hasOptimalOption = nextOptions.some((option) => option.value === optimalMaximum); + if (nextOptions.length === 1) { + // If there is one option, pick it. + setValueIfNotSame( + this.form.controls.children, + Number(nextOptions[0].value), + ); + } else if (hasOptimalOption) { + // Or try to default to normal maximum number of groups and spares. + setValueIfNotSame( + this.form.controls.children, + optimalMaximum, + ); + } + } + + this.widthOptions$ = of(nextOptions); + this.updateVdevsNumberOptions(); + } + + private updateVdevsNumberOptions(): void { + const width = this.form.controls.children.value; + let maxPossibleVdevs = 0; + if (width > 0) { + maxPossibleVdevs = Math.floor(this.selectedDisks.length / width); + } + + let nextOptions: Option[] = []; + if (maxPossibleVdevs > 0) { + nextOptions = generateOptionsRange(1, maxPossibleVdevs); + } + + unsetControlIfNoMatchingOption(this.form.controls.vdevsNumber, nextOptions); + + if (nextOptions.length === 1 && this.isStepActive) { + setValueIfNotSame( + this.form.controls.vdevsNumber, + Number(nextOptions[0].value), + ); + } + + this.vdevsNumberOptions$ = of(nextOptions); + } +} diff --git a/src/app/pages/storage/modules/pool-manager/components/pool-manager-wizard/components/layout-step/automated-disk-selection/normal-selection/normal-selection.component.html b/src/app/pages/storage/modules/pool-manager/components/pool-manager-wizard/components/layout-step/automated-disk-selection/normal-selection/normal-selection.component.html new file mode 100644 index 00000000000..72540dc4cfc --- /dev/null +++ b/src/app/pages/storage/modules/pool-manager/components/pool-manager-wizard/components/layout-step/automated-disk-selection/normal-selection/normal-selection.component.html @@ -0,0 +1,43 @@ +
+
+

{{ 'Automated Disk Selection' | translate }}

+ + + + + +
+
+

{{ 'Advanced Options' | translate }}

+ +

{{ 'Manual disk selection allows you to create VDEVs and add disks to those VDEVs individually.' | translate }}

+ + +
+
diff --git a/src/app/pages/storage/modules/pool-manager/components/pool-manager-wizard/components/layout-step/automated-disk-selection/normal-selection/normal-selection.component.scss b/src/app/pages/storage/modules/pool-manager/components/pool-manager-wizard/components/layout-step/automated-disk-selection/normal-selection/normal-selection.component.scss new file mode 100644 index 00000000000..8b7df160356 --- /dev/null +++ b/src/app/pages/storage/modules/pool-manager/components/pool-manager-wizard/components/layout-step/automated-disk-selection/normal-selection/normal-selection.component.scss @@ -0,0 +1,21 @@ +.content-container { + border: 1px solid var(--lines); + display: flex; + margin: 20px 0; + padding: 20px 0; + + .automated-disk-selection-container { + border-right: 1px solid var(--lines); + padding: 0 30px; + width: 100%; + } + + .advanced-options-container { + padding: 0 30px; + width: 100%; + } +} + +.manual-disk-selection { + margin-top: 25px; +} diff --git a/src/app/pages/storage/modules/pool-manager/components/pool-manager-wizard/components/layout-step/automated-disk-selection/normal-selection/normal-selection.component.spec.ts b/src/app/pages/storage/modules/pool-manager/components/pool-manager-wizard/components/layout-step/automated-disk-selection/normal-selection/normal-selection.component.spec.ts new file mode 100644 index 00000000000..620695a52c9 --- /dev/null +++ b/src/app/pages/storage/modules/pool-manager/components/pool-manager-wizard/components/layout-step/automated-disk-selection/normal-selection/normal-selection.component.spec.ts @@ -0,0 +1,250 @@ +import { HarnessLoader } from '@angular/cdk/testing'; +import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; +import { ReactiveFormsModule } from '@angular/forms'; +import { createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest'; +import { of, Subject } from 'rxjs'; +import { TiB } from 'app/constants/bytes.constant'; +import { DiskType } from 'app/enums/disk-type.enum'; +import { CreateVdevLayout, VdevType } from 'app/enums/v-dev-type.enum'; +import { UnusedDisk } from 'app/interfaces/storage.interface'; +import { IxSelectHarness } from 'app/modules/ix-forms/components/ix-select/ix-select.harness'; +import { IxFormsModule } from 'app/modules/ix-forms/ix-forms.module'; +import { + DiskSizeSelectsComponent, +} from 'app/pages/storage/modules/pool-manager/components/pool-manager-wizard/components/layout-step/automated-disk-selection/disk-size-selects/disk-size-selects.component'; +import { + NormalSelectionComponent, +} from 'app/pages/storage/modules/pool-manager/components/pool-manager-wizard/components/layout-step/automated-disk-selection/normal-selection/normal-selection.component'; +import { PoolManagerStore } from 'app/pages/storage/modules/pool-manager/store/pool-manager.store'; + +describe('NormalSelectionComponent', () => { + let spectator: Spectator; + let loader: HarnessLoader; + + let widthSelect: IxSelectHarness; + let vdevsSelect: IxSelectHarness; + let sizeSelect: IxSelectHarness; + + const unusedDisks: UnusedDisk[] = [ + { + devname: 'sdo', + size: 12 * TiB, + type: DiskType.Hdd, + }, + { + devname: 'sdr', + size: 12 * TiB, + type: DiskType.Hdd, + }, + { + devname: 'sdq', + size: 12 * TiB, + type: DiskType.Hdd, + }, + { + devname: 'sdw', + size: 12 * TiB, + type: DiskType.Hdd, + }, + { + devname: 'sdt', + size: 12 * TiB, + type: DiskType.Hdd, + }, + { + devname: 'sdu', + size: 12 * TiB, + type: DiskType.Hdd, + }, + { + devname: 'sdh', + size: 12 * TiB, + type: DiskType.Hdd, + }, + { + devname: 'sdg', + size: 14 * TiB, + type: DiskType.Hdd, + }, + { + devname: 'sdj', + size: 14 * TiB, + type: DiskType.Hdd, + }, + { + devname: 'sdk', + size: TiB, + type: DiskType.Hdd, + }, + ] as UnusedDisk[]; + const startOver$ = new Subject(); + const resetStep$ = new Subject(); + + const createComponent = createComponentFactory({ + component: NormalSelectionComponent, + imports: [ + ReactiveFormsModule, + IxFormsModule, + ], + declarations: [ + DiskSizeSelectsComponent, + ], + providers: [ + mockProvider(PoolManagerStore, { + getLayoutsForVdevType: jest.fn((vdevType: VdevType) => { + switch (vdevType) { + case VdevType.Cache: + return of([CreateVdevLayout.Stripe]); + case VdevType.Dedup: + return of([CreateVdevLayout.Mirror]); + case VdevType.Log: + return of([CreateVdevLayout.Mirror, CreateVdevLayout.Stripe]); + case VdevType.Spare: + return of([CreateVdevLayout.Stripe]); + case VdevType.Special: + return of([CreateVdevLayout.Mirror]); + default: + return of([...Object.values(CreateVdevLayout)]); + } + }), + startOver$, + resetStep$, + }), + ], + }); + + beforeEach(async () => { + spectator = createComponent({ + props: { + layout: CreateVdevLayout.Stripe, + type: VdevType.Data, + inventory: [...unusedDisks], + }, + }); + loader = TestbedHarnessEnvironment.loader(spectator.fixture); + + widthSelect = await loader.getHarness(IxSelectHarness.with({ label: 'Width' })); + vdevsSelect = await loader.getHarness(IxSelectHarness.with({ label: 'Number of VDEVs' })); + sizeSelect = await loader.getHarness(IxSelectHarness.with({ label: 'Disk Size' })); + }); + + it('updates width and vdev options when layout is mirror', async () => { + spectator.setInput('layout', CreateVdevLayout.Mirror); + await sizeSelect.setValue('12 TiB (HDD)'); + + expect(await widthSelect.getOptionLabels()) + .toStrictEqual(['2', '3', '4', '5', '6', '7']); + + await widthSelect.setValue('2'); + + expect(await vdevsSelect.getOptionLabels()).toStrictEqual(['1', '2', '3']); + }); + + it('updates width and vdev options when layout changes to Raidz1', async () => { + spectator.setInput('layout', CreateVdevLayout.Raidz1); + await sizeSelect.setValue('12 TiB (HDD)'); + + expect(await widthSelect.getOptionLabels()) + .toStrictEqual(['3', '4', '5', '6', '7']); + + await widthSelect.setValue('3'); + + expect(await vdevsSelect.getOptionLabels()) + .toStrictEqual(['1', '2']); + }); + + it('updates width and vdev options when layout changes to Raidz2', async () => { + spectator.setInput('layout', CreateVdevLayout.Raidz2); + await sizeSelect.setValue('12 TiB (HDD)'); + + expect(await widthSelect.getOptionLabels()) + .toStrictEqual(['4', '5', '6', '7']); + + await widthSelect.setValue('4'); + + expect(await vdevsSelect.getOptionLabels()).toStrictEqual(['1']); + }); + + it('updates width and vdev options when layout changes to Raidz3', async () => { + spectator.setInput('layout', CreateVdevLayout.Raidz3); + await sizeSelect.setValue('12 TiB (HDD)'); + + expect(await widthSelect.getOptionLabels()) + .toStrictEqual(['5', '6', '7']); + + await widthSelect.setValue('5'); + + expect(await vdevsSelect.getOptionLabels()).toStrictEqual(['1']); + }); + + it('updates width and vdev options when layout changes to Stripe', async () => { + spectator.setInput('layout', CreateVdevLayout.Stripe); + await sizeSelect.setValue('12 TiB (HDD)'); + + expect(await widthSelect.getOptionLabels()) + .toStrictEqual(['1', '2', '3', '4', '5', '6', '7']); + + await widthSelect.setValue('1'); + + expect(await vdevsSelect.getOptionLabels()) + .toStrictEqual(['1', '2', '3', '4', '5', '6', '7']); + }); + + it('auto fills select when only one value is available', async () => { + spectator.setInput('isStepActive', true); + spectator.setInput('layout', CreateVdevLayout.Stripe); + await sizeSelect.setValue('1 TiB (HDD)'); + + expect(await widthSelect.getOptionLabels()).toStrictEqual(['1']); + + const widthValue = await widthSelect.getValue(); + expect(widthValue).toBe('1'); + + expect(await vdevsSelect.getOptionLabels()).toStrictEqual(['1']); + + const vdevsValue = await widthSelect.getValue(); + expect(vdevsValue).toBe('1'); + }); + + it('saves the topology layout on form updates', async () => { + const poolManagerStore = spectator.inject(PoolManagerStore); + + spectator.setInput('layout', CreateVdevLayout.Mirror); + await sizeSelect.setValue('12 TiB (HDD)'); + + await widthSelect.setValue('2'); + expect(poolManagerStore.setAutomaticTopologyCategory).toHaveBeenLastCalledWith(VdevType.Data, { + width: 2, + vdevsNumber: null, + }); + + await vdevsSelect.setValue('2'); + expect(poolManagerStore.setAutomaticTopologyCategory).toHaveBeenLastCalledWith(VdevType.Data, { + width: 2, + vdevsNumber: 2, + }); + }); + + it('disables dependent fields until they are valid', async () => { + expect(await widthSelect.isDisabled()).toBeTruthy(); + expect(await vdevsSelect.isDisabled()).toBeTruthy(); + spectator.setInput('layout', CreateVdevLayout.Mirror); + expect(await vdevsSelect.isDisabled()).toBeTruthy(); + expect(await widthSelect.isDisabled()).toBeTruthy(); + await sizeSelect.setValue('12 TiB (HDD)'); + expect(await widthSelect.isDisabled()).toBeFalsy(); + expect(await vdevsSelect.isDisabled()).toBeFalsy(); + }); + + it('resets to default values when store emits a reset event', async () => { + spectator.setInput('layout', CreateVdevLayout.Mirror); + await sizeSelect.setValue('12 TiB (HDD)'); + await widthSelect.setValue('2'); + await vdevsSelect.setValue('2'); + + startOver$.next(); + + expect(await widthSelect.getValue()).toBe(''); + expect(await vdevsSelect.getValue()).toBe(''); + }); +}); diff --git a/src/app/pages/storage/modules/pool-manager/components/pool-manager-wizard/components/layout-step/automated-disk-selection/normal-selection/normal-selection.component.ts b/src/app/pages/storage/modules/pool-manager/components/pool-manager-wizard/components/layout-step/automated-disk-selection/normal-selection/normal-selection.component.ts new file mode 100644 index 00000000000..431fcb6c716 --- /dev/null +++ b/src/app/pages/storage/modules/pool-manager/components/pool-manager-wizard/components/layout-step/automated-disk-selection/normal-selection/normal-selection.component.ts @@ -0,0 +1,165 @@ +import { + ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, OnInit, Output, +} from '@angular/core'; +import { FormBuilder, Validators } from '@angular/forms'; +import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; +import { merge, of } from 'rxjs'; +import { filter } from 'rxjs/operators'; +import { CreateVdevLayout, VdevType } from 'app/enums/v-dev-type.enum'; +import { generateOptionsRange } from 'app/helpers/options.helper'; +import { Option, SelectOption } from 'app/interfaces/option.interface'; +import { IxSimpleChanges } from 'app/interfaces/simple-changes.interface'; +import { UnusedDisk } from 'app/interfaces/storage.interface'; +import { PoolManagerStore } from 'app/pages/storage/modules/pool-manager/store/pool-manager.store'; +import { + hasDeepChanges, + setValueIfNotSame, + unsetControlIfNoMatchingOption, +} from 'app/pages/storage/modules/pool-manager/utils/form.utils'; +import { minDisksPerLayout } from 'app/pages/storage/modules/pool-manager/utils/min-disks-per-layout.constant'; + +@UntilDestroy() +@Component({ + selector: 'ix-normal-selection', + templateUrl: './normal-selection.component.html', + styleUrls: ['./normal-selection.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class NormalSelectionComponent implements OnInit, OnChanges { + @Input() type: VdevType; + @Input() layout: CreateVdevLayout; + @Input() isStepActive: boolean; + @Input() inventory: UnusedDisk[]; + + // TODO: Consider moving to a service. + @Output() manualSelectionClicked = new EventEmitter(); + + form = this.formBuilder.group({ + width: [{ value: null as number, disabled: true }, Validators.required], + vdevsNumber: [{ value: null as number, disabled: true }, Validators.required], + }); + + protected widthOptions$ = of([]); + protected numberOptions$ = of([]); + + private selectedDisks: UnusedDisk[] = []; + + constructor( + private formBuilder: FormBuilder, + private store: PoolManagerStore, + ) {} + + openManualDiskSelection(): void { + this.manualSelectionClicked.emit(); + } + + ngOnInit(): void { + this.updateControlOptionsOnChanges(); + this.updateStoreOnChanges(); + this.listenForResetEvents(); + } + + ngOnChanges(changes: IxSimpleChanges): void { + if (hasDeepChanges(changes, 'inventory') || hasDeepChanges(changes, 'layout')) { + this.updateWidthOptions(); + } + } + + get isSpareVdev(): boolean { + return this.type === VdevType.Spare; + } + + protected onDisksSelected(disks: UnusedDisk[]): void { + this.selectedDisks = disks; + this.updateWidthOptions(); + this.updateDisabledStatuses(); + } + + private listenForResetEvents(): void { + merge( + this.store.startOver$, + this.store.resetStep$.pipe(filter((vdevType) => vdevType === this.type)), + ) + .pipe(untilDestroyed(this)) + .subscribe(() => { + this.form.setValue({ + width: null, + vdevsNumber: null, + }); + }); + } + + private updateDisabledStatuses(): void { + const fields = ['width', 'vdevsNumber'] as const; + fields.forEach((field) => { + if (this.selectedDisks.length) { + this.form.controls[field].enable({ emitEvent: false }); + } else { + this.form.controls[field].disable({ emitEvent: false }); + } + }); + } + + private updateControlOptionsOnChanges(): void { + this.form.controls.width.valueChanges.pipe(untilDestroyed(this)).subscribe(() => { + this.updateNumberOptions(); + }); + } + + private updateStoreOnChanges(): void { + this.form.valueChanges.pipe(untilDestroyed(this)).subscribe(() => { + const values = this.form.value; + + this.store.setAutomaticTopologyCategory(this.type, { + width: values.width, + vdevsNumber: this.isSpareVdev ? 1 : values.vdevsNumber, + }); + }); + } + + private updateWidthOptions(): void { + const availableDisks = this.selectedDisks.length; + if (!availableDisks) { + return; + } + const minRequired = minDisksPerLayout[this.layout]; + let nextOptions: Option[] = []; + + if (availableDisks && minRequired && availableDisks >= minRequired) { + nextOptions = generateOptionsRange(minRequired, availableDisks); + } + + this.widthOptions$ = of(nextOptions); + + unsetControlIfNoMatchingOption(this.form.controls.width, nextOptions); + + if (nextOptions.length === 1 && this.isStepActive) { + setValueIfNotSame(this.form.controls.width, Number(nextOptions[0].value)); + } + + this.updateNumberOptions(); + } + + private updateNumberOptions(): void { + const availableDisks = this.selectedDisks.length; + if (!availableDisks) { + return; + } + + const width = this.form.controls.width.value; + let nextOptions: Option[] = []; + + if (width) { + const maxNumber = Math.floor(availableDisks / width); + nextOptions = generateOptionsRange(1, maxNumber); + } + + this.numberOptions$ = of(nextOptions); + + unsetControlIfNoMatchingOption(this.form.controls.vdevsNumber, nextOptions); + + if (nextOptions.length === 1 && this.isStepActive) { + setValueIfNotSame(this.form.controls.vdevsNumber, Number(nextOptions[0].value)); + } + } +} diff --git a/src/app/pages/storage/modules/pool-manager/components/pool-manager-wizard/components/layout-step/layout-step.component.ts b/src/app/pages/storage/modules/pool-manager/components/pool-manager-wizard/components/layout-step/layout-step.component.ts index 3ff3ad5fb7c..eef74e5d180 100644 --- a/src/app/pages/storage/modules/pool-manager/components/pool-manager-wizard/components/layout-step/layout-step.component.ts +++ b/src/app/pages/storage/modules/pool-manager/components/pool-manager-wizard/components/layout-step/layout-step.component.ts @@ -33,7 +33,6 @@ export class LayoutStepComponent implements OnInit { @Input() canChangeLayout = false; @Input() limitLayouts: CreateVdevLayout[]; - // TODO: Limit to certain disks for certain vdev types. @Input() inventory: UnusedDisk[]; protected topologyCategory: PoolManagerTopologyCategory; diff --git a/src/app/pages/storage/modules/pool-manager/components/pool-manager-wizard/pool-manager-wizard.component.html b/src/app/pages/storage/modules/pool-manager/components/pool-manager-wizard/pool-manager-wizard.component.html index 1db542123bf..46b57be49bb 100644 --- a/src/app/pages/storage/modules/pool-manager/components/pool-manager-wizard/pool-manager-wizard.component.html +++ b/src/app/pages/storage/modules/pool-manager/components/pool-manager-wizard/pool-manager-wizard.component.html @@ -99,6 +99,7 @@ storeLoading || secondaryLoading), ); + usesDraidLayout$ = this.store.usesDraidLayout$; activeStep: PoolCreationWizardStep; hasEnclosureStep = false; @@ -60,6 +61,10 @@ export class PoolManagerWizardComponent implements OnInit, OnDestroy { return Boolean(this.state.encryption); } + get alreadyHasSpare(): boolean { + return Boolean(this.existingPool?.topology?.spare?.length); + } + constructor( private store: PoolManagerStore, private systemStore$: Store, diff --git a/src/app/pages/storage/modules/pool-manager/components/pool-manager-wizard/steps/3-data-wizard-step/data-wizard-step.component.ts b/src/app/pages/storage/modules/pool-manager/components/pool-manager-wizard/steps/3-data-wizard-step/data-wizard-step.component.ts index c6d70a91e42..563d3e9cf4a 100644 --- a/src/app/pages/storage/modules/pool-manager/components/pool-manager-wizard/steps/3-data-wizard-step/data-wizard-step.component.ts +++ b/src/app/pages/storage/modules/pool-manager/components/pool-manager-wizard/steps/3-data-wizard-step/data-wizard-step.component.ts @@ -1,13 +1,21 @@ import { - ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, OnInit, Output, + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + EventEmitter, + Input, + OnInit, + Output, } from '@angular/core'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; import { map } from 'rxjs'; import { CreateVdevLayout, TopologyItemType, VdevType } from 'app/enums/v-dev-type.enum'; import helptext from 'app/helptext/storage/volumes/manager/manager'; -import { PoolTopology } from 'app/interfaces/pool.interface'; -import { AddVdevsStore } from 'app/pages/storage/modules/pool-manager/components/add-vdevs/store/add-vdevs-store.service'; +import { + AddVdevsStore, +} from 'app/pages/storage/modules/pool-manager/components/add-vdevs/store/add-vdevs-store.service'; import { PoolManagerStore } from 'app/pages/storage/modules/pool-manager/store/pool-manager.store'; +import { parseDraidVdevName } from 'app/pages/storage/modules/pool-manager/utils/topology.utils'; @UntilDestroy() @Component({ @@ -26,8 +34,6 @@ export class DataWizardStepComponent implements OnInit { readonly helptext = helptext; canChangeLayout = true; - existingDataTopology: PoolTopology; - constructor( private store: PoolManagerStore, private addVdevsStore: AddVdevsStore, @@ -42,9 +48,13 @@ export class DataWizardStepComponent implements OnInit { if (!dataTopology?.length) { return; } + // TODO: Similar code in poolTopologyToStoreTopology let type = dataTopology[0].type; if (type === TopologyItemType.Disk && !dataTopology[0].children.length) { type = TopologyItemType.Stripe; + } else if (type === TopologyItemType.Draid) { + const parsedVdevName = parseDraidVdevName(dataTopology[0].name); + type = parsedVdevName.layout as unknown as TopologyItemType; } this.allowedLayouts = [type] as unknown as CreateVdevLayout[]; this.canChangeLayout = false; diff --git a/src/app/pages/storage/modules/pool-manager/components/pool-manager/pool-manager.component.ts b/src/app/pages/storage/modules/pool-manager/components/pool-manager/pool-manager.component.ts index f2cc31336ea..19f11286711 100644 --- a/src/app/pages/storage/modules/pool-manager/components/pool-manager/pool-manager.component.ts +++ b/src/app/pages/storage/modules/pool-manager/components/pool-manager/pool-manager.component.ts @@ -26,6 +26,7 @@ export class PoolManagerComponent implements OnInit { ngOnInit(): void { this.addVdevsStore.pool$.pipe( tap((pool) => { + // TODO: Figure out why cloning is here and remove. this.existingPool = _.cloneDeep(pool); }), untilDestroyed(this), diff --git a/src/app/pages/storage/modules/pool-manager/pool-manager.module.ts b/src/app/pages/storage/modules/pool-manager/pool-manager.module.ts index 11ec2742b67..9ee0662943f 100644 --- a/src/app/pages/storage/modules/pool-manager/pool-manager.module.ts +++ b/src/app/pages/storage/modules/pool-manager/pool-manager.module.ts @@ -38,6 +38,7 @@ import { ManualDiskDragToggleStore } from 'app/pages/storage/modules/pool-manage import { ManualDiskSelectionStore } from 'app/pages/storage/modules/pool-manager/components/manual-disk-selection/store/manual-disk-selection.store'; import { NewDevicesPreviewComponent } from 'app/pages/storage/modules/pool-manager/components/new-devices/new-devices-preview.component'; import { PoolManagerComponent } from 'app/pages/storage/modules/pool-manager/components/pool-manager/pool-manager.component'; +import { DiskSizeSelectsComponent } from 'app/pages/storage/modules/pool-manager/components/pool-manager-wizard/components/layout-step/automated-disk-selection/disk-size-selects/disk-size-selects.component'; import { PoolManagerWizardComponent } from 'app/pages/storage/modules/pool-manager/components/pool-manager-wizard/pool-manager-wizard.component'; import { GeneralWizardStepComponent } from 'app/pages/storage/modules/pool-manager/components/pool-manager-wizard/steps/1-general-wizard-step/general-wizard-step.component'; import { LogWizardStepComponent } from 'app/pages/storage/modules/pool-manager/components/pool-manager-wizard/steps/4-log-wizard-step/log-wizard-step.component'; @@ -58,6 +59,8 @@ import { InspectVdevsDialogComponent } from './components/inspect-vdevs-dialog/i import { ManualSelectionDiskFiltersComponent } from './components/manual-disk-selection/components/manual-selection-disks/manual-selection-disk-filters/manual-selection-disk-filters.component'; import { ManualSelectionDisksComponent } from './components/manual-disk-selection/components/manual-selection-disks/manual-selection-disks.component'; import { AutomatedDiskSelectionComponent } from './components/pool-manager-wizard/components/layout-step/automated-disk-selection/automated-disk-selection.component'; +import { DraidSelectionComponent } from './components/pool-manager-wizard/components/layout-step/automated-disk-selection/draid-selection/draid-selection.component'; +import { NormalSelectionComponent } from './components/pool-manager-wizard/components/layout-step/automated-disk-selection/normal-selection/normal-selection.component'; import { CustomLayoutAppliedComponent } from './components/pool-manager-wizard/components/layout-step/custom-layout-applied/custom-layout-applied.component'; import { LayoutStepComponent } from './components/pool-manager-wizard/components/layout-step/layout-step.component'; import { PoolWarningsComponent } from './components/pool-manager-wizard/components/pool-warnings/pool-warnings.component'; @@ -124,6 +127,9 @@ import { DataWizardStepComponent } from './components/pool-manager-wizard/steps/ DownloadKeyDialogComponent, InspectVdevsDialogComponent, TopologyCategoryDescriptionPipe, + DiskSizeSelectsComponent, + DraidSelectionComponent, + NormalSelectionComponent, ], providers: [ PoolManagerStore, diff --git a/src/app/pages/storage/modules/pool-manager/store/pool-manager-validation.service.spec.ts b/src/app/pages/storage/modules/pool-manager/store/pool-manager-validation.service.spec.ts index e46d67e7af7..91be16d1196 100644 --- a/src/app/pages/storage/modules/pool-manager/store/pool-manager-validation.service.spec.ts +++ b/src/app/pages/storage/modules/pool-manager/store/pool-manager-validation.service.spec.ts @@ -1,14 +1,25 @@ import { createServiceFactory, mockProvider, SpectatorService } from '@ngneat/spectator/jest'; import { provideMockStore } from '@ngrx/store/testing'; -import { of } from 'rxjs'; +import { firstValueFrom, of } from 'rxjs'; import { TestScheduler } from 'rxjs/testing'; import { getTestScheduler } from 'app/core/testing/utils/get-test-scheduler.utils'; -import { VdevType } from 'app/enums/v-dev-type.enum'; +import { CreateVdevLayout, VdevType } from 'app/enums/v-dev-type.enum'; import { Pool } from 'app/interfaces/pool.interface'; -import { AddVdevsStore } from 'app/pages/storage/modules/pool-manager/components/add-vdevs/store/add-vdevs-store.service'; -import { DispersalStrategy } from 'app/pages/storage/modules/pool-manager/components/pool-manager-wizard/steps/2-enclosure-wizard-step/enclosure-wizard-step.component'; -import { PoolManagerValidationService } from 'app/pages/storage/modules/pool-manager/store/pool-manager-validation.service'; -import { PoolManagerStore } from 'app/pages/storage/modules/pool-manager/store/pool-manager.store'; +import { + AddVdevsStore, +} from 'app/pages/storage/modules/pool-manager/components/add-vdevs/store/add-vdevs-store.service'; +import { + DispersalStrategy, +} from 'app/pages/storage/modules/pool-manager/components/pool-manager-wizard/steps/2-enclosure-wizard-step/enclosure-wizard-step.component'; +import { PoolCreationSeverity } from 'app/pages/storage/modules/pool-manager/enums/pool-creation-severity'; +import { PoolCreationWizardStep } from 'app/pages/storage/modules/pool-manager/enums/pool-creation-wizard-step.enum'; +import { + PoolManagerValidationService, +} from 'app/pages/storage/modules/pool-manager/store/pool-manager-validation.service'; +import { + PoolManagerStore, + PoolManagerTopologyCategory, +} from 'app/pages/storage/modules/pool-manager/store/pool-manager.store'; import { selectSystemFeatures } from 'app/store/system-info/system-info.selectors'; describe('PoolManagerValidationService', () => { @@ -399,4 +410,82 @@ describe('PoolManagerValidationService', () => { }); }); }); + + describe('dRAID validation', () => { + let spectator: SpectatorService; + + const mockName$ = of('Pool'); + const mockTopology$ = of({ + [VdevType.Data]: { + hasCustomDiskSelection: false, + layout: CreateVdevLayout.Draid1, + vdevs: [[{}]], + draidDataDisks: 1, + draidSpareDisks: 0, + width: 9, + } as PoolManagerTopologyCategory, + }); + const mockEnclosureSettings$ = of({ + limitToSingleEnclosure: null, + dispersalStrategy: DispersalStrategy.None, + }); + const mockHasMultipleEnclosuresAfterFirstStep$ = of(true); + + const createService = createServiceFactory({ + service: PoolManagerValidationService, + providers: [ + mockProvider(PoolManagerStore, { + name$: mockName$, + enclosureSettings$: mockEnclosureSettings$, + topology$: mockTopology$, + hasMultipleEnclosuresAfterFirstStep$: mockHasMultipleEnclosuresAfterFirstStep$, + }), + mockProvider(AddVdevsStore, { + pool$: of(null), + }), + provideMockStore({ + selectors: [ + { + selector: selectSystemFeatures, + value: { + enclosure: true, + }, + }, + ], + }), + ], + }); + + beforeEach(() => { + spectator = createService(); + }); + + // TODO: Spit apart and add more boundary checks. + it('adds a warning when dRAID data disk is not a power of two', async () => { + const errors = await firstValueFrom(spectator.service.getPoolCreationErrors()); + expect(errors).toContainEqual({ + text: 'Recommended number of data disks for optimal space allocation should be power of 2 (2, 4, 8, 16...).', + severity: PoolCreationSeverity.Warning, + step: PoolCreationWizardStep.Data, + }); + }); + + it('adds a warning when dRAID children is less than 10', async () => { + const errors = await firstValueFrom(spectator.service.getPoolCreationErrors()); + expect(errors).toContainEqual({ + text: 'In order for dRAID to overweight its benefits over RaidZ the minimum recommended number of disks per dRAID vdev is 10.', + severity: PoolCreationSeverity.Warning, + step: PoolCreationWizardStep.Data, + }); + }); + + it('adds a warning when dRAID does not have spares added', async () => { + const errors = await firstValueFrom(spectator.service.getPoolCreationErrors()); + expect(errors).toContainEqual({ + text: 'At least one spare is recommended for dRAID. Spares cannot be added later.', + severity: PoolCreationSeverity.Warning, + step: PoolCreationWizardStep.Data, + }); + }); + }); }); diff --git a/src/app/pages/storage/modules/pool-manager/store/pool-manager-validation.service.ts b/src/app/pages/storage/modules/pool-manager/store/pool-manager-validation.service.ts index 0ed5e16ca54..9ff17fc5503 100644 --- a/src/app/pages/storage/modules/pool-manager/store/pool-manager-validation.service.ts +++ b/src/app/pages/storage/modules/pool-manager/store/pool-manager-validation.service.ts @@ -2,19 +2,28 @@ import { Injectable } from '@angular/core'; import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; import _ from 'lodash'; -import { - Observable, combineLatest, map, -} from 'rxjs'; +import { combineLatest, map, Observable } from 'rxjs'; import { CreateVdevLayout, TopologyItemType, VdevType } from 'app/enums/v-dev-type.enum'; import helptext from 'app/helptext/storage/volumes/manager/manager'; -import { AddVdevsStore } from 'app/pages/storage/modules/pool-manager/components/add-vdevs/store/add-vdevs-store.service'; -import { getNonUniqueSerialDisksWarning } from 'app/pages/storage/modules/pool-manager/components/pool-manager-wizard/components/pool-warnings/get-non-unique-serial-disks'; -import { DispersalStrategy } from 'app/pages/storage/modules/pool-manager/components/pool-manager-wizard/steps/2-enclosure-wizard-step/enclosure-wizard-step.component'; +import { + AddVdevsStore, +} from 'app/pages/storage/modules/pool-manager/components/add-vdevs/store/add-vdevs-store.service'; +import { + getNonUniqueSerialDisksWarning, +} from 'app/pages/storage/modules/pool-manager/components/pool-manager-wizard/components/pool-warnings/get-non-unique-serial-disks'; +import { + DispersalStrategy, +} from 'app/pages/storage/modules/pool-manager/components/pool-manager-wizard/steps/2-enclosure-wizard-step/enclosure-wizard-step.component'; import { PoolCreationSeverity } from 'app/pages/storage/modules/pool-manager/enums/pool-creation-severity'; import { PoolCreationWizardStep } from 'app/pages/storage/modules/pool-manager/enums/pool-creation-wizard-step.enum'; import { PoolCreationError } from 'app/pages/storage/modules/pool-manager/interfaces/pool-creation-error'; -import { PoolManagerStore, PoolManagerTopology, PoolManagerTopologyCategory } from 'app/pages/storage/modules/pool-manager/store/pool-manager.store'; +import { + PoolManagerStore, + PoolManagerTopology, + PoolManagerTopologyCategory, +} from 'app/pages/storage/modules/pool-manager/store/pool-manager.store'; import { hasExportedPool, hasNonUniqueSerial } from 'app/pages/storage/modules/pool-manager/utils/disk.utils'; +import { isDraidLayout } from 'app/pages/storage/modules/pool-manager/utils/topology.utils'; import { AppState } from 'app/store'; import { waitForSystemFeatures } from 'app/store/system-info/system-info.selectors'; @@ -72,7 +81,12 @@ export class PoolManagerValidationService { oldDataLayoutType = TopologyItemType.Stripe; } - if (hasDataVdevs && topology[VdevType.Data].layout !== oldDataLayoutType as unknown as CreateVdevLayout) { + // TODO: How is this even possible? + if ( + hasDataVdevs + && topology[VdevType.Data].layout !== oldDataLayoutType as unknown as CreateVdevLayout + && !isDraidLayout(oldDataLayoutType) + ) { errors.push({ text: this.translate.instant( 'Mixing Vdev layout types is not allowed. This pool already has some {type} Data Vdevs. You can only add vdevs of {type} type.', @@ -100,23 +114,27 @@ export class PoolManagerValidationService { const nonEmptyTopologyCategories = this.filterNonEmptyCategories(topology); - nonEmptyTopologyCategories.forEach(([typologyCategoryType, typologyCategory]) => { + nonEmptyTopologyCategories.forEach(([topologyCategoryType, topologyCategory]) => { if (existingPool) { return; } + if (topologyCategoryType === VdevType.Data && isDraidLayout(topologyCategory.layout)) { + errors.push(...this.validateDraid(topologyCategory)); + } + if ( - [VdevType.Dedup, VdevType.Log, VdevType.Special, VdevType.Data].includes(typologyCategoryType) - && typologyCategory.vdevs.length >= 1 && typologyCategory.layout === CreateVdevLayout.Stripe + [VdevType.Dedup, VdevType.Log, VdevType.Special, VdevType.Data].includes(topologyCategoryType) + && topologyCategory.vdevs.length >= 1 && topologyCategory.layout === CreateVdevLayout.Stripe ) { - if (typologyCategoryType === VdevType.Log) { + if (topologyCategoryType === VdevType.Log) { errors.push({ text: this.translate.instant('A stripe log VDEV may result in data loss if it fails combined with a power outage.'), severity: PoolCreationSeverity.Warning, step: PoolCreationWizardStep.Log, }); } else { - const vdevType = typologyCategoryType === 'special' ? 'metadata' : typologyCategoryType; + const vdevType = topologyCategoryType === 'special' ? 'metadata' : topologyCategoryType; errors.push({ text: this.translate.instant('A stripe {vdevType} VDEV is highly discouraged and will result in data loss if it fails', { vdevType }), @@ -126,7 +144,7 @@ export class PoolManagerValidationService { } } - const nonUniqueSerialDisks = typologyCategory.vdevs.flat().filter(hasNonUniqueSerial); + const nonUniqueSerialDisks = topologyCategory.vdevs.flat().filter(hasNonUniqueSerial); if (nonUniqueSerialDisks?.length) { errors.push({ @@ -136,7 +154,7 @@ export class PoolManagerValidationService { }); } - const disksWithExportedPools = typologyCategory.vdevs.flat().filter(hasExportedPool); + const disksWithExportedPools = topologyCategory.vdevs.flat().filter(hasExportedPool); if (disksWithExportedPools?.length) { errors.push({ @@ -205,4 +223,34 @@ export class PoolManagerValidationService { return acc; }, [] as [VdevType, PoolManagerTopologyCategory][]); } + + private validateDraid(topologyCategory: PoolManagerTopologyCategory): PoolCreationError[] { + const errors: PoolCreationError[] = []; + const powerOfTwo = Math.log2(topologyCategory.draidDataDisks); + if (powerOfTwo < 1 || !Number.isInteger(powerOfTwo)) { + errors.push({ + text: this.translate.instant('Recommended number of data disks for optimal space allocation should be power of 2 (2, 4, 8, 16...).'), + severity: PoolCreationSeverity.Warning, + step: PoolCreationWizardStep.Data, + }); + } + + if (topologyCategory.width < 10) { + errors.push({ + text: this.translate.instant('In order for dRAID to overweight its benefits over RaidZ the minimum recommended number of disks per dRAID vdev is 10.'), + severity: PoolCreationSeverity.Warning, + step: PoolCreationWizardStep.Data, + }); + } + + if (!topologyCategory.draidSpareDisks) { + errors.push({ + text: this.translate.instant('At least one spare is recommended for dRAID. Spares cannot be added later.'), + severity: PoolCreationSeverity.Warning, + step: PoolCreationWizardStep.Data, + }); + } + + return errors; + } } diff --git a/src/app/pages/storage/modules/pool-manager/store/pool-manager.store.spec.ts b/src/app/pages/storage/modules/pool-manager/store/pool-manager.store.spec.ts index 41c15729e5f..8072897f612 100644 --- a/src/app/pages/storage/modules/pool-manager/store/pool-manager.store.spec.ts +++ b/src/app/pages/storage/modules/pool-manager/store/pool-manager.store.spec.ts @@ -32,7 +32,7 @@ describe('PoolManagerStore', () => { type: DiskType.Ssd, enclosure: { number: 1, - slot: 1, + slot: 2, }, }, { @@ -98,9 +98,9 @@ describe('PoolManagerStore', () => { spectator.service.setManualTopologyCategory(VdevType.Data, [[disks[0]]]); const inventory = await firstValueFrom(spectator.service.getInventoryForStep(VdevType.Data)); - expect(inventory).toHaveLength(3); + expect(inventory).toHaveLength(2); const diskNames = inventory.map((disk) => disk.devname).sort(); - expect(diskNames).toEqual(['sda', 'sdb', 'sdc']); + expect(diskNames).toEqual(['sdb', 'sdc']); }); }); @@ -217,6 +217,8 @@ describe('PoolManagerStore', () => { vdevsNumber: 1, width: 1, vdevs: [[disks[2]]], + draidDataDisks: 0, + draidSpareDisks: 0, }); }); diff --git a/src/app/pages/storage/modules/pool-manager/store/pool-manager.store.ts b/src/app/pages/storage/modules/pool-manager/store/pool-manager.store.ts index e6423b5875a..1e3f8d5fd63 100644 --- a/src/app/pages/storage/modules/pool-manager/store/pool-manager.store.ts +++ b/src/app/pages/storage/modules/pool-manager/store/pool-manager.store.ts @@ -14,13 +14,16 @@ import { CreateVdevLayout, VdevType } from 'app/enums/v-dev-type.enum'; import { Enclosure } from 'app/interfaces/enclosure.interface'; import { UnusedDisk } from 'app/interfaces/storage.interface'; import { WebsocketError } from 'app/interfaces/websocket-error.interface'; -import { DispersalStrategy } from 'app/pages/storage/modules/pool-manager/components/pool-manager-wizard/steps/2-enclosure-wizard-step/enclosure-wizard-step.component'; +import { + DispersalStrategy, +} from 'app/pages/storage/modules/pool-manager/components/pool-manager-wizard/steps/2-enclosure-wizard-step/enclosure-wizard-step.component'; import { categoryCapacity } from 'app/pages/storage/modules/pool-manager/utils/capacity.utils'; import { filterAllowedDisks } from 'app/pages/storage/modules/pool-manager/utils/disk.utils'; import { GenerateVdevsService, } from 'app/pages/storage/modules/pool-manager/utils/generate-vdevs/generate-vdevs.service'; import { + isDraidLayout, topologyCategoryToDisks, topologyToDisks, } from 'app/pages/storage/modules/pool-manager/utils/topology.utils'; @@ -39,6 +42,10 @@ export interface PoolManagerTopologyCategory { treatDiskSizeAsMinimum: boolean; vdevs: UnusedDisk[][]; hasCustomDiskSelection: boolean; + + // Only used for data step when dRAID is selected. + draidDataDisks: number; + draidSpareDisks: number; } export type PoolManagerTopology = { @@ -67,6 +74,8 @@ export interface PoolManagerState { topology: PoolManagerTopology; } +type TopologyCategoryUpdate = Partial>; + const initialTopology = Object.values(VdevType).reduce((topology, value) => { return { ...topology, @@ -78,6 +87,9 @@ const initialTopology = Object.values(VdevType).reduce((topology, value) => { treatDiskSizeAsMinimum: false, vdevs: [], hasCustomDiskSelection: false, + + draidDataDisks: 0, + draidSpareDisks: 0, } as PoolManagerTopologyCategory, }; }, {} as PoolManagerTopology); @@ -143,6 +155,14 @@ export class PoolManagerStore extends ComponentStore { }, ); + readonly usesDraidLayout$ = this.select( + this.topology$, + (topology) => { + const dataCategory = topology[VdevType.Data]; + return [CreateVdevLayout.Draid1, CreateVdevLayout.Draid2, CreateVdevLayout.Draid3].includes(dataCategory.layout); + }, + ); + getLayoutsForVdevType(vdevType: VdevType): Observable { switch (vdevType) { case VdevType.Cache: @@ -172,11 +192,16 @@ export class PoolManagerStore extends ComponentStore { // TODO: Check if this needs to be optimized getInventoryForStep(type: VdevType): Observable { return this.select( - this.inventory$, + this.allowedDisks$, this.topology$, - (inventory, topology) => { - const disksUsedInCategory = topologyCategoryToDisks(topology[type]); - return [...inventory, ...disksUsedInCategory]; + (allowedDisks, topology) => { + const disksUsedInOtherCategories = Object.values(topology).flatMap((category) => { + if (category === topology[type]) { + return []; + } + return topologyCategoryToDisks(category); + }); + return _.differenceBy(allowedDisks, disksUsedInOtherCategories, (disk) => disk.devname); }, ); } @@ -202,8 +227,8 @@ export class PoolManagerStore extends ComponentStore { } resetStep(vdevType: VdevType): void { + this.resetTopologyCategory(vdevType); this.resetStep$.next(vdevType); - this.updateTopologyCategory(vdevType, initialTopology[vdevType]); } readonly initialize = this.effect((trigger$) => { @@ -272,7 +297,31 @@ export class PoolManagerStore extends ComponentStore { this.resetTopologyIfNotEnoughDisks(); } - setAutomaticTopologyCategory(type: VdevType, updates: Omit): void { + setTopologyCategoryDiskSizes( + type: VdevType, + updates: Pick, + ): void { + this.updateTopologyCategory(type, updates); + } + + setTopologyCategoryLayout( + type: VdevType, + newLayout: CreateVdevLayout, + ): void { + const isNewLayoutDraid = isDraidLayout(newLayout); + const isOldLayoutDraid = isDraidLayout(this.get().topology[type].layout); + if (isNewLayoutDraid !== isOldLayoutDraid) { + this.resetTopologyCategory(type); + } + + this.updateTopologyCategory(type, { layout: newLayout }); + + if (isDraidLayout(newLayout)) { + this.resetTopologyCategory(VdevType.Spare); + } + } + + setAutomaticTopologyCategory(type: VdevType, updates: TopologyCategoryUpdate): void { this.updateTopologyCategory(type, updates); this.regenerateVdevs(); diff --git a/src/app/pages/storage/modules/pool-manager/utils/capacity.utils.spec.ts b/src/app/pages/storage/modules/pool-manager/utils/capacity.utils.spec.ts index f3d7f48e08e..dd44cf11390 100644 --- a/src/app/pages/storage/modules/pool-manager/utils/capacity.utils.spec.ts +++ b/src/app/pages/storage/modules/pool-manager/utils/capacity.utils.spec.ts @@ -72,7 +72,9 @@ describe('vdevCapacity', () => { vdev, layout: CreateVdevLayout.Draid1, swapOnDrive: 2 * GiB, - })).toBe(4 * GiB); + draidDataDisks: 2, + draidSpareDisks: 1, + }) / GiB).toBeCloseTo(2.67); }); it('draid2 layout', () => { @@ -80,7 +82,9 @@ describe('vdevCapacity', () => { vdev, layout: CreateVdevLayout.Draid2, swapOnDrive: 2 * GiB, - })).toBe(3 * GiB); + draidDataDisks: 2, + draidSpareDisks: 1, + }) / GiB).toBeCloseTo(2); }); it('draid3 layout', () => { @@ -88,6 +92,8 @@ describe('vdevCapacity', () => { vdev, layout: CreateVdevLayout.Draid3, swapOnDrive: 2 * GiB, - })).toBe(2 * GiB); + draidDataDisks: 2, + draidSpareDisks: 1, + }) / GiB).toBeCloseTo(1.6); }); }); diff --git a/src/app/pages/storage/modules/pool-manager/utils/capacity.utils.ts b/src/app/pages/storage/modules/pool-manager/utils/capacity.utils.ts index e47d897f888..0a75bc8f9dd 100644 --- a/src/app/pages/storage/modules/pool-manager/utils/capacity.utils.ts +++ b/src/app/pages/storage/modules/pool-manager/utils/capacity.utils.ts @@ -9,14 +9,20 @@ export function categoryCapacity(topologyCategory: PoolManagerTopologyCategory, vdev, layout: topologyCategory.layout, swapOnDrive, + draidDataDisks: topologyCategory.draidDataDisks, + draidSpareDisks: topologyCategory.draidSpareDisks, }); }, 0); } -export function vdevCapacity({ vdev, layout, swapOnDrive }: { +export function vdevCapacity({ + vdev, layout, swapOnDrive, draidDataDisks, draidSpareDisks, +}: { vdev: UnusedDisk[]; layout: CreateVdevLayout; swapOnDrive: number; + draidDataDisks?: number; + draidSpareDisks?: number; }): number { if (!vdev.length) { return 0; @@ -27,7 +33,6 @@ export function vdevCapacity({ vdev, layout, swapOnDrive }: { }).size - swapOnDrive; const totalSize = smallestDiskSize * vdev.length; - const defaultDraidDataPerGroup = 8; switch (layout) { case CreateVdevLayout.Mirror: @@ -38,20 +43,30 @@ export function vdevCapacity({ vdev, layout, swapOnDrive }: { return totalSize - smallestDiskSize * 2; case CreateVdevLayout.Raidz3: return totalSize - smallestDiskSize * 3; - - // https://openzfs.github.io/openzfs-docs/man/7/zpoolconcepts.7.html#draid - case CreateVdevLayout.Draid1: { - const dataPerGroup = Math.min(defaultDraidDataPerGroup, vdev.length - 1); - return vdev.length * (dataPerGroup / (dataPerGroup + 1)) * smallestDiskSize; - } - case CreateVdevLayout.Draid2: { - const dataPerGroup = Math.min(defaultDraidDataPerGroup, vdev.length - 2); - return vdev.length * (dataPerGroup / (dataPerGroup + 2)) * smallestDiskSize; - } - case CreateVdevLayout.Draid3: { - const dataPerGroup = Math.min(defaultDraidDataPerGroup, vdev.length - 3); - return vdev.length * (dataPerGroup / (dataPerGroup + 3)) * smallestDiskSize; - } + case CreateVdevLayout.Draid1: + return dRaidCapacity({ + children: vdev.length, + dataPerGroup: draidDataDisks, + parity: 1, + spares: draidSpareDisks, + size: smallestDiskSize, + }); + case CreateVdevLayout.Draid2: + return dRaidCapacity({ + children: vdev.length, + dataPerGroup: draidDataDisks, + parity: 2, + spares: draidSpareDisks, + size: smallestDiskSize, + }); + case CreateVdevLayout.Draid3: + return dRaidCapacity({ + children: vdev.length, + dataPerGroup: draidDataDisks, + parity: 3, + spares: draidSpareDisks, + size: smallestDiskSize, + }); case CreateVdevLayout.Stripe: return vdev.reduce((sum, disk) => sum + disk.size - swapOnDrive, 0); default: @@ -59,3 +74,18 @@ export function vdevCapacity({ vdev, layout, swapOnDrive }: { throw new Error(`Unknown layout: ${layout}`); } } + +/** + * https://openzfs.github.io/openzfs-docs/man/7/zpoolconcepts.7.html#draid + */ +function dRaidCapacity(values: { + children: number; + dataPerGroup: number; + parity: number; + size: number; + spares: number; +}): number { + return (values.children - values.spares) + * (values.dataPerGroup / (values.dataPerGroup + values.parity)) + * values.size; +} diff --git a/src/app/pages/storage/modules/pool-manager/utils/form.utils.ts b/src/app/pages/storage/modules/pool-manager/utils/form.utils.ts new file mode 100644 index 00000000000..897e57484cb --- /dev/null +++ b/src/app/pages/storage/modules/pool-manager/utils/form.utils.ts @@ -0,0 +1,34 @@ +import { FormControl } from '@angular/forms'; +import _ from 'lodash'; +import { Option } from 'app/interfaces/option.interface'; +import { IxSimpleChanges } from 'app/interfaces/simple-changes.interface'; + +export function unsetControlIfNoMatchingOption( + control: FormControl, + options: Option[], +): void { + const currentValue = control.value; + const hasMatchingOption = options.some((option) => option.value === currentValue); + if (!hasMatchingOption) { + setValueIfNotSame(control, null); + } +} + +export function setValueIfNotSame( + control: FormControl, + value: unknown, +): void { + if (control.value === value) { + return; + } + + control.setValue(value); +} + +export function hasDeepChanges( + changes: IxSimpleChanges, + key: keyof T, +): boolean { + return changes[key]?.currentValue + && !_.isEqual(changes[key].currentValue, changes[key].previousValue); +} diff --git a/src/app/pages/storage/modules/pool-manager/utils/topology.utils.spec.ts b/src/app/pages/storage/modules/pool-manager/utils/topology.utils.spec.ts index 53cf9bd841b..8e9d9d9542a 100644 --- a/src/app/pages/storage/modules/pool-manager/utils/topology.utils.spec.ts +++ b/src/app/pages/storage/modules/pool-manager/utils/topology.utils.spec.ts @@ -5,6 +5,7 @@ import { PoolManagerTopologyCategory, } from 'app/pages/storage/modules/pool-manager/store/pool-manager.store'; import { + parseDraidVdevName, topologyCategoryToDisks, topologyToDisks, topologyToPayload, } from 'app/pages/storage/modules/pool-manager/utils/topology.utils'; @@ -97,4 +98,44 @@ describe('topologyToPayload', () => { spares: ['ada7'], }); }); + + it('converts dRAID layout to websocket payload', () => { + const disk1 = { devname: 'ada1' } as UnusedDisk; + const disk2 = { devname: 'ada2' } as UnusedDisk; + const disk3 = { devname: 'ada3' } as UnusedDisk; + const disk4 = { devname: 'ada4' } as UnusedDisk; + + const topology = { + [VdevType.Data]: { + layout: CreateVdevLayout.Draid1, + vdevs: [ + [disk1, disk2], + [disk3, disk4], + ], + draidSpareDisks: 1, + draidDataDisks: 1, + }, + } as PoolManagerTopology; + + expect(topologyToPayload(topology)).toEqual({ + data: [ + { + type: CreateVdevLayout.Draid1, disks: ['ada1', 'ada2'], draid_data_disks: 1, draid_spare_disks: 1, + }, + { + type: CreateVdevLayout.Draid1, disks: ['ada3', 'ada4'], draid_data_disks: 1, draid_spare_disks: 1, + }, + ], + }); + }); +}); + +describe('parseDraidVdevName', () => { + it('parses dRAID vdev name into layout, data disks and spare', () => { + expect(parseDraidVdevName('draid3:1d:6c:2s-0')).toEqual({ + layout: CreateVdevLayout.Draid3, + dataDisks: 1, + spareDisks: 2, + }); + }); }); diff --git a/src/app/pages/storage/modules/pool-manager/utils/topology.utils.ts b/src/app/pages/storage/modules/pool-manager/utils/topology.utils.ts index 1185bdc8a21..14da7188b8f 100644 --- a/src/app/pages/storage/modules/pool-manager/utils/topology.utils.ts +++ b/src/app/pages/storage/modules/pool-manager/utils/topology.utils.ts @@ -1,6 +1,6 @@ import { DiskType } from 'app/enums/disk-type.enum'; import { CreateVdevLayout, TopologyItemType, VdevType } from 'app/enums/v-dev-type.enum'; -import { PoolTopology, UpdatePoolTopology } from 'app/interfaces/pool.interface'; +import { DataPoolTopologyUpdate, PoolTopology, UpdatePoolTopology } from 'app/interfaces/pool.interface'; import { Disk, UnusedDisk } from 'app/interfaces/storage.interface'; import { PoolManagerTopology, @@ -27,10 +27,20 @@ export function topologyToPayload(topology: PoolManagerTopology): UpdatePoolTopo } payload[vdevType] = category.vdevs.map((vdev) => { - return { + let typePayload = { type: category.layout, disks: vdev.map((disk) => disk.devname), }; + + if (isDraidLayout(category.layout)) { + typePayload = { + ...typePayload, + draid_data_disks: category.draidDataDisks, + draid_spare_disks: category.draidSpareDisks, + } as DataPoolTopologyUpdate; + } + + return typePayload; }); }); @@ -69,13 +79,23 @@ export function poolTopologyToStoreTopology(topology: PoolTopology, disks: Disk[ } const minSize = Math.min(...(disks.map((disk) => disk.size))); + let draidDataDisks: number = null; + let draidSpareDisks: number = null; + + if (vdevs[0].type === TopologyItemType.Draid) { + const parsedDraidInfo = parseDraidVdevName(vdevs[0].name); + draidDataDisks = parsedDraidInfo.dataDisks; + draidSpareDisks = parsedDraidInfo.spareDisks; + layoutType = parsedDraidInfo.layout as unknown as TopologyItemType; + } + poolManagerTopology[category as VdevType] = { diskType: disks[0].type, diskSize: minSize, layout: layoutType as unknown as CreateVdevLayout, vdevsNumber: vdevs.length, width, - hasCustomDiskSelection: vdevs.some((vdev2) => vdevs[0].children.length !== vdev2.children.length), + hasCustomDiskSelection: vdevs.some((vdev) => vdevs[0].children.length !== vdev.children.length), vdevs: topology[category as VdevType].map( (topologyItem) => { if (topologyItem.children.length) { @@ -94,7 +114,45 @@ export function poolTopologyToStoreTopology(topology: PoolTopology, disks: Disk[ }, ), treatDiskSizeAsMinimum: false, + draidDataDisks, + draidSpareDisks, }; } return poolManagerTopology; } + +export function isDraidLayout(layout: CreateVdevLayout | TopologyItemType): boolean { + return [ + CreateVdevLayout.Draid1, + CreateVdevLayout.Draid2, + CreateVdevLayout.Draid3, + TopologyItemType.Draid, + ].includes(layout); +} + +export function parseDraidVdevName( + vdevName: string, +): { layout: CreateVdevLayout; dataDisks: number; spareDisks: number } { + const regex = /draid(\d+):(\d)d:\dc:(\d)s/; + const match = vdevName.match(regex); + + if (!match) { + throw new Error('Invalid dRAID vdev name'); + } + + const [, parityLevelNumber, dataDisks, spareDisk] = match; + let parityLevel = CreateVdevLayout.Draid1; + if (parityLevelNumber === '2') { + parityLevel = CreateVdevLayout.Draid2; + } else if (parityLevelNumber === '3') { + parityLevel = CreateVdevLayout.Draid3; + } else { + parityLevel = CreateVdevLayout.Draid1; + } + + return { + layout: parityLevel, + dataDisks: Number(dataDisks), + spareDisks: Number(spareDisk), + }; +} diff --git a/src/app/pages/system/advanced/cron/cron-card/cron-card.component.html b/src/app/pages/system/advanced/cron/cron-card/cron-card.component.html index 693c14a33a5..d6e8e8ca31d 100644 --- a/src/app/pages/system/advanced/cron/cron-card/cron-card.component.html +++ b/src/app/pages/system/advanced/cron/cron-card/cron-card.component.html @@ -1,3 +1,58 @@ - + + + +

+ {{ title | translate }} + +

+
+
+ + + + + +
+ + + +
+
+ +
+
diff --git a/src/app/pages/system/advanced/cron/cron-card/cron-card.component.scss b/src/app/pages/system/advanced/cron/cron-card/cron-card.component.scss new file mode 100644 index 00000000000..38517058f61 --- /dev/null +++ b/src/app/pages/system/advanced/cron/cron-card/cron-card.component.scss @@ -0,0 +1,13 @@ +.card-title { + align-items: center; + display: flex; + + ix-icon { + margin-left: 5px; + } +} + +.buttons { + display: flex; + justify-content: flex-end; +} diff --git a/src/app/pages/system/advanced/cron/cron-card/cron-card.component.spec.ts b/src/app/pages/system/advanced/cron/cron-card/cron-card.component.spec.ts new file mode 100644 index 00000000000..e5372ad4092 --- /dev/null +++ b/src/app/pages/system/advanced/cron/cron-card/cron-card.component.spec.ts @@ -0,0 +1,124 @@ +import { HarnessLoader } from '@angular/cdk/testing'; +import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; +import { MatDialog } from '@angular/material/dialog'; +import { Spectator } from '@ngneat/spectator'; +import { createComponentFactory, mockProvider } from '@ngneat/spectator/jest'; +import { of } from 'rxjs'; +import { mockWebsocket, mockCall } from 'app/core/testing/utils/mock-websocket.utils'; +import { EntityModule } from 'app/modules/entity/entity.module'; +import { IxSlideInRef } from 'app/modules/ix-forms/components/ix-slide-in/ix-slide-in-ref'; +import { IxIconHarness } from 'app/modules/ix-icon/ix-icon.harness'; +import { IxTable2Harness } from 'app/modules/ix-table2/components/ix-table2/ix-table2.harness'; +import { IxTable2Module } from 'app/modules/ix-table2/ix-table2.module'; +import { AppLoaderModule } from 'app/modules/loader/app-loader.module'; +import { CronCardComponent } from 'app/pages/system/advanced/cron/cron-card/cron-card.component'; +import { CronDeleteDialogComponent } from 'app/pages/system/advanced/cron/cron-delete-dialog/cron-delete-dialog.component'; +import { CronFormComponent } from 'app/pages/system/advanced/cron/cron-form/cron-form.component'; +import { DialogService } from 'app/services/dialog.service'; +import { IxSlideInService } from 'app/services/ix-slide-in.service'; +import { LocaleService } from 'app/services/locale.service'; +import { TaskService } from 'app/services/task.service'; + +describe('CronCardComponent', () => { + let spectator: Spectator; + let loader: HarnessLoader; + let table: IxTable2Harness; + + const cronJobs = [ + { + id: 1, + user: 'root', + command: "echo 'Hello World'", + description: 'test', + enabled: true, + stdout: true, + stderr: false, + schedule: { + minute: '0', + hour: '0', + dom: '*', + month: '*', + dow: '*', + }, + }, + ]; + + const createComponent = createComponentFactory({ + component: CronCardComponent, + imports: [ + AppLoaderModule, + EntityModule, + IxTable2Module, + ], + providers: [ + mockWebsocket([ + mockCall('cronjob.query', cronJobs), + mockCall('cronjob.run'), + ]), + mockProvider(DialogService, { + confirm: jest.fn(() => of(true)), + }), + mockProvider(IxSlideInService, { + open: jest.fn(() => { + return { slideInClosed$: of() }; + }), + }), + mockProvider(IxSlideInRef), + mockProvider(MatDialog, { + open: jest.fn(() => ({ + afterClosed: () => of(true), + })), + }), + mockProvider(LocaleService), + mockProvider(TaskService, { + getTaskNextRun: jest.fn(() => 'in about 10 hours'), + }), + ], + }); + + beforeEach(async () => { + spectator = createComponent(); + loader = TestbedHarnessEnvironment.loader(spectator.fixture); + table = await loader.getHarness(IxTable2Harness); + }); + + it('should show table rows', async () => { + const expectedRows = [ + ['Users', 'Command', 'Description', 'Schedule', 'Enabled', ''], + ['root', "echo 'Hello World'", 'test', '0 0 * * *', 'Yes', ''], + ]; + + const cells = await table.getCellTexts(); + expect(cells).toEqual(expectedRows); + }); + + it('shows confirmation dialog when Run Now button is pressed', async () => { + jest.spyOn(spectator.inject(DialogService), 'confirm'); + const runNowButton = await table.getHarnessInCell(IxIconHarness.with({ name: 'play_arrow' }), 1, 5); + await runNowButton.click(); + + expect(spectator.inject(DialogService).confirm).toHaveBeenCalledWith({ + title: 'Run Now', + message: 'Run this job now?', + hideCheckbox: true, + }); + }); + + it('shows form to edit an existing interface when Edit button is pressed', async () => { + const editButton = await table.getHarnessInCell(IxIconHarness.with({ name: 'edit' }), 1, 5); + await editButton.click(); + + expect(spectator.inject(IxSlideInService).open).toHaveBeenCalledWith(CronFormComponent, { + data: expect.objectContaining(cronJobs[0]), + }); + }); + + it('deletes a cronjob with confirmation when Delete button is pressed', async () => { + const deleteIcon = await table.getHarnessInCell(IxIconHarness.with({ name: 'delete' }), 1, 5); + await deleteIcon.click(); + + expect(spectator.inject(MatDialog).open).toHaveBeenCalledWith(CronDeleteDialogComponent, { + data: expect.objectContaining({ id: 1 }), + }); + }); +}); diff --git a/src/app/pages/system/advanced/cron/cron-card/cron-card.component.ts b/src/app/pages/system/advanced/cron/cron-card/cron-card.component.ts index cc78461dd23..fbf3e4ebd9c 100644 --- a/src/app/pages/system/advanced/cron/cron-card/cron-card.component.ts +++ b/src/app/pages/system/advanced/cron/cron-card/cron-card.component.ts @@ -1,90 +1,65 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit } from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; import { TranslateService } from '@ngx-translate/core'; -import * as cronParser from 'cron-parser'; -import { formatDistanceToNowStrict } from 'date-fns'; -import { filter, switchMap } from 'rxjs/operators'; +import { filter, map, switchMap } from 'rxjs'; import { helptextSystemAdvanced } from 'app/helptext/system/advanced'; import { Cronjob } from 'app/interfaces/cronjob.interface'; import { WebsocketError } from 'app/interfaces/websocket-error.interface'; -import { AppTableAction, AppTableConfig } from 'app/modules/entity/table/table.component'; -import { AdvancedSettingsService } from 'app/pages/system/advanced/advanced-settings.service'; +import { ArrayDataProvider } from 'app/modules/ix-table2/array-data-provider'; +import { scheduleColumn } from 'app/modules/ix-table2/components/ix-table-body/cells/ix-cell-schedule/ix-cell-schedule.component'; +import { textColumn } from 'app/modules/ix-table2/components/ix-table-body/cells/ix-cell-text/ix-cell-text.component'; +import { yesNoColumn } from 'app/modules/ix-table2/components/ix-table-body/cells/ix-cell-yesno/ix-cell-yesno.component'; +import { createTable } from 'app/modules/ix-table2/utils'; +import { scheduleToCrontab } from 'app/modules/scheduler/utils/schedule-to-crontab.utils'; +import { CronDeleteDialogComponent } from 'app/pages/system/advanced/cron/cron-delete-dialog/cron-delete-dialog.component'; import { CronFormComponent } from 'app/pages/system/advanced/cron/cron-form/cron-form.component'; import { CronjobRow } from 'app/pages/system/advanced/cron/cron-list/cronjob-row.interface'; import { DialogService } from 'app/services/dialog.service'; import { ErrorHandlerService } from 'app/services/error-handler.service'; import { IxSlideInService } from 'app/services/ix-slide-in.service'; +import { TaskService } from 'app/services/task.service'; import { WebSocketService } from 'app/services/ws.service'; @UntilDestroy() @Component({ selector: 'ix-cron-card', templateUrl: './cron-card.component.html', + styleUrls: ['./cron-card.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class CronCardComponent { - readonly tableConfig: AppTableConfig = { - title: helptextSystemAdvanced.fieldset_cron, - titleHref: '/system/cron', - queryCall: 'cronjob.query', - deleteCall: 'cronjob.delete', - deleteMsg: { - title: this.translate.instant('Cron Job'), - key_props: ['user', 'command', 'description'], - }, - getActions: (): AppTableAction[] => { - return [ - { - name: 'play', - icon: 'play_arrow', - matTooltip: this.translate.instant('Run job'), - onClick: (row: CronjobRow): void => { - this.dialog - .confirm({ title: this.translate.instant('Run Now'), message: this.translate.instant('Run this job now?'), hideCheckbox: true }) - .pipe( - filter((run) => !!run), - switchMap(() => this.ws.call('cronjob.run', [row.id])), - ) - .pipe(untilDestroyed(this)).subscribe({ - next: () => { - const message = row.enabled - ? this.translate.instant('This job is scheduled to run again {nextRun}.', { nextRun: row.next_run }) - : this.translate.instant('This job will not run again until it is enabled.'); - this.dialog.info( - this.translate.instant('Job {job} Completed Successfully', { job: row.description }), - message, - true, - ); - }, - error: (error: WebsocketError) => this.dialog.error(this.errorHandler.parseWsError(error)), - }); - }, - }, - ]; - }, - emptyEntityLarge: false, - dataSourceHelper: this.cronDataSourceHelper, - columns: [ - { name: this.translate.instant('Users'), prop: 'user' }, - { name: this.translate.instant('Command'), prop: 'command' }, - { name: this.translate.instant('Description'), prop: 'description' }, - { name: this.translate.instant('Schedule'), prop: 'cron_schedule' }, - { name: this.translate.instant('Enabled'), prop: 'enabled' }, - { name: this.translate.instant('Next Run'), prop: 'next_run' }, - ], - add: async () => { - await this.advancedSettings.showFirstTimeWarningIfNeeded(); +export class CronCardComponent implements OnInit { + title = helptextSystemAdvanced.fieldset_cron; + cronjobs: CronjobRow[] = []; + dataProvider = new ArrayDataProvider(); - const slideInRef = this.slideInService.open(CronFormComponent); - slideInRef.slideInClosed$.pipe(untilDestroyed(this)).subscribe(() => this.tableConfig.tableComponent?.getData()); - }, - edit: async (cron: CronjobRow) => { - await this.advancedSettings.showFirstTimeWarningIfNeeded(); + columns = createTable([ + textColumn({ + title: this.translate.instant('Users'), + propertyName: 'user', + }), + textColumn({ + title: this.translate.instant('Command'), + propertyName: 'command', + }), + textColumn({ + title: this.translate.instant('Description'), + propertyName: 'description', + }), + scheduleColumn({ + title: this.translate.instant('Schedule'), + propertyName: 'schedule', + }), + yesNoColumn({ + title: this.translate.instant('Enabled'), + propertyName: 'enabled', + }), + textColumn({ + propertyName: 'id', + }), + ]); - const slideInRef = this.slideInService.open(CronFormComponent, { data: cron }); - slideInRef.slideInClosed$.pipe(untilDestroyed(this)).subscribe(() => this.tableConfig.tableComponent?.getData()); - }, - }; + isLoading = false; constructor( private slideInService: IxSlideInService, @@ -92,21 +67,73 @@ export class CronCardComponent { private errorHandler: ErrorHandlerService, private ws: WebSocketService, private dialog: DialogService, - private advancedSettings: AdvancedSettingsService, + private taskService: TaskService, + private cdr: ChangeDetectorRef, + private matDialog: MatDialog, ) {} - private cronDataSourceHelper(data: Cronjob[]): CronjobRow[] { - return data.map((job) => { - const schedule = `${job.schedule.minute} ${job.schedule.hour} ${job.schedule.dom} ${job.schedule.month} ${job.schedule.dow}`; - return { - ...job, - cron_schedule: schedule, + ngOnInit(): void { + this.getCronJobs(); + } + + getCronJobs(): void { + this.isLoading = true; + this.ws.call('cronjob.query').pipe( + map((cronjobs) => { + return cronjobs.map((job: Cronjob): CronjobRow => ({ + ...job, + cron_schedule: scheduleToCrontab(job.schedule), + next_run: this.taskService.getTaskNextRun(scheduleToCrontab(job.schedule)), + })); + }), + untilDestroyed(this), + ).subscribe((cronjobs) => { + this.cronjobs = cronjobs; + this.dataProvider.setRows(cronjobs); + this.isLoading = false; + this.cdr.markForCheck(); + }); + } - next_run: formatDistanceToNowStrict( - cronParser.parseExpression(schedule, { iterator: true }).next().value.toDate(), - { addSuffix: true }, - ), - }; + runNow(row: CronjobRow): void { + this.dialog.confirm({ + title: this.translate.instant('Run Now'), + message: this.translate.instant('Run this job now?'), + hideCheckbox: true, + }).pipe( + filter(Boolean), + switchMap(() => this.ws.call('cronjob.run', [row.id])), + untilDestroyed(this), + ).subscribe({ + next: () => { + const message = row.enabled + ? this.translate.instant('This job is scheduled to run again {nextRun}.', { nextRun: row.next_run }) + : this.translate.instant('This job will not run again until it is enabled.'); + this.dialog.info( + this.translate.instant('Job {job} Completed Successfully', { job: row.description }), + message, + ); + }, + error: (error: WebsocketError) => this.dialog.error(this.errorHandler.parseWsError(error)), }); } + + doDelete(row: CronjobRow): void { + this.matDialog.open(CronDeleteDialogComponent, { + data: row, + }).afterClosed() + .pipe(filter(Boolean), untilDestroyed(this)) + .subscribe(() => { + this.getCronJobs(); + }); + } + + doEdit(row: CronjobRow): void { + const slideInRef = this.slideInService.open(CronFormComponent, { data: row }); + slideInRef.slideInClosed$ + .pipe(filter(Boolean), untilDestroyed(this)) + .subscribe(() => { + this.getCronJobs(); + }); + } } diff --git a/src/app/pages/system/advanced/cron/cron-delete-dialog/cron-delete-dialog.component.ts b/src/app/pages/system/advanced/cron/cron-delete-dialog/cron-delete-dialog.component.ts index 3868e23adca..0cb193d0bec 100644 --- a/src/app/pages/system/advanced/cron/cron-delete-dialog/cron-delete-dialog.component.ts +++ b/src/app/pages/system/advanced/cron/cron-delete-dialog/cron-delete-dialog.component.ts @@ -8,7 +8,6 @@ import { TranslateService } from '@ngx-translate/core'; import { AppLoaderService } from 'app/modules/loader/app-loader.service'; import { SnackbarService } from 'app/modules/snackbar/services/snackbar.service'; import { CronjobRow } from 'app/pages/system/advanced/cron/cron-list/cronjob-row.interface'; -import { DialogService } from 'app/services/dialog.service'; import { ErrorHandlerService } from 'app/services/error-handler.service'; import { WebSocketService } from 'app/services/ws.service'; @@ -25,7 +24,6 @@ export class CronDeleteDialogComponent { private ws: WebSocketService, private snackbar: SnackbarService, private translate: TranslateService, - private dialogService: DialogService, private dialogRef: MatDialogRef, @Inject(MAT_DIALOG_DATA) public cronjob: CronjobRow, private errorHandler: ErrorHandlerService, diff --git a/src/app/pages/system/advanced/sessions/sessions-card/sessions-card.component.html b/src/app/pages/system/advanced/sessions/sessions-card/sessions-card.component.html index e17d81e2ea5..3d01cc3a3a8 100644 --- a/src/app/pages/system/advanced/sessions/sessions-card/sessions-card.component.html +++ b/src/app/pages/system/advanced/sessions/sessions-card/sessions-card.component.html @@ -51,23 +51,20 @@

{{ 'Sessions' | translate }}

> {{ getUsername(session) }} - - {{ getDate(session.created_at.$date) }} - -
+