diff --git a/src/app/enums/virtualization.enum.ts b/src/app/enums/virtualization.enum.ts index c3da88faf47..d1fefac781b 100644 --- a/src/app/enums/virtualization.enum.ts +++ b/src/app/enums/virtualization.enum.ts @@ -1,4 +1,5 @@ import { marker as T } from '@biesbjerg/ngx-translate-extract-marker'; +import { iconMarker } from 'app/modules/ix-icon/icon-marker.util'; export enum VirtualizationType { Container = 'CONTAINER', @@ -10,6 +11,21 @@ export const virtualizationTypeLabels = new Map([ [VirtualizationType.Vm, T('VM')], ]); +export const virtualizationTypeIcons = [ + { + value: VirtualizationType.Container, + icon: iconMarker('mdi-linux'), + label: T('Container'), + description: T('Linux Only'), + }, + { + value: VirtualizationType.Vm, + icon: iconMarker('mdi-laptop'), + label: T('VM'), + description: T('Any OS'), + }, +]; + export enum VirtualizationStatus { Running = 'RUNNING', Stopped = 'STOPPED', @@ -82,3 +98,7 @@ export const virtualizationNicTypeLabels = new Map]; response: ReplicationTask }; // Reporting - 'reporting.exporters.create': { params: [CreateReportingExporter]; response: ReportingExporter }; + 'reporting.exporters.create': { params: [UpdateReportingExporter]; response: ReportingExporter }; 'reporting.exporters.delete': { params: [id: number]; response: boolean }; 'reporting.exporters.exporter_schemas': { params: void; response: ReportingExporterSchema[] }; 'reporting.exporters.query': { params: QueryParams; response: ReportingExporter[] }; @@ -864,7 +864,7 @@ export interface ApiCallDirectory { 'virt.device.disk_choices': { params: []; response: Choices }; 'virt.device.gpu_choices': { - params: [instanceType: VirtualizationType, gpuType: VirtualizationGpuType]; + params: [gpuType: VirtualizationGpuType]; response: AvailableGpus; }; 'virt.device.usb_choices': { params: []; response: Record }; diff --git a/src/app/interfaces/dialog.interface.ts b/src/app/interfaces/dialog.interface.ts index 373f827a70e..4a6df028a1f 100644 --- a/src/app/interfaces/dialog.interface.ts +++ b/src/app/interfaces/dialog.interface.ts @@ -7,7 +7,7 @@ export interface ConfirmOptions { cancelText?: string; disableClose?: boolean; confirmationCheckboxText?: string; - buttonColor?: 'primary' | 'red'; + buttonColor?: 'primary' | 'warn'; } export interface ConfirmOptionsWithSecondaryCheckbox extends ConfirmOptions { diff --git a/src/app/interfaces/enclosure.interface.ts b/src/app/interfaces/enclosure.interface.ts index 166f4df89bd..8f56834e730 100644 --- a/src/app/interfaces/enclosure.interface.ts +++ b/src/app/interfaces/enclosure.interface.ts @@ -80,17 +80,17 @@ export interface DashboardEnclosureSlot { descriptor: string; status: EnclosureStatus; dev: string | null; - supports_identify_light?: boolean; + supports_identify_light: boolean; drive_bay_light_status: DriveBayLightStatus | null; - size?: number | null; - model?: string | null; + size: number | null; + model: string | null; is_top: boolean; is_front: boolean; is_rear: boolean; is_internal: boolean; - serial?: string | null; - type?: DiskType | null; - rotationrate?: number | null; + serial: string | null; + type: DiskType | null; + rotationrate: number | null; pool_info: EnclosureSlotPoolInfo | null; } diff --git a/src/app/interfaces/reporting-exporters.interface.ts b/src/app/interfaces/reporting-exporters.interface.ts index 2d0acff06ef..504917c6684 100644 --- a/src/app/interfaces/reporting-exporters.interface.ts +++ b/src/app/interfaces/reporting-exporters.interface.ts @@ -17,10 +17,8 @@ export interface ReportingExporterList { export interface ReportingExporter { name: string; id: number; - type: string; enabled: boolean; attributes: Record; } -export type CreateReportingExporter = Omit; -export type UpdateReportingExporter = Omit; +export type UpdateReportingExporter = Partial>; diff --git a/src/app/interfaces/schema.interface.ts b/src/app/interfaces/schema.interface.ts index 842ffe17c46..19a4fca0274 100644 --- a/src/app/interfaces/schema.interface.ts +++ b/src/app/interfaces/schema.interface.ts @@ -12,7 +12,7 @@ export interface OldSchema { type: SchemaType | SchemaType[]; _name_: string; _required_: boolean; - + const?: string; } export interface SchemaProperties { diff --git a/src/app/interfaces/virtualization.interface.ts b/src/app/interfaces/virtualization.interface.ts index 7e69c4c04c8..99fd10a481c 100644 --- a/src/app/interfaces/virtualization.interface.ts +++ b/src/app/interfaces/virtualization.interface.ts @@ -8,6 +8,7 @@ import { VirtualizationNicType, VirtualizationProxyProtocol, VirtualizationRemote, + VirtualizationSource, VirtualizationStatus, VirtualizationType, } from 'app/enums/virtualization.enum'; @@ -49,11 +50,20 @@ export interface CreateVirtualizationInstance { image: string; remote: VirtualizationRemote; instance_type: VirtualizationType; + source_type?: VirtualizationSource; environment?: Record; autostart?: boolean; cpu: string; + /** + * Value must be greater or equal to 33554432 + */ memory: number; devices: VirtualizationDevice[]; + enable_vnc?: boolean; + /** + * Value must be greater or equal to 5900 and lesser or equal to 65535 + */ + vnc_port?: number | null; } export interface UpdateVirtualizationInstance { @@ -148,6 +158,7 @@ export interface VirtualizationImage { os: string; release: string; variant: string; + instance_types: VirtualizationType[]; } export interface VirtualizationStopParams { diff --git a/src/app/modules/auth/two-factor-guard.service.spec.ts b/src/app/modules/auth/two-factor-guard.service.spec.ts index 3ca8fe323f5..55f0c3fa6c5 100644 --- a/src/app/modules/auth/two-factor-guard.service.spec.ts +++ b/src/app/modules/auth/two-factor-guard.service.spec.ts @@ -11,8 +11,8 @@ describe('TwoFactorGuardService', () => { let spectator: SpectatorService; const isAuthenticated$ = new BehaviorSubject(false); - const userTwoFactorConfig$ = new BehaviorSubject(null as UserTwoFactorConfig); - const getGlobalTwoFactorConfig = jest.fn(() => of(null as GlobalTwoFactorConfig)); + const userTwoFactorConfig$ = new BehaviorSubject(null); + const getGlobalTwoFactorConfig = jest.fn(() => of(null as GlobalTwoFactorConfig | null)); const hasRole$ = new BehaviorSubject(false); const createService = createServiceFactory({ diff --git a/src/app/modules/empty/empty.service.ts b/src/app/modules/empty/empty.service.ts index c8f43339567..e3154eca5de 100644 --- a/src/app/modules/empty/empty.service.ts +++ b/src/app/modules/empty/empty.service.ts @@ -9,7 +9,7 @@ import { EmptyConfig } from 'app/interfaces/empty-config.interface'; export class EmptyService { constructor(private translate: TranslateService) { } - defaultEmptyConfig(type: EmptyType): EmptyConfig { + defaultEmptyConfig(type?: EmptyType | null): EmptyConfig { switch (type) { case EmptyType.Loading: return { diff --git a/src/app/modules/forms/ix-forms/components/ix-combobox/ix-combobox.component.ts b/src/app/modules/forms/ix-forms/components/ix-combobox/ix-combobox.component.ts index a1d01d34259..5fd868936ae 100644 --- a/src/app/modules/forms/ix-forms/components/ix-combobox/ix-combobox.component.ts +++ b/src/app/modules/forms/ix-forms/components/ix-combobox/ix-combobox.component.ts @@ -74,7 +74,7 @@ export class IxComboboxComponent implements ControlValueAccessor, OnInit { }); private readonly inputElementRef: Signal> = viewChild.required('ixInput', { read: ElementRef }); - private readonly autoCompleteRef = viewChild('auto', { read: MatAutocomplete }); + private readonly autoCompleteRef = viewChild.required('auto', { read: MatAutocomplete }); private readonly autocompleteTrigger = viewChild(MatAutocompleteTrigger); options: Option[] = []; diff --git a/src/app/modules/forms/ix-forms/components/ix-icon-group/icon-group-option.interface.ts b/src/app/modules/forms/ix-forms/components/ix-icon-group/icon-group-option.interface.ts index a2e2a393977..dc46b73c35c 100644 --- a/src/app/modules/forms/ix-forms/components/ix-icon-group/icon-group-option.interface.ts +++ b/src/app/modules/forms/ix-forms/components/ix-icon-group/icon-group-option.interface.ts @@ -4,4 +4,5 @@ export interface IconGroupOption { icon: MarkedIcon; label: string; value: string; + description?: string; } diff --git a/src/app/modules/forms/ix-forms/components/ix-icon-group/ix-icon-group.component.html b/src/app/modules/forms/ix-forms/components/ix-icon-group/ix-icon-group.component.html index 4f29383bc27..e11a0e2b7ce 100644 --- a/src/app/modules/forms/ix-forms/components/ix-icon-group/ix-icon-group.component.html +++ b/src/app/modules/forms/ix-forms/components/ix-icon-group/ix-icon-group.component.html @@ -6,23 +6,37 @@ > } -
+
@for (option of options(); track option) { - +
+ + + @if (showLabels()) { +
{{ option.label | translate }}
+ @if (option.description) { + {{ option.description | translate }} + } + } +
} @empty { {{ 'No options are passed' | translate }} } diff --git a/src/app/modules/forms/ix-forms/components/ix-icon-group/ix-icon-group.component.scss b/src/app/modules/forms/ix-forms/components/ix-icon-group/ix-icon-group.component.scss index 6b5d0bdd38c..05fb0a91e58 100644 --- a/src/app/modules/forms/ix-forms/components/ix-icon-group/ix-icon-group.component.scss +++ b/src/app/modules/forms/ix-forms/components/ix-icon-group/ix-icon-group.component.scss @@ -20,3 +20,51 @@ .selected { color: var(--primary); } + +.title, +.description { + display: block; + margin: 0; + text-align: center; +} + +.title { + font-size: 14px; + margin-bottom: 2px; + margin-top: 8px; +} + +.description { + color: var(--fg2); +} + +.with-labels { + gap: 16px; + + ::ng-deep .mdc-icon-button { + border: 2px solid var(--lines); + border-radius: 0; + height: 100px !important; + line-height: 100px; + width: 100px !important; + + .mdc-icon-button__ripple, + .mat-ripple { + border-radius: 0 !important; + height: 100px !important; + width: 100px !important; + } + + .ix-icon, + .ix-icon svg { + font-size: 40px; + height: 40px; + line-height: 1; + width: 40px; + } + + &.selected { + border-color: var(--primary); + } + } +} diff --git a/src/app/modules/forms/ix-forms/components/ix-icon-group/ix-icon-group.component.spec.ts b/src/app/modules/forms/ix-forms/components/ix-icon-group/ix-icon-group.component.spec.ts index bd02b7b5265..6320dd8658a 100644 --- a/src/app/modules/forms/ix-forms/components/ix-icon-group/ix-icon-group.component.spec.ts +++ b/src/app/modules/forms/ix-forms/components/ix-icon-group/ix-icon-group.component.spec.ts @@ -28,6 +28,7 @@ describe('IxIconGroupComponent', () => { [tooltip]="tooltip" [required]="required" [formControl]="formControl" + [showLabels]="true" >`, { hostProps: { @@ -85,6 +86,14 @@ describe('IxIconGroupComponent', () => { formControl.setValue('edit'); expect(await iconGroupHarness.getValue()).toBe('edit'); }); + it('shows labels when `showLabels` is set to true', async () => { + const icons = await iconGroupHarness.getIcons(); + expect(icons).toHaveLength(2); + + const labels = spectator.queryAll('h5.title').map((el) => el.textContent); + expect(labels[0]).toBe('Edit'); + expect(labels[1]).toBe('Delete'); + }); }); it('updates form control value when user presses the button', async () => { diff --git a/src/app/modules/forms/ix-forms/components/ix-icon-group/ix-icon-group.component.ts b/src/app/modules/forms/ix-forms/components/ix-icon-group/ix-icon-group.component.ts index 0a342a0dece..b2cee9d93ca 100644 --- a/src/app/modules/forms/ix-forms/components/ix-icon-group/ix-icon-group.component.ts +++ b/src/app/modules/forms/ix-forms/components/ix-icon-group/ix-icon-group.component.ts @@ -20,13 +20,13 @@ import { TestDirective } from 'app/modules/test-id/test.directive'; changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, imports: [ - IxLabelComponent, - MatIconButton, - IxIconComponent, IxErrorsComponent, + IxIconComponent, + IxLabelComponent, ReactiveFormsModule, - TranslateModule, TestDirective, + TranslateModule, + MatIconButton, ], hostDirectives: [ { ...registeredDirectiveConfig }, @@ -37,6 +37,7 @@ export class IxIconGroupComponent implements ControlValueAccessor { readonly label = input(); readonly tooltip = input(); readonly required = input(false); + readonly showLabels = input(false); protected isDisabled = false; protected value: IconGroupOption['value']; diff --git a/src/app/modules/forms/ix-forms/components/ix-input/ix-input.component.ts b/src/app/modules/forms/ix-forms/components/ix-input/ix-input.component.ts index 7cb8525d857..1c9adee1e70 100644 --- a/src/app/modules/forms/ix-forms/components/ix-input/ix-input.component.ts +++ b/src/app/modules/forms/ix-forms/components/ix-input/ix-input.component.ts @@ -75,7 +75,7 @@ export class IxInputComponent implements ControlValueAccessor, OnInit, OnChanges /** If formatted value returned by parseAndFormatInput has non-numeric letters * and input 'type' is a number, the input will stay empty on the form */ readonly format = input<(value: string | number) => string>(); - readonly parse = input<(value: string | number) => string | number>(); + readonly parse = input<(value: string | number) => string | number | null>(); readonly inputElementRef: Signal> = viewChild.required('ixInput', { read: ElementRef }); diff --git a/src/app/modules/forms/ix-forms/services/form-error-handler.service.ts b/src/app/modules/forms/ix-forms/services/form-error-handler.service.ts index ddde9f6ce8f..cd593d49a5e 100644 --- a/src/app/modules/forms/ix-forms/services/form-error-handler.service.ts +++ b/src/app/modules/forms/ix-forms/services/form-error-handler.service.ts @@ -72,6 +72,10 @@ export class FormErrorHandlerService { const extra = (error as ApiError).extra as string[][]; for (const extraItem of extra) { const field = extraItem[0].split('.').pop(); + if (!field) { + return; + } + const errorMessage = extraItem[1]; const control = this.getFormField(formGroup, field, fieldsMap); diff --git a/src/app/modules/forms/ix-forms/services/ix-form.service.spec.ts b/src/app/modules/forms/ix-forms/services/ix-form.service.spec.ts index f1cf85bdf7e..bd78c0e4e4d 100644 --- a/src/app/modules/forms/ix-forms/services/ix-form.service.spec.ts +++ b/src/app/modules/forms/ix-forms/services/ix-form.service.spec.ts @@ -1,78 +1,90 @@ -import { NgControl } from '@angular/forms'; +import { ElementRef } from '@angular/core'; +import { FormControl, NgControl } from '@angular/forms'; import { SpectatorService, createServiceFactory } from '@ngneat/spectator/jest'; +import { TestScheduler } from 'rxjs/testing'; +import { getTestScheduler } from 'app/core/testing/utils/get-test-scheduler.utils'; +import { IxFormSectionComponent } from 'app/modules/forms/ix-forms/components/ix-form-section/ix-form-section.component'; +import { ixControlLabelTag } from 'app/modules/forms/ix-forms/directives/registered-control.directive'; import { IxFormService } from 'app/modules/forms/ix-forms/services/ix-form.service'; -// TODO: https://ixsystems.atlassian.net/browse/NAS-133118 -describe.skip('IxFormService', () => { +class MockNgControl extends NgControl { + override control = new FormControl('mock-value'); + + override viewToModelUpdate(newValue: string): void { + this.control.setValue(newValue); + } +} + +describe('IxFormService', () => { let spectator: SpectatorService; + let testScheduler: TestScheduler; const createService = createServiceFactory({ service: IxFormService, }); - const fakeComponents = [ - { - control: { - name: 'test_control_1', - }, - element: { - nativeElement: { - id: 'test_element_1', - }, - getAttribute: () => 'Test Element 1', - }, - }, - { - control: { - name: 'test_control_2', - }, - element: { - nativeElement: { - id: 'test_element_2', - }, - getAttribute: () => 'Test Element 2', - }, - }, - ] as { - control: NgControl; - element: { nativeElement: HTMLElement; getAttribute: () => string }; - }[]; - beforeEach(() => { spectator = createService(); - fakeComponents.forEach((component) => { - spectator.service.registerControl(component.control.name!.toString(), component.element); - }); + testScheduler = getTestScheduler(); }); - describe('getControlsNames', () => { - it('returns a list of control names', () => { - expect(spectator.service.getControlNames()).toEqual([ - 'test_control_1', - 'test_control_2', - ]); + describe('handles control register/unregister', () => { + it('registers control', () => { + const elRef = new ElementRef(document.createElement('input')); + elRef.nativeElement.setAttribute('id', 'control1'); + elRef.nativeElement.setAttribute(ixControlLabelTag, 'Control1'); + spectator.service.registerControl( + 'control1', + elRef, + ); + + expect(spectator.service.getControlNames()).toEqual(['control1']); + testScheduler.run(({ expectObservable }) => { + expectObservable(spectator.service.controlNamesWithLabels$).toBe('a', { + a: [{ label: 'Control1', name: 'control1' }], + }); + }); + expect(spectator.service.getElementByControlName('control1')).toEqual(elRef.nativeElement); + expect(spectator.service.getElementByLabel('Control1')).toEqual(elRef.nativeElement); }); - }); - describe('getControls', () => { - it('returns a list of controls', () => { - expect(spectator.service.getControlNames()).toEqual([ - 'test_control_1', - 'test_control_2', - ]); + it('unregisters control', () => { + const elRef = new ElementRef(document.createElement('input')); + elRef.nativeElement.setAttribute('id', 'control1'); + elRef.nativeElement.setAttribute(ixControlLabelTag, 'Control1'); + spectator.service.registerControl( + 'control1', + elRef, + ); + + expect(spectator.service.getControlNames()).toEqual(['control1']); + spectator.service.unregisterControl('control1'); + expect(spectator.service.getControlNames()).toEqual([]); }); }); - describe('getControlByName', () => { - it('returns control by name', () => { - expect(spectator.service.getControlNames()).toEqual(['test_control_2']); + it('registers section control', () => { + const ngControl = new MockNgControl(); + const formSection = { + label(): string { return 'Form Section'; }, + } as IxFormSectionComponent; + spectator.service.registerSectionControl( + ngControl, + formSection, + ); + + testScheduler.run(({ expectObservable }) => { + expectObservable(spectator.service.controlSections$).toBe('a', { + a: [ + { section: formSection, controls: [ngControl] }, + ], + }); }); - }); - describe('getElementByControlName', () => { - it('returns element by control name', () => { - expect(spectator.service.getElementByControlName('test_control_2')).toEqual({ - id: 'test_element_2', + spectator.service.unregisterSectionControl(formSection, ngControl); + testScheduler.run(({ expectObservable }) => { + expectObservable(spectator.service.controlSections$).toBe('a', { + a: [], }); }); }); diff --git a/src/app/modules/forms/ix-forms/services/ix-form.service.ts b/src/app/modules/forms/ix-forms/services/ix-form.service.ts index 7ad6aa8a009..a2978b3270e 100644 --- a/src/app/modules/forms/ix-forms/services/ix-form.service.ts +++ b/src/app/modules/forms/ix-forms/services/ix-form.service.ts @@ -7,13 +7,13 @@ import { ixControlLabelTag } from 'app/modules/forms/ix-forms/directives/registe @Injectable({ providedIn: 'root' }) export class IxFormService { - private controls = new Map(); - private sections = new Map(); + private readonly controls = new Map(); + private readonly sections = new Map(); private readonly controlNamesWithlabels = new BehaviorSubject([]); private readonly controlSections = new BehaviorSubject([]); - controlNamesWithLabels$: Observable = this.controlNamesWithlabels.asObservable(); - controlSections$: Observable = this.controlSections.asObservable(); + readonly controlNamesWithLabels$: Observable = this.controlNamesWithlabels.asObservable(); + readonly controlSections$: Observable = this.controlSections.asObservable(); getControlNames(): (string | number | null)[] { return [...this.controls.keys()]; diff --git a/src/app/modules/forms/ix-forms/validators/password-validation/password-validation.ts b/src/app/modules/forms/ix-forms/validators/password-validation/password-validation.ts index 193a561f8f4..f9ad068c150 100644 --- a/src/app/modules/forms/ix-forms/validators/password-validation/password-validation.ts +++ b/src/app/modules/forms/ix-forms/validators/password-validation/password-validation.ts @@ -25,19 +25,19 @@ export function matchOthersFgValidator( } } if (errFields.length) { - fg.get(controlName).setErrors({ + subjectControl.setErrors({ matchOther: errMsg ? { message: errMsg } : true, }); return { [controlName]: { matchOther: errMsg ? { message: errMsg } : true }, }; } - let prevErrors = { ...fg.get(controlName).errors }; + let prevErrors = { ...subjectControl.errors }; delete prevErrors.matchOther; if (isEmpty(prevErrors)) { prevErrors = null; } - fg.get(controlName).setErrors(prevErrors); + subjectControl.setErrors(prevErrors); return null; }; } @@ -66,19 +66,19 @@ export function doesNotEqualFgValidator( } } if (errFields.length) { - fg.get(controlName).setErrors({ + subjectControl.setErrors({ matchesOther: errMsg ? { message: errMsg } : true, }); return { [controlName]: { matchesOther: errMsg ? { message: errMsg } : true }, }; } - let prevErrors = { ...fg.get(controlName).errors }; + let prevErrors = { ...subjectControl.errors }; delete prevErrors.matchesOther; if (isEmpty(prevErrors)) { prevErrors = null; } - fg.get(controlName).setErrors(prevErrors); + subjectControl.setErrors(prevErrors); return null; }; } diff --git a/src/app/modules/jobs/store/job.reducer.ts b/src/app/modules/jobs/store/job.reducer.ts index 01fba0807e5..6b3fd7714dc 100644 --- a/src/app/modules/jobs/store/job.reducer.ts +++ b/src/app/modules/jobs/store/job.reducer.ts @@ -11,7 +11,7 @@ import { jobIndicatorPressed } from 'app/store/topbar/topbar.actions'; export interface JobsState extends EntityState { isLoading: boolean; isPanelOpen: boolean; - error: string; + error: string | null; } export const adapter = createEntityAdapter({ @@ -19,7 +19,7 @@ export const adapter = createEntityAdapter({ sortComparer: (a, b) => b.time_started.$date - a.time_started.$date, }); -export const jobsInitialState = adapter.getInitialState({ +export const jobsInitialState: JobsState = adapter.getInitialState({ isLoading: false, isPanelOpen: false, error: null, diff --git a/src/app/modules/layout/admin-layout/admin-layout.component.html b/src/app/modules/layout/admin-layout/admin-layout.component.html index 406661ac52d..c8b80e309f6 100644 --- a/src/app/modules/layout/admin-layout/admin-layout.component.html +++ b/src/app/modules/layout/admin-layout/admin-layout.component.html @@ -102,5 +102,4 @@ - diff --git a/src/app/modules/layout/admin-layout/admin-layout.component.ts b/src/app/modules/layout/admin-layout/admin-layout.component.ts index 3f3148237af..ae91b101d34 100644 --- a/src/app/modules/layout/admin-layout/admin-layout.component.ts +++ b/src/app/modules/layout/admin-layout/admin-layout.component.ts @@ -34,7 +34,6 @@ import { SidenavService } from 'app/modules/layout/sidenav.service'; import { TopbarComponent } from 'app/modules/layout/topbar/topbar.component'; import { DefaultPageHeaderComponent } from 'app/modules/page-header/default-page-header/default-page-header.component'; import { SlideInControllerComponent } from 'app/modules/slide-ins/components/slide-in-controller/slide-in-controller.component'; -import { OldSlideInComponent } from 'app/modules/slide-ins/old-slide-in.component'; import { TestDirective } from 'app/modules/test-id/test.directive'; import { ThemeService } from 'app/modules/theme/theme.service'; import { SentryService } from 'app/services/sentry.service'; @@ -66,7 +65,6 @@ import { selectCopyrightText, selectIsEnterprise, waitForSystemInfo } from 'app/ ConsoleFooterComponent, AlertsPanelComponent, SlideInControllerComponent, - OldSlideInComponent, AsyncPipe, TranslateModule, TestDirective, diff --git a/src/app/modules/slide-ins/components/modal-header/modal-header.component.html b/src/app/modules/slide-ins/components/modal-header/modal-header.component.html index c589048be3d..274c46e8a6d 100644 --- a/src/app/modules/slide-ins/components/modal-header/modal-header.component.html +++ b/src/app/modules/slide-ins/components/modal-header/modal-header.component.html @@ -16,7 +16,7 @@ }

{{ title() | translate }} - @if (requiredRoles()?.length && !(hasRequiredRoles | async)) { + @if (requiredRoles().length && !(hasRequiredRoles | async)) { }

diff --git a/src/app/modules/slide-ins/components/modal-header/modal-header.component.scss b/src/app/modules/slide-ins/components/modal-header/modal-header.component.scss index 96e87082e25..054402ace1a 100644 --- a/src/app/modules/slide-ins/components/modal-header/modal-header.component.scss +++ b/src/app/modules/slide-ins/components/modal-header/modal-header.component.scss @@ -25,18 +25,6 @@ .ix-form-title { color: var(--fg1); word-break: break-all; - - // TODO: Remove this when we finish with Slide In Migration - &::after { - background-color: var(--primary); - border-radius: 50%; - content: ''; - display: inline-block; - height: 6px; - margin-left: 6px; - vertical-align: middle; - width: 6px; - } } #ix-close-icon { diff --git a/src/app/modules/slide-ins/components/old-modal-header/old-modal-header.component.html b/src/app/modules/slide-ins/components/old-modal-header/old-modal-header.component.html deleted file mode 100644 index 788c3fdbf79..00000000000 --- a/src/app/modules/slide-ins/components/old-modal-header/old-modal-header.component.html +++ /dev/null @@ -1,24 +0,0 @@ -
-

- {{ title() | translate }} - @if (requiredRoles()?.length && !(hasRequiredRoles() | async)) { - - } -

- - @if (!disableClose()) { - - } -
- -@if (loading()) { - -} diff --git a/src/app/modules/slide-ins/components/old-modal-header/old-modal-header.component.scss b/src/app/modules/slide-ins/components/old-modal-header/old-modal-header.component.scss deleted file mode 100644 index 036b7b239bd..00000000000 --- a/src/app/modules/slide-ins/components/old-modal-header/old-modal-header.component.scss +++ /dev/null @@ -1,41 +0,0 @@ -:host { - display: block; - position: sticky; - top: 0; - width: 100%; - z-index: 20000; -} - -.ix-slidein-title-bar { - align-items: center; - background-color: var(--bg2); - border-bottom: 2px solid var(--lines); - display: flex; - height: 75px; - margin: 0 -20px; - padding: 0 30px; - place-content: center space-between; -} - -.ix-formtitle { - color: var(--fg1); - word-break: break-all; -} - -#ix-close-icon { - color: var(--fg1); - cursor: pointer; - - &:focus { - border-radius: 50%; - outline: 1.5px solid var(--primary); - } -} - -mat-progress-bar { - bottom: 0; - left: -20px; - position: absolute; - width: calc(100% + 40px); - z-index: 10005; -} diff --git a/src/app/modules/slide-ins/components/old-modal-header/old-modal-header.component.ts b/src/app/modules/slide-ins/components/old-modal-header/old-modal-header.component.ts deleted file mode 100644 index bebef78edee..00000000000 --- a/src/app/modules/slide-ins/components/old-modal-header/old-modal-header.component.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { AsyncPipe } from '@angular/common'; -import { - ChangeDetectionStrategy, Component, computed, input, -} from '@angular/core'; -import { MatProgressBar } from '@angular/material/progress-bar'; -import { TranslateModule } from '@ngx-translate/core'; -import { Role } from 'app/enums/role.enum'; -import { AuthService } from 'app/modules/auth/auth.service'; -import { ReadOnlyComponent } from 'app/modules/forms/ix-forms/components/readonly-badge/readonly-badge.component'; -import { IxIconComponent } from 'app/modules/ix-icon/ix-icon.component'; -import { OldSlideInRef } from 'app/modules/slide-ins/old-slide-in-ref'; - -/** - * @deprecated Use SlideIn and ix-modal-header. - */ -@Component({ - selector: 'ix-old-modal-header', - templateUrl: './old-modal-header.component.html', - styleUrls: ['./old-modal-header.component.scss'], - changeDetection: ChangeDetectionStrategy.OnPush, - standalone: true, - imports: [ - ReadOnlyComponent, - IxIconComponent, - MatProgressBar, - AsyncPipe, - TranslateModule, - ], -}) -export class OldModalHeaderComponent { - readonly title = input(); - readonly loading = input(); - readonly disableClose = input(false); - readonly requiredRoles = input([]); - - readonly hasRequiredRoles = computed(() => this.authService.hasRole(this.requiredRoles())); - - constructor( - private slideInRef: OldSlideInRef, - private authService: AuthService, - ) {} - - close(): void { - this.slideInRef.close(); - } -} diff --git a/src/app/modules/slide-ins/components/slide-in/slide-in.component.spec.ts b/src/app/modules/slide-ins/components/slide-in/slide-in.component.spec.ts index a2a759dfb56..2168c61efd5 100644 --- a/src/app/modules/slide-ins/components/slide-in/slide-in.component.spec.ts +++ b/src/app/modules/slide-ins/components/slide-in/slide-in.component.spec.ts @@ -215,7 +215,7 @@ describe('SlideInComponent', () => { message: 'You have unsaved changes. Are you sure you want to close?', cancelText: 'No', buttonText: 'Yes', - buttonColor: 'red', + buttonColor: 'warn', hideCheckbox: true, }); discardPeriodicTasks(); @@ -236,14 +236,7 @@ describe('SlideInComponent', () => { const backdrop = spectator.query('.ix-slide-in2-background')!; backdrop.dispatchEvent(new Event('click')); - expect(spectator.inject(DialogService).confirm).not.toHaveBeenCalledWith({ - title: 'Unsaved Changes', - message: 'You have unsaved changes. Are you sure you want to close?', - cancelText: 'No', - buttonText: 'Yes', - buttonColor: 'red', - hideCheckbox: true, - }); + expect(spectator.inject(DialogService).confirm).not.toHaveBeenCalled(); discardPeriodicTasks(); })); }); diff --git a/src/app/modules/slide-ins/components/slide-in/slide-in.component.ts b/src/app/modules/slide-ins/components/slide-in/slide-in.component.ts index e38e0478f57..b4c33b041ac 100644 --- a/src/app/modules/slide-ins/components/slide-in/slide-in.component.ts +++ b/src/app/modules/slide-ins/components/slide-in/slide-in.component.ts @@ -218,7 +218,7 @@ export class SlideInComponent implements OnInit, OnDestroy { message: this.translate.instant('You have unsaved changes. Are you sure you want to close?'), cancelText: this.translate.instant('No'), buttonText: this.translate.instant('Yes'), - buttonColor: 'red', + buttonColor: 'warn', hideCheckbox: true, }); } diff --git a/src/app/modules/slide-ins/old-slide-in-ref.ts b/src/app/modules/slide-ins/old-slide-in-ref.ts deleted file mode 100644 index 8c9d0131249..00000000000 --- a/src/app/modules/slide-ins/old-slide-in-ref.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { ComponentRef } from '@angular/core'; -import { Subject } from 'rxjs'; - -/** - * @deprecated Use `SlideIn` and `SlideInRef` instead. - */ -export class OldSlideInRef { - readonly slideInClosed$ = new Subject(); - componentRef: ComponentRef; - id: string; - - get componentInstance(): T { - return this.componentRef.instance; - } - - close(response?: D): void { - this.slideInClosed$.next(response); - this.slideInClosed$.complete(); - } -} diff --git a/src/app/modules/slide-ins/old-slide-in.component.html b/src/app/modules/slide-ins/old-slide-in.component.html deleted file mode 100644 index 8b3993a4af4..00000000000 --- a/src/app/modules/slide-ins/old-slide-in.component.html +++ /dev/null @@ -1,17 +0,0 @@ -
-
- -
-
- -
diff --git a/src/app/modules/slide-ins/old-slide-in.component.scss b/src/app/modules/slide-ins/old-slide-in.component.scss deleted file mode 100644 index 672b3c8c8e7..00000000000 --- a/src/app/modules/slide-ins/old-slide-in.component.scss +++ /dev/null @@ -1,79 +0,0 @@ -@import 'scss-imports/cssvars'; - -/* MODAL STYLES --------------------------------*/ - -.ix-slide-in-form { - bottom: 0; - min-width: 480px; - - /* enables scrolling for tall modals */ - overflow: auto; - position: fixed; - right: -480px; - top: 48px; - transition: 200ms; - width: 480px; - - /* z-index must be higher than .ix-slide-in-background */ - /* ...but less than 1000 otherwise you cover select */ - z-index: 999; -} - -.ix-slide-in-form.open { - overflow-x: hidden; - overflow-y: auto; - right: 0; - transition: 200ms; -} - -.ix-slide-in-form.open.wide { - width: 800px; - - @media(max-width: $breakpoint-sm) { - width: 100%; - } -} - -.ix-slide-in-body { - background: #fff; - - /* margin exposes part of the modal background */ - margin: 40px; - padding: 0 20px 20px; -} - -.ix-slide-in-form .ix-slide-in-body { - background-color: var(--bg2); - margin: 0; - min-height: 100%; -} - -.ix-slide-in-background { - /* semi-transparent black */ - background-color: #000; - bottom: 0; - left: 0; - opacity: 0.75; - /* modal background fixed across whole screen */ - position: fixed; - right: 0; - top: 48px; - transition: 100ms; - - /* z-index must be below .ix-slide-in and above everything else */ - z-index: -10; -} - -.ix-slide-in-background.open { - transition: 200ms; - z-index: 900; -} - -@media #{$media-lt-sm} { - .ix-slide-in-form { - min-width: 100%; - right: -100%; - width: 100%; - } -} diff --git a/src/app/modules/slide-ins/old-slide-in.component.spec.ts b/src/app/modules/slide-ins/old-slide-in.component.spec.ts deleted file mode 100644 index 60fa3d13eb5..00000000000 --- a/src/app/modules/slide-ins/old-slide-in.component.spec.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { A11yModule } from '@angular/cdk/a11y'; -import { - ChangeDetectionStrategy, - Component, ElementRef, Inject, -} from '@angular/core'; -import { fakeAsync } from '@angular/core/testing'; -import { createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest'; -import { OldSlideInComponent } from 'app/modules/slide-ins/old-slide-in.component'; -import { SLIDE_IN_DATA } from 'app/modules/slide-ins/slide-in.token'; - -/** Simple component for testing IxSlideInComponent */ -@Component({ - selector: 'ix-test', - template: '

{{text}}

', - standalone: true, - changeDetection: ChangeDetectionStrategy.OnPush, -}) -class TestClassComponent { - text: string; - constructor( - @Inject(SLIDE_IN_DATA) value: string, - ) { - this.text = value; - } -} - -describe('SlideInComponent', () => { - let spectator: Spectator; - - const createComponent = createComponentFactory({ - component: OldSlideInComponent, - imports: [ - A11yModule, - TestClassComponent, - ], - providers: [ - mockProvider(ElementRef, { - nativeElement: {} as HTMLElement, - }), - - ], - }); - - beforeEach(() => { - spectator = createComponent(); - spectator.inject(ElementRef); - }); - - it('call \'openSlideIn\' should create a host element in the body of the slide', () => { - spectator.component.openSlideIn(TestClassComponent, { wide: true, data: 'Component created dynamically' }); - const dynamicElement = (spectator.debugElement.nativeElement as Element).querySelector('h1'); - spectator.fixture.detectChanges(); - - expect(dynamicElement).toHaveText('Component created dynamically'); - }); - - it('call \'closeSlideIn\' should remove a created host element after 200ms', fakeAsync(() => { - spectator.component.openSlideIn( - TestClassComponent, - { wide: true, data: 'Component created dynamically' }, - ); - spectator.component.closeSlideIn(); - - spectator.tick(200); - const dynamicElement = (spectator.debugElement.nativeElement as Element).querySelector('.ix-slide-in-body'); - expect(dynamicElement).toBeEmpty(); - })); -}); diff --git a/src/app/modules/slide-ins/old-slide-in.component.ts b/src/app/modules/slide-ins/old-slide-in.component.ts deleted file mode 100644 index 32418a77f1b..00000000000 --- a/src/app/modules/slide-ins/old-slide-in.component.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { CdkTrapFocus } from '@angular/cdk/a11y'; -import { - ChangeDetectionStrategy, ChangeDetectorRef, - Component, - ElementRef, - HostListener, - Injector, - OnDestroy, - OnInit, - Renderer2, - Type, - ViewContainerRef, - viewChild, - input, -} from '@angular/core'; -import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; -import { UUID } from 'angular2-uuid'; -import { Subscription, timer } from 'rxjs'; -import { OldSlideInRef } from 'app/modules/slide-ins/old-slide-in-ref'; -import { SLIDE_IN_DATA } from 'app/modules/slide-ins/slide-in.token'; -import { OldSlideInService } from 'app/services/old-slide-in.service'; - -@UntilDestroy() -@Component({ - selector: 'ix-old-slide-in', - templateUrl: './old-slide-in.component.html', - styleUrls: ['./old-slide-in.component.scss'], - changeDetection: ChangeDetectionStrategy.OnPush, - standalone: true, - imports: [CdkTrapFocus], -}) -export class OldSlideInComponent implements OnInit, OnDestroy { - readonly id = input(); - - private readonly slideInBody = viewChild.required('body', { read: ViewContainerRef }); - - @HostListener('document:keydown.escape') onKeydownHandler(): void { - this.onBackdropClicked(); - } - - isSlideInOpen = false; - wide = false; - private element: HTMLElement; - private wasBodyCleared = false; - private timeOutOfClear: Subscription; - - constructor( - private el: ElementRef, - private slideInService: OldSlideInService, - private renderer: Renderer2, - private cdr: ChangeDetectorRef, - private defaultInjector: Injector, - ) { - this.element = this.el.nativeElement as HTMLElement; - } - - ngOnInit(): void { - // ensure id attribute exists - if (!this.id()) { - return; - } - - // move element to bottom of page (just before ) so it can be displayed above everything else - document.body.appendChild(this.element); - this.slideInService.setSlideComponent(this); - } - - onBackdropClicked(): void { - if (!this.element || !this.isSlideInOpen) { - return; - } - this.slideInService.closeLast(); - } - - closeSlideIn(): void { - this.isSlideInOpen = false; - this.renderer.removeStyle(document.body, 'overflow'); - this.wasBodyCleared = true; - this.cdr.markForCheck(); - this.timeOutOfClear = timer(200).pipe(untilDestroyed(this)).subscribe(() => { - // Destroying child component later improves performance a little bit. - // 200ms matches transition duration - this.slideInBody().clear(); - this.wasBodyCleared = false; - this.cdr.markForCheck(); - }); - } - - openSlideIn( - componentType: Type, - params?: { wide?: boolean; data?: D; injector?: Injector }, - ): OldSlideInRef { - if (this.isSlideInOpen) { - console.error('SlideIn is already open'); - } - - this.isSlideInOpen = true; - this.renderer.setStyle(document.body, 'overflow', 'hidden'); - this.wide = !!params?.wide; - - if (this.wasBodyCleared) { - this.timeOutOfClear.unsubscribe(); - } - this.slideInBody().clear(); - this.wasBodyCleared = false; - // clear body and close all slides - - this.cdr.markForCheck(); - - return this.createSlideInRef( - componentType, - params?.data, - params?.injector || this.defaultInjector, - ); - } - - private createSlideInRef( - componentType: Type, - data?: D, - parentInjector?: Injector, - ): OldSlideInRef { - const slideInRef = new OldSlideInRef(); - const injector = Injector.create({ - providers: [ - { provide: SLIDE_IN_DATA, useValue: data }, - { provide: OldSlideInRef, useValue: slideInRef }, - ], - parent: parentInjector, - }); - slideInRef.componentRef = this.slideInBody().createComponent(componentType, { injector }); - slideInRef.id = UUID.UUID(); - - return slideInRef; - } - - ngOnDestroy(): void { - this.element.remove(); - this.slideInService.closeAll(); - } -} diff --git a/src/app/modules/slide-ins/slide-in.token.ts b/src/app/modules/slide-ins/slide-in.token.ts deleted file mode 100644 index 0fc9ba02877..00000000000 --- a/src/app/modules/slide-ins/slide-in.token.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { InjectionToken } from '@angular/core'; - -// eslint-disable-next-line @typescript-eslint/naming-convention -export const SLIDE_IN_DATA = new InjectionToken('SLIDE_IN_DATA'); diff --git a/src/app/modules/slide-ins/slide-in.ts b/src/app/modules/slide-ins/slide-in.ts index fcbf7e7bb22..9391fd8495f 100644 --- a/src/app/modules/slide-ins/slide-in.ts +++ b/src/app/modules/slide-ins/slide-in.ts @@ -73,7 +73,7 @@ export class SlideIn extends ComponentStore { return close$.asObservable().pipe(tap(() => this.focusService.restoreFocus())); } - popComponent = this.updater((state, id: string) => { + popComponent = this.updater((state, id: string | undefined) => { const newMap = new Map(state.components); newMap.set(id, { ...newMap.get(id), isComponentAlive: false }); this.focusOnTheCloseButton(); diff --git a/src/app/pages/apps/components/app-delete-dialog/app-delete-dialog.component.html b/src/app/pages/apps/components/app-delete-dialog/app-delete-dialog.component.html index 738d3f52f18..5d90b1fc015 100644 --- a/src/app/pages/apps/components/app-delete-dialog/app-delete-dialog.component.html +++ b/src/app/pages/apps/components/app-delete-dialog/app-delete-dialog.component.html @@ -33,7 +33,7 @@

} - @if (isReady && !report()?.errorConf) { + @if (isReady && !report().errorConf) {
diff --git a/src/app/pages/reports-dashboard/components/report/report.component.ts b/src/app/pages/reports-dashboard/components/report/report.component.ts index 333e50a8cd9..92bb212e412 100644 --- a/src/app/pages/reports-dashboard/components/report/report.component.ts +++ b/src/app/pages/reports-dashboard/components/report/report.component.ts @@ -90,7 +90,7 @@ export class ReportComponent implements OnInit, OnChanges { private readonly lineChart = viewChild(LineChartComponent); - updateReport$ = new BehaviorSubject>(null); + updateReport$ = new BehaviorSubject | null>(null); fetchReport$ = new BehaviorSubject(null); autoRefreshTimer: Subscription; autoRefreshEnabled: boolean; @@ -130,7 +130,8 @@ export class ReportComponent implements OnInit, OnChanges { get reportTitle(): string { const trimmed = this.report().title.replace(/[()]/g, ''); - return this.identifier() ? trimmed.replace(/{identifier}/, this.identifier()) : this.report().title; + const identifier = this.identifier(); + return identifier ? trimmed.replace(/{identifier}/, identifier) : this.report().title; } get currentZoomLevel(): ReportZoomLevel { diff --git a/src/app/pages/reports-dashboard/components/reports-global-controls/reports-global-controls.component.ts b/src/app/pages/reports-dashboard/components/reports-global-controls/reports-global-controls.component.ts index d6c1a9e7221..3d025f7d5c3 100644 --- a/src/app/pages/reports-dashboard/components/reports-global-controls/reports-global-controls.component.ts +++ b/src/app/pages/reports-dashboard/components/reports-global-controls/reports-global-controls.component.ts @@ -4,7 +4,7 @@ import { ChangeDetectorRef, Component, OnInit, output, } from '@angular/core'; -import { FormBuilder, ReactiveFormsModule } from '@angular/forms'; +import { NonNullableFormBuilder, ReactiveFormsModule } from '@angular/forms'; import { MatButton } from '@angular/material/button'; import { MatMenuTrigger, MatMenu, MatMenuItem } from '@angular/material/menu'; import { ActivatedRoute, RouterLink } from '@angular/router'; @@ -56,7 +56,7 @@ export class ReportsGlobalControlsComponent implements OnInit { metrics: [[] as string[]], }); - protected activeTab: ReportTab; + protected activeTab: ReportTab | undefined; protected allTabs: ReportTab[]; protected diskDevices$ = this.reportsService.getDiskDevices(); protected diskMetrics$ = this.reportsService.getDiskMetrics(); @@ -65,7 +65,7 @@ export class ReportsGlobalControlsComponent implements OnInit { protected readonly searchableElements = reportingGlobalControlsElements; constructor( - private fb: FormBuilder, + private fb: NonNullableFormBuilder, private route: ActivatedRoute, private store$: Store, private reportsService: ReportsService, diff --git a/src/app/pages/services/components/service-ssh/service-ssh.component.ts b/src/app/pages/services/components/service-ssh/service-ssh.component.ts index 56d9ff21211..f62990f8c4d 100644 --- a/src/app/pages/services/components/service-ssh/service-ssh.component.ts +++ b/src/app/pages/services/components/service-ssh/service-ssh.component.ts @@ -1,7 +1,7 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit, } from '@angular/core'; -import { FormBuilder, ReactiveFormsModule } from '@angular/forms'; +import { NonNullableFormBuilder, ReactiveFormsModule } from '@angular/forms'; import { MatButton } from '@angular/material/button'; import { MatCard, MatCardContent } from '@angular/material/card'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; @@ -69,15 +69,15 @@ export class ServiceSshComponent implements OnInit { }; form = this.fb.group({ - tcpport: [null as number], - password_login_groups: [null as string[]], + tcpport: [null as number | null], + password_login_groups: [null as string[] | null], passwordauth: [false], kerberosauth: [false], tcpfwd: [false], bindiface: [[] as string[]], compression: [false], - sftp_log_level: [null as SshSftpLogLevel], - sftp_log_facility: [null as SshSftpLogFacility], + sftp_log_level: [null as SshSftpLogLevel | null], + sftp_log_facility: [null as SshSftpLogFacility | null], weak_ciphers: [[] as SshWeakCipher[]], options: [''], }); @@ -106,7 +106,7 @@ export class ServiceSshComponent implements OnInit { private errorHandler: ErrorHandlerService, private cdr: ChangeDetectorRef, private formErrorHandler: FormErrorHandlerService, - private fb: FormBuilder, + private fb: NonNullableFormBuilder, private dialogService: DialogService, private userService: UserService, private translate: TranslateService, 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 c0f664028e8..41d78c56c69 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 @@ -1,7 +1,7 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit, } from '@angular/core'; -import { FormBuilder, Validators, ReactiveFormsModule } from '@angular/forms'; +import { Validators, ReactiveFormsModule, NonNullableFormBuilder } from '@angular/forms'; import { MatButton } from '@angular/material/button'; import { MatCard, MatCardContent } from '@angular/material/card'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; @@ -63,25 +63,25 @@ export class ServiceUpsComponent implements OnInit { isMasterMode = true; form = this.fb.group({ - identifier: [null as string, [Validators.required, Validators.pattern(/^[\w|,|.|\-|_]+$/)]], - mode: [null as UpsMode], - remotehost: [null as string, Validators.required], - remoteport: [null as number, Validators.required], - driver: [null as string, Validators.required], - port: [null as string, Validators.required], - monuser: [null as string, Validators.required], - monpwd: [null as string, Validators.pattern(/^((?![#|\s]).)*$/)], - extrausers: [null as string], + identifier: [null as string | null, [Validators.required, Validators.pattern(/^[\w|,|.|\-|_]+$/)]], + mode: [null as UpsMode | null], + remotehost: [null as string | null, Validators.required], + remoteport: [null as number | null, Validators.required], + driver: [null as string | null, Validators.required], + port: [null as string | null, Validators.required], + monuser: [null as string | null, Validators.required], + monpwd: [null as string | null, Validators.pattern(/^((?![#|\s]).)*$/)], + extrausers: [null as string | null], rmonitor: [false], - shutdown: [null as string], - shutdowntimer: [null as number], - shutdowncmd: [null as string], + shutdown: [null as string | null], + shutdowntimer: [null as number | null], + shutdowncmd: [null as string | null], powerdown: [false], nocommwarntime: [300], hostsync: [15], - description: [null as string], - options: [null as string], - optionsupsd: [null as string], + description: [null as string | null], + options: [null as string | null], + optionsupsd: [null as string | null], }); readonly helptext = helptextServiceUps; @@ -150,7 +150,7 @@ export class ServiceUpsComponent implements OnInit { private formErrorHandler: FormErrorHandlerService, private cdr: ChangeDetectorRef, private errorHandler: ErrorHandlerService, - private fb: FormBuilder, + private fb: NonNullableFormBuilder, private dialogService: DialogService, private translate: TranslateService, private snackbar: SnackbarService, diff --git a/src/app/pages/sharing/components/shares-dashboard/iscsi-card/iscsi-card.component.html b/src/app/pages/sharing/components/shares-dashboard/iscsi-card/iscsi-card.component.html index 405e69bed05..a0e89ef17c9 100644 --- a/src/app/pages/sharing/components/shares-dashboard/iscsi-card/iscsi-card.component.html +++ b/src/app/pages/sharing/components/shares-dashboard/iscsi-card/iscsi-card.component.html @@ -10,7 +10,7 @@

@@ -20,7 +20,7 @@

mat-button [ixTest]="['iscsi-share', 'wizard']" [ixUiSearch]="searchableElements.elements.wizard" - (click)="openForm(null, true)" + (click)="openForm(undefined, true)" > {{ 'Wizard' | translate }} diff --git a/src/app/pages/sharing/components/shares-dashboard/iscsi-card/iscsi-card.component.ts b/src/app/pages/sharing/components/shares-dashboard/iscsi-card/iscsi-card.component.ts index aa503517719..b9d020a655f 100644 --- a/src/app/pages/sharing/components/shares-dashboard/iscsi-card/iscsi-card.component.ts +++ b/src/app/pages/sharing/components/shares-dashboard/iscsi-card/iscsi-card.component.ts @@ -79,7 +79,7 @@ export class IscsiCardComponent implements OnInit { Role.SharingWrite, ]; - targets = signal(null); + targets = signal(null); protected readonly searchableElements = iscsiCardElements; @@ -98,9 +98,7 @@ export class IscsiCardComponent implements OnInit { title: this.translate.instant('Mode'), propertyName: 'mode', hidden: true, - getValue: (row) => (iscsiTargetModeNames.has(row.mode) - ? this.translate.instant(iscsiTargetModeNames.get(row.mode)) - : row.mode || '-'), + getValue: (row) => this.translate.instant(iscsiTargetModeNames.get(row.mode) || row.mode) || '-', }), actionsColumn({ actions: [ diff --git a/src/app/pages/sharing/components/shares-dashboard/nfs-card/nfs-card.component.html b/src/app/pages/sharing/components/shares-dashboard/nfs-card/nfs-card.component.html index db4c53e705d..a810dc527d1 100644 --- a/src/app/pages/sharing/components/shares-dashboard/nfs-card/nfs-card.component.html +++ b/src/app/pages/sharing/components/shares-dashboard/nfs-card/nfs-card.component.html @@ -10,7 +10,7 @@

diff --git a/src/app/pages/sharing/components/shares-dashboard/nfs-card/nfs-card.component.ts b/src/app/pages/sharing/components/shares-dashboard/nfs-card/nfs-card.component.ts index 54fd70b60f3..d785ee2b5fb 100644 --- a/src/app/pages/sharing/components/shares-dashboard/nfs-card/nfs-card.component.ts +++ b/src/app/pages/sharing/components/shares-dashboard/nfs-card/nfs-card.component.ts @@ -136,6 +136,8 @@ export class NfsCardComponent implements OnInit { this.dialogService.confirm({ title: this.translate.instant('Confirmation'), message: this.translate.instant('Are you sure you want to delete NFS Share "{path}"?', { path: nfs.path }), + buttonColor: 'warn', + buttonText: this.translate.instant('Delete'), }).pipe( filter(Boolean), switchMap(() => this.api.call('sharing.nfs.delete', [nfs.id])), diff --git a/src/app/pages/sharing/components/shares-dashboard/smb-card/smb-card.component.html b/src/app/pages/sharing/components/shares-dashboard/smb-card/smb-card.component.html index 146d40576d9..4b71c159cf6 100644 --- a/src/app/pages/sharing/components/shares-dashboard/smb-card/smb-card.component.html +++ b/src/app/pages/sharing/components/shares-dashboard/smb-card/smb-card.component.html @@ -10,7 +10,7 @@

diff --git a/src/app/pages/sharing/components/shares-dashboard/smb-card/smb-card.component.ts b/src/app/pages/sharing/components/shares-dashboard/smb-card/smb-card.component.ts index b7f71077361..b3deed86f49 100644 --- a/src/app/pages/sharing/components/shares-dashboard/smb-card/smb-card.component.ts +++ b/src/app/pages/sharing/components/shares-dashboard/smb-card/smb-card.component.ts @@ -123,7 +123,7 @@ export class SmbCardComponent implements OnInit { { iconName: iconMarker('edit'), tooltip: this.translate.instant('Edit'), - disabled: (row) => this.loadingMap$.pipe(map((ids) => ids.get(row.id))), + disabled: (row) => this.loadingMap$.pipe(map((ids) => Boolean(ids.get(row.id)))), onClick: (row) => this.openForm(row), }, { @@ -170,6 +170,8 @@ export class SmbCardComponent implements OnInit { this.dialogService.confirm({ title: this.translate.instant('Confirmation'), message: this.translate.instant('Are you sure you want to delete SMB Share "{name}"?', { name: smb.name }), + buttonText: this.translate.instant('Delete'), + buttonColor: 'warn', }).pipe( filter(Boolean), switchMap(() => this.api.call('sharing.smb.delete', [smb.id])), diff --git a/src/app/pages/sharing/iscsi/authorized-access/authorized-access-form/authorized-access-form.component.ts b/src/app/pages/sharing/iscsi/authorized-access/authorized-access-form/authorized-access-form.component.ts index d1160d4953f..f447bacd462 100644 --- a/src/app/pages/sharing/iscsi/authorized-access/authorized-access-form/authorized-access-form.component.ts +++ b/src/app/pages/sharing/iscsi/authorized-access/authorized-access-form/authorized-access-form.component.ts @@ -1,7 +1,7 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit, } from '@angular/core'; -import { FormBuilder, Validators, ReactiveFormsModule } from '@angular/forms'; +import { Validators, ReactiveFormsModule, NonNullableFormBuilder } from '@angular/forms'; import { MatButton } from '@angular/material/button'; import { MatCard, MatCardContent } from '@angular/material/card'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; @@ -63,7 +63,7 @@ export class AuthorizedAccessFormComponent implements OnInit { } form = this.formBuilder.group({ - tag: [null as number, [Validators.required, Validators.min(0)]], + tag: [null as number | null, [Validators.required, Validators.min(0)]], user: ['', Validators.required], secret: ['', [ Validators.minLength(12), @@ -134,7 +134,7 @@ export class AuthorizedAccessFormComponent implements OnInit { constructor( private translate: TranslateService, - private formBuilder: FormBuilder, + private formBuilder: NonNullableFormBuilder, private errorHandler: FormErrorHandlerService, private cdr: ChangeDetectorRef, private api: ApiService, @@ -188,7 +188,7 @@ export class AuthorizedAccessFormComponent implements OnInit { } onSubmit(): void { - const values = this.form.value; + const values = this.form.getRawValue(); const payload = { tag: values.tag, user: values.user, diff --git a/src/app/pages/sharing/iscsi/authorized-access/authorized-access-list/authorized-access-list.component.ts b/src/app/pages/sharing/iscsi/authorized-access/authorized-access-list/authorized-access-list.component.ts index 38a676d1dd2..76f607ed179 100644 --- a/src/app/pages/sharing/iscsi/authorized-access/authorized-access-list/authorized-access-list.component.ts +++ b/src/app/pages/sharing/iscsi/authorized-access/authorized-access-list/authorized-access-list.component.ts @@ -115,6 +115,7 @@ export class AuthorizedAccessListComponent implements OnInit { title: this.translate.instant('Delete'), message: this.translate.instant('Are you sure you want to delete this item?'), buttonText: this.translate.instant('Delete'), + buttonColor: 'warn', }).pipe( filter(Boolean), switchMap(() => this.api.call('iscsi.auth.delete', [row.id]).pipe(this.loader.withLoader())), diff --git a/src/app/pages/sharing/iscsi/extent/extent-list/delete-extent-dialog/delete-extent-dialog.component.html b/src/app/pages/sharing/iscsi/extent/extent-list/delete-extent-dialog/delete-extent-dialog.component.html index d9ced51805c..160b517a0de 100644 --- a/src/app/pages/sharing/iscsi/extent/extent-list/delete-extent-dialog/delete-extent-dialog.component.html +++ b/src/app/pages/sharing/iscsi/extent/extent-list/delete-extent-dialog/delete-extent-dialog.component.html @@ -24,7 +24,7 @@

{{ 'Delete iSCSI extent {name}?' | translate: { name: exten diff --git a/src/app/pages/system/general-settings/manage-configuration-menu/manage-configuration-menu.component.ts b/src/app/pages/system/general-settings/manage-configuration-menu/manage-configuration-menu.component.ts index bf92c7cf639..7f2817061b9 100644 --- a/src/app/pages/system/general-settings/manage-configuration-menu/manage-configuration-menu.component.ts +++ b/src/app/pages/system/general-settings/manage-configuration-menu/manage-configuration-menu.component.ts @@ -62,11 +62,12 @@ export class ManageConfigurationMenuComponent { this.matDialog.open(UploadConfigDialogComponent); } - onResetDefaults(): void { + onResetToDefaults(): void { this.dialogService.confirm({ title: helptext.reset_config_form.title, message: helptext.reset_config_form.message, buttonText: helptext.reset_config_form.button_text, + buttonColor: 'warn', }) .pipe( filter(Boolean), diff --git a/src/app/pages/system/general-settings/ntp-server/ntp-server-card/ntp-server-card.component.ts b/src/app/pages/system/general-settings/ntp-server/ntp-server-card/ntp-server-card.component.ts index 95c47693199..a198dbf601a 100644 --- a/src/app/pages/system/general-settings/ntp-server/ntp-server-card/ntp-server-card.component.ts +++ b/src/app/pages/system/general-settings/ntp-server/ntp-server-card/ntp-server-card.component.ts @@ -131,6 +131,7 @@ export class NtpServerCardComponent implements OnInit { { address: server.address }, ), buttonText: this.translate.instant('Delete'), + buttonColor: 'warn', }).pipe( filter(Boolean), switchMap(() => this.api.call('system.ntpserver.delete', [server.id])), diff --git a/src/app/pages/system/general-settings/upload-config-dialog/upload-config-dialog.component.html b/src/app/pages/system/general-settings/upload-config-dialog/upload-config-dialog.component.html index 650626a823e..3c8496c1a62 100644 --- a/src/app/pages/system/general-settings/upload-config-dialog/upload-config-dialog.component.html +++ b/src/app/pages/system/general-settings/upload-config-dialog/upload-config-dialog.component.html @@ -15,7 +15,7 @@

{{ 'Upload Config' | translate }}

*ixRequiresRoles="requiredRoles" mat-button type="submit" - color="primary" + color="warn" ixTest="upload" [disabled]="form.invalid" > diff --git a/src/app/pages/system/update/components/manual-update-form/manual-update-form.component.ts b/src/app/pages/system/update/components/manual-update-form/manual-update-form.component.ts index 375e2e4b1c9..e6db7104098 100644 --- a/src/app/pages/system/update/components/manual-update-form/manual-update-form.component.ts +++ b/src/app/pages/system/update/components/manual-update-form/manual-update-form.component.ts @@ -2,7 +2,7 @@ import { AsyncPipe } from '@angular/common'; import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit, } from '@angular/core'; -import { FormBuilder, Validators, ReactiveFormsModule } from '@angular/forms'; +import { Validators, ReactiveFormsModule, NonNullableFormBuilder } from '@angular/forms'; import { MatButton } from '@angular/material/button'; import { MatCard, MatCardContent } from '@angular/material/card'; import { MatProgressBar } from '@angular/material/progress-bar'; @@ -75,7 +75,7 @@ export class ManualUpdateFormComponent implements OnInit { protected readonly searchableElements = systemManualUpdateFormElements; isFormLoading$ = new BehaviorSubject(false); - form = this.formBuilder.nonNullable.group({ + form = this.formBuilder.group({ filelocation: ['', Validators.required], updateFile: [null as FileList | null], rebootAfterManualUpdate: [false], @@ -93,7 +93,7 @@ export class ManualUpdateFormComponent implements OnInit { private dialogService: DialogService, protected router: Router, public systemService: SystemGeneralService, - private formBuilder: FormBuilder, + private formBuilder: NonNullableFormBuilder, private api: ApiService, private errorHandler: ErrorHandlerService, private authService: AuthService, diff --git a/src/app/pages/system/update/update.component.html b/src/app/pages/system/update/update.component.html index 8a326a1ac91..eecc30ba654 100644 --- a/src/app/pages/system/update/update.component.html +++ b/src/app/pages/system/update/update.component.html @@ -20,7 +20,7 @@ {{ package.name }} } - @if ((updateService.packages$ | async).length === 0) { + @if ((updateService.packages$ | async)?.length === 0) { {{ 'No update found.' | translate }} diff --git a/src/app/pages/virtualization/components/all-instances/instance-details/instance-devices/add-device-menu/add-device-menu.component.ts b/src/app/pages/virtualization/components/all-instances/instance-details/instance-devices/add-device-menu/add-device-menu.component.ts index cf41980cb50..ac0dc197054 100644 --- a/src/app/pages/virtualization/components/all-instances/instance-details/instance-devices/add-device-menu/add-device-menu.component.ts +++ b/src/app/pages/virtualization/components/all-instances/instance-details/instance-devices/add-device-menu/add-device-menu.component.ts @@ -7,7 +7,7 @@ import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { pickBy } from 'lodash-es'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; -import { VirtualizationDeviceType, VirtualizationGpuType, VirtualizationType } from 'app/enums/virtualization.enum'; +import { VirtualizationDeviceType, VirtualizationGpuType } from 'app/enums/virtualization.enum'; import { AvailableUsb, VirtualizationDevice, @@ -42,7 +42,7 @@ import { ErrorHandlerService } from 'app/services/error-handler.service'; export class AddDeviceMenuComponent { private readonly usbChoices = toSignal(this.api.call('virt.device.usb_choices'), { initialValue: {} }); // TODO: Stop hardcoding params - private readonly gpuChoices = toSignal(this.api.call('virt.device.gpu_choices', [VirtualizationType.Container, VirtualizationGpuType.Physical]), { initialValue: {} }); + private readonly gpuChoices = toSignal(this.api.call('virt.device.gpu_choices', [VirtualizationGpuType.Physical]), { initialValue: {} }); protected readonly isLoadingDevices = this.deviceStore.isLoading; diff --git a/src/app/pages/virtualization/components/instance-wizard/instance-wizard.component.html b/src/app/pages/virtualization/components/instance-wizard/instance-wizard.component.html index f224bfec1a2..02751cc20de 100644 --- a/src/app/pages/virtualization/components/instance-wizard/instance-wizard.component.html +++ b/src/app/pages/virtualization/components/instance-wizard/instance-wizard.component.html @@ -16,6 +16,14 @@ [required]="true" > + +
- @if ((usbDevices$ | async); as usbDevices) { - @if (usbDevices.length > 0) { - 0) { + + - - - } + [options]="usbDevices$" + > + } - @if ((gpuDevices$ | async); as gpuDevices) { - @if (gpuDevices.length > 0) { - 0) { + + - - - } + [options]="gpuDevices$" + > + }
diff --git a/src/app/pages/virtualization/components/instance-wizard/instance-wizard.component.spec.ts b/src/app/pages/virtualization/components/instance-wizard/instance-wizard.component.spec.ts index 3418b6fad98..6c1c75763f7 100644 --- a/src/app/pages/virtualization/components/instance-wizard/instance-wizard.component.spec.ts +++ b/src/app/pages/virtualization/components/instance-wizard/instance-wizard.component.spec.ts @@ -1,6 +1,7 @@ import { HarnessLoader } from '@angular/cdk/testing'; import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; import { MatButtonHarness } from '@angular/material/button/testing'; +import { MatCheckboxHarness } from '@angular/material/checkbox/testing'; import { MatDialog } from '@angular/material/dialog'; import { Router } from '@angular/router'; import { @@ -25,6 +26,7 @@ import { VirtualizationInstance } from 'app/interfaces/virtualization.interface' import { AuthService } from 'app/modules/auth/auth.service'; import { DialogService } from 'app/modules/dialog/dialog.service'; import { IxCheckboxHarness } from 'app/modules/forms/ix-forms/components/ix-checkbox/ix-checkbox.harness'; +import { IxIconGroupHarness } from 'app/modules/forms/ix-forms/components/ix-icon-group/ix-icon-group.harness'; import { IxListHarness } from 'app/modules/forms/ix-forms/components/ix-list/ix-list.harness'; import { IxFormHarness } from 'app/modules/forms/ix-forms/testing/ix-form.harness'; import { PageHeaderComponent } from 'app/modules/page-header/page-title-header/page-header.component'; @@ -34,8 +36,7 @@ import { InstanceWizardComponent } from 'app/pages/virtualization/components/ins import { VirtualizationImageWithId } from 'app/pages/virtualization/components/instance-wizard/select-image-dialog/select-image-dialog.component'; import { FilesystemService } from 'app/services/filesystem.service'; -// TODO: https://ixsystems.atlassian.net/browse/NAS-133118 -describe.skip('InstanceWizardComponent', () => { +describe('InstanceWizardComponent', () => { let spectator: SpectatorRouting; let loader: HarnessLoader; let form: IxFormHarness; @@ -57,7 +58,15 @@ describe.skip('InstanceWizardComponent', () => { autostart: false, cpu: 'Intel Xeon', memory: 2 * GiB, - } as VirtualizationInstance]), + }, + { + id: 'testVM', + name: 'testVM', + type: VirtualizationType.Vm, + autostart: false, + cpu: 'Intel Xeon', + memory: 4 * GiB, + }] as VirtualizationInstance[]), mockCall('interface.has_pending_changes', false), mockCall('virt.device.nic_choices', { nic1: 'nic1', @@ -113,18 +122,98 @@ describe.skip('InstanceWizardComponent', () => { expect(spectator.inject(MatDialog).open).toHaveBeenCalled(); expect(await form.getValues()).toMatchObject({ - Image: 'Almalinux 8 Cloud', + Image: 'almalinux/8/cloud', + }); + }); + + it('creates new container instance when form is submitted', async () => { + await form.fillForm({ + Name: 'new', + 'CPU Configuration': '1-2', + 'Memory Size': '1 GiB', }); + + const browseButton = await loader.getHarness(MatButtonHarness.with({ text: 'Browse Catalog' })); + await browseButton.click(); + + const diskList = await loader.getHarness(IxListHarness.with({ label: 'Disks' })); + await diskList.pressAddButton(); + const diskForm = await diskList.getLastListItem(); + await diskForm.fillForm({ + Source: '/mnt/source', + Destination: 'destination', + }); + + const proxiesList = await loader.getHarness(IxListHarness.with({ label: 'Proxies' })); + await proxiesList.pressAddButton(); + const proxyForm = await proxiesList.getLastListItem(); + await proxyForm.fillForm({ + 'Host Port': 3000, + 'Host Protocol': 'TCP', + 'Instance Port': 2000, + 'Instance Protocol': 'UDP', + }); + + // TODO: Fix this to use IxCheckboxHarness + const usbDeviceCheckbox = await loader.getHarness(MatCheckboxHarness.with({ + label: 'xHCI Host Controller (0003)', + })); + await usbDeviceCheckbox.check(); + + const useDefaultNetworkCheckbox = await loader.getHarness(IxCheckboxHarness.with({ label: 'Use default network settings' })); + await useDefaultNetworkCheckbox.setValue(false); + + // TODO: Fix this to use IxCheckboxHarness + const nicDeviceCheckbox = await loader.getHarness(MatCheckboxHarness.with({ label: 'nic1' })); + await nicDeviceCheckbox.check(); + + // TODO: Fix this to use IxCheckboxHarness + const gpuDeviceCheckbox = await loader.getHarness(MatCheckboxHarness.with({ label: 'NVIDIA GeForce GTX 1080' })); + await gpuDeviceCheckbox.check(); + + const createButton = await loader.getHarness(MatButtonHarness.with({ text: 'Create' })); + await createButton.click(); + + expect(spectator.inject(ApiService).job).toHaveBeenCalledWith('virt.instance.create', [{ + name: 'new', + autostart: true, + cpu: '1-2', + instance_type: VirtualizationType.Container, + devices: [ + { + dev_type: VirtualizationDeviceType.Disk, + source: '/mnt/source', + destination: 'destination', + }, + { + dev_type: VirtualizationDeviceType.Proxy, + source_port: 3000, + source_proto: VirtualizationProxyProtocol.Tcp, + dest_port: 2000, + dest_proto: VirtualizationProxyProtocol.Udp, + }, + { dev_type: VirtualizationDeviceType.Nic, nic_type: VirtualizationNicType.Bridged, parent: 'nic1' }, + { dev_type: VirtualizationDeviceType.Usb, product_id: '0003' }, + { dev_type: VirtualizationDeviceType.Gpu, pci: 'pci_0000_01_00_0' }, + ], + image: 'almalinux/8/cloud', + memory: GiB, + environment: {}, + }]); + expect(spectator.inject(DialogService).jobDialog).toHaveBeenCalled(); + expect(spectator.inject(SnackbarService).success).toHaveBeenCalled(); }); - it('creates new instance when form is submitted', async () => { + it('creates new vm instance when form is submitted', async () => { await form.fillForm({ Name: 'new', - Autostart: true, 'CPU Configuration': '1-2', 'Memory Size': '1 GiB', }); + const instanceType = await loader.getHarness(IxIconGroupHarness.with({ label: 'Virtualization Method' })); + await instanceType.setValue('VM'); + const browseButton = await loader.getHarness(MatButtonHarness.with({ text: 'Browse Catalog' })); await browseButton.click(); @@ -146,17 +235,22 @@ describe.skip('InstanceWizardComponent', () => { 'Instance Protocol': 'UDP', }); - const usbDeviceCheckbox = await loader.getHarness(IxCheckboxHarness.with({ label: 'xHCI Host Controller (0003)' })); - await usbDeviceCheckbox.setValue(true); + // TODO: Fix this to use IxCheckboxHarness + const usbDeviceCheckbox = await loader.getHarness(MatCheckboxHarness.with({ + label: 'xHCI Host Controller (0003)', + })); + await usbDeviceCheckbox.check(); const useDefaultNetworkCheckbox = await loader.getHarness(IxCheckboxHarness.with({ label: 'Use default network settings' })); await useDefaultNetworkCheckbox.setValue(false); - const nicDeviceCheckbox = await loader.getHarness(IxCheckboxHarness.with({ label: 'nic1' })); - await nicDeviceCheckbox.setValue(true); + // TODO: Fix this to use IxCheckboxHarness + const nicDeviceCheckbox = await loader.getHarness(MatCheckboxHarness.with({ label: 'nic1' })); + await nicDeviceCheckbox.check(); - const gpuDeviceCheckbox = await loader.getHarness(IxCheckboxHarness.with({ label: 'NVIDIA GeForce GTX 1080' })); - await gpuDeviceCheckbox.setValue(true); + // TODO: Fix this to use IxCheckboxHarness + const gpuDeviceCheckbox = await loader.getHarness(MatCheckboxHarness.with({ label: 'NVIDIA GeForce GTX 1080' })); + await gpuDeviceCheckbox.check(); const createButton = await loader.getHarness(MatButtonHarness.with({ text: 'Create' })); await createButton.click(); @@ -165,6 +259,7 @@ describe.skip('InstanceWizardComponent', () => { name: 'new', autostart: true, cpu: '1-2', + instance_type: VirtualizationType.Vm, devices: [ { dev_type: VirtualizationDeviceType.Disk, @@ -193,7 +288,6 @@ describe.skip('InstanceWizardComponent', () => { it('sends no NIC devices when default network settings checkbox is set', async () => { await form.fillForm({ Name: 'new', - Autostart: true, 'CPU Configuration': '1-2', 'Memory Size': '1 GiB', }); @@ -204,10 +298,12 @@ describe.skip('InstanceWizardComponent', () => { const useDefaultNetworkCheckbox = await loader.getHarness(IxCheckboxHarness.with({ label: 'Use default network settings' })); await useDefaultNetworkCheckbox.setValue(false); - const nicDeviceCheckbox = await loader.getHarness(IxCheckboxHarness.with({ label: 'nic1' })); - await nicDeviceCheckbox.setValue(true); + // TODO: Fix this to use IxCheckboxHarness + const nicDeviceCheckbox = await loader.getHarness(MatCheckboxHarness.with({ label: 'nic1' })); + await nicDeviceCheckbox.check(); await useDefaultNetworkCheckbox.setValue(true); // no nic1 should be send now + spectator.detectChanges(); const createButton = await loader.getHarness(MatButtonHarness.with({ text: 'Create' })); await createButton.click(); @@ -220,6 +316,7 @@ describe.skip('InstanceWizardComponent', () => { image: 'almalinux/8/cloud', memory: GiB, environment: {}, + instance_type: 'CONTAINER', }]); expect(spectator.inject(DialogService).jobDialog).toHaveBeenCalled(); expect(spectator.inject(SnackbarService).success).toHaveBeenCalled(); diff --git a/src/app/pages/virtualization/components/instance-wizard/instance-wizard.component.ts b/src/app/pages/virtualization/components/instance-wizard/instance-wizard.component.ts index 807e77dbb99..a2ed66c7ff9 100644 --- a/src/app/pages/virtualization/components/instance-wizard/instance-wizard.component.ts +++ b/src/app/pages/virtualization/components/instance-wizard/instance-wizard.component.ts @@ -14,6 +14,7 @@ import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { + filter, map, Observable, of, } from 'rxjs'; import { Role } from 'app/enums/role.enum'; @@ -26,6 +27,8 @@ import { virtualizationProxyProtocolLabels, VirtualizationRemote, VirtualizationType, + virtualizationTypeIcons, + virtualizationTypeLabels, } from 'app/enums/virtualization.enum'; import { mapToOptions } from 'app/helpers/options.helper'; import { containersHelptext } from 'app/helptext/virtualization/containers'; @@ -42,6 +45,7 @@ import { IxCheckboxListComponent } from 'app/modules/forms/ix-forms/components/i import { IxExplorerComponent } from 'app/modules/forms/ix-forms/components/ix-explorer/ix-explorer.component'; import { IxFormGlossaryComponent } from 'app/modules/forms/ix-forms/components/ix-form-glossary/ix-form-glossary.component'; import { IxFormSectionComponent } from 'app/modules/forms/ix-forms/components/ix-form-section/ix-form-section.component'; +import { IxIconGroupComponent } from 'app/modules/forms/ix-forms/components/ix-icon-group/ix-icon-group.component'; import { IxInputComponent } from 'app/modules/forms/ix-forms/components/ix-input/ix-input.component'; import { IxListItemComponent } from 'app/modules/forms/ix-forms/components/ix-list/ix-list-item/ix-list-item.component'; import { IxListComponent } from 'app/modules/forms/ix-forms/components/ix-list/ix-list.component'; @@ -64,23 +68,24 @@ import { FilesystemService } from 'app/services/filesystem.service'; selector: 'ix-instance-wizard', standalone: true, imports: [ - PageHeaderComponent, - IxInputComponent, - ReactiveFormsModule, - TranslateModule, - IxCheckboxComponent, - MatButton, - TestDirective, - ReadOnlyComponent, AsyncPipe, - IxListComponent, + IxCheckboxComponent, + IxCheckboxListComponent, + IxExplorerComponent, IxFormGlossaryComponent, IxFormSectionComponent, - IxCheckboxListComponent, + IxInputComponent, + IxListComponent, IxListItemComponent, IxSelectComponent, - IxExplorerComponent, + MatButton, NgxSkeletonLoaderModule, + PageHeaderComponent, + ReactiveFormsModule, + ReadOnlyComponent, + TestDirective, + TranslateModule, + IxIconGroupComponent, ], templateUrl: './instance-wizard.component.html', styleUrls: ['./instance-wizard.component.scss'], @@ -90,6 +95,8 @@ export class InstanceWizardComponent { protected readonly isLoading = signal(false); protected readonly requiredRoles = [Role.VirtGlobalWrite]; protected readonly VirtualizationNicType = VirtualizationNicType; + protected readonly virtualizationTypeOptions$ = of(mapToOptions(virtualizationTypeLabels, this.translate)); + protected readonly virtualizationTypeIcons = virtualizationTypeIcons; protected readonly hasPendingInterfaceChanges = toSignal(this.api.call('interface.has_pending_changes')); @@ -109,10 +116,9 @@ export class InstanceWizardComponent { }))), ); - // TODO: MV supports only [Container, Physical] for now (based on the response) gpuDevices$ = this.api.call( 'virt.device.gpu_choices', - [VirtualizationType.Container, VirtualizationGpuType.Physical], + [VirtualizationGpuType.Physical], ).pipe( map((choices) => Object.entries(choices).map(([pci, gpu]) => ({ label: gpu.description, @@ -121,10 +127,11 @@ export class InstanceWizardComponent { ); protected readonly form = this.formBuilder.nonNullable.group({ - name: ['', Validators.required], - image: ['', Validators.required], + name: ['', [Validators.required, Validators.minLength(1), Validators.maxLength(200)]], + instance_type: [VirtualizationType.Container, Validators.required], + image: ['', [Validators.required, Validators.minLength(1), Validators.maxLength(200)]], cpu: ['', [cpuValidator()]], - memory: [null as number | null], + memory: [null as number], use_default_network: [true], usb_devices: [[] as string[]], gpu_devices: [[] as string[]], @@ -132,9 +139,9 @@ export class InstanceWizardComponent { mac_vlan_nics: [[] as string[]], proxies: this.formBuilder.array; - source_port: FormControl; + source_port: FormControl; dest_proto: FormControl; - dest_port: FormControl; + dest_port: FormControl; }>>([]), disks: this.formBuilder.array; @@ -167,25 +174,22 @@ export class InstanceWizardComponent { minWidth: '90vw', data: { remote: VirtualizationRemote.LinuxContainers, + type: this.form.controls.instance_type.value, }, }) .afterClosed() - .pipe(untilDestroyed(this)) + .pipe(filter(Boolean), untilDestroyed(this)) .subscribe((image: VirtualizationImageWithId) => { - if (!image) { - return; - } - this.form.controls.image.setValue(image.id); }); } protected addProxy(): void { - const control = this.formBuilder.nonNullable.group({ + const control = this.formBuilder.group({ source_proto: [VirtualizationProxyProtocol.Tcp], - source_port: [null as number | null, Validators.required], + source_port: [null as number, Validators.required], dest_proto: [VirtualizationProxyProtocol.Tcp], - dest_port: [null as number | null, Validators.required], + dest_port: [null as number, Validators.required], }); this.form.controls.proxies.push(control); @@ -196,7 +200,7 @@ export class InstanceWizardComponent { } protected addDisk(): void { - const control = this.formBuilder.nonNullable.group({ + const control = this.formBuilder.group({ source: ['', Validators.required], destination: ['', Validators.required], }); @@ -228,7 +232,7 @@ export class InstanceWizardComponent { } addEnvironmentVariable(): void { - const control = this.formBuilder.nonNullable.group({ + const control = this.formBuilder.group({ name: ['', Validators.required], value: ['', Validators.required], }); @@ -246,6 +250,7 @@ export class InstanceWizardComponent { return { devices, autostart: true, + instance_type: this.form.controls.instance_type.value, name: this.form.controls.name.value, cpu: this.form.controls.cpu.value, memory: this.form.controls.memory.value, @@ -297,23 +302,26 @@ export class InstanceWizardComponent { dev_type: VirtualizationDeviceType.Gpu, }); } - const macVlanNics: { parent: string; dev_type: VirtualizationDeviceType; nic_type: VirtualizationNicType }[] = []; - for (const parent of this.form.controls.mac_vlan_nics.value) { - macVlanNics.push({ - parent, - dev_type: VirtualizationDeviceType.Nic, - nic_type: VirtualizationNicType.Macvlan, - }); + if (!this.form.controls.use_default_network.value) { + for (const parent of this.form.controls.mac_vlan_nics.value) { + macVlanNics.push({ + parent, + dev_type: VirtualizationDeviceType.Nic, + nic_type: VirtualizationNicType.Macvlan, + }); + } } const bridgedNics: { parent: string; dev_type: VirtualizationDeviceType; nic_type: VirtualizationNicType }[] = []; - for (const parent of this.form.controls.bridged_nics.value) { - bridgedNics.push({ - parent, - dev_type: VirtualizationDeviceType.Nic, - nic_type: VirtualizationNicType.Bridged, - }); + if (!this.form.controls.use_default_network.value) { + for (const parent of this.form.controls.bridged_nics.value) { + bridgedNics.push({ + parent, + dev_type: VirtualizationDeviceType.Nic, + nic_type: VirtualizationNicType.Bridged, + }); + } } const proxies = this.form.controls.proxies.value.map((proxy) => ({ diff --git a/src/app/pages/virtualization/components/instance-wizard/select-image-dialog/select-image-dialog.component.spec.ts b/src/app/pages/virtualization/components/instance-wizard/select-image-dialog/select-image-dialog.component.spec.ts index 2ae49a09c2a..c3b0e7f0f2d 100644 --- a/src/app/pages/virtualization/components/instance-wizard/select-image-dialog/select-image-dialog.component.spec.ts +++ b/src/app/pages/virtualization/components/instance-wizard/select-image-dialog/select-image-dialog.component.spec.ts @@ -12,7 +12,7 @@ import { mockCall, mockApi, } from 'app/core/testing/utils/mock-api.utils'; -import { VirtualizationRemote } from 'app/enums/virtualization.enum'; +import { VirtualizationRemote, VirtualizationType } from 'app/enums/virtualization.enum'; import { VirtualizationImage } from 'app/interfaces/virtualization.interface'; import { IxFormHarness } from 'app/modules/forms/ix-forms/testing/ix-form.harness'; import { ApiService } from 'app/modules/websocket/api.service'; @@ -25,6 +25,7 @@ const imageChoices: Record = { release: '8', archs: ['arm64'], variant: 'cloud', + instance_types: [VirtualizationType.Container], } as VirtualizationImage, 'alpine/3.18/default': { label: 'Alpine 3.18 (armhf, default)', @@ -32,6 +33,7 @@ const imageChoices: Record = { release: '3.18', archs: ['armhf'], variant: 'default', + instance_types: [VirtualizationType.Container], } as VirtualizationImage, } as Record; @@ -47,7 +49,10 @@ describe('SelectImageDialogComponent', () => { mockProvider(MatDialogRef), { provide: MAT_DIALOG_DATA, - useValue: { remote: VirtualizationRemote.LinuxContainers }, + useValue: { + remote: VirtualizationRemote.LinuxContainers, + type: VirtualizationType.Container, + }, }, ], }); diff --git a/src/app/pages/virtualization/components/instance-wizard/select-image-dialog/select-image-dialog.component.ts b/src/app/pages/virtualization/components/instance-wizard/select-image-dialog/select-image-dialog.component.ts index 52ee27272d3..7822dffdc9d 100644 --- a/src/app/pages/virtualization/components/instance-wizard/select-image-dialog/select-image-dialog.component.ts +++ b/src/app/pages/virtualization/components/instance-wizard/select-image-dialog/select-image-dialog.component.ts @@ -1,5 +1,6 @@ import { ChangeDetectionStrategy, Component, Inject, signal, OnInit, + computed, } from '@angular/core'; import { FormBuilder, ReactiveFormsModule } from '@angular/forms'; import { MatButton, MatIconButton } from '@angular/material/button'; @@ -11,7 +12,7 @@ import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { catchError, Observable, of } from 'rxjs'; import { EmptyType } from 'app/enums/empty-type.enum'; -import { VirtualizationRemote } from 'app/enums/virtualization.enum'; +import { VirtualizationRemote, VirtualizationType } from 'app/enums/virtualization.enum'; import { EmptyConfig } from 'app/interfaces/empty-config.interface'; import { Option } from 'app/interfaces/option.interface'; import { VirtualizationImage } from 'app/interfaces/virtualization.interface'; @@ -71,13 +72,17 @@ export class SelectImageDialogComponent implements OnInit { large: true, } as EmptyConfig); + protected isContainer = computed(() => { + return this.data.type === VirtualizationType.Container; + }); + constructor( private api: ApiService, private dialogRef: MatDialogRef, private fb: FormBuilder, private translate: TranslateService, private errorHandler: ErrorHandlerService, - @Inject(MAT_DIALOG_DATA) protected data: { remote: VirtualizationRemote }, + @Inject(MAT_DIALOG_DATA) protected data: { remote: VirtualizationRemote; type: VirtualizationType }, ) { this.filterForm.valueChanges.pipe(untilDestroyed(this)).subscribe(() => this.filterImages()); } @@ -95,7 +100,7 @@ export class SelectImageDialogComponent implements OnInit { } private getImages(): void { - this.api.call('virt.instance.image_choices', [this.data]) + this.api.call('virt.instance.image_choices', [{ remote: this.data.remote }]) .pipe( catchError((error: unknown) => { this.errorHandler.showErrorModal(error); @@ -114,7 +119,10 @@ export class SelectImageDialogComponent implements OnInit { const variantSet = new Set(); const releaseSet = new Set(); - const imageArray = Object.entries(images).map(([id, image]) => ({ ...image, id })); + const imageArray = Object.entries(images) + .filter(([_, image]) => image?.instance_types?.includes(this.data.type)) + .map(([id, image]) => ({ ...image, id })); + this.images.set(imageArray); imageArray.forEach((image) => { diff --git a/src/app/pages/vm/devices/device-list/device-delete-modal/device-delete-modal.component.html b/src/app/pages/vm/devices/device-list/device-delete-modal/device-delete-modal.component.html index b3254db7379..1f93ed37d89 100644 --- a/src/app/pages/vm/devices/device-list/device-delete-modal/device-delete-modal.component.html +++ b/src/app/pages/vm/devices/device-list/device-delete-modal/device-delete-modal.component.html @@ -51,7 +51,7 @@

{{ 'Delete' | translate }}

*ixRequiresRoles="requiredRoles" type="submit" mat-button - color="primary" + color="warn" ixTest="delete-device" [disabled]="form.invalid" > diff --git a/src/app/pages/vm/vm-edit-form/vm-edit-form.component.ts b/src/app/pages/vm/vm-edit-form/vm-edit-form.component.ts index b28e15a18f6..9a8b78c343b 100644 --- a/src/app/pages/vm/vm-edit-form/vm-edit-form.component.ts +++ b/src/app/pages/vm/vm-edit-form/vm-edit-form.component.ts @@ -119,7 +119,7 @@ export class VmEditFormComponent implements OnInit { private gpuService: GpuService, private vmGpuService: VmGpuService, private snackbar: SnackbarService, - public slideInRef: SlideInRef, + public slideInRef: SlideInRef, ) { this.slideInRef.requireConfirmationWhen(() => { return of(this.form.dirty); diff --git a/src/app/pages/vm/vm-list/delete-vm-dialog/delete-vm-dialog.component.html b/src/app/pages/vm/vm-list/delete-vm-dialog/delete-vm-dialog.component.html index 15efed6bb8d..c5c4a21d86b 100644 --- a/src/app/pages/vm/vm-list/delete-vm-dialog/delete-vm-dialog.component.html +++ b/src/app/pages/vm/vm-list/delete-vm-dialog/delete-vm-dialog.component.html @@ -30,7 +30,7 @@

{{ 'Delete Virtual Machine' | translate }}