You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@texera.apache.org by gi...@apache.org on 2026/07/04 04:13:59 UTC
(texera) branch gh-readonly-queue/main/pr-6082-93f4b6838f64550771be527e53038d5214c6981c created (now 3dd4c2b57d)
This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a change to branch gh-readonly-queue/main/pr-6082-93f4b6838f64550771be527e53038d5214c6981c
in repository https://gitbox.apache.org/repos/asf/texera.git
at 3dd4c2b57d refactor(frontend): extract shared computing-unit create modal component (#6082)
This branch includes the following new commits:
new 3dd4c2b57d refactor(frontend): extract shared computing-unit create modal component (#6082)
The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
(texera) 01/01: refactor(frontend): extract shared computing-unit create modal component (#6082)
Posted by gi...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a commit to branch gh-readonly-queue/main/pr-6082-93f4b6838f64550771be527e53038d5214c6981c
in repository https://gitbox.apache.org/repos/asf/texera.git
commit 3dd4c2b57d03c0029f72f3f6e15f374650f3179c
Author: Kunwoo (Chris) <14...@users.noreply.github.com>
AuthorDate: Sat Jul 4 00:13:37 2026 -0400
refactor(frontend): extract shared computing-unit create modal component (#6082)
### What changes were proposed in this PR?
This is a refactor PR that extracts the "create computing unit" modal,
previously duplicated between the workspace power button
(`computing-unit-selection.component`) and the dashboard Computing Units
page (`user-computing-unit.component`), into a new shared standalone
component `ComputingUnitCreateModalComponent` under
`frontend/src/app/common/component/computing-unit-create-modal/`. The
shared component owns the whole form (unit type, name, CPU/memory/GPU
selects, JVM memory slider, shared memory size, local URI), the option
fetching from
`getComputingUnitTypes()`/`getComputingUnitLimitOptions()`, validation,
and the create call. The workspace host selects the newly created unit
for the current workflow, and the dashboard host refreshes its list.
Net effect: 13 files changed, ~460 lines of duplicated TS/HTML/SCSS
removed, no user-visible behavior change.
### Any related issues, documentation, discussions?
Closes #6081
### How was this PR tested?
Since this is a refactoring, tested whether the behavior of creating a
CU hasn't changed.
### Was this PR authored or co-authored using generative AI tooling?
Generated-by: Claude Code, Claude Fable 5
---------
Signed-off-by: Kunwoo (Chris) <14...@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <17...@users.noreply.github.com>
---
.../computing-unit-create-modal.component.html} | 38 +-
.../computing-unit-create-modal.component.scss} | 11 -
.../computing-unit-create-modal.component.spec.ts | 507 +++++++++++++++++++++
.../computing-unit-create-modal.component.ts} | 189 +++-----
.../mock-computing-unit-status.service.ts | 2 +
.../src/app/common/util/computing-unit.util.ts | 4 +
.../user-computing-unit.component.html | 180 +-------
.../user-computing-unit.component.scss | 70 ---
.../user-computing-unit.component.spec.ts | 21 +
.../user-computing-unit.component.ts | 222 +--------
.../component/menu/menu.component.spec.ts | 5 +-
.../app/workspace/component/menu/menu.component.ts | 7 +-
.../computing-unit-selection.component.html | 180 +-------
.../computing-unit-selection.component.scss | 68 +--
.../computing-unit-selection.component.spec.ts | 83 ++++
.../computing-unit-selection.component.ts | 223 ++-------
16 files changed, 748 insertions(+), 1062 deletions(-)
diff --git a/frontend/src/app/dashboard/component/user/user-computing-unit/user-computing-unit.component.html b/frontend/src/app/common/component/computing-unit-create-modal/computing-unit-create-modal.component.html
similarity index 85%
copy from frontend/src/app/dashboard/component/user/user-computing-unit/user-computing-unit.component.html
copy to frontend/src/app/common/component/computing-unit-create-modal/computing-unit-create-modal.component.html
index 4a17246a18..df5f725c9c 100644
--- a/frontend/src/app/dashboard/component/user/user-computing-unit/user-computing-unit.component.html
+++ b/frontend/src/app/common/component/computing-unit-create-modal/computing-unit-create-modal.component.html
@@ -17,44 +17,8 @@
under the License.
-->
-<div class="section-container subsection-grid-container">
- <nz-card class="section-title">
- <h2 class="page-title">Computing Units</h2>
- <div class="button-group">
- <button
- nz-button
- class="create-btn"
- (click)="showAddComputeUnitModalVisible()"
- title="Create Computing Unit">
- <i
- nz-icon
- nzType="file-add"
- nzTheme="outline"></i>
- <span>Create Computing Unit</span>
- </button>
- </div>
- </nz-card>
-
- <nz-card
- class="section-list-container"
- [nzBodyStyle]="{ height: '100%'}">
- <cdk-virtual-scroll-viewport
- itemSize="70"
- class="virtual-scroll-container">
- <nz-list>
- <ng-container *cdkVirtualFor="let entry of entries">
- <texera-user-computing-unit-list-item
- (deleted)="terminateComputingUnit(entry.computingUnit.computingUnit.cuid)"
- [entry]="entry.computingUnit"></texera-user-computing-unit-list-item>
- </ng-container>
- </nz-list>
- </cdk-virtual-scroll-viewport>
- </nz-card>
-</div>
-
-<!-- Panel for creating the computing unit -->
<nz-modal
- [nzVisible]="addComputeUnitModalVisible"
+ [nzVisible]="visible"
[nzTitle]="getCreateModalTitle()"
[nzContent]="addComputeUnitModalContent"
[nzFooter]="addComputeUnitModalFooter"
diff --git a/frontend/src/app/dashboard/component/user/user-computing-unit/user-computing-unit.component.scss b/frontend/src/app/common/component/computing-unit-create-modal/computing-unit-create-modal.component.scss
similarity index 89%
copy from frontend/src/app/dashboard/component/user/user-computing-unit/user-computing-unit.component.scss
copy to frontend/src/app/common/component/computing-unit-create-modal/computing-unit-create-modal.component.scss
index 6ae48a1222..ba2b25415b 100644
--- a/frontend/src/app/dashboard/component/user/user-computing-unit/user-computing-unit.component.scss
+++ b/frontend/src/app/common/component/computing-unit-create-modal/computing-unit-create-modal.component.scss
@@ -17,17 +17,6 @@
* under the License.
*/
-@use "../../dashboard.component.scss" as *;
-@use "../../section-style" as *;
-@use "../../button-style" as *;
-
-.subsection-grid-container {
- min-width: 100%;
- width: 100%;
- min-height: 100%;
- height: 100%;
-}
-
.memory-selection,
.cpu-selection,
.gpu-selection {
diff --git a/frontend/src/app/common/component/computing-unit-create-modal/computing-unit-create-modal.component.spec.ts b/frontend/src/app/common/component/computing-unit-create-modal/computing-unit-create-modal.component.spec.ts
new file mode 100644
index 0000000000..027c62ee7a
--- /dev/null
+++ b/frontend/src/app/common/component/computing-unit-create-modal/computing-unit-create-modal.component.spec.ts
@@ -0,0 +1,507 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { ApplicationRef, DebugElement, getDebugNode } from "@angular/core";
+import { NgModel } from "@angular/forms";
+import { CdkVirtualScrollViewport } from "@angular/cdk/scrolling";
+import { ComponentFixture, TestBed } from "@angular/core/testing";
+import { HttpClientTestingModule } from "@angular/common/http/testing";
+import { NzModalService } from "ng-zorro-antd/modal";
+import { NoopAnimationsModule } from "@angular/platform-browser/animations";
+import { of, throwError } from "rxjs";
+import type { Mocked } from "vitest";
+import { ComputingUnitCreateModalComponent } from "./computing-unit-create-modal.component";
+import { WorkflowComputingUnitManagingService } from "../../service/computing-unit/workflow-computing-unit/workflow-computing-unit-managing.service";
+import { ComputingUnitStatusService } from "../../service/computing-unit/computing-unit-status/computing-unit-status.service";
+import { MockComputingUnitStatusService } from "../../service/computing-unit/computing-unit-status/mock-computing-unit-status.service";
+import { NotificationService } from "../../service/notification/notification.service";
+import { DashboardWorkflowComputingUnit, WorkflowComputingUnitType } from "../../type/workflow-computing-unit";
+import { commonTestProviders } from "../../testing/test-utils";
+import { buildLocalComputingUnitUri, getJvmMemorySliderConfig } from "../../util/computing-unit.util";
+
+describe("ComputingUnitCreateModalComponent", () => {
+ let component: ComputingUnitCreateModalComponent;
+ let fixture: ComponentFixture<ComputingUnitCreateModalComponent>;
+ let mockComputingUnitService: Mocked<WorkflowComputingUnitManagingService>;
+ let mockNotificationService: Mocked<NotificationService>;
+
+ const createdUnit = { computingUnit: { cuid: 42 } } as unknown as DashboardWorkflowComputingUnit;
+
+ beforeEach(async () => {
+ mockComputingUnitService = {
+ getComputingUnitTypes: vi.fn(),
+ getComputingUnitLimitOptions: vi.fn(),
+ createKubernetesBasedComputingUnit: vi.fn(),
+ createLocalComputingUnit: vi.fn(),
+ } as unknown as Mocked<WorkflowComputingUnitManagingService>;
+ mockComputingUnitService.getComputingUnitTypes.mockReturnValue(of({ typeOptions: [] }));
+ mockComputingUnitService.getComputingUnitLimitOptions.mockReturnValue(
+ of({ cpuLimitOptions: [], memoryLimitOptions: [], gpuLimitOptions: [] })
+ );
+ mockComputingUnitService.createKubernetesBasedComputingUnit.mockReturnValue(of(createdUnit));
+ mockComputingUnitService.createLocalComputingUnit.mockReturnValue(of(createdUnit));
+
+ mockNotificationService = {
+ success: vi.fn(),
+ error: vi.fn(),
+ info: vi.fn(),
+ } as unknown as Mocked<NotificationService>;
+
+ await TestBed.configureTestingModule({
+ providers: [
+ // The real NzModalService is required here: the declarative <nz-modal>
+ // in this component's template delegates opening to NzModalService.create(),
+ // so a stub breaks every test that renders the modal open.
+ NzModalService,
+ { provide: WorkflowComputingUnitManagingService, useValue: mockComputingUnitService },
+ { provide: NotificationService, useValue: mockNotificationService },
+ { provide: ComputingUnitStatusService, useClass: MockComputingUnitStatusService },
+ ...commonTestProviders,
+ ],
+ imports: [ComputingUnitCreateModalComponent, HttpClientTestingModule, NoopAnimationsModule],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(ComputingUnitCreateModalComponent);
+ component = fixture.componentInstance;
+ });
+
+ it("should create", () => {
+ fixture.detectChanges();
+ expect(component).toBeTruthy();
+ });
+
+ it("prefers the kubernetes type when available", () => {
+ mockComputingUnitService.getComputingUnitTypes.mockReturnValue(
+ of({ typeOptions: ["local", "kubernetes"] as WorkflowComputingUnitType[] })
+ );
+ fixture.detectChanges();
+ expect(component.selectedComputingUnitType).toBe("kubernetes");
+ });
+
+ it("falls back to the first available type when kubernetes is absent", () => {
+ mockComputingUnitService.getComputingUnitTypes.mockReturnValue(
+ of({ typeOptions: ["local"] as WorkflowComputingUnitType[] })
+ );
+ fixture.detectChanges();
+ expect(component.selectedComputingUnitType).toBe("local");
+ });
+
+ it("applies fetched limit options with first-option defaults", () => {
+ mockComputingUnitService.getComputingUnitLimitOptions.mockReturnValue(
+ of({ cpuLimitOptions: ["2", "4"], memoryLimitOptions: ["2Gi", "4Gi"], gpuLimitOptions: ["0", "1"] })
+ );
+ fixture.detectChanges();
+ expect(component.cpuOptions).toEqual(["2", "4"]);
+ expect(component.selectedCpu).toBe("2");
+ expect(component.selectedMemory).toBe("2Gi");
+ expect(component.selectedGpu).toBe("0");
+ expect(component.showGpuSelection()).toBe(true);
+ });
+
+ it("falls back to hardcoded defaults when option lists are empty", () => {
+ fixture.detectChanges();
+ expect(component.selectedCpu).toBe("1");
+ expect(component.selectedMemory).toBe("1Gi");
+ expect(component.selectedGpu).toBe("0");
+ expect(component.showGpuSelection()).toBe(false);
+ });
+
+ it("rejects a kubernetes create with an empty name but still closes the modal on Ok", () => {
+ fixture.detectChanges();
+ component.selectedComputingUnitType = "kubernetes";
+ component.newComputingUnitName = " ";
+ const visibleSpy = vi.fn();
+ component.visibleChange.subscribe(visibleSpy);
+
+ component.handleAddComputeUnitModalOk();
+
+ expect(mockNotificationService.error).toHaveBeenCalledWith("Name of the computing unit cannot be empty");
+ expect(mockComputingUnitService.createKubernetesBasedComputingUnit).not.toHaveBeenCalled();
+ expect(visibleSpy).toHaveBeenCalledWith(false);
+ });
+
+ it("rejects a local create with a blank URI", () => {
+ fixture.detectChanges();
+ component.selectedComputingUnitType = "local";
+ component.newComputingUnitName = "My Local Unit";
+ component.localComputingUnitUri = " ";
+
+ component.startComputingUnit();
+
+ expect(mockNotificationService.error).toHaveBeenCalledWith("URI for local computing unit cannot be empty");
+ expect(mockComputingUnitService.createLocalComputingUnit).not.toHaveBeenCalled();
+ });
+
+ it("rejects a create without a selected type", () => {
+ fixture.detectChanges();
+ component.selectedComputingUnitType = undefined;
+
+ component.startComputingUnit();
+
+ expect(mockNotificationService.error).toHaveBeenCalledWith("Please select a valid computing unit type");
+ expect(mockComputingUnitService.createKubernetesBasedComputingUnit).not.toHaveBeenCalled();
+ expect(mockComputingUnitService.createLocalComputingUnit).not.toHaveBeenCalled();
+ });
+
+ it("emits unitCreated and a success toast on a successful kubernetes create", () => {
+ fixture.detectChanges();
+ component.selectedComputingUnitType = "kubernetes";
+ component.newComputingUnitName = "GPU Test Unit";
+ component.selectedCpu = "2";
+ component.selectedMemory = "4Gi";
+ component.selectedGpu = "0";
+ component.selectedJvmMemorySize = "2G";
+ component.shmSizeValue = 128;
+ component.shmSizeUnit = "Mi";
+ const unitCreatedSpy = vi.fn();
+ component.unitCreated.subscribe(unitCreatedSpy);
+
+ component.startComputingUnit();
+
+ expect(mockComputingUnitService.createKubernetesBasedComputingUnit).toHaveBeenCalledWith(
+ "GPU Test Unit",
+ "2",
+ "4Gi",
+ "0",
+ "2G",
+ "128Mi"
+ );
+ expect(mockNotificationService.success).toHaveBeenCalledWith("Successfully created the new compute unit");
+ expect(unitCreatedSpy).toHaveBeenCalledWith(createdUnit);
+ });
+
+ it("closes without creating on Cancel", () => {
+ fixture.detectChanges();
+ const visibleSpy = vi.fn();
+ component.visibleChange.subscribe(visibleSpy);
+
+ component.handleAddComputeUnitModalCancel();
+
+ expect(visibleSpy).toHaveBeenCalledWith(false);
+ expect(mockComputingUnitService.createKubernetesBasedComputingUnit).not.toHaveBeenCalled();
+ expect(mockComputingUnitService.createLocalComputingUnit).not.toHaveBeenCalled();
+ });
+
+ it("reconfigures the JVM memory slider when the memory selection changes", () => {
+ fixture.detectChanges();
+ component.selectedMemory = "4Gi";
+ component.onMemorySelectionChange();
+ const expected = getJvmMemorySliderConfig("4Gi");
+ expect(component.jvmMemoryMax).toBe(expected.jvmMemoryMax);
+ expect(component.showJvmMemorySlider).toBe(expected.showJvmMemorySlider);
+ expect(component.jvmMemorySteps).toEqual(expected.jvmMemorySteps);
+ });
+
+ it("flags shared memory larger than total memory", () => {
+ fixture.detectChanges();
+ component.selectedMemory = "1Gi";
+ component.shmSizeValue = 2;
+ component.shmSizeUnit = "Gi";
+ expect(component.isShmTooLarge()).toBe(true);
+
+ component.shmSizeValue = 64;
+ component.shmSizeUnit = "Mi";
+ expect(component.isShmTooLarge()).toBe(false);
+ });
+
+ it("initializes the local computing unit URI from the window location", () => {
+ fixture.detectChanges();
+ expect(component.localComputingUnitUri).toBe(buildLocalComputingUnitUri(window.location));
+ });
+
+ it("builds the local URI with and without a port", () => {
+ expect(buildLocalComputingUnitUri({ protocol: "http:", hostname: "localhost", port: "8080" })).toBe(
+ "http://localhost:8080/wsapi"
+ );
+ expect(buildLocalComputingUnitUri({ protocol: "https:", hostname: "texera.io", port: "" })).toBe(
+ "https://texera.io/wsapi"
+ );
+ });
+
+ it("toasts when fetching computing unit types fails", () => {
+ mockComputingUnitService.getComputingUnitTypes.mockReturnValue(throwError(() => new Error("boom")));
+ fixture.detectChanges();
+ expect(mockNotificationService.error).toHaveBeenCalledWith(
+ expect.stringContaining("Failed to fetch computing unit types")
+ );
+ });
+
+ it("toasts when fetching resource limit options fails", () => {
+ mockComputingUnitService.getComputingUnitLimitOptions.mockReturnValue(throwError(() => new Error("boom")));
+ fixture.detectChanges();
+ expect(mockNotificationService.error).toHaveBeenCalledWith(
+ expect.stringContaining("Failed to fetch resource options")
+ );
+ });
+
+ it("toasts when the create request fails", () => {
+ fixture.detectChanges();
+ component.selectedComputingUnitType = "kubernetes";
+ component.newComputingUnitName = "Unit";
+ mockComputingUnitService.createKubernetesBasedComputingUnit.mockReturnValue(throwError(() => new Error("boom")));
+ const unitCreatedSpy = vi.fn();
+ component.unitCreated.subscribe(unitCreatedSpy);
+
+ component.startComputingUnit();
+
+ expect(mockNotificationService.error).toHaveBeenCalledWith(
+ expect.stringContaining("Failed to start computing unit")
+ );
+ expect(unitCreatedSpy).not.toHaveBeenCalled();
+ });
+
+ it("shows GPU selection for a single non-zero option and hides it for a single zero option", () => {
+ fixture.detectChanges();
+ component.gpuOptions = ["1"];
+ expect(component.showGpuSelection()).toBe(true);
+ component.gpuOptions = ["0"];
+ expect(component.showGpuSelection()).toBe(false);
+ });
+
+ it("snaps the JVM memory slider to the nearest valid step", () => {
+ fixture.detectChanges();
+ component.selectedMemory = "8Gi";
+ component.onMemorySelectionChange();
+
+ component.onJvmMemorySliderChange(component.jvmMemoryMax - 1);
+
+ expect(component.jvmMemorySteps).toContain(component.jvmMemorySliderValue);
+ expect(component.selectedJvmMemorySize).toBe(`${component.jvmMemorySliderValue}G`);
+ });
+
+ it("flags max JVM memory only when the slider is shown at its maximum", () => {
+ fixture.detectChanges();
+ component.selectedMemory = "8Gi";
+ component.onMemorySelectionChange();
+
+ component.onJvmMemorySliderChange(component.jvmMemoryMax);
+ expect(component.isMaxJvmMemorySelected()).toBe(true);
+
+ component.onJvmMemorySliderChange(component.jvmMemorySteps[0]);
+ expect(component.isMaxJvmMemorySelected()).toBe(false);
+ });
+
+ it("preserves a valid previous JVM selection when switching between large memory options", () => {
+ fixture.detectChanges();
+ component.selectedMemory = "8Gi";
+ component.onMemorySelectionChange();
+ component.onJvmMemorySliderChange(4);
+
+ component.selectedMemory = "4096Mi";
+ component.onMemorySelectionChange();
+
+ expect(component.jvmMemorySliderValue).toBe(4);
+ expect(component.selectedJvmMemorySize).toBe("4G");
+ });
+
+ it("resets the JVM selection when switching to an unrecognized memory unit", () => {
+ fixture.detectChanges();
+ component.selectedMemory = "8Gi";
+ component.onMemorySelectionChange();
+ component.onJvmMemorySliderChange(4);
+
+ component.selectedMemory = "2";
+ component.onMemorySelectionChange();
+
+ const expected = getJvmMemorySliderConfig("2");
+ expect(component.showJvmMemorySlider).toBe(expected.showJvmMemorySlider);
+ expect(component.jvmMemorySliderValue).toBe(expected.jvmMemorySliderValue);
+ });
+
+ describe("template rendering", () => {
+ afterEach(() => {
+ document.querySelectorAll(".cdk-overlay-container").forEach(el => el.remove());
+ });
+
+ it("renders the full kubernetes form with GPU select, shm warning, JVM slider and max-memory alert", () => {
+ mockComputingUnitService.getComputingUnitTypes.mockReturnValue(
+ of({ typeOptions: ["kubernetes", "local"] as WorkflowComputingUnitType[] })
+ );
+ mockComputingUnitService.getComputingUnitLimitOptions.mockReturnValue(
+ of({ cpuLimitOptions: ["1", "2"], memoryLimitOptions: ["8Gi", "16Gi"], gpuLimitOptions: ["0", "1"] })
+ );
+ fixture.detectChanges();
+ component.onJvmMemorySliderChange(component.jvmMemoryMax);
+ component.shmSizeValue = 16;
+ component.shmSizeUnit = "Gi";
+ component.visible = true;
+ fixture.detectChanges();
+
+ expect(document.querySelector(".create-compute-unit-container")).toBeTruthy();
+ expect(document.querySelector(".unit-name-input")).toBeTruthy();
+ expect(document.querySelector(".gpu-selection")).toBeTruthy();
+ expect(document.querySelector(".shm-warning")?.textContent).toContain(
+ "Shared memory cannot be greater than total memory."
+ );
+ expect(document.querySelector(".jvm-memory-slider")).toBeTruthy();
+ expect(document.querySelector("nz-alert")).toBeTruthy();
+ expect(document.querySelector(".unit-uri-input")).toBeNull();
+ });
+
+ it("renders the minimal kubernetes form without GPU select, warnings, or slider", () => {
+ mockComputingUnitService.getComputingUnitTypes.mockReturnValue(
+ of({ typeOptions: ["kubernetes"] as WorkflowComputingUnitType[] })
+ );
+ mockComputingUnitService.getComputingUnitLimitOptions.mockReturnValue(
+ of({ cpuLimitOptions: ["1"], memoryLimitOptions: ["1Gi"], gpuLimitOptions: ["0"] })
+ );
+ fixture.detectChanges();
+ component.visible = true;
+ fixture.detectChanges();
+
+ expect(document.querySelector(".create-compute-unit-container")).toBeTruthy();
+ expect(document.querySelector(".gpu-selection")).toBeNull();
+ expect(document.querySelector(".shm-warning")).toBeNull();
+ expect(document.querySelector(".jvm-memory-slider")).toBeNull();
+ expect(document.querySelector("nz-alert")).toBeNull();
+ });
+
+ it("renders the local form with name and URI inputs", () => {
+ mockComputingUnitService.getComputingUnitTypes.mockReturnValue(
+ of({ typeOptions: ["local"] as WorkflowComputingUnitType[] })
+ );
+ fixture.detectChanges();
+ component.visible = true;
+ fixture.detectChanges();
+
+ expect(document.querySelector(".unit-uri-input")).toBeTruthy();
+ expect(document.querySelector(".memory-selection")).toBeNull();
+ });
+
+ // The modal body renders inside the CDK overlay, which is a view attached to
+ // ApplicationRef rather than to this fixture — tick() is what re-renders it.
+ const tick = (): void => {
+ fixture.detectChanges();
+ TestBed.inject(ApplicationRef).tick();
+ };
+
+ const setInputValue = (selector: string, value: string): void => {
+ const input = document.querySelector<HTMLInputElement>(selector)!;
+ input.value = value;
+ input.dispatchEvent(new Event("input", { bubbles: true }));
+ tick();
+ };
+
+ const clickSelectOption = (selectClass: string, optionText: string): void => {
+ const select = document.querySelector<HTMLElement>(`nz-select.${selectClass}`)!;
+ select.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ tick();
+ // jsdom performs no layout, so the dropdown's virtual-scroll viewport
+ // measures 0px and renders no options; force the range to materialize them.
+ const viewportEl = document.querySelector("cdk-virtual-scroll-viewport")!;
+ const viewport = (getDebugNode(viewportEl) as DebugElement).componentInstance as CdkVirtualScrollViewport;
+ viewport.setRenderedRange({ start: 0, end: 20 });
+ tick();
+ const option = Array.from(document.querySelectorAll<HTMLElement>(".ant-select-item-option")).find(
+ o => o.textContent?.trim() === optionText
+ )!;
+ option.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ tick();
+ };
+
+ it("updates the form state through the rendered controls", () => {
+ mockComputingUnitService.getComputingUnitTypes.mockReturnValue(
+ of({ typeOptions: ["kubernetes", "local"] as WorkflowComputingUnitType[] })
+ );
+ mockComputingUnitService.getComputingUnitLimitOptions.mockReturnValue(
+ of({ cpuLimitOptions: ["1", "2"], memoryLimitOptions: ["8Gi", "16Gi"], gpuLimitOptions: ["0", "1"] })
+ );
+ fixture.detectChanges();
+ component.visible = true;
+ fixture.detectChanges();
+
+ setInputValue(".unit-name-input", "Typed Name");
+ expect(component.newComputingUnitName).toBe("Typed Name");
+
+ setInputValue(".shm-size-input", "128");
+ expect(component.shmSizeValue).toBe(128);
+
+ clickSelectOption("memory-selection", "16Gi");
+ expect(component.selectedMemory).toBe("16Gi");
+
+ clickSelectOption("cpu-selection", "2");
+ expect(component.selectedCpu).toBe("2");
+
+ clickSelectOption("gpu-selection", "1");
+ expect(component.selectedGpu).toBe("1");
+
+ clickSelectOption("shm-unit-select", "Gi");
+ expect(component.shmSizeUnit).toBe("Gi");
+
+ // jsdom cannot produce meaningful slider drag geometry; emit the view-model
+ // update at the NgModel seam, which is what a real drag/keypress triggers.
+ const sliderEl = document.querySelector("nz-slider")!;
+ const sliderNgModel = (getDebugNode(sliderEl) as DebugElement).injector.get(NgModel);
+ sliderNgModel.viewToModelUpdate(component.jvmMemoryMax);
+ tick();
+ expect(component.jvmMemorySliderValue).toBe(component.jvmMemoryMax);
+ expect(component.selectedJvmMemorySize).toBe(`${component.jvmMemoryMax}G`);
+
+ clickSelectOption("type-selection", "Local");
+ expect(component.selectedComputingUnitType).toBe("local");
+ fixture.detectChanges();
+
+ setInputValue(".unit-name-input", "Local Name");
+ expect(component.newComputingUnitName).toBe("Local Name");
+
+ setInputValue(".unit-uri-input", "http://localhost:8085");
+ expect(component.localComputingUnitUri).toBe("http://localhost:8085");
+ });
+
+ it("falls back to a slider minimum of 2 when no JVM steps are configured", () => {
+ mockComputingUnitService.getComputingUnitTypes.mockReturnValue(
+ of({ typeOptions: ["kubernetes"] as WorkflowComputingUnitType[] })
+ );
+ fixture.detectChanges();
+ component.showJvmMemorySlider = true;
+ component.jvmMemorySteps = [];
+ component.visible = true;
+ fixture.detectChanges();
+
+ const sliderEl = document.querySelector("nz-slider")!;
+ const slider = (getDebugNode(sliderEl) as DebugElement).componentInstance as { nzMin: number };
+ expect(slider.nzMin).toBe(2);
+ });
+
+ it("wires the footer buttons to cancel and create", () => {
+ mockComputingUnitService.getComputingUnitTypes.mockReturnValue(
+ of({ typeOptions: ["kubernetes"] as WorkflowComputingUnitType[] })
+ );
+ fixture.detectChanges();
+ component.visible = true;
+ fixture.detectChanges();
+ const visibleSpy = vi.fn();
+ const unitCreatedSpy = vi.fn();
+ component.visibleChange.subscribe(visibleSpy);
+ component.unitCreated.subscribe(unitCreatedSpy);
+ const buttons = document.querySelectorAll<HTMLButtonElement>(".ant-modal-footer button");
+ expect(buttons.length).toBe(2);
+
+ buttons[0].click();
+ expect(visibleSpy).toHaveBeenCalledWith(false);
+ expect(unitCreatedSpy).not.toHaveBeenCalled();
+
+ component.visible = true;
+ fixture.detectChanges();
+ buttons[1].click();
+ expect(unitCreatedSpy).toHaveBeenCalledWith(createdUnit);
+ expect(visibleSpy).toHaveBeenCalledTimes(2);
+ });
+ });
+});
diff --git a/frontend/src/app/dashboard/component/user/user-computing-unit/user-computing-unit.component.ts b/frontend/src/app/common/component/computing-unit-create-modal/computing-unit-create-modal.component.ts
similarity index 70%
copy from frontend/src/app/dashboard/component/user/user-computing-unit/user-computing-unit.component.ts
copy to frontend/src/app/common/component/computing-unit-create-modal/computing-unit-create-modal.component.ts
index b7400f036b..930bde396b 100644
--- a/frontend/src/app/dashboard/component/user/user-computing-unit/user-computing-unit.component.ts
+++ b/frontend/src/app/common/component/computing-unit-create-modal/computing-unit-create-modal.component.ts
@@ -17,85 +17,73 @@
* under the License.
*/
-import { Component, Input, OnInit } from "@angular/core";
-import { ComputingUnitStatusService } from "../../../../common/service/computing-unit/computing-unit-status/computing-unit-status.service";
-import { DashboardEntry } from "../../../type/dashboard-entry";
-import {
- DashboardWorkflowComputingUnit,
- WorkflowComputingUnitType,
-} from "../../../../common/type/workflow-computing-unit";
-import { extractErrorMessage } from "../../../../common/util/error";
-import { NotificationService } from "../../../../common/service/notification/notification.service";
-import { NzModalService, NzModalComponent } from "ng-zorro-antd/modal";
+import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core";
+import { NgFor, NgIf, TitleCasePipe } from "@angular/common";
+import { FormsModule } from "@angular/forms";
import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy";
-import { UserService } from "../../../../common/service/user/user.service";
-import { WorkflowComputingUnitManagingService } from "../../../../common/service/computing-unit/workflow-computing-unit/workflow-computing-unit-managing.service";
-import {
- parseResourceUnit,
- parseResourceNumber,
- findNearestValidStep,
- unitTypeMessageTemplate,
- isComputingUnitShmTooLarge,
- getJvmMemorySliderConfig,
-} from "../../../../common/util/computing-unit.util";
-import { ComputingUnitActionsService } from "../../../../common/service/computing-unit/computing-unit-actions/computing-unit-actions.service";
-import { interval } from "rxjs";
-import { NzCardComponent } from "ng-zorro-antd/card";
-import { NzSpaceCompactItemDirective } from "ng-zorro-antd/space";
+import { NzModalComponent } from "ng-zorro-antd/modal";
+import { NzSelectComponent, NzOptionComponent } from "ng-zorro-antd/select";
+import { NzInputDirective } from "ng-zorro-antd/input";
import { NzButtonComponent } from "ng-zorro-antd/button";
import { NzWaveDirective } from "ng-zorro-antd/core/wave";
import { ɵNzTransitionPatchDirective } from "ng-zorro-antd/core/transition-patch";
import { NzIconDirective } from "ng-zorro-antd/icon";
-import { ɵɵCdkVirtualScrollViewport, ɵɵCdkFixedSizeVirtualScroll, ɵɵCdkVirtualForOf } from "@angular/cdk/overlay";
-import { NzListComponent } from "ng-zorro-antd/list";
-import { UserComputingUnitListItemComponent } from "./user-computing-unit-list-item/user-computing-unit-list-item.component";
-import { NzSelectComponent, NzOptionComponent } from "ng-zorro-antd/select";
-import { FormsModule } from "@angular/forms";
-import { NgFor, NgIf, TitleCasePipe } from "@angular/common";
-import { NzInputDirective } from "ng-zorro-antd/input";
import { NzTooltipDirective } from "ng-zorro-antd/tooltip";
import { NzSliderComponent } from "ng-zorro-antd/slider";
import { NzAlertComponent } from "ng-zorro-antd/alert";
+import { WorkflowComputingUnitManagingService } from "../../service/computing-unit/workflow-computing-unit/workflow-computing-unit-managing.service";
+import { ComputingUnitActionsService } from "../../service/computing-unit/computing-unit-actions/computing-unit-actions.service";
+import { NotificationService } from "../../service/notification/notification.service";
+import { DashboardWorkflowComputingUnit, WorkflowComputingUnitType } from "../../type/workflow-computing-unit";
+import { extractErrorMessage } from "../../util/error";
+import {
+ buildLocalComputingUnitUri,
+ findNearestValidStep,
+ getJvmMemorySliderConfig,
+ isComputingUnitShmTooLarge,
+ parseResourceNumber,
+ parseResourceUnit,
+ unitTypeMessageTemplate,
+} from "../../util/computing-unit.util";
+/**
+ * The "create computing unit" modal shared by the workspace power button and
+ * the dashboard Computing Units page. Owns the whole form (type, name,
+ * CPU/memory/GPU, JVM memory, shared memory, local URI), fetches the
+ * available options, validates, and performs the create call. Hosts bind
+ * [(visible)] and handle (unitCreated) for their post-create action.
+ * Hosts must bind `visible` two-way (`[(visible)]`) — the modal writes
+ * `visible = false` and emits `visibleChange` when closing, so a one-way
+ * `[visible]` binding would desync.
+ */
@UntilDestroy()
@Component({
- selector: "texera-computing-unit-section",
- templateUrl: "user-computing-unit.component.html",
- styleUrls: ["user-computing-unit.component.scss"],
+ selector: "texera-computing-unit-create-modal",
+ templateUrl: "./computing-unit-create-modal.component.html",
+ styleUrls: ["./computing-unit-create-modal.component.scss"],
imports: [
- NzCardComponent,
- NzSpaceCompactItemDirective,
- NzButtonComponent,
- NzWaveDirective,
- ɵNzTransitionPatchDirective,
- NzIconDirective,
- ɵɵCdkVirtualScrollViewport,
- ɵɵCdkFixedSizeVirtualScroll,
- NzListComponent,
- ɵɵCdkVirtualForOf,
- UserComputingUnitListItemComponent,
NzModalComponent,
NzSelectComponent,
- FormsModule,
- NgFor,
NzOptionComponent,
- NgIf,
NzInputDirective,
+ NzButtonComponent,
+ NzWaveDirective,
+ ɵNzTransitionPatchDirective,
+ NzIconDirective,
NzTooltipDirective,
NzSliderComponent,
NzAlertComponent,
+ FormsModule,
+ NgFor,
+ NgIf,
TitleCasePipe,
],
})
-export class UserComputingUnitComponent implements OnInit {
- public entries: DashboardEntry[] = [];
- public isLogin = this.userService.isLogin();
- public currentUid = this.userService.getCurrentUser()?.uid;
+export class ComputingUnitCreateModalComponent implements OnInit {
+ @Input() visible = false;
+ @Output() visibleChange = new EventEmitter<boolean>();
+ @Output() unitCreated = new EventEmitter<DashboardWorkflowComputingUnit>();
- allComputingUnits: DashboardWorkflowComputingUnit[] = [];
-
- // variables for creating a computing unit
- addComputeUnitModalVisible = false;
newComputingUnitName: string = "";
selectedMemory: string = "";
selectedCpu: string = "";
@@ -121,24 +109,14 @@ export class UserComputingUnitComponent implements OnInit {
gpuOptions: string[] = []; // Add GPU options array
constructor(
- private notificationService: NotificationService,
- private modalService: NzModalService,
- private userService: UserService,
private computingUnitService: WorkflowComputingUnitManagingService,
- private computingUnitStatusService: ComputingUnitStatusService,
+ private notificationService: NotificationService,
private computingUnitActionsService: ComputingUnitActionsService
- ) {
- this.userService
- .userChanged()
- .pipe(untilDestroyed(this))
- .subscribe(() => {
- this.isLogin = this.userService.isLogin();
- this.currentUid = this.userService.getCurrentUser()?.uid;
- });
- }
+ ) {}
- ngOnInit() {
- this.localComputingUnitUri = `${window.location.protocol}//${window.location.hostname}${window.location.port ? `:${window.location.port}` : ""}/wsapi`;
+ ngOnInit(): void {
+ // Fetch available computing unit types
+ this.localComputingUnitUri = buildLocalComputingUnitUri(window.location);
this.newComputingUnitName = "My Computing Unit";
this.computingUnitService
.getComputingUnitTypes()
@@ -177,33 +155,35 @@ export class UserComputingUnitComponent implements OnInit {
error: (err: unknown) =>
this.notificationService.error(`Failed to fetch resource options: ${extractErrorMessage(err)}`),
});
+ }
- this.computingUnitStatusService
- .getAllComputingUnits()
- .pipe(untilDestroyed(this))
- .subscribe(units => {
- this.allComputingUnits = units;
- this.entries = units.map(u => new DashboardEntry(u));
- });
+ // Determines if the GPU selection dropdown should be shown
+ showGpuSelection(): boolean {
+ // Don't show GPU selection if there are no options or only "0" option
+ return this.gpuOptions.length > 1 || (this.gpuOptions.length === 1 && this.gpuOptions[0] !== "0");
+ }
- interval(1000)
- .pipe(untilDestroyed(this))
- .subscribe(() => {
- this.computingUnitStatusService.refreshComputingUnitList();
- });
+ handleAddComputeUnitModalOk(): void {
+ this.startComputingUnit();
+ this.closeModal();
}
- terminateComputingUnit(cuid: number): void {
- const unit = this.allComputingUnits.find(u => u.computingUnit.cuid === cuid);
+ handleAddComputeUnitModalCancel(): void {
+ this.closeModal();
+ }
- if (!unit) {
- this.notificationService.error("Invalid computing unit.");
- return;
- }
+ private closeModal(): void {
+ this.visible = false;
+ this.visibleChange.emit(false);
+ }
- this.computingUnitActionsService.confirmAndTerminate(cuid, unit);
+ isShmTooLarge(): boolean {
+ return isComputingUnitShmTooLarge(this.selectedMemory, this.shmSizeValue, this.shmSizeUnit);
}
+ /**
+ * Start a new computing unit.
+ */
startComputingUnit(): void {
if (this.selectedComputingUnitType === "kubernetes" && this.newComputingUnitName.trim() === "") {
this.notificationService.error("Name of the computing unit cannot be empty");
@@ -235,37 +215,16 @@ export class UserComputingUnitComponent implements OnInit {
.create(request)
.pipe(untilDestroyed(this))
.subscribe({
- next: () => {
+ next: (unit: DashboardWorkflowComputingUnit) => {
this.notificationService.success("Successfully created the new compute unit");
- this.computingUnitStatusService.refreshComputingUnitList();
+ this.unitCreated.emit(unit);
},
error: (err: unknown) =>
this.notificationService.error(`Failed to start computing unit: ${extractErrorMessage(err)}`),
});
}
- showGpuSelection(): boolean {
- // Don't show GPU selection if there are no options or only "0" option
- return this.gpuOptions.length > 1 || (this.gpuOptions.length === 1 && this.gpuOptions[0] !== "0");
- }
-
- showAddComputeUnitModalVisible(): void {
- this.addComputeUnitModalVisible = true;
- }
-
- handleAddComputeUnitModalOk(): void {
- this.startComputingUnit();
- this.addComputeUnitModalVisible = false;
- }
-
- handleAddComputeUnitModalCancel(): void {
- this.addComputeUnitModalVisible = false;
- }
-
- isShmTooLarge(): boolean {
- return isComputingUnitShmTooLarge(this.selectedMemory, this.shmSizeValue, this.shmSizeUnit);
- }
-
+ // Called when the component initializes
updateJvmMemorySlider(): void {
this.resetJvmMemorySlider();
}
@@ -277,6 +236,7 @@ export class UserComputingUnitComponent implements OnInit {
this.selectedJvmMemorySize = `${validStep}G`;
}
+ // Check if the maximum JVM memory value is selected
isMaxJvmMemorySelected(): boolean {
// Only show warning for larger memory sizes (>=4GB) where the slider is shown
// AND when the maximum value is selected
@@ -295,6 +255,7 @@ export class UserComputingUnitComponent implements OnInit {
this.selectedJvmMemorySize = config.selectedJvmMemorySize;
}
+ // Listen for memory selection changes
onMemorySelectionChange(): void {
// Store current JVM memory value for potential reuse
const previousJvmMemory = this.jvmMemorySliderValue;
diff --git a/frontend/src/app/common/service/computing-unit/computing-unit-status/mock-computing-unit-status.service.ts b/frontend/src/app/common/service/computing-unit/computing-unit-status/mock-computing-unit-status.service.ts
index d6b47dd308..821b8610b8 100644
--- a/frontend/src/app/common/service/computing-unit/computing-unit-status/mock-computing-unit-status.service.ts
+++ b/frontend/src/app/common/service/computing-unit/computing-unit-status/mock-computing-unit-status.service.ts
@@ -41,6 +41,8 @@ export class MockComputingUnitStatusService {
selectComputingUnit(): void {}
+ refreshComputingUnitList(): void {}
+
startPolling(): void {}
stopPolling(): void {}
diff --git a/frontend/src/app/common/util/computing-unit.util.ts b/frontend/src/app/common/util/computing-unit.util.ts
index 8028c881f8..65fe07b44c 100644
--- a/frontend/src/app/common/util/computing-unit.util.ts
+++ b/frontend/src/app/common/util/computing-unit.util.ts
@@ -329,6 +329,10 @@ interface JvmMemorySliderConfig {
selectedJvmMemorySize: string;
}
+export function buildLocalComputingUnitUri(location: { protocol: string; hostname: string; port: string }): string {
+ return `${location.protocol}//${location.hostname}${location.port ? `:${location.port}` : ""}/wsapi`;
+}
+
export const unitTypeMessageTemplate = {
local: {
createTitle: "Connect to a Local Computing Unit",
diff --git a/frontend/src/app/dashboard/component/user/user-computing-unit/user-computing-unit.component.html b/frontend/src/app/dashboard/component/user/user-computing-unit/user-computing-unit.component.html
index 4a17246a18..16a192c836 100644
--- a/frontend/src/app/dashboard/component/user/user-computing-unit/user-computing-unit.component.html
+++ b/frontend/src/app/dashboard/component/user/user-computing-unit/user-computing-unit.component.html
@@ -53,180 +53,6 @@
</div>
<!-- Panel for creating the computing unit -->
-<nz-modal
- [nzVisible]="addComputeUnitModalVisible"
- [nzTitle]="getCreateModalTitle()"
- [nzContent]="addComputeUnitModalContent"
- [nzFooter]="addComputeUnitModalFooter"
- (nzOnCancel)="handleAddComputeUnitModalCancel()">
- <ng-template #addComputeUnitModalTitle>Create Computing Unit</ng-template>
- <ng-template #addComputeUnitModalContent>
- <div class="create-compute-unit-container">
- <!-- Computing unit type selection -->
- <div class="select-unit">
- <span>Computing Unit Type</span>
- <nz-select
- class="type-selection"
- [(ngModel)]="selectedComputingUnitType">
- <nz-option
- *ngFor="let type of availableComputingUnitTypes"
- [nzValue]="type"
- [nzLabel]="type | titlecase">
- </nz-option>
- </nz-select>
- </div>
-
- <!-- Fields for Kubernetes computing units -->
- <ng-container *ngIf="selectedComputingUnitType === 'kubernetes'">
- <div class="select-unit name-field">
- <span>Computing Unit Name</span>
- <input
- [required]="true"
- nz-input
- placeholder="Enter the name of your computing unit (required)"
- [(ngModel)]="newComputingUnitName"
- class="unit-name-input" />
- </div>
- <div class="select-unit">
- <span>Select RAM Size</span>
- <nz-select
- class="memory-selection"
- [(ngModel)]="selectedMemory"
- (ngModelChange)="onMemorySelectionChange()">
- <nz-option
- *ngFor="let option of memoryOptions"
- [nzValue]="option"
- [nzLabel]="option">
- </nz-option>
- </nz-select>
- </div>
-
- <div class="select-unit">
- <span>Select #CPU Core(s)</span>
- <nz-select
- class="cpu-selection"
- [(ngModel)]="selectedCpu">
- <nz-option
- *ngFor="let option of cpuOptions"
- [nzValue]="option"
- [nzLabel]="option">
- </nz-option>
- </nz-select>
- </div>
-
- <div
- *ngIf="showGpuSelection()"
- class="select-unit">
- <span>Select #GPU(s)</span>
- <nz-select
- class="gpu-selection"
- [(ngModel)]="selectedGpu">
- <nz-option
- *ngFor="let option of gpuOptions"
- [nzValue]="option"
- [nzLabel]="option">
- </nz-option>
- </nz-select>
- </div>
-
- <div class="select-unit shared-memory-group">
- <span>
- Adjust the Shared Memory Size
- <i
- nz-icon
- nzType="info-circle"
- nz-tooltip
- [nzTooltipTitle]="
- 'Shared memory (/dev/shm) is used for inter-process communication. If you plan to run ML tasks (e.g., using PyTorch), consider increasing this size. Learn more: https://man7.org/linux/man-pages/man7/shm_overview.7.html'
- ">
- </i>
- </span>
- <div class="shm-input-row">
- <input
- nz-input
- type="number"
- min="0"
- [(ngModel)]="shmSizeValue"
- class="shm-size-input"
- placeholder="e.g. 512" />
- <nz-select
- class="shm-unit-select"
- [(ngModel)]="shmSizeUnit">
- <nz-option
- nzValue="Mi"
- nzLabel="Mi"></nz-option>
- <nz-option
- nzValue="Gi"
- nzLabel="Gi"></nz-option>
- </nz-select>
- </div>
- <div
- *ngIf="isShmTooLarge()"
- class="shm-warning">
- Shared memory cannot be greater than total memory.
- </div>
- </div>
-
- <div class="select-unit name-field">
- <span>JVM Memory Size: {{selectedJvmMemorySize}}</span>
- <nz-slider
- *ngIf="showJvmMemorySlider"
- class="jvm-memory-slider"
- [nzMarks]="jvmMemoryMarks"
- [nzMin]="jvmMemorySteps[0] || 2"
- [nzMax]="jvmMemoryMax"
- [nzStep]="null"
- [nzIncluded]="false"
- [ngModel]="jvmMemorySliderValue"
- (ngModelChange)="onJvmMemorySliderChange($event)">
- </nz-slider>
- <div
- *ngIf="isMaxJvmMemorySelected()"
- class="memory-warning">
- <nz-alert
- nzType="warning"
- nzMessage="Using maximum JVM memory may affect the performance of Python-based operators."
- nzShowIcon>
- </nz-alert>
- </div>
- </div>
- </ng-container>
-
- <!-- Fields for Local computing units -->
- <ng-container *ngIf="selectedComputingUnitType === 'local'">
- <div class="select-unit name-field">
- <span>Computing Unit Name</span>
- <input
- [required]="true"
- nz-input
- placeholder="Enter the name of your local computing unit (required)"
- [(ngModel)]="newComputingUnitName"
- class="unit-name-input" />
- </div>
- <div class="select-unit name-field">
- <span>Computing Unit URI</span>
- <input
- [required]="true"
- nz-input
- placeholder="URI of the local computing unit (e.g. http://localhost:8085)"
- [(ngModel)]="localComputingUnitUri"
- class="unit-uri-input" />
- </div>
- </ng-container>
- </div>
- </ng-template>
- <ng-template #addComputeUnitModalFooter>
- <button
- nz-button
- nzType="default"
- (click)="handleAddComputeUnitModalCancel()">
- Cancel
- </button>
- <button
- nz-button
- nzType="primary"
- (click)="handleAddComputeUnitModalOk()">
- Create
- </button>
- </ng-template>
-</nz-modal>
+<texera-computing-unit-create-modal
+ [(visible)]="addComputeUnitModalVisible"
+ (unitCreated)="onComputingUnitCreated()"></texera-computing-unit-create-modal>
diff --git a/frontend/src/app/dashboard/component/user/user-computing-unit/user-computing-unit.component.scss b/frontend/src/app/dashboard/component/user/user-computing-unit/user-computing-unit.component.scss
index 6ae48a1222..e527d3adbc 100644
--- a/frontend/src/app/dashboard/component/user/user-computing-unit/user-computing-unit.component.scss
+++ b/frontend/src/app/dashboard/component/user/user-computing-unit/user-computing-unit.component.scss
@@ -27,73 +27,3 @@
min-height: 100%;
height: 100%;
}
-
-.memory-selection,
-.cpu-selection,
-.gpu-selection {
- width: 100%;
-}
-
-.jvm-memory-slider {
- width: 100%;
- margin: 10px 0;
-}
-
-.memory-warning {
- margin-top: 10px;
- font-size: 0.9em;
-}
-
-.create-compute-unit-container {
- display: grid;
- grid-template-columns: repeat(2, 1fr);
- gap: 10px;
- justify-content: start;
- align-items: center;
-}
-
-.select-unit {
- display: flex;
- flex-direction: column;
- gap: 10px;
- justify-content: center;
- align-items: flex-start;
-}
-
-.select-unit.name-field {
- grid-column: span 2;
-}
-
-.unit-name-input {
- width: 100%;
-}
-
-.shared-memory-group {
- width: 100%;
-
- .shm-input-row {
- display: flex;
- gap: 8px;
- align-items: center;
- width: 100%;
- }
-
- .shm-size-input {
- width: 60px;
- min-width: 50px;
- flex-shrink: 0;
- }
-
- .shm-unit-select {
- width: 80px;
- min-width: 70px;
- flex-shrink: 0;
- }
-
- .shm-warning {
- margin-top: 4px;
- font-size: 12px;
- color: #faad14;
- white-space: nowrap;
- }
-}
diff --git a/frontend/src/app/dashboard/component/user/user-computing-unit/user-computing-unit.component.spec.ts b/frontend/src/app/dashboard/component/user/user-computing-unit/user-computing-unit.component.spec.ts
index 0a10aae28f..bd90137c96 100644
--- a/frontend/src/app/dashboard/component/user/user-computing-unit/user-computing-unit.component.spec.ts
+++ b/frontend/src/app/dashboard/component/user/user-computing-unit/user-computing-unit.component.spec.ts
@@ -18,7 +18,10 @@
*/
import { ComponentFixture, TestBed } from "@angular/core/testing";
+import { By } from "@angular/platform-browser";
import { UserComputingUnitComponent } from "./user-computing-unit.component";
+import { ComputingUnitCreateModalComponent } from "../../../../common/component/computing-unit-create-modal/computing-unit-create-modal.component";
+import { DashboardWorkflowComputingUnit } from "../../../../common/type/workflow-computing-unit";
import { NzCardModule } from "ng-zorro-antd/card";
import { NzIconModule } from "ng-zorro-antd/icon";
import { NzModalService } from "ng-zorro-antd/modal";
@@ -73,4 +76,22 @@ describe("UserComputingUnitComponent", () => {
fixture.detectChanges();
expect(component).toBeTruthy();
});
+
+ it("refreshes the computing unit list when the modal emits unitCreated", () => {
+ fixture.detectChanges();
+ const statusService = TestBed.inject(ComputingUnitStatusService);
+ const refreshSpy = vi.spyOn(statusService, "refreshComputingUnitList").mockImplementation(() => {});
+ const modal = fixture.debugElement.query(By.directive(ComputingUnitCreateModalComponent)).componentInstance;
+ modal.unitCreated.emit({} as unknown as DashboardWorkflowComputingUnit);
+ expect(refreshSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it("syncs visibility when the embedded modal closes itself", () => {
+ fixture.detectChanges();
+ component.showAddComputeUnitModalVisible();
+ expect(component.addComputeUnitModalVisible).toBe(true);
+ const modal = fixture.debugElement.query(By.directive(ComputingUnitCreateModalComponent)).componentInstance;
+ modal.visibleChange.emit(false);
+ expect(component.addComputeUnitModalVisible).toBe(false);
+ });
});
diff --git a/frontend/src/app/dashboard/component/user/user-computing-unit/user-computing-unit.component.ts b/frontend/src/app/dashboard/component/user/user-computing-unit/user-computing-unit.component.ts
index b7400f036b..7f62d81540 100644
--- a/frontend/src/app/dashboard/component/user/user-computing-unit/user-computing-unit.component.ts
+++ b/frontend/src/app/dashboard/component/user/user-computing-unit/user-computing-unit.component.ts
@@ -17,27 +17,13 @@
* under the License.
*/
-import { Component, Input, OnInit } from "@angular/core";
+import { Component, OnInit } from "@angular/core";
import { ComputingUnitStatusService } from "../../../../common/service/computing-unit/computing-unit-status/computing-unit-status.service";
import { DashboardEntry } from "../../../type/dashboard-entry";
-import {
- DashboardWorkflowComputingUnit,
- WorkflowComputingUnitType,
-} from "../../../../common/type/workflow-computing-unit";
-import { extractErrorMessage } from "../../../../common/util/error";
+import { DashboardWorkflowComputingUnit } from "../../../../common/type/workflow-computing-unit";
import { NotificationService } from "../../../../common/service/notification/notification.service";
-import { NzModalService, NzModalComponent } from "ng-zorro-antd/modal";
import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy";
import { UserService } from "../../../../common/service/user/user.service";
-import { WorkflowComputingUnitManagingService } from "../../../../common/service/computing-unit/workflow-computing-unit/workflow-computing-unit-managing.service";
-import {
- parseResourceUnit,
- parseResourceNumber,
- findNearestValidStep,
- unitTypeMessageTemplate,
- isComputingUnitShmTooLarge,
- getJvmMemorySliderConfig,
-} from "../../../../common/util/computing-unit.util";
import { ComputingUnitActionsService } from "../../../../common/service/computing-unit/computing-unit-actions/computing-unit-actions.service";
import { interval } from "rxjs";
import { NzCardComponent } from "ng-zorro-antd/card";
@@ -49,13 +35,7 @@ import { NzIconDirective } from "ng-zorro-antd/icon";
import { ɵɵCdkVirtualScrollViewport, ɵɵCdkFixedSizeVirtualScroll, ɵɵCdkVirtualForOf } from "@angular/cdk/overlay";
import { NzListComponent } from "ng-zorro-antd/list";
import { UserComputingUnitListItemComponent } from "./user-computing-unit-list-item/user-computing-unit-list-item.component";
-import { NzSelectComponent, NzOptionComponent } from "ng-zorro-antd/select";
-import { FormsModule } from "@angular/forms";
-import { NgFor, NgIf, TitleCasePipe } from "@angular/common";
-import { NzInputDirective } from "ng-zorro-antd/input";
-import { NzTooltipDirective } from "ng-zorro-antd/tooltip";
-import { NzSliderComponent } from "ng-zorro-antd/slider";
-import { NzAlertComponent } from "ng-zorro-antd/alert";
+import { ComputingUnitCreateModalComponent } from "../../../../common/component/computing-unit-create-modal/computing-unit-create-modal.component";
@UntilDestroy()
@Component({
@@ -74,17 +54,7 @@ import { NzAlertComponent } from "ng-zorro-antd/alert";
NzListComponent,
ɵɵCdkVirtualForOf,
UserComputingUnitListItemComponent,
- NzModalComponent,
- NzSelectComponent,
- FormsModule,
- NgFor,
- NzOptionComponent,
- NgIf,
- NzInputDirective,
- NzTooltipDirective,
- NzSliderComponent,
- NzAlertComponent,
- TitleCasePipe,
+ ComputingUnitCreateModalComponent,
],
})
export class UserComputingUnitComponent implements OnInit {
@@ -94,37 +64,12 @@ export class UserComputingUnitComponent implements OnInit {
allComputingUnits: DashboardWorkflowComputingUnit[] = [];
- // variables for creating a computing unit
+ // visibility of the shared create-computing-unit modal
addComputeUnitModalVisible = false;
- newComputingUnitName: string = "";
- selectedMemory: string = "";
- selectedCpu: string = "";
- selectedGpu: string = "0"; // Default to no GPU
- selectedJvmMemorySize: string = "1G"; // Initial JVM memory size
- selectedComputingUnitType?: WorkflowComputingUnitType; // Selected computing unit type
- selectedShmSize: string = "64Mi"; // Shared memory size
- shmSizeValue: number = 64; // default to 64
- shmSizeUnit: "Mi" | "Gi" = "Mi"; // default unit
- availableComputingUnitTypes: WorkflowComputingUnitType[] = [];
- localComputingUnitUri: string = ""; // URI for local computing unit
-
- // JVM memory slider configuration
- jvmMemorySliderValue: number = 1; // Initial value in GB
- jvmMemoryMarks: { [key: number]: string } = { 1: "1G" };
- jvmMemoryMax: number = 1;
- jvmMemorySteps: number[] = [1]; // Available steps in binary progression (1,2,4,8...)
- showJvmMemorySlider: boolean = false; // Whether to show the slider
-
- // cpu&memory limit options from backend
- cpuOptions: string[] = [];
- memoryOptions: string[] = [];
- gpuOptions: string[] = []; // Add GPU options array
constructor(
private notificationService: NotificationService,
- private modalService: NzModalService,
private userService: UserService,
- private computingUnitService: WorkflowComputingUnitManagingService,
private computingUnitStatusService: ComputingUnitStatusService,
private computingUnitActionsService: ComputingUnitActionsService
) {
@@ -138,46 +83,6 @@ export class UserComputingUnitComponent implements OnInit {
}
ngOnInit() {
- this.localComputingUnitUri = `${window.location.protocol}//${window.location.hostname}${window.location.port ? `:${window.location.port}` : ""}/wsapi`;
- this.newComputingUnitName = "My Computing Unit";
- this.computingUnitService
- .getComputingUnitTypes()
- .pipe(untilDestroyed(this))
- .subscribe({
- next: ({ typeOptions }) => {
- this.availableComputingUnitTypes = typeOptions;
- // Set default selected type if available
- if (typeOptions.includes("kubernetes")) {
- this.selectedComputingUnitType = "kubernetes";
- } else if (typeOptions.length > 0) {
- this.selectedComputingUnitType = typeOptions[0];
- }
- },
- error: (err: unknown) =>
- this.notificationService.error(`Failed to fetch computing unit types: ${extractErrorMessage(err)}`),
- });
-
- this.computingUnitService
- .getComputingUnitLimitOptions()
- .pipe(untilDestroyed(this))
- .subscribe({
- next: ({ cpuLimitOptions, memoryLimitOptions, gpuLimitOptions }) => {
- this.cpuOptions = cpuLimitOptions;
- this.memoryOptions = memoryLimitOptions;
- this.gpuOptions = gpuLimitOptions;
-
- // fallback defaults
- this.selectedCpu = this.cpuOptions[0] ?? "1";
- this.selectedMemory = this.memoryOptions[0] ?? "1Gi";
- this.selectedGpu = this.gpuOptions[0] ?? "0";
-
- // Initialize JVM memory slider based on selected memory
- this.updateJvmMemorySlider();
- },
- error: (err: unknown) =>
- this.notificationService.error(`Failed to fetch resource options: ${extractErrorMessage(err)}`),
- });
-
this.computingUnitStatusService
.getAllComputingUnits()
.pipe(untilDestroyed(this))
@@ -204,124 +109,11 @@ export class UserComputingUnitComponent implements OnInit {
this.computingUnitActionsService.confirmAndTerminate(cuid, unit);
}
- startComputingUnit(): void {
- if (this.selectedComputingUnitType === "kubernetes" && this.newComputingUnitName.trim() === "") {
- this.notificationService.error("Name of the computing unit cannot be empty");
- return;
- }
-
- if (this.selectedComputingUnitType === "local" && this.localComputingUnitUri.trim() === "") {
- this.notificationService.error("URI for local computing unit cannot be empty");
- return;
- }
-
- if (!this.selectedComputingUnitType) {
- this.notificationService.error("Please select a valid computing unit type");
- return;
- }
-
- const request = {
- type: this.selectedComputingUnitType,
- name: this.newComputingUnitName,
- cpu: this.selectedCpu,
- memory: this.selectedMemory,
- gpu: this.selectedGpu,
- jvmMemorySize: this.selectedJvmMemorySize,
- shmSize: `${this.shmSizeValue}${this.shmSizeUnit}`,
- localUri: this.localComputingUnitUri,
- };
-
- this.computingUnitActionsService
- .create(request)
- .pipe(untilDestroyed(this))
- .subscribe({
- next: () => {
- this.notificationService.success("Successfully created the new compute unit");
- this.computingUnitStatusService.refreshComputingUnitList();
- },
- error: (err: unknown) =>
- this.notificationService.error(`Failed to start computing unit: ${extractErrorMessage(err)}`),
- });
- }
-
- showGpuSelection(): boolean {
- // Don't show GPU selection if there are no options or only "0" option
- return this.gpuOptions.length > 1 || (this.gpuOptions.length === 1 && this.gpuOptions[0] !== "0");
- }
-
showAddComputeUnitModalVisible(): void {
this.addComputeUnitModalVisible = true;
}
- handleAddComputeUnitModalOk(): void {
- this.startComputingUnit();
- this.addComputeUnitModalVisible = false;
- }
-
- handleAddComputeUnitModalCancel(): void {
- this.addComputeUnitModalVisible = false;
- }
-
- isShmTooLarge(): boolean {
- return isComputingUnitShmTooLarge(this.selectedMemory, this.shmSizeValue, this.shmSizeUnit);
- }
-
- updateJvmMemorySlider(): void {
- this.resetJvmMemorySlider();
- }
-
- onJvmMemorySliderChange(value: number): void {
- // Ensure the value is one of the valid steps
- const validStep = findNearestValidStep(value, this.jvmMemorySteps);
- this.jvmMemorySliderValue = validStep;
- this.selectedJvmMemorySize = `${validStep}G`;
- }
-
- isMaxJvmMemorySelected(): boolean {
- // Only show warning for larger memory sizes (>=4GB) where the slider is shown
- // AND when the maximum value is selected
- return this.showJvmMemorySlider && this.jvmMemorySliderValue === this.jvmMemoryMax && this.jvmMemoryMax >= 4;
- }
-
- // Completely reset the JVM memory slider based on the selected CU memory
- resetJvmMemorySlider(): void {
- const config = getJvmMemorySliderConfig(this.selectedMemory);
-
- this.jvmMemoryMax = config.jvmMemoryMax;
- this.showJvmMemorySlider = config.showJvmMemorySlider;
- this.jvmMemorySteps = config.jvmMemorySteps;
- this.jvmMemoryMarks = config.jvmMemoryMarks;
- this.jvmMemorySliderValue = config.jvmMemorySliderValue;
- this.selectedJvmMemorySize = config.selectedJvmMemorySize;
- }
-
- onMemorySelectionChange(): void {
- // Store current JVM memory value for potential reuse
- const previousJvmMemory = this.jvmMemorySliderValue;
-
- // Reset slider configuration based on the new memory selection
- this.resetJvmMemorySlider();
-
- // For CU memory > 3GB, preserve previous value if valid and >= 2GB
- // Get the current memory in GB
- const memoryValue = parseResourceNumber(this.selectedMemory);
- const memoryUnit = parseResourceUnit(this.selectedMemory);
- let cuMemoryInGb = memoryUnit === "Gi" ? memoryValue : memoryUnit === "Mi" ? Math.floor(memoryValue / 1024) : 1;
-
- // Only try to preserve previous value for larger memory sizes where slider is shown
- if (
- cuMemoryInGb > 3 &&
- previousJvmMemory >= 2 &&
- previousJvmMemory <= this.jvmMemoryMax &&
- this.jvmMemorySteps.includes(previousJvmMemory)
- ) {
- this.jvmMemorySliderValue = previousJvmMemory;
- this.selectedJvmMemorySize = `${previousJvmMemory}G`;
- }
- }
-
- getCreateModalTitle(): string {
- if (!this.selectedComputingUnitType) return "Create Computing Unit";
- return unitTypeMessageTemplate[this.selectedComputingUnitType].createTitle;
+ onComputingUnitCreated(): void {
+ this.computingUnitStatusService.refreshComputingUnitList();
}
}
diff --git a/frontend/src/app/workspace/component/menu/menu.component.spec.ts b/frontend/src/app/workspace/component/menu/menu.component.spec.ts
index 3825da95a0..ebec65bb10 100644
--- a/frontend/src/app/workspace/component/menu/menu.component.spec.ts
+++ b/frontend/src/app/workspace/component/menu/menu.component.spec.ts
@@ -307,7 +307,6 @@ describe("MenuComponent", () => {
describe("runWorkflow", () => {
beforeEach(() => {
component.computingUnitSelectionComponent = {
- newComputingUnitName: "",
showAddComputeUnitModalVisible: vi.fn(),
} as unknown as Mocked<ComputingUnitSelectionComponent>;
});
@@ -342,7 +341,9 @@ describe("MenuComponent", () => {
component.runWorkflow();
- expect(component.computingUnitSelectionComponent.newComputingUnitName).toBe("wf's Computing Unit");
+ expect(component.computingUnitSelectionComponent.showAddComputeUnitModalVisible).toHaveBeenCalledWith(
+ "wf's Computing Unit"
+ );
expect(component.computingUnitSelectionComponent.showAddComputeUnitModalVisible).toHaveBeenCalledTimes(1);
expect(executeSpy).not.toHaveBeenCalled();
});
diff --git a/frontend/src/app/workspace/component/menu/menu.component.ts b/frontend/src/app/workspace/component/menu/menu.component.ts
index aa9b3a0727..a205b2b8bb 100644
--- a/frontend/src/app/workspace/component/menu/menu.component.ts
+++ b/frontend/src/app/workspace/component/menu/menu.component.ts
@@ -831,11 +831,8 @@ export class MenuComponent implements OnInit, OnDestroy {
? `${this.currentWorkflowName}'s Computing Unit`
: "New Computing Unit";
- // Set the default name in the computing unit selection component
- this.computingUnitSelectionComponent.newComputingUnitName = defaultName;
-
- // Show the existing modal in the ComputingUnitSelectionComponent
- this.computingUnitSelectionComponent.showAddComputeUnitModalVisible();
+ // Show the modal in the ComputingUnitSelectionComponent, seeding the name field
+ this.computingUnitSelectionComponent.showAddComputeUnitModalVisible(defaultName);
return;
}
diff --git a/frontend/src/app/workspace/component/power-button/computing-unit-selection.component.html b/frontend/src/app/workspace/component/power-button/computing-unit-selection.component.html
index ae8738fe69..747354a04c 100644
--- a/frontend/src/app/workspace/component/power-button/computing-unit-selection.component.html
+++ b/frontend/src/app/workspace/component/power-button/computing-unit-selection.component.html
@@ -219,183 +219,9 @@
</div>
<!-- Panel for creating the computing unit -->
-<nz-modal
- [nzVisible]="addComputeUnitModalVisible"
- [nzTitle]="getCreateModalTitle()"
- [nzContent]="addComputeUnitModalContent"
- [nzFooter]="addComputeUnitModalFooter"
- (nzOnCancel)="handleAddComputeUnitModalCancel()">
- <ng-template #addComputeUnitModalTitle>Create Computing Unit</ng-template>
- <ng-template #addComputeUnitModalContent>
- <div class="create-compute-unit-container">
- <!-- Computing unit type selection -->
- <div class="select-unit">
- <span>Computing Unit Type</span>
- <nz-select
- class="type-selection"
- [(ngModel)]="selectedComputingUnitType">
- <nz-option
- *ngFor="let type of availableComputingUnitTypes"
- [nzValue]="type"
- [nzLabel]="type | titlecase">
- </nz-option>
- </nz-select>
- </div>
-
- <!-- Fields for Kubernetes computing units -->
- <ng-container *ngIf="selectedComputingUnitType === 'kubernetes'">
- <div class="select-unit name-field">
- <span>Computing Unit Name</span>
- <input
- [required]="true"
- nz-input
- placeholder="Enter the name of your computing unit (required)"
- [(ngModel)]="newComputingUnitName"
- class="unit-name-input" />
- </div>
- <div class="select-unit">
- <span>Select RAM Size</span>
- <nz-select
- class="memory-selection"
- [(ngModel)]="selectedMemory"
- (ngModelChange)="onMemorySelectionChange()">
- <nz-option
- *ngFor="let option of memoryOptions"
- [nzValue]="option"
- [nzLabel]="option">
- </nz-option>
- </nz-select>
- </div>
-
- <div class="select-unit">
- <span>Select #CPU Core(s)</span>
- <nz-select
- class="cpu-selection"
- [(ngModel)]="selectedCpu">
- <nz-option
- *ngFor="let option of cpuOptions"
- [nzValue]="option"
- [nzLabel]="option">
- </nz-option>
- </nz-select>
- </div>
-
- <div
- *ngIf="showGpuSelection()"
- class="select-unit">
- <span>Select #GPU(s)</span>
- <nz-select
- class="gpu-selection"
- [(ngModel)]="selectedGpu">
- <nz-option
- *ngFor="let option of gpuOptions"
- [nzValue]="option"
- [nzLabel]="option">
- </nz-option>
- </nz-select>
- </div>
-
- <div class="select-unit shared-memory-group">
- <span>
- Adjust the Shared Memory Size
- <i
- nz-icon
- nzType="info-circle"
- nz-tooltip
- [nzTooltipTitle]="
- 'Shared memory (/dev/shm) is used for inter-process communication. If you plan to run ML tasks (e.g., using PyTorch), consider increasing this size. Learn more: https://man7.org/linux/man-pages/man7/shm_overview.7.html'
- ">
- </i>
- </span>
- <div class="shm-input-row">
- <input
- nz-input
- type="number"
- min="0"
- [(ngModel)]="shmSizeValue"
- class="shm-size-input"
- placeholder="e.g. 512" />
- <nz-select
- class="shm-unit-select"
- [(ngModel)]="shmSizeUnit">
- <nz-option
- nzValue="Mi"
- nzLabel="Mi"></nz-option>
- <nz-option
- nzValue="Gi"
- nzLabel="Gi"></nz-option>
- </nz-select>
- </div>
- <div
- *ngIf="isShmTooLarge()"
- class="shm-warning">
- Shared memory cannot be greater than total memory.
- </div>
- </div>
-
- <div class="select-unit name-field">
- <span>JVM Memory Size: {{selectedJvmMemorySize}}</span>
- <nz-slider
- *ngIf="showJvmMemorySlider"
- class="jvm-memory-slider"
- [nzMarks]="jvmMemoryMarks"
- [nzMin]="jvmMemorySteps[0] || 2"
- [nzMax]="jvmMemoryMax"
- [nzStep]="null"
- [nzIncluded]="false"
- [ngModel]="jvmMemorySliderValue"
- (ngModelChange)="onJvmMemorySliderChange($event)">
- </nz-slider>
- <div
- *ngIf="isMaxJvmMemorySelected()"
- class="memory-warning">
- <nz-alert
- nzType="warning"
- nzMessage="Using maximum JVM memory may affect the performance of Python-based operators."
- nzShowIcon>
- </nz-alert>
- </div>
- </div>
- </ng-container>
-
- <!-- Fields for Local computing units -->
- <ng-container *ngIf="selectedComputingUnitType === 'local'">
- <div class="select-unit name-field">
- <span>Computing Unit Name</span>
- <input
- [required]="true"
- nz-input
- placeholder="Enter the name of your local computing unit (required)"
- [(ngModel)]="newComputingUnitName"
- class="unit-name-input" />
- </div>
- <div class="select-unit name-field">
- <span>Computing Unit URI</span>
- <input
- [required]="true"
- nz-input
- placeholder="URI of the local computing unit (e.g. http://localhost:8085)"
- [(ngModel)]="localComputingUnitUri"
- class="unit-uri-input" />
- </div>
- </ng-container>
- </div>
- </ng-template>
- <ng-template #addComputeUnitModalFooter>
- <button
- nz-button
- nzType="default"
- (click)="handleAddComputeUnitModalCancel()">
- Cancel
- </button>
- <button
- nz-button
- nzType="primary"
- (click)="handleAddComputeUnitModalOk()">
- Create
- </button>
- </ng-template>
-</nz-modal>
+<texera-computing-unit-create-modal
+ [(visible)]="addComputeUnitModalVisible"
+ (unitCreated)="onComputingUnitCreated($event)"></texera-computing-unit-create-modal>
<ng-template #metricsTemplate>
<div class="resource-metrics">
diff --git a/frontend/src/app/workspace/component/power-button/computing-unit-selection.component.scss b/frontend/src/app/workspace/component/power-button/computing-unit-selection.component.scss
index 975632305c..1213a861a5 100644
--- a/frontend/src/app/workspace/component/power-button/computing-unit-selection.component.scss
+++ b/frontend/src/app/workspace/component/power-button/computing-unit-selection.component.scss
@@ -86,8 +86,7 @@
.computing-unit-row,
.computing-unit-name,
.create-computing-unit,
-.metric-item,
-.shm-input-row {
+.metric-item {
display: flex;
align-items: center;
}
@@ -135,52 +134,15 @@
}
}
-.memory-selection,
-.cpu-selection,
-.gpu-selection,
-.unit-name-input {
- width: 100%;
-}
-
.create-computing-unit {
gap: 10px;
justify-content: flex-start;
}
-.create-compute-unit-container,
.resource-metrics {
display: grid;
}
-.create-compute-unit-container {
- grid-template-columns: repeat(2, 1fr);
- gap: 10px;
- justify-content: start;
- align-items: center;
-}
-
-.select-unit {
- display: flex;
- flex-direction: column;
- align-items: flex-start;
- justify-content: center;
- gap: 10px;
-
- &.name-field {
- grid-column: span 2;
- }
-}
-
-.jvm-memory-slider {
- width: 100%;
- margin: 10px 0;
-}
-
-.memory-warning {
- margin-top: 10px;
- font-size: 0.9em;
-}
-
.computing-units-dropdown-button {
display: inline-flex;
align-items: center;
@@ -315,34 +277,6 @@
font-weight: 500;
}
-.shared-memory-group {
- width: 100%;
-
- .shm-input-row {
- width: 100%;
- gap: 8px;
- }
-
- .shm-size-input {
- width: 60px;
- min-width: 50px;
- flex-shrink: 0;
- }
-
- .shm-unit-select {
- width: 80px;
- min-width: 70px;
- flex-shrink: 0;
- }
-
- .shm-warning {
- margin-top: 4px;
- color: #faad14;
- font-size: 12px;
- white-space: nowrap;
- }
-}
-
.unit-name-edit-input {
width: 100%;
max-width: 200px;
diff --git a/frontend/src/app/workspace/component/power-button/computing-unit-selection.component.spec.ts b/frontend/src/app/workspace/component/power-button/computing-unit-selection.component.spec.ts
index efa6700262..c1f905e3e1 100644
--- a/frontend/src/app/workspace/component/power-button/computing-unit-selection.component.spec.ts
+++ b/frontend/src/app/workspace/component/power-button/computing-unit-selection.component.spec.ts
@@ -19,6 +19,7 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { HttpClientTestingModule } from "@angular/common/http/testing";
+import { By } from "@angular/platform-browser";
import { ComputingUnitSelectionComponent } from "./computing-unit-selection.component";
import { NzButtonModule } from "ng-zorro-antd/button";
import { CommonModule } from "@angular/common";
@@ -27,6 +28,7 @@ import { ActivatedRoute, ActivatedRouteSnapshot, convertToParamMap, Data, Params
import { NzDropDownModule } from "ng-zorro-antd/dropdown";
import { NzModalModule, NzModalService } from "ng-zorro-antd/modal";
import { ComputingUnitStatusService } from "../../../common/service/computing-unit/computing-unit-status/computing-unit-status.service";
+import { WorkflowComputingUnitManagingService } from "../../../common/service/computing-unit/workflow-computing-unit/workflow-computing-unit-managing.service";
import { MockComputingUnitStatusService } from "../../../common/service/computing-unit/computing-unit-status/mock-computing-unit-status.service";
import { commonTestProviders } from "../../../common/testing/test-utils";
import { UserPveRecord } from "../../service/virtual-environment/virtual-environment.service";
@@ -38,6 +40,7 @@ import {
WorkflowPveService,
} from "../../service/virtual-environment/virtual-environment.service";
import { DashboardWorkflowComputingUnit } from "../../../common/type/workflow-computing-unit";
+import { ComputingUnitCreateModalComponent } from "../../../common/component/computing-unit-create-modal/computing-unit-create-modal.component";
describe("PowerButtonComponent", () => {
let component: ComputingUnitSelectionComponent;
@@ -92,6 +95,86 @@ describe("PowerButtonComponent", () => {
expect(component).toBeTruthy();
});
+ describe("showAddComputeUnitModalVisible", () => {
+ it("forwards the seeded default name to the embedded create modal", () => {
+ fixture.detectChanges();
+ component.showAddComputeUnitModalVisible("wf's Computing Unit");
+ const modal = fixture.debugElement.query(By.directive(ComputingUnitCreateModalComponent)).componentInstance;
+ expect(modal.newComputingUnitName).toBe("wf's Computing Unit");
+ expect(component.addComputeUnitModalVisible).toBe(true);
+ });
+
+ it("opens the modal without touching the name when no default is given", () => {
+ fixture.detectChanges();
+ const modal = fixture.debugElement.query(By.directive(ComputingUnitCreateModalComponent)).componentInstance;
+ const nameBefore = modal.newComputingUnitName;
+ component.showAddComputeUnitModalVisible();
+ expect(component.addComputeUnitModalVisible).toBe(true);
+ expect(modal.newComputingUnitName).toBe(nameBefore);
+ });
+
+ it("still opens when the embedded modal is not available", () => {
+ fixture.detectChanges();
+ // simulate the ViewChild not (yet) being resolved
+ (component as any).computingUnitCreateModal = undefined;
+ component.showAddComputeUnitModalVisible("Seed Name");
+ expect(component.addComputeUnitModalVisible).toBe(true);
+ });
+
+ it("syncs visibility when the embedded modal closes itself", () => {
+ fixture.detectChanges();
+ component.showAddComputeUnitModalVisible();
+ expect(component.addComputeUnitModalVisible).toBe(true);
+ const modal = fixture.debugElement.query(By.directive(ComputingUnitCreateModalComponent)).componentInstance;
+ modal.visibleChange.emit(false);
+ expect(component.addComputeUnitModalVisible).toBe(false);
+ });
+ });
+
+ describe("onComputingUnitCreated", () => {
+ it("selects the created unit for the current workflow when the modal emits unitCreated", () => {
+ fixture.detectChanges();
+ component.workflowId = 7;
+ const selectSpy = vi.spyOn(component, "selectComputingUnit").mockImplementation(() => {});
+ const modal = fixture.debugElement.query(By.directive(ComputingUnitCreateModalComponent)).componentInstance;
+ modal.unitCreated.emit({ computingUnit: { cuid: 42 } } as unknown as DashboardWorkflowComputingUnit);
+ expect(selectSpy).toHaveBeenCalledWith(7, 42);
+ });
+ });
+
+ describe("showGpuSelection", () => {
+ it("reflects the fetched GPU limit options", () => {
+ const managingService = TestBed.inject(WorkflowComputingUnitManagingService);
+ vi.spyOn(managingService, "getComputingUnitLimitOptions").mockReturnValue(
+ of({ cpuLimitOptions: [], memoryLimitOptions: [], gpuLimitOptions: ["0", "1"] })
+ );
+ const gpuFixture = TestBed.createComponent(ComputingUnitSelectionComponent);
+ gpuFixture.detectChanges();
+ expect(gpuFixture.componentInstance.showGpuSelection()).toBe(true);
+ });
+
+ it("hides the GPU row when the deployment reports no GPU support", () => {
+ const managingService = TestBed.inject(WorkflowComputingUnitManagingService);
+ vi.spyOn(managingService, "getComputingUnitLimitOptions").mockReturnValue(
+ of({ cpuLimitOptions: [], memoryLimitOptions: [], gpuLimitOptions: ["0"] })
+ );
+ const gpuFixture = TestBed.createComponent(ComputingUnitSelectionComponent);
+ gpuFixture.detectChanges();
+ expect(gpuFixture.componentInstance.showGpuSelection()).toBe(false);
+ });
+
+ it("falls back to showing GPU metrics when the limit-options fetch fails", () => {
+ const managingService = TestBed.inject(WorkflowComputingUnitManagingService);
+ vi.spyOn(managingService, "getComputingUnitLimitOptions").mockReturnValue(throwError(() => new Error("boom")));
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
+ const failedFixture = TestBed.createComponent(ComputingUnitSelectionComponent);
+ failedFixture.detectChanges();
+ expect(failedFixture.componentInstance.showGpuSelection()).toBe(true);
+ expect(errorSpy).toHaveBeenCalled();
+ errorSpy.mockRestore();
+ });
+ });
+
describe("isSavedPveInstalledInCu", () => {
it("returns true when a locked card with the same trimmed name exists", () => {
component.pves = [
diff --git a/frontend/src/app/workspace/component/power-button/computing-unit-selection.component.ts b/frontend/src/app/workspace/component/power-button/computing-unit-selection.component.ts
index ac59584218..fed31fddb4 100644
--- a/frontend/src/app/workspace/component/power-button/computing-unit-selection.component.ts
+++ b/frontend/src/app/workspace/component/power-button/computing-unit-selection.component.ts
@@ -17,13 +17,10 @@
* under the License.
*/
-import { ChangeDetectorRef, Component, OnInit, NgZone } from "@angular/core";
+import { ChangeDetectorRef, Component, OnInit, NgZone, ViewChild } from "@angular/core";
import { take } from "rxjs/operators";
import { WorkflowComputingUnitManagingService } from "../../../common/service/computing-unit/workflow-computing-unit/workflow-computing-unit-managing.service";
-import {
- DashboardWorkflowComputingUnit,
- WorkflowComputingUnitType,
-} from "../../../common/type/workflow-computing-unit";
+import { DashboardWorkflowComputingUnit } from "../../../common/type/workflow-computing-unit";
import { NotificationService } from "../../../common/service/notification/notification.service";
import { DEFAULT_WORKFLOW, WorkflowActionService } from "../../service/workflow-graph/model/workflow-action.service";
import { isDefined } from "../../../common/util/predicate";
@@ -41,7 +38,6 @@ import {
ComputingUnitMetadataComponent,
parseResourceUnit,
parseResourceNumber,
- findNearestValidStep,
unitTypeMessageTemplate,
cpuResourceConversion,
memoryResourceConversion,
@@ -53,15 +49,13 @@ import {
getComputingUnitCpuStatus,
getComputingUnitMemoryStatus,
getComputingUnitCpuLimitUnit,
- isComputingUnitShmTooLarge,
- getJvmMemorySliderConfig,
} from "../../../common/util/computing-unit.util";
import {
PvePackageResponse,
UserPveRecord,
WorkflowPveService,
} from "../../service/virtual-environment/virtual-environment.service";
-import { NgClass, NgIf, NgFor, DecimalPipe, TitleCasePipe } from "@angular/common";
+import { NgClass, NgIf, NgFor, DecimalPipe } from "@angular/common";
import { ɵNzTransitionPatchDirective } from "ng-zorro-antd/core/transition-patch";
import { NzPopoverDirective } from "ng-zorro-antd/popover";
import { NzProgressComponent } from "ng-zorro-antd/progress";
@@ -77,9 +71,8 @@ import { NzMenuDirective, NzMenuItemComponent, NzMenuDividerDirective } from "ng
import { NzInputDirective } from "ng-zorro-antd/input";
import { NzSelectComponent, NzOptionComponent } from "ng-zorro-antd/select";
import { FormsModule } from "@angular/forms";
-import { NzSliderComponent } from "ng-zorro-antd/slider";
-import { NzAlertComponent } from "ng-zorro-antd/alert";
import { NzCollapseComponent, NzCollapsePanelComponent } from "ng-zorro-antd/collapse";
+import { ComputingUnitCreateModalComponent } from "../../../common/component/computing-unit-create-modal/computing-unit-create-modal.component";
type PveUserPackageRow = {
name: string;
@@ -130,13 +123,11 @@ type PveDraft = {
NzSelectComponent,
FormsModule,
NzOptionComponent,
- NzSliderComponent,
- NzAlertComponent,
NzModalContentDirective,
NzCollapseComponent,
NzCollapsePanelComponent,
DecimalPipe,
- TitleCasePipe,
+ ComputingUnitCreateModalComponent,
],
})
export class ComputingUnitSelectionComponent implements OnInit {
@@ -162,35 +153,21 @@ export class ComputingUnitSelectionComponent implements OnInit {
selectedComputingUnit: DashboardWorkflowComputingUnit | null = null;
allComputingUnits: DashboardWorkflowComputingUnit[] = [];
- // variables for creating a computing unit
+ // visibility of the shared create-computing-unit modal
addComputeUnitModalVisible = false;
- newComputingUnitName: string = "";
- selectedMemory: string = "";
- selectedCpu: string = "";
- selectedGpu: string = "0"; // Default to no GPU
- selectedJvmMemorySize: string = "1G"; // Initial JVM memory size
- selectedComputingUnitType?: WorkflowComputingUnitType; // Selected computing unit type
- selectedShmSize: string = "64Mi"; // Shared memory size
- shmSizeValue: number = 64; // default to 64
- shmSizeUnit: "Mi" | "Gi" = "Mi"; // default unit
- availableComputingUnitTypes: WorkflowComputingUnitType[] = [];
- localComputingUnitUri: string = ""; // URI for local computing unit
+
+ @ViewChild(ComputingUnitCreateModalComponent)
+ private computingUnitCreateModal?: ComputingUnitCreateModalComponent;
// variables for renaming a computing unit
editingNameOfUnit: number | null = null;
editingUnitName: string = "";
- // JVM memory slider configuration
- jvmMemorySliderValue: number = 1; // Initial value in GB
- jvmMemoryMarks: { [key: number]: string } = { 1: "1G" };
- jvmMemoryMax: number = 1;
- jvmMemorySteps: number[] = [1]; // Available steps in binary progression (1,2,4,8...)
- showJvmMemorySlider: boolean = false; // Whether to show the slider
-
- // cpu&memory limit options from backend
- cpuOptions: string[] = [];
- memoryOptions: string[] = [];
- gpuOptions: string[] = []; // Add GPU options array
+ // GPU limit options, used by the metrics popover's GPU row via showGpuSelection()
+ gpuOptions: string[] = [];
+ // True when the limit-options fetch failed; showGpuSelection() then falls back
+ // to permissive so the metrics popover doesn't silently hide the GPU row.
+ private gpuOptionsFetchFailed = false;
constructor(
private computingUnitService: WorkflowComputingUnitManagingService,
@@ -207,45 +184,22 @@ export class ComputingUnitSelectionComponent implements OnInit {
) {}
ngOnInit(): void {
- // Fetch available computing unit types
- this.localComputingUnitUri = `${window.location.protocol}//${window.location.hostname}${window.location.port ? `:${window.location.port}` : ""}/wsapi`;
- this.newComputingUnitName = "My Computing Unit";
- this.computingUnitService
- .getComputingUnitTypes()
- .pipe(untilDestroyed(this))
- .subscribe({
- next: ({ typeOptions }) => {
- this.availableComputingUnitTypes = typeOptions;
- // Set default selected type if available
- if (typeOptions.includes("kubernetes")) {
- this.selectedComputingUnitType = "kubernetes";
- } else if (typeOptions.length > 0) {
- this.selectedComputingUnitType = typeOptions[0];
- }
- },
- error: (err: unknown) =>
- this.notificationService.error(`Failed to fetch computing unit types: ${extractErrorMessage(err)}`),
- });
-
+ // GPU options drive the GPU row in the metrics popover. The shared
+ // create modal fetches these options itself and owns the user-facing
+ // error toast for this endpoint, so on failure this only logs and falls
+ // back to showing GPU metrics based on the unit's own allocation.
this.computingUnitService
.getComputingUnitLimitOptions()
.pipe(untilDestroyed(this))
.subscribe({
- next: ({ cpuLimitOptions, memoryLimitOptions, gpuLimitOptions }) => {
- this.cpuOptions = cpuLimitOptions;
- this.memoryOptions = memoryLimitOptions;
+ next: ({ gpuLimitOptions }) => {
this.gpuOptions = gpuLimitOptions;
-
- // fallback defaults
- this.selectedCpu = this.cpuOptions[0] ?? "1";
- this.selectedMemory = this.memoryOptions[0] ?? "1Gi";
- this.selectedGpu = this.gpuOptions[0] ?? "0";
-
- // Initialize JVM memory slider based on selected memory
- this.updateJvmMemorySlider();
+ this.gpuOptionsFetchFailed = false;
+ },
+ error: (err: unknown) => {
+ this.gpuOptionsFetchFailed = true;
+ console.error("Failed to fetch computing unit limit options for the GPU metric row", err);
},
- error: (err: unknown) =>
- this.notificationService.error(`Failed to fetch resource options: ${extractErrorMessage(err)}`),
});
// Subscribe to the current selected unit from the status service
@@ -384,68 +338,25 @@ export class ComputingUnitSelectionComponent implements OnInit {
// Determines if the GPU selection dropdown should be shown
showGpuSelection(): boolean {
+ // If the options fetch failed, err on the side of showing the GPU row —
+ // the metrics template additionally requires the unit's own GPU limit to
+ // be non-zero, so this cannot show a GPU row for a GPU-less unit.
+ if (this.gpuOptionsFetchFailed) {
+ return true;
+ }
// Don't show GPU selection if there are no options or only "0" option
return this.gpuOptions.length > 1 || (this.gpuOptions.length === 1 && this.gpuOptions[0] !== "0");
}
- showAddComputeUnitModalVisible(): void {
+ showAddComputeUnitModalVisible(defaultName?: string): void {
+ if (defaultName !== undefined && this.computingUnitCreateModal) {
+ this.computingUnitCreateModal.newComputingUnitName = defaultName;
+ }
this.addComputeUnitModalVisible = true;
}
- handleAddComputeUnitModalOk(): void {
- this.startComputingUnit();
- this.addComputeUnitModalVisible = false;
- }
-
- handleAddComputeUnitModalCancel(): void {
- this.addComputeUnitModalVisible = false;
- }
-
- isShmTooLarge(): boolean {
- return isComputingUnitShmTooLarge(this.selectedMemory, this.shmSizeValue, this.shmSizeUnit);
- }
-
- /**
- * Start a new computing unit.
- */
- startComputingUnit(): void {
- if (this.selectedComputingUnitType === "kubernetes" && this.newComputingUnitName.trim() === "") {
- this.notificationService.error("Name of the computing unit cannot be empty");
- return;
- }
-
- if (this.selectedComputingUnitType === "local" && this.localComputingUnitUri.trim() === "") {
- this.notificationService.error("URI for local computing unit cannot be empty");
- return;
- }
-
- if (!this.selectedComputingUnitType) {
- this.notificationService.error("Please select a valid computing unit type");
- return;
- }
-
- const request = {
- type: this.selectedComputingUnitType,
- name: this.newComputingUnitName,
- cpu: this.selectedCpu,
- memory: this.selectedMemory,
- gpu: this.selectedGpu,
- jvmMemorySize: this.selectedJvmMemorySize,
- shmSize: `${this.shmSizeValue}${this.shmSizeUnit}`,
- localUri: this.localComputingUnitUri,
- };
-
- this.computingUnitActionsService
- .create(request)
- .pipe(untilDestroyed(this))
- .subscribe({
- next: (unit: DashboardWorkflowComputingUnit) => {
- this.notificationService.success("Successfully created the new compute unit");
- this.selectComputingUnit(this.workflowId, unit.computingUnit.cuid);
- },
- error: (err: unknown) =>
- this.notificationService.error(`Failed to start computing unit: ${extractErrorMessage(err)}`),
- });
+ onComputingUnitCreated(unit: DashboardWorkflowComputingUnit): void {
+ this.selectComputingUnit(this.workflowId, unit.computingUnit.cuid);
}
openComputingUnitMetadataModal(unit: DashboardWorkflowComputingUnit) {
@@ -669,68 +580,6 @@ export class ComputingUnitSelectionComponent implements OnInit {
return getComputingUnitStatusTooltip(unit);
}
- // Called when the component initializes
- updateJvmMemorySlider(): void {
- this.resetJvmMemorySlider();
- }
-
- onJvmMemorySliderChange(value: number): void {
- // Ensure the value is one of the valid steps
- const validStep = findNearestValidStep(value, this.jvmMemorySteps);
- this.jvmMemorySliderValue = validStep;
- this.selectedJvmMemorySize = `${validStep}G`;
- }
-
- // Check if the maximum JVM memory value is selected
- isMaxJvmMemorySelected(): boolean {
- // Only show warning for larger memory sizes (>=4GB) where the slider is shown
- // AND when the maximum value is selected
- return this.showJvmMemorySlider && this.jvmMemorySliderValue === this.jvmMemoryMax && this.jvmMemoryMax >= 4;
- }
-
- // Completely reset the JVM memory slider based on the selected CU memory
- resetJvmMemorySlider(): void {
- const config = getJvmMemorySliderConfig(this.selectedMemory);
-
- this.jvmMemoryMax = config.jvmMemoryMax;
- this.showJvmMemorySlider = config.showJvmMemorySlider;
- this.jvmMemorySteps = config.jvmMemorySteps;
- this.jvmMemoryMarks = config.jvmMemoryMarks;
- this.jvmMemorySliderValue = config.jvmMemorySliderValue;
- this.selectedJvmMemorySize = config.selectedJvmMemorySize;
- }
-
- // Listen for memory selection changes
- onMemorySelectionChange(): void {
- // Store current JVM memory value for potential reuse
- const previousJvmMemory = this.jvmMemorySliderValue;
-
- // Reset slider configuration based on the new memory selection
- this.resetJvmMemorySlider();
-
- // For CU memory > 3GB, preserve previous value if valid and >= 2GB
- // Get the current memory in GB
- const memoryValue = parseResourceNumber(this.selectedMemory);
- const memoryUnit = parseResourceUnit(this.selectedMemory);
- let cuMemoryInGb = memoryUnit === "Gi" ? memoryValue : memoryUnit === "Mi" ? Math.floor(memoryValue / 1024) : 1;
-
- // Only try to preserve previous value for larger memory sizes where slider is shown
- if (
- cuMemoryInGb > 3 &&
- previousJvmMemory >= 2 &&
- previousJvmMemory <= this.jvmMemoryMax &&
- this.jvmMemorySteps.includes(previousJvmMemory)
- ) {
- this.jvmMemorySliderValue = previousJvmMemory;
- this.selectedJvmMemorySize = `${previousJvmMemory}G`;
- }
- }
-
- getCreateModalTitle(): string {
- if (!this.selectedComputingUnitType) return "Create Computing Unit";
- return unitTypeMessageTemplate[this.selectedComputingUnitType].createTitle;
- }
-
public async onClickOpenShareAccess(cuid: number): Promise<void> {
this.computingUnitActionsService.openShareAccessModal(cuid, true);
}