diff --git a/amber/src/main/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveManager.scala b/amber/src/main/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveManager.scala index 27a3b7be7c1..fce636a3820 100644 --- a/amber/src/main/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveManager.scala +++ b/amber/src/main/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveManager.scala @@ -341,4 +341,97 @@ object PveManager { queue.put(s"[user-package] $pkg") } } + + /** + * Uninstalls a user-installed package from the PVE. + * 1. Prevents deletion of system packages + * 2. Updates user metadata upon success + * 3. Returns status messages + */ + def deletePackages( + cuid: Int, + packageName: String, + pveName: String, + isLocal: Boolean + ): List[String] = { + val python = pythonBinPath(cuid, pveName).toAbsolutePath.toString + val metadataPath = cuidDir(cuid, pveName).resolve("user-packages.txt") + + if (!Files.exists(Paths.get(python))) { + val msg = s"[PVE][ERR] Python executable not found for PVE: $python" + println(msg) + return List(msg) + } + + val trimmedPackageName = packageName.trim + val normalizedPackageName = trimmedPackageName.split("==")(0).trim.toLowerCase + + val systemPackages = + if (Files.exists(getSystemPath(isLocal))) { + Files + .readAllLines(getSystemPath(isLocal)) + .asScala + .map(_.trim) + .filter(line => line.nonEmpty && !line.startsWith("#")) + .map(line => line.split("==")(0).trim.toLowerCase) + .toSet + } else { + Set[String]() + } + + if (systemPackages.contains(normalizedPackageName)) { + return List( + s"[PVE][ERR] $trimmedPackageName is a system package and cannot be deleted." + ) + } + + try { + val command = Process( + Seq( + python, + "-u", + "-m", + "pip", + "uninstall", + "-y", + trimmedPackageName + ), + None, + pipEnv.toSeq: _* + ) + + val output = scala.collection.mutable.ListBuffer[String]() + + val exitCode = command.!( + ProcessLogger( + out => { + println(s"[pip] $out") + output += s"[pip] $out" + }, + err => { + System.err.println(s"[pip][ERR] $err") + output += s"[pip][ERR] $err" + } + ) + ) + + if (exitCode == 0) { + val updatedPackages = readPackageFile(metadataPath) + .filterNot(line => line.split("==")(0).trim.toLowerCase == normalizedPackageName) + .sorted + + Files.write(metadataPath, updatedPackages.asJava) + + output += s"[pip] uninstall($trimmedPackageName) finished with exit code $exitCode" + output += s"[PVE] Uninstalled $trimmedPackageName successfully" + } else { + output += s"[PVE][ERR] Failed to uninstall package: $trimmedPackageName" + } + + output.toList + } catch { + case e: Exception => + List(s"[PVE][ERR] Failed to delete package for cuid=$cuid: ${e.getMessage}") + } + } } diff --git a/amber/src/main/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveResource.scala b/amber/src/main/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveResource.scala index 8a6f4875293..4d810678cc5 100644 --- a/amber/src/main/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveResource.scala +++ b/amber/src/main/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveResource.scala @@ -23,6 +23,9 @@ import javax.ws.rs._ import javax.ws.rs.core.MediaType import scala.jdk.CollectionConverters._ import java.util +import javax.ws.rs.DELETE +import javax.ws.rs.PathParam +import javax.ws.rs.core.Response @Path("/pve") @Consumes(Array(MediaType.APPLICATION_JSON)) @@ -85,4 +88,29 @@ class PveResource { def deleteEnvironments(@PathParam("cuId") cuid: Int): Unit = { PveManager.deleteEnvironments(cuid) } + + // -------------------------------------------------- + // Delete User Installed Package + // -------------------------------------------------- + @DELETE + @Path("/{cuid}/{pveName}/packages/{packageName}") + def deletePackage( + @PathParam("cuid") cuid: Int, + @PathParam("pveName") pveName: String, + @PathParam("packageName") packageName: String, + @QueryParam("isLocal") isLocal: Boolean + ): Response = { + val messages = PveManager.deletePackages( + cuid, + packageName, + pveName, + isLocal + ) + + if (messages.exists(_.contains("[PVE][ERR]"))) { + Response.status(Response.Status.BAD_REQUEST).entity(messages.asJava).build() + } else { + Response.ok(messages.asJava).build() + } + } } diff --git a/amber/src/test/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveResourceSpec.scala b/amber/src/test/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveResourceSpec.scala index 10e952c8bdc..f8b365c0032 100644 --- a/amber/src/test/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveResourceSpec.scala +++ b/amber/src/test/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveResourceSpec.scala @@ -98,6 +98,47 @@ class PveResourceSpec extends AnyFlatSpec with Matchers with BeforeAndAfterEach pve.get.userPackages should contain(packageSpec) } + "PveManager" should "delete a user package and remove it from the PVE package list" in { + PveManager.createNewPve(testCuid, queue, testPveName, isLocal = true) + + val packageName = "colorama" + val packageVersion = "0.4.6" + val packageSpec = s"$packageName==$packageVersion" + + queue.clear() + + PveManager.installUserPackages( + List(packageSpec), + testCuid, + queue, + testPveName, + isLocal = true + ) + + PveManager + .getEnvironments(testCuid) + .find(_.pveName == testPveName) + .get + .userPackages should contain(packageSpec) + + val deleteLogs = PveManager.deletePackages( + testCuid, + packageName, + testPveName, + isLocal = true + ) + + deleteLogs.mkString("\n") should not include "[PVE][ERR]" + deleteLogs.mkString("\n") should include(s"[PVE] Uninstalled $packageName successfully") + + val pve = PveManager + .getEnvironments(testCuid) + .find(_.pveName == testPveName) + + pve should not be empty + pve.get.userPackages should not contain packageSpec + } + "PveManager" should "delete all PVEs for a computing unit" in { PveManager.createNewPve(testCuid, queue, testPveName, isLocal = true) diff --git a/frontend/src/app/workspace/component/power-button/computing-unit-selection.component.html b/frontend/src/app/workspace/component/power-button/computing-unit-selection.component.html index 6f16073073f..6167d367591 100644 --- a/frontend/src/app/workspace/component/power-button/computing-unit-selection.component.html +++ b/frontend/src/app/workspace/component/power-button/computing-unit-selection.component.html @@ -554,17 +554,31 @@ [ngModel]="pkg.version" [disabled]="true" /> +
-
Package
- -
Version
+
+
Package
+
+
Version
+
+
+
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 1b3dfb23226..a2843a51bf3 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 @@ -81,12 +81,14 @@ 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; @@ -732,7 +734,21 @@ export class ComputingUnitSelectionComponent implements OnInit { addPackage(index: number): void { const env = this.pves[index]; - env.newPackages.push({ name: "", version: "", versionOp: undefined }); + 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 { @@ -740,6 +756,7 @@ export class ComputingUnitSelectionComponent implements OnInit { name: "", userPackages: [], newPackages: [], + deletingPackages: [], pipOutput: "", prettyPipOutput: "", expanded: true, @@ -776,6 +793,7 @@ export class ComputingUnitSelectionComponent implements OnInit { name: pve.pveName, userPackages: this.parsePackageRows(pve.userPackages), newPackages: [], + deletingPackages: [], expanded: false, isInstalling: false, pipOutput: "", @@ -949,6 +967,7 @@ export class ComputingUnitSelectionComponent implements OnInit { 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."); @@ -956,7 +975,9 @@ export class ComputingUnitSelectionComponent implements OnInit { } if (env.isLocked) { - this.installUserPackages(index); + this.deleteUserPackages(index, () => { + this.installUserPackages(index); + }); return; } @@ -968,7 +989,9 @@ export class ComputingUnitSelectionComponent implements OnInit { } this.runPveWebSocket(index, "create", "Creating virtual environment...\n", [], () => { - this.installUserPackages(index); + this.deleteUserPackages(index, () => { + this.installUserPackages(index); + }); }); } @@ -1019,6 +1042,7 @@ export class ComputingUnitSelectionComponent implements OnInit { if (packageArray.length === 0) { this.pves[index].newPackages = []; + this.pves[index].isInstalling = false; this.refreshUserPackages(index); return; } @@ -1039,4 +1063,62 @@ export class ComputingUnitSelectionComponent implements OnInit { }; }); } + + 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(); + } } diff --git a/frontend/src/app/workspace/service/virtual-environment/virtual-environment.service.ts b/frontend/src/app/workspace/service/virtual-environment/virtual-environment.service.ts index 9c7c123df1e..7788cba2701 100644 --- a/frontend/src/app/workspace/service/virtual-environment/virtual-environment.service.ts +++ b/frontend/src/app/workspace/service/virtual-environment/virtual-environment.service.ts @@ -67,6 +67,15 @@ export class WorkflowPveService { return this.http.delete(`/pve/pves/${cuid}`); } + deletePackage(cuid: number, pveName: string, packageName: string, isLocal: boolean) { + const params = this.buildBaseParams().set("isLocal", isLocal.toString()); + + return this.http.delete( + `/pve/${cuid}/${encodeURIComponent(pveName)}/packages/${encodeURIComponent(packageName)}`, + { params } + ); + } + getPveWebSocketUrl(cuid: number, pveName: string, isLocal: boolean, action: string, packages: string[] = []): string { const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; const query = encodeURIComponent(JSON.stringify(packages)); diff --git a/frontend/src/styles.scss b/frontend/src/styles.scss index 27b2a5362a2..5f4a00952a9 100644 --- a/frontend/src/styles.scss +++ b/frontend/src/styles.scss @@ -55,14 +55,6 @@ img { padding-bottom: 0; } -.ant-select-selector { - height: 24px !important; -} - -.ant-select-selection-item { - line-height: 24px !important; -} - .ant-form-item-label { padding-bottom: 0 !important; } @@ -236,6 +228,21 @@ hr { padding: 14px !important; } + .operator-select { + height: 24px; + } + + .operator-select nz-select, + .operator-select .ant-select, + .operator-select .ant-select-selector { + height: 24px !important; + } + + .operator-select .ant-select-selection-item, + .operator-select .ant-select-selection-placeholder { + line-height: 22px !important; + } + .ve-form { display: flex; flex-direction: column; @@ -262,7 +269,7 @@ hr { .package-row { display: flex; - align-items: flex-end; + align-items: center; justify-content: space-between; gap: 10px; bottom: 0px; @@ -281,9 +288,10 @@ hr { .user-package-inputs { flex: 1; display: grid; - grid-template-columns: 1fr 160px 1fr; + grid-template-columns: 1fr 160px 1fr 58px; gap: 14px; width: 100%; + align-items: center; } .field { @@ -309,11 +317,6 @@ hr { width: 100%; } - .ant-input, - .ant-select-selector { - //border-radius: 10px !important; - } - .ant-input[disabled] { background: #f5f6f8 !important; border-color: #e6e8ec !important; @@ -369,22 +372,30 @@ hr { background: transparent; } - .user-package-header-row .package-column-label { - font-weight: 600; - } - .new-packages-section { display: flex; flex-direction: column; gap: 2px; } + .user-package-inputs button { + align-self: center; + justify-self: center; + } + + .highlighted-btn { + background-color: #ff4d4f !important; /* Ant Design red */ + border-color: #ff4d4f !important; + color: white !important; + } .user-package-header-row { - display: grid; - grid-template-columns: 1fr 160px 1fr; - gap: 14px; - margin-bottom: 0; - padding: 0; + border: none; + background: transparent; + align-items: center; + } + + .user-package-header-row .package-column-label { + font-weight: 600; } .system-header {