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, "&")
+ .replace(/</g, "<")
+ .replace(/>/g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+
+ 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;
+ }
+ }
+}