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

linxinyuan pushed a commit to branch asf-site
in repository https://gitbox.apache.org/repos/asf/texera.git


The following commit(s) were added to refs/heads/asf-site by this push:
     new f4827796d1 Merge main to asf-site (#3791)
f4827796d1 is described below

commit f4827796d109f3702f8777b93ee161edfd914949
Author: Xinyuan Lin <[email protected]>
AuthorDate: Tue Sep 30 14:32:07 2025 -0700

    Merge main to asf-site (#3791)
    
    Signed-off-by: Ma77Ball <[email protected]>
    Signed-off-by: PJ Fanning <[email protected]>
    Co-authored-by: ali risheh <[email protected]>
    Co-authored-by: Meng Wang <[email protected]>
    Co-authored-by: yunyad <[email protected]>
    Co-authored-by: Xuan Gu <[email protected]>
    Co-authored-by: colinthebomb1 <[email protected]>
    Co-authored-by: Ma77Ball <[email protected]>
    Co-authored-by: Copilot <[email protected]>
    Co-authored-by: PJ Fanning <[email protected]>
    Co-authored-by: Seongjin Yoon 
<[email protected]>
    Co-authored-by: Seongjin Yoon <[email protected]>
    Co-authored-by: Seongjin Yoon <[email protected]>
    Co-authored-by: Grace Chia <[email protected]>
---
 .asf.yaml                                          |   3 +
 core/access-control-service/build.sbt              |  81 ++++++++
 .../project/build.properties                       |  18 ++
 .../access-control-service-web-config.yaml         |  33 ++++
 .../src/main/resources/logback.xml                 |  55 ++++++
 .../ics/texera/service/AccessControlService.scala  |  78 ++++++++
 .../AccessControlServiceConfiguration.scala        |  22 +++
 .../service/resource/AccessControlResource.scala   | 132 +++++++++++++
 .../service/resource/HealthCheckResource.scala     |  30 +++
 .../uci/ics/texera/AccessControlResourceSpec.scala | 220 +++++++++++++++++++++
 .../ics/texera/auth/util/ComputingUnitAccess.scala |  55 ++++++
 .../edu/uci/ics/texera/auth/util/HeaderField.scala |  27 +++
 core/build.sbt                                     |  10 +
 core/config/src/main/resources/default.conf        |   5 +-
 .../scala/edu/uci/ics/amber/util/PathUtils.scala   |   2 +
 .../admin/settings/admin-settings.component.html   |  12 ++
 .../admin/settings/admin-settings.component.ts     |  17 +-
 .../dataset-detail.component.html                  | 118 +++++++----
 .../dataset-detail.component.scss                  |  19 +-
 .../dataset-detail.component.ts                    | 216 +++++++++++++-------
 ...user-dataset-staged-objects-list.component.html |   1 -
 .../user/user-quota/user-quota.component.html      |  23 +--
 .../user/user-quota/user-quota.component.scss      |   4 +-
 .../service/user/download/download.service.ts      |   4 +-
 .../left-panel/settings/settings.component.ts      |   6 +
 .../computing-unit-selection.component.ts          |   2 +-
 .../workflow-editor/workflow-editor.component.ts   | 134 +++++++++++--
 .../computing-unit-status.service.ts               |   7 +-
 .../workspace/service/joint-ui/joint-ui.service.ts |  38 ----
 .../SklearnTrainingAdaptiveBoosting.png            | Bin 0 -> 117082 bytes
 .../operator_images/SklearnTrainingBagging.png     | Bin 0 -> 60221 bytes
 .../SklearnTrainingBernoulliNaiveBayes.png         | Bin 0 -> 433434 bytes
 .../SklearnTrainingComplementNaiveBayes.png        | Bin 0 -> 74896 bytes
 .../SklearnTrainingDecisionTree.png                | Bin 0 -> 7095 bytes
 .../operator_images/SklearnTrainingDummy.png       | Bin 0 -> 39008 bytes
 .../operator_images/SklearnTrainingExtraTree.png   | Bin 0 -> 20903 bytes
 .../operator_images/SklearnTrainingExtraTrees.png  | Bin 0 -> 75482 bytes
 .../SklearnTrainingGaussianNaiveBayes.png          | Bin 0 -> 69880 bytes
 .../SklearnTrainingGradientBoosting.png            | Bin 0 -> 100542 bytes
 .../assets/operator_images/SklearnTrainingKNN.png  | Bin 0 -> 96537 bytes
 .../SklearnTrainingLinearRegression.png            | Bin 0 -> 13177 bytes
 .../operator_images/SklearnTrainingLinearSVM.png   | Bin 0 -> 17599 bytes
 .../SklearnTrainingLogisticRegression.png          | Bin 0 -> 18324 bytes
 .../SklearnTrainingLogisticRegressionCV.png        | Bin 0 -> 10842 bytes
 .../SklearnTrainingMultiLayerPerceptron.png        | Bin 0 -> 128735 bytes
 .../SklearnTrainingMultinomialNaiveBayes.png       | Bin 0 -> 34729 bytes
 .../SklearnTrainingNearestCentroid.png             | Bin 0 -> 214245 bytes
 .../SklearnTrainingPassiveAggressive.png           | Bin 0 -> 9322 bytes
 .../operator_images/SklearnTrainingPerceptron.png  | Bin 0 -> 13079 bytes
 .../SklearnTrainingProbabilityCalibration.png      | Bin 0 -> 83338 bytes
 .../SklearnTrainingRandomForest.png                | Bin 0 -> 81937 bytes
 .../operator_images/SklearnTrainingRidge.png       | Bin 0 -> 24635 bytes
 .../operator_images/SklearnTrainingRidgeCV.png     | Bin 0 -> 16258 bytes
 .../assets/operator_images/SklearnTrainingSDG.png  | Bin 0 -> 22220 bytes
 .../assets/operator_images/SklearnTrainingSVM.png  | Bin 0 -> 17776 bytes
 .../edu/uci/ics/amber/operator/LogicalOp.scala     |  67 ++++---
 .../SklearnTrainingLinearRegressionOpDesc.scala    |  25 +++
 57 files changed, 1251 insertions(+), 213 deletions(-)

diff --git a/.asf.yaml b/.asf.yaml
index f8028b19a8..8bd6ce570b 100644
--- a/.asf.yaml
+++ b/.asf.yaml
@@ -78,3 +78,6 @@ notifications:
   pullrequests:         [email protected]
   discussions:          [email protected]
   jobs:                 [email protected]
+
+publish:
+  whoami: asf-site
diff --git a/core/access-control-service/build.sbt 
b/core/access-control-service/build.sbt
new file mode 100644
index 0000000000..052dad4c13
--- /dev/null
+++ b/core/access-control-service/build.sbt
@@ -0,0 +1,81 @@
+// 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 scala.collection.Seq
+
+name := "access-control-service"
+organization := "edu.uci.ics"
+version := "1.0.0"
+
+scalaVersion := "2.13.12"
+
+enablePlugins(JavaAppPackaging)
+
+// Enable semanticdb for Scalafix
+ThisBuild / semanticdbEnabled := true
+ThisBuild / semanticdbVersion := scalafixSemanticdb.revision
+
+// Manage dependency conflicts by always using the latest revision
+ThisBuild / conflictManager := ConflictManager.latestRevision
+
+// Restrict parallel execution of tests to avoid conflicts
+Global / concurrentRestrictions += Tags.limit(Tags.Test, 1)
+
+/////////////////////////////////////////////////////////////////////////////
+// Compiler Options
+/////////////////////////////////////////////////////////////////////////////
+
+// Scala compiler options
+Compile / scalacOptions ++= Seq(
+  "-Xelide-below", "WARNING",       // Turn on optimizations with "WARNING" as 
the threshold
+  "-feature",                       // Check feature warnings
+  "-deprecation",                   // Check deprecation warnings
+  "-Ywarn-unused:imports"           // Check for unused imports
+)
+
+/////////////////////////////////////////////////////////////////////////////
+// Version Variables
+/////////////////////////////////////////////////////////////////////////////
+
+val dropwizardVersion = "4.0.7"
+val mockitoVersion = "5.4.0"
+val assertjVersion = "3.24.2"
+
+/////////////////////////////////////////////////////////////////////////////
+// Test-related Dependencies
+/////////////////////////////////////////////////////////////////////////////
+
+libraryDependencies ++= Seq(
+  "org.scalamock" %% "scalamock" % "5.2.0" % Test,                   // 
ScalaMock
+  "org.scalatest" %% "scalatest" % "3.2.17" % Test,                  // 
ScalaTest
+  "io.dropwizard" % "dropwizard-testing" % dropwizardVersion % Test, // 
Dropwizard Testing
+  "org.mockito" % "mockito-core" % mockitoVersion % Test,            // 
Mockito for mocking
+  "org.assertj" % "assertj-core" % assertjVersion % Test,            // 
AssertJ for assertions
+  "com.novocode" % "junit-interface" % "0.11" % Test                // SBT 
interface for JUnit
+)
+
+/////////////////////////////////////////////////////////////////////////////
+// Dependencies
+/////////////////////////////////////////////////////////////////////////////
+
+// Core Dependencies
+libraryDependencies ++= Seq(
+  "io.dropwizard" % "dropwizard-core" % dropwizardVersion,
+  "io.dropwizard" % "dropwizard-auth" % dropwizardVersion, // Dropwizard 
Authentication module
+  "com.fasterxml.jackson.module" %% "jackson-module-scala" % "2.15.2",
+  "org.playframework" %% "play-json" % "3.1.0-M1",
+)
\ No newline at end of file
diff --git a/core/access-control-service/project/build.properties 
b/core/access-control-service/project/build.properties
new file mode 100644
index 0000000000..5a15dd8541
--- /dev/null
+++ b/core/access-control-service/project/build.properties
@@ -0,0 +1,18 @@
+# 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.
+
+sbt.version = 1.9.9
\ No newline at end of file
diff --git 
a/core/access-control-service/src/main/resources/access-control-service-web-config.yaml
 
b/core/access-control-service/src/main/resources/access-control-service-web-config.yaml
new file mode 100644
index 0000000000..e8d17cec28
--- /dev/null
+++ 
b/core/access-control-service/src/main/resources/access-control-service-web-config.yaml
@@ -0,0 +1,33 @@
+# 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.
+
+server:
+  applicationConnectors:
+    - type: http
+      port: 9096
+  adminConnectors: []
+
+logging:
+  level: INFO
+  appenders:
+    - type: console
+      threshold: INFO
+    - type: file
+      currentLogFilename: logs/access-control-service.log
+      archive: true
+      archivedLogFilenamePattern: logs/access-control-service-%d.log.gz
+      archivedFileCount: 5
\ No newline at end of file
diff --git a/core/access-control-service/src/main/resources/logback.xml 
b/core/access-control-service/src/main/resources/logback.xml
new file mode 100644
index 0000000000..4763107b50
--- /dev/null
+++ b/core/access-control-service/src/main/resources/logback.xml
@@ -0,0 +1,55 @@
+<!--
+ 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.
+-->
+
+<configuration>
+    <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
+        <!-- encoders are assigned the type 
ch.qos.logback.classic.encoder.PatternLayoutEncoder
+            by default -->
+        <encoder>
+            <pattern>[%date{ISO8601}] [%level] [%logger] [%thread] - %msg %n
+            </pattern>
+        </encoder>
+    </appender>
+
+
+    <appender name="FILE" 
class="ch.qos.logback.core.rolling.RollingFileAppender">
+        <file>../log/access-control-service.log</file>
+        <immediateFlush>true</immediateFlush>
+        <rollingPolicy 
class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
+            
<fileNamePattern>../log/access-control-service-%d{yyyy-MM-dd}.log.gz</fileNamePattern>
+        </rollingPolicy>
+        <encoder>
+            <pattern>[%date{ISO8601}] [%level] [%logger] [%thread] - %msg 
%n</pattern>
+        </encoder>
+    </appender>
+
+    <appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender">
+        <queueSize>8192</queueSize>
+        <neverBlock>true</neverBlock>
+        <appender-ref ref="FILE"/>
+    </appender>
+
+    <root level="INFO">
+        <appender-ref ref="ASYNC"/>
+        <appender-ref ref="STDOUT"/>
+    </root>
+    <logger name="org.apache" level="WARN"/>
+    <logger name="httpclient" level="WARN"/>
+    <logger name="io.grpc.netty" level="WARN"/>
+</configuration>
\ No newline at end of file
diff --git 
a/core/access-control-service/src/main/scala/edu/uci/ics/texera/service/AccessControlService.scala
 
b/core/access-control-service/src/main/scala/edu/uci/ics/texera/service/AccessControlService.scala
new file mode 100644
index 0000000000..02278fd97a
--- /dev/null
+++ 
b/core/access-control-service/src/main/scala/edu/uci/ics/texera/service/AccessControlService.scala
@@ -0,0 +1,78 @@
+// 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 edu.uci.ics.texera.service
+
+import io.dropwizard.core.Application
+import io.dropwizard.core.setup.{Bootstrap, Environment}
+import com.fasterxml.jackson.module.scala.DefaultScalaModule
+import com.typesafe.scalalogging.LazyLogging
+import edu.uci.ics.amber.config.StorageConfig
+import edu.uci.ics.amber.util.PathUtils.{configServicePath, 
accessControlServicePath}
+import edu.uci.ics.texera.auth.{JwtAuthFilter, SessionUser}
+import edu.uci.ics.texera.dao.SqlServer
+import edu.uci.ics.texera.service.resource.{HealthCheckResource, 
AccessControlResource}
+import io.dropwizard.auth.AuthDynamicFeature
+import org.eclipse.jetty.server.session.SessionHandler
+import org.jooq.impl.DSL
+
+
+class AccessControlService extends 
Application[AccessControlServiceConfiguration] with LazyLogging {
+  override def initialize(bootstrap: 
Bootstrap[AccessControlServiceConfiguration]): Unit = {
+    // Register Scala module to Dropwizard default object mapper
+    bootstrap.getObjectMapper.registerModule(DefaultScalaModule)
+
+    SqlServer.initConnection(
+      StorageConfig.jdbcUrl,
+      StorageConfig.jdbcUsername,
+      StorageConfig.jdbcPassword
+    )
+  }
+
+  override def run(configuration: AccessControlServiceConfiguration, 
environment: Environment): Unit = {
+    // Serve backend at /api
+    environment.jersey.setUrlPattern("/api/*")
+
+    environment.jersey.register(classOf[SessionHandler])
+    environment.servlets.setSessionHandler(new SessionHandler)
+
+    environment.jersey.register(classOf[HealthCheckResource])
+    environment.jersey.register(classOf[AccessControlResource])
+
+    // Register JWT authentication filter
+    environment.jersey.register(new AuthDynamicFeature(classOf[JwtAuthFilter]))
+
+    // Enable @Auth annotation for injecting SessionUser
+    environment.jersey.register(
+      new 
io.dropwizard.auth.AuthValueFactoryProvider.Binder(classOf[SessionUser])
+    )
+  }
+}
+object AccessControlService {
+  def main(args: Array[String]): Unit = {
+    val accessControlPath = accessControlServicePath
+      .resolve("src")
+      .resolve("main")
+      .resolve("resources")
+      .resolve("access-control-service-web-config.yaml")
+      .toAbsolutePath
+      .toString
+
+    // Start the Dropwizard application
+    new AccessControlService().run("server", accessControlPath)
+  }
+}
diff --git 
a/core/access-control-service/src/main/scala/edu/uci/ics/texera/service/AccessControlServiceConfiguration.scala
 
b/core/access-control-service/src/main/scala/edu/uci/ics/texera/service/AccessControlServiceConfiguration.scala
new file mode 100644
index 0000000000..1f388d8f9a
--- /dev/null
+++ 
b/core/access-control-service/src/main/scala/edu/uci/ics/texera/service/AccessControlServiceConfiguration.scala
@@ -0,0 +1,22 @@
+// 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 edu.uci.ics.texera.service
+
+import io.dropwizard.core.Configuration
+
+class AccessControlServiceConfiguration extends Configuration {}
diff --git 
a/core/access-control-service/src/main/scala/edu/uci/ics/texera/service/resource/AccessControlResource.scala
 
b/core/access-control-service/src/main/scala/edu/uci/ics/texera/service/resource/AccessControlResource.scala
new file mode 100644
index 0000000000..8bca493850
--- /dev/null
+++ 
b/core/access-control-service/src/main/scala/edu/uci/ics/texera/service/resource/AccessControlResource.scala
@@ -0,0 +1,132 @@
+// 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 edu.uci.ics.texera.service.resource
+
+import com.typesafe.scalalogging.LazyLogging
+import edu.uci.ics.texera.auth.JwtParser.parseToken
+import edu.uci.ics.texera.auth.SessionUser
+import edu.uci.ics.texera.auth.util.{ComputingUnitAccess, HeaderField}
+import edu.uci.ics.texera.dao.jooq.generated.enums.PrivilegeEnum
+import jakarta.ws.rs.core._
+import jakarta.ws.rs.{GET, POST, Path, Produces}
+
+import java.util.Optional
+import scala.jdk.CollectionConverters.{CollectionHasAsScala, MapHasAsScala}
+import scala.util.matching.Regex
+
+object AccessControlResource extends LazyLogging {
+
+  // Regex for the paths that require authorization
+  private val wsapiWorkflowWebsocket: Regex = 
""".*/wsapi/workflow-websocket.*""".r
+  private val apiExecutionsStats: Regex = 
""".*/api/executions/[0-9]+/stats/[0-9]+.*""".r
+  private val apiExecutionsResultExport: Regex = 
""".*/api/executions/result/export.*""".r
+
+  /**
+    * Authorize the request based on the path and headers.
+   * @param uriInfo URI sent by Envoy or API Gateway
+   * @param headers HTTP headers sent by Envoy or API Gateway which include
+   *                headers sent by the client (browser)
+   * @return HTTP Response with appropriate status code and headers
+   */
+  def authorize(uriInfo: UriInfo, headers: HttpHeaders): Response = {
+    val path = uriInfo.getPath
+    logger.info(s"Authorizing request for path: $path")
+
+    path match {
+      case wsapiWorkflowWebsocket() | apiExecutionsStats() | 
apiExecutionsResultExport() =>
+        checkComputingUnitAccess(uriInfo, headers)
+      case _ =>
+        logger.warn(s"No authorization logic for path: $path. Denying access.")
+        Response.status(Response.Status.FORBIDDEN).build()
+    }
+  }
+
+  private def checkComputingUnitAccess(uriInfo: UriInfo, headers: 
HttpHeaders): Response = {
+    val queryParams: Map[String, String] = uriInfo
+      .getQueryParameters()
+      .asScala
+      .view
+      .mapValues(values => values.asScala.headOption.getOrElse(""))
+      .toMap
+
+    logger.info(s"Request URI: ${uriInfo.getRequestUri} and headers: 
${headers.getRequestHeaders.asScala} and queryParams: $queryParams")
+
+    val token = queryParams.getOrElse(
+      "access-token",
+      headers
+        .getRequestHeader("Authorization")
+        .asScala
+        .headOption
+        .getOrElse("")
+        .replace("Bearer ", "")
+    )
+    val cuid = queryParams.getOrElse("cuid", "")
+    val cuidInt = try {
+      cuid.toInt
+    } catch {
+      case _: NumberFormatException =>
+        return Response.status(Response.Status.FORBIDDEN).build()
+    }
+
+    var cuAccess: PrivilegeEnum = PrivilegeEnum.NONE
+    var userSession: Optional[SessionUser] = Optional.empty()
+    try {
+      userSession = parseToken(token)
+      if (userSession.isEmpty)
+        return Response.status(Response.Status.FORBIDDEN).build()
+
+      val uid = userSession.get().getUid
+      cuAccess = ComputingUnitAccess.getComputingUnitAccess(cuidInt, uid)
+      if (cuAccess == PrivilegeEnum.NONE)
+        return Response.status(Response.Status.FORBIDDEN).build()
+    } catch {
+      case e: Exception =>
+        return Response.status(Response.Status.FORBIDDEN).build()
+    }
+
+    Response
+      .ok()
+      .header(HeaderField.UserComputingUnitAccess, cuAccess.toString)
+      .header(HeaderField.UserId, userSession.get().getUid.toString)
+      .header(HeaderField.UserName, userSession.get().getName)
+      .header(HeaderField.UserEmail, userSession.get().getEmail)
+      .build()
+  }
+}
+@Produces(Array(MediaType.APPLICATION_JSON))
+@Path("/auth")
+class AccessControlResource extends LazyLogging {
+
+  @GET
+  @Path("/{path:.*}")
+  def authorizeGet(
+                    @Context uriInfo: UriInfo,
+                    @Context headers: HttpHeaders
+                  ): Response = {
+    AccessControlResource.authorize(uriInfo, headers)
+  }
+
+  @POST
+  @Path("/{path:.*}")
+  def authorizePost(
+                     @Context uriInfo: UriInfo,
+                     @Context headers: HttpHeaders
+                   ): Response = {
+    AccessControlResource.authorize(uriInfo, headers)
+  }
+}
diff --git 
a/core/access-control-service/src/main/scala/edu/uci/ics/texera/service/resource/HealthCheckResource.scala
 
b/core/access-control-service/src/main/scala/edu/uci/ics/texera/service/resource/HealthCheckResource.scala
new file mode 100644
index 0000000000..895f6a400a
--- /dev/null
+++ 
b/core/access-control-service/src/main/scala/edu/uci/ics/texera/service/resource/HealthCheckResource.scala
@@ -0,0 +1,30 @@
+/*
+ * 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 edu.uci.ics.texera.service.resource
+
+import jakarta.ws.rs.core.MediaType
+import jakarta.ws.rs.{GET, Path, Produces}
+
+@Path("/healthcheck")
+@Produces(Array(MediaType.APPLICATION_JSON))
+class HealthCheckResource {
+  @GET
+  def healthCheck: Map[String, String] = Map("status" -> "ok")
+}
diff --git 
a/core/access-control-service/src/test/scala/edu/uci/ics/texera/AccessControlResourceSpec.scala
 
b/core/access-control-service/src/test/scala/edu/uci/ics/texera/AccessControlResourceSpec.scala
new file mode 100644
index 0000000000..349ec334aa
--- /dev/null
+++ 
b/core/access-control-service/src/test/scala/edu/uci/ics/texera/AccessControlResourceSpec.scala
@@ -0,0 +1,220 @@
+// 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 edu.uci.ics.texera
+
+import edu.uci.ics.texera.auth.JwtAuth
+import edu.uci.ics.texera.auth.util.HeaderField
+import edu.uci.ics.texera.dao.MockTexeraDB
+import edu.uci.ics.texera.dao.jooq.generated.enums.{PrivilegeEnum, 
UserRoleEnum, WorkflowComputingUnitTypeEnum}
+import 
edu.uci.ics.texera.dao.jooq.generated.tables.daos.{ComputingUnitUserAccessDao, 
UserDao, WorkflowComputingUnitDao}
+import 
edu.uci.ics.texera.dao.jooq.generated.tables.pojos.{ComputingUnitUserAccess, 
User, WorkflowComputingUnit}
+import edu.uci.ics.texera.service.resource.AccessControlResource
+import jakarta.ws.rs.core.{HttpHeaders, MultivaluedHashMap, Response, UriInfo}
+import org.mockito.Mockito._
+import org.scalatest.flatspec.AnyFlatSpec
+import org.scalatest.matchers.should.Matchers
+import org.scalatest.{BeforeAndAfterAll, BeforeAndAfterEach}
+
+import java.net.URI
+import java.util
+
+class AccessControlResourceSpec extends AnyFlatSpec
+  with Matchers
+  with BeforeAndAfterAll
+  with BeforeAndAfterEach
+  with MockTexeraDB {
+
+  private val testURI: String = "http://localhost:8080/";
+  private val testPath: String = "/api/executions/1/stats/1"
+
+  private val testUser1: User = {
+    val user = new User()
+    user.setUid(1)
+    user.setName("testuser")
+    user.setEmail("[email protected]")
+    user.setRole(UserRoleEnum.REGULAR)
+    user.setPassword("password")
+    user
+  }
+
+  private val testUser2: User = {
+    val user = new User()
+    user.setUid(2)
+    user.setName("testuser2")
+    user.setEmail("[email protected]")
+    user.setRole(UserRoleEnum.REGULAR)
+    user.setPassword("password")
+    user
+  }
+
+  private val testCU: WorkflowComputingUnit = {
+    val cu = new WorkflowComputingUnit()
+    cu.setUid(2)
+    cu.setType(WorkflowComputingUnitTypeEnum.kubernetes)
+    cu.setCuid(2)
+    cu.setName("test-cu")
+    cu
+  }
+
+  private var token: String = _
+
+  override protected def beforeAll(): Unit = {
+    initializeDBAndReplaceDSLContext()
+    val userDao = new UserDao(getDSLContext.configuration())
+    val computingUnitDao = new 
WorkflowComputingUnitDao(getDSLContext.configuration())
+    val computingUnitOfUserDao = new 
ComputingUnitUserAccessDao(getDSLContext.configuration())
+
+    // insert user, computing unit, and access privilege into the mock database
+    userDao.insert(testUser1)
+    userDao.insert(testUser2)
+    computingUnitDao.insert(testCU)
+
+    val cuAccess = new ComputingUnitUserAccess()
+    cuAccess.setUid(testUser1.getUid)
+    cuAccess.setCuid(testCU.getCuid)
+    cuAccess.setPrivilege(PrivilegeEnum.WRITE)
+    computingUnitOfUserDao.insert(cuAccess)
+
+    val claims = JwtAuth.jwtClaims(testUser1, 1)
+    token = JwtAuth.jwtToken(claims)
+  }
+
+  override protected def afterAll(): Unit = {
+    shutdownDB()
+  }
+
+  "AccessControlResource" should "return FORBIDDEN for a GET request without a 
token" in {
+    val mockUriInfo = mock(classOf[UriInfo])
+    val mockHttpHeaders = mock(classOf[HttpHeaders])
+    val queryParams = new MultivaluedHashMap[String, String]()
+    queryParams.add("cuid", "1")
+    val requestHeaders = new MultivaluedHashMap[String, String]()
+
+    when(mockUriInfo.getQueryParameters).thenReturn(queryParams)
+    when(mockUriInfo.getRequestUri).thenReturn(new URI(testURI))
+    when(mockUriInfo.getPath).thenReturn(testPath)
+    when(mockHttpHeaders.getRequestHeaders).thenReturn(requestHeaders)
+    when(mockHttpHeaders.getRequestHeader("Authorization")).thenReturn(new 
util.ArrayList[String]())
+
+    val accessControlResource = new AccessControlResource()
+    val response = accessControlResource.authorizeGet(mockUriInfo, 
mockHttpHeaders)
+
+    response.getStatus shouldBe Response.Status.FORBIDDEN.getStatusCode
+  }
+
+  it should "return FORBIDDEN for a GET request with a non-integer cuid" in {
+    val mockUriInfo = mock(classOf[UriInfo])
+    val mockHttpHeaders = mock(classOf[HttpHeaders])
+    val queryParams = new MultivaluedHashMap[String, String]()
+    queryParams.add("cuid", "abc")
+    val requestHeaders = new MultivaluedHashMap[String, String]()
+    requestHeaders.add("Authorization", "Bearer dummy-token")
+
+    when(mockUriInfo.getQueryParameters).thenReturn(queryParams)
+    when(mockUriInfo.getRequestUri).thenReturn(new URI(testURI))
+    when(mockUriInfo.getPath).thenReturn(testPath)
+    when(mockHttpHeaders.getRequestHeaders).thenReturn(requestHeaders)
+    
when(mockHttpHeaders.getRequestHeader("Authorization")).thenReturn(util.Arrays.asList("Bearer
 dummy-token"))
+
+    val accessControlResource = new AccessControlResource()
+    val response = accessControlResource.authorizeGet(mockUriInfo, 
mockHttpHeaders)
+
+    response.getStatus shouldBe Response.Status.FORBIDDEN.getStatusCode
+  }
+
+  it should "return FORBIDDEN for a POST request without a token" in {
+    val mockUriInfo = mock(classOf[UriInfo])
+    val mockHttpHeaders = mock(classOf[HttpHeaders])
+    val queryParams = new MultivaluedHashMap[String, String]()
+    queryParams.add("cuid", "1")
+    val requestHeaders = new MultivaluedHashMap[String, String]()
+
+    when(mockUriInfo.getQueryParameters).thenReturn(queryParams)
+    when(mockUriInfo.getRequestUri).thenReturn(new URI(testURI))
+    when(mockUriInfo.getPath).thenReturn(testPath)
+    when(mockHttpHeaders.getRequestHeaders).thenReturn(requestHeaders)
+    when(mockHttpHeaders.getRequestHeader("Authorization")).thenReturn(new 
util.ArrayList[String]())
+
+    val accessControlResource = new AccessControlResource()
+    val response = accessControlResource.authorizePost(mockUriInfo, 
mockHttpHeaders)
+
+    response.getStatus shouldBe Response.Status.FORBIDDEN.getStatusCode
+  }
+
+  "AccessControlResource" should "return FORBIDDEN when user does not have 
access to the computing unit" in {
+    // Mock the request context
+    val mockUriInfo = mock(classOf[UriInfo])
+    val mockHttpHeaders = mock(classOf[HttpHeaders])
+
+    // Prepare query parameters with a computing unit ID (cuid)
+    val queryParams = new MultivaluedHashMap[String, String]()
+    queryParams.add("cuid", "1") // Assuming user 1 does not have access to 
cuid 1
+
+    // Prepare request headers with the generated JWT
+    val requestHeaders = new MultivaluedHashMap[String, String]()
+    requestHeaders.add("Authorization", "Bearer " + token)
+
+    // Stub the mock objects to return the prepared data
+    when(mockUriInfo.getQueryParameters).thenReturn(queryParams)
+    when(mockUriInfo.getRequestUri).thenReturn(new URI(testURI))
+    when(mockUriInfo.getPath).thenReturn(testPath)
+    when(mockHttpHeaders.getRequestHeaders).thenReturn(requestHeaders)
+    
when(mockHttpHeaders.getRequestHeader("Authorization")).thenReturn(util.Arrays.asList("Bearer
 " + token))
+
+    // Instantiate the resource and call the method under test
+    val accessControlResource = new AccessControlResource()
+    val response = accessControlResource.authorizeGet(mockUriInfo, 
mockHttpHeaders)
+
+    // Assert that the response status is FORBIDDEN
+    response.getStatus shouldBe Response.Status.FORBIDDEN.getStatusCode
+  }
+
+  it should "return OK and correct headers when user has access" in {
+    // Mock the request context
+    val mockUriInfo = mock(classOf[UriInfo])
+    val mockHttpHeaders = mock(classOf[HttpHeaders])
+
+    // Prepare query parameters with a computing unit ID the user HAS access to
+    val queryParams = new MultivaluedHashMap[String, String]()
+    queryParams.add("cuid", testCU.getCuid.toString)
+
+    // Prepare request headers with the generated JWT
+    val requestHeaders = new MultivaluedHashMap[String, String]()
+    requestHeaders.add("Authorization", "Bearer " + token)
+
+    // Stub the mock objects to return the prepared data
+    when(mockUriInfo.getQueryParameters).thenReturn(queryParams)
+    when(mockUriInfo.getRequestUri).thenReturn(new URI(testURI))
+    when(mockUriInfo.getPath).thenReturn(testPath)
+    when(mockHttpHeaders.getRequestHeaders).thenReturn(requestHeaders)
+    
when(mockHttpHeaders.getRequestHeader("Authorization")).thenReturn(util.Arrays.asList("Bearer
 " + token))
+
+    // Instantiate the resource and call the method under test
+    val accessControlResource = new AccessControlResource()
+    val response = accessControlResource.authorizeGet(mockUriInfo, 
mockHttpHeaders)
+
+    // Assert that the response status is OK and headers are correct
+    response.getStatus shouldBe Response.Status.OK.getStatusCode
+    response.getHeaderString(
+      HeaderField.UserComputingUnitAccess
+    ) shouldBe PrivilegeEnum.WRITE.toString
+    response.getHeaderString(HeaderField.UserId) shouldBe 
testUser1.getUid.toString
+    response.getHeaderString(HeaderField.UserName) shouldBe testUser1.getName
+    response.getHeaderString(HeaderField.UserEmail) shouldBe testUser1.getEmail
+  }
+}
\ No newline at end of file
diff --git 
a/core/auth/src/main/scala/edu/uci/ics/texera/auth/util/ComputingUnitAccess.scala
 
b/core/auth/src/main/scala/edu/uci/ics/texera/auth/util/ComputingUnitAccess.scala
new file mode 100644
index 0000000000..c529edf8ec
--- /dev/null
+++ 
b/core/auth/src/main/scala/edu/uci/ics/texera/auth/util/ComputingUnitAccess.scala
@@ -0,0 +1,55 @@
+// 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 edu.uci.ics.texera.auth.util
+
+import edu.uci.ics.texera.dao.SqlServer
+import edu.uci.ics.texera.dao.jooq.generated.enums.PrivilegeEnum
+import edu.uci.ics.texera.dao.jooq.generated.tables.daos.{
+  ComputingUnitUserAccessDao,
+  WorkflowComputingUnitDao
+}
+import ComputingUnitAccess._
+import org.jooq.DSLContext
+
+import scala.jdk.CollectionConverters._
+
+object ComputingUnitAccess {
+  private lazy val context: DSLContext = SqlServer
+    .getInstance()
+    .createDSLContext()
+
+  def getComputingUnitAccess(cuid: Integer, uid: Integer): PrivilegeEnum = {
+    val workflowComputingUnitDao = new 
WorkflowComputingUnitDao(context.configuration())
+    val unit = workflowComputingUnitDao.fetchOneByCuid(cuid)
+
+    if (unit.getUid.equals(uid)) {
+      return PrivilegeEnum.WRITE // owner has write access
+    }
+
+    val computingUnitUserAccessDao = new 
ComputingUnitUserAccessDao(context.configuration())
+    val accessOpt = computingUnitUserAccessDao
+      .fetchByUid(uid)
+      .asScala
+      .find(_.getCuid.equals(cuid))
+
+    accessOpt match {
+      case Some(access) => access.getPrivilege
+      case None         => PrivilegeEnum.NONE
+    }
+  }
+}
diff --git 
a/core/auth/src/main/scala/edu/uci/ics/texera/auth/util/HeaderField.scala 
b/core/auth/src/main/scala/edu/uci/ics/texera/auth/util/HeaderField.scala
new file mode 100644
index 0000000000..2b98989737
--- /dev/null
+++ b/core/auth/src/main/scala/edu/uci/ics/texera/auth/util/HeaderField.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 edu.uci.ics.texera.auth.util
+
+object HeaderField {
+  val UserComputingUnitAccess = "x-user-computing-unit-access"
+  val UserId = "x-user-id"
+  val UserName = "x-user-name"
+  val UserEmail = "x-user-email"
+}
diff --git a/core/build.sbt b/core/build.sbt
index 658f755f33..dc4fa0e823 100644
--- a/core/build.sbt
+++ b/core/build.sbt
@@ -27,6 +27,16 @@ lazy val ConfigService = (project in file("config-service"))
       "com.fasterxml.jackson.module" %% "jackson-module-scala" % "2.17.0"
     )
   )
+lazy val AccessControlService = (project in file("access-control-service"))
+  .dependsOn(Auth, Config, DAO)
+  .settings(
+    dependencyOverrides ++= Seq(
+      // override it as io.dropwizard 4 require 2.16.1 or higher
+      "com.fasterxml.jackson.module" %% "jackson-module-scala" % "2.17.0"
+    )
+  )
+  .configs(Test)
+  .dependsOn(DAO % "test->test", Auth % "test->test")
 lazy val WorkflowCore = (project in file("workflow-core"))
   .dependsOn(DAO, Config)
   .configs(Test)
diff --git a/core/config/src/main/resources/default.conf 
b/core/config/src/main/resources/default.conf
index 548a4882d3..5df68ecbfc 100644
--- a/core/config/src/main/resources/default.conf
+++ b/core/config/src/main/resources/default.conf
@@ -52,11 +52,10 @@ gui {
 }
 
 dataset {
-  # the file size limit for dataset upload
   single_file_upload_max_size_mib = 20
+  max_number_of_concurrent_uploading_file = 3
 
-  # the maximum number of file chunks that can be held in the memory;
-  # you may increase this number if your deployment environment has enough 
memory resource
+  # The maximum number of file chunks that can be held in the memory
   max_number_of_concurrent_uploading_file_chunks = 10
 
   # the size of each chunk during the multipart upload of file
diff --git a/core/config/src/main/scala/edu/uci/ics/amber/util/PathUtils.scala 
b/core/config/src/main/scala/edu/uci/ics/amber/util/PathUtils.scala
index a4d82a9030..827bd5e289 100644
--- a/core/config/src/main/scala/edu/uci/ics/amber/util/PathUtils.scala
+++ b/core/config/src/main/scala/edu/uci/ics/amber/util/PathUtils.scala
@@ -63,6 +63,8 @@ object PathUtils {
 
   lazy val configServicePath: Path = corePath.resolve("config-service")
 
+  lazy val accessControlServicePath: Path = 
corePath.resolve("access-control-service")
+
   private lazy val datasetsRootPath =
     corePath.resolve("amber").resolve("user-resources").resolve("datasets")
 
diff --git 
a/core/gui/src/app/dashboard/component/admin/settings/admin-settings.component.html
 
b/core/gui/src/app/dashboard/component/admin/settings/admin-settings.component.html
index 8438ab7213..21f42c8a09 100644
--- 
a/core/gui/src/app/dashboard/component/admin/settings/admin-settings.component.html
+++ 
b/core/gui/src/app/dashboard/component/admin/settings/admin-settings.component.html
@@ -272,6 +272,18 @@
     </div>
   </ng-template>
 
+  <div class="settings-row">
+    <span>Concurrent Files:</span>
+    <nz-input-number
+      [(ngModel)]="maxConcurrentFiles"
+      [nzMin]="1"
+      [nzMax]="1000"
+      [nzStep]="1"
+      [nzPrecision]="0">
+    </nz-input-number>
+  </div>
+  <div class="help-text-number">Number of files that can be uploaded 
simultaneously. (Range: 1 - 1000)</div>
+
   <div class="settings-row">
     <span>File Size:</span>
     <nz-input-number
diff --git 
a/core/gui/src/app/dashboard/component/admin/settings/admin-settings.component.ts
 
b/core/gui/src/app/dashboard/component/admin/settings/admin-settings.component.ts
index f7d5186ec3..52a8c5d2e0 100644
--- 
a/core/gui/src/app/dashboard/component/admin/settings/admin-settings.component.ts
+++ 
b/core/gui/src/app/dashboard/component/admin/settings/admin-settings.component.ts
@@ -48,6 +48,7 @@ export class AdminSettingsComponent implements OnInit {
     about_enabled: false,
   };
 
+  maxConcurrentFiles: number = 3;
   maxFileSizeMiB: number = 20;
   maxConcurrentChunks: number = 10;
   chunkSizeMiB: number = 50;
@@ -97,6 +98,10 @@ export class AdminSettingsComponent implements OnInit {
   }
 
   private loadDatasetSettings(): void {
+    this.adminSettingsService
+      .getSetting("max_number_of_concurrent_uploading_file")
+      .pipe(untilDestroyed(this))
+      .subscribe(value => (this.maxConcurrentFiles = parseInt(value)));
     this.adminSettingsService
       .getSetting("single_file_upload_max_size_mib")
       .pipe(untilDestroyed(this))
@@ -203,7 +208,12 @@ export class AdminSettingsComponent implements OnInit {
   }
 
   saveDatasetSettings(): void {
-    if (this.maxFileSizeMiB < 1 || this.maxConcurrentChunks < 1 || 
this.chunkSizeMiB < 1) {
+    if (
+      this.maxFileSizeMiB < 1 ||
+      this.maxConcurrentFiles < 1 ||
+      this.maxConcurrentChunks < 1 ||
+      this.chunkSizeMiB < 1
+    ) {
       this.message.error("Please enter valid integer values.");
       return;
     }
@@ -217,6 +227,10 @@ export class AdminSettingsComponent implements OnInit {
     }
 
     const saveRequests = [
+      this.adminSettingsService.updateSetting(
+        "max_number_of_concurrent_uploading_file",
+        this.maxConcurrentFiles.toString()
+      ),
       
this.adminSettingsService.updateSetting("single_file_upload_max_size_mib", 
this.maxFileSizeMiB.toString()),
       this.adminSettingsService.updateSetting(
         "max_number_of_concurrent_uploading_file_chunks",
@@ -235,6 +249,7 @@ export class AdminSettingsComponent implements OnInit {
 
   resetDatasetSettings(): void {
     [
+      "max_number_of_concurrent_uploading_file",
       "single_file_upload_max_size_mib",
       "max_number_of_concurrent_uploading_file_chunks",
       "multipart_upload_chunk_size_mib",
diff --git 
a/core/gui/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.html
 
b/core/gui/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.html
index 622124625c..d2b01ac115 100644
--- 
a/core/gui/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.html
+++ 
b/core/gui/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.html
@@ -276,54 +276,90 @@
           nzActive="true"
           nzHeader="Create New Version">
           <texera-user-files-uploader 
(uploadedFiles)="onNewUploadFilesChanged($event)"> </texera-user-files-uploader>
-          <div class="upload-progress-wrapper">
-            <div
-              *ngFor="let task of uploadTasks; trackBy: trackByTask"
-              class="upload-progress-container">
-              <div class="progress-header">
-                <span><b>{{ task.status }}</b>: {{ task.filePath }}</span>
-                <button
-                  nz-button
-                  nzType="text"
-                  nzShape="circle"
-                  [nz-tooltip]="
+
+          <nz-collapse
+            nzGhost
+            class="upload-status-panels">
+            <nz-collapse-panel
+              [nzHeader]="queuedCount > 0
+                  ? ('Pending: ' + queuedCount + ' file(s)')
+                  : 'Pending'">
+              <div
+                *ngIf="queuedCount > 0"
+                class="upload-progress-wrapper-pending">
+                <div
+                  *ngFor="let fileName of queuedFileNames"
+                  class="upload-progress-container">
+                  <span>{{ fileName }}</span>
+                </div>
+              </div>
+            </nz-collapse-panel>
+            <nz-divider class="section-divider"></nz-divider>
+
+            <nz-collapse-panel
+              [nzHeader]="activeCount > 0
+                  ? ('Uploading: ' + activeCount + ' file(s)')
+                  : 'Uploading'">
+              <div
+                *ngIf="activeCount > 0"
+                class="upload-progress-wrapper">
+                <div
+                  *ngFor="let task of uploadTasks; trackBy: trackByTask"
+                  class="upload-progress-container">
+                  <div class="progress-header">
+                    <span><b>{{ task.status }}</b>: {{ task.filePath }}</span>
+                    <button
+                      nz-button
+                      nzType="text"
+                      nzShape="circle"
+                      [nz-tooltip]="
                 (task.status === 'aborted' || task.status === 'finished')
                 ? 'Close'
                 : 'Cancel the upload'
                 "
-                  (click)="onClickAbortUploadProgress(task)">
-                  <i
-                    nz-icon
-                    nzType="close"
-                    nzTheme="outline"></i>
-                </button>
-              </div>
-              <div
-                class="upload-stats"
-                *ngIf="task.status !== 'initializing'">
-                <nz-progress
-                  [nzPercent]="task.percentage"
-                  [nzStatus]="getUploadStatus(task.status)"></nz-progress>
-                <nz-tag
-                  *ngIf="task.status === 'uploading'"
-                  [nzColor]="'blue'">
-                  <span class="fixed-width-speed">{{ 
formatSpeed(task.uploadSpeed) }}</span> -
-                  <span class="fixed-width-time">{{ formatTime(task.totalTime 
?? 0) }}</span> elapsed,
-                  <span class="fixed-width-time">{{ 
formatTime(task.estimatedTimeRemaining ?? 0) }} left</span>
-                </nz-tag>
+                      (click)="onClickAbortUploadProgress(task)">
+                      <i
+                        nz-icon
+                        nzType="close"
+                        nzTheme="outline"></i>
+                    </button>
+                  </div>
+
+                  <div
+                    class="upload-stats"
+                    *ngIf="task.status !== 'initializing'">
+                    <nz-progress
+                      [nzPercent]="task.percentage"
+                      [nzStatus]="getUploadStatus(task.status)"></nz-progress>
+                    <nz-tag
+                      *ngIf="task.status === 'uploading'"
+                      [nzColor]="'blue'">
+                      <span class="fixed-width-speed">{{ 
formatSpeed(task.uploadSpeed) }}</span> -
+                      <span class="fixed-width-time">{{ 
formatTime(task.totalTime ?? 0) }}</span> elapsed,
+                      <span class="fixed-width-time">{{ 
formatTime(task.estimatedTimeRemaining ?? 0) }} left</span>
+                    </nz-tag>
 
-                <nz-tag *ngIf="(task.status === 'finished' || task.status === 
'aborted')">
-                  Upload time: {{ formatTime(task.totalTime ?? 0) }}
-                </nz-tag>
+                    <nz-tag *ngIf="(task.status === 'finished' || task.status 
=== 'aborted')">
+                      Upload time: {{ formatTime(task.totalTime ?? 0) }}
+                    </nz-tag>
+                  </div>
+                </div>
               </div>
-            </div>
-          </div>
+            </nz-collapse-panel>
+            <nz-divider class="section-divider"></nz-divider>
 
-          <texera-dataset-staged-objects-list
-            [uploadTimeMap]="uploadTimeMap"
-            [did]="did"
-            [userMakeChangesEvent]="userMakeChanges"
-            
(stagedObjectsChanged)="onStagedObjectsUpdated($event)"></texera-dataset-staged-objects-list>
+            <nz-collapse-panel
+              [nzHeader]="pendingChangesCount > 0
+              ? ('Finished: ' + pendingChangesCount + ' file(s)')
+              : 'Finished'"
+              [nzActive]="false">
+              <texera-dataset-staged-objects-list
+                [uploadTimeMap]="uploadTimeMap"
+                [did]="did"
+                [userMakeChangesEvent]="userMakeChanges"
+                
(stagedObjectsChanged)="onStagedObjectsUpdated($event)"></texera-dataset-staged-objects-list>
+            </nz-collapse-panel>
+          </nz-collapse>
           <div
             *ngIf="userHasWriteAccess() && userHasPendingChanges"
             class="version-creator">
diff --git 
a/core/gui/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.scss
 
b/core/gui/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.scss
index 6e40560aa0..0790f28358 100644
--- 
a/core/gui/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.scss
+++ 
b/core/gui/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.scss
@@ -170,7 +170,9 @@ nz-select {
   margin-top: 15%;
 }
 
-.upload-progress-wrapper {
+.upload-progress-wrapper,
+.upload-progress-wrapper-pending {
+  margin-top: 5px;
   max-height: 25vh;
   overflow-y: auto;
   padding-right: 4px;
@@ -180,6 +182,13 @@ nz-select {
   margin-left: 20px;
 }
 
+.upload-progress-wrapper-pending {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+  max-height: 15vh;
+}
+
 .version-creator {
   margin-top: 20px;
   padding: 40px;
@@ -233,6 +242,10 @@ nz-select {
 .upload-stats {
   font-size: 13px;
   margin-bottom: 20px;
+  nz-progress {
+    width: 97%;
+    display: inline-block;
+  }
 }
 
 :host ::ng-deep .upload-stats .ant-tag {
@@ -250,3 +263,7 @@ nz-select {
   min-width: 2ch;
   text-align: right;
 }
+
+.section-divider {
+  margin: 8px 0;
+}
diff --git 
a/core/gui/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.ts
 
b/core/gui/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.ts
index 7b7f7947f3..a7ae1e17d8 100644
--- 
a/core/gui/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.ts
+++ 
b/core/gui/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.ts
@@ -85,12 +85,19 @@ export class DatasetDetailComponent implements OnInit {
   public displayPreciseViewCount = false;
 
   userHasPendingChanges: boolean = false;
+  pendingChangesCount: number = 0;
+
   // Uploading setting
   chunkSizeMiB: number = 50;
   maxConcurrentChunks: number = 10;
   private uploadSubscriptions = new Map<string, Subscription>();
   uploadTimeMap = new Map<string, number>();
 
+  // Cap number of concurrent files uploads
+  maxConcurrentFiles: number = 3;
+  private activeUploads: number = 0;
+  private pendingQueue: Array<{ fileName: string; startUpload: () => void }> = 
[];
+
   versionName: string = "";
   isCreatingVersion: boolean = false;
 
@@ -100,7 +107,6 @@ export class DatasetDetailComponent implements OnInit {
       filePath: string;
     }
   > = [];
-  private autoHideTimers: number[] = [];
 
   @Output() userMakeChanges = new EventEmitter<void>();
 
@@ -329,6 +335,7 @@ export class DatasetDetailComponent implements OnInit {
 
   onStagedObjectsUpdated(stagedObjects: DatasetStagedObject[]) {
     this.userHasPendingChanges = stagedObjects.length > 0;
+    this.pendingChangesCount = stagedObjects.length;
   }
 
   onVersionSelected(version: DatasetVersion): void {
@@ -385,95 +392,157 @@ export class DatasetDetailComponent implements OnInit {
       .getSetting("max_number_of_concurrent_uploading_file_chunks")
       .pipe(untilDestroyed(this))
       .subscribe(value => (this.maxConcurrentChunks = parseInt(value)));
+    this.adminSettingsService
+      .getSetting("max_number_of_concurrent_uploading_file")
+      .pipe(untilDestroyed(this))
+      .subscribe(value => {
+        this.maxConcurrentFiles = parseInt(value);
+      });
   }
 
   onNewUploadFilesChanged(files: FileUploadItem[]) {
     if (this.did) {
-      files.forEach((file, idx) => {
-        // Cancel any existing upload for the same file to prevent progress 
confusion
-        this.uploadSubscriptions.get(file.name)?.unsubscribe();
-        this.uploadSubscriptions.delete(file.name);
-        this.uploadTasks = this.uploadTasks.filter(t => t.filePath !== 
file.name);
-
-        // Add an initializing task placeholder to uploadTasks
-        this.uploadTasks.push({
-          filePath: file.name,
-          percentage: 0,
-          status: "initializing",
-          uploadId: "",
-          physicalAddress: "",
-        });
-        // Start multipart upload
-        const subscription = this.datasetService
-          .multipartUpload(
-            this.datasetName,
-            file.name,
-            file.file,
-            this.chunkSizeMiB * 1024 * 1024,
-            this.maxConcurrentChunks
-          )
-          .pipe(untilDestroyed(this))
-          .subscribe({
-            next: progress => {
-              // Find the task
-              const taskIndex = this.uploadTasks.findIndex(t => t.filePath === 
file.name);
-
-              if (taskIndex !== -1) {
-                // Update the task with new progress info
-                this.uploadTasks[taskIndex] = {
-                  ...this.uploadTasks[taskIndex],
-                  ...progress,
-                  percentage: progress.percentage ?? 
this.uploadTasks[taskIndex].percentage ?? 0,
-                };
-
-                // Auto‑hide when upload is truly finished
-                if (progress.status === "finished" && progress.totalTime) {
-                  const filename = file.name.split("/").pop() || file.name;
-                  this.uploadTimeMap.set(filename, progress.totalTime);
+      files.forEach(file => {
+        // Check if currently uploading
+        this.cancelExistingUpload(file.name);
+
+        // Create upload function
+        const startUpload = () => {
+          this.pendingQueue = this.pendingQueue.filter(item => item.fileName 
!== file.name);
+
+          // Add an initializing task placeholder to uploadTasks
+          this.uploadTasks.unshift({
+            filePath: file.name,
+            percentage: 0,
+            status: "initializing",
+            uploadId: "",
+            physicalAddress: "",
+          });
+          // Start multipart upload
+          const subscription = this.datasetService
+            .multipartUpload(
+              this.datasetName,
+              file.name,
+              file.file,
+              this.chunkSizeMiB * 1024 * 1024,
+              this.maxConcurrentChunks
+            )
+            .pipe(untilDestroyed(this))
+            .subscribe({
+              next: progress => {
+                // Find the task
+                const taskIndex = this.uploadTasks.findIndex(t => t.filePath 
=== file.name);
+
+                if (taskIndex !== -1) {
+                  // Update the task with new progress info
+                  this.uploadTasks[taskIndex] = {
+                    ...this.uploadTasks[taskIndex],
+                    ...progress,
+                    percentage: progress.percentage ?? 
this.uploadTasks[taskIndex].percentage ?? 0,
+                  };
+
+                  // Auto-hide when upload is truly finished
+                  if (progress.status === "finished" && progress.totalTime) {
+                    const filename = file.name.split("/").pop() || file.name;
+                    this.uploadTimeMap.set(filename, progress.totalTime);
+                    this.userMakeChanges.emit();
+                    this.scheduleHide(taskIndex);
+                    this.onUploadComplete();
+                  }
+                }
+              },
+              error: () => {
+                // Handle upload error
+                const taskIndex = this.uploadTasks.findIndex(t => t.filePath 
=== file.name);
+
+                if (taskIndex !== -1) {
+                  this.uploadTasks[taskIndex] = {
+                    ...this.uploadTasks[taskIndex],
+                    percentage: 100,
+                    status: "aborted",
+                  };
+                  this.scheduleHide(taskIndex);
+                }
+                this.onUploadComplete();
+              },
+              complete: () => {
+                const taskIndex = this.uploadTasks.findIndex(t => t.filePath 
=== file.name);
+                if (taskIndex !== -1 && this.uploadTasks[taskIndex].status !== 
"finished") {
+                  this.uploadTasks[taskIndex].status = "finished";
                   this.userMakeChanges.emit();
                   this.scheduleHide(taskIndex);
+                  this.onUploadComplete();
                 }
-              }
-            },
-            error: () => {
-              // Handle upload error
-              const taskIndex = this.uploadTasks.findIndex(t => t.filePath === 
file.name);
-
-              if (taskIndex !== -1) {
-                this.uploadTasks[taskIndex] = {
-                  ...this.uploadTasks[taskIndex],
-                  percentage: 100,
-                  status: "aborted",
-                };
-                this.scheduleHide(taskIndex);
-              }
-            },
-            complete: () => {
-              const taskIndex = this.uploadTasks.findIndex(t => t.filePath === 
file.name);
-              if (taskIndex !== -1 && this.uploadTasks[taskIndex].status !== 
"finished") {
-                this.uploadTasks[taskIndex].status = "finished";
-                this.userMakeChanges.emit();
-                this.scheduleHide(taskIndex);
-              }
-            },
-          });
-        // Store the subscription for later cleanup
-        this.uploadSubscriptions.set(file.name, subscription);
+              },
+            });
+          // Store the subscription for later cleanup
+          this.uploadSubscriptions.set(file.name, subscription);
+        };
+
+        // Queue management
+        if (this.activeUploads < this.maxConcurrentFiles) {
+          this.activeUploads++;
+          startUpload();
+        } else {
+          this.pendingQueue.push({ fileName: file.name, startUpload });
+        }
       });
     }
   }
 
-  // Hide a task row after 5s (stores timer to clear on destroy) and clean up 
its subscription
+  private cancelExistingUpload(fileName: string): void {
+    const isUploading = this.uploadTasks.some(
+      t => t.filePath === fileName && (t.status === "uploading" || t.status 
=== "initializing")
+    );
+    this.uploadSubscriptions.get(fileName)?.unsubscribe();
+    this.uploadSubscriptions.delete(fileName);
+    this.uploadTasks = this.uploadTasks.filter(t => t.filePath !== fileName);
+
+    // Process next in queue if this was active
+    if (isUploading) {
+      this.onUploadComplete();
+    }
+    // Remove from pending queue if present
+    this.pendingQueue = this.pendingQueue.filter(item => item.fileName !== 
fileName);
+  }
+
+  private processNextQueuedUpload(): void {
+    if (this.pendingQueue.length > 0 && this.activeUploads < 
this.maxConcurrentFiles) {
+      const next = this.pendingQueue.shift();
+      if (next) {
+        this.activeUploads++;
+        next.startUpload();
+      }
+    }
+  }
+
+  private onUploadComplete(): void {
+    this.activeUploads--;
+    this.processNextQueuedUpload();
+  }
+
+  get queuedFileNames(): string[] {
+    return this.pendingQueue.map(item => item.fileName);
+  }
+
+  get queuedCount(): number {
+    return this.pendingQueue.length;
+  }
+
+  get activeCount(): number {
+    return this.activeUploads;
+  }
+
+  // Hide a task row after 5s
   private scheduleHide(idx: number) {
     if (idx === -1) {
       return;
     }
     const key = this.uploadTasks[idx].filePath;
     this.uploadSubscriptions.delete(key);
-    const handle = window.setTimeout(() => {
+    setTimeout(() => {
       this.uploadTasks = this.uploadTasks.filter(t => t.filePath !== key);
     }, 5000);
-    this.autoHideTimers.push(handle);
   }
 
   onClickAbortUploadProgress(task: MultipartUploadProgress & { filePath: 
string }) {
@@ -482,6 +551,11 @@ export class DatasetDetailComponent implements OnInit {
       subscription.unsubscribe();
       this.uploadSubscriptions.delete(task.filePath);
     }
+
+    if (task.status === "uploading" || task.status === "initializing") {
+      this.onUploadComplete();
+    }
+
     this.datasetService
       .finalizeMultipartUpload(
         this.datasetName,
diff --git 
a/core/gui/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-staged-objects-list/user-dataset-staged-objects-list.component.html
 
b/core/gui/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-staged-objects-list/user-dataset-staged-objects-list.component.html
index 5b1dece350..a1820dcecf 100644
--- 
a/core/gui/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-staged-objects-list/user-dataset-staged-objects-list.component.html
+++ 
b/core/gui/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-staged-objects-list/user-dataset-staged-objects-list.component.html
@@ -19,7 +19,6 @@
 
 <div class="staged-object-list-container">
   <nz-list
-    nzBordered
     nzSize="small"
     *ngIf="datasetStagedObjects.length > 0">
     <nz-list-item *ngFor="let obj of datasetStagedObjects">
diff --git 
a/core/gui/src/app/dashboard/component/user/user-quota/user-quota.component.html
 
b/core/gui/src/app/dashboard/component/user/user-quota/user-quota.component.html
index 5df5793baf..51a1dcbf69 100644
--- 
a/core/gui/src/app/dashboard/component/user/user-quota/user-quota.component.html
+++ 
b/core/gui/src/app/dashboard/component/user/user-quota/user-quota.component.html
@@ -66,20 +66,6 @@
             <p class="info-content">{{ formatSize(this.totalQuotaSize) }}</p>
           </div>
         </div>
-        <nz-card
-          class="section-title"
-          [style.backgroundColor]="backgroundColor">
-          <h2
-            class="page-title"
-            [style.color]="textColor">
-            Diagram
-          </h2>
-        </nz-card>
-        <div class="charts-grid">
-          <div id="sizePieChart"></div>
-          <div id="datasetLineChart"></div>
-          <div id="workflowLineChart"></div>
-        </div>
       </nz-tab>
       <nz-tab nzTitle="Result Cache">
         <nz-collapse>
@@ -124,6 +110,15 @@
           </nz-collapse-panel>
         </nz-collapse>
       </nz-tab>
+      <nz-tab
+        nzTitle="Diagrams"
+        nzForceRender="true">
+        <div class="charts-grid">
+          <div id="sizePieChart"></div>
+          <div id="datasetLineChart"></div>
+          <div id="workflowLineChart"></div>
+        </div>
+      </nz-tab>
     </nz-tabset>
   </div>
 </div>
diff --git 
a/core/gui/src/app/dashboard/component/user/user-quota/user-quota.component.scss
 
b/core/gui/src/app/dashboard/component/user/user-quota/user-quota.component.scss
index d5b32caba8..6efa34ba61 100644
--- 
a/core/gui/src/app/dashboard/component/user/user-quota/user-quota.component.scss
+++ 
b/core/gui/src/app/dashboard/component/user/user-quota/user-quota.component.scss
@@ -49,7 +49,8 @@
   display: flex;
   flex-wrap: wrap;
   justify-content: center;
-  padding-top: 40px;
+  height: 70vh;
+  overflow-y: auto;
 
   > div {
     display: flex;
@@ -59,7 +60,6 @@
     background: #fff;
     box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
     padding: 10px;
-    height: 100%;
 
     &:hover {
       box-shadow: 0 0 15px rgba(0, 0, 0, 0.2);
diff --git 
a/core/gui/src/app/dashboard/service/user/download/download.service.ts 
b/core/gui/src/app/dashboard/service/user/download/download.service.ts
index 9c19cf19e2..f02a2411b5 100644
--- a/core/gui/src/app/dashboard/service/user/download/download.service.ts
+++ b/core/gui/src/app/dashboard/service/user/download/download.service.ts
@@ -58,8 +58,8 @@ export class DownloadService {
 
   downloadWorkflow(id: number, name: string): Observable<DownloadableItem> {
     return this.workflowPersistService.retrieveWorkflow(id).pipe(
-      map(({ wid, creationTime, lastModifiedTime, ...workflowCopy }) => {
-        const workflowJson = JSON.stringify({ ...workflowCopy, readonly: false 
});
+      map(({ content }) => {
+        const workflowJson = JSON.stringify(content, null, 2);
         const fileName = `${name}.json`;
         const blob = new Blob([workflowJson], { type: 
"text/plain;charset=utf-8" });
         return { blob, fileName };
diff --git 
a/core/gui/src/app/workspace/component/left-panel/settings/settings.component.ts
 
b/core/gui/src/app/workspace/component/left-panel/settings/settings.component.ts
index 39547bda62..ead28857ff 100644
--- 
a/core/gui/src/app/workspace/component/left-panel/settings/settings.component.ts
+++ 
b/core/gui/src/app/workspace/component/left-panel/settings/settings.component.ts
@@ -55,6 +55,12 @@ export class SettingsComponent implements OnInit {
       dataTransferBatchSize: [this.currentDataTransferBatchSize, 
[Validators.required, Validators.min(1)]],
     });
 
+    this.settingsForm.valueChanges.pipe(untilDestroyed(this)).subscribe(value 
=> {
+      if (this.settingsForm.valid) {
+        this.confirmUpdateDataTransferBatchSize(value.dataTransferBatchSize);
+      }
+    });
+
     this.workflowActionService
       .workflowChanged()
       .pipe(untilDestroyed(this))
diff --git 
a/core/gui/src/app/workspace/component/power-button/computing-unit-selection.component.ts
 
b/core/gui/src/app/workspace/component/power-button/computing-unit-selection.component.ts
index cde1da2742..cc42b86325 100644
--- 
a/core/gui/src/app/workspace/component/power-button/computing-unit-selection.component.ts
+++ 
b/core/gui/src/app/workspace/component/power-button/computing-unit-selection.component.ts
@@ -188,7 +188,7 @@ export class ComputingUnitSelectionComponent implements 
OnInit {
 
   /**
    * Registers a subscription to listen for workflow metadata changes;
-   * Calls `onComputingUnitChange` when the `wid` changes;
+   * Calls `selectComputingUnit` when the `wid` changes;
    * The wid can change by time because of the workspace rendering;
    */
   private registerWorkflowMetadataSubscription(): void {
diff --git 
a/core/gui/src/app/workspace/component/workflow-editor/workflow-editor.component.ts
 
b/core/gui/src/app/workspace/component/workflow-editor/workflow-editor.component.ts
index 089ea9eead..e46bb9acec 100644
--- 
a/core/gui/src/app/workspace/component/workflow-editor/workflow-editor.component.ts
+++ 
b/core/gui/src/app/workspace/component/workflow-editor/workflow-editor.component.ts
@@ -17,14 +17,19 @@
  * under the License.
  */
 
-import { AfterViewInit, ChangeDetectorRef, Component, ElementRef, OnDestroy } 
from "@angular/core";
+import { OnInit, AfterViewInit, ChangeDetectorRef, Component, ElementRef, 
OnDestroy } from "@angular/core";
 import { fromEvent, merge, Subject } from "rxjs";
 import { NzModalCommentBoxComponent } from 
"./comment-box-modal/nz-modal-comment-box.component";
 import { NzModalRef, NzModalService } from "ng-zorro-antd/modal";
 import { DragDropService } from "../../service/drag-drop/drag-drop.service";
 import { DynamicSchemaService } from 
"../../service/dynamic-schema/dynamic-schema.service";
 import { ExecuteWorkflowService } from 
"../../service/execute-workflow/execute-workflow.service";
-import { fromJointPaperEvent, JointUIService, linkPathStrokeColor } from 
"../../service/joint-ui/joint-ui.service";
+import {
+  deleteButtonPath,
+  fromJointPaperEvent,
+  JointUIService,
+  linkPathStrokeColor,
+} from "../../service/joint-ui/joint-ui.service";
 import { ValidationWorkflowService } from 
"../../service/validation/validation-workflow.service";
 import { WorkflowActionService } from 
"../../service/workflow-graph/model/workflow-action.service";
 import { WorkflowStatusService } from 
"../../service/workflow-status/workflow-status.service";
@@ -82,7 +87,7 @@ export const MAIN_CANVAS = {
   templateUrl: "workflow-editor.component.html",
   styleUrls: ["workflow-editor.component.scss"],
 })
-export class WorkflowEditorComponent implements AfterViewInit, OnDestroy {
+export class WorkflowEditorComponent implements OnInit, AfterViewInit, 
OnDestroy {
   editor!: HTMLElement;
   editorWrapper!: HTMLElement;
   paper!: joint.dia.Paper;
@@ -91,6 +96,8 @@ export class WorkflowEditorComponent implements 
AfterViewInit, OnDestroy {
   private _onProcessKeyboardActionObservable: Subject<void> = new Subject();
   private wrapper;
   private currentOpenedOperatorID: string | null = null;
+  private removeButton!: new () => joint.linkTools.Button;
+  private breakpointButton!: new () => joint.linkTools.Button;
 
   constructor(
     private workflowActionService: WorkflowActionService,
@@ -114,6 +121,12 @@ export class WorkflowEditorComponent implements 
AfterViewInit, OnDestroy {
     this.wrapper = this.workflowActionService.getJointGraphWrapper();
   }
 
+  ngOnInit(): void {
+    // Cache the tool constructors
+    this.removeButton = WorkflowEditorComponent.getRemoveButton();
+    this.breakpointButton = WorkflowEditorComponent.getBreakpointButton();
+  }
+
   /**
    * This function is provided to JointJS to disallow links starting from an 
in port.
    *
@@ -1086,18 +1099,17 @@ export class WorkflowEditorComponent implements 
AfterViewInit, OnDestroy {
     fromJointPaperEvent(this.paper, "link:mouseenter")
       .pipe(map(value => value[0]))
       .pipe(untilDestroyed(this))
-      .subscribe(elementView => {
+      .subscribe(linkView => {
+        // Create an array to hold the tools
+        const tools: joint.dia.ToolView[] = [new this.removeButton()];
+
+        // If breakpoints are enabled, also add the breakpoint button
         if (this.config.env.linkBreakpointEnabled) {
-          this.paper.getModelById(elementView.model.id).attr({
-            ".tool-remove": { display: "block" },
-          });
-          
this.paper.getModelById(elementView.model.id).findView(this.paper).showTools();
-        } else {
-          // only display the delete button
-          this.paper.getModelById(elementView.model.id).attr({
-            ".tool-remove": { display: "block" },
-          });
+          tools.push(new this.breakpointButton());
         }
+
+        const toolsView = new joint.dia.ToolsView({ tools });
+        linkView.addTools(toolsView);
       });
 
     /**
@@ -1139,7 +1151,7 @@ export class WorkflowEditorComponent implements 
AfterViewInit, OnDestroy {
       .pipe(this.wrapper.jointGraphContext.bufferWhileAsync, 
untilDestroyed(this))
       .subscribe(link => {
         const linkView = link.findView(this.paper);
-        const breakpointButtonTool = this.jointUIService.getBreakpointButton();
+        const breakpointButtonTool = this.breakpointButton;
         const breakpointButton = new breakpointButtonTool();
         const toolsView = new joint.dia.ToolsView({
           name: "basic-tools",
@@ -1362,4 +1374,98 @@ export class WorkflowEditorComponent implements 
AfterViewInit, OnDestroy {
         this.paper.translate(-targetCoord.x, -targetCoord.y);
       });
   }
+
+  /**
+   * Info button on link between operator shown when user hovers over links
+   */
+  private static getBreakpointButton(): new () => joint.linkTools.Button {
+    return joint.linkTools.Button.extend({
+      name: "info-button",
+      options: {
+        markup: [
+          {
+            tagName: "circle",
+            selector: "info-button",
+            attributes: {
+              r: 10,
+              fill: "#001DFF",
+              cursor: "pointer",
+            },
+          },
+          {
+            tagName: "path",
+            selector: "icon",
+            attributes: {
+              d: "M -2 4 2 4 M 0 3 0 0 M -2 -1 1 -1 M -1 -4 1 -4",
+              fill: "none",
+              stroke: "#FFFFFF",
+              "stroke-width": 2,
+              "pointer-events": "none",
+            },
+          },
+        ],
+        distance: -60,
+        offset: 0,
+        action: function (event: JQuery.Event, linkView: joint.dia.LinkView) {
+          // when this button is clicked, it triggers an joint paper event
+          if (linkView.paper) {
+            linkView.paper.trigger("tool:breakpoint", linkView, event);
+          }
+        },
+      },
+    });
+  }
+
+  /**
+   * Remove button on link between operator shown when user hovers over links
+   */
+  private static RemoveButton: new () => joint.linkTools.Button;
+
+  private static getRemoveButton(): new () => joint.linkTools.Button {
+    // Check if the class has already been created.
+    if (!WorkflowEditorComponent.RemoveButton) {
+      // If not, create it once and store it in the static property.
+      WorkflowEditorComponent.RemoveButton = joint.linkTools.Button.extend({
+        name: "remove-button",
+        options: {
+          markup: [
+            {
+              tagName: "circle",
+              selector: "button",
+              attributes: {
+                r: 10,
+                fill: "none",
+                stroke: "#D8656A",
+                "stroke-width": 2,
+                "pointer-events": "visibleStroke",
+                cursor: "pointer",
+              },
+            },
+            {
+              tagName: "path",
+              selector: "icon",
+              attributes: {
+                d: "M -4 -4 L 4 4 M 4 -4 L -4 4",
+                fill: "none",
+                stroke: "#D8656A",
+                "stroke-width": 2,
+                "stroke-linecap": "round",
+                "pointer-events": "none",
+              },
+            },
+          ],
+          distance: -90,
+          offset: 0,
+          action: function (evt: JQuery.Event, linkView: joint.dia.LinkView) {
+            if (linkView.paper) {
+              linkView.paper.trigger("tool:remove", linkView, evt);
+            }
+          },
+        },
+      });
+    }
+
+    // Return the cached class.
+    return WorkflowEditorComponent.RemoveButton;
+  }
 }
diff --git 
a/core/gui/src/app/workspace/service/computing-unit-status/computing-unit-status.service.ts
 
b/core/gui/src/app/workspace/service/computing-unit-status/computing-unit-status.service.ts
index b5c840c866..4604e52c6f 100644
--- 
a/core/gui/src/app/workspace/service/computing-unit-status/computing-unit-status.service.ts
+++ 
b/core/gui/src/app/workspace/service/computing-unit-status/computing-unit-status.service.ts
@@ -51,6 +51,7 @@ export class ComputingUnitStatusService implements OnDestroy {
   private readonly REFRESH_INTERVAL_MS = 2000;
   private refreshSubscription: Subscription | null = null;
   private currentConnectedCuid?: number;
+  private currentConnectedWid?: number;
   private selectedUnitPoll?: Subscription;
 
   constructor(
@@ -148,19 +149,23 @@ export class ComputingUnitStatusService implements 
OnDestroy {
       });
   }
 
+  //
   /**
    * Select a computing unit **by its CUID** and emit the updated selection.
    */
   public selectComputingUnit(wid: number | undefined, cuid: number): void {
     const trySelect = (unit: DashboardWorkflowComputingUnit) => {
       // open websocket if needed
-      if (isDefined(wid) && this.currentConnectedCuid !== cuid) {
+      const shouldReconnect = this.currentConnectedCuid !== cuid || 
this.currentConnectedWid !== wid;
+      if (isDefined(wid) && shouldReconnect) {
         if (this.workflowWebsocketService.isConnected) {
           this.workflowWebsocketService.closeWebsocket();
           this.workflowStatusService.clearStatus();
         }
+
         this.workflowWebsocketService.openWebsocket(wid, 
this.userService.getCurrentUser()?.uid, cuid);
         this.currentConnectedCuid = cuid;
+        this.currentConnectedWid = wid;
         this.selectedUnitSubject.next(unit);
         this.startPollingSelectedUnit(cuid);
       }
diff --git a/core/gui/src/app/workspace/service/joint-ui/joint-ui.service.ts 
b/core/gui/src/app/workspace/service/joint-ui/joint-ui.service.ts
index 700594ab2d..ff0c930ab7 100644
--- a/core/gui/src/app/workspace/service/joint-ui/joint-ui.service.ts
+++ b/core/gui/src/app/workspace/service/joint-ui/joint-ui.service.ts
@@ -472,44 +472,6 @@ export class JointUIService {
     
jointPaper.getModelById(operator.operatorID).attr(`.${operatorNameClass}/text`, 
displayName);
   }
 
-  public getBreakpointButton(): new () => joint.linkTools.Button {
-    return joint.linkTools.Button.extend({
-      name: "info-button",
-      options: {
-        markup: [
-          {
-            tagName: "circle",
-            selector: "info-button",
-            attributes: {
-              r: 10,
-              fill: "#001DFF",
-              cursor: "pointer",
-            },
-          },
-          {
-            tagName: "path",
-            selector: "icon",
-            attributes: {
-              d: "M -2 4 2 4 M 0 3 0 0 M -2 -1 1 -1 M -1 -4 1 -4",
-              fill: "none",
-              stroke: "#FFFFFF",
-              "stroke-width": 2,
-              "pointer-events": "none",
-            },
-          },
-        ],
-        distance: 60,
-        offset: 0,
-        action: function (event: JQuery.Event, linkView: joint.dia.LinkView) {
-          // when this button is clicked, it triggers an joint paper event
-          if (linkView.paper) {
-            linkView.paper.trigger("tool:breakpoint", linkView, event);
-          }
-        },
-      },
-    });
-  }
-
   public getCommentElement(commentBox: CommentBox): joint.dia.Element {
     const basic = new joint.shapes.standard.Rectangle();
     if (commentBox.commentBoxPosition) 
basic.position(commentBox.commentBoxPosition.x, 
commentBox.commentBoxPosition.y);
diff --git 
a/core/gui/src/assets/operator_images/SklearnTrainingAdaptiveBoosting.png 
b/core/gui/src/assets/operator_images/SklearnTrainingAdaptiveBoosting.png
new file mode 100644
index 0000000000..2daaf54222
Binary files /dev/null and 
b/core/gui/src/assets/operator_images/SklearnTrainingAdaptiveBoosting.png differ
diff --git a/core/gui/src/assets/operator_images/SklearnTrainingBagging.png 
b/core/gui/src/assets/operator_images/SklearnTrainingBagging.png
new file mode 100644
index 0000000000..debd6fec78
Binary files /dev/null and 
b/core/gui/src/assets/operator_images/SklearnTrainingBagging.png differ
diff --git 
a/core/gui/src/assets/operator_images/SklearnTrainingBernoulliNaiveBayes.png 
b/core/gui/src/assets/operator_images/SklearnTrainingBernoulliNaiveBayes.png
new file mode 100644
index 0000000000..736f562180
Binary files /dev/null and 
b/core/gui/src/assets/operator_images/SklearnTrainingBernoulliNaiveBayes.png 
differ
diff --git 
a/core/gui/src/assets/operator_images/SklearnTrainingComplementNaiveBayes.png 
b/core/gui/src/assets/operator_images/SklearnTrainingComplementNaiveBayes.png
new file mode 100644
index 0000000000..8c048f0b00
Binary files /dev/null and 
b/core/gui/src/assets/operator_images/SklearnTrainingComplementNaiveBayes.png 
differ
diff --git 
a/core/gui/src/assets/operator_images/SklearnTrainingDecisionTree.png 
b/core/gui/src/assets/operator_images/SklearnTrainingDecisionTree.png
new file mode 100644
index 0000000000..60d0b815c3
Binary files /dev/null and 
b/core/gui/src/assets/operator_images/SklearnTrainingDecisionTree.png differ
diff --git a/core/gui/src/assets/operator_images/SklearnTrainingDummy.png 
b/core/gui/src/assets/operator_images/SklearnTrainingDummy.png
new file mode 100644
index 0000000000..203f4a1e68
Binary files /dev/null and 
b/core/gui/src/assets/operator_images/SklearnTrainingDummy.png differ
diff --git a/core/gui/src/assets/operator_images/SklearnTrainingExtraTree.png 
b/core/gui/src/assets/operator_images/SklearnTrainingExtraTree.png
new file mode 100644
index 0000000000..273c719535
Binary files /dev/null and 
b/core/gui/src/assets/operator_images/SklearnTrainingExtraTree.png differ
diff --git a/core/gui/src/assets/operator_images/SklearnTrainingExtraTrees.png 
b/core/gui/src/assets/operator_images/SklearnTrainingExtraTrees.png
new file mode 100644
index 0000000000..42c77aa5e5
Binary files /dev/null and 
b/core/gui/src/assets/operator_images/SklearnTrainingExtraTrees.png differ
diff --git 
a/core/gui/src/assets/operator_images/SklearnTrainingGaussianNaiveBayes.png 
b/core/gui/src/assets/operator_images/SklearnTrainingGaussianNaiveBayes.png
new file mode 100644
index 0000000000..09c1f13acc
Binary files /dev/null and 
b/core/gui/src/assets/operator_images/SklearnTrainingGaussianNaiveBayes.png 
differ
diff --git 
a/core/gui/src/assets/operator_images/SklearnTrainingGradientBoosting.png 
b/core/gui/src/assets/operator_images/SklearnTrainingGradientBoosting.png
new file mode 100644
index 0000000000..980f5910c8
Binary files /dev/null and 
b/core/gui/src/assets/operator_images/SklearnTrainingGradientBoosting.png differ
diff --git a/core/gui/src/assets/operator_images/SklearnTrainingKNN.png 
b/core/gui/src/assets/operator_images/SklearnTrainingKNN.png
new file mode 100644
index 0000000000..23e0477686
Binary files /dev/null and 
b/core/gui/src/assets/operator_images/SklearnTrainingKNN.png differ
diff --git 
a/core/gui/src/assets/operator_images/SklearnTrainingLinearRegression.png 
b/core/gui/src/assets/operator_images/SklearnTrainingLinearRegression.png
new file mode 100644
index 0000000000..5ee01e7e47
Binary files /dev/null and 
b/core/gui/src/assets/operator_images/SklearnTrainingLinearRegression.png differ
diff --git a/core/gui/src/assets/operator_images/SklearnTrainingLinearSVM.png 
b/core/gui/src/assets/operator_images/SklearnTrainingLinearSVM.png
new file mode 100644
index 0000000000..510d391429
Binary files /dev/null and 
b/core/gui/src/assets/operator_images/SklearnTrainingLinearSVM.png differ
diff --git 
a/core/gui/src/assets/operator_images/SklearnTrainingLogisticRegression.png 
b/core/gui/src/assets/operator_images/SklearnTrainingLogisticRegression.png
new file mode 100644
index 0000000000..4c598becc9
Binary files /dev/null and 
b/core/gui/src/assets/operator_images/SklearnTrainingLogisticRegression.png 
differ
diff --git 
a/core/gui/src/assets/operator_images/SklearnTrainingLogisticRegressionCV.png 
b/core/gui/src/assets/operator_images/SklearnTrainingLogisticRegressionCV.png
new file mode 100644
index 0000000000..d7dbc742e3
Binary files /dev/null and 
b/core/gui/src/assets/operator_images/SklearnTrainingLogisticRegressionCV.png 
differ
diff --git 
a/core/gui/src/assets/operator_images/SklearnTrainingMultiLayerPerceptron.png 
b/core/gui/src/assets/operator_images/SklearnTrainingMultiLayerPerceptron.png
new file mode 100644
index 0000000000..ebd38d1d56
Binary files /dev/null and 
b/core/gui/src/assets/operator_images/SklearnTrainingMultiLayerPerceptron.png 
differ
diff --git 
a/core/gui/src/assets/operator_images/SklearnTrainingMultinomialNaiveBayes.png 
b/core/gui/src/assets/operator_images/SklearnTrainingMultinomialNaiveBayes.png
new file mode 100644
index 0000000000..8de675ba7f
Binary files /dev/null and 
b/core/gui/src/assets/operator_images/SklearnTrainingMultinomialNaiveBayes.png 
differ
diff --git 
a/core/gui/src/assets/operator_images/SklearnTrainingNearestCentroid.png 
b/core/gui/src/assets/operator_images/SklearnTrainingNearestCentroid.png
new file mode 100644
index 0000000000..b548043784
Binary files /dev/null and 
b/core/gui/src/assets/operator_images/SklearnTrainingNearestCentroid.png differ
diff --git 
a/core/gui/src/assets/operator_images/SklearnTrainingPassiveAggressive.png 
b/core/gui/src/assets/operator_images/SklearnTrainingPassiveAggressive.png
new file mode 100644
index 0000000000..1b41270fd0
Binary files /dev/null and 
b/core/gui/src/assets/operator_images/SklearnTrainingPassiveAggressive.png 
differ
diff --git a/core/gui/src/assets/operator_images/SklearnTrainingPerceptron.png 
b/core/gui/src/assets/operator_images/SklearnTrainingPerceptron.png
new file mode 100644
index 0000000000..858c506282
Binary files /dev/null and 
b/core/gui/src/assets/operator_images/SklearnTrainingPerceptron.png differ
diff --git 
a/core/gui/src/assets/operator_images/SklearnTrainingProbabilityCalibration.png 
b/core/gui/src/assets/operator_images/SklearnTrainingProbabilityCalibration.png
new file mode 100644
index 0000000000..114dfbac06
Binary files /dev/null and 
b/core/gui/src/assets/operator_images/SklearnTrainingProbabilityCalibration.png 
differ
diff --git 
a/core/gui/src/assets/operator_images/SklearnTrainingRandomForest.png 
b/core/gui/src/assets/operator_images/SklearnTrainingRandomForest.png
new file mode 100644
index 0000000000..2ba59197dc
Binary files /dev/null and 
b/core/gui/src/assets/operator_images/SklearnTrainingRandomForest.png differ
diff --git a/core/gui/src/assets/operator_images/SklearnTrainingRidge.png 
b/core/gui/src/assets/operator_images/SklearnTrainingRidge.png
new file mode 100644
index 0000000000..0c1f47c743
Binary files /dev/null and 
b/core/gui/src/assets/operator_images/SklearnTrainingRidge.png differ
diff --git a/core/gui/src/assets/operator_images/SklearnTrainingRidgeCV.png 
b/core/gui/src/assets/operator_images/SklearnTrainingRidgeCV.png
new file mode 100644
index 0000000000..b28c68ee08
Binary files /dev/null and 
b/core/gui/src/assets/operator_images/SklearnTrainingRidgeCV.png differ
diff --git a/core/gui/src/assets/operator_images/SklearnTrainingSDG.png 
b/core/gui/src/assets/operator_images/SklearnTrainingSDG.png
new file mode 100644
index 0000000000..fddaa7bfd0
Binary files /dev/null and 
b/core/gui/src/assets/operator_images/SklearnTrainingSDG.png differ
diff --git a/core/gui/src/assets/operator_images/SklearnTrainingSVM.png 
b/core/gui/src/assets/operator_images/SklearnTrainingSVM.png
new file mode 100644
index 0000000000..4fb5d4aede
Binary files /dev/null and 
b/core/gui/src/assets/operator_images/SklearnTrainingSVM.png differ
diff --git 
a/core/workflow-operator/src/main/scala/edu/uci/ics/amber/operator/LogicalOp.scala
 
b/core/workflow-operator/src/main/scala/edu/uci/ics/amber/operator/LogicalOp.scala
index 6aa57f2574..790e463898 100644
--- 
a/core/workflow-operator/src/main/scala/edu/uci/ics/amber/operator/LogicalOp.scala
+++ 
b/core/workflow-operator/src/main/scala/edu/uci/ics/amber/operator/LogicalOp.scala
@@ -76,7 +76,10 @@ import edu.uci.ics.amber.operator.sklearn.training.{
   SklearnTrainingGaussianNaiveBayesOpDesc,
   SklearnTrainingGradientBoostingOpDesc,
   SklearnTrainingKNNOpDesc,
+  SklearnTrainingLinearRegressionOpDesc,
   SklearnTrainingLinearSVMOpDesc,
+  SklearnTrainingLogisticRegressionCVOpDesc,
+  SklearnTrainingLogisticRegressionOpDesc,
   SklearnTrainingMultiLayerPerceptronOpDesc,
   SklearnTrainingMultinomialNaiveBayesOpDesc,
   SklearnTrainingNearestCentroidOpDesc,
@@ -273,61 +276,79 @@ trait StateTransferFunc
       value = classOf[SklearnLogisticRegressionCVOpDesc],
       name = "SklearnLogisticRegressionCV"
     ),
-    new Type(value = classOf[SklearnTrainingRidgeOpDesc], name = 
"SklearnRidge"),
-    new Type(value = classOf[SklearnTrainingRidgeCVOpDesc], name = 
"SklearnRidgeCV"),
-    new Type(value = classOf[SklearnTrainingSDGOpDesc], name = "SklearnSDG"),
+    new Type(value = classOf[SklearnTrainingRidgeOpDesc], name = 
"SklearnTrainingRidge"),
+    new Type(value = classOf[SklearnTrainingRidgeCVOpDesc], name = 
"SklearnTrainingRidgeCV"),
+    new Type(value = classOf[SklearnTrainingSDGOpDesc], name = 
"SklearnTrainingSDG"),
     new Type(
       value = classOf[SklearnTrainingPassiveAggressiveOpDesc],
-      name = "SklearnPassiveAggressive"
+      name = "SklearnTrainingPassiveAggressive"
     ),
-    new Type(value = classOf[SklearnTrainingPerceptronOpDesc], name = 
"SklearnPerceptron"),
-    new Type(value = classOf[SklearnTrainingKNNOpDesc], name = "SklearnKNN"),
+    new Type(value = classOf[SklearnTrainingPerceptronOpDesc], name = 
"SklearnTrainingPerceptron"),
+    new Type(value = classOf[SklearnTrainingKNNOpDesc], name = 
"SklearnTrainingKNN"),
     new Type(
       value = classOf[SklearnTrainingNearestCentroidOpDesc],
-      name = "SklearnNearestCentroid"
+      name = "SklearnTrainingNearestCentroid"
     ),
-    new Type(value = classOf[SklearnTrainingSVMOpDesc], name = "SklearnSVM"),
-    new Type(value = classOf[SklearnTrainingLinearSVMOpDesc], name = 
"SklearnLinearSVM"),
-    new Type(value = classOf[SklearnTrainingDecisionTreeOpDesc], name = 
"SklearnDecisionTree"),
-    new Type(value = classOf[SklearnTrainingExtraTreeOpDesc], name = 
"SklearnExtraTree"),
+    new Type(value = classOf[SklearnTrainingSVMOpDesc], name = 
"SklearnTrainingSVM"),
+    new Type(value = classOf[SklearnTrainingLinearSVMOpDesc], name = 
"SklearnTrainingLinearSVM"),
+    new Type(
+      value = classOf[SklearnTrainingDecisionTreeOpDesc],
+      name = "SklearnTrainingDecisionTree"
+    ),
+    new Type(value = classOf[SklearnTrainingExtraTreeOpDesc], name = 
"SklearnTrainingExtraTree"),
     new Type(
       value = classOf[SklearnTrainingMultiLayerPerceptronOpDesc],
-      name = "SklearnMultiLayerPerceptron"
+      name = "SklearnTrainingMultiLayerPerceptron"
     ),
     new Type(
       value = classOf[SklearnTrainingProbabilityCalibrationOpDesc],
-      name = "SklearnProbabilityCalibration"
+      name = "SklearnTrainingProbabilityCalibration"
     ),
-    new Type(value = classOf[SklearnTrainingRandomForestOpDesc], name = 
"SklearnRandomForest"),
-    new Type(value = classOf[SklearnTrainingBaggingOpDesc], name = 
"SklearnBagging"),
+    new Type(
+      value = classOf[SklearnTrainingRandomForestOpDesc],
+      name = "SklearnTrainingRandomForest"
+    ),
+    new Type(value = classOf[SklearnTrainingBaggingOpDesc], name = 
"SklearnTrainingBagging"),
     new Type(
       value = classOf[SklearnTrainingGradientBoostingOpDesc],
-      name = "SklearnGradientBoosting"
+      name = "SklearnTrainingGradientBoosting"
     ),
     new Type(
       value = classOf[SklearnTrainingAdaptiveBoostingOpDesc],
-      name = "SklearnAdaptiveBoosting"
+      name = "SklearnTrainingAdaptiveBoosting"
     ),
-    new Type(value = classOf[SklearnTrainingExtraTreesOpDesc], name = 
"SklearnExtraTrees"),
+    new Type(value = classOf[SklearnTrainingExtraTreesOpDesc], name = 
"SklearnTrainingExtraTrees"),
     new Type(
       value = classOf[SklearnTrainingGaussianNaiveBayesOpDesc],
-      name = "SklearnGaussianNaiveBayes"
+      name = "SklearnTrainingGaussianNaiveBayes"
     ),
     new Type(
       value = classOf[SklearnTrainingMultinomialNaiveBayesOpDesc],
-      name = "SklearnMultinomialNaiveBayes"
+      name = "SklearnTrainingMultinomialNaiveBayes"
     ),
     new Type(
       value = classOf[SklearnTrainingComplementNaiveBayesOpDesc],
-      name = "SklearnComplementNaiveBayes"
+      name = "SklearnTrainingComplementNaiveBayes"
     ),
     new Type(
       value = classOf[SklearnTrainingBernoulliNaiveBayesOpDesc],
-      name = "SklearnBernoulliNaiveBayes"
+      name = "SklearnTrainingBernoulliNaiveBayes"
     ),
     new Type(
       value = classOf[SklearnTrainingDummyClassifierOpDesc],
-      name = "SklearnDummyClassifier"
+      name = "SklearnTrainingDummyClassifier"
+    ),
+    new Type(
+      value = classOf[SklearnTrainingLinearRegressionOpDesc],
+      name = "SklearnTrainingLinearRegression"
+    ),
+    new Type(
+      value = classOf[SklearnTrainingLogisticRegressionOpDesc],
+      name = "SklearnTrainingLogisticRegression"
+    ),
+    new Type(
+      value = classOf[SklearnTrainingLogisticRegressionCVOpDesc],
+      name = "SklearnTrainingLogisticRegressionCV"
     ),
     new Type(value = classOf[SklearnLogisticRegressionOpDesc], name = 
"SklearnLogisticRegression"),
     new Type(
diff --git 
a/core/workflow-operator/src/main/scala/edu/uci/ics/amber/operator/sklearn/training/SklearnTrainingLinearRegressionOpDesc.scala
 
b/core/workflow-operator/src/main/scala/edu/uci/ics/amber/operator/sklearn/training/SklearnTrainingLinearRegressionOpDesc.scala
new file mode 100644
index 0000000000..12d5eddb87
--- /dev/null
+++ 
b/core/workflow-operator/src/main/scala/edu/uci/ics/amber/operator/sklearn/training/SklearnTrainingLinearRegressionOpDesc.scala
@@ -0,0 +1,25 @@
+/*
+ * 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 edu.uci.ics.amber.operator.sklearn.training
+
+class SklearnTrainingLinearRegressionOpDesc extends SklearnTrainingOpDesc {
+  override def getImportStatements = "from sklearn.linear_model import 
LinearRegression"
+  override def getUserFriendlyModelName = "Training: Linear Regression"
+}


Reply via email to