blob: a2843a51bf36f1e6d72235d257987a4e8732033d [file]
/**
* 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, OnInit, NgZone } from "@angular/core";
import { take } from "rxjs/operators";
import { WorkflowComputingUnitManagingService } from "../../../common/service/computing-unit/workflow-computing-unit/workflow-computing-unit-managing.service";
import {
DashboardWorkflowComputingUnit,
WorkflowComputingUnitType,
} from "../../../common/type/workflow-computing-unit";
import { NotificationService } from "../../../common/service/notification/notification.service";
import { DEFAULT_WORKFLOW, WorkflowActionService } from "../../service/workflow-graph/model/workflow-action.service";
import { isDefined } from "../../../common/util/predicate";
import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy";
import { extractErrorMessage } from "../../../common/util/error";
import { ComputingUnitStatusService } from "../../../common/service/computing-unit/computing-unit-status/computing-unit-status.service";
import { NzModalService, NzModalComponent, NzModalContentDirective } from "ng-zorro-antd/modal";
import { WorkflowExecutionsService } from "../../../dashboard/service/user/workflow-executions/workflow-executions.service";
import { WorkflowExecutionsEntry } from "../../../dashboard/type/workflow-executions-entry";
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 "../../../common/service/computing-unit/computing-unit-actions/computing-unit-actions.service";
import {
ComputingUnitMetadataComponent,
parseResourceUnit,
parseResourceNumber,
findNearestValidStep,
unitTypeMessageTemplate,
cpuResourceConversion,
memoryResourceConversion,
cpuPercentage,
memoryPercentage,
validateName,
getComputingUnitBadgeColor,
getComputingUnitStatusTooltip,
getComputingUnitCpuStatus,
getComputingUnitMemoryStatus,
getComputingUnitCpuLimitUnit,
isComputingUnitShmTooLarge,
getJvmMemorySliderConfig,
} from "../../../common/util/computing-unit.util";
import { PvePackageResponse, WorkflowPveService } from "../../service/virtual-environment/virtual-environment.service";
import { NgClass, NgIf, NgFor, DecimalPipe, TitleCasePipe } from "@angular/common";
import { ɵNzTransitionPatchDirective } from "ng-zorro-antd/core/transition-patch";
import { NzPopoverDirective } from "ng-zorro-antd/popover";
import { NzProgressComponent } from "ng-zorro-antd/progress";
import { NzSpaceCompactItemDirective } from "ng-zorro-antd/space";
import { NzButtonComponent } from "ng-zorro-antd/button";
import { NzWaveDirective } from "ng-zorro-antd/core/wave";
import { NzDropdownDirective, NzDropdownMenuComponent } from "ng-zorro-antd/dropdown";
import { UserAvatarComponent } from "../../../dashboard/component/user/user-avatar/user-avatar.component";
import { NzBadgeComponent } from "ng-zorro-antd/badge";
import { NzTooltipDirective } from "ng-zorro-antd/tooltip";
import { NzIconDirective } from "ng-zorro-antd/icon";
import { NzMenuDirective, NzMenuItemComponent, NzMenuDividerDirective } from "ng-zorro-antd/menu";
import { NzInputDirective } from "ng-zorro-antd/input";
import { NzSelectComponent, NzOptionComponent } from "ng-zorro-antd/select";
import { FormsModule } from "@angular/forms";
import { NzSliderComponent } from "ng-zorro-antd/slider";
import { NzAlertComponent } from "ng-zorro-antd/alert";
import { NzCollapseComponent, NzCollapsePanelComponent } from "ng-zorro-antd/collapse";
type PveUserPackageRow = {
name: string;
versionOp?: "==" | ">=" | "<=";
version?: string;
deleteToggle?: boolean;
};
type PveDraft = {
name: string;
userPackages: PveUserPackageRow[];
newPackages: PveUserPackageRow[];
deletingPackages: { name: string; version: string }[];
pipOutput: string;
prettyPipOutput: string;
expanded: boolean;
socket?: WebSocket;
isInstalling: boolean;
isLocked: boolean;
};
@UntilDestroy()
@Component({
selector: "texera-computing-unit-selection",
templateUrl: "./computing-unit-selection.component.html",
styleUrls: ["./computing-unit-selection.component.scss"],
imports: [
NgClass,
NgIf,
ɵNzTransitionPatchDirective,
NzPopoverDirective,
NzProgressComponent,
NzSpaceCompactItemDirective,
NzButtonComponent,
NzWaveDirective,
NzDropdownDirective,
UserAvatarComponent,
NzBadgeComponent,
NzTooltipDirective,
NzIconDirective,
NzDropdownMenuComponent,
NzMenuDirective,
NgFor,
NzMenuItemComponent,
NzInputDirective,
NzMenuDividerDirective,
NzModalComponent,
NzSelectComponent,
FormsModule,
NzOptionComponent,
NzSliderComponent,
NzAlertComponent,
NzModalContentDirective,
NzCollapseComponent,
NzCollapsePanelComponent,
DecimalPipe,
TitleCasePipe,
],
})
export class ComputingUnitSelectionComponent implements OnInit {
// variables for creating a virtual environment
pves: PveDraft[] = [];
systemPackages: { name: string; version: string }[] = [];
pveModalVisible = false;
// current workflow's Id, will change with wid in the workflowActionService.metadata
protected readonly unitTypeMessageTemplate = unitTypeMessageTemplate;
workflowId: number | undefined;
lastSelectedCuid?: number;
selectedComputingUnit: DashboardWorkflowComputingUnit | null = null;
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
// variables for renaming a computing unit
editingNameOfUnit: number | null = null;
editingUnitName: string = "";
// JVM memory slider configuration
jvmMemorySliderValue: number = 1; // Initial value in GB
jvmMemoryMarks: { [key: number]: string } = { 1: "1G" };
jvmMemoryMax: number = 1;
jvmMemorySteps: number[] = [1]; // Available steps in binary progression (1,2,4,8...)
showJvmMemorySlider: boolean = false; // Whether to show the slider
// cpu&memory limit options from backend
cpuOptions: string[] = [];
memoryOptions: string[] = [];
gpuOptions: string[] = []; // Add GPU options array
constructor(
private computingUnitService: WorkflowComputingUnitManagingService,
private notificationService: NotificationService,
protected config: GuiConfigService,
private workflowActionService: WorkflowActionService,
private computingUnitStatusService: ComputingUnitStatusService,
private workflowExecutionsService: WorkflowExecutionsService,
private modalService: NzModalService,
private cdr: ChangeDetectorRef,
private computingUnitActionsService: ComputingUnitActionsService,
private workflowPveService: WorkflowPveService,
private ngZone: NgZone
) {}
ngOnInit(): void {
// Fetch available computing unit types
this.localComputingUnitUri = `${window.location.protocol}//${window.location.hostname}${window.location.port ? `:${window.location.port}` : ""}/wsapi`;
this.newComputingUnitName = "My Computing Unit";
this.computingUnitService
.getComputingUnitTypes()
.pipe(untilDestroyed(this))
.subscribe({
next: ({ typeOptions }) => {
this.availableComputingUnitTypes = typeOptions;
// Set default selected type if available
if (typeOptions.includes("kubernetes")) {
this.selectedComputingUnitType = "kubernetes";
} else if (typeOptions.length > 0) {
this.selectedComputingUnitType = typeOptions[0];
}
},
error: (err: unknown) =>
this.notificationService.error(`Failed to fetch computing unit types: ${extractErrorMessage(err)}`),
});
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)}`),
});
// Subscribe to the current selected unit from the status service
this.computingUnitStatusService
.getSelectedComputingUnit()
.pipe(untilDestroyed(this))
.subscribe(unit => {
const wid = this.workflowActionService.getWorkflowMetadata()?.wid;
// ── compare with the *previous* cuid, not the one we are just about to store ──
if (isDefined(wid) && unit?.computingUnit.cuid !== this.lastSelectedCuid) {
this.updateWorkflowModificationStatus(wid);
}
// update local caches **after** the comparison
this.lastSelectedCuid = unit?.computingUnit.cuid;
this.selectedComputingUnit = unit;
});
this.computingUnitStatusService
.getAllComputingUnits()
.pipe(untilDestroyed(this))
.subscribe(units => {
this.allComputingUnits = units;
});
this.registerWorkflowMetadataSubscription();
}
/**
* Helper to query backend and (de)activate modification status.
*/
private updateWorkflowModificationStatus(wid: number): void {
this.workflowExecutionsService
.retrieveWorkflowExecutions(wid, [ExecutionState.Running, ExecutionState.Initializing])
.pipe(take(1), untilDestroyed(this))
.subscribe(execList => {
if (execList.length > 0) {
this.notificationService.info(
"There are ongoing executions on this workflow. Modification of the workflow is currently disabled."
);
this.workflowActionService.disableWorkflowModification();
} else {
this.workflowActionService.enableWorkflowModification();
}
});
}
/**
* utility function used for displaying the computing unit
*/
public trackByCuid(_idx: number, unit: DashboardWorkflowComputingUnit): number {
return unit.computingUnit.cuid;
}
/**
* Registers a subscription to listen for workflow metadata changes;
* Calls `selectComputingUnit` when the `wid` changes;
* The wid can change by time because of the workspace rendering;
*/
private registerWorkflowMetadataSubscription(): void {
this.workflowActionService
.workflowMetaDataChanged()
.pipe(untilDestroyed(this))
.subscribe(() => {
const wid = this.workflowActionService.getWorkflowMetadata()?.wid;
if (wid !== this.workflowId) {
this.workflowId = wid;
if (isDefined(this.workflowId) && this.workflowId !== DEFAULT_WORKFLOW.wid) {
this.workflowExecutionsService
.retrieveLatestWorkflowExecution(this.workflowId)
.pipe(untilDestroyed(this))
.subscribe({
next: (latestWorkflowExecution: WorkflowExecutionsEntry) => {
this.selectComputingUnit(this.workflowId, latestWorkflowExecution.cuId);
},
error: (err: unknown) => {
const runningUnit = this.allComputingUnits.find(unit => unit.status === "Running");
if (runningUnit) {
this.selectComputingUnit(this.workflowId, runningUnit.computingUnit.cuid);
}
},
});
}
}
});
}
/**
* Called whenever the selected computing unit changes.
*/
selectComputingUnit(wid: number | undefined, cuid: number | undefined): void {
if (isDefined(cuid) && wid !== DEFAULT_WORKFLOW.wid) {
this.computingUnitStatusService.selectComputingUnit(wid, cuid);
}
}
isComputingUnitRunning(): boolean {
return this.selectedComputingUnit != null && this.selectedComputingUnit.status === "Running";
}
getButtonText(): string {
if (!this.selectedComputingUnit) {
return "Connect";
} else {
return this.selectedComputingUnit.computingUnit.name;
}
}
computeStatus(): string {
if (!this.selectedComputingUnit) {
return "processing";
}
const status = this.selectedComputingUnit.status;
if (status === "Running") {
return "success";
} else if (status === "Pending" || status === "Terminating") {
return "warning";
} else {
return "error";
}
}
/**
* Determines if a unit cannot be selected (disabled in the dropdown)
*/
cannotSelectUnit(unit: DashboardWorkflowComputingUnit): boolean {
// Only allow selecting units that are in the Running state
return unit.status !== "Running";
}
isSelectedUnit(unit: DashboardWorkflowComputingUnit): boolean {
return unit.computingUnit.uri === this.selectedComputingUnit?.computingUnit.uri;
}
// Determines if the GPU selection dropdown should be shown
showGpuSelection(): boolean {
// Don't show GPU selection if there are no options or only "0" option
return this.gpuOptions.length > 1 || (this.gpuOptions.length === 1 && this.gpuOptions[0] !== "0");
}
showAddComputeUnitModalVisible(): void {
this.addComputeUnitModalVisible = true;
}
handleAddComputeUnitModalOk(): void {
this.startComputingUnit();
this.addComputeUnitModalVisible = false;
}
handleAddComputeUnitModalCancel(): void {
this.addComputeUnitModalVisible = false;
}
isShmTooLarge(): boolean {
return isComputingUnitShmTooLarge(this.selectedMemory, this.shmSizeValue, this.shmSizeUnit);
}
/**
* Start a new computing unit.
*/
startComputingUnit(): void {
if (this.selectedComputingUnitType === "kubernetes" && this.newComputingUnitName.trim() === "") {
this.notificationService.error("Name of the computing unit cannot be empty");
return;
}
if (this.selectedComputingUnitType === "local" && this.localComputingUnitUri.trim() === "") {
this.notificationService.error("URI for local computing unit cannot be empty");
return;
}
if (!this.selectedComputingUnitType) {
this.notificationService.error("Please select a valid computing unit type");
return;
}
const request = {
type: this.selectedComputingUnitType,
name: this.newComputingUnitName,
cpu: this.selectedCpu,
memory: this.selectedMemory,
gpu: this.selectedGpu,
jvmMemorySize: this.selectedJvmMemorySize,
shmSize: `${this.shmSizeValue}${this.shmSizeUnit}`,
localUri: this.localComputingUnitUri,
};
this.computingUnitActionsService
.create(request)
.pipe(untilDestroyed(this))
.subscribe({
next: (unit: DashboardWorkflowComputingUnit) => {
this.notificationService.success("Successfully created the new compute unit");
this.selectComputingUnit(this.workflowId, unit.computingUnit.cuid);
},
error: (err: unknown) =>
this.notificationService.error(`Failed to start computing unit: ${extractErrorMessage(err)}`),
});
}
openComputingUnitMetadataModal(unit: DashboardWorkflowComputingUnit) {
this.modalService.create({
nzTitle: "Computing Unit Information",
nzContent: ComputingUnitMetadataComponent,
nzData: unit,
nzFooter: null,
nzMaskClosable: true,
nzWidth: "600px",
});
}
/**
* Terminate a computing unit.
* @param cuid The CUID of the unit to terminate.
*/
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);
if (this.selectedComputingUnit?.computingUnit.type === "local") {
this.workflowPveService
.deleteEnvironments(cuid)
.pipe(untilDestroyed(this))
.subscribe({
error: (err: unknown) => {
console.error("Failed to delete PVE environments", err);
},
});
}
}
/**
* Start editing the name of a computing unit.
*/
startEditingUnitName(unit: DashboardWorkflowComputingUnit): void {
if (!unit.isOwner) {
this.notificationService.error("Only owners can rename computing units");
return;
}
this.editingNameOfUnit = unit.computingUnit.cuid;
this.editingUnitName = unit.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) {
input.focus();
input.select();
}
}, 0);
}
/**
* Confirm the new name and update the computing unit.
*/
confirmUpdateUnitName(cuid: number, newName: string): void {
const trimmedName = newName.trim();
const validationError = validateName(trimmedName);
if (validationError) {
this.notificationService.error(validationError);
this.cancelEditingUnitName();
return;
}
this.computingUnitService
.renameComputingUnit(cuid, trimmedName)
.pipe(untilDestroyed(this))
.subscribe({
next: () => {
this.notificationService.success("Successfully renamed computing unit");
// Update the local unit name immediately for better UX
const unit = this.allComputingUnits.find(u => u.computingUnit.cuid === cuid);
if (unit) {
unit.computingUnit.name = trimmedName;
}
// Also update the selected unit if it's the one being renamed
if (this.selectedComputingUnit?.computingUnit.cuid === cuid) {
this.selectedComputingUnit.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 = "";
});
}
/**
* Cancel editing the computing unit name.
*/
cancelEditingUnitName(): void {
this.editingNameOfUnit = null;
this.editingUnitName = "";
}
getCurrentComputingUnitCpuUsage(): string {
return this.selectedComputingUnit ? this.selectedComputingUnit.metrics.cpuUsage : "NaN";
}
getCurrentComputingUnitMemoryUsage(): string {
return this.selectedComputingUnit ? this.selectedComputingUnit.metrics.memoryUsage : "NaN";
}
getCurrentComputingUnitCpuLimit(): string {
return this.selectedComputingUnit ? this.selectedComputingUnit.computingUnit.resource.cpuLimit : "NaN";
}
getCurrentComputingUnitMemoryLimit(): string {
return this.selectedComputingUnit ? this.selectedComputingUnit.computingUnit.resource.memoryLimit : "NaN";
}
getCurrentComputingUnitGpuLimit(): string {
return this.selectedComputingUnit ? this.selectedComputingUnit.computingUnit.resource.gpuLimit : "NaN";
}
getCurrentComputingUnitJvmMemorySize(): string {
return this.selectedComputingUnit ? this.selectedComputingUnit.computingUnit.resource.jvmMemorySize : "NaN";
}
getCurrentSharedMemorySize(): string {
return this.selectedComputingUnit ? this.selectedComputingUnit.computingUnit.resource.shmSize : "NaN";
}
/**
* Returns the badge color based on computing unit status
*/
getBadgeColor(status: string): string {
return getComputingUnitBadgeColor(status);
}
getCpuLimit(): number {
return parseResourceNumber(this.getCurrentComputingUnitCpuLimit());
}
getGpuLimit(): string {
return this.getCurrentComputingUnitGpuLimit();
}
getJvmMemorySize(): string {
return this.getCurrentComputingUnitJvmMemorySize();
}
getSharedMemorySize(): string {
return this.getCurrentSharedMemorySize();
}
getCpuLimitUnit(): string {
return getComputingUnitCpuLimitUnit(parseResourceUnit(this.getCurrentComputingUnitCpuLimit()));
}
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);
}
getCpuPercentage(): number {
return cpuPercentage(this.getCurrentComputingUnitCpuUsage(), this.getCurrentComputingUnitCpuLimit());
}
getMemoryPercentage(): number {
return memoryPercentage(this.getCurrentComputingUnitMemoryUsage(), this.getCurrentComputingUnitMemoryLimit());
}
getCpuStatus(): "success" | "exception" | "active" | "normal" {
return getComputingUnitCpuStatus(this.getCpuPercentage());
}
getMemoryStatus(): "success" | "exception" | "active" | "normal" {
return getComputingUnitMemoryStatus(this.getMemoryPercentage());
}
getCpuUnit(): string {
return this.getCpuLimitUnit() === "CPU" ? "Cores" : this.getCpuLimitUnit();
}
getMemoryUnit(): string {
return this.getMemoryLimitUnit() === "" ? "B" : this.getMemoryLimitUnit();
}
/**
* Returns a descriptive tooltip for a specific unit's status
*/
getUnitStatusTooltip(unit: DashboardWorkflowComputingUnit): string {
return getComputingUnitStatusTooltip(unit);
}
// Called when the component initializes
updateJvmMemorySlider(): void {
this.resetJvmMemorySlider();
}
onJvmMemorySliderChange(value: number): void {
// Ensure the value is one of the valid steps
const validStep = findNearestValidStep(value, this.jvmMemorySteps);
this.jvmMemorySliderValue = validStep;
this.selectedJvmMemorySize = `${validStep}G`;
}
// Check if the maximum JVM memory value is selected
isMaxJvmMemorySelected(): boolean {
// Only show warning for larger memory sizes (>=4GB) where the slider is shown
// AND when the maximum value is selected
return this.showJvmMemorySlider && this.jvmMemorySliderValue === this.jvmMemoryMax && this.jvmMemoryMax >= 4;
}
// Completely reset the JVM memory slider based on the selected CU memory
resetJvmMemorySlider(): void {
const config = getJvmMemorySliderConfig(this.selectedMemory);
this.jvmMemoryMax = config.jvmMemoryMax;
this.showJvmMemorySlider = config.showJvmMemorySlider;
this.jvmMemorySteps = config.jvmMemorySteps;
this.jvmMemoryMarks = config.jvmMemoryMarks;
this.jvmMemorySliderValue = config.jvmMemorySliderValue;
this.selectedJvmMemorySize = config.selectedJvmMemorySize;
}
// Listen for memory selection changes
onMemorySelectionChange(): void {
// Store current JVM memory value for potential reuse
const previousJvmMemory = this.jvmMemorySliderValue;
// Reset slider configuration based on the new memory selection
this.resetJvmMemorySlider();
// For CU memory > 3GB, preserve previous value if valid and >= 2GB
// Get the current memory in GB
const memoryValue = parseResourceNumber(this.selectedMemory);
const memoryUnit = parseResourceUnit(this.selectedMemory);
let cuMemoryInGb = memoryUnit === "Gi" ? memoryValue : memoryUnit === "Mi" ? Math.floor(memoryValue / 1024) : 1;
// Only try to preserve previous value for larger memory sizes where slider is shown
if (
cuMemoryInGb > 3 &&
previousJvmMemory >= 2 &&
previousJvmMemory <= this.jvmMemoryMax &&
this.jvmMemorySteps.includes(previousJvmMemory)
) {
this.jvmMemorySliderValue = previousJvmMemory;
this.selectedJvmMemorySize = `${previousJvmMemory}G`;
}
}
getCreateModalTitle(): string {
if (!this.selectedComputingUnitType) return "Create Computing Unit";
return unitTypeMessageTemplate[this.selectedComputingUnitType].createTitle;
}
public async onClickOpenShareAccess(cuid: number): Promise<void> {
this.computingUnitActionsService.openShareAccessModal(cuid, true);
}
onDropdownVisibilityChange(visible: boolean): void {
if (visible) {
this.computingUnitStatusService.refreshComputingUnitList();
}
}
trackByIndex(index: number): number {
return index;
}
addPackage(index: number): void {
const env = this.pves[index];
env.newPackages.push({ name: "", version: "", versionOp: undefined, deleteToggle: false });
}
togglePackageDelete(index: number, pkg: PveUserPackageRow): void {
const env = this.pves[index];
pkg.deleteToggle = !pkg.deleteToggle;
const version = pkg.version ?? "";
env.deletingPackages = env.deletingPackages.filter(p => !(p.name === pkg.name && p.version === version));
if (pkg.deleteToggle) {
env.deletingPackages.push({ name: pkg.name, version });
}
}
addEnvironment(): void {
this.pves.push({
name: "",
userPackages: [],
newPackages: [],
deletingPackages: [],
pipOutput: "",
prettyPipOutput: "",
expanded: true,
isInstalling: false,
isLocked: false,
});
}
showPVEmodalVisible(): void {
this.pveModalVisible = true;
this.getPVEs();
}
closePveModal(): void {
this.pves.forEach(pve => {
pve.socket?.close();
pve.socket = undefined;
pve.isInstalling = false;
});
this.pveModalVisible = false;
}
getPVEs(): void {
const cuId = this.selectedComputingUnit!.computingUnit.cuid;
const isLocal = this.selectedComputingUnit?.computingUnit.type === "local";
this.workflowPveService
.fetchPVEs(cuId)
.pipe(untilDestroyed(this))
.subscribe({
next: (resp: PvePackageResponse[]) => {
this.pves = resp.map(pve => ({
name: pve.pveName,
userPackages: this.parsePackageRows(pve.userPackages),
newPackages: [],
deletingPackages: [],
expanded: false,
isInstalling: false,
pipOutput: "",
prettyPipOutput: "",
isLocked: true,
}));
this.workflowPveService
.getSystemPackages(isLocal)
.pipe(untilDestroyed(this))
.subscribe({
next: installedResp => {
this.systemPackages = installedResp.system.map(pkgStr => {
const [name, version] = pkgStr.split("==");
return {
name: name.trim(),
version: (version ?? "").trim(),
};
});
},
error: (err: unknown) => {
console.error("Failed to fetch system packages:", err);
this.systemPackages = [];
},
});
},
error: (err: unknown) => {
console.error("Failed to fetch PVEs:", err);
this.pves = [];
this.systemPackages = [];
},
});
}
scrollToBottomOfPipModal(index: number) {
setTimeout(() => {
const pre = document.getElementById(`pip-log-${index}`) as HTMLElement | null;
if (pre) {
pre.scrollTop = pre.scrollHeight;
}
}, 50);
}
// Converts raw pip output for UI rendering by escaping unsafe characters and
// applying styling to exit codes, errors, warnings, and common success messages.
updatePrettyPipOutput(index: number) {
const env = this.pves[index];
const escapeHtml = (s: string) =>
s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
const raw = env.pipOutput ?? "";
const safe = escapeHtml(raw);
env.prettyPipOutput = safe
.replace(/^(\[pip\] Successfully installed.*)$/gm, '<span class="pip-exit ok"><strong>$1</strong></span>')
.replace(
/^(\[(?:PVE|pip|pve)\].*finished with exit code\s+0.*)$/gm,
'<span class="pip-exit ok"><strong>$1</strong></span>'
)
.replace(/^(\[PVE\] Running pip freeze.*)$/gm, '<span class="pip-exit ok"><strong>$1</strong></span>')
.replace(/^(\[(?:PVE|pip|pve)\]\[ERR\].*)$/gm, '<span class="pip-exit err"><strong>$1</strong></span>')
.replace(/^(\[PVE\] Skipped.*)$/gm, '<span class="pip-exit err"><strong>$1</strong></span>')
.replace(/\n/g, "<br/>");
}
private runPveWebSocket(
index: number,
action: "create" | "install",
initialMessage: string,
packages: string[] = [],
onDone?: () => void
): void {
const cuId = this.selectedComputingUnit!.computingUnit.cuid;
const env = this.pves[index];
const trimmedName = env.name.trim();
const isLocal = this.selectedComputingUnit?.computingUnit.type === "local";
env.socket?.close();
const websocketUrl = this.workflowPveService.getPveWebSocketUrl(cuId, trimmedName, isLocal, action, packages);
const socket = new WebSocket(websocketUrl);
this.pves[index] = {
...env,
name: trimmedName,
socket,
pipOutput: initialMessage,
isInstalling: true,
isLocked: true,
};
this.updatePrettyPipOutput(index);
this.scrollToBottomOfPipModal(index);
socket.onmessage = event => {
this.ngZone.run(() => {
const currentEnv = this.pves[index];
if (event.data === "__DONE__") {
this.pves[index] = {
...currentEnv,
socket: undefined,
isInstalling: false,
isLocked: true,
};
socket.close();
onDone?.();
this.cdr.detectChanges();
return;
}
this.pves[index] = {
...currentEnv,
pipOutput: `${currentEnv.pipOutput ?? ""}${event.data}\n`,
};
this.updatePrettyPipOutput(index);
this.scrollToBottomOfPipModal(index);
this.cdr.detectChanges();
});
};
socket.onerror = () => {
this.ngZone.run(() => {
const currentEnv = this.pves[index];
this.pves[index] = {
...currentEnv,
pipOutput: `${currentEnv.pipOutput ?? ""}\n[WebSocket error]\n`,
socket: undefined,
isInstalling: false,
isLocked: true,
};
socket.close();
this.updatePrettyPipOutput(index);
this.cdr.detectChanges();
});
};
}
private refreshUserPackages(index: number): void {
const env = this.pves[index];
this.workflowPveService
.getUserPackages(this.selectedComputingUnit!.computingUnit.cuid, env.name)
.pipe(untilDestroyed(this))
.subscribe({
next: pkgs => {
env.userPackages = env.userPackages = this.parsePackageRows(pkgs);
this.cdr.detectChanges();
},
error: (e: unknown) => console.error("Failed to refresh user packages", e),
});
}
createVirtualEnvironment(index: number): void {
const env = this.pves[index];
const trimmedName = env.name.trim();
const isLocal = this.selectedComputingUnit?.computingUnit.type === "local";
if (!/^[a-zA-Z0-9]+$/.test(trimmedName)) {
this.notificationService.error("Environment name must contain only letters and numbers.");
return;
}
if (env.isLocked) {
this.deleteUserPackages(index, () => {
this.installUserPackages(index);
});
return;
}
const duplicateExists = this.pves.some((pve, i) => i !== index && (pve.name ?? "").trim() === trimmedName);
if (duplicateExists) {
this.notificationService.error("An environment with this name already exists.");
return;
}
this.runPveWebSocket(index, "create", "Creating virtual environment...\n", [], () => {
this.deleteUserPackages(index, () => {
this.installUserPackages(index);
});
});
}
private installUserPackages(index: number): void {
const env = this.pves[index];
const missingVersionPackage = env.newPackages?.find(
pkg => pkg.name?.trim() && (!pkg.versionOp?.trim() || !pkg.version?.trim())
);
if (missingVersionPackage) {
this.notificationService.error("Please specify an operator and version for each package.");
return;
}
const systemPackageNames = new Set(this.systemPackages.map(pkg => pkg.name.trim().toLowerCase()));
const userPackageNames = new Set(env.userPackages.map(pkg => pkg.name.trim().toLowerCase()));
const skippedMessages: string[] = [];
const packageArray =
env.newPackages
?.filter(pkg => pkg.name?.trim())
.filter(pkg => {
const packageName = pkg.name.trim().toLowerCase();
if (systemPackageNames.has(packageName)) {
this.notificationService.error(`Skipped ${pkg.name}: already installed as a system package.`);
return false;
}
if (userPackageNames.has(packageName)) {
this.notificationService.error(`Skipped ${pkg.name}: already installed in this environment.`);
return false;
}
return true;
})
.map(pkg => `${pkg.name.trim()}${pkg.versionOp}${(pkg.version ?? "").trim()}`) ?? [];
if (skippedMessages.length > 0) {
this.pves[index].pipOutput = `${this.pves[index].pipOutput ?? ""}` + skippedMessages.join("\n") + "\n";
this.updatePrettyPipOutput(index);
this.scrollToBottomOfPipModal(index);
}
if (packageArray.length === 0) {
this.pves[index].newPackages = [];
this.pves[index].isInstalling = false;
this.refreshUserPackages(index);
return;
}
this.runPveWebSocket(index, "install", "Installing user packages...\n", packageArray, () => {
this.pves[index].newPackages = [];
this.refreshUserPackages(index);
});
}
private parsePackageRows(packages: string[]): PveUserPackageRow[] {
return packages.map(pkgStr => {
const [name, version] = pkgStr.split("==");
return {
name: name.trim(),
versionOp: "==" as const,
version: (version ?? "").trim(),
};
});
}
private deleteUserPackages(index: number, onDone?: () => void): void {
const cuId = this.selectedComputingUnit!.computingUnit.cuid;
const isLocal = this.selectedComputingUnit?.computingUnit.type === "local";
const pveName = this.pves[index].name.trim();
const packagesToDelete = [...this.pves[index].deletingPackages];
if (packagesToDelete.length === 0) {
onDone?.();
return;
}
this.pves[index] = {
...this.pves[index],
pipOutput: `${this.pves[index].pipOutput ?? ""}Deleting user packages...\n`,
isInstalling: true,
};
let deleteIndex = 0;
const deleteNext = (): void => {
if (deleteIndex >= packagesToDelete.length) {
this.pves[index].deletingPackages = [];
this.refreshUserPackages(index);
onDone?.();
return;
}
const pkg = packagesToDelete[deleteIndex];
this.workflowPveService
.deletePackage(cuId, pveName, pkg.name, isLocal)
.pipe(untilDestroyed(this))
.subscribe({
next: messages => {
this.pves[index].pipOutput = `${this.pves[index].pipOutput ?? ""}${messages.join("\n")}\n`;
this.updatePrettyPipOutput(index);
this.scrollToBottomOfPipModal(index);
deleteIndex++;
deleteNext();
},
error: () => {
this.pves[index].pipOutput =
`${this.pves[index].pipOutput ?? ""}[PVE][ERR] Failed to delete package: ${pkg.name}\n`;
this.updatePrettyPipOutput(index);
this.scrollToBottomOfPipModal(index);
deleteIndex++;
deleteNext();
},
});
};
deleteNext();
}
}