From cd745e5433f8778ef6dfc3298d54b015b9e1a8c8 Mon Sep 17 00:00:00 2001 From: Sarah Asad Date: Mon, 8 Jun 2026 14:38:25 -0700 Subject: [PATCH 01/11] Add PVE table --- sql/texera_ddl.sql | 11 +++++++++++ sql/updates/24.sql | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 sql/updates/24.sql diff --git a/sql/texera_ddl.sql b/sql/texera_ddl.sql index 83ed0abb503..939c2943529 100644 --- a/sql/texera_ddl.sql +++ b/sql/texera_ddl.sql @@ -76,6 +76,7 @@ DROP TABLE IF EXISTS site_settings CASCADE; DROP TABLE IF EXISTS computing_unit_user_access CASCADE; DROP TABLE IF EXISTS notebook CASCADE; DROP TABLE IF EXISTS workflow_notebook_mapping CASCADE; +DROP TABLE IF EXISTS python_virtual_environments CASCADE; -- ============================================ -- 4. Create PostgreSQL enum types @@ -213,6 +214,16 @@ CREATE TABLE IF NOT EXISTS workflow_computing_unit FOREIGN KEY (uid) REFERENCES "user"(uid) ON DELETE CASCADE ); +-- python_virtual_environments table +CREATE TABLE IF NOT EXISTS python_virtual_environments +( + pveid SERIAL PRIMARY KEY, + uid INT NOT NULL, + name VARCHAR(128) NOT NULL, + packages JSONB NOT NULL DEFAULT '{}'::jsonb, + FOREIGN KEY (uid) REFERENCES "user"(uid) ON DELETE CASCADE +); + -- workflow_executions CREATE TABLE IF NOT EXISTS workflow_executions ( diff --git a/sql/updates/24.sql b/sql/updates/24.sql new file mode 100644 index 00000000000..518976d06f2 --- /dev/null +++ b/sql/updates/24.sql @@ -0,0 +1,38 @@ +/* + * 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. + */ + +\c texera_db + +SET search_path TO texera_db; + +BEGIN; + +-- Adds the python_virtual_environments table, used to persist user-owned PVE +-- metadata (name + installed package versions) instead of relying on the +-- filesystem layout under /tmp/texera-pve/venvs. +CREATE TABLE IF NOT EXISTS python_virtual_environments +( + pveid SERIAL PRIMARY KEY, + uid INT NOT NULL, + name VARCHAR(128) NOT NULL, + packages JSONB NOT NULL DEFAULT '{}'::jsonb, + FOREIGN KEY (uid) REFERENCES "user"(uid) ON DELETE CASCADE +); + +COMMIT; From 58c80b2d6ed145187926588a4294c497fda93822 Mon Sep 17 00:00:00 2001 From: Sarah Asad Date: Mon, 8 Jun 2026 15:30:42 -0700 Subject: [PATCH 02/11] add PUT --- .../service/resource/AccessControlResource.scala | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/access-control-service/src/main/scala/org/apache/texera/service/resource/AccessControlResource.scala b/access-control-service/src/main/scala/org/apache/texera/service/resource/AccessControlResource.scala index 0c90a6ce31f..96b2d52624f 100644 --- a/access-control-service/src/main/scala/org/apache/texera/service/resource/AccessControlResource.scala +++ b/access-control-service/src/main/scala/org/apache/texera/service/resource/AccessControlResource.scala @@ -23,7 +23,7 @@ import com.typesafe.scalalogging.LazyLogging import jakarta.annotation.security.{PermitAll, RolesAllowed} import jakarta.ws.rs.client.{Client, ClientBuilder, Entity} import jakarta.ws.rs.core._ -import jakarta.ws.rs.{Consumes, DELETE, GET, POST, Path, Produces} +import jakarta.ws.rs.{Consumes, DELETE, GET, POST, PUT, Path, Produces} import org.apache.texera.auth.JwtParser.parseToken import org.apache.texera.auth.SessionUser import org.apache.texera.auth.util.{ComputingUnitAccess, HeaderField} @@ -233,6 +233,16 @@ class AccessControlResource extends LazyLogging { AccessControlResource.authorize(uriInfo, headers, Option(body).map(_.trim).filter(_.nonEmpty)) } + @PUT + @Path("/{path:.*}") + def authorizePut( + @Context uriInfo: UriInfo, + @Context headers: HttpHeaders, + body: String + ): Response = { + AccessControlResource.authorize(uriInfo, headers, Option(body).map(_.trim).filter(_.nonEmpty)) + } + @DELETE @Path("/{path:.*}") def authorizeDelete( From 7eb96f7f85166dca14da97178847c85f5d7e50f6 Mon Sep 17 00:00:00 2001 From: Sarah Asad Date: Mon, 8 Jun 2026 15:31:21 -0700 Subject: [PATCH 03/11] frontend service updated --- .../virtual-environment.service.ts | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/frontend/src/app/workspace/service/virtual-environment/virtual-environment.service.ts b/frontend/src/app/workspace/service/virtual-environment/virtual-environment.service.ts index d3108e47564..9fe9b1ca80f 100644 --- a/frontend/src/app/workspace/service/virtual-environment/virtual-environment.service.ts +++ b/frontend/src/app/workspace/service/virtual-environment/virtual-environment.service.ts @@ -31,10 +31,36 @@ export interface PvePackageResponse { userPackages: string[]; } +export interface UserPveRecord { + pveid: number; + name: string; + packages: Record; +} + @Injectable({ providedIn: "root" }) export class WorkflowPveService { constructor(private http: HttpClient) {} + // Persists a PVE spec (name + packages map) for the current user. + savePve(name: string, packages: Record): Observable<{ pveid: number }> { + return this.http.post<{ pveid: number }>("/pve/db", { name, packages }); + } + + // Updates an existing PVE row owned by the current user. + updateUserPve(pveid: number, name: string, packages: Record): Observable<{ pveid: number }> { + return this.http.put<{ pveid: number }>(`/pve/db/${pveid}`, { name, packages }); + } + + // Returns every PVE row owned by the current user. + listUserPves(): Observable { + return this.http.get("/pve/db"); + } + + // Deletes one of the current user's PVEs by its pveid. + deleteUserPve(pveid: number): Observable { + return this.http.delete(`/pve/db/${pveid}`); + } + getAccessToken(): string | null { const token = AuthService.getAccessToken(); return token && token.trim().length > 0 ? token : null; From e378ce9e4982086f0d89aaeddef6c3a0adcc1c04 Mon Sep 17 00:00:00 2001 From: Sarah Asad Date: Mon, 8 Jun 2026 15:32:13 -0700 Subject: [PATCH 04/11] backend api --- .../pythonvirtualenvironment/PveManager.scala | 74 ++++++++++++++ .../PveResource.scala | 98 +++++++++++++++++++ 2 files changed, 172 insertions(+) diff --git a/amber/src/main/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveManager.scala b/amber/src/main/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveManager.scala index 2256798030b..662eb06a7e8 100644 --- a/amber/src/main/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveManager.scala +++ b/amber/src/main/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveManager.scala @@ -26,6 +26,10 @@ import scala.jdk.CollectionConverters._ import scala.sys.process._ import java.util.Comparator import org.apache.texera.amber.config.PythonUtils +import org.apache.texera.dao.SqlServer +import org.apache.texera.dao.jooq.generated.tables.daos.PythonVirtualEnvironmentsDao +import org.apache.texera.dao.jooq.generated.tables.pojos.PythonVirtualEnvironments +import org.jooq.JSONB /** * PveManager is responsible for managing Python Virtual Environments (PVEs) @@ -47,6 +51,10 @@ object PveManager { userPackages: Seq[String] ) + // Row read from the python_virtual_environments table; packages is kept as the + // raw JSON string so PveResource can parse it with its own ObjectMapper. + case class StoredPve(pveid: Int, name: String, packagesJson: String) + private val VenvRoot: Path = Paths.get("/tmp/texera-pve/venvs") private val SafePveName = "^[A-Za-z0-9._-]+$".r @@ -213,6 +221,72 @@ object PveManager { queue.put(s"[PVE] Created new environment for cuid = $cuid") } + // Returns every PVE row belonging to the given user. + def listPvesForUser(uid: Int): List[StoredPve] = { + import org.apache.texera.dao.jooq.generated.Tables.PYTHON_VIRTUAL_ENVIRONMENTS + SqlServer + .getInstance() + .createDSLContext() + .selectFrom(PYTHON_VIRTUAL_ENVIRONMENTS) + .where(PYTHON_VIRTUAL_ENVIRONMENTS.UID.eq(uid)) + .fetchInto(classOf[PythonVirtualEnvironments]) + .asScala + .map { row => + val pkgsJson = Option(row.getPackages).map(_.data).getOrElse("{}") + StoredPve(row.getPveid, row.getName, pkgsJson) + } + .toList + } + + // Deletes a PVE row owned by `uid`. Returns true if a row was deleted, false if no + // matching row was found (either the pveid doesn't exist or it belongs to another user). + def deletePveFromDb(pveid: Int, uid: Int): Boolean = { + import org.apache.texera.dao.jooq.generated.Tables.PYTHON_VIRTUAL_ENVIRONMENTS + val rows = SqlServer + .getInstance() + .createDSLContext() + .deleteFrom(PYTHON_VIRTUAL_ENVIRONMENTS) + .where( + PYTHON_VIRTUAL_ENVIRONMENTS.PVEID + .eq(pveid) + .and(PYTHON_VIRTUAL_ENVIRONMENTS.UID.eq(uid)) + ) + .execute() + rows > 0 + } + + // Updates an existing PVE row owned by `uid`. Returns true if a row was + // updated, false if no matching row was found. + def updatePve(pveid: Int, uid: Int, name: String, packagesJson: String): Boolean = { + import org.apache.texera.dao.jooq.generated.Tables.PYTHON_VIRTUAL_ENVIRONMENTS + val rows = SqlServer + .getInstance() + .createDSLContext() + .update(PYTHON_VIRTUAL_ENVIRONMENTS) + .set(PYTHON_VIRTUAL_ENVIRONMENTS.NAME, name) + .set(PYTHON_VIRTUAL_ENVIRONMENTS.PACKAGES, JSONB.valueOf(packagesJson)) + .where( + PYTHON_VIRTUAL_ENVIRONMENTS.PVEID + .eq(pveid) + .and(PYTHON_VIRTUAL_ENVIRONMENTS.UID.eq(uid)) + ) + .execute() + rows > 0 + } + + // Persists a PVE spec (name + packages JSON) for the given user. Returns the new pveid. + def savePve(uid: Int, name: String, packagesJson: String): Int = { + val row = new PythonVirtualEnvironments() + row.setUid(uid) + row.setName(name) + row.setPackages(JSONB.valueOf(packagesJson)) + val dao = new PythonVirtualEnvironmentsDao( + SqlServer.getInstance().createDSLContext().configuration + ) + dao.insert(row) + row.getPveid + } + // returns list of PVE names and corresponding user packages for a given CU def getEnvironments(cuid: Int): List[PvePackageResponse] = { diff --git a/amber/src/main/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveResource.scala b/amber/src/main/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveResource.scala index ac07616d509..3b97dbdda23 100644 --- a/amber/src/main/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveResource.scala +++ b/amber/src/main/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveResource.scala @@ -19,6 +19,11 @@ package org.apache.texera.web.resource.pythonvirtualenvironment +import com.fasterxml.jackson.core.`type`.TypeReference +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.scala.DefaultScalaModule +import io.dropwizard.auth.Auth +import org.apache.texera.auth.SessionUser import org.apache.texera.config.KubernetesConfig import javax.ws.rs._ @@ -29,9 +34,18 @@ import javax.ws.rs.DELETE import javax.ws.rs.PathParam import javax.ws.rs.core.Response +object PveResource { + case class SavePvePayload(name: String, packages: Map[String, String]) + case class PveListItem(pveid: Int, name: String, packages: Map[String, String]) +} + @Path("/pve") @Consumes(Array(MediaType.APPLICATION_JSON)) class PveResource { + import PveResource._ + + private val mapper: ObjectMapper = new ObjectMapper().registerModule(DefaultScalaModule) + private val packagesType = new TypeReference[java.util.Map[String, String]] {} // -------------------------------------------------- // Get system packages // -------------------------------------------------- @@ -54,6 +68,90 @@ class PveResource { } } + // -------------------------------------------------- + // List all PVEs for the current user from the database + // -------------------------------------------------- + @GET + @Path("/db") + @Produces(Array(MediaType.APPLICATION_JSON)) + def listPves(@Auth sessionUser: SessionUser): java.util.List[PveListItem] = { + PveManager + .listPvesForUser(sessionUser.getUid.intValue()) + .map { stored => + val packages: Map[String, String] = + try mapper.readValue(stored.packagesJson, packagesType).asScala.toMap + catch { case _: Throwable => Map.empty[String, String] } + PveListItem(stored.pveid, stored.name, packages) + } + .asJava + } + + // -------------------------------------------------- + // Update a PVE row owned by the current user + // -------------------------------------------------- + @PUT + @Path("/db/{pveid}") + @Produces(Array(MediaType.APPLICATION_JSON)) + def updatePve( + @PathParam("pveid") pveid: Int, + payload: SavePvePayload, + @Auth sessionUser: SessionUser + ): Response = { + val name = Option(payload.name).map(_.trim).getOrElse("") + if (name.isEmpty) { + return Response + .status(Response.Status.BAD_REQUEST) + .entity("name is required") + .build() + } + try { + val packagesJson = mapper.writeValueAsString(payload.packages) + val updated = PveManager.updatePve(pveid, sessionUser.getUid.intValue(), name, packagesJson) + if (updated) Response.ok(Map("pveid" -> pveid).asJava).build() + else Response.status(Response.Status.NOT_FOUND).build() + } catch { + case e: Exception => + e.printStackTrace() + throw new InternalServerErrorException(s"Failed to update PVE: ${e.getMessage}") + } + } + + // -------------------------------------------------- + // Delete a PVE row owned by the current user + // -------------------------------------------------- + @DELETE + @Path("/db/{pveid}") + def deletePveFromDb(@PathParam("pveid") pveid: Int, @Auth sessionUser: SessionUser): Response = { + val deleted = PveManager.deletePveFromDb(pveid, sessionUser.getUid.intValue()) + if (deleted) Response.noContent().build() + else Response.status(Response.Status.NOT_FOUND).build() + } + + // -------------------------------------------------- + // Save a PVE (name + packages) to the database for the current user + // -------------------------------------------------- + @POST + @Path("/db") + @Produces(Array(MediaType.APPLICATION_JSON)) + def savePve(payload: SavePvePayload, @Auth sessionUser: SessionUser): Response = { + val name = Option(payload.name).map(_.trim).getOrElse("") + if (name.isEmpty) { + return Response + .status(Response.Status.BAD_REQUEST) + .entity("name is required") + .build() + } + try { + val packagesJson = mapper.writeValueAsString(payload.packages) + val pveid = PveManager.savePve(sessionUser.getUid.intValue(), name, packagesJson) + Response.ok(Map("pveid" -> pveid).asJava).build() + } catch { + case e: Exception => + e.printStackTrace() + throw new InternalServerErrorException(s"Failed to save PVE: ${e.getMessage}") + } + } + // -------------------------------------------------- // Fetch PVEs and Installed User Packages // -------------------------------------------------- From ead124d2e9cca0a1b61ae47ef8cb558746e3ed2c Mon Sep 17 00:00:00 2001 From: Sarah Asad Date: Mon, 8 Jun 2026 17:03:44 -0700 Subject: [PATCH 05/11] add ove to left panel --- .../app/dashboard/component/dashboard.component.html | 11 +++++++++++ .../app/dashboard/component/dashboard.component.ts | 2 ++ 2 files changed, 13 insertions(+) diff --git a/frontend/src/app/dashboard/component/dashboard.component.html b/frontend/src/app/dashboard/component/dashboard.component.html index 9014ea37e5a..c01def869b5 100644 --- a/frontend/src/app/dashboard/component/dashboard.component.html +++ b/frontend/src/app/dashboard/component/dashboard.component.html @@ -109,6 +109,17 @@ nzType="deployment-unit"> Compute +
  • + + Environments +
  • Date: Mon, 8 Jun 2026 17:04:01 -0700 Subject: [PATCH 06/11] add pve to left panel --- frontend/src/app/app-routing.constant.ts | 1 + frontend/src/app/app-routing.module.ts | 5 +++++ frontend/src/app/app.module.ts | 2 ++ 3 files changed, 8 insertions(+) diff --git a/frontend/src/app/app-routing.constant.ts b/frontend/src/app/app-routing.constant.ts index 6e06f725201..e0b2c9eab09 100644 --- a/frontend/src/app/app-routing.constant.ts +++ b/frontend/src/app/app-routing.constant.ts @@ -35,6 +35,7 @@ export const USER_WORKFLOW = `${USER}/workflow`; export const USER_DATASET = `${USER}/dataset`; export const USER_DATASET_CREATE = `${USER_DATASET}/create`; export const USER_COMPUTING_UNIT = `${USER}/compute`; +export const USER_PYTHON_VENV = `${USER}/python-venv`; export const USER_QUOTA = `${USER}/quota`; export const USER_DISCUSSION = `${USER}/discussion`; diff --git a/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts index 8e5a44903e3..ec2abafcb27 100644 --- a/frontend/src/app/app-routing.module.ts +++ b/frontend/src/app/app-routing.module.ts @@ -25,6 +25,7 @@ import { UserQuotaComponent } from "./dashboard/component/user/user-quota/user-q import { UserProjectSectionComponent } from "./dashboard/component/user/user-project/user-project-section/user-project-section.component"; import { UserProjectComponent } from "./dashboard/component/user/user-project/user-project.component"; import { UserComputingUnitComponent } from "./dashboard/component/user/user-computing-unit/user-computing-unit.component"; +import { UserPythonVenvComponent } from "./dashboard/component/user/user-python-venv/user-python-venv.component"; import { WorkspaceComponent } from "./workspace/component/workspace.component"; import { AboutComponent } from "./hub/component/about/about.component"; import { AuthGuardService } from "./common/service/user/auth-guard.service"; @@ -128,6 +129,10 @@ routes.push({ path: "compute", component: UserComputingUnitComponent, }, + { + path: "python-venv", + component: UserPythonVenvComponent, + }, { path: "quota", component: UserQuotaComponent, diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index 511395365df..c5492bc728f 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -192,6 +192,7 @@ import { NzCheckboxModule } from "ng-zorro-antd/checkbox"; import { RegistrationRequestModalComponent } from "./common/service/user/registration-request-modal/registration-request-modal.component"; import { UserComputingUnitComponent } from "./dashboard/component/user/user-computing-unit/user-computing-unit.component"; import { UserComputingUnitListItemComponent } from "./dashboard/component/user/user-computing-unit/user-computing-unit-list-item/user-computing-unit-list-item.component"; +import { UserPythonVenvComponent } from "./dashboard/component/user/user-python-venv/user-python-venv.component"; registerLocaleData(en); @@ -361,6 +362,7 @@ registerLocaleData(en); MarkdownDescriptionComponent, UserComputingUnitComponent, UserComputingUnitListItemComponent, + UserPythonVenvComponent, ], providers: [ provideNzI18n(en_US), From 362f85057147a9b6757002fe6347d0c71db6828e Mon Sep 17 00:00:00 2001 From: Sarah Asad Date: Mon, 8 Jun 2026 17:04:31 -0700 Subject: [PATCH 07/11] add pve page --- .../user-python-venv.component.html | 221 +++++++++++++++ .../user-python-venv.component.scss | 97 +++++++ .../user-python-venv.component.ts | 264 ++++++++++++++++++ 3 files changed, 582 insertions(+) create mode 100644 frontend/src/app/dashboard/component/user/user-python-venv/user-python-venv.component.html create mode 100644 frontend/src/app/dashboard/component/user/user-python-venv/user-python-venv.component.scss create mode 100644 frontend/src/app/dashboard/component/user/user-python-venv/user-python-venv.component.ts diff --git a/frontend/src/app/dashboard/component/user/user-python-venv/user-python-venv.component.html b/frontend/src/app/dashboard/component/user/user-python-venv/user-python-venv.component.html new file mode 100644 index 00000000000..ba505a42624 --- /dev/null +++ b/frontend/src/app/dashboard/component/user/user-python-venv/user-python-venv.component.html @@ -0,0 +1,221 @@ + + +
    + +

    Python Venvs

    +
    + +
    +
    + + +
    +
    + No environments yet. Click Create Python Venv to create one. +
    + +
      +
    • + {{ pve.name || "(unnamed)" }} + + +
    • +
    +
    +
    +
    + + + + + + + +
    +
    + + +
    + + +
    +
    +
    + +
    +
    + +
    +
    + +
    + +
    +
    + + +
    +
    +
    +
    Package
    +
    +
    Version
    +
    +
    +
    + +
    +
    +
    + +
    +
    + + + + + +
    +
    + +
    + +
    +
    +
    + +
    + +
    +
    +
    +
    diff --git a/frontend/src/app/dashboard/component/user/user-python-venv/user-python-venv.component.scss b/frontend/src/app/dashboard/component/user/user-python-venv/user-python-venv.component.scss new file mode 100644 index 00000000000..e620f4fc61a --- /dev/null +++ b/frontend/src/app/dashboard/component/user/user-python-venv/user-python-venv.component.scss @@ -0,0 +1,97 @@ +/* + * 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 "../../section-style"; +@import "../../button-style"; + +.subsection-grid-container { + min-width: 100%; + width: 100%; + min-height: 100%; + height: 100%; +} + +.python-env-name { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.python-env-delete-icon { + flex-shrink: 0; + color: rgba(0, 0, 0, 0.55); + cursor: pointer; + + &:hover { + color: #ff4d4f; + } +} + +.python-env-page { + display: flex; + flex-direction: column; + gap: 16px; + width: 100%; + + .python-env-page-empty { + padding: 24px; + color: rgba(0, 0, 0, 0.55); + text-align: center; + border: 1px dashed #d9d9d9; + border-radius: 4px; + } + + .python-env-page-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 8px; + width: 100%; + } + + .python-env-page-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + width: 100%; + padding: 16px 20px; + box-sizing: border-box; + background: #ffffff; + border: 1px solid #eef0f3; + border-radius: 6px; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03); + cursor: pointer; + transition: background 0.15s ease, box-shadow 0.15s ease; + + &:hover { + background: #fafafa; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.06); + } + + .python-env-name { + font-size: 15px; + font-weight: 500; + } + } +} diff --git a/frontend/src/app/dashboard/component/user/user-python-venv/user-python-venv.component.ts b/frontend/src/app/dashboard/component/user/user-python-venv/user-python-venv.component.ts new file mode 100644 index 00000000000..a19ae16bd72 --- /dev/null +++ b/frontend/src/app/dashboard/component/user/user-python-venv/user-python-venv.component.ts @@ -0,0 +1,264 @@ +/* + * 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 { Component, OnInit } from "@angular/core"; +import { FormsModule } from "@angular/forms"; +import { NgFor, NgIf } from "@angular/common"; +import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy"; + +import { NzButtonComponent } from "ng-zorro-antd/button"; +import { NzCardComponent } from "ng-zorro-antd/card"; +import { NzIconDirective } from "ng-zorro-antd/icon"; +import { NzInputDirective } from "ng-zorro-antd/input"; +import { NzModalComponent, NzModalContentDirective, NzModalService } from "ng-zorro-antd/modal"; +import { NzOptionComponent, NzSelectComponent } from "ng-zorro-antd/select"; +import { NzTooltipDirective } from "ng-zorro-antd/tooltip"; + +import { NotificationService } from "../../../../common/service/notification/notification.service"; +import { + UserPveRecord, + WorkflowPveService, +} from "../../../../workspace/service/virtual-environment/virtual-environment.service"; + +type PveUserPackageRow = { + name: string; + versionOp: "==" | ">=" | "<="; + version: string; + deleteToggle?: boolean; +}; + +type PveDraft = { + pveid?: number; + name: string; + userPackages: PveUserPackageRow[]; + newPackages: PveUserPackageRow[]; + expanded: boolean; +}; + +@UntilDestroy() +@Component({ + selector: "texera-user-python-venv", + templateUrl: "./user-python-venv.component.html", + styleUrls: ["./user-python-venv.component.scss"], + imports: [ + NgIf, + NgFor, + FormsModule, + NzButtonComponent, + NzCardComponent, + NzIconDirective, + NzInputDirective, + NzModalComponent, + NzModalContentDirective, + NzSelectComponent, + NzOptionComponent, + NzTooltipDirective, + ], +}) +export class UserPythonVenvComponent implements OnInit { + // The user's PVEs (fetched from the DB), rendered as the page list. + pves: PveDraft[] = []; + + // The single PVE currently being edited in the modal. Null when modal is closed. + currentDraft: PveDraft | null = null; + + pveModalVisible = false; + saving = false; + + constructor( + private workflowPveService: WorkflowPveService, + private notificationService: NotificationService, + private modalService: NzModalService + ) {} + + ngOnInit(): void { + this.refreshPves(); + } + + // Pops a confirmation dialog before actually deleting. Wired into the trash + // icons so a single misclick doesn't drop a row. + confirmDeletePve(index: number): void { + const target = this.pves[index]; + if (!target) return; + const name = target.name || "(unnamed)"; + this.modalService.confirm({ + nzTitle: `Delete environment "${name}"?`, + nzContent: "This permanently removes the environment from the database.", + nzOkText: "Delete", + nzOkDanger: true, + nzOnOk: () => this.deletePve(index), + }); + } + + private refreshPves(): void { + this.workflowPveService + .listUserPves() + .pipe(untilDestroyed(this)) + .subscribe({ + next: records => { + this.pves = records.map(record => this.recordToDraft(record)); + }, + error: (err: unknown) => { + console.error("Failed to fetch Python environments", err); + this.notificationService.error("Failed to fetch Python environments."); + }, + }); + } + + private recordToDraft(record: UserPveRecord): PveDraft { + const newPackages: PveUserPackageRow[] = Object.entries(record.packages ?? {}).map(([name, raw]) => { + const match = raw?.match?.(/^(==|>=|<=)(.*)$/); + return { + name, + versionOp: (match ? match[1] : "==") as "==" | ">=" | "<=", + version: match ? match[2] : raw ?? "", + }; + }); + return { + pveid: record.pveid, + name: record.name, + userPackages: [], + newPackages, + expanded: false, + }; + } + + // "Create Python Venv" entry-point: open the modal with a fresh empty draft. + showPveModal(): void { + this.currentDraft = { + name: "", + userPackages: [], + newPackages: [], + expanded: true, + }; + this.pveModalVisible = true; + } + + // Clicking an existing PVE row: open the modal pre-filled. + openExistingPve(index: number): void { + const source = this.pves[index]; + if (!source) return; + this.currentDraft = { + pveid: source.pveid, + name: source.name, + userPackages: source.userPackages.map(p => ({ ...p })), + newPackages: source.newPackages.map(p => ({ ...p })), + expanded: true, + }; + this.pveModalVisible = true; + } + + closePveModal(): void { + this.pveModalVisible = false; + this.currentDraft = null; + } + + addPackage(): void { + this.currentDraft?.newPackages.push({ name: "", versionOp: "==", version: "" }); + } + + togglePackageDelete(pkg: PveUserPackageRow): void { + pkg.deleteToggle = !pkg.deleteToggle; + } + + saveEnvironment(): void { + const draft = this.currentDraft; + if (!draft) return; + + const trimmedName = draft.name.trim(); + if (!trimmedName) { + this.notificationService.error("Environment name is required."); + return; + } + + // Block duplicate names. When editing an existing draft (one with a pveid), + // a same-named row referring to itself is fine — that's just the current + // record. Anything else with the same name is a conflict. + const conflict = this.pves.find( + p => p.name.trim() === trimmedName && p.pveid !== draft.pveid + ); + if (conflict) { + this.notificationService.error(`An environment named "${trimmedName}" already exists.`); + return; + } + + const packages: Record = {}; + for (const row of draft.newPackages) { + // Rows highlighted via the trash icon are dropped from the persisted set, + // so saving with one selected effectively deletes that package. + if (row.deleteToggle) continue; + const pkgName = row.name.trim(); + if (!pkgName) continue; + const pkgVersion = (row.version ?? "").trim(); + if (packages[pkgName] !== undefined) { + this.notificationService.error(`Duplicate package "${pkgName}".`); + return; + } + packages[pkgName] = pkgVersion ? `${row.versionOp}${pkgVersion}` : ""; + } + + this.saving = true; + const request$ = + draft.pveid === undefined + ? this.workflowPveService.savePve(trimmedName, packages) + : this.workflowPveService.updateUserPve(draft.pveid, trimmedName, packages); + + request$.pipe(untilDestroyed(this)).subscribe({ + next: () => { + this.saving = false; + this.notificationService.success(`Saved environment "${trimmedName}".`); + this.closePveModal(); + this.refreshPves(); + }, + error: (err: unknown) => { + this.saving = false; + console.error("Failed to save PVE", err); + this.notificationService.error("Failed to save Python environment."); + }, + }); + } + + trackByIndex(index: number): number { + return index; + } + + // Deletes the PVE at `index` from the DB and refreshes the list. Invoked from + // the confirmation modal opened by confirmDeletePve(index). + deletePve(index: number): void { + const target = this.pves[index]; + if (!target || target.pveid === undefined) return; + + const pveid = target.pveid; + const name = target.name || "(unnamed)"; + + this.workflowPveService + .deleteUserPve(pveid) + .pipe(untilDestroyed(this)) + .subscribe({ + next: () => { + this.notificationService.success(`Deleted environment "${name}".`); + this.refreshPves(); + }, + error: (err: unknown) => { + console.error("Failed to delete PVE", err); + this.notificationService.error("Failed to delete Python environment."); + }, + }); + } +} From 8cd13d4fdc2f75e56ce0f80884dba999639adf5a Mon Sep 17 00:00:00 2001 From: Sarah Asad Date: Mon, 8 Jun 2026 17:31:51 -0700 Subject: [PATCH 08/11] formatting --- .../user/user-python-venv/user-python-venv.component.scss | 4 +++- .../user/user-python-venv/user-python-venv.component.ts | 4 +--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/app/dashboard/component/user/user-python-venv/user-python-venv.component.scss b/frontend/src/app/dashboard/component/user/user-python-venv/user-python-venv.component.scss index e620f4fc61a..141c2a980d6 100644 --- a/frontend/src/app/dashboard/component/user/user-python-venv/user-python-venv.component.scss +++ b/frontend/src/app/dashboard/component/user/user-python-venv/user-python-venv.component.scss @@ -82,7 +82,9 @@ border-radius: 6px; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03); cursor: pointer; - transition: background 0.15s ease, box-shadow 0.15s ease; + transition: + background 0.15s ease, + box-shadow 0.15s ease; &:hover { background: #fafafa; diff --git a/frontend/src/app/dashboard/component/user/user-python-venv/user-python-venv.component.ts b/frontend/src/app/dashboard/component/user/user-python-venv/user-python-venv.component.ts index a19ae16bd72..7dd51a78752 100644 --- a/frontend/src/app/dashboard/component/user/user-python-venv/user-python-venv.component.ts +++ b/frontend/src/app/dashboard/component/user/user-python-venv/user-python-venv.component.ts @@ -190,9 +190,7 @@ export class UserPythonVenvComponent implements OnInit { // Block duplicate names. When editing an existing draft (one with a pveid), // a same-named row referring to itself is fine — that's just the current // record. Anything else with the same name is a conflict. - const conflict = this.pves.find( - p => p.name.trim() === trimmedName && p.pveid !== draft.pveid - ); + const conflict = this.pves.find(p => p.name.trim() === trimmedName && p.pveid !== draft.pveid); if (conflict) { this.notificationService.error(`An environment named "${trimmedName}" already exists.`); return; From 816dfd2d33668633e53ceff31d9f9c7b88b9abdd Mon Sep 17 00:00:00 2001 From: Sarah Asad Date: Mon, 8 Jun 2026 17:58:35 -0700 Subject: [PATCH 09/11] formatting --- .../pythonvirtualenvironment/PveManager.scala | 2 -- .../user-python-venv/user-python-venv.component.ts | 11 ----------- .../virtual-environment.service.ts | 4 ---- 3 files changed, 17 deletions(-) diff --git a/amber/src/main/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveManager.scala b/amber/src/main/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveManager.scala index 662eb06a7e8..658ac80b1d9 100644 --- a/amber/src/main/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveManager.scala +++ b/amber/src/main/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveManager.scala @@ -51,8 +51,6 @@ object PveManager { userPackages: Seq[String] ) - // Row read from the python_virtual_environments table; packages is kept as the - // raw JSON string so PveResource can parse it with its own ObjectMapper. case class StoredPve(pveid: Int, name: String, packagesJson: String) private val VenvRoot: Path = Paths.get("/tmp/texera-pve/venvs") diff --git a/frontend/src/app/dashboard/component/user/user-python-venv/user-python-venv.component.ts b/frontend/src/app/dashboard/component/user/user-python-venv/user-python-venv.component.ts index 7dd51a78752..abd590f4b1a 100644 --- a/frontend/src/app/dashboard/component/user/user-python-venv/user-python-venv.component.ts +++ b/frontend/src/app/dashboard/component/user/user-python-venv/user-python-venv.component.ts @@ -91,8 +91,6 @@ export class UserPythonVenvComponent implements OnInit { this.refreshPves(); } - // Pops a confirmation dialog before actually deleting. Wired into the trash - // icons so a single misclick doesn't drop a row. confirmDeletePve(index: number): void { const target = this.pves[index]; if (!target) return; @@ -139,7 +137,6 @@ export class UserPythonVenvComponent implements OnInit { }; } - // "Create Python Venv" entry-point: open the modal with a fresh empty draft. showPveModal(): void { this.currentDraft = { name: "", @@ -150,7 +147,6 @@ export class UserPythonVenvComponent implements OnInit { this.pveModalVisible = true; } - // Clicking an existing PVE row: open the modal pre-filled. openExistingPve(index: number): void { const source = this.pves[index]; if (!source) return; @@ -187,9 +183,6 @@ export class UserPythonVenvComponent implements OnInit { return; } - // Block duplicate names. When editing an existing draft (one with a pveid), - // a same-named row referring to itself is fine — that's just the current - // record. Anything else with the same name is a conflict. const conflict = this.pves.find(p => p.name.trim() === trimmedName && p.pveid !== draft.pveid); if (conflict) { this.notificationService.error(`An environment named "${trimmedName}" already exists.`); @@ -198,8 +191,6 @@ export class UserPythonVenvComponent implements OnInit { const packages: Record = {}; for (const row of draft.newPackages) { - // Rows highlighted via the trash icon are dropped from the persisted set, - // so saving with one selected effectively deletes that package. if (row.deleteToggle) continue; const pkgName = row.name.trim(); if (!pkgName) continue; @@ -236,8 +227,6 @@ export class UserPythonVenvComponent implements OnInit { return index; } - // Deletes the PVE at `index` from the DB and refreshes the list. Invoked from - // the confirmation modal opened by confirmDeletePve(index). deletePve(index: number): void { const target = this.pves[index]; if (!target || target.pveid === undefined) return; diff --git a/frontend/src/app/workspace/service/virtual-environment/virtual-environment.service.ts b/frontend/src/app/workspace/service/virtual-environment/virtual-environment.service.ts index 9fe9b1ca80f..6fbf2a7eb64 100644 --- a/frontend/src/app/workspace/service/virtual-environment/virtual-environment.service.ts +++ b/frontend/src/app/workspace/service/virtual-environment/virtual-environment.service.ts @@ -41,22 +41,18 @@ export interface UserPveRecord { export class WorkflowPveService { constructor(private http: HttpClient) {} - // Persists a PVE spec (name + packages map) for the current user. savePve(name: string, packages: Record): Observable<{ pveid: number }> { return this.http.post<{ pveid: number }>("/pve/db", { name, packages }); } - // Updates an existing PVE row owned by the current user. updateUserPve(pveid: number, name: string, packages: Record): Observable<{ pveid: number }> { return this.http.put<{ pveid: number }>(`/pve/db/${pveid}`, { name, packages }); } - // Returns every PVE row owned by the current user. listUserPves(): Observable { return this.http.get("/pve/db"); } - // Deletes one of the current user's PVEs by its pveid. deleteUserPve(pveid: number): Observable { return this.http.delete(`/pve/db/${pveid}`); } From 56198755a8aeff037d21a06dfa7ef75661bbf7af Mon Sep 17 00:00:00 2001 From: Sarah Asad Date: Mon, 8 Jun 2026 18:36:55 -0700 Subject: [PATCH 10/11] tests added --- .../PveResourceSpec.scala | 77 ++++++++++++++++++- .../component/dashboard.component.spec.ts | 4 +- 2 files changed, 77 insertions(+), 4 deletions(-) diff --git a/amber/src/test/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveResourceSpec.scala b/amber/src/test/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveResourceSpec.scala index 92e83615c9f..e938cd21554 100644 --- a/amber/src/test/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveResourceSpec.scala +++ b/amber/src/test/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveResourceSpec.scala @@ -19,25 +19,58 @@ package org.apache.texera.web.resource.pythonvirtualenvironment -import org.scalatest.BeforeAndAfterEach +import org.apache.texera.dao.MockTexeraDB +import org.apache.texera.dao.jooq.generated.Tables.PYTHON_VIRTUAL_ENVIRONMENTS +import org.apache.texera.dao.jooq.generated.tables.daos.UserDao +import org.apache.texera.dao.jooq.generated.tables.pojos.User +import org.scalatest.{BeforeAndAfterAll, BeforeAndAfterEach} import org.scalatest.flatspec.AnyFlatSpec import org.scalatest.matchers.should.Matchers import java.nio.file.{Files, Path, Paths} +import java.util.UUID import java.util.concurrent.LinkedBlockingQueue import scala.jdk.CollectionConverters._ -class PveResourceSpec extends AnyFlatSpec with Matchers with BeforeAndAfterEach { +class PveResourceSpec + extends AnyFlatSpec + with Matchers + with BeforeAndAfterAll + with BeforeAndAfterEach + with MockTexeraDB { private val testCuid = 256 + // Randomised to avoid colliding with unrelated specs that may seed uids in the + // same embedded postgres if they run in parallel. + private val testUid = 8000 + scala.util.Random.nextInt(1000) private var testPveName: String = _ private var testRoot: Path = _ private var queue: LinkedBlockingQueue[String] = _ + override protected def beforeAll(): Unit = { + initializeDBAndReplaceDSLContext() + // python_virtual_environments.uid has an FK to user(uid); seed one user + // for the DB-backed tests below to attach their rows to. + val userDao = new UserDao(getDSLContext.configuration()) + val user = new User + user.setUid(testUid) + user.setName("pve_resource_spec_user") + user.setEmail(s"user_${UUID.randomUUID()}@example.com") + user.setPassword("password") + userDao.insert(user) + } + + override protected def afterAll(): Unit = shutdownDB() + override protected def beforeEach(): Unit = { testPveName = s"testenv${System.currentTimeMillis()}" testRoot = Paths.get("/tmp/texera-pve/venvs").resolve(testCuid.toString) queue = new LinkedBlockingQueue[String]() + // Clean any PVE rows left over from a prior test in this class. + getDSLContext + .deleteFrom(PYTHON_VIRTUAL_ENVIRONMENTS) + .where(PYTHON_VIRTUAL_ENVIRONMENTS.UID.eq(testUid)) + .execute() } override protected def afterEach(): Unit = { @@ -173,4 +206,44 @@ class PveResourceSpec extends AnyFlatSpec with Matchers with BeforeAndAfterEach PveManager.getPythonBin(testCuid, "name with spaces") shouldBe None PveManager.getPythonBin(testCuid, "name;rm") shouldBe None } + + "PveManager.savePve + listPvesForUser" should "round-trip a row for the owning user" in { + val pveid = PveManager.savePve(testUid, "env-a", """{"numpy":"==1.26.0"}""") + pveid should be > 0 + + val rows = PveManager.listPvesForUser(testUid) + rows.map(_.name) should contain("env-a") + val row = rows.find(_.pveid == pveid).get + row.name shouldBe "env-a" + // Postgres JSONB normalises whitespace on read-back, so assert key/value separately + // rather than matching a literal JSON string. + row.packagesJson should include(""""numpy"""") + row.packagesJson should include(""""==1.26.0"""") + } + + "PveManager.updatePve" should "mutate an owned row and refuse rows owned by someone else" in { + val pveid = PveManager.savePve(testUid, "env-b", "{}") + + PveManager.updatePve(pveid, testUid, "env-b-renamed", """{"pandas":""}""") shouldBe true + + val updated = PveManager.listPvesForUser(testUid).find(_.pveid == pveid).get + updated.name shouldBe "env-b-renamed" + updated.packagesJson should include(""""pandas"""") + + // A different uid claiming the same pveid must not be able to update it. + val otherUid = testUid + 1 + PveManager.updatePve(pveid, otherUid, "hijacked", "{}") shouldBe false + PveManager.listPvesForUser(testUid).find(_.pveid == pveid).get.name shouldBe "env-b-renamed" + } + + "PveManager.deletePveFromDb" should "remove an owned row and return false for missing pveids" in { + val pveid = PveManager.savePve(testUid, "env-c", "{}") + + PveManager.deletePveFromDb(pveid, testUid) shouldBe true + PveManager.listPvesForUser(testUid).map(_.pveid) should not contain pveid + + // Already-deleted (or never-existed) pveid: deleter reports false, doesn't throw. + PveManager.deletePveFromDb(pveid, testUid) shouldBe false + PveManager.deletePveFromDb(-1, testUid) shouldBe false + } } diff --git a/frontend/src/app/dashboard/component/dashboard.component.spec.ts b/frontend/src/app/dashboard/component/dashboard.component.spec.ts index a53244b3cdd..10352e3b57a 100644 --- a/frontend/src/app/dashboard/component/dashboard.component.spec.ts +++ b/frontend/src/app/dashboard/component/dashboard.component.spec.ts @@ -283,7 +283,7 @@ describe("DashboardComponent", () => { }; fixture.detectChanges(); - // 6 "Your Work" links + 4 admin links + 1 about link = 11 - expect(fixture.debugElement.queryAll(By.directive(RouterLink)).length).toBe(11); + // 7 "Your Work" links (incl. Python Venvs) + 4 admin links + 1 about link = 12 + expect(fixture.debugElement.queryAll(By.directive(RouterLink)).length).toBe(12); }); }); From de5d222e45c020c057c50b68f672f422f9f043d0 Mon Sep 17 00:00:00 2001 From: Sarah Asad Date: Mon, 8 Jun 2026 19:14:21 -0700 Subject: [PATCH 11/11] cleanup --- .../user-python-venv.component.html | 38 ------------------- .../user-python-venv.component.ts | 8 ---- 2 files changed, 46 deletions(-) diff --git a/frontend/src/app/dashboard/component/user/user-python-venv/user-python-venv.component.html b/frontend/src/app/dashboard/component/user/user-python-venv/user-python-venv.component.html index ba505a42624..cb08cd0efcd 100644 --- a/frontend/src/app/dashboard/component/user/user-python-venv/user-python-venv.component.html +++ b/frontend/src/app/dashboard/component/user/user-python-venv/user-python-venv.component.html @@ -108,44 +108,6 @@

    Python Venvs

    [(ngModel)]="pve.name" /> - -
    -
    -
    - -
    -
    - -
    -
    - -
    - -
    -
    - -
    ({ ...p })), newPackages: source.newPackages.map(p => ({ ...p })), - expanded: true, }; this.pveModalVisible = true; }