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" />
+
+
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 {