Skip to content

Commit

Permalink
NAS-132332 / 25.04 / Adding proxies to containers (#11010)
Browse files Browse the repository at this point in the history
  • Loading branch information
undsoft authored Nov 13, 2024
1 parent 1c69bf6 commit 572e9e1
Show file tree
Hide file tree
Showing 110 changed files with 2,436 additions and 210 deletions.
5 changes: 5 additions & 0 deletions src/app/enums/virtualization.enum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@ export enum VirtualizationProxyProtocol {
Tcp = 'TCP',
}

export const virtualizationProxyProtocolLabels = new Map<VirtualizationProxyProtocol, string>([
[VirtualizationProxyProtocol.Udp, 'UDP'],
[VirtualizationProxyProtocol.Tcp, 'TCP'],
]);

export enum VirtualizationNetworkType {
Bridge = 'BRIDGE',
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
<div class="cards">
<div class="scroll-window">
<ix-instance-devices [instance]="instance()"></ix-instance-devices>
<ix-instance-general-info [instance]="instance()"></ix-instance-general-info>

<ix-instance-devices></ix-instance-devices>

<ix-instance-proxies></ix-instance-proxies>
</div>
</div>
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {
ChangeDetectionStrategy, Component, inject, input,
ChangeDetectionStrategy, Component, input,
} from '@angular/core';
import { TranslateModule } from '@ngx-translate/core';
import { VirtualizationInstance } from 'app/interfaces/virtualization.interface';
Expand All @@ -10,6 +10,9 @@ import {
import {
InstanceGeneralInfoComponent,
} from 'app/pages/virtualization/components/all-instances/instance-details/instance-general-info/instance-general-info.component';
import {
InstanceProxiesComponent,
} from 'app/pages/virtualization/components/all-instances/instance-details/instance-proxies/instance-proxies.component';
import { VirtualizationInstancesStore } from 'app/pages/virtualization/stores/virtualization-instances.store';

@Component({
Expand All @@ -23,12 +26,20 @@ import { VirtualizationInstancesStore } from 'app/pages/virtualization/stores/vi
InstanceDevicesComponent,
InstanceGeneralInfoComponent,
MobileBackButtonComponent,
InstanceProxiesComponent,
],
})
export class InstanceDetailsComponent {
instance = input.required<VirtualizationInstance>();

protected readonly devices = this.instancesStore.selectedInstanceDevices;
protected readonly isLoadingDevices = this.instancesStore.isLoadingDevices;

constructor(
private instancesStore: VirtualizationInstancesStore,
) {}

onCloseMobileDetails(): void {
inject(VirtualizationInstancesStore).selectInstance(null);
this.instancesStore.selectInstance(null);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,24 @@ <h3 mat-card-title>
</mat-card-header>

<mat-card-content>
@if (isLoading()) {
<mat-spinner [diameter]="40"></mat-spinner>
@if (isLoadingDevices()) {
<ngx-skeleton-loader></ngx-skeleton-loader>
} @else {
@for (device of devices(); track device) {
<div>
<p>{{ 'Device Type' | translate }}: {{ device.dev_type | titlecase }}</p>
@for (device of shownDevices(); track device) {
<div class="device">
<span>{{ getDeviceDescription(device) }}</span>
<button
mat-icon-button
class="delete-button"
[attr.aria-label]="'Delete device' | translate"
[ixTest]="['delete-device', $index]"
(click)="deleteProxyPressed(device)"
>
<ix-icon name="mdi-close"></ix-icon>
</button>
</div>
} @empty {
{{ 'No devices added.' | translate }}
}
}
</mat-card-content>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,19 @@
mat-card-content p {
margin: 0 0 6px;
.device {
align-items: center;
display: flex;
justify-content: space-between;
}

.delete-button {
opacity: 0.8;

&:focus,
&:hover {
opacity: 1;
}

ix-icon {
height: 20px;
width: 20px;
}
}
Original file line number Diff line number Diff line change
@@ -1,71 +1,72 @@
import { MatProgressSpinner } from '@angular/material/progress-spinner';
import { createComponentFactory, Spectator } from '@ngneat/spectator/jest';
import { MockComponents } from 'ng-mocks';
import { HarnessLoader } from '@angular/cdk/testing';
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest';
import { of } from 'rxjs';
import { mockCall, mockWebSocket } from 'app/core/testing/utils/mock-websocket.utils';
import { VirtualizationDeviceType, VirtualizationStatus, VirtualizationType } from 'app/enums/virtualization.enum';
import {
VirtualizationDevice,
VirtualizationInstance,
} from 'app/interfaces/virtualization.interface';
import { VirtualizationDeviceType } from 'app/enums/virtualization.enum';
import { VirtualizationProxy, VirtualizationUsb } from 'app/interfaces/virtualization.interface';
import { DialogService } from 'app/modules/dialog/dialog.service';
import { IxIconHarness } from 'app/modules/ix-icon/ix-icon.harness';
import { SnackbarService } from 'app/modules/snackbar/services/snackbar.service';
import {
InstanceDevicesComponent,
} from 'app/pages/virtualization/components/all-instances/instance-details/instance-devices/instance-devices.component';
import { VirtualizationInstancesStore } from 'app/pages/virtualization/stores/virtualization-instances.store';
import { WebSocketService } from 'app/services/ws.service';

describe('InstanceDevicesComponent', () => {
let spectator: Spectator<InstanceDevicesComponent>;

const demoInstance = {
id: 'demo',
name: 'Demo',
type: VirtualizationType.Container,
status: VirtualizationStatus.Running,
cpu: '525',
autostart: true,
image: {
archs: ['amd64'],
description: 'Almalinux 8 amd64 (20241030_23:38)',
os: 'Almalinux',
release: '8',
},
memory: 131072000,
} as VirtualizationInstance;

const devices = [
{ dev_type: VirtualizationDeviceType.Gpu },
{ dev_type: VirtualizationDeviceType.Usb },
] as VirtualizationDevice[];

let loader: HarnessLoader;
const createComponent = createComponentFactory({
component: InstanceDevicesComponent,
declarations: [
MockComponents(
MatProgressSpinner,
),
],
providers: [
mockProvider(DialogService, {
confirm: jest.fn(() => of(true)),
}),
mockWebSocket([
mockCall('virt.instance.device_list', devices),
mockCall('virt.instance.device_delete'),
]),
mockProvider(SnackbarService),
mockProvider(VirtualizationInstancesStore, {
isLoadingDevices: () => false,
selectedInstance: () => ({ id: 'my-instance' }),
selectedInstanceDevices: () => [
{
dev_type: VirtualizationDeviceType.Usb,
name: 'usb1',
} as VirtualizationUsb,
{
dev_type: VirtualizationDeviceType.Gpu,
name: 'gpu1',
},
{
name: 'proxy2',
} as VirtualizationProxy,
],
loadDevices: jest.fn(),
}),
],
});

beforeEach(() => {
spectator = createComponent({
props: {
instance: demoInstance,
},
});
spectator = createComponent();
loader = TestbedHarnessEnvironment.loader(spectator.fixture);
});

it('checks card title', () => {
const title = spectator.query('h3');
expect(title).toHaveText('Devices');
it('shows a list of USB or GPU devices', () => {
const devices = spectator.queryAll('.device');

expect(devices).toHaveLength(2);
expect(devices[0]).toHaveText('usb1');
expect(devices[1]).toHaveText('gpu1');
});

it('renders details in card', () => {
const chartExtra = spectator.query('mat-card-content').querySelectorAll('p');
expect(chartExtra).toHaveLength(2);
expect(chartExtra[0]).toHaveText('Device Type: Gpu');
expect(chartExtra[1]).toHaveText('Device Type: Usb');
it('deletes a device with confirmation and reloads the list when delete icon is pressed', async () => {
const deleteIcon = await loader.getHarness(IxIconHarness.with({ name: 'mdi-close' }));
await deleteIcon.click();

expect(spectator.inject(DialogService).confirm).toHaveBeenCalled();
expect(spectator.inject(WebSocketService).call).toHaveBeenCalledWith('virt.instance.device_delete', ['my-instance', 'usb1']);
expect(spectator.inject(VirtualizationInstancesStore).loadDevices).toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
@@ -1,16 +1,25 @@
import { TitleCasePipe } from '@angular/common';
import {
ChangeDetectionStrategy, Component, input, OnChanges, signal,
ChangeDetectionStrategy, Component, computed,
} from '@angular/core';
import { MatButton } from '@angular/material/button';
import {
MatCardContent, MatCardModule,
} from '@angular/material/card';
import { MatProgressSpinner } from '@angular/material/progress-spinner';
import { MatIconButton } from '@angular/material/button';
import { MatCard, MatCardContent, MatCardHeader } from '@angular/material/card';
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
import { TranslateModule } from '@ngx-translate/core';
import { IxSimpleChanges } from 'app/interfaces/simple-changes.interface';
import { VirtualizationDevice, VirtualizationInstance } from 'app/interfaces/virtualization.interface';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import {
EMPTY, Observable, switchMap, tap,
} from 'rxjs';
import { VirtualizationDeviceType, virtualizationDeviceTypeLabels } from 'app/enums/virtualization.enum';
import {
VirtualizationDevice,
} from 'app/interfaces/virtualization.interface';
import { DialogService } from 'app/modules/dialog/dialog.service';
import { IxIconComponent } from 'app/modules/ix-icon/ix-icon.component';
import { AppLoaderService } from 'app/modules/loader/app-loader.service';
import { MapValuePipe } from 'app/modules/pipes/map-value/map-value.pipe';
import { SnackbarService } from 'app/modules/snackbar/services/snackbar.service';
import { TestDirective } from 'app/modules/test-id/test.directive';
import { VirtualizationInstancesStore } from 'app/pages/virtualization/stores/virtualization-instances.store';
import { ErrorHandlerService } from 'app/services/error-handler.service';
import { WebSocketService } from 'app/services/ws.service';

Expand All @@ -22,46 +31,73 @@ import { WebSocketService } from 'app/services/ws.service';
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
MatButton,
MatCardModule,
MatCard,
MatCardHeader,
TranslateModule,
MatCardContent,
MatProgressSpinner,
TitleCasePipe,
NgxSkeletonLoaderModule,
MapValuePipe,
MatIconButton,
TestDirective,
IxIconComponent,
],
})
export class InstanceDevicesComponent implements OnChanges {
instance = input.required<VirtualizationInstance>();
export class InstanceDevicesComponent {
protected readonly isLoadingDevices = this.instanceStore.isLoadingDevices;

devices = signal<VirtualizationDevice[]>([]);
isLoading = signal<boolean>(false);
protected readonly shownDevices = computed(() => {
return this.instanceStore.selectedInstanceDevices().filter((device) => {
return [VirtualizationDeviceType.Usb, VirtualizationDeviceType.Gpu].includes(device.dev_type);
});
});

constructor(
private instanceStore: VirtualizationInstancesStore,
private dialog: DialogService,
private translate: TranslateService,
private snackbar: SnackbarService,
private ws: WebSocketService,
private loader: AppLoaderService,
private errorHandler: ErrorHandlerService,
) {}

ngOnChanges(changes: IxSimpleChanges<this>): void {
if (!(changes.instance.currentValue as unknown as VirtualizationInstance).id) {
return;
}
protected getDeviceDescription(device: VirtualizationDevice): string {
const type = virtualizationDeviceTypeLabels.has(device.dev_type)
? virtualizationDeviceTypeLabels.get(device.dev_type)
: device.dev_type;

// TODO: Get better names.
const description = device.name;

return `${type}: ${description}`;
}

protected deleteProxyPressed(device: VirtualizationDevice): void {
this.dialog.confirm({
message: this.translate.instant('Are you sure you want to delete this device?'),
title: this.translate.instant('Delete Device'),
})
.pipe(
switchMap((confirmed) => {
if (!confirmed) {
return EMPTY;
}

this.loadDevices();
return this.deleteDevice(device);
}),
untilDestroyed(this),
)
.subscribe();
}

loadDevices(): void {
this.isLoading.set(true);
this.ws.call('virt.instance.device_list', [this.instance().id])
.pipe(untilDestroyed(this))
.subscribe({
next: (devices) => {
this.devices.set(devices);
this.isLoading.set(false);
},
error: (error: unknown) => {
this.errorHandler.showErrorModal(error);
this.isLoading.set(false);
},
});
private deleteDevice(proxy: VirtualizationDevice): Observable<unknown> {
return this.ws.call('virt.instance.device_delete', [this.instanceStore.selectedInstance().id, proxy.name]).pipe(
this.loader.withLoader(),
this.errorHandler.catchError(),
tap(() => {
this.snackbar.success(this.translate.instant('Device deleted'));
this.instanceStore.loadDevices();
}),
);
}
}
Loading

0 comments on commit 572e9e1

Please sign in to comment.