diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts
index 6a92d4be2e9..b6364e781f9 100644
--- a/frontend/src/app/app.module.ts
+++ b/frontend/src/app/app.module.ts
@@ -186,6 +186,7 @@ import { NzRadioModule } from "ng-zorro-antd/radio";
import { RegistrationRequestModalComponent } from "./common/service/user/registration-request-modal/registration-request-modal.component";
import { MarkdownDescriptionComponent } from "./dashboard/component/user/markdown-description/markdown-description.component";
import { UserComputingUnitComponent } from "./dashboard/component/user/user-computing-unit/user-computing-unit.component";
+import { UserComputingUnitListItemComponent } from "./dashboard/component/user/user-computing-unit/user-computing-unit-list-item/user-computing-unit-list-item.component";
registerLocaleData(en);
@@ -285,6 +286,7 @@ registerLocaleData(en);
RegistrationRequestModalComponent,
MarkdownDescriptionComponent,
UserComputingUnitComponent,
+ UserComputingUnitListItemComponent,
],
imports: [
BrowserModule,
diff --git a/frontend/src/app/common/util/computing-unit.util.ts b/frontend/src/app/common/util/computing-unit.util.ts
new file mode 100644
index 00000000000..28c55d67a82
--- /dev/null
+++ b/frontend/src/app/common/util/computing-unit.util.ts
@@ -0,0 +1,221 @@
+/**
+ * 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 { Component, inject } from "@angular/core";
+import { NZ_MODAL_DATA } from "ng-zorro-antd/modal";
+import { DashboardWorkflowComputingUnit } from "../../workspace/types/workflow-computing-unit";
+
+@Component({
+ template: `
+
+
+
+ Name
+ {{ unit.computingUnit.name }}
+
+
+ Status
+ {{ unit.status }}
+
+
+ Type
+ {{ unit.computingUnit.type }}
+
+
+ CPU Limit
+ {{ unit.computingUnit.resource.cpuLimit }}
+
+
+ Memory Limit
+ {{ unit.computingUnit.resource.memoryLimit }}
+
+
+ GPU Limit
+ {{ unit.computingUnit.resource.gpuLimit || "None" }}
+
+
+ JVM Memory
+ {{ unit.computingUnit.resource.jvmMemorySize }}
+
+
+ Shared Memory
+ {{ unit.computingUnit.resource.shmSize }}
+
+
+ Created
+ {{ createdAt }}
+
+
+ Access
+ {{ unit.isOwner ? "Owner" : unit.accessPrivilege }}
+
+
+
+ `,
+})
+export class ComputingUnitMetadataComponent {
+ readonly unit: DashboardWorkflowComputingUnit = inject(NZ_MODAL_DATA);
+ readonly createdAt = new Date(this.unit.computingUnit.creationTime).toLocaleString();
+}
+
+export function parseResourceUnit(resource: string): string {
+ // check if has a capacity (is a number followed by a unit)
+ if (!resource || resource === "NaN") return "NaN";
+ const re = /^(\d+(\.\d+)?)([a-zA-Z]*)$/;
+ const match = resource.match(re);
+ if (match) {
+ return match[3] || "";
+ }
+ return "";
+}
+
+export function parseResourceNumber(resource: string): number {
+ // check if has a capacity (is a number followed by a unit)
+ if (!resource || resource === "NaN") return 0;
+ const re = /^(\d+(\.\d+)?)([a-zA-Z]*)$/;
+ const match = resource.match(re);
+ if (match) {
+ return parseFloat(match[1]);
+ }
+ return 0;
+}
+
+export function cpuResourceConversion(from: string, toUnit: string): string {
+ // cpu conversions
+ type CpuUnit = "n" | "u" | "m" | "";
+ const cpuScales: { [key in CpuUnit]: number } = {
+ n: 1,
+ u: 1_000,
+ m: 1_000_000,
+ "": 1_000_000_000,
+ };
+ const fromUnit = parseResourceUnit(from) as CpuUnit;
+ const fromNumber = parseResourceNumber(from);
+
+ // Handle empty unit in input (means cores)
+ const effectiveFromUnit = (fromUnit || "") as CpuUnit;
+ const effectiveToUnit = (toUnit || "") as CpuUnit;
+
+ // Convert to base units (nanocores) then to target unit
+ const fromScaled = fromNumber * (cpuScales[effectiveFromUnit] || cpuScales["m"]);
+ const toScaled = fromScaled / (cpuScales[effectiveToUnit] || cpuScales[""]);
+
+ // For display purposes, use appropriate precision
+ if (effectiveToUnit === "") {
+ return toScaled.toFixed(4); // 4 decimal places for cores
+ } else if (effectiveToUnit === "m") {
+ return toScaled.toFixed(2); // 2 decimal places for millicores
+ } else {
+ return Math.round(toScaled).toString(); // Whole numbers for smaller units
+ }
+}
+
+export function memoryResourceConversion(from: string, toUnit: string): string {
+ // memory conversion
+ type MemoryUnit = "Ki" | "Mi" | "Gi" | "";
+ const memoryScales: { [key in MemoryUnit]: number } = {
+ "": 1,
+ Ki: 1024,
+ Mi: 1024 * 1024,
+ Gi: 1024 * 1024 * 1024,
+ };
+ const fromUnit = parseResourceUnit(from) as MemoryUnit;
+ const fromNumber = parseResourceNumber(from);
+
+ // Handle empty unit in input (means bytes)
+ const effectiveFromUnit = (fromUnit || "") as MemoryUnit;
+ const effectiveToUnit = (toUnit || "") as MemoryUnit;
+
+ // Convert to base units (bytes) then to target unit
+ const fromScaled = fromNumber * (memoryScales[effectiveFromUnit] || 1);
+ const toScaled = fromScaled / (memoryScales[effectiveToUnit] || 1);
+
+ // For memory, we want to show in the same format as the limit (typically X.XXX Gi)
+ return toScaled.toFixed(4);
+}
+
+export function cpuPercentage(usage: string, limit: string): number {
+ if (usage === "N/A" || limit === "N/A") return 0;
+
+ // Convert to the same unit for comparison
+ const displayUnit = ""; // Convert to cores for percentage calculation
+
+ // Use our existing conversion method to get values in the same unit
+ const usageValue = parseFloat(cpuResourceConversion(usage, displayUnit));
+ const limitValue = parseFloat(cpuResourceConversion(limit, displayUnit));
+
+ if (limitValue <= 0) return 0;
+
+ // Calculate percentage and ensure it doesn't exceed 100%
+ const percentage = (usageValue / limitValue) * 100;
+
+ return Math.min(percentage, 100);
+}
+
+export function memoryPercentage(usage: string, limit: string): number {
+ if (usage === "N/A" || limit === "N/A") return 0;
+
+ // Convert to the same unit for comparison
+ const displayUnit = "Gi"; // Convert to GiB for percentage calculation
+
+ // Use our existing conversion method to get values in the same unit
+ const usageValue = parseFloat(memoryResourceConversion(usage, displayUnit));
+ const limitValue = parseFloat(memoryResourceConversion(limit, displayUnit));
+
+ if (limitValue <= 0) return 0;
+
+ // Calculate percentage and ensure it doesn't exceed 100%
+ const percentage = (usageValue / limitValue) * 100;
+
+ return Math.min(percentage, 100);
+}
+
+export function findNearestValidStep(value: number, jvmMemorySteps: number[]): number {
+ if (jvmMemorySteps.length === 0) return 1;
+ if (jvmMemorySteps.includes(value)) return value;
+
+ // Find the closest step value
+ return jvmMemorySteps.reduce((prev, curr) => {
+ return Math.abs(curr - value) < Math.abs(prev - value) ? curr : prev;
+ });
+}
+
+export const unitTypeMessageTemplate = {
+ local: {
+ createTitle: "Connect to a Local Computing Unit",
+ terminateTitle: "Disconnect from Local Computing Unit",
+ terminateWarning: "", // no red warning
+ createSuccess: "Successfully connected to the local computing unit",
+ createFailure: "Failed to connect to the local computing unit",
+ terminateSuccess: "Disconnected from the local computing unit",
+ terminateFailure: "Failed to disconnect from the local computing unit",
+ terminateTooltip: "Disconnect from this computing unit",
+ },
+ kubernetes: {
+ createTitle: "Create Computing Unit",
+ terminateTitle: "Terminate Computing Unit",
+ terminateWarning:
+ "Warning: All execution results in this computing unit will be lost.
",
+ createSuccess: "Successfully created the Kubernetes computing unit",
+ createFailure: "Failed to create the Kubernetes computing unit",
+ terminateSuccess: "Terminated Kubernetes computing unit",
+ terminateFailure: "Failed to terminate Kubernetes computing unit",
+ terminateTooltip: "Terminate this computing unit",
+ },
+} as const;
diff --git a/frontend/src/app/dashboard/component/dashboard.component.html b/frontend/src/app/dashboard/component/dashboard.component.html
index 1b1bc910b43..4813f0b4e2c 100644
--- a/frontend/src/app/dashboard/component/dashboard.component.html
+++ b/frontend/src/app/dashboard/component/dashboard.component.html
@@ -106,7 +106,7 @@
- Computing Units
+ Compute
+
+
+
+
+
+
+
+
+
+
+ #{{ unit.cuid }}
+
+
+
+
+
+
+ {{ unit.name }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Created:
+ {{ formatTime(unit.creationTime) }}
+
+
+
+ CPU Limit:
+ {{ unit.resource.cpuLimit }}
+
+
+
+ Memory Limit:
+ {{ unit.resource.memoryLimit }}
+
+
+
+
+
+
+
+
CPU
+
+ {{getCpuValue() | number:'1.4-4'}}
+ / {{getCpuLimit()}} {{getCpuLimitUnit()}}
+ ({{getCpuPercentage() | number:'1.1-1'}}%)
+
+
+
+
RAM
+
+ {{getMemoryValue() | number:'1.4-4'}}
+ / {{getMemoryLimit()}} {{getMemoryLimitUnit()}}
+ ({{getMemoryPercentage() | number:'1.1-1'}}%)
+
+
+
+
GPU
+
{{getGpuLimit()}} GPU(s)
+
+
+
JVM Memory Size
+
{{getJvmMemorySize()}}
+
+
+
Shared Memory Size
+
{{getSharedMemorySize()}}
+
+
+
diff --git a/frontend/src/app/dashboard/component/user/user-computing-unit/user-computing-unit-list-item/user-computing-unit-list-item.component.scss b/frontend/src/app/dashboard/component/user/user-computing-unit/user-computing-unit-list-item/user-computing-unit-list-item.component.scss
new file mode 100644
index 00000000000..4d0b596fa78
--- /dev/null
+++ b/frontend/src/app/dashboard/component/user/user-computing-unit/user-computing-unit-list-item/user-computing-unit-list-item.component.scss
@@ -0,0 +1,230 @@
+/**
+ * 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 "../../../section-style";
+@import "../../../dashboard.component.scss";
+
+.computing-unit-list-item-card {
+ padding: 3px;
+ width: 100%;
+ background-color: white;
+ position: relative;
+ min-height: 65px;
+ height: auto;
+
+ &:hover {
+ background-color: #f0f0f0;
+
+ .edit-button {
+ display: flex;
+ }
+
+ &.resource-info {
+ opacity: 0.3;
+ }
+ }
+}
+
+.computing-unit-list-item-card:hover .button-group {
+ display: flex;
+ background-color: transparent;
+}
+
+.edit-button {
+ display: none;
+
+ &:hover {
+ display: block;
+ background-color: #d9d9d9;
+ }
+
+ button {
+ height: 25px;
+ }
+}
+
+.type-icon {
+ font-size: 30px;
+}
+
+.unit-id {
+ padding: 6px;
+}
+
+.resource-name-group {
+ min-width: 0;
+}
+
+.resource-name {
+ font-size: 17px;
+ font-weight: 600;
+ cursor: pointer;
+ text-decoration: none;
+}
+
+.resource-name:hover {
+ text-decoration: underline;
+}
+
+.resource-info {
+ font-size: 13px;
+ color: grey;
+}
+
+.truncate-single-line {
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ overflow: hidden;
+}
+
+.unit-name-edit-input {
+ width: 100%;
+ max-width: 200px;
+ font-size: inherit;
+ border: 1px solid #d9d9d9;
+ border-radius: 2px;
+ padding: 2px 6px;
+ background: white;
+}
+
+.resource-metrics {
+ width: 250px;
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ grid-template-rows: repeat(1, 1fr);
+ justify-content: start;
+ align-items: center;
+ gap: 5px;
+}
+
+.general-metric {
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ background-color: #f9fafb;
+ border-radius: 3px;
+ padding: 10px;
+ gap: 3px;
+}
+
+.metrics-container {
+ margin-right: 20px;
+}
+
+.metric-unit {
+ color: #888;
+ font-size: 0.9em;
+ margin-left: 4px;
+}
+
+.metric-percentage {
+ color: #555;
+ font-size: 0.9em;
+ margin-left: 6px;
+ font-weight: 500;
+}
+
+.metric-name {
+ font-size: 10px;
+ margin: 0;
+}
+
+.metric-value {
+ margin: 0;
+}
+
+.metric-item {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ height: 32px;
+ width: 120px;
+ min-width: 120px;
+ padding: 0;
+ border: none;
+ flex-shrink: 0; /* Prevent shrinking */
+}
+
+.metric-label {
+ font-size: 10px;
+ width: 45px;
+ flex-shrink: 0;
+ line-height: 1;
+ text-align: center;
+}
+
+.metric-bar-wrapper {
+ flex-grow: 1;
+ width: 90px;
+ min-width: 60px;
+ display: flex;
+ align-items: center;
+ padding: 0;
+ height: 8px;
+}
+
+.cpu-progress-bar,
+.memory-progress-bar {
+ width: 100%;
+ margin: 0 !important;
+ padding: 0 !important;
+ vertical-align: middle;
+}
+
+.button-group {
+ display: none;
+ position: absolute;
+ height: 70px;
+ min-width: 150px;
+ right: 0;
+ bottom: 0;
+ justify-content: right;
+ align-items: center;
+ transition: none;
+ z-index: 10;
+
+ button {
+ margin-right: 32px;
+ transition: none;
+ background-color: #e0e0e0;
+ border: 1px solid #d0d0d0;
+ border-radius: 8px;
+ }
+
+ button:hover {
+ background-color: #c7c7c7;
+ }
+}
+
+:host ::ng-deep .ant-progress {
+ width: 100%;
+}
+
+:host ::ng-deep .ant-progress-inner {
+ width: 100% !important;
+ background-color: #cacaca !important;
+}
+
+:host ::ng-deep .ant-badge-status-dot {
+ position: relative;
+ top: -1px;
+ vertical-align: middle;
+}
diff --git a/frontend/src/app/dashboard/component/user/user-computing-unit/user-computing-unit-list-item/user-computing-unit-list-item.component.ts b/frontend/src/app/dashboard/component/user/user-computing-unit/user-computing-unit-list-item/user-computing-unit-list-item.component.ts
new file mode 100644
index 00000000000..0805a05483e
--- /dev/null
+++ b/frontend/src/app/dashboard/component/user/user-computing-unit/user-computing-unit-list-item/user-computing-unit-list-item.component.ts
@@ -0,0 +1,338 @@
+/**
+ * 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 {
+ ChangeDetectorRef,
+ Component,
+ EventEmitter,
+ Input,
+ OnInit,
+ Output,
+ ViewChild,
+ ElementRef,
+} from "@angular/core";
+import { ComputingUnitStatusService } from "../../../../../workspace/service/computing-unit-status/computing-unit-status.service";
+import { extractErrorMessage } from "../../../../../common/util/error";
+import { NotificationService } from "../../../../../common/service/notification/notification.service";
+import { NzModalService } from "ng-zorro-antd/modal";
+import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy";
+import {
+ DashboardWorkflowComputingUnit,
+ WorkflowComputingUnit,
+} from "../../../../../workspace/types/workflow-computing-unit";
+import { WorkflowComputingUnitManagingService } from "../../../../../workspace/service/workflow-computing-unit/workflow-computing-unit-managing.service";
+import {
+ ComputingUnitMetadataComponent,
+ parseResourceUnit,
+ parseResourceNumber,
+ cpuResourceConversion,
+ memoryResourceConversion,
+ cpuPercentage,
+ memoryPercentage,
+} from "../../../../../common/util/computing-unit.util";
+import { GuiConfigService } from "../../../../../common/service/gui-config.service";
+import { ComputingUnitActionsService } from "../../../../service/user/computing-unit-actions/computing-unit-actions.service";
+
+@UntilDestroy()
+@Component({
+ selector: "texera-user-computing-unit-list-item",
+ templateUrl: "./user-computing-unit-list-item.component.html",
+ styleUrls: ["./user-computing-unit-list-item.component.scss"],
+})
+export class UserComputingUnitListItemComponent implements OnInit {
+ private _entry?: DashboardWorkflowComputingUnit;
+ editingNameOfUnit: number | null = null;
+ editingUnitName: string = "";
+ gpuOptions: string[] = [];
+ @Output() deleted = new EventEmitter();
+
+ @Input()
+ get entry(): DashboardWorkflowComputingUnit {
+ if (!this._entry) {
+ throw new Error("entry property must be provided to UserComputingUnitListItemComponent.");
+ }
+ return this._entry;
+ }
+
+ set entry(value: DashboardWorkflowComputingUnit) {
+ this._entry = value;
+ }
+
+ get unit(): WorkflowComputingUnit {
+ if (!this.entry.computingUnit) {
+ throw new Error(
+ "Incorrect type of DashboardEntry provided to UserComputingUnitListItemComponent. Entry must be computing unit."
+ );
+ }
+ return this.entry.computingUnit;
+ }
+
+ constructor(
+ private cdr: ChangeDetectorRef,
+ private modalService: NzModalService,
+ private notificationService: NotificationService,
+ private computingUnitService: WorkflowComputingUnitManagingService,
+ private computingUnitStatusService: ComputingUnitStatusService,
+ private computingUnitActionsService: ComputingUnitActionsService,
+ protected config: GuiConfigService
+ ) {}
+
+ ngOnInit(): void {
+ this.computingUnitService
+ .getComputingUnitLimitOptions()
+ .pipe(untilDestroyed(this))
+ .subscribe({
+ next: ({ gpuLimitOptions }) => {
+ this.gpuOptions = gpuLimitOptions ?? [];
+ },
+ error: (err: unknown) =>
+ this.notificationService.error(`Failed to fetch resource options: ${extractErrorMessage(err)}`),
+ });
+ }
+
+ @ViewChild("unitNameInput") unitNameInputRef?: ElementRef;
+
+ startEditingUnitName(entry: DashboardWorkflowComputingUnit): void {
+ if (!entry.isOwner) {
+ this.notificationService.error("Only owners can rename computing units");
+ return;
+ }
+
+ this.editingNameOfUnit = entry.computingUnit.cuid;
+ this.editingUnitName = entry.computingUnit.name;
+
+ // Force change detection and focus the input
+ this.cdr.detectChanges();
+ setTimeout(() => {
+ const input = document.querySelector(".unit-name-edit-input") as HTMLInputElement;
+ if (input) {
+ this.unitNameInputRef?.nativeElement.focus();
+ this.unitNameInputRef?.nativeElement.select();
+ }
+ }, 0);
+ }
+
+ confirmUpdateUnitName(cuid: number, newName: string): void {
+ const trimmedName = newName.trim();
+
+ if (!trimmedName) {
+ this.notificationService.error("Computing unit name cannot be empty");
+ this.editingNameOfUnit = null;
+ return;
+ }
+
+ if (trimmedName.length > 128) {
+ this.notificationService.error("Computing unit name cannot exceed 128 characters");
+ this.editingNameOfUnit = null;
+ return;
+ }
+
+ this.computingUnitService
+ .renameComputingUnit(cuid, trimmedName)
+ .pipe(untilDestroyed(this))
+ .subscribe({
+ next: () => {
+ if (this.entry.computingUnit.cuid === cuid) {
+ this.entry.computingUnit.name = trimmedName;
+ }
+ // Refresh the computing units list
+ this.computingUnitStatusService.refreshComputingUnitList();
+ },
+ error: (err: unknown) => {
+ this.notificationService.error(`Failed to rename computing unit: ${extractErrorMessage(err)}`);
+ },
+ })
+ .add(() => {
+ this.editingNameOfUnit = null;
+ this.editingUnitName = "";
+ });
+ }
+
+ cancelEditingUnitName(): void {
+ this.editingNameOfUnit = null;
+ this.editingUnitName = "";
+ }
+
+ openComputingUnitMetadataModal(entry: DashboardWorkflowComputingUnit) {
+ this.modalService.create({
+ nzTitle: "Computing Unit Information",
+ nzContent: ComputingUnitMetadataComponent,
+ nzData: entry,
+ nzFooter: null,
+ nzMaskClosable: true,
+ nzWidth: "600px",
+ });
+ }
+
+ getBadgeColor(status: string): string {
+ switch (status) {
+ case "Running":
+ return "green";
+ case "Pending":
+ return "gold";
+ default:
+ return "red";
+ }
+ }
+
+ getUnitStatusTooltip(entry: DashboardWorkflowComputingUnit): string {
+ switch (entry.status) {
+ case "Running":
+ return "Ready to use";
+ case "Pending":
+ return "Computing unit is starting up";
+ default:
+ return entry.status;
+ }
+ }
+
+ getCpuPercentage(): number {
+ return cpuPercentage(this.getCurrentComputingUnitCpuUsage(), this.getCurrentComputingUnitCpuLimit());
+ }
+
+ getMemoryPercentage(): number {
+ return memoryPercentage(this.getCurrentComputingUnitMemoryUsage(), this.getCurrentComputingUnitMemoryLimit());
+ }
+
+ getCpuStatus(): "success" | "exception" | "active" | "normal" {
+ const percentage = this.getCpuPercentage();
+ if (percentage > 90) return "exception";
+ if (percentage > 50) return "normal";
+ return "success";
+ }
+
+ getMemoryStatus(): "success" | "exception" | "active" | "normal" {
+ const percentage = this.getMemoryPercentage();
+ if (percentage > 90) return "exception";
+ if (percentage > 50) return "normal";
+ return "success";
+ }
+
+ getCurrentComputingUnitCpuUsage(): string {
+ return this.entry?.metrics?.cpuUsage ?? "N/A";
+ }
+
+ getCurrentComputingUnitMemoryUsage(): string {
+ return this.entry?.metrics?.memoryUsage ?? "N/A";
+ }
+
+ getCurrentComputingUnitCpuLimit(): string {
+ return this.unit?.resource?.cpuLimit ?? "N/A";
+ }
+
+ getCurrentComputingUnitMemoryLimit(): string {
+ return this.unit?.resource?.memoryLimit ?? "N/A";
+ }
+
+ getCurrentComputingUnitGpuLimit(): string {
+ return this.unit?.resource?.gpuLimit ?? "N/A";
+ }
+
+ getCurrentComputingUnitJvmMemorySize(): string {
+ return this.unit?.resource?.jvmMemorySize ?? "N/A";
+ }
+
+ getCurrentSharedMemorySize(): string {
+ return this.unit?.resource?.shmSize ?? "N/A";
+ }
+
+ getCpuLimit(): number {
+ return parseResourceNumber(this.getCurrentComputingUnitCpuLimit());
+ }
+
+ getGpuLimit(): string {
+ return this.getCurrentComputingUnitGpuLimit();
+ }
+
+ getJvmMemorySize(): string {
+ return this.getCurrentComputingUnitJvmMemorySize();
+ }
+
+ getSharedMemorySize(): string {
+ return this.getCurrentSharedMemorySize();
+ }
+
+ getCpuLimitUnit(): string {
+ const unit = parseResourceUnit(this.getCurrentComputingUnitCpuLimit());
+ if (unit === "") {
+ return "CPU";
+ }
+ return unit;
+ }
+
+ getMemoryLimit(): number {
+ return parseResourceNumber(this.getCurrentComputingUnitMemoryLimit());
+ }
+
+ getMemoryLimitUnit(): string {
+ return parseResourceUnit(this.getCurrentComputingUnitMemoryLimit());
+ }
+
+ getCpuValue(): number {
+ const usage = this.getCurrentComputingUnitCpuUsage();
+ const limit = this.getCurrentComputingUnitCpuLimit();
+ if (usage === "N/A" || limit === "N/A") return 0;
+ const displayUnit = this.getCpuLimitUnit() === "CPU" ? "" : this.getCpuLimitUnit();
+ const usageValue = cpuResourceConversion(usage, displayUnit);
+ return parseFloat(usageValue);
+ }
+
+ getMemoryValue(): number {
+ const usage = this.getCurrentComputingUnitMemoryUsage();
+ const limit = this.getCurrentComputingUnitMemoryLimit();
+ if (usage === "N/A" || limit === "N/A") return 0;
+ const displayUnit = this.getMemoryLimitUnit();
+ const usageValue = memoryResourceConversion(usage, displayUnit);
+ return parseFloat(usageValue);
+ }
+
+ showGpuSelection(): boolean {
+ return this.gpuOptions.length > 1 || (this.gpuOptions.length === 1 && this.gpuOptions[0] !== "0");
+ }
+
+ formatTime(timestamp: number | undefined): string {
+ if (timestamp === undefined) {
+ return "Unknown"; // Return "Unknown" if the timestamp is undefined
+ }
+
+ const currentTime = new Date().getTime();
+ const timeDifference = currentTime - timestamp;
+
+ const minutesAgo = Math.floor(timeDifference / (1000 * 60));
+ const hoursAgo = Math.floor(timeDifference / (1000 * 60 * 60));
+ const daysAgo = Math.floor(timeDifference / (1000 * 60 * 60 * 24));
+ const weeksAgo = Math.floor(daysAgo / 7);
+
+ if (minutesAgo < 60) {
+ return `${minutesAgo} minutes ago`;
+ } else if (hoursAgo < 24) {
+ return `${hoursAgo} hours ago`;
+ } else if (daysAgo < 7) {
+ return `${daysAgo} days ago`;
+ } else if (weeksAgo < 4) {
+ return `${weeksAgo} weeks ago`;
+ } else {
+ return new Date(timestamp).toLocaleDateString();
+ }
+ }
+
+ public async onClickOpenShareAccess(cuid: number): Promise {
+ this.computingUnitActionsService.openShareAccessModal(cuid, false);
+ }
+}
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 53c7cb3deda..4a17246a182 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
@@ -20,10 +20,213 @@
Computing Units
+
+
+
+ Create Computing Unit
+
+
+
+
+
+
+
+
+
+
+
+
+ Create Computing Unit
+
+
+
+
+ Computing Unit Type
+
+
+
+
+
+
+
+
+
+ Computing Unit Name
+
+
+
+ Select RAM Size
+
+
+
+
+
+
+
+ Select #CPU Core(s)
+
+
+
+
+
+
+
+ Select #GPU(s)
+
+
+
+
+
+
+
+
+ Adjust the Shared Memory Size
+
+
+
+
+
+
+
+
+
+
+
+ Shared memory cannot be greater than total memory.
+
+
+
+
+
JVM Memory Size: {{selectedJvmMemorySize}}
+
+
+
+
+
+
+
+
+
+
+
+
+ Computing Unit Name
+
+
+
+ Computing Unit URI
+
+
+
+
+
+
+
+ Cancel
+
+
+ Create
+
+
+
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 181db6355aa..772bdf21aa3 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,3 +27,73 @@
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: 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 a171078e9a2..53b6ecca1c3 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
@@ -20,22 +20,43 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { UserComputingUnitComponent } from "./user-computing-unit.component";
import { NzCardModule } from "ng-zorro-antd/card";
+import { NzModalService } from "ng-zorro-antd/modal";
+import { HttpClient } from "@angular/common/http";
+import { UserService } from "../../../../common/service/user/user.service";
+import { StubUserService } from "../../../../common/service/user/stub-user.service";
+import { commonTestProviders } from "../../../../common/testing/test-utils";
+import { WorkflowComputingUnitManagingService } from "../../../../workspace/service/workflow-computing-unit/workflow-computing-unit-managing.service";
+import { ComputingUnitStatusService } from "../../../../workspace/service/computing-unit-status/computing-unit-status.service";
+import { MockComputingUnitStatusService } from "../../../../workspace/service/computing-unit-status/mock-computing-unit-status.service";
describe("UserComputingUnitComponent", () => {
let component: UserComputingUnitComponent;
let fixture: ComponentFixture;
+ let mockComputingUnitService: jasmine.SpyObj;
beforeEach(async () => {
+ mockComputingUnitService = jasmine.createSpyObj([
+ "getComputingUnitTypes",
+ "getComputingUnitLimitOptions",
+ "createKubernetesBasedComputingUnit",
+ "createLocalComputingUnit",
+ ]);
+
await TestBed.configureTestingModule({
declarations: [UserComputingUnitComponent],
+ providers: [
+ NzModalService,
+ HttpClient,
+ { provide: UserService, useClass: StubUserService },
+ { provide: WorkflowComputingUnitManagingService, useValue: mockComputingUnitService },
+ { provide: ComputingUnitStatusService, useClass: MockComputingUnitStatusService },
+ ...commonTestProviders,
+ ],
imports: [NzCardModule],
}).compileComponents();
- });
- beforeEach(() => {
fixture = TestBed.createComponent(UserComputingUnitComponent);
component = fixture.componentInstance;
- fixture.detectChanges();
});
it("should create", () => {
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 1084a19d556..6b7b9676b7f 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,11 +17,325 @@
* under the License.
*/
-import { Component } from "@angular/core";
+import { Component, Input, OnInit } from "@angular/core";
+import { ComputingUnitStatusService } from "../../../../workspace/service/computing-unit-status/computing-unit-status.service";
+import { DashboardEntry } from "../../../type/dashboard-entry";
+import {
+ DashboardWorkflowComputingUnit,
+ WorkflowComputingUnitType,
+} from "../../../../workspace/types/workflow-computing-unit";
+import { extractErrorMessage } from "../../../../common/util/error";
+import { NotificationService } from "../../../../common/service/notification/notification.service";
+import { NzModalService } from "ng-zorro-antd/modal";
+import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy";
+import { UserService } from "../../../../common/service/user/user.service";
+import { WorkflowComputingUnitManagingService } from "../../../../workspace/service/workflow-computing-unit/workflow-computing-unit-managing.service";
+import {
+ parseResourceUnit,
+ parseResourceNumber,
+ findNearestValidStep,
+ unitTypeMessageTemplate,
+} from "../../../../common/util/computing-unit.util";
+import { ComputingUnitActionsService } from "../../../service/user/computing-unit-actions/computing-unit-actions.service";
+@UntilDestroy()
@Component({
selector: "texera-computing-unit-section",
templateUrl: "user-computing-unit.component.html",
styleUrls: ["user-computing-unit.component.scss"],
})
-export class UserComputingUnitComponent {}
+export class UserComputingUnitComponent implements OnInit {
+ public entries: DashboardEntry[] = [];
+ public isLogin = this.userService.isLogin();
+ public currentUid = this.userService.getCurrentUser()?.uid;
+
+ allComputingUnits: DashboardWorkflowComputingUnit[] = [];
+
+ // variables for creating a computing unit
+ 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
+ ) {
+ 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`;
+ 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))
+ .subscribe(units => {
+ this.allComputingUnits = units;
+ this.entries = units.map(u => new DashboardEntry(u));
+ });
+ }
+
+ terminateComputingUnit(cuid: number): void {
+ const unit = this.allComputingUnits.find(u => u.computingUnit.cuid === cuid);
+
+ if (!unit) {
+ this.notificationService.error("Invalid computing unit.");
+ return;
+ }
+
+ 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 {
+ const total = parseResourceNumber(this.selectedMemory);
+ const unit = parseResourceUnit(this.selectedMemory);
+ const memoryInMi = unit === "Gi" ? total * 1024 : total;
+ const shmInMi = this.shmSizeUnit === "Gi" ? this.shmSizeValue * 1024 : this.shmSizeValue;
+
+ return shmInMi > memoryInMi;
+ }
+
+ 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 {
+ // Parse memory limit to determine max JVM memory
+ const memoryValue = parseResourceNumber(this.selectedMemory);
+ const memoryUnit = parseResourceUnit(this.selectedMemory);
+
+ // Set max JVM memory to the total memory selected (in GB)
+ let cuMemoryInGb = 1; // Default to 1GB
+ if (memoryUnit === "Gi") {
+ cuMemoryInGb = memoryValue;
+ } else if (memoryUnit === "Mi") {
+ cuMemoryInGb = Math.max(1, Math.floor(memoryValue / 1024));
+ }
+
+ this.jvmMemoryMax = cuMemoryInGb;
+
+ // Special cases for smaller memory sizes (1-3GB)
+ if (cuMemoryInGb <= 3) {
+ // Don't show slider for small memory sizes
+ this.showJvmMemorySlider = false;
+
+ // Set JVM memory size to 1GB when CU memory is 1GB, otherwise set to 2GB
+ if (cuMemoryInGb === 1) {
+ this.jvmMemorySliderValue = 1;
+ this.selectedJvmMemorySize = "1G";
+ } else {
+ // For 2-3GB instances, use 2GB for JVM
+ this.jvmMemorySliderValue = 2;
+ this.selectedJvmMemorySize = "2G";
+ }
+
+ // Still calculate steps for completeness
+ this.jvmMemorySteps = [];
+ let value = 1;
+ while (value <= this.jvmMemoryMax) {
+ this.jvmMemorySteps.push(value);
+ value = value * 2;
+ }
+
+ // Update marks
+ this.jvmMemoryMarks = {};
+ this.jvmMemorySteps.forEach(step => {
+ this.jvmMemoryMarks[step] = `${step}G`;
+ });
+
+ return;
+ }
+
+ // For larger memory sizes (4GB+), show the slider
+ this.showJvmMemorySlider = true;
+
+ // Calculate binary steps (2,4,8,...) starting from 2GB
+ this.jvmMemorySteps = [];
+ let value = 2; // Start from 2GB for larger instances
+ while (value <= this.jvmMemoryMax) {
+ this.jvmMemorySteps.push(value);
+ value = value * 2;
+ }
+
+ // Update slider marks
+ this.jvmMemoryMarks = {};
+ this.jvmMemorySteps.forEach(step => {
+ this.jvmMemoryMarks[step] = `${step}G`;
+ });
+
+ // Always default to 2GB for larger memory sizes
+ this.jvmMemorySliderValue = 2;
+ this.selectedJvmMemorySize = "2G";
+ }
+
+ 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;
+ }
+}
diff --git a/frontend/src/app/dashboard/service/user/computing-unit-actions/computing-unit-actions.service.ts b/frontend/src/app/dashboard/service/user/computing-unit-actions/computing-unit-actions.service.ts
new file mode 100644
index 00000000000..3f88c368e62
--- /dev/null
+++ b/frontend/src/app/dashboard/service/user/computing-unit-actions/computing-unit-actions.service.ts
@@ -0,0 +1,114 @@
+import { Injectable } from "@angular/core";
+import { Observable } from "rxjs";
+import { NzModalService } from "ng-zorro-antd/modal";
+import { ShareAccessComponent } from "../../../component/user/share-access/share-access.component";
+import { WorkflowComputingUnitManagingService } from "../../../../workspace/service/workflow-computing-unit/workflow-computing-unit-managing.service";
+import {
+ DashboardWorkflowComputingUnit,
+ WorkflowComputingUnitType,
+} from "../../../../workspace/types/workflow-computing-unit";
+import { NotificationService } from "../../../../common/service/notification/notification.service";
+import { unitTypeMessageTemplate } from "../../../../common/util/computing-unit.util";
+import { ComputingUnitStatusService } from "../../../../workspace/service/computing-unit-status/computing-unit-status.service";
+import { extractErrorMessage } from "../../../../common/util/error";
+
+export interface StartComputingUnitRequest {
+ type: WorkflowComputingUnitType;
+ name: string;
+ cpu: string;
+ memory: string;
+ gpu: string;
+ jvmMemorySize: string;
+ shmSize: string;
+ localUri: string;
+}
+
+@Injectable({
+ providedIn: "root",
+})
+export class ComputingUnitActionsService {
+ constructor(
+ private modalService: NzModalService,
+ private computingUnitService: WorkflowComputingUnitManagingService,
+ private notificationService: NotificationService,
+ private computingUnitStatusService: ComputingUnitStatusService
+ ) {}
+
+ openShareAccessModal(cuid: number, inWorkspace: boolean = true): void {
+ this.modalService.create({
+ nzContent: ShareAccessComponent,
+ nzData: {
+ writeAccess: true,
+ type: "computing-unit",
+ id: cuid,
+ inWorkspace,
+ },
+ nzFooter: null,
+ nzTitle: "Share this computing unit with others",
+ nzCentered: true,
+ nzWidth: "800px",
+ });
+ }
+
+ create(request: StartComputingUnitRequest): Observable {
+ if (request.type === "kubernetes") {
+ return this.computingUnitService.createKubernetesBasedComputingUnit(
+ request.name,
+ request.cpu,
+ request.memory,
+ request.gpu,
+ request.jvmMemorySize,
+ request.shmSize
+ );
+ }
+
+ if (request.type === "local") {
+ return this.computingUnitService.createLocalComputingUnit(request.name, request.localUri);
+ }
+
+ throw new Error("Unsupported computing unit type");
+ }
+
+ confirmAndTerminate(cuid: number, unit: DashboardWorkflowComputingUnit): void {
+ if (!unit.computingUnit.uri) {
+ this.notificationService.error("Invalid computing unit.");
+ return;
+ }
+
+ const unitName = unit.computingUnit.name;
+ const unitType = unit?.computingUnit.type || "kubernetes"; // fallback
+ const templates = unitTypeMessageTemplate[unitType];
+
+ // Show confirmation modal
+ this.modalService.confirm({
+ nzTitle: templates.terminateTitle,
+ nzContent: templates.terminateWarning
+ ? `
+ Are you sure you want to terminate ${unitName} ?
+ ${templates.terminateWarning}
+ `
+ : `
+ Are you sure you want to disconnect from ${unitName} ?
+ `,
+ nzOkText: unitType === "local" ? "Disconnect" : "Terminate",
+ nzOkType: "primary",
+ nzOnOk: () => {
+ // Use the ComputingUnitStatusService to handle termination
+ // This will properly close the websocket before terminating the unit
+ this.computingUnitStatusService.terminateComputingUnit(cuid).subscribe({
+ next: (success: boolean) => {
+ if (success) {
+ this.notificationService.success(`Terminated Computing Unit: ${unitName}`);
+ } else {
+ this.notificationService.error("Failed to terminate computing unit");
+ }
+ },
+ error: (err: unknown) => {
+ this.notificationService.error(`Failed to terminate computing unit: ${extractErrorMessage(err)}`);
+ },
+ });
+ },
+ nzCancelText: "Cancel",
+ });
+ }
+}
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 a72dbab52c5..7cf5b221ce5 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
@@ -33,6 +33,18 @@ import { WorkflowExecutionsEntry } from "../../../dashboard/type/workflow-execut
import { ExecutionState } from "../../types/execute-workflow.interface";
import { ShareAccessComponent } from "../../../dashboard/component/user/share-access/share-access.component";
import { GuiConfigService } from "../../../common/service/gui-config.service";
+import { ComputingUnitActionsService } from "../../../dashboard/service/user/computing-unit-actions/computing-unit-actions.service";
+import {
+ ComputingUnitMetadataComponent,
+ parseResourceUnit,
+ parseResourceNumber,
+ findNearestValidStep,
+ unitTypeMessageTemplate,
+ cpuResourceConversion,
+ memoryResourceConversion,
+ cpuPercentage,
+ memoryPercentage,
+} from "../../../common/util/computing-unit.util";
@UntilDestroy()
@Component({
@@ -42,6 +54,7 @@ import { GuiConfigService } from "../../../common/service/gui-config.service";
})
export class ComputingUnitSelectionComponent implements OnInit {
// current workflow's Id, will change with wid in the workflowActionService.metadata
+ protected readonly unitTypeMessageTemplate = unitTypeMessageTemplate;
workflowId: number | undefined;
lastSelectedCuid?: number;
@@ -86,7 +99,8 @@ export class ComputingUnitSelectionComponent implements OnInit {
private computingUnitStatusService: ComputingUnitStatusService,
private workflowExecutionsService: WorkflowExecutionsService,
private modalService: NzModalService,
- private cdr: ChangeDetectorRef
+ private cdr: ChangeDetectorRef,
+ private computingUnitActionsService: ComputingUnitActionsService
) {}
ngOnInit(): void {
@@ -285,8 +299,8 @@ export class ComputingUnitSelectionComponent implements OnInit {
}
isShmTooLarge(): boolean {
- const total = this.parseResourceNumber(this.selectedMemory);
- const unit = this.parseResourceUnit(this.selectedMemory);
+ const total = parseResourceNumber(this.selectedMemory);
+ const unit = parseResourceUnit(this.selectedMemory);
const memoryInMi = unit === "Gi" ? total * 1024 : total;
const shmInMi = this.shmSizeUnit === "Gi" ? this.shmSizeValue * 1024 : this.shmSizeValue;
@@ -297,77 +311,50 @@ export class ComputingUnitSelectionComponent implements OnInit {
* Start a new computing unit.
*/
startComputingUnit(): void {
- // Validate based on computing unit type
- if (this.selectedComputingUnitType === "kubernetes") {
- if (this.newComputingUnitName.trim() == "") {
- this.notificationService.error("Name of the computing unit cannot be empty");
- return;
- }
+ if (this.selectedComputingUnitType === "kubernetes" && this.newComputingUnitName.trim() === "") {
+ this.notificationService.error("Name of the computing unit cannot be empty");
+ return;
+ }
- this.selectedShmSize = `${this.shmSizeValue}${this.shmSizeUnit}`;
-
- this.computingUnitService
- .createKubernetesBasedComputingUnit(
- this.newComputingUnitName,
- this.selectedCpu,
- this.selectedMemory,
- this.selectedGpu,
- this.selectedJvmMemorySize,
- this.selectedShmSize
- )
- .pipe(untilDestroyed(this))
- .subscribe({
- next: (unit: DashboardWorkflowComputingUnit) => {
- this.notificationService.success("Successfully created the new Kubernetes compute unit");
- // Select the newly created unit
- this.selectComputingUnit(this.workflowId, unit.computingUnit.cuid);
- },
- error: (err: unknown) =>
- this.notificationService.error(`Failed to start Kubernetes computing unit: ${extractErrorMessage(err)}`),
- });
- } else if (this.selectedComputingUnitType === "local") {
- // For local computing units, validate the URI
- if (!this.localComputingUnitUri || this.localComputingUnitUri.trim() === "") {
- this.notificationService.error("URI for local 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;
+ }
- this.computingUnitService
- .createLocalComputingUnit(this.newComputingUnitName, this.localComputingUnitUri)
- .pipe(untilDestroyed(this))
- .subscribe({
- next: (unit: DashboardWorkflowComputingUnit) => {
- this.notificationService.success("Successfully created the new local compute unit");
- // Select the newly created unit
- this.selectComputingUnit(this.workflowId, unit.computingUnit.cuid);
- },
- error: (err: unknown) =>
- this.notificationService.error(`Failed to start local computing unit: ${extractErrorMessage(err)}`),
- });
- } else {
+ 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)}`),
+ });
}
openComputingUnitMetadataModal(unit: DashboardWorkflowComputingUnit) {
this.modalService.create({
nzTitle: "Computing Unit Information",
- nzContent: `
-
-
- Name ${unit.computingUnit.name}
- Status ${unit.status}
- Type ${unit.computingUnit.type}
- CPU Limit ${unit.computingUnit.resource.cpuLimit}
- Memory Limit ${unit.computingUnit.resource.memoryLimit}
- GPU Limit ${unit.computingUnit.resource.gpuLimit || "None"}
- JVM Memory ${unit.computingUnit.resource.jvmMemorySize}
- Shared Memory ${unit.computingUnit.resource.shmSize}
- Created ${new Date(unit.computingUnit.creationTime).toLocaleString()}
- Access ${unit.isOwner ? "Owner" : unit.accessPrivilege}
-
-
- `,
+ nzContent: ComputingUnitMetadataComponent,
+ nzData: unit,
nzFooter: null,
nzMaskClosable: true,
nzWidth: "600px",
@@ -381,49 +368,12 @@ export class ComputingUnitSelectionComponent implements OnInit {
terminateComputingUnit(cuid: number): void {
const unit = this.allComputingUnits.find(u => u.computingUnit.cuid === cuid);
- if (!unit || !unit.computingUnit.uri) {
+ if (!unit) {
this.notificationService.error("Invalid computing unit.");
return;
}
- const unitName = unit.computingUnit.name;
- const unitType = unit?.computingUnit.type || "kubernetes"; // fallback
- const templates = this.unitTypeMessageTemplate[unitType];
-
- // Show confirmation modal
- this.modalService.confirm({
- nzTitle: templates.terminateTitle,
- nzContent: templates.terminateWarning
- ? `
- Are you sure you want to terminate ${unitName} ?
- ${templates.terminateWarning}
- `
- : `
- Are you sure you want to disconnect from ${unitName} ?
- `,
- nzOkText: unitType === "local" ? "Disconnect" : "Terminate",
- nzOkType: "primary",
- nzOnOk: () => {
- // Use the ComputingUnitStatusService to handle termination
- // This will properly close the websocket before terminating the unit
- this.computingUnitStatusService
- .terminateComputingUnit(cuid)
- .pipe(untilDestroyed(this))
- .subscribe({
- next: (success: boolean) => {
- if (success) {
- this.notificationService.success(`Terminated Computing Unit: ${unitName}`);
- } else {
- this.notificationService.error("Failed to terminate computing unit");
- }
- },
- error: (err: unknown) => {
- this.notificationService.error(`Failed to terminate computing unit: ${extractErrorMessage(err)}`);
- },
- });
- },
- nzCancelText: "Cancel",
- });
+ this.computingUnitActionsService.confirmAndTerminate(cuid, unit);
}
/**
@@ -503,82 +453,6 @@ export class ComputingUnitSelectionComponent implements OnInit {
this.editingUnitName = "";
}
- parseResourceUnit(resource: string): string {
- // check if has a capacity (is a number followed by a unit)
- if (!resource || resource === "NaN") return "NaN";
- const re = /^(\d+(\.\d+)?)([a-zA-Z]*)$/;
- const match = resource.match(re);
- if (match) {
- return match[3] || "";
- }
- return "";
- }
-
- parseResourceNumber(resource: string): number {
- // check if has a capacity (is a number followed by a unit)
- if (!resource || resource === "NaN") return 0;
- const re = /^(\d+(\.\d+)?)([a-zA-Z]*)$/;
- const match = resource.match(re);
- if (match) {
- return parseFloat(match[1]);
- }
- return 0;
- }
-
- cpuResourceConversion(from: string, toUnit: string): string {
- // cpu conversions
- type CpuUnit = "n" | "u" | "m" | "";
- const cpuScales: { [key in CpuUnit]: number } = {
- n: 1,
- u: 1_000,
- m: 1_000_000,
- "": 1_000_000_000,
- };
- const fromUnit = this.parseResourceUnit(from) as CpuUnit;
- const fromNumber = this.parseResourceNumber(from);
-
- // Handle empty unit in input (means cores)
- const effectiveFromUnit = (fromUnit || "") as CpuUnit;
- const effectiveToUnit = (toUnit || "") as CpuUnit;
-
- // Convert to base units (nanocores) then to target unit
- const fromScaled = fromNumber * (cpuScales[effectiveFromUnit] || cpuScales["m"]);
- const toScaled = fromScaled / (cpuScales[effectiveToUnit] || cpuScales[""]);
-
- // For display purposes, use appropriate precision
- if (effectiveToUnit === "") {
- return toScaled.toFixed(4); // 4 decimal places for cores
- } else if (effectiveToUnit === "m") {
- return toScaled.toFixed(2); // 2 decimal places for millicores
- } else {
- return Math.round(toScaled).toString(); // Whole numbers for smaller units
- }
- }
-
- memoryResourceConversion(from: string, toUnit: string): string {
- // memory conversion
- type MemoryUnit = "Ki" | "Mi" | "Gi" | "";
- const memoryScales: { [key in MemoryUnit]: number } = {
- "": 1,
- Ki: 1024,
- Mi: 1024 * 1024,
- Gi: 1024 * 1024 * 1024,
- };
- const fromUnit = this.parseResourceUnit(from) as MemoryUnit;
- const fromNumber = this.parseResourceNumber(from);
-
- // Handle empty unit in input (means bytes)
- const effectiveFromUnit = (fromUnit || "") as MemoryUnit;
- const effectiveToUnit = (toUnit || "") as MemoryUnit;
-
- // Convert to base units (bytes) then to target unit
- const fromScaled = fromNumber * (memoryScales[effectiveFromUnit] || 1);
- const toScaled = fromScaled / (memoryScales[effectiveToUnit] || 1);
-
- // For memory, we want to show in the same format as the limit (typically X.XXX Gi)
- return toScaled.toFixed(4);
- }
-
getCurrentComputingUnitCpuUsage(): string {
return this.selectedComputingUnit ? this.selectedComputingUnit.metrics.cpuUsage : "NaN";
}
@@ -622,7 +496,7 @@ export class ComputingUnitSelectionComponent implements OnInit {
}
getCpuLimit(): number {
- return this.parseResourceNumber(this.getCurrentComputingUnitCpuLimit());
+ return parseResourceNumber(this.getCurrentComputingUnitCpuLimit());
}
getGpuLimit(): string {
@@ -638,7 +512,7 @@ export class ComputingUnitSelectionComponent implements OnInit {
}
getCpuLimitUnit(): string {
- const unit = this.parseResourceUnit(this.getCurrentComputingUnitCpuLimit());
+ const unit = parseResourceUnit(this.getCurrentComputingUnitCpuLimit());
if (unit === "") {
return "CPU";
}
@@ -646,11 +520,11 @@ export class ComputingUnitSelectionComponent implements OnInit {
}
getMemoryLimit(): number {
- return this.parseResourceNumber(this.getCurrentComputingUnitMemoryLimit());
+ return parseResourceNumber(this.getCurrentComputingUnitMemoryLimit());
}
getMemoryLimitUnit(): string {
- return this.parseResourceUnit(this.getCurrentComputingUnitMemoryLimit());
+ return parseResourceUnit(this.getCurrentComputingUnitMemoryLimit());
}
getCpuValue(): number {
@@ -658,7 +532,7 @@ export class ComputingUnitSelectionComponent implements OnInit {
const limit = this.getCurrentComputingUnitCpuLimit();
if (usage === "N/A" || limit === "N/A") return 0;
const displayUnit = this.getCpuLimitUnit() === "CPU" ? "" : this.getCpuLimitUnit();
- const usageValue = this.cpuResourceConversion(usage, displayUnit);
+ const usageValue = cpuResourceConversion(usage, displayUnit);
return parseFloat(usageValue);
}
@@ -667,48 +541,16 @@ export class ComputingUnitSelectionComponent implements OnInit {
const limit = this.getCurrentComputingUnitMemoryLimit();
if (usage === "N/A" || limit === "N/A") return 0;
const displayUnit = this.getMemoryLimitUnit();
- const usageValue = this.memoryResourceConversion(usage, displayUnit);
+ const usageValue = memoryResourceConversion(usage, displayUnit);
return parseFloat(usageValue);
}
getCpuPercentage(): number {
- const usage = this.getCurrentComputingUnitCpuUsage();
- const limit = this.getCurrentComputingUnitCpuLimit();
- if (usage === "N/A" || limit === "N/A") return 0;
-
- // Convert to the same unit for comparison
- const displayUnit = ""; // Convert to cores for percentage calculation
-
- // Use our existing conversion method to get values in the same unit
- const usageValue = parseFloat(this.cpuResourceConversion(usage, displayUnit));
- const limitValue = parseFloat(this.cpuResourceConversion(limit, displayUnit));
-
- if (limitValue <= 0) return 0;
-
- // Calculate percentage and ensure it doesn't exceed 100%
- const percentage = (usageValue / limitValue) * 100;
-
- return Math.min(percentage, 100);
+ return cpuPercentage(this.getCurrentComputingUnitCpuUsage(), this.getCurrentComputingUnitCpuLimit());
}
getMemoryPercentage(): number {
- const usage = this.getCurrentComputingUnitMemoryUsage();
- const limit = this.getCurrentComputingUnitMemoryLimit();
- if (usage === "N/A" || limit === "N/A") return 0;
-
- // Convert to the same unit for comparison
- const displayUnit = "Gi"; // Convert to GiB for percentage calculation
-
- // Use our existing conversion method to get values in the same unit
- const usageValue = parseFloat(this.memoryResourceConversion(usage, displayUnit));
- const limitValue = parseFloat(this.memoryResourceConversion(limit, displayUnit));
-
- if (limitValue <= 0) return 0;
-
- // Calculate percentage and ensure it doesn't exceed 100%
- const percentage = (usageValue / limitValue) * 100;
-
- return Math.min(percentage, 100);
+ return memoryPercentage(this.getCurrentComputingUnitMemoryUsage(), this.getCurrentComputingUnitMemoryLimit());
}
getCpuStatus(): "success" | "exception" | "active" | "normal" {
@@ -752,20 +594,9 @@ export class ComputingUnitSelectionComponent implements OnInit {
this.resetJvmMemorySlider();
}
- // Find the nearest valid step value
- findNearestValidStep(value: number): number {
- if (this.jvmMemorySteps.length === 0) return 1;
- if (this.jvmMemorySteps.includes(value)) return value;
-
- // Find the closest step value
- return this.jvmMemorySteps.reduce((prev, curr) => {
- return Math.abs(curr - value) < Math.abs(prev - value) ? curr : prev;
- });
- }
-
onJvmMemorySliderChange(value: number): void {
// Ensure the value is one of the valid steps
- const validStep = this.findNearestValidStep(value);
+ const validStep = findNearestValidStep(value, this.jvmMemorySteps);
this.jvmMemorySliderValue = validStep;
this.selectedJvmMemorySize = `${validStep}G`;
}
@@ -780,8 +611,8 @@ export class ComputingUnitSelectionComponent implements OnInit {
// Completely reset the JVM memory slider based on the selected CU memory
resetJvmMemorySlider(): void {
// Parse memory limit to determine max JVM memory
- const memoryValue = this.parseResourceNumber(this.selectedMemory);
- const memoryUnit = this.parseResourceUnit(this.selectedMemory);
+ const memoryValue = parseResourceNumber(this.selectedMemory);
+ const memoryUnit = parseResourceUnit(this.selectedMemory);
// Set max JVM memory to the total memory selected (in GB)
let cuMemoryInGb = 1; // Default to 1GB
@@ -857,8 +688,8 @@ export class ComputingUnitSelectionComponent implements OnInit {
// For CU memory > 3GB, preserve previous value if valid and >= 2GB
// Get the current memory in GB
- const memoryValue = this.parseResourceNumber(this.selectedMemory);
- const memoryUnit = this.parseResourceUnit(this.selectedMemory);
+ 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
@@ -875,23 +706,11 @@ export class ComputingUnitSelectionComponent implements OnInit {
getCreateModalTitle(): string {
if (!this.selectedComputingUnitType) return "Create Computing Unit";
- return this.unitTypeMessageTemplate[this.selectedComputingUnitType].createTitle;
+ return unitTypeMessageTemplate[this.selectedComputingUnitType].createTitle;
}
public async onClickOpenShareAccess(cuid: number): Promise {
- this.modalService.create({
- nzContent: ShareAccessComponent,
- nzData: {
- writeAccess: true,
- type: "computing-unit",
- id: cuid,
- inWorkspace: true,
- },
- nzFooter: null,
- nzTitle: "Share this computing unit with others",
- nzCentered: true,
- nzWidth: "800px",
- });
+ this.computingUnitActionsService.openShareAccessModal(cuid, true);
}
onDropdownVisibilityChange(visible: boolean): void {
@@ -899,28 +718,4 @@ export class ComputingUnitSelectionComponent implements OnInit {
this.computingUnitStatusService.refreshComputingUnitList();
}
}
-
- unitTypeMessageTemplate = {
- local: {
- createTitle: "Connect to a Local Computing Unit",
- terminateTitle: "Disconnect from Local Computing Unit",
- terminateWarning: "", // no red warning
- createSuccess: "Successfully connected to the local computing unit",
- createFailure: "Failed to connect to the local computing unit",
- terminateSuccess: "Disconnected from the local computing unit",
- terminateFailure: "Failed to disconnect from the local computing unit",
- terminateTooltip: "Disconnect from this computing unit",
- },
- kubernetes: {
- createTitle: "Create Computing Unit",
- terminateTitle: "Terminate Computing Unit",
- terminateWarning:
- "Warning: All execution results in this computing unit will be lost.
",
- createSuccess: "Successfully created the Kubernetes computing unit",
- createFailure: "Failed to create the Kubernetes computing unit",
- terminateSuccess: "Terminated Kubernetes computing unit",
- terminateFailure: "Failed to terminate Kubernetes computing unit",
- terminateTooltip: "Terminate this computing unit",
- },
- } as const;
}