This is an automated email from the ASF dual-hosted git repository.

kunwp1 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/texera.git


The following commit(s) were added to refs/heads/main by this push:
     new f3fbf4bf49 feat: Add Python Virtual Environment Support: Creating a 
Virtual Environment  (#4484)
f3fbf4bf49 is described below

commit f3fbf4bf49edc9ce4ed694a2bc20693d3e04975e
Author: Sarah Asad <[email protected]>
AuthorDate: Fri May 1 12:56:04 2026 -0700

    feat: Add Python Virtual Environment Support: Creating a Virtual 
Environment  (#4484)
    
    <!--
    Thanks for sending a pull request (PR)! Here are some tips for you:
    1. If this is your first time, please read our contributor guidelines:
    [Contributing to
    Texera](https://github.com/apache/texera/blob/main/CONTRIBUTING.md)
      2. Ensure you have added or run the appropriate tests for your PR
      3. If the PR is work in progress, mark it a draft on GitHub.
      4. Please write your PR title to summarize what this PR proposes, we
        are following Conventional Commits style for PR titles as well.
      5. Be sure to keep the PR description updated to reflect all changes.
    -->
    
    ### What changes were proposed in this PR?
    <!--
    Please clarify what changes you are proposing. The purpose of this
    section
    is to outline the changes. Here are some tips for you:
      1. If you propose a new API, clarify the use case for a new API.
      2. If you fix a bug, you can clarify why it is a bug.
      3. If it is a refactoring, clarify what has been changed.
      3. It would be helpful to include a before-and-after comparison using
         screenshots or GIFs.
      4. Please consider writing useful notes for better and faster reviews.
    -->
    
    This PR adds initial support for Python Virtual Environments (PVEs) at
    the Computing Unit level. It allows users to create and manage multiple
    PVEs within a CU and perform package management operations (using pip)
    within those environments. PVEs are scoped to a CU and follow its
    lifecycle. Each PVE maintains its own set of installed packages,
    enabling user-specific dependency configurations. Each package operation
    also outputs live pip logs.
    
    This PR only supports the initial creation of a PVE within a CU,
    including the installation of system packages. When a computing unit is
    deleted or terminated, all associated environments are removed as well
    when running locally. In a deployment setting, PVEs are automatically
    deleted as they are stored in the local volume of the CU they are
    created in.
    
    
    
    
https://github.com/user-attachments/assets/a8f4633a-dfc8-42ff-a94b-0d613cb70bec
    
    Two backend resources have been added:
    
    HTTP Resource: Handles standard operations such as fetching available
    PVEs and system packages, along with other management actions.
    
    WebSocket Resource: Streams pip installation output to the frontend in
    real time during PVE creation, allowing users to monitor progress as it
    happens.
    
    
    ### Any related issues, documentation, discussions?
    <!--
    Please use this section to link other resources if not mentioned
    already.
    1. If this PR fixes an issue, please include `Fixes #1234`, `Resolves
    #1234`
    or `Closes #1234`. If it is only related, simply mention the issue
    number.
      2. If there is design documentation, please add the link.
      3. If there is a discussion in the mailing list, please add the link.
    -->
    
    This change is part of ongoing efforts to support environment isolation
    and reproducibility within Texera. Related issue includes
    https://github.com/apache/texera/issues/4296. This PR closes sub-issue
    #4464.
    
    ### How was this PR tested?
    <!--
    If tests were added, say they were added here. Or simply mention that if
    the PR
    is tested with existing test cases. Make sure to include/update test
    cases that
    check the changes thoroughly including negative and positive cases if
    possible.
    If it was tested in a way different from regular unit tests, please
    clarify how
    you tested step by step, ideally copy and paste-able, so that other
    reviewers can
    test and check, and descendants can verify in the future. If tests were
    not added,
    please describe why they were not added and/or why it was difficult to
    add.
    -->
    
    Tested Manually and PveResourceSpec test file added.
    
    To test:
    
    1. On CU click "+" Python Environments.
    2. Input environment name.
    3. Click "OK" and wait for pip logs.
    
    
    ### Was this PR authored or co-authored using generative AI tooling?
    <!--
    If generative AI tooling has been used in the process of authoring this
    PR,
    please include the phrase: 'Generated-by: ' followed by the name of the
    tool
    and its version. If no, write 'No'.
    Please refer to the [ASF Generative Tooling
    Guidance](https://www.apache.org/legal/generative-tooling.html) for
    details.
    -->
    
    Co-authored using: ChatGPT (OpenAI)
    
    ---------
    
    Co-authored-by: Kunwoo (Chris) <[email protected]>
    Co-authored-by: Chen Li <[email protected]>
---
 .../pythonworker/PythonWorkflowWorker.scala        |   5 +-
 .../apache/texera/web/ComputingUnitMaster.scala    |  11 +-
 .../pythonvirtualenvironment/PveManager.scala      | 215 ++++++++++++++++
 .../pythonvirtualenvironment/PveResource.scala     |  79 ++++++
 .../PveWebsocketResource.scala                     |  74 ++++++
 .../pythonvirtualenvironment/PveResourceSpec.scala |  80 ++++++
 .../apache/texera/amber/config/PythonUtils.scala   |  27 +++
 frontend/proxy.config.json                         |   8 +
 .../computing-unit-selection.component.html        | 132 ++++++++++
 .../computing-unit-selection.component.scss        |  25 ++
 .../computing-unit-selection.component.ts          | 261 +++++++++++++++++++-
 .../virtual-environment.service.ts                 |  82 +++++++
 frontend/src/styles.scss                           | 269 +++++++++++++++++++++
 13 files changed, 1262 insertions(+), 6 deletions(-)

diff --git 
a/amber/src/main/scala/org/apache/texera/amber/engine/architecture/pythonworker/PythonWorkflowWorker.scala
 
b/amber/src/main/scala/org/apache/texera/amber/engine/architecture/pythonworker/PythonWorkflowWorker.scala
index 32e417f3c0..4ff5ff15ae 100644
--- 
a/amber/src/main/scala/org/apache/texera/amber/engine/architecture/pythonworker/PythonWorkflowWorker.scala
+++ 
b/amber/src/main/scala/org/apache/texera/amber/engine/architecture/pythonworker/PythonWorkflowWorker.scala
@@ -39,6 +39,7 @@ import 
org.apache.texera.amber.engine.common.actormessage.{Backpressure, CreditU
 import 
org.apache.texera.amber.engine.common.ambermessage.WorkflowMessage.getInMemSize
 import org.apache.texera.amber.engine.common.ambermessage._
 import org.apache.texera.amber.engine.common.{CheckpointState, Utils}
+import org.apache.texera.amber.config.PythonUtils
 
 import java.nio.file.Path
 import java.util.concurrent.{ExecutorService, Executors}
@@ -65,7 +66,6 @@ class PythonWorkflowWorker(
     .resolve("src")
     .resolve("main")
     .resolve("python")
-  val pythonENVPath: String = UdfConfig.pythonPath.trim
   val RENVPath: String = UdfConfig.rPath.trim
 
   // Python process
@@ -173,8 +173,7 @@ class PythonWorkflowWorker(
     val isRest = StorageConfig.icebergCatalogType == "rest"
     pythonServerProcess = Process(
       Seq(
-        if (pythonENVPath.isEmpty) "python3"
-        else pythonENVPath, // add fall back in case of empty
+        PythonUtils.getPythonExecutable,
         "-u",
         udfEntryScriptPath,
         workerConfig.workerId.name,
diff --git 
a/amber/src/main/scala/org/apache/texera/web/ComputingUnitMaster.scala 
b/amber/src/main/scala/org/apache/texera/web/ComputingUnitMaster.scala
index d0162b176d..41d8d3b583 100644
--- a/amber/src/main/scala/org/apache/texera/web/ComputingUnitMaster.scala
+++ b/amber/src/main/scala/org/apache/texera/web/ComputingUnitMaster.scala
@@ -56,6 +56,8 @@ import 
org.apache.texera.web.service.ExecutionsMetadataPersistService
 import org.eclipse.jetty.server.session.SessionHandler
 import org.eclipse.jetty.servlet.FilterHolder
 import org.eclipse.jetty.websocket.server.WebSocketUpgradeFilter
+import org.apache.texera.web.resource.pythonvirtualenvironment.PveResource
+import 
org.apache.texera.web.resource.pythonvirtualenvironment.PveWebsocketResource
 
 import java.net.URI
 import java.time.Duration
@@ -126,7 +128,12 @@ class ComputingUnitMaster extends 
io.dropwizard.Application[Configuration] with
       )
     )
     // add websocket bundle
-    bootstrap.addBundle(new 
WebsocketBundle(classOf[WorkflowWebsocketResource]))
+    bootstrap.addBundle(
+      new WebsocketBundle(
+        classOf[WorkflowWebsocketResource],
+        classOf[PveWebsocketResource]
+      )
+    )
     // register scala module to dropwizard default object mapper
     bootstrap.getObjectMapper.registerModule(DefaultScalaModule)
   }
@@ -154,6 +161,8 @@ class ComputingUnitMaster extends 
io.dropwizard.Application[Configuration] with
     environment.jersey.register(classOf[SessionHandler])
     environment.servlets.setSessionHandler(new SessionHandler)
 
+    environment.jersey.register(classOf[PveResource])
+
     setupJwtAuth(environment)
 
     environment.jersey.register(
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
new file mode 100644
index 0000000000..0399e386ba
--- /dev/null
+++ 
b/amber/src/main/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveManager.scala
@@ -0,0 +1,215 @@
+/*
+ * 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.
+ */
+
+package org.apache.texera.web.resource.pythonvirtualenvironment
+
+import java.nio.file.{Files, Path, Paths}
+import java.util.concurrent.BlockingQueue
+import scala.collection.mutable.Map
+import scala.jdk.CollectionConverters._
+import scala.sys.process._
+import java.util.Comparator
+import org.apache.texera.amber.config.PythonUtils
+
+/**
+  * PveManager is responsible for managing Python Virtual Environments (PVEs)
+  * for each Computing Unit
+  *
+  * It supports:
+  * - Creating and initializing isolated Python environments
+  * - Streaming pip output logs back to the caller
+  *
+  * Each PVE is stored under:
+  *   /tmp/texera-pve/venvs/{cuid}/{pveName}/
+  */
+
+object PveManager {
+
+  private val VenvRoot: Path = Paths.get("/tmp/texera-pve/venvs")
+
+  private def cuidDir(cuid: Int, pveName: String): Path = {
+    VenvRoot.resolve(cuid.toString).resolve(pveName)
+  }
+
+  private def pveDir(cuid: Int, pveName: String): Path =
+    cuidDir(cuid, pveName).resolve("pve")
+
+  private def pythonBinPath(cuid: Int, pveName: String): Path =
+    pveDir(cuid, pveName).resolve("bin").resolve("python")
+
+  private def pipEnv: Map[String, String] =
+    Map(
+      "PYTHONUNBUFFERED" -> "1",
+      "PIP_PROGRESS_BAR" -> "off",
+      "PIP_DISABLE_PIP_VERSION_CHECK" -> "1",
+      "PIP_NO_INPUT" -> "1"
+    )
+
+  def getSystemPackages(): Seq[String] = {
+    val python = PythonUtils.getPythonExecutable
+    Process(Seq(python, "-m", "pip", 
"freeze")).!!.split("\n").map(_.trim).filter(_.nonEmpty).toSeq
+  }
+
+  /**
+    * Creates a new PVE for a CU.
+    *
+    * Behavior:
+    * Creates a fresh venv and installs dependencies
+    *
+    * Steps:
+    * 1. Install system dependencies
+    * 2. Logs progress to the provided queue.
+    */
+  def createNewPve(
+      cuid: Int,
+      queue: BlockingQueue[String],
+      pveName: String,
+      isLocal: Boolean
+  ): Unit = {
+    queue.put(s"[PVE] Creating new PVE for cuid: $cuid with name: $pveName")
+
+    // NOTE: These paths are derived from computing-unit-master.dockerfile.
+    // If requirements.txt or operator-requirements.txt locations change, 
update these paths.
+    val requirementsPath =
+      if (isLocal) Paths.get("amber", "requirements.txt")
+      else Paths.get("/tmp", "requirements.txt")
+
+    val operatorRequirementsPath =
+      if (isLocal) Paths.get("amber", "operator-requirements.txt")
+      else Paths.get("/tmp", "operator-requirements.txt")
+
+    if (!Files.exists(requirementsPath) || 
!Files.exists(operatorRequirementsPath)) {
+      queue.put(s"[PVE][ERR] System requirements not found")
+      return
+    }
+
+    val venvDirPath = pveDir(cuid, pveName).toAbsolutePath
+    val python = pythonBinPath(cuid, pveName).toAbsolutePath.toString
+    val envVars = pipEnv
+
+    val createVenvPython = PythonUtils.getPythonExecutable
+
+    Files.createDirectories(venvDirPath.getParent)
+
+    val createCode = Process(Seq(createVenvPython, "-m", "venv", 
venvDirPath.toString)).!(
+      ProcessLogger(
+        out => queue.put(s"[pve] $out"),
+        err => queue.put(s"[pve][ERR] $err")
+      )
+    )
+
+    queue.put(s"[pve] venv creation finished with exit code $createCode")
+
+    if (createCode != 0) {
+      queue.put(s"[PVE][ERR] Failed to create venv (exit=$createCode)")
+      return
+    }
+
+    if (!Files.exists(requirementsPath)) {
+      queue.put(s"[PVE][ERR] requirements.txt not found at 
${requirementsPath.toAbsolutePath}")
+      return
+    }
+
+    if (!Files.exists(operatorRequirementsPath)) {
+      queue.put(
+        s"[PVE][ERR] operator-requirements.txt not found at 
${operatorRequirementsPath.toAbsolutePath}"
+      )
+      return
+    }
+
+    queue.put(
+      s"[PVE] Installing requirements from ${requirementsPath.toAbsolutePath} 
and ${operatorRequirementsPath.toAbsolutePath}"
+    )
+
+    val installReqCode = Process(
+      Seq(
+        python,
+        "-u",
+        "-m",
+        "pip",
+        "install",
+        "--progress-bar",
+        "off",
+        "-r",
+        requirementsPath.toString,
+        "-r",
+        operatorRequirementsPath.toString
+      ),
+      None,
+      envVars.toSeq: _*
+    ).!(
+      ProcessLogger(
+        out => queue.put(s"[pip] $out"),
+        err => queue.put(s"[pip][ERR] $err")
+      )
+    )
+
+    queue.put(s"[PVE] requirements install finished with exit code 
$installReqCode")
+
+    if (installReqCode != 0) {
+      queue.put(s"[PVE][ERR] Failed to install requirements files 
(exit=$installReqCode)")
+      return
+    }
+
+    queue.put(s"[PVE] Created new environment for cuid = $cuid")
+  }
+
+  def getEnvironments(cuid: Int): List[String] = {
+
+    val cuPath = VenvRoot.resolve(cuid.toString)
+
+    if (!Files.isDirectory(cuPath)) {
+      return List()
+    }
+
+    val stream = Files.list(cuPath)
+
+    try {
+      stream
+        .iterator()
+        .asScala
+        .filter(path => Files.isDirectory(path))
+        .map(path => path.getFileName.toString)
+        .toList
+    } finally {
+      stream.close()
+    }
+  }
+
+  // Deletes all PVE environments for a given CU (when running locally)
+  def deleteEnvironments(cuid: Int): Unit = {
+    val cuPath = VenvRoot.resolve(cuid.toString)
+
+    if (!Files.isDirectory(cuPath)) {
+      return
+    }
+
+    val stream = Files.walk(cuPath)
+
+    try {
+      stream
+        .sorted(Comparator.reverseOrder())
+        .iterator()
+        .asScala
+        .foreach(path => Files.deleteIfExists(path))
+    } finally {
+      stream.close()
+    }
+  }
+}
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
new file mode 100644
index 0000000000..1040fd64ea
--- /dev/null
+++ 
b/amber/src/main/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveResource.scala
@@ -0,0 +1,79 @@
+/*
+ * 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.
+ */
+
+package org.apache.texera.web.resource.pythonvirtualenvironment
+
+import javax.ws.rs._
+import javax.ws.rs.core.MediaType
+import scala.jdk.CollectionConverters._
+import java.util
+
+@Path("/pve")
+@Consumes(Array(MediaType.APPLICATION_JSON))
+class PveResource {
+  // --------------------------------------------------
+  // Get installed packages
+  // --------------------------------------------------
+  @GET
+  @Path("/system")
+  @Produces(Array(MediaType.APPLICATION_JSON))
+  def getSystemPackages: util.Map[String, util.List[String]] = {
+    try {
+      val systemPkgs = PveManager.getSystemPackages().toList.asJava
+      Map("system" -> systemPkgs).asJava
+    } catch {
+      case e: Exception =>
+        e.printStackTrace()
+        throw new InternalServerErrorException("Failed to get system 
packages.")
+    }
+  }
+
+  // --------------------------------------------------
+  // Fetch PVEs
+  // --------------------------------------------------
+  @GET
+  @Path("/pves")
+  @Produces(Array(MediaType.APPLICATION_JSON))
+  def fetchPVEs(@QueryParam("cuid") cuid: Int): util.List[util.Map[String, 
Object]] = {
+    try {
+      PveManager
+        .getEnvironments(cuid)
+        .map { pveName =>
+          Map(
+            "pveName" -> pveName.asInstanceOf[Object]
+          ).asJava
+        }
+        .asJava
+
+    } catch {
+      case e: Exception =>
+        e.printStackTrace()
+        throw new InternalServerErrorException(s"Failed to get PVEs: 
${e.getMessage}")
+    }
+  }
+
+  // --------------------------------------------------
+  // Delete PVEs
+  // --------------------------------------------------
+  @DELETE
+  @Path("/pves/{cuId}")
+  def deleteEnvironments(@PathParam("cuId") cuid: Int): Unit = {
+    PveManager.deleteEnvironments(cuid)
+  }
+}
diff --git 
a/amber/src/main/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveWebsocketResource.scala
 
b/amber/src/main/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveWebsocketResource.scala
new file mode 100644
index 0000000000..b93d1bfde0
--- /dev/null
+++ 
b/amber/src/main/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveWebsocketResource.scala
@@ -0,0 +1,74 @@
+/*
+ * 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.
+ */
+
+package org.apache.texera.web.resource.pythonvirtualenvironment
+
+import javax.websocket._
+import javax.websocket.server.ServerEndpoint
+import java.util.concurrent.LinkedBlockingQueue
+import scala.concurrent.Future
+import scala.concurrent.ExecutionContext.Implicits.global
+
+/**
+  * WebSocket endpoint for PVE creation that streams pip installation logs
+  * to the frontend in real time. The environment setup runs asynchronously,
+  * and output is pushed to the client until completion.
+  */
+
+@ServerEndpoint("/wsapi/pve")
+class PveWebsocketResource {
+
+  @OnOpen
+  def onOpen(session: Session): Unit = {
+
+    val params = session.getRequestParameterMap
+
+    val cuid = params.get("cuid").get(0).toInt
+    val pveName = params.get("pveName").get(0)
+    val isLocal = params.get("isLocal").get(0).toBoolean
+
+    val queue = new LinkedBlockingQueue[String]()
+
+    Future {
+      try {
+        PveManager.createNewPve(cuid, queue, pveName, isLocal)
+      } catch {
+        case e: Exception =>
+          queue.put(s"[ERR] ${e.getMessage}")
+      } finally {
+        queue.put("__DONE__")
+      }
+    }
+
+    Future {
+      var done = false
+
+      while (!done && session.isOpen) {
+        val line = queue.take()
+
+        session.getBasicRemote.sendText(line)
+
+        if (line == "__DONE__") {
+          done = true
+          session.close()
+        }
+      }
+    }
+  }
+}
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
new file mode 100644
index 0000000000..a093cf1ad2
--- /dev/null
+++ 
b/amber/src/test/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveResourceSpec.scala
@@ -0,0 +1,80 @@
+/*
+ * 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.
+ */
+
+package org.apache.texera.web.resource.pythonvirtualenvironment
+
+import org.scalatest.BeforeAndAfterEach
+import org.scalatest.flatspec.AnyFlatSpec
+import org.scalatest.matchers.should.Matchers
+
+import java.nio.file.{Files, Path, Paths}
+import java.util.concurrent.LinkedBlockingQueue
+import scala.jdk.CollectionConverters._
+
+class PveResourceSpec extends AnyFlatSpec with Matchers with 
BeforeAndAfterEach {
+
+  private val testCuid = 256
+  private var testPveName: String = _
+  private var testRoot: Path = _
+  private var queue: LinkedBlockingQueue[String] = _
+
+  override protected def beforeEach(): Unit = {
+    testPveName = s"testenv${System.currentTimeMillis()}"
+    testRoot = Paths.get("/tmp/texera-pve/venvs").resolve(testCuid.toString)
+    queue = new LinkedBlockingQueue[String]()
+  }
+
+  override protected def afterEach(): Unit = {
+    PveManager.deleteEnvironments(testCuid)
+  }
+
+  private def queueText(): String = {
+    queue.iterator().asScala.toList.mkString("\n")
+  }
+
+  "PveManager" should "create a new PVE and list it" in {
+    PveManager.createNewPve(testCuid, queue, testPveName, isLocal = true)
+
+    val logs = queueText()
+
+    logs should not include "[PVE][ERR]"
+    logs should include(s"[PVE] Created new environment for cuid = $testCuid")
+
+    val pvePath = testRoot.resolve(testPveName).resolve("pve")
+    val pythonPath = pvePath.resolve("bin").resolve("python")
+    val pipPath = pvePath.resolve("bin").resolve("pip")
+
+    Files.exists(pvePath) shouldBe true
+    Files.exists(pythonPath) shouldBe true
+    Files.exists(pipPath) shouldBe true
+
+    PveManager.getEnvironments(testCuid) should contain(testPveName)
+  }
+
+  "PveManager" should "delete all PVEs for a computing unit" in {
+    PveManager.createNewPve(testCuid, queue, testPveName, isLocal = true)
+
+    Files.exists(testRoot.resolve(testPveName)) shouldBe true
+
+    PveManager.deleteEnvironments(testCuid)
+
+    Files.exists(testRoot) shouldBe false
+    PveManager.getEnvironments(testCuid) shouldBe empty
+  }
+}
diff --git 
a/common/config/src/main/scala/org/apache/texera/amber/config/PythonUtils.scala 
b/common/config/src/main/scala/org/apache/texera/amber/config/PythonUtils.scala
new file mode 100644
index 0000000000..353945f04e
--- /dev/null
+++ 
b/common/config/src/main/scala/org/apache/texera/amber/config/PythonUtils.scala
@@ -0,0 +1,27 @@
+/*
+ * 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.
+ */
+
+package org.apache.texera.amber.config
+
+// Util function used by PveManager and PythonWorkflowWorker
+object PythonUtils {
+  def getPythonExecutable: String = {
+    val pythonPath = UdfConfig.pythonPath.trim
+    if (pythonPath.isEmpty) "python3" else pythonPath
+  }
+}
diff --git a/frontend/proxy.config.json b/frontend/proxy.config.json
index 400864a0c3..f68602e071 100755
--- a/frontend/proxy.config.json
+++ b/frontend/proxy.config.json
@@ -55,6 +55,14 @@
     "secure": false,
     "changeOrigin": false
   },
+  "/pve": {
+    "target": "http://localhost:8085";,
+    "secure": false,
+    "changeOrigin": false,
+    "pathRewrite": {
+      "^/pve": "/api/pve"
+    }
+  },
   "/wsapi": {
     "target": "http://localhost:8085";,
     "secure": false,
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 96debacec0..b742c71581 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
@@ -160,6 +160,14 @@
             (click)="startEditingUnitName(unit); $event.stopPropagation()"
             role="button">
           </i>
+          <i
+            nz-icon
+            nzType="plus"
+            nz-tooltip
+            *ngIf="unit.isOwner"
+            [nzTooltipTitle]="'Python Environment'"
+            (click)="selectedComputingUnit = unit; showPVEmodalVisible(); 
$event.stopPropagation()">
+          </i>
           <i
             nz-icon
             nzType="share-alt"
@@ -427,3 +435,127 @@
     </div>
   </div>
 </ng-template>
+
+<!-- Modal for adding packages -->
+<nz-modal
+  nzWrapClassName="pve-modal"
+  nzClassName="pve-modal"
+  [nzVisible]="pveModalVisible"
+  nzTitle="Python Environments"
+  (nzOnCancel)="closePveModal()"
+  [nzFooter]="customFooter">
+  <ng-template #customFooter>
+    <div class="footer-all">
+      <button
+        nz-button
+        nzType="default"
+        (click)="closePveModal()">
+        Close
+      </button>
+      <button
+        nz-button
+        nzType="primary"
+        (click)="addEnvironment()">
+        Add Environment
+      </button>
+    </div>
+  </ng-template>
+
+  <ng-container *nzModalContent>
+    <!-- Shared system packages -->
+    <div class="system-section">
+      <nz-collapse>
+        <ng-template #systemHeaderTpl>
+          <div class="system-header">
+            <div class="title">System Packages (read-only)</div>
+          </div>
+        </ng-template>
+        <nz-collapse-panel [nzHeader]="systemHeaderTpl">
+          <div class="system-panel-inner">
+            <div class="package-header-row">
+              <div class="package-column-label">Package</div>
+              <div class="package-column-label">Version</div>
+            </div>
+
+            <div
+              *ngFor="let pkg of systemPackages"
+              class="package-row system-row">
+              <div class="package-inputs">
+                <input
+                  nz-input
+                  class="system-input"
+                  [disabled]="true"
+                  [ngModel]="pkg.name" />
+
+                <input
+                  nz-input
+                  class="system-input"
+                  [disabled]="true"
+                  [ngModel]="pkg.version" />
+              </div>
+            </div>
+          </div>
+        </nz-collapse-panel>
+      </nz-collapse>
+    </div>
+
+    <!-- Environments -->
+    <nz-collapse>
+      <nz-collapse-panel
+        *ngFor="let pve of pves; let envIndex = index; trackBy: trackByIndex"
+        [nzActive]="pve.expanded"
+        (nzActiveChange)="pve.expanded = $event"
+        [nzHeader]="headerTpl">
+        <!-- Custom header -->
+        <ng-template #headerTpl>
+          <div class="env-header">
+            <span class="env-title"> {{ pve.name }} </span>
+            <span
+              class="env-actions"
+              (click)="$event.stopPropagation()">
+            </span>
+          </div>
+        </ng-template>
+
+        <!-- Panel body -->
+        <div class="ve-form">
+          <div class="fieldRow">
+            <label class="fieldLabel">Virtual Environment Name</label>
+            <input
+              nz-input
+              class="fieldInput"
+              placeholder="Environment Name"
+              [(ngModel)]="pve.name"
+              [disabled]="pve.isLocked || pve.isInstalling" />
+          </div>
+          <!-- Per-environment OK/Install button -->
+          <div class="env-footer">
+            <button
+              nz-button
+              nzType="primary"
+              (click)="createVirtualEnvironment(envIndex)"
+              [disabled]="!pve.name?.trim()"
+              [nzLoading]="pve.isInstalling">
+              OK
+            </button>
+          </div>
+
+          <!-- Pip Output (per env) -->
+          <div class="pip-panel">
+            <div class="pip-panel-header">
+              <span class="pip-panel-title">Pip Installation Output</span>
+              <span class="pip-panel-subtitle"> (Output will appear here after 
you click OK.) </span>
+            </div>
+
+            <div class="pip-panel-body">
+              <pre
+                class="pip-fullscreen-log"
+                [attr.id]="'pip-log-' + envIndex"
+                [innerHTML]="pve.prettyPipOutput || ' '"></pre>
+            </div>
+          </div>
+        </div>
+      </nz-collapse-panel>
+    </nz-collapse>
+  </ng-container>
+</nz-modal>
diff --git 
a/frontend/src/app/workspace/component/power-button/computing-unit-selection.component.scss
 
b/frontend/src/app/workspace/component/power-button/computing-unit-selection.component.scss
index 8f75d52a75..d6c552f64d 100644
--- 
a/frontend/src/app/workspace/component/power-button/computing-unit-selection.component.scss
+++ 
b/frontend/src/app/workspace/component/power-button/computing-unit-selection.component.scss
@@ -358,3 +358,28 @@
     outline: none;
   }
 }
+
+::ng-deep .error {
+  color: red;
+  font-weight: bold;
+}
+
+::ng-deep .warning {
+  color: #d4a000;
+  font-weight: bold;
+}
+
+::ng-deep .success {
+  color: green;
+  font-weight: bold;
+}
+
+::ng-deep .pip-exit.ok {
+  color: green;
+  font-weight: bold;
+}
+
+::ng-deep .pip-exit.err {
+  color: red;
+  font-weight: bold;
+}
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 fd0cb6265f..7d4712c165 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
@@ -17,7 +17,7 @@
  * under the License.
  */
 
-import { ChangeDetectorRef, Component, OnInit } from "@angular/core";
+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 {
@@ -56,6 +56,17 @@ import {
   isComputingUnitShmTooLarge,
   getJvmMemorySliderConfig,
 } from "../../../common/util/computing-unit.util";
+import { PvePackageResponse, WorkflowPveService } from 
"../../service/virtual-environment/virtual-environment.service";
+
+type PveDraft = {
+  name: string;
+  pipOutput: string;
+  prettyPipOutput: string;
+  expanded: boolean;
+  socket?: WebSocket;
+  isInstalling: boolean;
+  isLocked: boolean;
+};
 
 @UntilDestroy()
 @Component({
@@ -65,6 +76,11 @@ import {
   standalone: false,
 })
 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;
@@ -112,7 +128,9 @@ export class ComputingUnitSelectionComponent implements 
OnInit {
     private workflowExecutionsService: WorkflowExecutionsService,
     private modalService: NzModalService,
     private cdr: ChangeDetectorRef,
-    private computingUnitActionsService: ComputingUnitActionsService
+    private computingUnitActionsService: ComputingUnitActionsService,
+    private workflowPveService: WorkflowPveService,
+    private ngZone: NgZone
   ) {}
 
   ngOnInit(): void {
@@ -381,6 +399,17 @@ export class ComputingUnitSelectionComponent implements 
OnInit {
     }
 
     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);
+          },
+        });
+    }
   }
 
   /**
@@ -638,4 +667,232 @@ export class ComputingUnitSelectionComponent implements 
OnInit {
       this.computingUnitStatusService.refreshComputingUnitList();
     }
   }
+
+  trackByIndex(index: number): number {
+    return index;
+  }
+
+  addEnvironment(): void {
+    this.pves.push({
+      name: "",
+      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;
+
+    this.workflowPveService
+      .fetchPVEs(cuId)
+      .pipe(untilDestroyed(this))
+      .subscribe({
+        next: (resp: PvePackageResponse[]) => {
+          this.pves = resp.map(pve => ({
+            name: pve.pveName,
+            expanded: false,
+            isInstalling: false,
+            pipOutput: "",
+            prettyPipOutput: "",
+            isLocked: true,
+          }));
+
+          this.workflowPveService
+            .getSystemPackages()
+            .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(/\n/g, "<br/>");
+  }
+
+  createVirtualEnvironment(index: number): void {
+    const cuId = this.selectedComputingUnit!.computingUnit.cuid;
+
+    const env = this.pves[index];
+
+    const trimmedName = env.name.trim();
+
+    if (!/^[a-zA-Z0-9]+$/.test(trimmedName)) {
+      this.notificationService.error("Environment name must contain only 
letters and numbers.");
+      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;
+    }
+
+    const packageArray: string[] = [];
+
+    env.socket?.close();
+
+    const isLocal = this.selectedComputingUnit?.computingUnit.type === "local";
+
+    const websocketUrl = this.workflowPveService.createPveWebSocketUrl(cuId, 
trimmedName, isLocal, packageArray);
+    console.log("PVE websocketUrl", websocketUrl);
+    const socket = new WebSocket(websocketUrl);
+
+    this.pves[index] = {
+      ...env,
+      name: trimmedName,
+      socket,
+      pipOutput: "Starting ...\n",
+      isInstalling: true,
+      isLocked: true,
+    };
+
+    this.updatePrettyPipOutput(index);
+    this.scrollToBottomOfPipModal(index);
+
+    socket.onmessage = event => {
+      console.log("PVE WS received:", event.data);
+
+      this.ngZone.run(() => {
+        const currentEnv = this.pves[index];
+
+        if (event.data === "__DONE__") {
+          this.pves[index] = {
+            ...currentEnv,
+            socket: undefined,
+            isInstalling: false,
+            isLocked: true,
+          };
+
+          socket.close();
+          this.workflowPveService
+            .getSystemPackages()
+            .pipe(untilDestroyed(this))
+            .subscribe({
+              next: resp => {
+                this.systemPackages = resp.system.map(pkg => {
+                  const [name, version] = pkg.split("==");
+                  return { name: name.trim(), version: (version ?? "").trim() 
};
+                });
+                this.cdr.detectChanges();
+              },
+              error: (e: unknown) => console.error("Failed to refresh 
packages", e),
+            });
+
+          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 = err => {
+      console.log("PVE WS error", err);
+
+      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();
+      });
+    };
+
+    socket.onclose = event => {
+      console.log("PVE WS closed", {
+        code: event.code,
+        reason: event.reason,
+        wasClean: event.wasClean,
+      });
+    };
+  }
 }
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
new file mode 100644
index 0000000000..a581305759
--- /dev/null
+++ 
b/frontend/src/app/workspace/service/virtual-environment/virtual-environment.service.ts
@@ -0,0 +1,82 @@
+/*
+ * 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 { Injectable } from "@angular/core";
+import { Observable } from "rxjs";
+import { HttpClient, HttpParams } from "@angular/common/http";
+import { AuthService } from "../../../common/service/user/auth.service";
+
+export interface PackageResponse {
+  system: string[];
+  user: string[];
+}
+
+export interface PvePackageResponse {
+  pveName: string;
+  userPackages: string[];
+}
+
+@Injectable({ providedIn: "root" })
+export class WorkflowPveService {
+  constructor(private http: HttpClient) {}
+
+  getAccessToken(): string | null {
+    const token = AuthService.getAccessToken();
+    return token && token.trim().length > 0 ? token : null;
+  }
+
+  private buildBaseParams(): HttpParams {
+    let params = new HttpParams();
+    const token = this.getAccessToken();
+    if (token) {
+      params = params.set("access-token", token);
+    }
+    return params;
+  }
+
+  getSystemPackages(): Observable<PackageResponse> {
+    const params = this.buildBaseParams();
+    return this.http.get<PackageResponse>("/pve/system", { params });
+  }
+
+  fetchPVEs(cuid: number): Observable<PvePackageResponse[]> {
+    const params = this.buildBaseParams().set("cuid", cuid.toString());
+    return this.http.get<PvePackageResponse[]>("/pve/pves", { params });
+  }
+
+  deleteEnvironments(cuid: number) {
+    return this.http.delete(`/pve/pves/${cuid}`);
+  }
+
+  createPveWebSocketUrl(cuid: number, pveName: string, isLocal: boolean, 
packages: string[] = []): string {
+    const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
+    const query = encodeURIComponent(JSON.stringify(packages));
+
+    const token = this.getAccessToken();
+    const tokenParam = token ? `&access-token=${encodeURIComponent(token)}` : 
"";
+
+    return (
+      `${protocol}//${window.location.host}/wsapi/pve` +
+      `?packages=${query}` +
+      `&cuid=${cuid}` +
+      `&pveName=${encodeURIComponent(pveName)}` +
+      `&isLocal=${isLocal}` +
+      tokenParam
+    );
+  }
+}
diff --git a/frontend/src/styles.scss b/frontend/src/styles.scss
index 0ef58d8c16..6915b7f795 100644
--- a/frontend/src/styles.scss
+++ b/frontend/src/styles.scss
@@ -107,3 +107,272 @@ hr {
   position: relative;
   left: 0;
 }
+
+.pve-modal {
+  .ant-modal {
+    max-width: 980px;
+    width: 92vw !important;
+  }
+
+  .ant-modal-body {
+    padding: 16px 20px 18px;
+    background: #fafafa;
+  }
+
+  .ant-modal-header {
+    padding: 14px 20px;
+  }
+
+  .ant-modal-title {
+    font-weight: 600;
+    letter-spacing: 0.2px;
+  }
+
+  .footer-all {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    width: 100%;
+    gap: 10px;
+    padding: 8px 0;
+  }
+
+  nz-collapse {
+    display: block;
+  }
+
+  .ant-collapse {
+    border-radius: 12px;
+    overflow: hidden;
+    background: transparent;
+  }
+
+  .system-section {
+    margin-top: 5px;
+    margin-bottom: 5px;
+  }
+
+  .system-section .ant-collapse-item {
+    overflow: hidden;
+    background: #ffffff;
+    border: 1px solid #eef0f3;
+    box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
+  }
+
+  .system-section .ant-collapse-header {
+    font-weight: 600;
+  }
+
+  .system-panel-inner {
+    display: flex;
+    flex-direction: column;
+    gap: 6px;
+  }
+
+  .package-header-row,
+  .package-inputs {
+    display: grid;
+    grid-template-columns: 1fr 1fr;
+    gap: 12px;
+    align-items: center;
+  }
+
+  .package-column-label {
+    font-size: 12px;
+    font-weight: 500;
+    color: #666;
+  }
+
+  .system-row {
+    opacity: 0.9;
+    margin-bottom: 0px;
+  }
+
+  .system-input {
+    background: #f5f6f8 !important;
+    border-color: #e6e8ec !important;
+    color: #5a667a;
+    cursor: not-allowed;
+  }
+  .env-header {
+    width: 100%;
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: 12px;
+  }
+
+  .env-title {
+    font-weight: 600;
+    font-size: 14px;
+    color: #1f2a37;
+    min-width: 0;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+
+  .env-actions {
+    display: inline-flex;
+    align-items: center;
+    gap: 8px;
+    flex-shrink: 0;
+  }
+
+  .ant-collapse-item {
+    background: #ffffff;
+    border: 1px solid #eef0f3;
+    overflow: hidden;
+    margin-bottom: 12px;
+    box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
+  }
+
+  .ant-collapse-header {
+    padding: 12px 14px !important;
+    align-items: center !important;
+  }
+
+  .ant-collapse-content-box {
+    padding: 14px !important;
+  }
+
+  .ve-form {
+    display: flex;
+    flex-direction: column;
+    gap: 14px;
+  }
+
+  .fieldRow {
+    display: flex !important;
+    align-items: center !important;
+    gap: 12px !important;
+  }
+
+  .fieldLabel {
+    width: 220px;
+    margin: 0;
+    font-weight: 700;
+    white-space: nowrap;
+  }
+
+  .fieldInput {
+    flex: 1;
+    min-width: 0;
+  }
+
+  .package-row {
+    display: flex;
+    align-items: flex-end;
+    justify-content: space-between;
+    gap: 10px;
+    bottom: 0px;
+    border: 1px solid #eef0f3;
+    background: #ffffff;
+  }
+
+  .package-inputs {
+    flex: 1;
+    display: grid;
+    grid-template-columns: 1fr 1fr;
+    gap: 14px;
+    width: 100%;
+  }
+
+  .field {
+    display: flex;
+    flex-direction: column;
+    gap: 6px;
+    min-width: 0;
+    width: 100%;
+  }
+
+  .field input {
+    width: 100%;
+  }
+
+  .field label {
+    font-size: 11px;
+    font-weight: 600;
+    color: #6b7280;
+    line-height: 1;
+  }
+
+  .operator-select .ant-select {
+    width: 100%;
+  }
+
+  .ant-input,
+  .ant-select-selector {
+    //border-radius: 10px !important;
+  }
+
+  .ant-input[disabled] {
+    background: #f5f6f8 !important;
+    border-color: #e6e8ec !important;
+    color: #5a667a;
+  }
+
+  .env-footer {
+    display: flex;
+    justify-content: flex-end;
+    padding-top: 6px;
+  }
+
+  .pip-panel {
+    margin-top: 16px;
+    border: 1px solid #d9d9d9;
+    background: #f2f2f2;
+    overflow: hidden;
+  }
+
+  .pip-panel-header {
+    display: flex;
+    justify-content: space-between;
+    align-items: baseline;
+    padding: 10px 14px;
+    background: #e9e9e9;
+    border-bottom: 1px solid #d9d9d9;
+  }
+
+  .pip-panel-title {
+    font-weight: 600;
+    color: #222;
+  }
+
+  .pip-panel-subtitle {
+    font-size: 12px;
+    color: #666;
+  }
+
+  .pip-panel-body {
+    padding: 0;
+  }
+
+  .pip-fullscreen-log {
+    color: #333;
+    font-family: "JetBrains Mono", monospace;
+    font-size: 13px;
+    line-height: 1.6;
+    margin: 0;
+    padding: 14px;
+    white-space: pre-wrap;
+    overflow-y: auto;
+    max-height: 220px;
+    background: transparent;
+  }
+
+  .system-header {
+    display: flex;
+    flex-direction: column;
+
+    .title {
+      font-weight: 500;
+    }
+
+    .subtitle {
+      font-size: 12px;
+      color: #8c8c8c;
+      margin-top: 2px;
+    }
+  }
+}

Reply via email to