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 }} + +
    + + +
    + +
    + + +
    + +
    +
    + CPU +
    + +
    +
    + +
    + Memory +
    + +
    +
    +
    + +
    + 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 + +
    + +
    + 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 + +
    +
    +
    +
    + + + + +
    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; }