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
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" +}
