You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@texera.apache.org by yi...@apache.org on 2025/08/28 05:33:35 UTC
(texera) 01/05: feat: add model
This is an automated email from the ASF dual-hosted git repository.
yiconghuang pushed a commit to branch feat/add-model
in repository https://gitbox.apache.org/repos/asf/texera.git
commit f3df59c59c8a512765a398aa49936514c0c788ee
Author: Yicong Huang <17...@users.noreply.github.com>
AuthorDate: Sun Aug 24 02:46:15 2025 -0700
feat: add model
---
.../web/resource/dashboard/DashboardResource.scala | 19 +-
.../dashboard/ModelSearchQueryBuilder.scala | 144 ++++
.../resource/dashboard/SearchQueryBuilder.scala | 1 +
.../resource/dashboard/UnifiedResourceSchema.scala | 10 +-
.../web/resource/dashboard/hub/EntityTables.scala | 17 +
.../web/resource/dashboard/hub/EntityType.scala | 4 +-
.../web/resource/dashboard/hub/HubResource.scala | 24 +-
.../dashboard/user/model/ModelResource.scala} | 23 +-
core/config/src/main/resources/default.conf | 1 +
core/config/src/main/resources/storage.conf | 2 +-
core/config/src/main/resources/user-system.conf | 4 +-
.../scala/edu/uci/ics/texera/dao/SqlServer.scala | 2 +
.../uci/ics/texera/dao/jooq/generated/Keys.java | 22 +
.../uci/ics/texera/dao/jooq/generated/Tables.java | 30 +
.../ics/texera/dao/jooq/generated/TexeraDb.java | 35 +
.../texera/dao/jooq/generated/tables/Dataset.java | 12 +-
.../dao/jooq/generated/tables/daos/DatasetDao.java | 30 +-
.../jooq/generated/tables/interfaces/IDataset.java | 20 +-
.../dao/jooq/generated/tables/pojos/Dataset.java | 46 +-
.../generated/tables/records/DatasetRecord.java | 94 +--
.../edu/uci/ics/texera/service/FileService.scala | 8 +-
.../texera/service/resource/DatasetResource.scala | 8 +-
.../service/resource/ModelAccessResource.scala | 221 ++++++
.../{DatasetResource.scala => ModelResource.scala} | 759 ++++++++++-----------
core/gui/proxy.config.json | 5 +
core/gui/src/app/app-routing.constant.ts | 1 +
core/gui/src/app/app-routing.module.ts | 5 +
core/gui/src/app/app.module.ts | 10 +
core/gui/src/app/common/type/dataset.ts | 12 +
core/gui/src/app/common/type/gui-config.ts | 1 +
.../admin/settings/admin-settings.component.ts | 1 +
.../dashboard/component/dashboard.component.html | 12 +
.../app/dashboard/component/dashboard.component.ts | 4 +-
.../model-detail.component.html | 336 +++++++++
.../model-detail.component.scss | 247 +++++++
.../model-detail.component.ts | 567 +++++++++++++++
.../user-dataset-file-renderer.component.html | 104 +++
.../user-dataset-file-renderer.component.scss} | 30 +-
.../user-dataset-file-renderer.component.spec.ts | 57 ++
.../user-dataset-file-renderer.component.ts | 347 ++++++++++
...user-dataset-staged-objects-list.component.html | 58 ++
...ser-dataset-staged-objects-list.component.scss} | 38 +-
.../user-dataset-staged-objects-list.component.ts | 94 +++
.../user-model-version-creator.component.html | 72 ++
.../user-model-version-creator.component.scss | 86 +++
.../user-model-version-creator.component.ts | 220 ++++++
.../user-dataset-version-filetree.component.html | 54 ++
.../user-dataset-version-filetree.component.scss} | 42 +-
.../user-dataset-version-filetree.component.ts | 77 +++
.../user-dataset-list-item.component.html | 154 +++++
.../user-dataset-list-item.component.scss | 87 +++
.../user-dataset-list-item.component.ts | 133 ++++
.../user/user-model/user-model.component.html | 61 ++
.../user/user-model/user-model.component.scss} | 14 +-
.../user/user-model/user-model.component.ts | 207 ++++++
.../service/user/download/download.service.spec.ts | 2 +-
.../dashboard/service/user/model/model.service.ts | 524 ++++++++++++++
.../app/dashboard/service/user/search.service.ts | 25 +-
.../dashboard/type/dashboard-dataset.interface.ts | 11 +-
core/gui/src/app/dashboard/type/dashboard-entry.ts | 37 +-
.../app/dashboard/type/search-filter-parameters.ts | 2 +-
core/gui/src/app/dashboard/type/search-result.ts | 5 +-
core/gui/src/app/dashboard/type/type-predicates.ts | 6 +-
core/gui/src/app/hub/service/hub.service.ts | 1 +
core/scripts/sql/texera_ddl.sql | 61 ++
65 files changed, 4734 insertions(+), 612 deletions(-)
diff --git a/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/DashboardResource.scala b/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/DashboardResource.scala
index 92341df0af..5a0d06ebad 100644
--- a/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/DashboardResource.scala
+++ b/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/DashboardResource.scala
@@ -25,9 +25,9 @@ import edu.uci.ics.texera.dao.jooq.generated.tables.pojos._
import edu.uci.ics.texera.web.resource.dashboard.DashboardResource._
import edu.uci.ics.texera.web.resource.dashboard.SearchQueryBuilder.{ALL_RESOURCE_TYPE, context}
import edu.uci.ics.texera.web.resource.dashboard.user.dataset.DatasetResource.DashboardDataset
+import edu.uci.ics.texera.web.resource.dashboard.user.model.ModelResource.DashboardModel
import edu.uci.ics.texera.web.resource.dashboard.user.workflow.WorkflowResource.DashboardWorkflow
import io.dropwizard.auth.Auth
-
import org.jooq.{Field, OrderField}
import java.util
@@ -40,7 +40,8 @@ object DashboardResource {
resourceType: String,
workflow: Option[DashboardWorkflow] = None,
project: Option[Project] = None,
- dataset: Option[DashboardDataset] = None
+ dataset: Option[DashboardDataset] = None,
+ model: Option[DashboardModel] = None
)
case class UserInfo(userId: Integer, userName: String, googleAvatar: Option[String])
@@ -80,6 +81,7 @@ object DashboardResource {
@QueryParam("operator") operators: java.util.List[String] = new util.ArrayList(),
@QueryParam("projectId") projectIds: java.util.List[Integer] = new util.ArrayList(),
@QueryParam("datasetId") datasetIds: java.util.List[Integer] = new util.ArrayList(),
+ @QueryParam("modelId") modelIds: java.util.List[Integer] = new util.ArrayList(),
@QueryParam("start") @DefaultValue("0") offset: Int = 0,
@QueryParam("count") @DefaultValue("20") count: Int = 20,
@QueryParam("orderBy") @DefaultValue("EditTimeDesc") orderBy: String = "EditTimeDesc"
@@ -100,17 +102,22 @@ object DashboardResource {
ProjectSearchQueryBuilder.constructQuery(uid, params, includePublic)
case SearchQueryBuilder.DATASET_RESOURCE_TYPE =>
DatasetSearchQueryBuilder.constructQuery(uid, params, includePublic)
+ case SearchQueryBuilder.MODEL_RESOURCE_TYPE =>
+ ModelSearchQueryBuilder.constructQuery(uid, params, includePublic)
case SearchQueryBuilder.ALL_RESOURCE_TYPE =>
val q1 = WorkflowSearchQueryBuilder.constructQuery(uid, params, includePublic)
val q3 = ProjectSearchQueryBuilder.constructQuery(uid, params, includePublic)
val q4 = DatasetSearchQueryBuilder.constructQuery(uid, params, includePublic)
- q1.unionAll(q3).unionAll(q4)
+ val q5 = ModelSearchQueryBuilder.constructQuery(uid, params, includePublic)
+ q1.unionAll(q3).unionAll(q4).unionAll(q5)
case _ => throw new IllegalArgumentException(s"Unknown resource type: ${params.resourceType}")
}
val finalQuery =
query.orderBy(getOrderFields(params): _*).offset(params.offset).limit(params.count + 1)
+ println("SQL:" + finalQuery.getSQL)
val queryResult = finalQuery.fetch()
+ println("Result size:" + queryResult.size())
val allEntries = queryResult.asScala.toList
.take(params.count)
@@ -121,6 +128,8 @@ object DashboardResource {
WorkflowSearchQueryBuilder.toEntry(uid, record)
case SearchQueryBuilder.PROJECT_RESOURCE_TYPE =>
ProjectSearchQueryBuilder.toEntry(uid, record)
+ case SearchQueryBuilder.MODEL_RESOURCE_TYPE =>
+ ModelSearchQueryBuilder.toEntry(uid, record)
case SearchQueryBuilder.DATASET_RESOURCE_TYPE =>
DatasetSearchQueryBuilder.toEntry(uid, record)
}
@@ -129,8 +138,8 @@ object DashboardResource {
val entries = allEntries.filter(_ != null)
val hasMismatch =
params.resourceType match {
- case SearchQueryBuilder.DATASET_RESOURCE_TYPE | SearchQueryBuilder.ALL_RESOURCE_TYPE =>
- allEntries.exists(_ == null)
+ case SearchQueryBuilder.DATASET_RESOURCE_TYPE | SearchQueryBuilder.MODEL_RESOURCE_TYPE | SearchQueryBuilder.ALL_RESOURCE_TYPE =>
+ allEntries.contains(null)
case _ =>
false
}
diff --git a/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/ModelSearchQueryBuilder.scala b/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/ModelSearchQueryBuilder.scala
new file mode 100644
index 0000000000..a3baf3ddda
--- /dev/null
+++ b/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/ModelSearchQueryBuilder.scala
@@ -0,0 +1,144 @@
+/*
+ * 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.web.resource.dashboard
+
+import com.typesafe.scalalogging.LazyLogging
+import edu.uci.ics.amber.core.storage.util.LakeFSStorageClient
+import edu.uci.ics.texera.dao.jooq.generated.Tables.{ MODEL, MODEL_USER_ACCESS}
+import edu.uci.ics.texera.dao.jooq.generated.enums.PrivilegeEnum
+import edu.uci.ics.texera.dao.jooq.generated.tables.User.USER
+import edu.uci.ics.texera.dao.jooq.generated.tables.pojos.{ Model, User}
+import edu.uci.ics.texera.web.resource.dashboard.DashboardResource.DashboardClickableFileEntry
+import edu.uci.ics.texera.web.resource.dashboard.FulltextSearchQueryUtils.{getContainsFilter, getDateFilter, getFullTextSearchFilter}
+import edu.uci.ics.texera.web.resource.dashboard.user.model.ModelResource.DashboardModel
+import org.jooq.impl.DSL
+import org.jooq.{Condition, GroupField, Record, TableLike}
+
+import scala.jdk.CollectionConverters.CollectionHasAsScala
+
+object ModelSearchQueryBuilder extends SearchQueryBuilder with LazyLogging {
+ override protected val mappedResourceSchema: UnifiedResourceSchema = UnifiedResourceSchema(
+ resourceType = DSL.inline(SearchQueryBuilder.MODEL_RESOURCE_TYPE),
+ name = MODEL.NAME,
+ description = MODEL.DESCRIPTION,
+ creationTime = MODEL.CREATION_TIME,
+ mid = MODEL.MID,
+ ownerId = MODEL.OWNER_UID,
+ isModelPublic= MODEL.IS_PUBLIC,
+ modelUserAccess = MODEL_USER_ACCESS.PRIVILEGE
+ )
+
+ /*
+ * constructs the FROM clause for querying models with specific access controls.
+ *
+ * Parameter:
+ * - uid: Integer - Represents the unique identifier of the current user.
+ * - uid is 'null' if the user is not logged in or performing a public search.
+ * - Otherwise, `uid` holds the identifier for the logged-in user.
+ * - includePublic - Boolean - Specifies whether to include public models in the result.
+ */
+ override protected def constructFromClause(
+ uid: Integer,
+ params: DashboardResource.SearchQueryParams,
+ includePublic: Boolean = false
+ ): TableLike[_] = {
+ val baseJoin = MODEL
+ .leftJoin(MODEL_USER_ACCESS)
+ .on(MODEL_USER_ACCESS.MID.eq(MODEL.MID))
+ .leftJoin(USER)
+ .on(USER.UID.eq(MODEL.OWNER_UID))
+
+ // Default condition starts as true, ensuring all models are selected initially.
+ var condition: Condition = DSL.trueCondition()
+
+ if (uid == null) {
+ // If `uid` is null, the user is not logged in or performing a public search
+ // We only select models marked as public
+ condition = MODEL.IS_PUBLIC.eq(true)
+ } else {
+ // When `uid` is present, we add a condition to only include models with direct user access.
+ val userAccessCondition = MODEL_USER_ACCESS.UID.eq(uid)
+
+ if (includePublic) {
+ // If `includePublic` is true, we extend visibility to public models as well.
+ condition = userAccessCondition.or(MODEL.IS_PUBLIC.eq(true))
+ } else {
+ condition = userAccessCondition
+ }
+ }
+ baseJoin.where(condition)
+ }
+
+ override protected def constructWhereClause(
+ uid: Integer,
+ params: DashboardResource.SearchQueryParams
+ ): Condition = {
+ val splitKeywords = params.keywords.asScala
+ .flatMap(_.split("[+\\-()<>~*@\"]"))
+ .filter(_.nonEmpty)
+ .toSeq
+
+ getDateFilter(
+ params.creationStartDate,
+ params.creationEndDate,
+ MODEL.CREATION_TIME
+ )
+ .and(getContainsFilter(params.modelIds, MODEL.MID))
+ .and(
+ getFullTextSearchFilter(splitKeywords, List(MODEL.NAME, MODEL.DESCRIPTION))
+ )
+ }
+
+ override protected def getGroupByFields: Seq[GroupField] = {
+ Seq.empty
+ }
+
+ override protected def toEntryImpl(
+ uid: Integer,
+ record: Record
+ ): DashboardResource.DashboardClickableFileEntry = {
+ val model = record.into(MODEL).into(classOf[Model])
+ val owner = record.into(USER).into(classOf[User])
+ var size = 0L
+
+ try {
+ size = LakeFSStorageClient.retrieveRepositorySize(model.getName)
+ } catch {
+ case e: io.lakefs.clients.sdk.ApiException =>
+ // Treat all LakeFS ApiException as mismatch (repository not found, being deleted, or any fatal error)
+ logger.error(s"LakeFS ApiException for model '${model.getName}': ${e.getMessage}", e)
+ return null
+ }
+
+ val dd = DashboardModel(
+ model,
+ owner.getEmail,
+ record.get(MODEL_USER_ACCESS.PRIVILEGE, classOf[PrivilegeEnum]),
+ model.getOwnerUid == uid,
+ size
+ )
+ DashboardClickableFileEntry(
+ resourceType = SearchQueryBuilder.MODEL_RESOURCE_TYPE,
+ model = Some(dd)
+ )
+ }
+}
+
+class ModelSearchQueryBuilder {}
diff --git a/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/SearchQueryBuilder.scala b/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/SearchQueryBuilder.scala
index 014d29e0e7..0f40f28932 100644
--- a/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/SearchQueryBuilder.scala
+++ b/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/SearchQueryBuilder.scala
@@ -36,6 +36,7 @@ object SearchQueryBuilder {
val WORKFLOW_RESOURCE_TYPE = "workflow"
val PROJECT_RESOURCE_TYPE = "project"
val DATASET_RESOURCE_TYPE = "dataset"
+ val MODEL_RESOURCE_TYPE = "model"
val ALL_RESOURCE_TYPE = ""
}
diff --git a/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/UnifiedResourceSchema.scala b/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/UnifiedResourceSchema.scala
index 5d2c52584b..894b688943 100644
--- a/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/UnifiedResourceSchema.scala
+++ b/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/UnifiedResourceSchema.scala
@@ -67,9 +67,13 @@ object UnifiedResourceSchema {
projectOwnerId: Field[Integer] = DSL.cast(null, classOf[Integer]),
projectColor: Field[String] = DSL.inline(""),
did: Field[Integer] = DSL.cast(null, classOf[Integer]),
+ mid: Field[Integer] = DSL.cast(null, classOf[Integer]),
datasetStoragePath: Field[String] = DSL.cast(null, classOf[String]),
isDatasetPublic: Field[java.lang.Boolean] = DSL.cast(null, classOf[java.lang.Boolean]),
- datasetUserAccess: Field[PrivilegeEnum] = DSL.castNull(classOf[PrivilegeEnum])
+ datasetUserAccess: Field[PrivilegeEnum] = DSL.castNull(classOf[PrivilegeEnum]),
+ modelStoragePath: Field[String] = DSL.cast(null, classOf[String]),
+ isModelPublic: Field[java.lang.Boolean] = DSL.cast(null, classOf[java.lang.Boolean]),
+ modelUserAccess: Field[PrivilegeEnum] = DSL.castNull(classOf[PrivilegeEnum])
): UnifiedResourceSchema = {
new UnifiedResourceSchema(
Seq(
@@ -89,6 +93,10 @@ object UnifiedResourceSchema {
projectOwnerId -> projectOwnerId.as("owner_uid"),
projectColor -> projectColor.as("color"),
did -> did.as("did"),
+ mid -> mid.as("mid"),
+ modelStoragePath -> modelStoragePath.as("model_storage_path"),
+ modelUserAccess -> modelUserAccess.as("user_model_access"),
+ isModelPublic -> isModelPublic.as("is_model_public"),
datasetStoragePath -> datasetStoragePath.as("dataset_storage_path"),
datasetUserAccess -> datasetUserAccess.as("user_dataset_access"),
isDatasetPublic -> isDatasetPublic.as("is_dataset_public")
diff --git a/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/hub/EntityTables.scala b/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/hub/EntityTables.scala
index e6a8a8d813..4aeedd28fd 100644
--- a/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/hub/EntityTables.scala
+++ b/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/hub/EntityTables.scala
@@ -83,10 +83,19 @@ object EntityTables {
override val idColumn: TableField[DatasetUserLikesRecord, Integer] = DATASET_USER_LIKES.DID
}
+ case object ModelLikeTable extends LikeTable {
+ override type R = ModelUserLikesRecord
+ override val table: Table[ModelUserLikesRecord] = MODEL_USER_LIKES
+ override val uidColumn: TableField[ModelUserLikesRecord, Integer] =
+ MODEL_USER_LIKES.UID
+ override val idColumn: TableField[ModelUserLikesRecord, Integer] = MODEL_USER_LIKES.MID
+ }
+
def apply(entityType: EntityType): LikeTable =
entityType match {
case EntityType.Workflow => WorkflowLikeTable
case EntityType.Dataset => DatasetLikeTable
+ case EntityType.Model => ModelLikeTable
}
}
@@ -135,11 +144,19 @@ object EntityTables {
override val viewCountColumn: TableField[DatasetViewCountRecord, Integer] =
DATASET_VIEW_COUNT.VIEW_COUNT
}
+ case object ModelViewCountTable extends ViewCountTable {
+ override type R = ModelViewCountRecord
+ override val table: Table[ModelViewCountRecord] = MODEL_VIEW_COUNT
+ override val idColumn: TableField[ModelViewCountRecord, Integer] = MODEL_VIEW_COUNT.MID
+ override val viewCountColumn: TableField[ModelViewCountRecord, Integer] =
+ MODEL_VIEW_COUNT.VIEW_COUNT
+ }
def apply(entityType: EntityType): ViewCountTable =
entityType match {
case EntityType.Workflow => WorkflowViewCountTable
case EntityType.Dataset => DatasetViewCountTable
+ case EntityType.Model => ModelViewCountTable
}
}
}
diff --git a/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/hub/EntityType.scala b/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/hub/EntityType.scala
index 0ef787716b..46e9bc17e5 100644
--- a/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/hub/EntityType.scala
+++ b/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/hub/EntityType.scala
@@ -35,8 +35,8 @@ sealed trait EntityType {
object EntityType {
case object Workflow extends EntityType { val value = "workflow" }
case object Dataset extends EntityType { val value = "dataset" }
-
- private val values = Seq(Workflow, Dataset)
+ case object Model extends EntityType { val value = "model" }
+ private val values = Seq(Workflow, Dataset, Model)
@JsonCreator
def fromString(s: String): EntityType =
diff --git a/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/hub/HubResource.scala b/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/hub/HubResource.scala
index 84c5c9f028..6c803d2f5e 100644
--- a/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/hub/HubResource.scala
+++ b/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/hub/HubResource.scala
@@ -21,23 +21,8 @@ package edu.uci.ics.texera.web.resource.dashboard.hub
import edu.uci.ics.texera.dao.SqlServer
import edu.uci.ics.texera.dao.jooq.generated.Tables._
-import HubResource.{
- AccessResponse,
- CountResponse,
- LikedResponse,
- UserRequest,
- ViewRequest,
- fetchDashboardDatasetsByDids,
- fetchDashboardWorkflowsByWids,
- isLikedHelper,
- recordLikeActivity,
- recordUserActivity
-}
-import edu.uci.ics.texera.web.resource.dashboard.user.workflow.WorkflowResource.{
- DashboardWorkflow,
- baseWorkflowSelect,
- mapWorkflowEntries
-}
+import HubResource.{AccessResponse, CountResponse, LikedResponse, UserRequest, ViewRequest, fetchDashboardDatasetsByDids, fetchDashboardWorkflowsByWids, isLikedHelper, recordLikeActivity, recordUserActivity}
+import edu.uci.ics.texera.web.resource.dashboard.user.workflow.WorkflowResource.{DashboardWorkflow, baseWorkflowSelect, mapWorkflowEntries}
import org.jooq.impl.DSL
import java.util.regex.Pattern
@@ -55,6 +40,7 @@ import edu.uci.ics.texera.dao.jooq.generated.tables.User.USER
import edu.uci.ics.texera.dao.jooq.generated.tables.pojos.{Dataset, DatasetUserAccess}
import edu.uci.ics.texera.web.resource.dashboard.DashboardResource.DashboardClickableFileEntry
import edu.uci.ics.texera.web.resource.dashboard.hub.ActionType.{Clone, Like, Unlike, View}
+import edu.uci.ics.texera.web.resource.dashboard.hub.EntityType.Model
import edu.uci.ics.texera.web.resource.dashboard.user.dataset.DatasetResource.DashboardDataset
import io.dropwizard.auth.Auth
import org.jooq.Table
@@ -614,7 +600,7 @@ class HubResource {
} else Map.empty
val cloneMap: Map[Int, Int] =
- if (requestedActions.contains(ActionType.Clone) && etype != EntityType.Dataset) {
+ if (requestedActions.contains(ActionType.Clone) && etype != EntityType.Dataset && etype !=EntityType.Model) {
val cloneTbl = CloneTable(etype)
context
.select(cloneTbl.idColumn, DSL.count().`as`("cnt"))
@@ -679,6 +665,8 @@ class HubResource {
(WORKFLOW_USER_ACCESS: Table[_], WORKFLOW_USER_ACCESS.WID, WORKFLOW_USER_ACCESS.UID)
case EntityType.Dataset =>
(DATASET_USER_ACCESS: Table[_], DATASET_USER_ACCESS.DID, DATASET_USER_ACCESS.UID)
+ case EntityType.Model =>
+ (MODEL_USER_ACCESS: Table[_], MODEL_USER_ACCESS.MID, MODEL_USER_ACCESS.UID)
}
val records = context
diff --git a/core/gui/src/app/dashboard/type/dashboard-dataset.interface.ts b/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/user/model/ModelResource.scala
similarity index 65%
copy from core/gui/src/app/dashboard/type/dashboard-dataset.interface.ts
copy to core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/user/model/ModelResource.scala
index 2ea0058264..3fd4efe5bd 100644
--- a/core/gui/src/app/dashboard/type/dashboard-dataset.interface.ts
+++ b/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/user/model/ModelResource.scala
@@ -1,4 +1,4 @@
-/**
+/*
* 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
@@ -17,13 +17,18 @@
* under the License.
*/
-import { Dataset, DatasetVersion } from "../../common/type/dataset";
-import { DatasetFileNode } from "../../common/type/datasetVersionFileTree";
+package edu.uci.ics.texera.web.resource.dashboard.user.model
-export interface DashboardDataset {
- isOwner: boolean;
- ownerEmail: string;
- dataset: Dataset;
- accessPrivilege: "READ" | "WRITE" | "NONE";
- size: number;
+import edu.uci.ics.texera.dao.jooq.generated.tables.pojos.Model
+import org.jooq.EnumType
+
+object ModelResource {
+ // TODO: move these community resource definitions to a centralized package, similar to workflow-core
+ case class DashboardModel(
+ model: Model,
+ ownerEmail: String,
+ accessPrivilege: EnumType,
+ isOwner: Boolean,
+ size: Long
+ )
}
diff --git a/core/config/src/main/resources/default.conf b/core/config/src/main/resources/default.conf
index fa6e949c1d..8506dd7f0d 100644
--- a/core/config/src/main/resources/default.conf
+++ b/core/config/src/main/resources/default.conf
@@ -43,6 +43,7 @@ gui {
projects_enabled = false
workflows_enabled = true
datasets_enabled = true
+ models_enabled = true
quota_enabled = true
forum_enabled = false
diff --git a/core/config/src/main/resources/storage.conf b/core/config/src/main/resources/storage.conf
index 276c1efad8..b5c8395299 100644
--- a/core/config/src/main/resources/storage.conf
+++ b/core/config/src/main/resources/storage.conf
@@ -122,7 +122,7 @@ storage {
# Configuration for Postgres, used for user system data & metadata storage
jdbc {
- url = "jdbc:postgresql://localhost:5432/texera_db?currentSchema=texera_db,public"
+ url = "jdbc:postgresql://localhost:5432/postgres?currentSchema=texera_db"
url = ${?STORAGE_JDBC_URL}
username = "postgres"
diff --git a/core/config/src/main/resources/user-system.conf b/core/config/src/main/resources/user-system.conf
index aa7d65a333..c9cd480b33 100644
--- a/core/config/src/main/resources/user-system.conf
+++ b/core/config/src/main/resources/user-system.conf
@@ -17,7 +17,7 @@
# See PR https://github.com/Texera/texera/pull/3326 for configuration guidelines.
user-sys {
- enabled = false
+ enabled = true
enabled = ${?USER_SYS_ENABLED}
admin-username = "texera"
@@ -39,7 +39,7 @@ user-sys {
}
}
- domain = ""
+ domain = "texera"
domain = ${?USER_SYS_DOMAIN}
invite-only = false
diff --git a/core/dao/src/main/scala/edu/uci/ics/texera/dao/SqlServer.scala b/core/dao/src/main/scala/edu/uci/ics/texera/dao/SqlServer.scala
index 18eb92b9f3..8973aa2737 100644
--- a/core/dao/src/main/scala/edu/uci/ics/texera/dao/SqlServer.scala
+++ b/core/dao/src/main/scala/edu/uci/ics/texera/dao/SqlServer.scala
@@ -52,6 +52,8 @@ object SqlServer {
private var instance: Option[SqlServer] = None
def initConnection(url: String, user: String, password: String): Unit = {
+
+ println(s"Initializing SqlServer connection with URL: $url, User: $user, Password: $password")
if (instance.isEmpty) {
val server = new SqlServer(url, user, password)
instance = Some(server)
diff --git a/core/dao/src/main/scala/edu/uci/ics/texera/dao/jooq/generated/Keys.java b/core/dao/src/main/scala/edu/uci/ics/texera/dao/jooq/generated/Keys.java
index b07305f249..7c134084f9 100644
--- a/core/dao/src/main/scala/edu/uci/ics/texera/dao/jooq/generated/Keys.java
+++ b/core/dao/src/main/scala/edu/uci/ics/texera/dao/jooq/generated/Keys.java
@@ -27,6 +27,11 @@ import edu.uci.ics.texera.dao.jooq.generated.tables.DatasetUserAccess;
import edu.uci.ics.texera.dao.jooq.generated.tables.DatasetUserLikes;
import edu.uci.ics.texera.dao.jooq.generated.tables.DatasetVersion;
import edu.uci.ics.texera.dao.jooq.generated.tables.DatasetViewCount;
+import edu.uci.ics.texera.dao.jooq.generated.tables.Model;
+import edu.uci.ics.texera.dao.jooq.generated.tables.ModelUserAccess;
+import edu.uci.ics.texera.dao.jooq.generated.tables.ModelUserLikes;
+import edu.uci.ics.texera.dao.jooq.generated.tables.ModelVersion;
+import edu.uci.ics.texera.dao.jooq.generated.tables.ModelViewCount;
import edu.uci.ics.texera.dao.jooq.generated.tables.OperatorExecutions;
import edu.uci.ics.texera.dao.jooq.generated.tables.OperatorPortExecutions;
import edu.uci.ics.texera.dao.jooq.generated.tables.Project;
@@ -52,6 +57,11 @@ import edu.uci.ics.texera.dao.jooq.generated.tables.records.DatasetUserAccessRec
import edu.uci.ics.texera.dao.jooq.generated.tables.records.DatasetUserLikesRecord;
import edu.uci.ics.texera.dao.jooq.generated.tables.records.DatasetVersionRecord;
import edu.uci.ics.texera.dao.jooq.generated.tables.records.DatasetViewCountRecord;
+import edu.uci.ics.texera.dao.jooq.generated.tables.records.ModelRecord;
+import edu.uci.ics.texera.dao.jooq.generated.tables.records.ModelUserAccessRecord;
+import edu.uci.ics.texera.dao.jooq.generated.tables.records.ModelUserLikesRecord;
+import edu.uci.ics.texera.dao.jooq.generated.tables.records.ModelVersionRecord;
+import edu.uci.ics.texera.dao.jooq.generated.tables.records.ModelViewCountRecord;
import edu.uci.ics.texera.dao.jooq.generated.tables.records.OperatorExecutionsRecord;
import edu.uci.ics.texera.dao.jooq.generated.tables.records.OperatorPortExecutionsRecord;
import edu.uci.ics.texera.dao.jooq.generated.tables.records.ProjectRecord;
@@ -96,6 +106,11 @@ public class Keys {
public static final UniqueKey<DatasetUserLikesRecord> DATASET_USER_LIKES_PKEY = Internal.createUniqueKey(DatasetUserLikes.DATASET_USER_LIKES, DSL.name("dataset_user_likes_pkey"), new TableField[] { DatasetUserLikes.DATASET_USER_LIKES.UID, DatasetUserLikes.DATASET_USER_LIKES.DID }, true);
public static final UniqueKey<DatasetVersionRecord> DATASET_VERSION_PKEY = Internal.createUniqueKey(DatasetVersion.DATASET_VERSION, DSL.name("dataset_version_pkey"), new TableField[] { DatasetVersion.DATASET_VERSION.DVID }, true);
public static final UniqueKey<DatasetViewCountRecord> DATASET_VIEW_COUNT_PKEY = Internal.createUniqueKey(DatasetViewCount.DATASET_VIEW_COUNT, DSL.name("dataset_view_count_pkey"), new TableField[] { DatasetViewCount.DATASET_VIEW_COUNT.DID }, true);
+ public static final UniqueKey<ModelRecord> MODEL_PKEY = Internal.createUniqueKey(Model.MODEL, DSL.name("model_pkey"), new TableField[] { Model.MODEL.MID }, true);
+ public static final UniqueKey<ModelUserAccessRecord> MODEL_USER_ACCESS_PKEY = Internal.createUniqueKey(ModelUserAccess.MODEL_USER_ACCESS, DSL.name("model_user_access_pkey"), new TableField[] { ModelUserAccess.MODEL_USER_ACCESS.MID, ModelUserAccess.MODEL_USER_ACCESS.UID }, true);
+ public static final UniqueKey<ModelUserLikesRecord> MODEL_USER_LIKES_PKEY = Internal.createUniqueKey(ModelUserLikes.MODEL_USER_LIKES, DSL.name("model_user_likes_pkey"), new TableField[] { ModelUserLikes.MODEL_USER_LIKES.UID, ModelUserLikes.MODEL_USER_LIKES.MID }, true);
+ public static final UniqueKey<ModelVersionRecord> MODEL_VERSION_PKEY = Internal.createUniqueKey(ModelVersion.MODEL_VERSION, DSL.name("model_version_pkey"), new TableField[] { ModelVersion.MODEL_VERSION.MVID }, true);
+ public static final UniqueKey<ModelViewCountRecord> MODEL_VIEW_COUNT_PKEY = Internal.createUniqueKey(ModelViewCount.MODEL_VIEW_COUNT, DSL.name("model_view_count_pkey"), new TableField[] { ModelViewCount.MODEL_VIEW_COUNT.MID }, true);
public static final UniqueKey<OperatorExecutionsRecord> OPERATOR_EXECUTIONS_PKEY = Internal.createUniqueKey(OperatorExecutions.OPERATOR_EXECUTIONS, DSL.name("operator_executions_pkey"), new TableField[] { OperatorExecutions.OPERATOR_EXECUTIONS.WORKFLOW_EXECUTION_ID, OperatorExecutions.OPERATOR_EXECUTIONS.OPERATOR_ID }, true);
public static final UniqueKey<OperatorPortExecutionsRecord> OPERATOR_PORT_EXECUTIONS_PKEY = Internal.createUniqueKey(OperatorPortExecutions.OPERATOR_PORT_EXECUTIONS, DSL.name("operator_port_executions_pkey"), new TableField[] { OperatorPortExecutions.OPERATOR_PORT_EXECUTIONS.WORKFLOW_EXECUTION_ID, OperatorPortExecutions.OPERATOR_PORT_EXECUTIONS.GLOBAL_PORT_ID }, true);
public static final UniqueKey<ProjectRecord> PROJECT_OWNER_ID_NAME_KEY = Internal.createUniqueKey(Project.PROJECT, DSL.name("project_owner_id_name_key"), new TableField[] { Project.PROJECT.OWNER_ID, Project.PROJECT.NAME }, true);
@@ -132,6 +147,13 @@ public class Keys {
public static final ForeignKey<DatasetUserLikesRecord, UserRecord> DATASET_USER_LIKES__DATASET_USER_LIKES_UID_FKEY = Internal.createForeignKey(DatasetUserLikes.DATASET_USER_LIKES, DSL.name("dataset_user_likes_uid_fkey"), new TableField[] { DatasetUserLikes.DATASET_USER_LIKES.UID }, Keys.USER_PKEY, new TableField[] { User.USER.UID }, true);
public static final ForeignKey<DatasetVersionRecord, DatasetRecord> DATASET_VERSION__DATASET_VERSION_DID_FKEY = Internal.createForeignKey(DatasetVersion.DATASET_VERSION, DSL.name("dataset_version_did_fkey"), new TableField[] { DatasetVersion.DATASET_VERSION.DID }, Keys.DATASET_PKEY, new TableField[] { Dataset.DATASET.DID }, true);
public static final ForeignKey<DatasetViewCountRecord, DatasetRecord> DATASET_VIEW_COUNT__DATASET_VIEW_COUNT_DID_FKEY = Internal.createForeignKey(DatasetViewCount.DATASET_VIEW_COUNT, DSL.name("dataset_view_count_did_fkey"), new TableField[] { DatasetViewCount.DATASET_VIEW_COUNT.DID }, Keys.DATASET_PKEY, new TableField[] { Dataset.DATASET.DID }, true);
+ public static final ForeignKey<ModelRecord, UserRecord> MODEL__MODEL_OWNER_UID_FKEY = Internal.createForeignKey(Model.MODEL, DSL.name("model_owner_uid_fkey"), new TableField[] { Model.MODEL.OWNER_UID }, Keys.USER_PKEY, new TableField[] { User.USER.UID }, true);
+ public static final ForeignKey<ModelUserAccessRecord, ModelRecord> MODEL_USER_ACCESS__MODEL_USER_ACCESS_MID_FKEY = Internal.createForeignKey(ModelUserAccess.MODEL_USER_ACCESS, DSL.name("model_user_access_mid_fkey"), new TableField[] { ModelUserAccess.MODEL_USER_ACCESS.MID }, Keys.MODEL_PKEY, new TableField[] { Model.MODEL.MID }, true);
+ public static final ForeignKey<ModelUserAccessRecord, UserRecord> MODEL_USER_ACCESS__MODEL_USER_ACCESS_UID_FKEY = Internal.createForeignKey(ModelUserAccess.MODEL_USER_ACCESS, DSL.name("model_user_access_uid_fkey"), new TableField[] { ModelUserAccess.MODEL_USER_ACCESS.UID }, Keys.USER_PKEY, new TableField[] { User.USER.UID }, true);
+ public static final ForeignKey<ModelUserLikesRecord, ModelRecord> MODEL_USER_LIKES__MODEL_USER_LIKES_MID_FKEY = Internal.createForeignKey(ModelUserLikes.MODEL_USER_LIKES, DSL.name("model_user_likes_mid_fkey"), new TableField[] { ModelUserLikes.MODEL_USER_LIKES.MID }, Keys.MODEL_PKEY, new TableField[] { Model.MODEL.MID }, true);
+ public static final ForeignKey<ModelUserLikesRecord, UserRecord> MODEL_USER_LIKES__MODEL_USER_LIKES_UID_FKEY = Internal.createForeignKey(ModelUserLikes.MODEL_USER_LIKES, DSL.name("model_user_likes_uid_fkey"), new TableField[] { ModelUserLikes.MODEL_USER_LIKES.UID }, Keys.USER_PKEY, new TableField[] { User.USER.UID }, true);
+ public static final ForeignKey<ModelVersionRecord, ModelRecord> MODEL_VERSION__MODEL_VERSION_MID_FKEY = Internal.createForeignKey(ModelVersion.MODEL_VERSION, DSL.name("model_version_mid_fkey"), new TableField[] { ModelVersion.MODEL_VERSION.MID }, Keys.MODEL_PKEY, new TableField[] { Model.MODEL.MID }, true);
+ public static final ForeignKey<ModelViewCountRecord, ModelRecord> MODEL_VIEW_COUNT__MODEL_VIEW_COUNT_MID_FKEY = Internal.createForeignKey(ModelViewCount.MODEL_VIEW_COUNT, DSL.name("model_view_count_mid_fkey"), new TableField[] { ModelViewCount.MODEL_VIEW_COUNT.MID }, Keys.MODEL_PKEY, new TableField[] { Model.MODEL.MID }, true);
public static final ForeignKey<OperatorExecutionsRecord, WorkflowExecutionsRecord> OPERATOR_EXECUTIONS__OPERATOR_EXECUTIONS_WORKFLOW_EXECUTION_ID_FKEY = Internal.createForeignKey(OperatorExecutions.OPERATOR_EXECUTIONS, DSL.name("operator_executions_workflow_execution_id_fkey"), new TableField[] { OperatorExecutions.OPERATOR_EXECUTIONS.WORKFLOW_EXECUTION_ID }, Keys.WORKFLOW_EXECUTIONS_PKEY, new TableField[] { WorkflowExecutions.WORKFLOW_EXECUTIONS.EID }, true);
public static final ForeignKey<OperatorPortExecutionsRecord, WorkflowExecutionsRecord> OPERATOR_PORT_EXECUTIONS__OPERATOR_PORT_EXECUTIONS_WORKFLOW_EXECUTION_ID_FKEY = Internal.createForeignKey(OperatorPortExecutions.OPERATOR_PORT_EXECUTIONS, DSL.name("operator_port_executions_workflow_execution_id_fkey"), new TableField[] { OperatorPortExecutions.OPERATOR_PORT_EXECUTIONS.WORKFLOW_EXECUTION_ID }, Keys.WORKFLOW_EXECUTIONS_PKEY, new TableField[] { WorkflowExecutions.WORKFLOW_EXECUTIONS. [...]
public static final ForeignKey<ProjectRecord, UserRecord> PROJECT__PROJECT_OWNER_ID_FKEY = Internal.createForeignKey(Project.PROJECT, DSL.name("project_owner_id_fkey"), new TableField[] { Project.PROJECT.OWNER_ID }, Keys.USER_PKEY, new TableField[] { User.USER.UID }, true);
diff --git a/core/dao/src/main/scala/edu/uci/ics/texera/dao/jooq/generated/Tables.java b/core/dao/src/main/scala/edu/uci/ics/texera/dao/jooq/generated/Tables.java
index 39af6f8f8f..19024a784d 100644
--- a/core/dao/src/main/scala/edu/uci/ics/texera/dao/jooq/generated/Tables.java
+++ b/core/dao/src/main/scala/edu/uci/ics/texera/dao/jooq/generated/Tables.java
@@ -27,6 +27,11 @@ import edu.uci.ics.texera.dao.jooq.generated.tables.DatasetUserAccess;
import edu.uci.ics.texera.dao.jooq.generated.tables.DatasetUserLikes;
import edu.uci.ics.texera.dao.jooq.generated.tables.DatasetVersion;
import edu.uci.ics.texera.dao.jooq.generated.tables.DatasetViewCount;
+import edu.uci.ics.texera.dao.jooq.generated.tables.Model;
+import edu.uci.ics.texera.dao.jooq.generated.tables.ModelUserAccess;
+import edu.uci.ics.texera.dao.jooq.generated.tables.ModelUserLikes;
+import edu.uci.ics.texera.dao.jooq.generated.tables.ModelVersion;
+import edu.uci.ics.texera.dao.jooq.generated.tables.ModelViewCount;
import edu.uci.ics.texera.dao.jooq.generated.tables.OperatorExecutions;
import edu.uci.ics.texera.dao.jooq.generated.tables.OperatorPortExecutions;
import edu.uci.ics.texera.dao.jooq.generated.tables.Project;
@@ -85,6 +90,31 @@ public class Tables {
*/
public static final DatasetViewCount DATASET_VIEW_COUNT = DatasetViewCount.DATASET_VIEW_COUNT;
+ /**
+ * The table <code>texera_db.model</code>.
+ */
+ public static final Model MODEL = Model.MODEL;
+
+ /**
+ * The table <code>texera_db.model_user_access</code>.
+ */
+ public static final ModelUserAccess MODEL_USER_ACCESS = ModelUserAccess.MODEL_USER_ACCESS;
+
+ /**
+ * The table <code>texera_db.model_user_likes</code>.
+ */
+ public static final ModelUserLikes MODEL_USER_LIKES = ModelUserLikes.MODEL_USER_LIKES;
+
+ /**
+ * The table <code>texera_db.model_version</code>.
+ */
+ public static final ModelVersion MODEL_VERSION = ModelVersion.MODEL_VERSION;
+
+ /**
+ * The table <code>texera_db.model_view_count</code>.
+ */
+ public static final ModelViewCount MODEL_VIEW_COUNT = ModelViewCount.MODEL_VIEW_COUNT;
+
/**
* The table <code>texera_db.operator_executions</code>.
*/
diff --git a/core/dao/src/main/scala/edu/uci/ics/texera/dao/jooq/generated/TexeraDb.java b/core/dao/src/main/scala/edu/uci/ics/texera/dao/jooq/generated/TexeraDb.java
index 269aad41f3..dba2a1f0ec 100644
--- a/core/dao/src/main/scala/edu/uci/ics/texera/dao/jooq/generated/TexeraDb.java
+++ b/core/dao/src/main/scala/edu/uci/ics/texera/dao/jooq/generated/TexeraDb.java
@@ -27,6 +27,11 @@ import edu.uci.ics.texera.dao.jooq.generated.tables.DatasetUserAccess;
import edu.uci.ics.texera.dao.jooq.generated.tables.DatasetUserLikes;
import edu.uci.ics.texera.dao.jooq.generated.tables.DatasetVersion;
import edu.uci.ics.texera.dao.jooq.generated.tables.DatasetViewCount;
+import edu.uci.ics.texera.dao.jooq.generated.tables.Model;
+import edu.uci.ics.texera.dao.jooq.generated.tables.ModelUserAccess;
+import edu.uci.ics.texera.dao.jooq.generated.tables.ModelUserLikes;
+import edu.uci.ics.texera.dao.jooq.generated.tables.ModelVersion;
+import edu.uci.ics.texera.dao.jooq.generated.tables.ModelViewCount;
import edu.uci.ics.texera.dao.jooq.generated.tables.OperatorExecutions;
import edu.uci.ics.texera.dao.jooq.generated.tables.OperatorPortExecutions;
import edu.uci.ics.texera.dao.jooq.generated.tables.Project;
@@ -99,6 +104,31 @@ public class TexeraDb extends SchemaImpl {
*/
public final DatasetViewCount DATASET_VIEW_COUNT = DatasetViewCount.DATASET_VIEW_COUNT;
+ /**
+ * The table <code>texera_db.model</code>.
+ */
+ public final Model MODEL = Model.MODEL;
+
+ /**
+ * The table <code>texera_db.model_user_access</code>.
+ */
+ public final ModelUserAccess MODEL_USER_ACCESS = ModelUserAccess.MODEL_USER_ACCESS;
+
+ /**
+ * The table <code>texera_db.model_user_likes</code>.
+ */
+ public final ModelUserLikes MODEL_USER_LIKES = ModelUserLikes.MODEL_USER_LIKES;
+
+ /**
+ * The table <code>texera_db.model_version</code>.
+ */
+ public final ModelVersion MODEL_VERSION = ModelVersion.MODEL_VERSION;
+
+ /**
+ * The table <code>texera_db.model_view_count</code>.
+ */
+ public final ModelViewCount MODEL_VIEW_COUNT = ModelViewCount.MODEL_VIEW_COUNT;
+
/**
* The table <code>texera_db.operator_executions</code>.
*/
@@ -221,6 +251,11 @@ public class TexeraDb extends SchemaImpl {
DatasetUserLikes.DATASET_USER_LIKES,
DatasetVersion.DATASET_VERSION,
DatasetViewCount.DATASET_VIEW_COUNT,
+ Model.MODEL,
+ ModelUserAccess.MODEL_USER_ACCESS,
+ ModelUserLikes.MODEL_USER_LIKES,
+ ModelVersion.MODEL_VERSION,
+ ModelViewCount.MODEL_VIEW_COUNT,
OperatorExecutions.OPERATOR_EXECUTIONS,
OperatorPortExecutions.OPERATOR_PORT_EXECUTIONS,
Project.PROJECT,
diff --git a/core/dao/src/main/scala/edu/uci/ics/texera/dao/jooq/generated/tables/Dataset.java b/core/dao/src/main/scala/edu/uci/ics/texera/dao/jooq/generated/tables/Dataset.java
index fe6283a03f..084562e0fa 100644
--- a/core/dao/src/main/scala/edu/uci/ics/texera/dao/jooq/generated/tables/Dataset.java
+++ b/core/dao/src/main/scala/edu/uci/ics/texera/dao/jooq/generated/tables/Dataset.java
@@ -86,6 +86,11 @@ public class Dataset extends TableImpl<DatasetRecord> {
*/
public final TableField<DatasetRecord, Boolean> IS_PUBLIC = createField(DSL.name("is_public"), SQLDataType.BOOLEAN.nullable(false).defaultValue(DSL.field("true", SQLDataType.BOOLEAN)), this, "");
+ /**
+ * The column <code>texera_db.dataset.is_downloadable</code>.
+ */
+ public final TableField<DatasetRecord, Boolean> IS_DOWNLOADABLE = createField(DSL.name("is_downloadable"), SQLDataType.BOOLEAN.nullable(false).defaultValue(DSL.field("true", SQLDataType.BOOLEAN)), this, "");
+
/**
* The column <code>texera_db.dataset.description</code>.
*/
@@ -96,11 +101,6 @@ public class Dataset extends TableImpl<DatasetRecord> {
*/
public final TableField<DatasetRecord, Timestamp> CREATION_TIME = createField(DSL.name("creation_time"), SQLDataType.TIMESTAMP(0).nullable(false).defaultValue(DSL.field("CURRENT_TIMESTAMP", SQLDataType.TIMESTAMP)), this, "");
- /**
- * The column <code>texera_db.dataset.is_downloadable</code>.
- */
- public final TableField<DatasetRecord, Boolean> IS_DOWNLOADABLE = createField(DSL.name("is_downloadable"), SQLDataType.BOOLEAN.nullable(false).defaultValue(DSL.field("true", SQLDataType.BOOLEAN)), this, "");
-
private Dataset(Name alias, Table<DatasetRecord> aliased) {
this(alias, aliased, null);
}
@@ -197,7 +197,7 @@ public class Dataset extends TableImpl<DatasetRecord> {
// -------------------------------------------------------------------------
@Override
- public Row7<Integer, Integer, String, Boolean, String, Timestamp, Boolean> fieldsRow() {
+ public Row7<Integer, Integer, String, Boolean, Boolean, String, Timestamp> fieldsRow() {
return (Row7) super.fieldsRow();
}
}
diff --git a/core/dao/src/main/scala/edu/uci/ics/texera/dao/jooq/generated/tables/daos/DatasetDao.java b/core/dao/src/main/scala/edu/uci/ics/texera/dao/jooq/generated/tables/daos/DatasetDao.java
index 2a03a24415..124c88df3b 100644
--- a/core/dao/src/main/scala/edu/uci/ics/texera/dao/jooq/generated/tables/daos/DatasetDao.java
+++ b/core/dao/src/main/scala/edu/uci/ics/texera/dao/jooq/generated/tables/daos/DatasetDao.java
@@ -131,6 +131,21 @@ public class DatasetDao extends DAOImpl<DatasetRecord, edu.uci.ics.texera.dao.jo
return fetch(Dataset.DATASET.IS_PUBLIC, values);
}
+ /**
+ * Fetch records that have <code>is_downloadable BETWEEN lowerInclusive AND
+ * upperInclusive</code>
+ */
+ public List<edu.uci.ics.texera.dao.jooq.generated.tables.pojos.Dataset> fetchRangeOfIsDownloadable(Boolean lowerInclusive, Boolean upperInclusive) {
+ return fetchRange(Dataset.DATASET.IS_DOWNLOADABLE, lowerInclusive, upperInclusive);
+ }
+
+ /**
+ * Fetch records that have <code>is_downloadable IN (values)</code>
+ */
+ public List<edu.uci.ics.texera.dao.jooq.generated.tables.pojos.Dataset> fetchByIsDownloadable(Boolean... values) {
+ return fetch(Dataset.DATASET.IS_DOWNLOADABLE, values);
+ }
+
/**
* Fetch records that have <code>description BETWEEN lowerInclusive AND
* upperInclusive</code>
@@ -160,19 +175,4 @@ public class DatasetDao extends DAOImpl<DatasetRecord, edu.uci.ics.texera.dao.jo
public List<edu.uci.ics.texera.dao.jooq.generated.tables.pojos.Dataset> fetchByCreationTime(Timestamp... values) {
return fetch(Dataset.DATASET.CREATION_TIME, values);
}
-
- /**
- * Fetch records that have <code>is_downloadable BETWEEN lowerInclusive AND
- * upperInclusive</code>
- */
- public List<edu.uci.ics.texera.dao.jooq.generated.tables.pojos.Dataset> fetchRangeOfIsDownloadable(Boolean lowerInclusive, Boolean upperInclusive) {
- return fetchRange(Dataset.DATASET.IS_DOWNLOADABLE, lowerInclusive, upperInclusive);
- }
-
- /**
- * Fetch records that have <code>is_downloadable IN (values)</code>
- */
- public List<edu.uci.ics.texera.dao.jooq.generated.tables.pojos.Dataset> fetchByIsDownloadable(Boolean... values) {
- return fetch(Dataset.DATASET.IS_DOWNLOADABLE, values);
- }
}
diff --git a/core/dao/src/main/scala/edu/uci/ics/texera/dao/jooq/generated/tables/interfaces/IDataset.java b/core/dao/src/main/scala/edu/uci/ics/texera/dao/jooq/generated/tables/interfaces/IDataset.java
index cf07efa094..3396aefbc2 100644
--- a/core/dao/src/main/scala/edu/uci/ics/texera/dao/jooq/generated/tables/interfaces/IDataset.java
+++ b/core/dao/src/main/scala/edu/uci/ics/texera/dao/jooq/generated/tables/interfaces/IDataset.java
@@ -71,6 +71,16 @@ public interface IDataset extends Serializable {
*/
public Boolean getIsPublic();
+ /**
+ * Setter for <code>texera_db.dataset.is_downloadable</code>.
+ */
+ public void setIsDownloadable(Boolean value);
+
+ /**
+ * Getter for <code>texera_db.dataset.is_downloadable</code>.
+ */
+ public Boolean getIsDownloadable();
+
/**
* Setter for <code>texera_db.dataset.description</code>.
*/
@@ -91,16 +101,6 @@ public interface IDataset extends Serializable {
*/
public Timestamp getCreationTime();
- /**
- * Setter for <code>texera_db.dataset.is_downloadable</code>.
- */
- public void setIsDownloadable(Boolean value);
-
- /**
- * Getter for <code>texera_db.dataset.is_downloadable</code>.
- */
- public Boolean getIsDownloadable();
-
// -------------------------------------------------------------------------
// FROM and INTO
// -------------------------------------------------------------------------
diff --git a/core/dao/src/main/scala/edu/uci/ics/texera/dao/jooq/generated/tables/pojos/Dataset.java b/core/dao/src/main/scala/edu/uci/ics/texera/dao/jooq/generated/tables/pojos/Dataset.java
index 15be7dace4..ac2e72561e 100644
--- a/core/dao/src/main/scala/edu/uci/ics/texera/dao/jooq/generated/tables/pojos/Dataset.java
+++ b/core/dao/src/main/scala/edu/uci/ics/texera/dao/jooq/generated/tables/pojos/Dataset.java
@@ -38,9 +38,9 @@ public class Dataset implements IDataset {
private Integer ownerUid;
private String name;
private Boolean isPublic;
+ private Boolean isDownloadable;
private String description;
private Timestamp creationTime;
- private Boolean isDownloadable;
public Dataset() {}
@@ -49,9 +49,9 @@ public class Dataset implements IDataset {
this.ownerUid = value.getOwnerUid();
this.name = value.getName();
this.isPublic = value.getIsPublic();
+ this.isDownloadable = value.getIsDownloadable();
this.description = value.getDescription();
this.creationTime = value.getCreationTime();
- this.isDownloadable = value.getIsDownloadable();
}
public Dataset(
@@ -59,17 +59,17 @@ public class Dataset implements IDataset {
Integer ownerUid,
String name,
Boolean isPublic,
+ Boolean isDownloadable,
String description,
- Timestamp creationTime,
- Boolean isDownloadable
+ Timestamp creationTime
) {
this.did = did;
this.ownerUid = ownerUid;
this.name = name;
this.isPublic = isPublic;
+ this.isDownloadable = isDownloadable;
this.description = description;
this.creationTime = creationTime;
- this.isDownloadable = isDownloadable;
}
/**
@@ -136,6 +136,22 @@ public class Dataset implements IDataset {
this.isPublic = isPublic;
}
+ /**
+ * Getter for <code>texera_db.dataset.is_downloadable</code>.
+ */
+ @Override
+ public Boolean getIsDownloadable() {
+ return this.isDownloadable;
+ }
+
+ /**
+ * Setter for <code>texera_db.dataset.is_downloadable</code>.
+ */
+ @Override
+ public void setIsDownloadable(Boolean isDownloadable) {
+ this.isDownloadable = isDownloadable;
+ }
+
/**
* Getter for <code>texera_db.dataset.description</code>.
*/
@@ -168,22 +184,6 @@ public class Dataset implements IDataset {
this.creationTime = creationTime;
}
- /**
- * Getter for <code>texera_db.dataset.is_downloadable</code>.
- */
- @Override
- public Boolean getIsDownloadable() {
- return this.isDownloadable;
- }
-
- /**
- * Setter for <code>texera_db.dataset.is_downloadable</code>.
- */
- @Override
- public void setIsDownloadable(Boolean isDownloadable) {
- this.isDownloadable = isDownloadable;
- }
-
@Override
public String toString() {
StringBuilder sb = new StringBuilder("Dataset (");
@@ -192,9 +192,9 @@ public class Dataset implements IDataset {
sb.append(", ").append(ownerUid);
sb.append(", ").append(name);
sb.append(", ").append(isPublic);
+ sb.append(", ").append(isDownloadable);
sb.append(", ").append(description);
sb.append(", ").append(creationTime);
- sb.append(", ").append(isDownloadable);
sb.append(")");
return sb.toString();
@@ -210,9 +210,9 @@ public class Dataset implements IDataset {
setOwnerUid(from.getOwnerUid());
setName(from.getName());
setIsPublic(from.getIsPublic());
+ setIsDownloadable(from.getIsDownloadable());
setDescription(from.getDescription());
setCreationTime(from.getCreationTime());
- setIsDownloadable(from.getIsDownloadable());
}
@Override
diff --git a/core/dao/src/main/scala/edu/uci/ics/texera/dao/jooq/generated/tables/records/DatasetRecord.java b/core/dao/src/main/scala/edu/uci/ics/texera/dao/jooq/generated/tables/records/DatasetRecord.java
index 7070836def..d451864bab 100644
--- a/core/dao/src/main/scala/edu/uci/ics/texera/dao/jooq/generated/tables/records/DatasetRecord.java
+++ b/core/dao/src/main/scala/edu/uci/ics/texera/dao/jooq/generated/tables/records/DatasetRecord.java
@@ -37,7 +37,7 @@ import org.jooq.impl.UpdatableRecordImpl;
* This class is generated by jOOQ.
*/
@SuppressWarnings({ "all", "unchecked", "rawtypes" })
-public class DatasetRecord extends UpdatableRecordImpl<DatasetRecord> implements Record7<Integer, Integer, String, Boolean, String, Timestamp, Boolean>, IDataset {
+public class DatasetRecord extends UpdatableRecordImpl<DatasetRecord> implements Record7<Integer, Integer, String, Boolean, Boolean, String, Timestamp>, IDataset {
private static final long serialVersionUID = 1L;
@@ -106,51 +106,51 @@ public class DatasetRecord extends UpdatableRecordImpl<DatasetRecord> implements
}
/**
- * Setter for <code>texera_db.dataset.description</code>.
+ * Setter for <code>texera_db.dataset.is_downloadable</code>.
*/
@Override
- public void setDescription(String value) {
+ public void setIsDownloadable(Boolean value) {
set(4, value);
}
/**
- * Getter for <code>texera_db.dataset.description</code>.
+ * Getter for <code>texera_db.dataset.is_downloadable</code>.
*/
@Override
- public String getDescription() {
- return (String) get(4);
+ public Boolean getIsDownloadable() {
+ return (Boolean) get(4);
}
/**
- * Setter for <code>texera_db.dataset.creation_time</code>.
+ * Setter for <code>texera_db.dataset.description</code>.
*/
@Override
- public void setCreationTime(Timestamp value) {
+ public void setDescription(String value) {
set(5, value);
}
/**
- * Getter for <code>texera_db.dataset.creation_time</code>.
+ * Getter for <code>texera_db.dataset.description</code>.
*/
@Override
- public Timestamp getCreationTime() {
- return (Timestamp) get(5);
+ public String getDescription() {
+ return (String) get(5);
}
/**
- * Setter for <code>texera_db.dataset.is_downloadable</code>.
+ * Setter for <code>texera_db.dataset.creation_time</code>.
*/
@Override
- public void setIsDownloadable(Boolean value) {
+ public void setCreationTime(Timestamp value) {
set(6, value);
}
/**
- * Getter for <code>texera_db.dataset.is_downloadable</code>.
+ * Getter for <code>texera_db.dataset.creation_time</code>.
*/
@Override
- public Boolean getIsDownloadable() {
- return (Boolean) get(6);
+ public Timestamp getCreationTime() {
+ return (Timestamp) get(6);
}
// -------------------------------------------------------------------------
@@ -167,12 +167,12 @@ public class DatasetRecord extends UpdatableRecordImpl<DatasetRecord> implements
// -------------------------------------------------------------------------
@Override
- public Row7<Integer, Integer, String, Boolean, String, Timestamp, Boolean> fieldsRow() {
+ public Row7<Integer, Integer, String, Boolean, Boolean, String, Timestamp> fieldsRow() {
return (Row7) super.fieldsRow();
}
@Override
- public Row7<Integer, Integer, String, Boolean, String, Timestamp, Boolean> valuesRow() {
+ public Row7<Integer, Integer, String, Boolean, Boolean, String, Timestamp> valuesRow() {
return (Row7) super.valuesRow();
}
@@ -197,18 +197,18 @@ public class DatasetRecord extends UpdatableRecordImpl<DatasetRecord> implements
}
@Override
- public Field<String> field5() {
- return Dataset.DATASET.DESCRIPTION;
+ public Field<Boolean> field5() {
+ return Dataset.DATASET.IS_DOWNLOADABLE;
}
@Override
- public Field<Timestamp> field6() {
- return Dataset.DATASET.CREATION_TIME;
+ public Field<String> field6() {
+ return Dataset.DATASET.DESCRIPTION;
}
@Override
- public Field<Boolean> field7() {
- return Dataset.DATASET.IS_DOWNLOADABLE;
+ public Field<Timestamp> field7() {
+ return Dataset.DATASET.CREATION_TIME;
}
@Override
@@ -232,18 +232,18 @@ public class DatasetRecord extends UpdatableRecordImpl<DatasetRecord> implements
}
@Override
- public String component5() {
- return getDescription();
+ public Boolean component5() {
+ return getIsDownloadable();
}
@Override
- public Timestamp component6() {
- return getCreationTime();
+ public String component6() {
+ return getDescription();
}
@Override
- public Boolean component7() {
- return getIsDownloadable();
+ public Timestamp component7() {
+ return getCreationTime();
}
@Override
@@ -267,18 +267,18 @@ public class DatasetRecord extends UpdatableRecordImpl<DatasetRecord> implements
}
@Override
- public String value5() {
- return getDescription();
+ public Boolean value5() {
+ return getIsDownloadable();
}
@Override
- public Timestamp value6() {
- return getCreationTime();
+ public String value6() {
+ return getDescription();
}
@Override
- public Boolean value7() {
- return getIsDownloadable();
+ public Timestamp value7() {
+ return getCreationTime();
}
@Override
@@ -306,25 +306,25 @@ public class DatasetRecord extends UpdatableRecordImpl<DatasetRecord> implements
}
@Override
- public DatasetRecord value5(String value) {
- setDescription(value);
+ public DatasetRecord value5(Boolean value) {
+ setIsDownloadable(value);
return this;
}
@Override
- public DatasetRecord value6(Timestamp value) {
- setCreationTime(value);
+ public DatasetRecord value6(String value) {
+ setDescription(value);
return this;
}
@Override
- public DatasetRecord value7(Boolean value) {
- setIsDownloadable(value);
+ public DatasetRecord value7(Timestamp value) {
+ setCreationTime(value);
return this;
}
@Override
- public DatasetRecord values(Integer value1, Integer value2, String value3, Boolean value4, String value5, Timestamp value6, Boolean value7) {
+ public DatasetRecord values(Integer value1, Integer value2, String value3, Boolean value4, Boolean value5, String value6, Timestamp value7) {
value1(value1);
value2(value2);
value3(value3);
@@ -345,9 +345,9 @@ public class DatasetRecord extends UpdatableRecordImpl<DatasetRecord> implements
setOwnerUid(from.getOwnerUid());
setName(from.getName());
setIsPublic(from.getIsPublic());
+ setIsDownloadable(from.getIsDownloadable());
setDescription(from.getDescription());
setCreationTime(from.getCreationTime());
- setIsDownloadable(from.getIsDownloadable());
}
@Override
@@ -370,16 +370,16 @@ public class DatasetRecord extends UpdatableRecordImpl<DatasetRecord> implements
/**
* Create a detached, initialised DatasetRecord
*/
- public DatasetRecord(Integer did, Integer ownerUid, String name, Boolean isPublic, String description, Timestamp creationTime, Boolean isDownloadable) {
+ public DatasetRecord(Integer did, Integer ownerUid, String name, Boolean isPublic, Boolean isDownloadable, String description, Timestamp creationTime) {
super(Dataset.DATASET);
setDid(did);
setOwnerUid(ownerUid);
setName(name);
setIsPublic(isPublic);
+ setIsDownloadable(isDownloadable);
setDescription(description);
setCreationTime(creationTime);
- setIsDownloadable(isDownloadable);
}
/**
@@ -393,9 +393,9 @@ public class DatasetRecord extends UpdatableRecordImpl<DatasetRecord> implements
setOwnerUid(value.getOwnerUid());
setName(value.getName());
setIsPublic(value.getIsPublic());
+ setIsDownloadable(value.getIsDownloadable());
setDescription(value.getDescription());
setCreationTime(value.getCreationTime());
- setIsDownloadable(value.getIsDownloadable());
}
}
}
diff --git a/core/file-service/src/main/scala/edu/uci/ics/texera/service/FileService.scala b/core/file-service/src/main/scala/edu/uci/ics/texera/service/FileService.scala
index 02c2b3fc84..317e71e0c8 100644
--- a/core/file-service/src/main/scala/edu/uci/ics/texera/service/FileService.scala
+++ b/core/file-service/src/main/scala/edu/uci/ics/texera/service/FileService.scala
@@ -31,11 +31,7 @@ import edu.uci.ics.texera.auth.{JwtAuthFilter, SessionUser}
import edu.uci.ics.texera.dao.SqlServer
import edu.uci.ics.texera.service.`type`.DatasetFileNode
import edu.uci.ics.texera.service.`type`.serde.DatasetFileNodeSerializer
-import edu.uci.ics.texera.service.resource.{
- DatasetAccessResource,
- DatasetResource,
- HealthCheckResource
-}
+import edu.uci.ics.texera.service.resource.{DatasetAccessResource, DatasetResource, HealthCheckResource, ModelAccessResource, ModelResource}
import edu.uci.ics.texera.service.util.S3StorageClient
import io.dropwizard.auth.AuthDynamicFeature
import org.eclipse.jetty.server.session.SessionHandler
@@ -80,6 +76,8 @@ class FileService extends Application[FileServiceConfiguration] with LazyLogging
environment.jersey.register(classOf[DatasetResource])
environment.jersey.register(classOf[DatasetAccessResource])
+ environment.jersey.register(classOf[ModelResource])
+ environment.jersey.register(classOf[ModelAccessResource])
}
}
diff --git a/core/file-service/src/main/scala/edu/uci/ics/texera/service/resource/DatasetResource.scala b/core/file-service/src/main/scala/edu/uci/ics/texera/service/resource/DatasetResource.scala
index a551b79900..ff2502838f 100644
--- a/core/file-service/src/main/scala/edu/uci/ics/texera/service/resource/DatasetResource.scala
+++ b/core/file-service/src/main/scala/edu/uci/ics/texera/service/resource/DatasetResource.scala
@@ -185,8 +185,8 @@ object DatasetResource {
case class DatasetDescriptionModification(did: Integer, description: String)
case class DatasetVersionRootFileNodesResponse(
- fileNodes: List[DatasetFileNode],
- size: Long
+ fileNodes: List[DatasetFileNode],
+ size: Long
)
}
@@ -289,9 +289,9 @@ class DatasetResource {
createdDataset.getOwnerUid,
createdDataset.getName,
createdDataset.getIsPublic,
+ createdDataset.getIsDownloadable,
createdDataset.getDescription,
- createdDataset.getCreationTime,
- createdDataset.getIsDownloadable
+ createdDataset.getCreationTime
),
user.getEmail,
PrivilegeEnum.WRITE,
diff --git a/core/file-service/src/main/scala/edu/uci/ics/texera/service/resource/ModelAccessResource.scala b/core/file-service/src/main/scala/edu/uci/ics/texera/service/resource/ModelAccessResource.scala
new file mode 100644
index 0000000000..e62d2d4cea
--- /dev/null
+++ b/core/file-service/src/main/scala/edu/uci/ics/texera/service/resource/ModelAccessResource.scala
@@ -0,0 +1,221 @@
+/*
+ * 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 edu.uci.ics.texera.auth.SessionUser
+import edu.uci.ics.texera.dao.SqlServer
+import edu.uci.ics.texera.dao.SqlServer.withTransaction
+import edu.uci.ics.texera.dao.jooq.generated.Tables.{MODEL_USER_ACCESS, USER}
+import edu.uci.ics.texera.dao.jooq.generated.enums.PrivilegeEnum
+import edu.uci.ics.texera.dao.jooq.generated.tables.daos.{ DatasetUserAccessDao, ModelDao, UserDao}
+import edu.uci.ics.texera.dao.jooq.generated.tables.pojos.{DatasetUserAccess, User}
+import edu.uci.ics.texera.service.resource.ModelAccessResource.{AccessEntry, context, getOwner, userHasWriteAccess}
+import io.dropwizard.auth.Auth
+import jakarta.annotation.security.RolesAllowed
+import jakarta.ws.rs.core.{MediaType, Response}
+import jakarta.ws.rs._
+import org.jooq.{DSLContext, EnumType}
+
+import javax.ws.rs.ForbiddenException
+
+object ModelAccessResource {
+ private lazy val context: DSLContext = SqlServer
+ .getInstance()
+ .createDSLContext()
+
+ def isDatasetPublic(ctx: DSLContext, mid: Integer): Boolean = {
+ val modelDao = new ModelDao(ctx.configuration())
+ Option(modelDao.fetchOneByMid(mid))
+ .flatMap(dataset => Option(dataset.getIsPublic))
+ .contains(true)
+ }
+
+ def userHasReadAccess(ctx: DSLContext, mid: Integer, uid: Integer): Boolean = {
+ isDatasetPublic(ctx, mid) ||
+ userHasWriteAccess(ctx, mid, uid) ||
+ getModelUserAccessPrivilege(ctx, mid, uid) == PrivilegeEnum.READ
+ }
+
+ def userOwnDataset(ctx: DSLContext, mid: Integer, uid: Integer): Boolean = {
+ val modelDao = new ModelDao(ctx.configuration())
+
+ Option(modelDao.fetchOneByMid(mid))
+ .exists(_.getOwnerUid == uid)
+ }
+
+ def userHasWriteAccess(ctx: DSLContext, mid: Integer, uid: Integer): Boolean = {
+ userOwnDataset(ctx, mid, uid) ||
+ getModelUserAccessPrivilege(ctx, mid, uid) == PrivilegeEnum.WRITE
+ }
+
+ def getModelUserAccessPrivilege(
+ ctx: DSLContext,
+ mid: Integer,
+ uid: Integer
+ ): PrivilegeEnum = {
+ Option(
+ ctx
+ .select(MODEL_USER_ACCESS.PRIVILEGE)
+ .from(MODEL_USER_ACCESS)
+ .where(
+ MODEL_USER_ACCESS.MID
+ .eq(mid)
+ .and(MODEL_USER_ACCESS.UID.eq(uid))
+ )
+ .fetchOneInto(classOf[PrivilegeEnum])
+ ).getOrElse(PrivilegeEnum.NONE)
+ }
+
+ def getOwner(ctx: DSLContext, mid: Integer): User = {
+ val modelDao = new ModelDao(ctx.configuration())
+ val userDao = new UserDao(ctx.configuration())
+
+ Option(modelDao.fetchOneByMid(mid))
+ .flatMap(dataset => Option(dataset.getOwnerUid))
+ .map(ownerUid => userDao.fetchOneByUid(ownerUid))
+ .orNull
+ }
+
+ case class AccessEntry(email: String, name: String, privilege: EnumType) {}
+
+}
+
+@Produces(Array(MediaType.APPLICATION_JSON))
+@RolesAllowed(Array("REGULAR", "ADMIN"))
+@Path("/access/model")
+class ModelAccessResource {
+
+ /**
+ * This method returns the owner of a dataset
+ *
+ * @param mid , dataset id
+ * @return ownerEmail, the owner's email
+ */
+ @GET
+ @Path("/owner/{mid}")
+ def getOwnerEmailOfDataset(@PathParam("mid") mid: Integer): String = {
+ var email = ""
+ withTransaction(context) { ctx =>
+ val owner = getOwner(ctx, mid)
+ if (owner != null) {
+ email = owner.getEmail
+ }
+ }
+ email
+ }
+
+ /**
+ * Returns information about all current shared access of the given dataset
+ *
+ * @param mid model id
+ * @return a List of email/name/permission
+ */
+ @GET
+ @Path("/list/{mid}")
+ def getAccessList(
+ @PathParam("mid") mid: Integer
+ ): java.util.List[AccessEntry] = {
+ withTransaction(context) { ctx =>
+ val modelDao = new ModelDao(ctx.configuration())
+ ctx
+ .select(
+ USER.EMAIL,
+ USER.NAME,
+ MODEL_USER_ACCESS.PRIVILEGE
+ )
+ .from(MODEL_USER_ACCESS)
+ .join(USER)
+ .on(USER.UID.eq(MODEL_USER_ACCESS.UID))
+ .where(
+ MODEL_USER_ACCESS.MID
+ .eq(mid)
+ .and(MODEL_USER_ACCESS.UID.notEqual(modelDao.fetchOneByMid(mid).getOwnerUid))
+ )
+ .fetchInto(classOf[AccessEntry])
+ }
+ }
+
+ /**
+ * This method shares a dataset to a user with a specific access type
+ *
+ * @param mid the given dataset
+ * @param email the email which the access is given to
+ * @param privilege the type of Access given to the target user
+ * @return rejection if user not permitted to share the workflow or Success Message
+ */
+ @PUT
+ @Path("/grant/{mid}/{email}/{privilege}")
+ def grantAccess(
+ @PathParam("mid") mid: Integer,
+ @PathParam("email") email: String,
+ @PathParam("privilege") privilege: String,
+ @Auth user: SessionUser
+ ): Response = {
+ withTransaction(context) { ctx =>
+ if (!userHasWriteAccess(ctx, mid, user.getUid)) {
+ throw new ForbiddenException(s"You do not have permission to modify dataset $mid")
+ }
+ val datasetUserAccessDao = new DatasetUserAccessDao(ctx.configuration())
+ val userDao = new UserDao(ctx.configuration())
+ datasetUserAccessDao.merge(
+ new DatasetUserAccess(
+ mid,
+ userDao.fetchOneByEmail(email).getUid,
+ PrivilegeEnum.valueOf(privilege)
+ )
+ )
+ Response.ok().build()
+ }
+ }
+
+ /**
+ * This method revoke the user's access of the given dataset
+ *
+ * @param mid the given dataset
+ * @param email the email of the use whose access is about to be removed
+ * @return message indicating a success message
+ */
+ @DELETE
+ @Path("/revoke/{mid}/{email}")
+ def revokeAccess(
+ @PathParam("mid") mid: Integer,
+ @PathParam("email") email: String,
+ @Auth user: SessionUser
+ ): Response = {
+ withTransaction(context) { ctx =>
+ if (!userHasWriteAccess(ctx, mid, user.getUid)) {
+ throw new ForbiddenException(s"You do not have permission to modify dataset $mid")
+ }
+
+ val userDao = new UserDao(ctx.configuration())
+
+ ctx
+ .delete(MODEL_USER_ACCESS)
+ .where(
+ MODEL_USER_ACCESS.UID
+ .eq(userDao.fetchOneByEmail(email).getUid)
+ .and(MODEL_USER_ACCESS.MID.eq(mid))
+ )
+ .execute()
+
+ Response.ok().build()
+ }
+ }
+}
diff --git a/core/file-service/src/main/scala/edu/uci/ics/texera/service/resource/DatasetResource.scala b/core/file-service/src/main/scala/edu/uci/ics/texera/service/resource/ModelResource.scala
similarity index 59%
copy from core/file-service/src/main/scala/edu/uci/ics/texera/service/resource/DatasetResource.scala
copy to core/file-service/src/main/scala/edu/uci/ics/texera/service/resource/ModelResource.scala
index a551b79900..7493796a60 100644
--- a/core/file-service/src/main/scala/edu/uci/ics/texera/service/resource/DatasetResource.scala
+++ b/core/file-service/src/main/scala/edu/uci/ics/texera/service/resource/ModelResource.scala
@@ -27,80 +27,50 @@ import edu.uci.ics.texera.auth.SessionUser
import edu.uci.ics.texera.dao.SqlServer
import edu.uci.ics.texera.dao.SqlServer.withTransaction
import edu.uci.ics.texera.dao.jooq.generated.enums.PrivilegeEnum
+import edu.uci.ics.texera.dao.jooq.generated.tables.Model.MODEL
+import edu.uci.ics.texera.dao.jooq.generated.tables.ModelUserAccess.MODEL_USER_ACCESS
+import edu.uci.ics.texera.dao.jooq.generated.tables.ModelVersion.MODEL_VERSION
import edu.uci.ics.texera.dao.jooq.generated.tables.User.USER
-import edu.uci.ics.texera.dao.jooq.generated.tables.Dataset.DATASET
-import edu.uci.ics.texera.dao.jooq.generated.tables.DatasetUserAccess.DATASET_USER_ACCESS
-import edu.uci.ics.texera.dao.jooq.generated.tables.DatasetVersion.DATASET_VERSION
-import edu.uci.ics.texera.dao.jooq.generated.tables.daos.{
- DatasetDao,
- DatasetUserAccessDao,
- DatasetVersionDao
-}
-import edu.uci.ics.texera.dao.jooq.generated.tables.pojos.{
- Dataset,
- DatasetUserAccess,
- DatasetVersion
-}
+import edu.uci.ics.texera.dao.jooq.generated.tables.daos.{ModelDao, ModelUserAccessDao, ModelVersionDao}
+import edu.uci.ics.texera.dao.jooq.generated.tables.pojos.{Model, ModelUserAccess, ModelVersion}
import edu.uci.ics.texera.service.`type`.DatasetFileNode
-import edu.uci.ics.texera.service.resource.DatasetAccessResource.{
- getDatasetUserAccessPrivilege,
- getOwner,
- isDatasetPublic,
- userHasReadAccess,
- userHasWriteAccess,
- userOwnDataset
-}
-import edu.uci.ics.texera.service.resource.DatasetResource.{
- CreateDatasetRequest,
- DashboardDataset,
- DashboardDatasetVersion,
- DatasetDescriptionModification,
- DatasetVersionRootFileNodesResponse,
- Diff,
- context,
- getDatasetByID,
- getDatasetVersionByID,
- getLatestDatasetVersion,
- put
-}
+import edu.uci.ics.texera.service.resource.ModelAccessResource._
+import edu.uci.ics.texera.service.resource.ModelResource.{context, _}
import edu.uci.ics.texera.service.util.S3StorageClient
-import edu.uci.ics.texera.service.util.S3StorageClient.{
- MAXIMUM_NUM_OF_MULTIPART_S3_PARTS,
- MINIMUM_NUM_OF_MULTIPART_S3_PART
-}
+import edu.uci.ics.texera.service.util.S3StorageClient.{MAXIMUM_NUM_OF_MULTIPART_S3_PARTS, MINIMUM_NUM_OF_MULTIPART_S3_PART}
import io.dropwizard.auth.Auth
import jakarta.annotation.security.RolesAllowed
import jakarta.ws.rs._
-import jakarta.ws.rs.core.{Context, HttpHeaders, MediaType, Response, StreamingOutput}
+import jakarta.ws.rs.core._
import org.jooq.{DSLContext, EnumType}
-import java.util
import java.io.{InputStream, OutputStream}
import java.net.{HttpURLConnection, URL, URLDecoder}
import java.nio.charset.StandardCharsets
import java.nio.file.{Files, Paths}
+import java.util
import java.util.Optional
import java.util.zip.{ZipEntry, ZipOutputStream}
import scala.collection.mutable.ListBuffer
import scala.jdk.CollectionConverters._
import scala.jdk.OptionConverters._
-object DatasetResource {
+object ModelResource {
private val context = SqlServer
.getInstance()
.createDSLContext()
/**
- * Helper function to get the dataset from DB using did
+ * Helper function to get the model from DB using mid
*/
- private def getDatasetByID(ctx: DSLContext, did: Integer): Dataset = {
- val datasetDao = new DatasetDao(ctx.configuration())
- val dataset = datasetDao.fetchOneByDid(did)
- if (dataset == null) {
- throw new NotFoundException(f"Dataset $did not found")
+ private def getModelByID(ctx: DSLContext, mid: Integer): Model = {
+ val modelDao = new ModelDao(ctx.configuration())
+ val model = modelDao.fetchOneByMid(mid)
+ if (model == null) {
+ throw new NotFoundException(f"Model $mid not found")
}
- dataset
+ model
}
/**
@@ -125,54 +95,54 @@ object DatasetResource {
}
/**
- * Helper function to get the dataset version from DB using dvid
+ * Helper function to get the model version from DB using dvid
*/
- private def getDatasetVersionByID(
+ private def getModelVersionByID(
ctx: DSLContext,
- dvid: Integer
- ): DatasetVersion = {
- val datasetVersionDao = new DatasetVersionDao(ctx.configuration())
- val version = datasetVersionDao.fetchOneByDvid(dvid)
+ mvid: Integer
+ ): ModelVersion = {
+ val modelVersionDao = new ModelVersionDao(ctx.configuration())
+ val version = modelVersionDao.fetchOneByMvid(mvid)
if (version == null) {
- throw new NotFoundException("Dataset Version not found")
+ throw new NotFoundException("Model Version not found")
}
version
}
/**
- * Helper function to get the latest dataset version from the DB
+ * Helper function to get the latest model version from the DB
*/
- private def getLatestDatasetVersion(
+ private def getLatestModelVersion(
ctx: DSLContext,
- did: Integer
- ): Option[DatasetVersion] = {
+ mid: Integer
+ ): Option[ModelVersion] = {
ctx
- .selectFrom(DATASET_VERSION)
- .where(DATASET_VERSION.DID.eq(did))
- .orderBy(DATASET_VERSION.CREATION_TIME.desc())
+ .selectFrom(MODEL_VERSION)
+ .where(MODEL_VERSION.MID.eq(mid))
+ .orderBy(MODEL_VERSION.CREATION_TIME.desc())
.limit(1)
- .fetchOptionalInto(classOf[DatasetVersion])
+ .fetchOptionalInto(classOf[ModelVersion])
.toScala
}
- case class DashboardDataset(
- dataset: Dataset,
+ case class DashboardModel(
+ model: Model,
ownerEmail: String,
accessPrivilege: EnumType,
isOwner: Boolean,
size: Long
)
- case class DashboardDatasetVersion(
- datasetVersion: DatasetVersion,
- fileNodes: List[DatasetFileNode]
+ case class DashboardModelVersion(
+ modelVersion: ModelVersion,
+ fileNodes: List[DatasetFileNode]
)
- case class CreateDatasetRequest(
- datasetName: String,
- datasetDescription: String,
- isDatasetPublic: Boolean,
- isDatasetDownloadable: Boolean
+ case class CreateModelRequest(
+ modelName: String,
+ modelDescription: String,
+ isModelPublic: Boolean,
+ isModelDownloadable: Boolean
)
case class Diff(
@@ -182,49 +152,49 @@ object DatasetResource {
sizeBytes: Option[Long] // Size of the changed file (None for directories)
)
- case class DatasetDescriptionModification(did: Integer, description: String)
+ case class ModelDescriptionModification(mid: Integer, description: String)
- case class DatasetVersionRootFileNodesResponse(
- fileNodes: List[DatasetFileNode],
- size: Long
+ case class ModelVersionRootFileNodesResponse(
+ fileNodes: List[DatasetFileNode],
+ size: Long
)
}
@Produces(Array(MediaType.APPLICATION_JSON, "image/jpeg", "application/pdf"))
-@Path("/dataset")
-class DatasetResource {
- private val ERR_USER_HAS_NO_ACCESS_TO_DATASET_MESSAGE = "User has no access to this dataset"
- private val ERR_DATASET_VERSION_NOT_FOUND_MESSAGE = "The version of the dataset not found"
+@Path("/model")
+class ModelResource {
+ private val ERR_USER_HAS_NO_ACCESS_TO_MODEL_MESSAGE = "User has no access to this model"
+ private val ERR_MODEL_VERSION_NOT_FOUND_MESSAGE = "The version of the model not found"
private val EXPIRATION_MINUTES = 5
/**
- * Helper function to get the dataset from DB with additional information including user access privilege and owner email
+ * Helper function to get the model from DB with additional information including user access privilege and owner email
*/
- private def getDashboardDataset(
+ private def getDashboardModel(
ctx: DSLContext,
- did: Integer,
+ mid: Integer,
requesterUid: Option[Integer]
- ): DashboardDataset = {
- val targetDataset = getDatasetByID(ctx, did)
+ ): DashboardModel = {
+ val targetModel = getModelByID(ctx, mid)
- if (requesterUid.isEmpty && !targetDataset.getIsPublic) {
- throw new ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_DATASET_MESSAGE)
- } else if (requesterUid.exists(uid => !userHasReadAccess(ctx, did, uid))) {
- throw new ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_DATASET_MESSAGE)
+ if (requesterUid.isEmpty && !targetModel.getIsPublic) {
+ throw new ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_MODEL_MESSAGE)
+ } else if (requesterUid.exists(uid => !userHasReadAccess(ctx, mid, uid))) {
+ throw new ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_MODEL_MESSAGE)
}
val userAccessPrivilege = requesterUid
- .map(uid => getDatasetUserAccessPrivilege(ctx, did, uid))
+ .map(uid => getModelUserAccessPrivilege(ctx, mid, uid))
.getOrElse(PrivilegeEnum.READ)
- val isOwner = requesterUid.contains(targetDataset.getOwnerUid)
+ val isOwner = requesterUid.contains(targetModel.getOwnerUid)
- DashboardDataset(
- targetDataset,
- getOwner(ctx, did).getEmail,
+ DashboardModel(
+ targetModel,
+ getOwner(ctx, mid).getEmail,
userAccessPrivilege,
isOwner,
- LakeFSStorageClient.retrieveRepositorySize(targetDataset.getName)
+ LakeFSStorageClient.retrieveRepositorySize(targetModel.getName)
)
}
@@ -233,65 +203,65 @@ class DatasetResource {
@Path("/create")
@Consumes(Array(MediaType.APPLICATION_JSON))
def createDataset(
- request: CreateDatasetRequest,
- @Auth user: SessionUser
- ): DashboardDataset = {
+ request: CreateModelRequest,
+ @Auth user: SessionUser
+ ): DashboardModel = {
withTransaction(context) { ctx =>
val uid = user.getUid
- val datasetDao: DatasetDao = new DatasetDao(ctx.configuration())
- val datasetUserAccessDao: DatasetUserAccessDao = new DatasetUserAccessDao(ctx.configuration())
+ val modelDao: ModelDao = new ModelDao(ctx.configuration())
+ val modelUserAccessDao: ModelUserAccessDao = new ModelUserAccessDao(ctx.configuration())
- val datasetName = request.datasetName
- val datasetDescription = request.datasetDescription
- val isDatasetPublic = request.isDatasetPublic
- val isDatasetDownloadable = request.isDatasetDownloadable
+ val modelName = request.modelName
+ val modelDescription = request.modelDescription
+ val isModelPublic = request.isModelPublic
+ val isModelDownloadable = request.isModelDownloadable
- // Check if a dataset with the same name already exists
- if (!datasetDao.fetchByName(datasetName).isEmpty) {
- throw new BadRequestException("Dataset with the same name already exists")
+ // Check if a model with the same name already exists
+ if (!modelDao.fetchByName(modelName).isEmpty) {
+ throw new BadRequestException("model with the same name already exists")
}
// Initialize the repository in LakeFS
try {
- LakeFSStorageClient.initRepo(datasetName)
+ LakeFSStorageClient.initRepo(modelName)
} catch {
case e: Exception =>
throw new WebApplicationException(
- s"Failed to create the dataset: ${e.getMessage}"
+ s"Failed to create the model: ${e.getMessage}"
)
}
- // Insert the dataset into the database
- val dataset = new Dataset()
- dataset.setName(datasetName)
- dataset.setDescription(datasetDescription)
- dataset.setIsPublic(isDatasetPublic)
- dataset.setIsDownloadable(isDatasetDownloadable)
- dataset.setOwnerUid(uid)
-
- val createdDataset = ctx
- .insertInto(DATASET)
- .set(ctx.newRecord(DATASET, dataset))
+ // Insert the model into the database
+ val model = new Model()
+ model.setName(modelName)
+ model.setDescription(modelDescription)
+ model.setIsPublic(isModelPublic)
+ model.setIsDownloadable(isModelDownloadable)
+ model.setOwnerUid(uid)
+
+ val createdModel = ctx
+ .insertInto(MODEL)
+ .set(ctx.newRecord(MODEL, model))
.returning()
.fetchOne()
- // Insert the requester as the WRITE access user for this dataset
- val datasetUserAccess = new DatasetUserAccess()
- datasetUserAccess.setDid(createdDataset.getDid)
- datasetUserAccess.setUid(uid)
- datasetUserAccess.setPrivilege(PrivilegeEnum.WRITE)
- datasetUserAccessDao.insert(datasetUserAccess)
-
- DashboardDataset(
- new Dataset(
- createdDataset.getDid,
- createdDataset.getOwnerUid,
- createdDataset.getName,
- createdDataset.getIsPublic,
- createdDataset.getDescription,
- createdDataset.getCreationTime,
- createdDataset.getIsDownloadable
+ // Insert the requester as the WRITE access user for this model
+ val modelUserAccess = new ModelUserAccess()
+ modelUserAccess.setMid(createdModel.getMid)
+ modelUserAccess.setUid(uid)
+ modelUserAccess.setPrivilege(PrivilegeEnum.WRITE)
+ modelUserAccessDao.insert(modelUserAccess)
+
+ DashboardModel(
+ new Model(
+ createdModel.getMid,
+ createdModel.getOwnerUid,
+ createdModel.getName,
+ createdModel.getIsPublic,
+ createdModel.getIsDownloadable,
+ createdModel.getDescription,
+ createdModel.getCreationTime,
),
user.getEmail,
PrivilegeEnum.WRITE,
@@ -303,28 +273,28 @@ class DatasetResource {
@POST
@RolesAllowed(Array("REGULAR", "ADMIN"))
- @Path("/{did}/version/create")
+ @Path("/{mid}/version/create")
@Consumes(Array(MediaType.TEXT_PLAIN))
def createDatasetVersion(
versionName: String,
- @PathParam("did") did: Integer,
+ @PathParam("mid") mid: Integer,
@Auth user: SessionUser
- ): DashboardDatasetVersion = {
+ ): DashboardModelVersion = {
val uid = user.getUid
withTransaction(context) { ctx =>
- if (!userHasWriteAccess(ctx, did, uid)) {
- throw new ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_DATASET_MESSAGE)
+ if (!userHasWriteAccess(ctx, mid, uid)) {
+ throw new ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_MODEL_MESSAGE)
}
- val dataset = getDatasetByID(ctx, did)
- val datasetName = dataset.getName
+ val model = getModelByID(ctx, mid)
+ val modelName = model.getName
// Check if there are any changes in LakeFS before creating a new version
- val diffs = LakeFSStorageClient.retrieveUncommittedObjects(repoName = datasetName)
+ val diffs = LakeFSStorageClient.retrieveUncommittedObjects(repoName = modelName)
if (diffs.isEmpty) {
throw new WebApplicationException(
- "No changes detected in dataset. Version creation aborted.",
+ "No changes detected in model. Version creation aborted.",
Response.Status.BAD_REQUEST
)
}
@@ -332,8 +302,8 @@ class DatasetResource {
// Generate a new version name
val versionCount = ctx
.selectCount()
- .from(DATASET_VERSION)
- .where(DATASET_VERSION.DID.eq(did))
+ .from(MODEL_VERSION)
+ .where(MODEL_VERSION.MID.eq(mid))
.fetchOne(0, classOf[Int])
val sanitizedVersionName = Option(versionName).filter(_.nonEmpty).getOrElse("")
@@ -345,9 +315,9 @@ class DatasetResource {
// Create a commit in LakeFS
val commit = LakeFSStorageClient.createCommit(
- repoName = datasetName,
+ repoName = modelName,
branch = "main",
- commitMessage = s"Created dataset version: $newVersionName"
+ commitMessage = s"Created model version: $newVersionName"
)
if (commit == null || commit.getId == null) {
@@ -357,28 +327,28 @@ class DatasetResource {
)
}
- // Create a new dataset version entry in the database
- val datasetVersion = new DatasetVersion()
- datasetVersion.setDid(did)
- datasetVersion.setCreatorUid(uid)
- datasetVersion.setName(newVersionName)
- datasetVersion.setVersionHash(commit.getId) // Store LakeFS version hash
+ // Create a new model version entry in the database
+ val modelVersion = new ModelVersion()
+ modelVersion.setMid(mid)
+ modelVersion.setCreatorUid(uid)
+ modelVersion.setName(newVersionName)
+ modelVersion.setVersionHash(commit.getId) // Store LakeFS version hash
val insertedVersion = ctx
- .insertInto(DATASET_VERSION)
- .set(ctx.newRecord(DATASET_VERSION, datasetVersion))
+ .insertInto(MODEL_VERSION)
+ .set(ctx.newRecord(MODEL_VERSION, modelVersion))
.returning()
.fetchOne()
- .into(classOf[DatasetVersion])
+ .into(classOf[ModelVersion])
// Retrieve committed file structure
- val fileNodes = LakeFSStorageClient.retrieveObjectsOfVersion(datasetName, commit.getId)
+ val fileNodes = LakeFSStorageClient.retrieveObjectsOfVersion(modelName, commit.getId)
- DashboardDatasetVersion(
+ DashboardModelVersion(
insertedVersion,
DatasetFileNode
.fromLakeFSRepositoryCommittedObjects(
- Map((user.getEmail, datasetName, newVersionName) -> fileNodes)
+ Map((user.getEmail, modelName, newVersionName) -> fileNodes)
)
)
}
@@ -386,18 +356,18 @@ class DatasetResource {
@DELETE
@RolesAllowed(Array("REGULAR", "ADMIN"))
- @Path("/{did}")
- def deleteDataset(@PathParam("did") did: Integer, @Auth user: SessionUser): Response = {
+ @Path("/{mid}")
+ def deleteDataset(@PathParam("mid") mid: Integer, @Auth user: SessionUser): Response = {
val uid = user.getUid
withTransaction(context) { ctx =>
- val datasetDao = new DatasetDao(ctx.configuration())
- val dataset = getDatasetByID(ctx, did)
- if (!userOwnDataset(ctx, dataset.getDid, uid)) {
- // throw the exception that user has no access to certain dataset
- throw new ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_DATASET_MESSAGE)
+ val modelDao = new ModelDao(ctx.configuration())
+ val model = getModelByID(ctx, mid)
+ if (!userOwnDataset(ctx, model.getMid, uid)) {
+ // throw the exception that user has no access to certain model
+ throw new ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_MODEL_MESSAGE)
}
try {
- LakeFSStorageClient.deleteRepo(dataset.getName)
+ LakeFSStorageClient.deleteRepo(model.getName)
} catch {
case e: Exception =>
throw new WebApplicationException(
@@ -407,12 +377,12 @@ class DatasetResource {
}
// delete the directory on S3
- if (S3StorageClient.directoryExists(StorageConfig.lakefsBucketName, dataset.getName)) {
- S3StorageClient.deleteDirectory(StorageConfig.lakefsBucketName, dataset.getName)
+ if (S3StorageClient.directoryExists(StorageConfig.lakefsBucketName, model.getName)) {
+ S3StorageClient.deleteDirectory(StorageConfig.lakefsBucketName, model.getName)
}
- // delete the dataset from the DB
- datasetDao.deleteById(dataset.getDid)
+ // delete the model from the DB
+ modelDao.deleteById(model.getMid)
Response.ok().build()
}
@@ -423,30 +393,30 @@ class DatasetResource {
@Produces(Array(MediaType.APPLICATION_JSON))
@RolesAllowed(Array("REGULAR", "ADMIN"))
@Path("/update/description")
- def updateDatasetDescription(
- modificator: DatasetDescriptionModification,
- @Auth sessionUser: SessionUser
+ def updateModelDescription(
+ modificator: ModelDescriptionModification,
+ @Auth sessionUser: SessionUser
): Response = {
withTransaction(context) { ctx =>
val uid = sessionUser.getUid
- val datasetDao = new DatasetDao(ctx.configuration())
- val dataset = getDatasetByID(ctx, modificator.did)
- if (!userHasWriteAccess(ctx, modificator.did, uid)) {
- throw new ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_DATASET_MESSAGE)
+ val modelDao = new ModelDao(ctx.configuration())
+ val model = getModelByID(ctx, modificator.mid)
+ if (!userHasWriteAccess(ctx, modificator.mid, uid)) {
+ throw new ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_MODEL_MESSAGE)
}
- dataset.setDescription(modificator.description)
- datasetDao.update(dataset)
+ model.setDescription(modificator.description)
+ modelDao.update(model)
Response.ok().build()
}
}
@POST
@RolesAllowed(Array("REGULAR", "ADMIN"))
- @Path("/{did}/upload")
+ @Path("/{mid}/upload")
@Consumes(Array(MediaType.APPLICATION_OCTET_STREAM))
- def uploadOneFileToDataset(
- @PathParam("did") did: Integer,
+ def uploadOneFileToModel(
+ @PathParam("mid") mid: Integer,
@QueryParam("filePath") encodedFilePath: String,
@QueryParam("message") message: String,
fileStream: InputStream,
@@ -462,11 +432,11 @@ class DatasetResource {
try {
withTransaction(context) { ctx =>
- if (!userHasWriteAccess(ctx, did, uid))
- throw new ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_DATASET_MESSAGE)
+ if (!userHasWriteAccess(ctx, mid, uid))
+ throw new ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_MODEL_MESSAGE)
- val dataset = getDatasetByID(ctx, did)
- repoName = dataset.getName
+ val model = getModelByID(ctx, mid)
+ repoName = model.getName
filePath = URLDecoder.decode(encodedFilePath, StandardCharsets.UTF_8.name)
// ---------- decide part-size & number-of-parts ----------
@@ -549,7 +519,7 @@ class DatasetResource {
)
}
throw new WebApplicationException(
- s"Failed to upload file to dataset: ${e.getMessage}",
+ s"Failed to upload file to model: ${e.getMessage}",
e
)
}
@@ -559,69 +529,69 @@ class DatasetResource {
@RolesAllowed(Array("REGULAR", "ADMIN"))
@Path("/presign-download")
def getPresignedUrl(
- @QueryParam("filePath") encodedUrl: String,
- @QueryParam("datasetName") datasetName: String,
- @QueryParam("commitHash") commitHash: String,
- @Auth user: SessionUser
+ @QueryParam("filePath") encodedUrl: String,
+ @QueryParam("modelName") modelName: String,
+ @QueryParam("commitHash") commitHash: String,
+ @Auth user: SessionUser
): Response = {
val uid = user.getUid
- generatePresignedResponse(encodedUrl, datasetName, commitHash, uid)
+ generatePresignedResponse(encodedUrl, modelName, commitHash, uid)
}
@GET
@RolesAllowed(Array("REGULAR", "ADMIN"))
@Path("/presign-download-s3")
def getPresignedUrlWithS3(
- @QueryParam("filePath") encodedUrl: String,
- @QueryParam("datasetName") datasetName: String,
- @QueryParam("commitHash") commitHash: String,
- @Auth user: SessionUser
+ @QueryParam("filePath") encodedUrl: String,
+ @QueryParam("modelName") modelName: String,
+ @QueryParam("commitHash") commitHash: String,
+ @Auth user: SessionUser
): Response = {
val uid = user.getUid
- generatePresignedResponse(encodedUrl, datasetName, commitHash, uid)
+ generatePresignedResponse(encodedUrl, modelName, commitHash, uid)
}
@GET
@Path("/public-presign-download")
def getPublicPresignedUrl(
@QueryParam("filePath") encodedUrl: String,
- @QueryParam("datasetName") datasetName: String,
+ @QueryParam("modelName") modelName: String,
@QueryParam("commitHash") commitHash: String
): Response = {
- generatePresignedResponse(encodedUrl, datasetName, commitHash, null)
+ generatePresignedResponse(encodedUrl, modelName, commitHash, null)
}
@GET
@Path("/public-presign-download-s3")
def getPublicPresignedUrlWithS3(
@QueryParam("filePath") encodedUrl: String,
- @QueryParam("datasetName") datasetName: String,
+ @QueryParam("modelName") modelName: String,
@QueryParam("commitHash") commitHash: String
): Response = {
- generatePresignedResponse(encodedUrl, datasetName, commitHash, null)
+ generatePresignedResponse(encodedUrl, modelName, commitHash, null)
}
@DELETE
@RolesAllowed(Array("REGULAR", "ADMIN"))
- @Path("/{did}/file")
+ @Path("/{mid}/file")
@Consumes(Array(MediaType.APPLICATION_JSON))
def deleteDatasetFile(
- @PathParam("did") did: Integer,
+ @PathParam("mid") mid: Integer,
@QueryParam("filePath") encodedFilePath: String,
@Auth user: SessionUser
): Response = {
val uid = user.getUid
withTransaction(context) { ctx =>
- if (!userHasWriteAccess(ctx, did, uid)) {
- throw new ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_DATASET_MESSAGE)
+ if (!userHasWriteAccess(ctx, mid, uid)) {
+ throw new ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_MODEL_MESSAGE)
}
- val datasetName = getDatasetByID(ctx, did).getName
+ val modelName = getModelByID(ctx, mid).getName
// Decode the file path
val filePath = URLDecoder.decode(encodedFilePath, StandardCharsets.UTF_8.name())
// Try to initialize the repository in LakeFS
try {
- LakeFSStorageClient.deleteObject(datasetName, filePath)
+ LakeFSStorageClient.deleteObject(modelName, filePath)
} catch {
case e: Exception =>
throw new WebApplicationException(
@@ -638,24 +608,24 @@ class DatasetResource {
@Path("/multipart-upload")
@Consumes(Array(MediaType.APPLICATION_JSON))
def multipartUpload(
- @QueryParam("datasetName") datasetName: String,
- @QueryParam("type") operationType: String,
- @QueryParam("filePath") encodedUrl: String,
- @QueryParam("uploadId") uploadId: Optional[String],
- @QueryParam("numParts") numParts: Optional[Integer],
- payload: Map[
+ @QueryParam("modelName") modelName: String,
+ @QueryParam("type") operationType: String,
+ @QueryParam("filePath") encodedUrl: String,
+ @QueryParam("uploadId") uploadId: Optional[String],
+ @QueryParam("numParts") numParts: Optional[Integer],
+ payload: Map[
String,
Any
], // Expecting {"parts": [...], "physicalAddress": "s3://bucket/path"}
- @Auth user: SessionUser
+ @Auth user: SessionUser
): Response = {
val uid = user.getUid
withTransaction(context) { ctx =>
- val datasetDao = new DatasetDao(ctx.configuration())
- val datasets = datasetDao.fetchByName(datasetName).asScala.toList
- if (datasets.isEmpty || !userHasWriteAccess(ctx, datasets.head.getDid, uid)) {
- throw new ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_DATASET_MESSAGE)
+ val modelDao = new ModelDao(ctx.configuration())
+ val models = modelDao.fetchByName(modelName).asScala.toList
+ if (models.isEmpty || !userHasWriteAccess(ctx, models.head.getMid, uid)) {
+ throw new ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_MODEL_MESSAGE)
}
// Decode the file path
@@ -668,7 +638,7 @@ class DatasetResource {
)
val presignedResponse = LakeFSStorageClient.initiatePresignedMultipartUploads(
- datasetName,
+ modelName,
filePath,
numPartsValue
)
@@ -725,7 +695,7 @@ class DatasetResource {
// Complete the multipart upload with parts and physical address
val objectStats = LakeFSStorageClient.completePresignedMultipartUploads(
- datasetName,
+ modelName,
filePath,
uploadIdValue,
partsList,
@@ -742,8 +712,8 @@ class DatasetResource {
.build()
case "abort" =>
- val uploadIdValue = uploadId.toScala.getOrElse(
- throw new BadRequestException("uploadId is required for abortion")
+ val uploamidValue = uploadId.toScala.getOrElse(
+ throw new BadRequestException("uploamid is required for abortion")
)
// Extract physical address from payload
@@ -754,9 +724,9 @@ class DatasetResource {
// Abort the multipart upload
LakeFSStorageClient.abortPresignedMultipartUploads(
- datasetName,
+ modelName,
filePath,
- uploadIdValue,
+ uploamidValue,
physicalAddress
)
@@ -770,73 +740,73 @@ class DatasetResource {
@POST
@RolesAllowed(Array("REGULAR", "ADMIN"))
- @Path("/{did}/update/publicity")
+ @Path("/{mid}/update/publicity")
def toggleDatasetPublicity(
- @PathParam("did") did: Integer,
+ @PathParam("mid") mid: Integer,
@Auth sessionUser: SessionUser
): Response = {
withTransaction(context) { ctx =>
- val datasetDao = new DatasetDao(ctx.configuration())
+ val modelDao = new ModelDao(ctx.configuration())
val uid = sessionUser.getUid
- if (!userHasWriteAccess(ctx, did, uid)) {
- throw new ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_DATASET_MESSAGE)
+ if (!userHasWriteAccess(ctx, mid, uid)) {
+ throw new ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_MODEL_MESSAGE)
}
- val existedDataset = getDatasetByID(ctx, did)
- val newPublicStatus = !existedDataset.getIsPublic
- existedDataset.setIsPublic(newPublicStatus)
+ val existedModel = getModelByID(ctx, mid)
+ val newPublicStatus = !existedModel.getIsPublic
+ existedModel.setIsPublic(newPublicStatus)
- datasetDao.update(existedDataset)
+ modelDao.update(existedModel)
Response.ok().build()
}
}
@POST
@RolesAllowed(Array("REGULAR", "ADMIN"))
- @Path("/{did}/update/downloadable")
+ @Path("/{mid}/update/downloadable")
def toggleDatasetDownloadable(
- @PathParam("did") did: Integer,
+ @PathParam("mid") mid: Integer,
@Auth sessionUser: SessionUser
): Response = {
withTransaction(context) { ctx =>
- val datasetDao = new DatasetDao(ctx.configuration())
+ val modelDao = new ModelDao(ctx.configuration())
val uid = sessionUser.getUid
- if (!userOwnDataset(ctx, did, uid)) {
- throw new ForbiddenException("Only dataset owners can modify download permissions")
+ if (!userOwnDataset(ctx, mid, uid)) {
+ throw new ForbiddenException("Only model owners can modify download permissions")
}
- val existedDataset = getDatasetByID(ctx, did)
- val newDownloadableStatus = !existedDataset.getIsDownloadable
+ val existedModel = getModelByID(ctx, mid)
+ val newDownloadableStatus = !existedModel.getIsDownloadable
- existedDataset.setIsDownloadable(newDownloadableStatus)
+ existedModel.setIsDownloadable(newDownloadableStatus)
- datasetDao.update(existedDataset)
+ modelDao.update(existedModel)
Response.ok().build()
}
}
@GET
@RolesAllowed(Array("REGULAR", "ADMIN"))
- @Path("/{did}/diff")
+ @Path("/{mid}/diff")
def getDatasetDiff(
- @PathParam("did") did: Integer,
+ @PathParam("mid") mid: Integer,
@Auth user: SessionUser
): List[Diff] = {
val uid = user.getUid
withTransaction(context) { ctx =>
- if (!userHasReadAccess(ctx, did, uid)) {
- throw new ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_DATASET_MESSAGE)
+ if (!userHasReadAccess(ctx, mid, uid)) {
+ throw new ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_MODEL_MESSAGE)
}
// Retrieve staged (uncommitted) changes from LakeFS
- val dataset = getDatasetByID(ctx, did)
- val lakefsDiffs = LakeFSStorageClient.retrieveUncommittedObjects(dataset.getName)
+ val model = getModelByID(ctx, mid)
+ val lakefsDiffs = LakeFSStorageClient.retrieveUncommittedObjects(model.getName)
// Convert LakeFS Diff objects to our custom Diff case class
lakefsDiffs.map(d =>
- new Diff(
+ Diff(
d.getPath,
d.getPathType.getValue,
d.getType.getValue,
@@ -848,25 +818,25 @@ class DatasetResource {
@PUT
@RolesAllowed(Array("REGULAR", "ADMIN"))
- @Path("/{did}/diff")
+ @Path("/{mid}/diff")
@Consumes(Array(MediaType.APPLICATION_JSON))
def resetDatasetFileDiff(
- @PathParam("did") did: Integer,
+ @PathParam("mid") mid: Integer,
@QueryParam("filePath") encodedFilePath: String,
@Auth user: SessionUser
): Response = {
val uid = user.getUid
withTransaction(context) { ctx =>
- if (!userHasWriteAccess(ctx, did, uid)) {
- throw new ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_DATASET_MESSAGE)
+ if (!userHasWriteAccess(ctx, mid, uid)) {
+ throw new ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_MODEL_MESSAGE)
}
- val datasetName = getDatasetByID(ctx, did).getName
+ val modelName = getModelByID(ctx, mid).getName
// Decode the file path
val filePath = URLDecoder.decode(encodedFilePath, StandardCharsets.UTF_8.name())
// Try to reset the file change in LakeFS
try {
- LakeFSStorageClient.resetObjectUploadOrDeletion(datasetName, filePath)
+ LakeFSStorageClient.resetObjectUploadOrDeletion(modelName, filePath)
} catch {
case e: Exception =>
throw new WebApplicationException(
@@ -878,40 +848,40 @@ class DatasetResource {
}
/**
- * This method returns a list of DashboardDatasets objects that are accessible by current user.
+ * This method returns a list of Dashboardmodels objects that are accessible by current user.
*
* @param user the session user
- * @return list of user accessible DashboardDataset objects
+ * @return list of user accessible Dashboardmodel objects
*/
@GET
@RolesAllowed(Array("REGULAR", "ADMIN"))
@Path("/list")
def listDatasets(
@Auth user: SessionUser
- ): List[DashboardDataset] = {
+ ): List[DashboardModel] = {
val uid = user.getUid
withTransaction(context)(ctx => {
- var accessibleDatasets: ListBuffer[DashboardDataset] = ListBuffer()
- // first fetch all datasets user have explicit access to
- accessibleDatasets = ListBuffer.from(
+ var accessibleModels: ListBuffer[DashboardModel] = ListBuffer()
+ // first fetch all models user have explicit access to
+ accessibleModels = ListBuffer.from(
ctx
.select()
.from(
- DATASET
- .leftJoin(DATASET_USER_ACCESS)
- .on(DATASET_USER_ACCESS.DID.eq(DATASET.DID))
+ MODEL
+ .leftJoin(MODEL_USER_ACCESS)
+ .on(MODEL_USER_ACCESS.MID.eq(MODEL.MID))
.leftJoin(USER)
- .on(USER.UID.eq(DATASET.OWNER_UID))
+ .on(USER.UID.eq(MODEL.OWNER_UID))
)
- .where(DATASET_USER_ACCESS.UID.eq(uid))
+ .where(MODEL_USER_ACCESS.UID.eq(uid))
.fetch()
.map(record => {
- val dataset = record.into(DATASET).into(classOf[Dataset])
- val datasetAccess = record.into(DATASET_USER_ACCESS).into(classOf[DatasetUserAccess])
+ val model = record.into(MODEL).into(classOf[Model])
+ val datasetAccess = record.into(MODEL_USER_ACCESS).into(classOf[ModelUserAccess])
val ownerEmail = record.into(USER).getEmail
- DashboardDataset(
- isOwner = dataset.getOwnerUid == uid,
- dataset = dataset,
+ DashboardModel(
+ isOwner = model.getOwnerUid == uid,
+ model = model,
accessPrivilege = datasetAccess.getPrivilege,
ownerEmail = ownerEmail,
size = 0
@@ -920,88 +890,88 @@ class DatasetResource {
.asScala
)
- // then we fetch the public datasets and merge it as a part of the result if not exist
- val publicDatasets = ctx
+ // then we fetch the public models and merge it as a part of the result if not exist
+ val publicModels = ctx
.select()
.from(
- DATASET
+ MODEL
.leftJoin(USER)
- .on(USER.UID.eq(DATASET.OWNER_UID))
+ .on(USER.UID.eq(MODEL.OWNER_UID))
)
- .where(DATASET.IS_PUBLIC.eq(true))
+ .where(MODEL.IS_PUBLIC.eq(true))
.fetch()
.map(record => {
- val dataset = record.into(DATASET).into(classOf[Dataset])
+ val model = record.into(MODEL).into(classOf[Model])
val ownerEmail = record.into(USER).getEmail
- DashboardDataset(
+ DashboardModel(
isOwner = false,
- dataset = dataset,
+ model = model,
accessPrivilege = PrivilegeEnum.READ,
ownerEmail = ownerEmail,
- size = LakeFSStorageClient.retrieveRepositorySize(dataset.getName)
+ size = LakeFSStorageClient.retrieveRepositorySize(model.getName)
)
})
- publicDatasets.forEach { publicDataset =>
- if (!accessibleDatasets.exists(_.dataset.getDid == publicDataset.dataset.getDid)) {
- val dashboardDataset = DashboardDataset(
+ publicModels.forEach { publicModel =>
+ if (!accessibleModels.exists(_.model.getMid == publicModel.model.getMid)) {
+ val dashboardDataset = DashboardModel(
isOwner = false,
- dataset = publicDataset.dataset,
- ownerEmail = publicDataset.ownerEmail,
+ model = publicModel.model,
+ ownerEmail = publicModel.ownerEmail,
accessPrivilege = PrivilegeEnum.READ,
- size = LakeFSStorageClient.retrieveRepositorySize(publicDataset.dataset.getName)
+ size = LakeFSStorageClient.retrieveRepositorySize(publicModel.model.getName)
)
- accessibleDatasets = accessibleDatasets :+ dashboardDataset
+ accessibleModels = accessibleModels :+ dashboardDataset
}
}
- accessibleDatasets.toList
+ accessibleModels.toList
})
}
@GET
@RolesAllowed(Array("REGULAR", "ADMIN"))
- @Path("/{did}/version/list")
+ @Path("/{mid}/version/list")
def getDatasetVersionList(
- @PathParam("did") did: Integer,
+ @PathParam("mid") mid: Integer,
@Auth user: SessionUser
- ): List[DatasetVersion] = {
+ ): List[ModelVersion] = {
val uid = user.getUid
withTransaction(context)(ctx => {
- val dataset = getDatasetByID(ctx, did)
- if (!userHasReadAccess(ctx, dataset.getDid, uid)) {
- throw new ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_DATASET_MESSAGE)
+ val model = getModelByID(ctx, mid)
+ if (!userHasReadAccess(ctx, model.getMid, uid)) {
+ throw new ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_MODEL_MESSAGE)
}
- fetchDatasetVersions(ctx, dataset.getDid)
+ fetchModelVersions(ctx, model.getMid)
})
}
@GET
@Path("/{name}/publicVersion/list")
def getPublicDatasetVersionList(
- @PathParam("name") did: Integer
- ): List[DatasetVersion] = {
+ @PathParam("name") mid: Integer
+ ): List[ModelVersion] = {
withTransaction(context)(ctx => {
- if (!isDatasetPublic(ctx, did)) {
- throw new ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_DATASET_MESSAGE)
+ if (!isDatasetPublic(ctx, mid)) {
+ throw new ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_MODEL_MESSAGE)
}
- fetchDatasetVersions(ctx, did)
+ fetchModelVersions(ctx, mid)
})
}
@GET
@RolesAllowed(Array("REGULAR", "ADMIN"))
- @Path("/{did}/version/latest")
+ @Path("/{mid}/version/latest")
def retrieveLatestDatasetVersion(
- @PathParam("did") did: Integer,
+ @PathParam("mid") mid: Integer,
@Auth user: SessionUser
- ): DashboardDatasetVersion = {
+ ): DashboardModelVersion = {
val uid = user.getUid
withTransaction(context)(ctx => {
- if (!userHasReadAccess(ctx, did, uid)) {
- throw new ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_DATASET_MESSAGE)
+ if (!userHasReadAccess(ctx, mid, uid)) {
+ throw new ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_MODEL_MESSAGE)
}
- val dataset = getDatasetByID(ctx, did)
- val latestVersion = getLatestDatasetVersion(ctx, did).getOrElse(
- throw new NotFoundException(ERR_DATASET_VERSION_NOT_FOUND_MESSAGE)
+ val dataset = getModelByID(ctx, mid)
+ val latestVersion = getLatestModelVersion(ctx, mid).getOrElse(
+ throw new NotFoundException(ERR_MODEL_VERSION_NOT_FOUND_MESSAGE)
)
val ownerNode = DatasetFileNode
@@ -1014,7 +984,7 @@ class DatasetResource {
)
.head
- DashboardDatasetVersion(
+ DashboardModelVersion(
latestVersion,
ownerNode.children.get
.find(_.getName == dataset.getName)
@@ -1031,10 +1001,10 @@ class DatasetResource {
@GET
@RolesAllowed(Array("REGULAR", "ADMIN"))
- @Path("/{did}/versionZip")
+ @Path("/{mid}/versionZip")
def getDatasetVersionZip(
- @PathParam("did") did: Integer,
- @QueryParam("dvid") dvid: Integer, // Dataset version ID, nullable
+ @PathParam("mid") mid: Integer,
+ @QueryParam("dvid") dvid: Integer, // model version ID, nullable
@QueryParam("latest") latest: java.lang.Boolean, // Flag to get latest version, nullable
@Auth user: SessionUser
): Response = {
@@ -1046,29 +1016,29 @@ class DatasetResource {
// Check read access and download permission
val uid = user.getUid
- if (!userHasReadAccess(ctx, did, uid)) {
- throw new ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_DATASET_MESSAGE)
+ if (!userHasReadAccess(ctx, mid, uid)) {
+ throw new ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_MODEL_MESSAGE)
}
- // Retrieve dataset and check download permission
- val dataset = getDatasetByID(ctx, did)
- // Non-owners can download if dataset is downloadable and they have read access
- if (!userOwnDataset(ctx, did, uid) && !dataset.getIsDownloadable) {
- throw new ForbiddenException("Dataset download is not allowed")
+ // Retrieve model and check download permission
+ val dataset = getModelByID(ctx, mid)
+ // Non-owners can download if model is downloadable and they have read access
+ if (!userOwnDataset(ctx, mid, uid) && !dataset.getIsDownloadable) {
+ throw new ForbiddenException("model download is not allowed")
}
// Determine which version to retrieve
val datasetVersion = if (dvid != null) {
- getDatasetVersionByID(ctx, dvid)
+ getModelVersionByID(ctx, dvid)
} else if (java.lang.Boolean.TRUE.equals(latest)) {
- getLatestDatasetVersion(ctx, did).getOrElse(
- throw new NotFoundException(ERR_DATASET_VERSION_NOT_FOUND_MESSAGE)
+ getLatestModelVersion(ctx, mid).getOrElse(
+ throw new NotFoundException(ERR_MODEL_VERSION_NOT_FOUND_MESSAGE)
)
} else {
throw new BadRequestException("Invalid parameters")
}
- // Retrieve dataset and version details
+ // Retrieve model and version details
val datasetName = dataset.getName
val versionHash = datasetVersion.getVersionHash
val objects = LakeFSStorageClient.retrieveObjectsOfVersion(datasetName, versionHash)
@@ -1110,42 +1080,42 @@ class DatasetResource {
@GET
@RolesAllowed(Array("REGULAR", "ADMIN"))
- @Path("/{did}/version/{dvid}/rootFileNodes")
+ @Path("/{mid}/version/{dvid}/rootFileNodes")
def retrieveDatasetVersionRootFileNodes(
- @PathParam("did") did: Integer,
+ @PathParam("mid") mid: Integer,
@PathParam("dvid") dvid: Integer,
@Auth user: SessionUser
- ): DatasetVersionRootFileNodesResponse = {
+ ): ModelVersionRootFileNodesResponse = {
val uid = user.getUid
- withTransaction(context)(ctx => fetchDatasetVersionRootFileNodes(ctx, did, dvid, Some(uid)))
+ withTransaction(context)(ctx => fetchModelVersionRootFileNodes(ctx, mid, dvid, Some(uid)))
}
@GET
- @Path("/{did}/publicVersion/{dvid}/rootFileNodes")
+ @Path("/{mid}/publicVersion/{dvid}/rootFileNodes")
def retrievePublicDatasetVersionRootFileNodes(
- @PathParam("did") did: Integer,
+ @PathParam("mid") mid: Integer,
@PathParam("dvid") dvid: Integer
- ): DatasetVersionRootFileNodesResponse = {
- withTransaction(context)(ctx => fetchDatasetVersionRootFileNodes(ctx, did, dvid, None))
+ ): ModelVersionRootFileNodesResponse = {
+ withTransaction(context)(ctx => fetchModelVersionRootFileNodes(ctx, mid, dvid, None))
}
@GET
@RolesAllowed(Array("REGULAR", "ADMIN"))
- @Path("/{did}")
+ @Path("/{mid}")
def getDataset(
- @PathParam("did") did: Integer,
+ @PathParam("mid") mid: Integer,
@Auth user: SessionUser
- ): DashboardDataset = {
+ ): DashboardModel = {
val uid = user.getUid
- withTransaction(context)(ctx => getDashboardDataset(ctx, did, Some(uid)))
+ withTransaction(context)(ctx => getDashboardModel(ctx, mid, Some(uid)))
}
@GET
- @Path("/public/{did}")
+ @Path("/public/{mid}")
def getPublicDataset(
- @PathParam("did") did: Integer
- ): DashboardDataset = {
- withTransaction(context)(ctx => getDashboardDataset(ctx, did, None))
+ @PathParam("mid") mid: Integer
+ ): DashboardModel = {
+ withTransaction(context)(ctx => getDashboardModel(ctx, mid, None))
}
@GET
@@ -1195,61 +1165,61 @@ class DatasetResource {
}
/**
- * This method returns all owner user names of the dataset that the user has access to
+ * This method returns all owner user names of the model that the user has access to
*
* @return OwnerName[]
*/
@GET
@RolesAllowed(Array("REGULAR", "ADMIN"))
- @Path("/user-dataset-owners")
+ @Path("/user-model-owners")
def retrieveOwners(@Auth user: SessionUser): util.List[String] = {
context
.selectDistinct(USER.EMAIL)
.from(USER)
- .join(DATASET)
- .on(DATASET.OWNER_UID.eq(USER.UID))
- .join(DATASET_USER_ACCESS)
- .on(DATASET_USER_ACCESS.DID.eq(DATASET.DID))
- .where(DATASET_USER_ACCESS.UID.eq(user.getUid))
+ .join(MODEL)
+ .on(MODEL.OWNER_UID.eq(USER.UID))
+ .join(MODEL_USER_ACCESS)
+ .on(MODEL_USER_ACCESS.MID.eq(MODEL.MID))
+ .where(MODEL_USER_ACCESS.UID.eq(user.getUid))
.fetchInto(classOf[String])
}
- private def fetchDatasetVersions(ctx: DSLContext, did: Integer): List[DatasetVersion] = {
+ private def fetchModelVersions(ctx: DSLContext, mid: Integer): List[ModelVersion] = {
ctx
- .selectFrom(DATASET_VERSION)
- .where(DATASET_VERSION.DID.eq(did))
- .orderBy(DATASET_VERSION.CREATION_TIME.desc()) // Change to .asc() for ascending order
- .fetchInto(classOf[DatasetVersion])
+ .selectFrom(MODEL_VERSION)
+ .where(MODEL_VERSION.MID.eq(mid))
+ .orderBy(MODEL_VERSION.CREATION_TIME.desc()) // Change to .asc() for ascending order
+ .fetchInto(classOf[ModelVersion])
.asScala
.toList
}
- private def fetchDatasetVersionRootFileNodes(
+ private def fetchModelVersionRootFileNodes(
ctx: DSLContext,
- did: Integer,
+ mid: Integer,
dvid: Integer,
uid: Option[Integer]
- ): DatasetVersionRootFileNodesResponse = {
- val dataset = getDashboardDataset(ctx, did, uid)
- val datasetVersion = getDatasetVersionByID(ctx, dvid)
- val datasetName = dataset.dataset.getName
+ ): ModelVersionRootFileNodesResponse = {
+ val model = getDashboardModel(ctx, mid, uid)
+ val modelVersion = getModelVersionByID(ctx, dvid)
+ val modelName = model.model.getName
val ownerFileNode = DatasetFileNode
.fromLakeFSRepositoryCommittedObjects(
Map(
- (dataset.ownerEmail, datasetName, datasetVersion.getName) -> LakeFSStorageClient
- .retrieveObjectsOfVersion(datasetName, datasetVersion.getVersionHash)
+ (model.ownerEmail, modelName, modelVersion.getName) -> LakeFSStorageClient
+ .retrieveObjectsOfVersion(modelName, modelVersion.getVersionHash)
)
)
.head
- DatasetVersionRootFileNodesResponse(
+ ModelVersionRootFileNodesResponse(
ownerFileNode.children.get
- .find(_.getName == datasetName)
+ .find(_.getName == modelName)
.head
.children
.get
- .find(_.getName == datasetVersion.getName)
+ .find(_.getName == modelVersion.getName)
.head
.children
.get,
@@ -1258,18 +1228,18 @@ class DatasetResource {
}
private def generatePresignedResponse(
- encodedUrl: String,
- datasetName: String,
- commitHash: String,
- uid: Integer
+ encodedUrl: String,
+ modelName: String,
+ commitHash: String,
+ uid: Integer
): Response = {
- resolveDatasetAndPath(encodedUrl, datasetName, commitHash, uid) match {
+ resolveModelAndPath(encodedUrl, modelName, commitHash, uid) match {
case Left(errorResponse) =>
errorResponse
- case Right((resolvedDatasetName, resolvedCommitHash, resolvedFilePath)) =>
+ case Right((resolvedModelName, resolvedCommitHash, resolvedFilePath)) =>
val url = LakeFSStorageClient.getFilePresignedUrl(
- resolvedDatasetName,
+ resolvedModelName,
resolvedCommitHash,
resolvedFilePath
)
@@ -1278,57 +1248,56 @@ class DatasetResource {
}
}
- private def resolveDatasetAndPath(
- encodedUrl: String,
- datasetName: String,
- commitHash: String,
- uid: Integer
+ private def resolveModelAndPath(
+ encodedUrl: String,
+ modelName: String,
+ commitHash: String,
+ uid: Integer
): Either[Response, (String, String, String)] = {
val decodedPathStr = URLDecoder.decode(encodedUrl, StandardCharsets.UTF_8.name())
- (Option(datasetName), Option(commitHash)) match {
+ (Option(modelName), Option(commitHash)) match {
case (Some(_), None) | (None, Some(_)) =>
// Case 1: Only one parameter is provided (error case)
Left(
Response
.status(Response.Status.BAD_REQUEST)
.entity(
- "Both datasetName and commitHash must be provided together, or neither should be provided."
+ "Both modelName and commitHash must be provided together, or neither should be provided."
)
.build()
)
case (Some(dsName), Some(commit)) =>
- // Case 2: datasetName and commitHash are provided, validate access
+ // Case 2: modelName and commitHash are provided, validate access
val response = withTransaction(context) { ctx =>
- val datasetDao = new DatasetDao(ctx.configuration())
- val datasets = datasetDao.fetchByName(dsName).asScala.toList
+ val modelDao = new ModelDao(ctx.configuration())
+ val models = modelDao.fetchByName(dsName).asScala.toList
- if (datasets.isEmpty || !userHasReadAccess(ctx, datasets.head.getDid, uid))
- throw new ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_DATASET_MESSAGE)
+ if (models.isEmpty || !userHasReadAccess(ctx, models.head.getMid, uid))
+ throw new ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_MODEL_MESSAGE)
- val dataset = datasets.head
+ val model = models.head
// Standard read access check only - download restrictions handled per endpoint
- // Non-download operations (viewing) should work for all public datasets
+ // Non-download operations (viewing) should work for all public models
(dsName, commit, decodedPathStr)
}
Right(response)
case (None, None) =>
- // Case 3: Neither datasetName nor commitHash are provided, resolve normally
+ // Case 3: Neither modelName nor commitHash are provided, resolve normally
val response = withTransaction(context) { ctx =>
val fileUri = FileResolver.resolve(decodedPathStr)
val document = DocumentFactory.openReadonlyDocument(fileUri).asInstanceOf[OnDataset]
- val datasetDao = new DatasetDao(ctx.configuration())
- val datasets = datasetDao.fetchByName(document.getDatasetName()).asScala.toList
-
- if (datasets.isEmpty || !userHasReadAccess(ctx, datasets.head.getDid, uid))
- throw new ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_DATASET_MESSAGE)
+ val modelDao = new ModelDao(ctx.configuration())
+ val models = modelDao.fetchByName(document.getDatasetName()).asScala.toList
- val dataset = datasets.head
+ if (models.isEmpty || !userHasReadAccess(ctx, models.head.getMid, uid))
+ throw new ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_MODEL_MESSAGE)
+
// Standard read access check only - download restrictions handled per endpoint
- // Non-download operations (viewing) should work for all public datasets
+ // Non-download operations (viewing) should work for all public models
(
document.getDatasetName(),
diff --git a/core/gui/proxy.config.json b/core/gui/proxy.config.json
index 3acc9a480c..33df99be3d 100755
--- a/core/gui/proxy.config.json
+++ b/core/gui/proxy.config.json
@@ -9,6 +9,11 @@
"secure": false,
"changeOrigin": true
},
+ "/api/model": {
+ "target": "http://localhost:9092",
+ "secure": false,
+ "changeOrigin": true
+ },
"/api/access/dataset/**": {
"target": "http://localhost:9092",
"secure": false,
diff --git a/core/gui/src/app/app-routing.constant.ts b/core/gui/src/app/app-routing.constant.ts
index a22f6439e2..db50133d66 100644
--- a/core/gui/src/app/app-routing.constant.ts
+++ b/core/gui/src/app/app-routing.constant.ts
@@ -34,6 +34,7 @@ export const DASHBOARD_USER_PROJECT = `${DASHBOARD_USER}/project`;
export const DASHBOARD_USER_WORKSPACE = `${DASHBOARD_USER}/workflow`;
export const DASHBOARD_USER_WORKFLOW = `${DASHBOARD_USER}/workflow`;
export const DASHBOARD_USER_DATASET = `${DASHBOARD_USER}/dataset`;
+export const DASHBOARD_USER_MODEL = `${DASHBOARD_USER}/model`;
export const DASHBOARD_USER_DATASET_CREATE = `${DASHBOARD_USER_DATASET}/create`;
export const DASHBOARD_USER_QUOTA = `${DASHBOARD_USER}/quota`;
export const DASHBOARD_USER_DISCUSSION = `${DASHBOARD_USER}/discussion`;
diff --git a/core/gui/src/app/app-routing.module.ts b/core/gui/src/app/app-routing.module.ts
index fffe3f8e42..f8472bd57e 100644
--- a/core/gui/src/app/app-routing.module.ts
+++ b/core/gui/src/app/app-routing.module.ts
@@ -44,6 +44,7 @@ import { inject } from "@angular/core";
import { GuiConfigService } from "./common/service/gui-config.service";
import { Router, CanActivateFn } from "@angular/router";
import { DASHBOARD_ABOUT } from "./app-routing.constant";
+import { UserModelComponent } from "./dashboard/component/user/user-model/user-model.component";
const rootRedirectGuard: CanActivateFn = () => {
const config = inject(GuiConfigService);
@@ -127,6 +128,10 @@ routes.push({
path: "dataset",
component: UserDatasetComponent,
},
+ {
+ path: "model",
+ component: UserModelComponent,
+ },
{
path: "dataset/:did",
component: DatasetDetailComponent,
diff --git a/core/gui/src/app/app.module.ts b/core/gui/src/app/app.module.ts
index 13759e6e55..4b07f986a3 100644
--- a/core/gui/src/app/app.module.ts
+++ b/core/gui/src/app/app.module.ts
@@ -172,6 +172,13 @@ import { AdminSettingsComponent } from "./dashboard/component/admin/settings/adm
import { catchError, of } from "rxjs";
import { FormlyRepeatDndComponent } from "./common/formly/repeat-dnd/repeat-dnd.component";
import { NzInputNumberModule } from "ng-zorro-antd/input-number";
+import { UserModelComponent } from "./dashboard/component/user/user-model/user-model.component";
+import {
+ UserModelVersionCreatorComponent
+} from "./dashboard/component/user/user-model/user-dataset-explorer/user-dataset-version-creator/user-model-version-creator.component";
+import {
+ ModelDetailComponent
+} from "./dashboard/component/user/user-model/user-dataset-explorer/model-detail.component";
registerLocaleData(en);
@@ -233,6 +240,9 @@ registerLocaleData(en);
UserDatasetListItemComponent,
UserDatasetFileRendererComponent,
UserDatasetStagedObjectsListComponent,
+ UserModelVersionCreatorComponent,
+ UserModelComponent,
+ ModelDetailComponent,
NzModalCommentBoxComponent,
LeftPanelComponent,
LocalLoginComponent,
diff --git a/core/gui/src/app/common/type/dataset.ts b/core/gui/src/app/common/type/dataset.ts
index 7825ca2797..44642d441a 100644
--- a/core/gui/src/app/common/type/dataset.ts
+++ b/core/gui/src/app/common/type/dataset.ts
@@ -39,3 +39,15 @@ export interface Dataset {
description: string;
creationTime: number | undefined;
}
+
+export interface Model {
+ mid: number | undefined;
+ ownerUid: number | undefined;
+ name: string;
+ isPublic: boolean;
+ isDownloadable: boolean;
+ storagePath: string | undefined;
+ description: string;
+ creationTime: number | undefined;
+}
+
diff --git a/core/gui/src/app/common/type/gui-config.ts b/core/gui/src/app/common/type/gui-config.ts
index d60ba1cf01..7909843a6c 100644
--- a/core/gui/src/app/common/type/gui-config.ts
+++ b/core/gui/src/app/common/type/gui-config.ts
@@ -49,6 +49,7 @@ export interface SidebarTabs {
projects_enabled: boolean;
workflows_enabled: boolean;
datasets_enabled: boolean;
+ models_enabled:boolean;
quota_enabled: boolean;
forum_enabled: boolean;
about_enabled: boolean;
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 863ea67b81..0955a69323 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
@@ -43,6 +43,7 @@ export class AdminSettingsComponent implements OnInit {
projects_enabled: false,
workflows_enabled: false,
datasets_enabled: false,
+ models_enabled: false,
quota_enabled: false,
forum_enabled: false,
about_enabled: false,
diff --git a/core/gui/src/app/dashboard/component/dashboard.component.html b/core/gui/src/app/dashboard/component/dashboard.component.html
index cf490d3304..92cae29991 100644
--- a/core/gui/src/app/dashboard/component/dashboard.component.html
+++ b/core/gui/src/app/dashboard/component/dashboard.component.html
@@ -97,6 +97,18 @@
nzType="database"></span>
<span>Datasets</span>
</li>
+ <li
+ *ngIf="sidebarTabs.models_enabled"
+ nz-menu-item
+ nz-tooltip="Look up for models"
+ nzMatchRouter="true"
+ nzTooltipPlacement="right"
+ [routerLink]="DASHBOARD_USER_MODEL">
+ <span
+ nz-icon
+ nzType="database"></span>
+ <span>Models</span>
+ </li>
<li
*ngIf="sidebarTabs.quota_enabled"
nz-menu-item
diff --git a/core/gui/src/app/dashboard/component/dashboard.component.ts b/core/gui/src/app/dashboard/component/dashboard.component.ts
index c07a78f7bf..a6f57c1545 100644
--- a/core/gui/src/app/dashboard/component/dashboard.component.ts
+++ b/core/gui/src/app/dashboard/component/dashboard.component.ts
@@ -38,7 +38,7 @@ import {
DASHBOARD_USER_DISCUSSION,
DASHBOARD_USER_PROJECT,
DASHBOARD_USER_QUOTA,
- DASHBOARD_USER_WORKFLOW,
+ DASHBOARD_USER_WORKFLOW, DASHBOARD_USER_MODEL,
} from "../../app-routing.constant";
import { Version } from "../../../environments/version";
import { SidebarTabs } from "../../common/type/gui-config";
@@ -70,6 +70,7 @@ export class DashboardComponent implements OnInit {
projects_enabled: false,
workflows_enabled: false,
datasets_enabled: false,
+ models_enabled: false,
quota_enabled: false,
forum_enabled: false,
about_enabled: false,
@@ -78,6 +79,7 @@ export class DashboardComponent implements OnInit {
protected readonly DASHBOARD_USER_PROJECT = DASHBOARD_USER_PROJECT;
protected readonly DASHBOARD_USER_WORKFLOW = DASHBOARD_USER_WORKFLOW;
protected readonly DASHBOARD_USER_DATASET = DASHBOARD_USER_DATASET;
+ protected readonly DASHBOARD_USER_MODEL = DASHBOARD_USER_MODEL;
protected readonly DASHBOARD_USER_QUOTA = DASHBOARD_USER_QUOTA;
protected readonly DASHBOARD_USER_DISCUSSION = DASHBOARD_USER_DISCUSSION;
protected readonly DASHBOARD_ADMIN_USER = DASHBOARD_ADMIN_USER;
diff --git a/core/gui/src/app/dashboard/component/user/user-model/user-dataset-explorer/model-detail.component.html b/core/gui/src/app/dashboard/component/user/user-model/user-dataset-explorer/model-detail.component.html
new file mode 100644
index 0000000000..e0bee86403
--- /dev/null
+++ b/core/gui/src/app/dashboard/component/user/user-model/user-dataset-explorer/model-detail.component.html
@@ -0,0 +1,336 @@
+<!--
+ 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.
+-->
+
+<div *ngIf="!isMaximized">
+ <nz-card>
+ <h2 class="dataset-title">Dataset: {{datasetName}}</h2>
+ <nz-card-meta
+ style="padding-top: 10px"
+ nzDescription="Created at: {{datasetCreationTime}}"></nz-card-meta>
+ <nz-card-meta
+ style="padding-top: 20px"
+ nzDescription="{{datasetDescription}}"></nz-card-meta>
+ <div
+ class="workflow-panel"
+ style="padding-top: 30px">
+ <div
+ *ngIf="!(userDatasetAccessLevel === 'NONE')"
+ class="dataset-controls">
+ <div class="control-row">
+ <label>Visibility:</label>
+ <nz-switch
+ [ngModel]="datasetIsPublic"
+ (ngModelChange)="onPublicStatusChange($event)"
+ [nzDisabled]="userDatasetAccessLevel !== 'WRITE'"
+ nzCheckedChildren="public"
+ nzUnCheckedChildren="private"></nz-switch>
+ </div>
+ <div
+ class="control-row"
+ *ngIf="isOwner">
+ <label>Download:</label>
+ <nz-switch
+ [ngModel]="datasetIsDownloadable"
+ (ngModelChange)="onDownloadableStatusChange($event)"
+ nzCheckedChildren="allowed"
+ nzUnCheckedChildren="blocked"></nz-switch>
+ </div>
+ </div>
+ <button
+ nz-button
+ nzType="text"
+ class="dropdown-item like-button"
+ title="View"
+ (click)="changeViewDisplayStyle()">
+ <i
+ nz-icon
+ [nzType]="'eye'"
+ class="like-icon">
+ </i>
+ <span>{{ formatCount(viewCount) }}</span>
+ </button>
+ <button
+ nz-button
+ nzType="text"
+ class="like-button"
+ title="Like"
+ [disabled]="!isLogin"
+ (click)="toggleLike()">
+ <i
+ nz-icon
+ [nzType]="'like'"
+ [ngClass]="{liked: isLiked}">
+ </i>
+ <span>{{ formatCount(likeCount) }}</span>
+ </button>
+ </div>
+ </nz-card>
+</div>
+<nz-layout>
+ <nz-content
+ [ngClass]="{'grayed-out': false, 'disabled-click': false}"
+ style="background-color: white">
+ <nz-card>
+ <div style="display: flex; justify-content: space-between; align-items: center">
+ <div class="file-info">
+ <h3 class="file-title">
+ <b>{{currentDisplayedFileName}}</b>
+ <span
+ *ngIf="currentFileSize"
+ class="file-size">
+ <i
+ nz-icon
+ nzType="file"
+ nzTheme="outline"
+ class="icon-file"></i>
+ {{ formatSize(currentFileSize) }}
+ </span>
+ </h3>
+ </div>
+ <div style="display: flex">
+ <button
+ nz-button
+ *ngIf="selectedVersion"
+ nz-tooltip="Download the file"
+ [disabled]="!isLogin || !isDownloadAllowed()"
+ (click)="onClickDownloadCurrentFile()">
+ <i
+ nz-icon
+ nzTheme="outline"
+ nzType="download">
+ </i>
+ </button>
+ <button
+ nz-button
+ *ngIf="!isMaximized && selectedVersion"
+ nz-tooltip="Maximize View"
+ (click)="onClickScaleTheView()">
+ <i
+ nz-icon
+ nzTheme="outline"
+ nzType="expand">
+ </i>
+ </button>
+ <button
+ nz-button
+ *ngIf="isMaximized && selectedVersion"
+ nz-tooltip="Minimize View"
+ (click)="onClickScaleTheView()">
+ <i
+ nz-icon
+ nzTheme="outline"
+ nzType="compress">
+ </i>
+ </button>
+ <button
+ *ngIf="!isRightBarCollapsed"
+ nz-button
+ nz-tooltip="Hide the right bar"
+ (click)="onClickHideRightBar()">
+ <i
+ nz-icon
+ nzTheme="outline"
+ nzType="right">
+ </i>
+ </button>
+ <button
+ *ngIf="isRightBarCollapsed"
+ nz-button
+ nz-tooltip="Show Tree"
+ (click)="onClickHideRightBar()">
+ <i
+ nz-icon
+ nzTheme="outline"
+ nzType="left">
+ </i>
+ </button>
+ </div>
+ </div>
+ </nz-card>
+ <nz-empty
+ class="empty-version-indicator"
+ *ngIf="!selectedVersion"
+ nzNotFoundContent="No version is selected"></nz-empty>
+
+ <texera-user-dataset-file-renderer
+ *ngIf="selectedVersion"
+ [isMaximized]="isMaximized"
+ [did]="did"
+ [dvid]="selectedVersion?.dvid"
+ [filePath]="currentDisplayedFileName"
+ [fileSize]="currentFileSize"
+ [isLogin]="isLogin"
+ class="file-renderer">
+ </texera-user-dataset-file-renderer>
+ </nz-content>
+ <nz-sider
+ *ngIf="!isRightBarCollapsed"
+ nzTheme="light"
+ style="float: right; height: 100%"
+ [nzWidth]="siderWidth"
+ nz-resizable
+ [nzMinWidth]="MIN_SIDER_WIDTH"
+ [nzMaxWidth]="MAX_SIDER_WIDTH"
+ (nzResize)="onSideResize($event)">
+ <nz-resize-handle nzDirection="left">
+ <div class="sider-resize-line">
+ <i
+ class="sider-resize-handle"
+ nz-icon
+ nzType="more"
+ nzTheme="outline"></i>
+ </div>
+ </nz-resize-handle>
+ <div class="right-sider">
+ <nz-collapse nzGhost>
+ <nz-collapse-panel
+ nzHeader="Current Versions"
+ nzActive="true">
+ <div class="version-storage">
+ <h6 style="font-weight: lighter; font-size: 0.9em">Choose a Version:</h6>
+ <div class="select-and-button-container">
+ <nz-select
+ nzShowSearch
+ nzAllowClear
+ nzPlaceHolder="Select a version"
+ (ngModelChange)="onVersionSelected($event)"
+ [(ngModel)]="selectedVersion">
+ <nz-option
+ *ngFor="let version of versions"
+ [nzValue]="version"
+ [nzLabel]="version.name"></nz-option>
+ </nz-select>
+ <button
+ nz-button
+ nz-tooltip="Download Dataset"
+ (click)="onClickDownloadVersionAsZip()"
+ *ngIf="selectedVersion"
+ [disabled]="!isLogin || !isDownloadAllowed()"
+ class="spaced-button">
+ <i
+ nz-icon
+ nzType="download"
+ nzTheme="outline"></i>
+ </button>
+ </div>
+ <div
+ *ngIf="selectedVersion"
+ class="version-size">
+ <i
+ nz-icon
+ nzType="database"
+ nzTheme="outline"
+ class="icon-database"></i>
+ Version Size: {{ formatSize(currentDatasetVersionSize) }}
+ </div>
+ </div>
+ <texera-user-dataset-version-filetree
+ [fileTreeNodes]="fileTreeNodeList"
+ [isTreeNodeDeletable]="true"
+ (selectedTreeNode)="onVersionFileTreeNodeSelected($event)"
+ (deletedTreeNode)="onPreviouslyUploadedFileDeleted($event)">
+ </texera-user-dataset-version-filetree>
+ </nz-collapse-panel>
+ </nz-collapse>
+
+ <nz-divider></nz-divider>
+ <nz-collapse
+ *ngIf="userDatasetAccessLevel === 'WRITE'"
+ nzGhost>
+ <nz-collapse-panel
+ 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]="
+ (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>
+
+ <nz-tag *ngIf="(task.status === 'finished' || task.status === 'aborted')">
+ Upload time: {{ formatTime(task.totalTime ?? 0) }}
+ </nz-tag>
+ </div>
+ </div>
+ </div>
+
+ <texera-dataset-staged-objects-list
+ [uploadTimeMap]="uploadTimeMap"
+ [did]="did"
+ [userMakeChangesEvent]="userMakeChanges"
+ (stagedObjectsChanged)="onStagedObjectsUpdated($event)"></texera-dataset-staged-objects-list>
+ <div
+ *ngIf="userHasWriteAccess() && userHasPendingChanges"
+ class="version-creator">
+ <div class="version-input-container">
+ <label>Version:</label>
+ <input
+ nz-input
+ [(ngModel)]="versionName"
+ placeholder="Describe the new version (Optional)"
+ [disabled]="isCreatingVersion"
+ (keydown.enter)="onClickOpenVersionCreator()"
+ class="version-input" />
+ </div>
+ <div>
+ <button
+ nz-button
+ nzType="primary"
+ [nzLoading]="isCreatingVersion"
+ (click)="onClickOpenVersionCreator()"
+ class="create-dataset-version-button">
+ Submit
+ </button>
+ </div>
+ </div>
+ </nz-collapse-panel>
+ </nz-collapse>
+ </div>
+ </nz-sider>
+</nz-layout>
diff --git a/core/gui/src/app/dashboard/component/user/user-model/user-dataset-explorer/model-detail.component.scss b/core/gui/src/app/dashboard/component/user/user-model/user-dataset-explorer/model-detail.component.scss
new file mode 100644
index 0000000000..68087749ae
--- /dev/null
+++ b/core/gui/src/app/dashboard/component/user/user-model/user-dataset-explorer/model-detail.component.scss
@@ -0,0 +1,247 @@
+/**
+ * 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.
+ */
+
+.create-dataset-version-button {
+ display: flex; /* Use flexbox for centering */
+ align-items: center; /* Center vertically */
+ justify-content: center; /* Center horizontally */
+ color: white;
+ border: none;
+ padding: 12px 40px; /* Increase padding for a wider button */
+ border-radius: 25px;
+ cursor: pointer;
+ transition: background-color 0.3s;
+ margin: 18px auto 0 auto; /* Auto margins for horizontal centering */
+ width: 200px; /* Adjust width as needed */
+ font-size: 18px; /* Make text slightly bigger */
+ font-weight: bold; /* Optional: Make text bold */
+}
+
+.version-storage {
+ padding-left: 20px;
+ width: 90%;
+ margin-left: auto;
+ margin-right: auto;
+ margin-bottom: 25px;
+}
+
+.version-storage nz-select {
+ width: 100%; /* Make the select element take the full width of its parent */
+}
+
+.right-panel-title {
+ padding-left: 20px;
+ text-align: center;
+ margin-top: 10px;
+}
+
+nz-layout {
+ height: 100%;
+}
+
+html,
+body {
+ height: 100%;
+ margin: 0;
+ padding: 0;
+}
+
+.sider-resize-line {
+ height: 100%;
+ width: 5px;
+ border-right: 1px solid #e8e8e8;
+}
+
+.sider-resize-handle {
+ background: #fff;
+ border: 1px solid #ddd;
+ text-align: center;
+ font-size: 12px;
+ height: 20px;
+ line-height: 20px;
+ margin-top: 400px;
+}
+
+.file-renderer {
+ width: 95%;
+ height: 80%;
+ margin: auto;
+}
+
+.dataset-version-creator {
+ height: 100%;
+}
+
+.grayed-out {
+ filter: grayscale(1) opacity(0.7);
+ background-color: #f0f0f0; /* Adjust the color as needed */
+}
+
+.disabled-click {
+ pointer-events: none;
+}
+
+.select-and-button-container {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.spaced-button {
+ flex-shrink: 0;
+ width: 30px;
+ height: 24px;
+}
+
+nz-select {
+ flex-grow: 1;
+}
+
+.file-title {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.file-size,
+.version-size {
+ font-size: 12px;
+ color: #8c8c8c;
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+}
+
+.version-size {
+ margin-top: 8px;
+}
+
+.icon-database,
+.icon-file {
+ font-size: 14px;
+}
+
+.version-size-badge {
+ margin-top: 10px;
+}
+
+.nz-tag {
+ font-size: 12px;
+}
+
+.workflow-panel {
+ align-items: center;
+ gap: 10px;
+}
+
+.like-button {
+ margin-left: 15px;
+ width: 70px;
+ border-radius: 8px;
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+}
+
+.liked {
+ color: red;
+}
+
+.empty-version-indicator {
+ margin-top: 15%;
+}
+
+.upload-progress-wrapper {
+ max-height: 25vh;
+ overflow-y: auto;
+ padding-right: 4px;
+}
+
+.upload-progress-container {
+ margin-left: 20px;
+}
+
+.version-creator {
+ margin-top: 20px;
+ padding: 40px;
+}
+
+.version-input-container label {
+ font-size: 15px;
+}
+
+.version-input-container {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.version-input {
+ padding: 6px;
+}
+
+.dataset-controls {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ margin-right: 20px;
+ margin-bottom: 20px;
+}
+
+.control-row {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+
+ label {
+ min-width: 80px;
+ font-weight: 500;
+ color: #666;
+ }
+
+ nz-switch {
+ margin-left: 8px;
+ }
+
+ .help-text {
+ margin-left: 8px;
+ font-size: 12px;
+ color: #999;
+ font-style: italic;
+ }
+}
+
+.upload-stats {
+ font-size: 13px;
+ margin-bottom: 20px;
+}
+
+:host ::ng-deep .upload-stats .ant-tag {
+ border: none;
+}
+
+.fixed-width-speed {
+ display: inline-block;
+ min-width: 5ch;
+ text-align: right;
+}
+
+.fixed-width-time {
+ display: inline-block;
+ min-width: 2ch;
+ text-align: right;
+}
diff --git a/core/gui/src/app/dashboard/component/user/user-model/user-dataset-explorer/model-detail.component.ts b/core/gui/src/app/dashboard/component/user/user-model/user-dataset-explorer/model-detail.component.ts
new file mode 100644
index 0000000000..0762021f0b
--- /dev/null
+++ b/core/gui/src/app/dashboard/component/user/user-model/user-dataset-explorer/model-detail.component.ts
@@ -0,0 +1,567 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { Component, EventEmitter, OnInit, Output } from "@angular/core";
+import { ActivatedRoute } from "@angular/router";
+import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy";
+import { DatasetService, MultipartUploadProgress } from "../../../../service/user/dataset/dataset.service";
+import { NzResizeEvent } from "ng-zorro-antd/resizable";
+import {
+ DatasetFileNode,
+ getFullPathFromDatasetFileNode,
+ getRelativePathFromDatasetFileNode,
+} from "../../../../../common/type/datasetVersionFileTree";
+import { DatasetVersion } from "../../../../../common/type/dataset";
+import { switchMap, throttleTime } from "rxjs/operators";
+import { NotificationService } from "../../../../../common/service/notification/notification.service";
+import { DownloadService } from "../../../../service/user/download/download.service";
+import { formatSize } from "src/app/common/util/size-formatter.util";
+import { UserService } from "../../../../../common/service/user/user.service";
+import { isDefined } from "../../../../../common/util/predicate";
+import { ActionType, EntityType, HubService, LikedStatus } from "../../../../../hub/service/hub.service";
+import { FileUploadItem } from "../../../../type/dashboard-file.interface";
+import { DatasetStagedObject } from "../../../../../common/type/dataset-staged-object";
+import { NzModalService } from "ng-zorro-antd/modal";
+import { UserModelVersionCreatorComponent } from "./user-dataset-version-creator/user-model-version-creator.component";
+import { AdminSettingsService } from "../../../../service/admin/settings/admin-settings.service";
+import { HttpErrorResponse } from "@angular/common/http";
+import { Subscription } from "rxjs";
+import { formatSpeed, formatTime } from "src/app/common/util/format.util";
+
+export const THROTTLE_TIME_MS = 1000;
+
+@UntilDestroy()
+@Component({
+ templateUrl: "./model-detail.component.html",
+ styleUrls: ["./model-detail.component.scss"],
+})
+export class ModelDetailComponent implements OnInit {
+ public did: number | undefined;
+ public datasetName: string = "";
+ public datasetDescription: string = "";
+ public datasetCreationTime: string = "";
+ public datasetIsPublic: boolean = false;
+ public datasetIsDownloadable: boolean = true;
+ public userDatasetAccessLevel: "READ" | "WRITE" | "NONE" = "NONE";
+ public isOwner: boolean = false;
+
+ public currentDisplayedFileName: string = "";
+ public currentFileSize: number | undefined;
+ public currentDatasetVersionSize: number | undefined;
+
+ public isRightBarCollapsed = false;
+ public isMaximized = false;
+
+ public versions: ReadonlyArray<DatasetVersion> = [];
+ public selectedVersion: DatasetVersion | undefined;
+ public fileTreeNodeList: DatasetFileNode[] = [];
+
+ public versionCreatorBaseVersion: DatasetVersion | undefined;
+ public isLogin: boolean = this.userService.isLogin();
+
+ public isLiked: boolean = false;
+ public likeCount: number = 0;
+ public currentUid: number | undefined;
+ public viewCount: number = 0;
+ public displayPreciseViewCount = false;
+
+ userHasPendingChanges: boolean = false;
+ // Uploading setting
+ chunkSizeMB: number = 50;
+ maxConcurrentChunks: number = 10;
+ private uploadSubscriptions = new Map<string, Subscription>();
+ uploadTimeMap = new Map<string, number>();
+
+ versionName: string = "";
+ isCreatingVersion: boolean = false;
+
+ // List of upload tasks – each task tracked by its filePath
+ public uploadTasks: Array<
+ MultipartUploadProgress & {
+ filePath: string;
+ }
+ > = [];
+ private autoHideTimers: number[] = [];
+
+ @Output() userMakeChanges = new EventEmitter<void>();
+
+ constructor(
+ private route: ActivatedRoute,
+ private modalService: NzModalService,
+ private datasetService: DatasetService,
+ private notificationService: NotificationService,
+ private downloadService: DownloadService,
+ private userService: UserService,
+ private hubService: HubService,
+ private adminSettingsService: AdminSettingsService
+ ) {
+ this.userService
+ .userChanged()
+ .pipe(untilDestroyed(this))
+ .subscribe(() => {
+ this.currentUid = this.userService.getCurrentUser()?.uid;
+ this.isLogin = this.userService.isLogin();
+ });
+ }
+
+ // item for control the resizeable sider
+ MAX_SIDER_WIDTH = 600;
+ MIN_SIDER_WIDTH = 150;
+ siderWidth = 400;
+ id = -1;
+ onSideResize({ width }: NzResizeEvent): void {
+ cancelAnimationFrame(this.id);
+ this.id = requestAnimationFrame(() => {
+ this.siderWidth = width!;
+ });
+ }
+
+ ngOnInit(): void {
+ this.route.params
+ .pipe(
+ switchMap(params => {
+ this.did = params["did"];
+ this.retrieveDatasetInfo();
+ this.retrieveDatasetVersionList();
+ return this.route.data; // or some other observable
+ }),
+ untilDestroyed(this)
+ )
+ .subscribe();
+
+ if (!isDefined(this.did)) {
+ return;
+ }
+
+ this.hubService
+ .getCounts([EntityType.Dataset], [this.did], [ActionType.Like])
+ .pipe(untilDestroyed(this))
+ .subscribe(counts => {
+ this.likeCount = counts[0].counts.like ?? 0;
+ });
+
+ this.hubService
+ .postView(this.did, this.currentUid ? this.currentUid : 0, EntityType.Dataset)
+ .pipe(throttleTime(THROTTLE_TIME_MS))
+ .pipe(untilDestroyed(this))
+ .subscribe(count => {
+ this.viewCount = count;
+ });
+
+ if (!isDefined(this.currentUid)) {
+ return;
+ }
+
+ this.hubService
+ .isLiked([this.did], [EntityType.Dataset])
+ .pipe(untilDestroyed(this))
+ .subscribe((isLiked: LikedStatus[]) => {
+ this.isLiked = isLiked.length > 0 ? isLiked[0].isLiked : false;
+ });
+
+ this.loadUploadSettings();
+ }
+
+ public onClickOpenVersionCreator() {
+ if (this.did && !this.isCreatingVersion) {
+ this.isCreatingVersion = true;
+
+ this.datasetService
+ .createDatasetVersion(this.did, this.versionName?.trim() || "")
+ .pipe(untilDestroyed(this))
+ .subscribe({
+ next: res => {
+ this.notificationService.success("Version Created");
+ this.isCreatingVersion = false;
+ this.versionName = "";
+ this.retrieveDatasetVersionList();
+ this.userMakeChanges.emit();
+ },
+ error: (res: unknown) => {
+ const err = res as HttpErrorResponse;
+ this.notificationService.error(`Version creation failed: ${err.error.message}`);
+ this.isCreatingVersion = false;
+ },
+ });
+ }
+ }
+
+ public onClickDownloadVersionAsZip() {
+ if (this.did && this.selectedVersion && this.selectedVersion.dvid) {
+ this.downloadService
+ .downloadDatasetVersion(this.did, this.selectedVersion.dvid, this.datasetName, this.selectedVersion.name)
+ .pipe(untilDestroyed(this))
+ .subscribe();
+ }
+ }
+
+ onPublicStatusChange(checked: boolean): void {
+ // Handle the change in dataset public status
+ if (this.did) {
+ this.datasetService
+ .updateDatasetPublicity(this.did)
+ .pipe(untilDestroyed(this))
+ .subscribe({
+ next: (res: Response) => {
+ this.datasetIsPublic = checked;
+ let state = "public";
+ if (!this.datasetIsPublic) {
+ state = "private";
+ }
+ this.notificationService.success(`Dataset ${this.datasetName} is now ${state}`);
+ },
+ error: (err: unknown) => {
+ this.notificationService.error("Fail to change the dataset publicity");
+ },
+ });
+ }
+ }
+
+ onDownloadableStatusChange(checked: boolean): void {
+ // Handle the change in dataset downloadable status
+ if (this.did) {
+ this.datasetService
+ .updateDatasetDownloadable(this.did)
+ .pipe(untilDestroyed(this))
+ .subscribe({
+ next: (res: Response) => {
+ this.datasetIsDownloadable = checked;
+ let state = "allowed";
+ if (!this.datasetIsDownloadable) {
+ state = "not allowed";
+ }
+ this.notificationService.success(`Dataset downloads are now ${state}`);
+ },
+ error: (err: unknown) => {
+ this.notificationService.error("Failed to change the dataset download permission");
+ },
+ });
+ }
+ }
+
+ retrieveDatasetInfo() {
+ if (this.did) {
+ this.datasetService
+ .getDataset(this.did, this.isLogin)
+ .pipe(untilDestroyed(this))
+ .subscribe(dashboardDataset => {
+ const dataset = dashboardDataset.dataset;
+ this.datasetName = dataset.name;
+ this.datasetDescription = dataset.description;
+ this.userDatasetAccessLevel = dashboardDataset.accessPrivilege;
+ this.datasetIsPublic = dataset.isPublic;
+ this.datasetIsDownloadable = dataset.isDownloadable;
+ this.isOwner = dashboardDataset.isOwner;
+ if (typeof dataset.creationTime === "number") {
+ this.datasetCreationTime = new Date(dataset.creationTime).toString();
+ }
+ });
+ }
+ }
+
+ retrieveDatasetVersionList() {
+ if (this.did) {
+ this.datasetService
+ .retrieveDatasetVersionList(this.did, this.isLogin)
+ .pipe(untilDestroyed(this))
+ .subscribe(versionNames => {
+ this.versions = versionNames;
+ // by default, the selected version is the 1st element in the retrieved list
+ // which is guaranteed(by the backend) to be the latest created version.
+ if (this.versions.length > 0) {
+ this.selectedVersion = this.versions[0];
+ this.onVersionSelected(this.selectedVersion);
+ }
+ });
+ }
+ }
+
+ loadFileContent(node: DatasetFileNode) {
+ this.currentDisplayedFileName = getFullPathFromDatasetFileNode(node);
+ this.currentFileSize = node.size;
+ }
+
+ onClickDownloadCurrentFile = (): void => {
+ if (!this.did || !this.selectedVersion?.dvid) return;
+ // For public datasets accessed by non-owners, use public endpoint
+ const shouldUsePublicEndpoint = this.datasetIsPublic && !this.isOwner;
+ this.downloadService
+ .downloadSingleFile(this.currentDisplayedFileName, !shouldUsePublicEndpoint)
+ .pipe(untilDestroyed(this))
+ .subscribe();
+ };
+
+ onClickScaleTheView() {
+ this.isMaximized = !this.isMaximized;
+ }
+
+ onClickHideRightBar() {
+ this.isRightBarCollapsed = !this.isRightBarCollapsed;
+ }
+
+ onStagedObjectsUpdated(stagedObjects: DatasetStagedObject[]) {
+ this.userHasPendingChanges = stagedObjects.length > 0;
+ }
+
+ onVersionSelected(version: DatasetVersion): void {
+ this.selectedVersion = version;
+ if (this.did && this.selectedVersion.dvid)
+ this.datasetService
+ .retrieveDatasetVersionFileTree(this.did, this.selectedVersion.dvid, this.isLogin)
+ .pipe(untilDestroyed(this))
+ .subscribe(data => {
+ this.fileTreeNodeList = data.fileNodes;
+ this.currentDatasetVersionSize = data.size;
+ let currentNode = this.fileTreeNodeList[0];
+ while (currentNode.type === "directory" && currentNode.children) {
+ currentNode = currentNode.children[0];
+ }
+ this.loadFileContent(currentNode);
+ });
+ }
+
+ onVersionFileTreeNodeSelected(node: DatasetFileNode) {
+ this.loadFileContent(node);
+ }
+
+ userHasWriteAccess(): boolean {
+ return this.userDatasetAccessLevel == "WRITE";
+ }
+
+ isDownloadAllowed(): boolean {
+ // Owners can always download
+ if (this.isOwner) {
+ return true;
+ }
+ // Non-owners can download if dataset is downloadable and they have access
+ // For public datasets, users have access even if userDatasetAccessLevel is 'NONE'
+ // For private datasets, users need explicit access (userDatasetAccessLevel !== 'NONE')
+ return this.datasetIsDownloadable && (this.datasetIsPublic || this.userDatasetAccessLevel !== "NONE");
+ }
+
+ // Track multiple file by unique key
+ trackByTask(_: number, task: MultipartUploadProgress & { filePath: string }): string {
+ return task.filePath;
+ }
+
+ private loadUploadSettings(): void {
+ this.adminSettingsService
+ .getSetting("multipart_upload_chunk_size_mb")
+ .pipe(untilDestroyed(this))
+ .subscribe(value => (this.chunkSizeMB = parseInt(value)));
+ this.adminSettingsService
+ .getSetting("max_number_of_concurrent_uploading_file_chunks")
+ .pipe(untilDestroyed(this))
+ .subscribe(value => (this.maxConcurrentChunks = 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.chunkSizeMB * 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);
+ }
+ }
+ },
+ 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);
+ });
+ }
+ }
+
+ // Hide a task row after 5s (stores timer to clear on destroy) and clean up its subscription
+ private scheduleHide(idx: number) {
+ if (idx === -1) {
+ return;
+ }
+ const key = this.uploadTasks[idx].filePath;
+ this.uploadSubscriptions.delete(key);
+ const handle = window.setTimeout(() => {
+ this.uploadTasks = this.uploadTasks.filter(t => t.filePath !== key);
+ }, 5000);
+ this.autoHideTimers.push(handle);
+ }
+
+ onClickAbortUploadProgress(task: MultipartUploadProgress & { filePath: string }) {
+ const subscription = this.uploadSubscriptions.get(task.filePath);
+ if (subscription) {
+ subscription.unsubscribe();
+ this.uploadSubscriptions.delete(task.filePath);
+ }
+ this.datasetService
+ .finalizeMultipartUpload(
+ this.datasetName,
+ task.filePath,
+ task.uploadId,
+ [],
+ task.physicalAddress,
+ true // abort flag
+ )
+ .pipe(untilDestroyed(this))
+ .subscribe(() => {
+ this.notificationService.info(`${task.filePath} uploading has been terminated`);
+ });
+ // Remove the aborted task immediately
+ this.uploadTasks = this.uploadTasks.filter(t => t.filePath !== task.filePath);
+ }
+
+ getUploadStatus(status: "initializing" | "uploading" | "finished" | "aborted"): "active" | "exception" | "success" {
+ return status === "uploading" || status === "initializing"
+ ? "active"
+ : status === "aborted"
+ ? "exception"
+ : "success";
+ }
+
+ onPreviouslyUploadedFileDeleted(node: DatasetFileNode) {
+ if (this.did) {
+ this.datasetService
+ .deleteDatasetFile(this.did, getRelativePathFromDatasetFileNode(node))
+ .pipe(untilDestroyed(this))
+ .subscribe({
+ next: (res: Response) => {
+ this.notificationService.success(
+ `File ${node.name} is successfully deleted. You may finalize it or revert it at the "Create Version" panel`
+ );
+ this.userMakeChanges.emit();
+ },
+ error: (err: unknown) => {
+ this.notificationService.error("Failed to delete the file");
+ },
+ });
+ }
+ }
+
+ // alias for formatSize
+ formatSize = formatSize;
+
+ formatCount(count: number): string {
+ if (count >= 1000) {
+ return (count / 1000).toFixed(1) + "k";
+ }
+ return count.toString();
+ }
+ formatTime = formatTime;
+ formatSpeed = formatSpeed;
+
+ toggleLike(): void {
+ const userId = this.currentUid;
+ if (!isDefined(userId) || !isDefined(this.did)) {
+ return;
+ }
+
+ if (this.isLiked) {
+ this.hubService
+ .postUnlike(this.did, EntityType.Dataset)
+ .pipe(untilDestroyed(this))
+ .subscribe((success: boolean) => {
+ if (success) {
+ this.isLiked = false;
+ this.hubService
+ .getCounts([EntityType.Dataset], [this.did!], [ActionType.Like])
+ .pipe(untilDestroyed(this))
+ .subscribe(counts => {
+ this.likeCount = counts[0].counts.like ?? 0;
+ });
+ }
+ });
+ } else {
+ this.hubService
+ .postLike(this.did, EntityType.Dataset)
+ .pipe(untilDestroyed(this))
+ .subscribe((success: boolean) => {
+ if (success) {
+ this.isLiked = true;
+ this.hubService
+ .getCounts([EntityType.Dataset], [this.did!], [ActionType.Like])
+ .pipe(untilDestroyed(this))
+ .subscribe(counts => {
+ this.likeCount = counts[0].counts.like ?? 0;
+ });
+ }
+ });
+ }
+ }
+
+ changeViewDisplayStyle() {
+ this.displayPreciseViewCount = !this.displayPreciseViewCount;
+ }
+}
diff --git a/core/gui/src/app/dashboard/component/user/user-model/user-dataset-explorer/user-dataset-file-renderer/user-dataset-file-renderer.component.html b/core/gui/src/app/dashboard/component/user/user-model/user-dataset-explorer/user-dataset-file-renderer/user-dataset-file-renderer.component.html
new file mode 100644
index 0000000000..fd0ba3af15
--- /dev/null
+++ b/core/gui/src/app/dashboard/component/user/user-model/user-dataset-explorer/user-dataset-file-renderer/user-dataset-file-renderer.component.html
@@ -0,0 +1,104 @@
+<!--
+ 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.
+-->
+
+<div
+ [ngStyle]="!isMaximized ? {'padding-top': '15px', 'margin-left': '30px', 'width': '95%', 'height': '80%', 'overflow-x': 'auto', 'overflow-y': 'auto'}:
+ {'padding-top': '15px', 'margin-left': '30px', 'width': '95%', 'height': '100%', 'overflow-x': 'auto', 'overflow-y': 'auto'}">
+ <div class="file-display-area">
+ <div *ngIf="isLoading">
+ <nz-spin nzTip="Loading...">
+ <nz-alert
+ nzType="info"
+ nzMessage="File content is loading"></nz-alert>
+ </nz-spin>
+ </div>
+ <div *ngIf="isFileLoadingError">
+ <nz-alert
+ nzType="error"
+ nzMessage="File loading encounter error."
+ nzDescription="Ops, There is something wrong when loading the file you are requesting."></nz-alert>
+ </div>
+ <div *ngIf="isFileSizeUnloadable">
+ <nz-alert
+ nzType="warning"
+ nzMessage="File is too large to preview"></nz-alert>
+ </div>
+ <div *ngIf="isFileTypePreviewUnsupported">
+ <nz-alert
+ nzType="warning"
+ nzMessage="Preview of the file type is currently not supported"></nz-alert>
+ </div>
+
+ <nz-table
+ *ngIf="displayCSV || displayXlsx"
+ #basicTable
+ [nzData]="tableContent">
+ <thead>
+ <tr>
+ <th *ngFor="let column of tableDataHeader">{{ column }}</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr *ngFor="let row of basicTable.data">
+ <td *ngFor="let cell of row">{{ cell }}</td>
+ </tr>
+ </tbody>
+ </nz-table>
+
+ <div *ngIf="displayImage && safeFileURL">
+ <img
+ [src]="safeFileURL"
+ alt="{{filePath}}"
+ (click)="toggleImageModal()"
+ style="width: 50%; height: 70%; margin: auto" />
+ </div>
+
+ <!-- Full-size image modal -->
+ <div
+ *ngIf="showImageModal"
+ class="image-modal"
+ (click)="toggleImageModal()">
+ <img
+ [src]="safeFileURL"
+ alt="{{filePath}}"
+ class="full-size-image" />
+ </div>
+ <div *ngIf="displayMarkdown">
+ <markdown [data]="textContent"></markdown>
+ </div>
+ <div *ngIf="displayJson">
+ <ngx-json-viewer [json]="textContent"></ngx-json-viewer>
+ </div>
+ <div *ngIf="displayMP4 && safeFileURL">
+ <video
+ controls
+ [src]="safeFileURL">
+ Your browser does not support the video tag.
+ </video>
+ </div>
+ <div *ngIf="displayMP3 && safeFileURL">
+ <audio
+ controls
+ [src]="safeFileURL">
+ Your browser does not support the audio element.
+ </audio>
+ </div>
+ <div *ngIf="displayPlainText">{{ textContent }}</div>
+ </div>
+</div>
diff --git a/core/gui/src/app/dashboard/type/dashboard-dataset.interface.ts b/core/gui/src/app/dashboard/component/user/user-model/user-dataset-explorer/user-dataset-file-renderer/user-dataset-file-renderer.component.scss
similarity index 69%
copy from core/gui/src/app/dashboard/type/dashboard-dataset.interface.ts
copy to core/gui/src/app/dashboard/component/user/user-model/user-dataset-explorer/user-dataset-file-renderer/user-dataset-file-renderer.component.scss
index 2ea0058264..4b5865edaf 100644
--- a/core/gui/src/app/dashboard/type/dashboard-dataset.interface.ts
+++ b/core/gui/src/app/dashboard/component/user/user-model/user-dataset-explorer/user-dataset-file-renderer/user-dataset-file-renderer.component.scss
@@ -17,13 +17,27 @@
* under the License.
*/
-import { Dataset, DatasetVersion } from "../../common/type/dataset";
-import { DatasetFileNode } from "../../common/type/datasetVersionFileTree";
+.file-display-area {
+ width: 90%;
+ height: 100%;
+ margin: auto;
+}
+
+/* CSS styles */
+.image-modal {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background-color: rgba(0, 0, 0, 0.8);
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ z-index: 1000;
+}
-export interface DashboardDataset {
- isOwner: boolean;
- ownerEmail: string;
- dataset: Dataset;
- accessPrivilege: "READ" | "WRITE" | "NONE";
- size: number;
+.full-size-image {
+ max-width: 90%;
+ max-height: 90%;
}
diff --git a/core/gui/src/app/dashboard/component/user/user-model/user-dataset-explorer/user-dataset-file-renderer/user-dataset-file-renderer.component.spec.ts b/core/gui/src/app/dashboard/component/user/user-model/user-dataset-explorer/user-dataset-file-renderer/user-dataset-file-renderer.component.spec.ts
new file mode 100644
index 0000000000..0062a9a328
--- /dev/null
+++ b/core/gui/src/app/dashboard/component/user/user-model/user-dataset-explorer/user-dataset-file-renderer/user-dataset-file-renderer.component.spec.ts
@@ -0,0 +1,57 @@
+/**
+ * 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 { TestBed } from "@angular/core/testing";
+import { HttpClientTestingModule } from "@angular/common/http/testing";
+import { UserDatasetFileRendererComponent } from "./user-dataset-file-renderer.component";
+import { DatasetService } from "../../../../../service/user/dataset/dataset.service";
+import { NotificationService } from "../../../../../../common/service/notification/notification.service";
+import { DomSanitizer } from "@angular/platform-browser";
+import { commonTestProviders } from "../../../../../../common/testing/test-utils";
+
+describe("UserDatasetFileRendererComponent", () => {
+ let component: UserDatasetFileRendererComponent;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ imports: [HttpClientTestingModule],
+ declarations: [UserDatasetFileRendererComponent],
+ providers: [
+ DatasetService,
+ NotificationService,
+ { provide: DomSanitizer, useValue: jasmine.createSpyObj("DomSanitizer", ["bypassSecurityTrustUrl"]) },
+ ...commonTestProviders,
+ ],
+ });
+ const fixture = TestBed.createComponent(UserDatasetFileRendererComponent);
+ component = fixture.componentInstance;
+ });
+
+ it("should return true for supported MIME type", () => {
+ const supportedMimeType = "image/jpeg"; // Example of a supported MIME type
+ const result = component.isPreviewSupported(supportedMimeType);
+ expect(result).toBeTrue();
+ });
+
+ it("should return false for unsupported MIME type", () => {
+ const unsupportedMimeType = "application/unknown"; // Example of an unsupported MIME type
+ const result = component.isPreviewSupported(unsupportedMimeType);
+ expect(result).toBeFalse();
+ });
+});
diff --git a/core/gui/src/app/dashboard/component/user/user-model/user-dataset-explorer/user-dataset-file-renderer/user-dataset-file-renderer.component.ts b/core/gui/src/app/dashboard/component/user/user-model/user-dataset-explorer/user-dataset-file-renderer/user-dataset-file-renderer.component.ts
new file mode 100644
index 0000000000..b56c1e6d44
--- /dev/null
+++ b/core/gui/src/app/dashboard/component/user/user-model/user-dataset-explorer/user-dataset-file-renderer/user-dataset-file-renderer.component.ts
@@ -0,0 +1,347 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { Component, Input, Output, EventEmitter, OnInit, OnChanges, SimpleChanges, OnDestroy } from "@angular/core";
+import { DatasetService } from "../../../../../service/user/dataset/dataset.service";
+import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy";
+import * as Papa from "papaparse";
+import { ParseResult } from "papaparse";
+import { DomSanitizer, SafeUrl } from "@angular/platform-browser";
+import readXlsxFile from "read-excel-file";
+import { NotificationService } from "../../../../../../common/service/notification/notification.service";
+
+export const MIME_TYPES = {
+ JPEG: "image/jpeg",
+ JPG: "image/jpeg",
+ PNG: "image/png",
+ CSV: "text/csv",
+ TXT: "text/plain",
+ MD: "text/markdown",
+ HTML: "text/html",
+ JSON: "application/json",
+ PDF: "application/pdf",
+ MSWORD: "application/msword",
+ MSEXCEL: "application/vnd.ms-excel",
+ MSPOWERPOINT: "application/vnd.ms-powerpoint",
+ MP4: "video/mp4",
+ MP3: "audio/mpeg",
+ OCTET_STREAM: "application/octet-stream", // Default binary format
+};
+
+export function getMimeType(filename: string): string {
+ const extension = filename.split(".").pop()?.toUpperCase();
+ return extension && MIME_TYPES[extension as keyof typeof MIME_TYPES]
+ ? MIME_TYPES[extension as keyof typeof MIME_TYPES]
+ : MIME_TYPES.OCTET_STREAM;
+}
+
+// the size limits for all preview-supported types
+export const MIME_TYPE_SIZE_LIMITS_MB = {
+ [MIME_TYPES.JPEG]: 5 * 1024 * 1024, // 5 MB
+ [MIME_TYPES.PNG]: 5 * 1024 * 1024, // 5 MB
+ [MIME_TYPES.CSV]: 2 * 1024 * 1024, // 2 MB for text-based data files
+ [MIME_TYPES.TXT]: 1 * 1024 * 1024, // 1 MB for plain text files
+ [MIME_TYPES.MD]: 1 * 1024 * 1024, // 1 MB for MD files
+ [MIME_TYPES.JSON]: 1 * 1024 * 1024, // 1 MB for JSON files
+ [MIME_TYPES.MSEXCEL]: 10 * 1024 * 1024, // 10 MB for Excel spreadsheets
+ [MIME_TYPES.MP4]: 50 * 1024 * 1024, // 50 MB for MP4 videos
+ [MIME_TYPES.MP3]: 10 * 1024 * 1024, // 10 MB for MP3 audio files
+ [MIME_TYPES.OCTET_STREAM]: 5 * 1024 * 1024, // Default size for other binary formats
+};
+
+@UntilDestroy()
+@Component({
+ selector: "texera-user-dataset-file-renderer",
+ templateUrl: "./user-dataset-file-renderer.component.html",
+ styleUrls: ["./user-dataset-file-renderer.component.scss"],
+})
+export class UserDatasetFileRendererComponent implements OnInit, OnChanges, OnDestroy {
+ private DEFAULT_MAX_SIZE = 5 * 1024 * 1024; // 5 MB
+
+ public fileURL: string | undefined;
+ // safe url is used to display some formats including image
+ public safeFileURL: SafeUrl | undefined;
+
+ // table related control
+ public displayCSV: boolean = false;
+ public displayXlsx: boolean = false;
+ public tableDataHeader: any[] = [];
+ public tableContent: any[][] = [];
+
+ // image related control
+ public displayImage: boolean = false;
+
+ // markdown control
+ public displayMarkdown: boolean = false;
+
+ // json control
+ public displayJson: boolean = false;
+
+ // video
+ public displayMP4: boolean = false;
+
+ // audio
+ public displayMP3: boolean = false;
+
+ // plain text & octet stream related control
+ public displayPlainText: boolean = false;
+ public textContent: string = "";
+
+ // control flags
+ public isLoading: boolean = false;
+ public isFileSizeUnloadable = false;
+ public isFileLoadingError: boolean = false;
+ public isFileTypePreviewUnsupported: boolean = false;
+
+ public currentFile: File | undefined = undefined;
+ @Input()
+ isMaximized: boolean = false;
+
+ @Input()
+ did: number | undefined;
+
+ @Input()
+ dvid: number | undefined;
+
+ @Input()
+ filePath: string = "";
+
+ @Input()
+ fileSize?: number;
+
+ @Input()
+ isLogin: boolean = false;
+
+ @Output()
+ loadFile = new EventEmitter<{ file: string; prefix: string }>();
+
+ constructor(
+ private datasetService: DatasetService,
+ private sanitizer: DomSanitizer,
+ private notificationService: NotificationService
+ ) {}
+
+ ngOnInit(): void {
+ this.reloadFileContent();
+ }
+
+ ngOnChanges(changes: SimpleChanges): void {
+ if ((changes.did && changes.dvid) || changes.filePath) {
+ this.reloadFileContent();
+ }
+ }
+
+ ngOnDestroy(): void {
+ if (this.fileURL) {
+ URL.revokeObjectURL(this.fileURL);
+ }
+ }
+
+ showImageModal = false;
+
+ toggleImageModal() {
+ this.showImageModal = !this.showImageModal;
+ }
+
+ reloadFileContent() {
+ this.turnOffAllDisplay();
+
+ // Pre-check - file size
+ const mimeType = getMimeType(this.filePath);
+ if (!this.isPreviewSupported(mimeType)) {
+ this.onFileTypePreviewUnsupported();
+ return;
+ }
+ const limit = MIME_TYPE_SIZE_LIMITS_MB[mimeType] ?? this.DEFAULT_MAX_SIZE;
+ if (this.fileSize != null && this.fileSize > limit) {
+ this.onFileSizeNotLoadable();
+ return;
+ }
+
+ // Load file
+ this.isLoading = true;
+ if (this.did && this.dvid && this.filePath != "") {
+ this.datasetService
+ .retrieveDatasetVersionSingleFile(this.filePath, this.isLogin)
+ .pipe(untilDestroyed(this))
+ .subscribe({
+ next: blob => {
+ this.isLoading = false;
+ const blobMimeType = getMimeType(this.filePath);
+ if (!this.isPreviewSupported(blobMimeType)) {
+ this.onFileTypePreviewUnsupported();
+ return;
+ }
+ const MaxSize = MIME_TYPE_SIZE_LIMITS_MB[blobMimeType] || this.DEFAULT_MAX_SIZE;
+ const fileSize = blob.size;
+ if (fileSize > MaxSize) {
+ this.onFileSizeNotLoadable();
+ this.notificationService.warning(`File ${this.filePath} is too large to be previewed`);
+ return;
+ }
+ this.currentFile = new File([blob], this.filePath, { type: blob.type });
+ // Handle different file types
+ switch (blobMimeType) {
+ case MIME_TYPES.PNG:
+ case MIME_TYPES.JPEG:
+ this.displayImage = true;
+ this.loadSafeURL(blob);
+ break;
+ case MIME_TYPES.MP4:
+ this.displayMP4 = true;
+ this.loadSafeURL(blob);
+ break;
+
+ case MIME_TYPES.MP3:
+ this.displayMP3 = true;
+ this.loadSafeURL(blob);
+ break;
+
+ case MIME_TYPES.MSEXCEL:
+ readXlsxFile(blob).then(rows => {
+ let parsedData: string[][] = [];
+ rows.forEach(row => {
+ // Convert each cell in the row to a string
+ let stringRow = row.map(cell => (cell ? cell.toString() : ""));
+ // Add the string array to the main array
+ parsedData.push(stringRow);
+ });
+ if (parsedData.length > 0) {
+ this.loadTabularFile(parsedData);
+ this.displayXlsx = true;
+ }
+ });
+ break;
+ case MIME_TYPES.CSV:
+ this.displayCSV = true;
+ // Handle CSV display
+ Papa.parse(this.currentFile, {
+ complete: (results: ParseResult<any>) => {
+ if (results.data.length > 0) {
+ this.loadTabularFile(results.data);
+ }
+ },
+ error: error => {
+ console.error("Error parsing file:", error);
+ this.onFileLoadingError();
+ },
+ });
+ break;
+ case MIME_TYPES.MD:
+ this.displayMarkdown = true;
+ this.readFileAsText(blob);
+ break;
+ case MIME_TYPES.JSON:
+ this.displayJson = true;
+ this.readFileAsText(blob);
+ break;
+ case MIME_TYPES.TXT:
+ default:
+ this.displayPlainText = true;
+ this.readFileAsText(blob);
+ break;
+ }
+ },
+ });
+ }
+ }
+
+ turnOffAllDisplay() {
+ this.displayCSV = false;
+ this.displayXlsx = false;
+ this.displayImage = false;
+ this.displayPlainText = false;
+ this.displayMarkdown = false;
+ this.displayJson = false;
+ this.displayMP4 = false;
+ this.displayMP3 = false;
+ this.isLoading = false;
+ this.isFileLoadingError = false;
+ this.isFileSizeUnloadable = false;
+ this.isFileTypePreviewUnsupported = false;
+ // garbage collection
+ if (this.fileURL) {
+ URL.revokeObjectURL(this.fileURL);
+ }
+ if (this.safeFileURL) {
+ URL.revokeObjectURL(this.safeFileURL.toString());
+ }
+ }
+
+ onFileLoadingError() {
+ this.turnOffAllDisplay();
+ this.isFileLoadingError = true;
+ }
+
+ onFileSizeNotLoadable() {
+ this.turnOffAllDisplay();
+ this.isFileSizeUnloadable = true;
+ }
+
+ onFileTypePreviewUnsupported() {
+ this.turnOffAllDisplay();
+ this.isFileTypePreviewUnsupported = true;
+ }
+
+ isPreviewSupported(mimeType: string) {
+ return mimeType !== MIME_TYPES.OCTET_STREAM && Object.hasOwnProperty.call(MIME_TYPE_SIZE_LIMITS_MB, mimeType);
+ }
+
+ private readFileAsText(blob: Blob) {
+ const txtReader = new FileReader();
+ txtReader.onload = (event: any) => {
+ this.textContent = event.target.result;
+ };
+ txtReader.readAsText(blob);
+ }
+
+ private loadSafeURL(blob: Blob) {
+ this.fileURL = URL.createObjectURL(blob);
+ this.safeFileURL = this.sanitizer.bypassSecurityTrustUrl(this.fileURL);
+ }
+
+ private loadTabularFile(data: any[][]) {
+ if (data.length > 0) {
+ // Extract the header (first row)
+ this.tableDataHeader = data[0];
+
+ // Process the rest of the rows
+ this.tableContent = data
+ .slice(1)
+ .map(row => {
+ // Normalize the row length to match the header length
+ while (row.length < this.tableDataHeader.length) {
+ row.push("");
+ }
+ return row;
+ })
+ .filter(row => {
+ // filter out all empty row
+ let areCellAllEmpty = true;
+ for (const cell in row) {
+ if (cell != "") {
+ areCellAllEmpty = false;
+ break;
+ }
+ }
+ return !areCellAllEmpty;
+ });
+ }
+ }
+}
diff --git a/core/gui/src/app/dashboard/component/user/user-model/user-dataset-explorer/user-dataset-staged-objects-list/user-dataset-staged-objects-list.component.html b/core/gui/src/app/dashboard/component/user/user-model/user-dataset-explorer/user-dataset-staged-objects-list/user-dataset-staged-objects-list.component.html
new file mode 100644
index 0000000000..5b1dece350
--- /dev/null
+++ b/core/gui/src/app/dashboard/component/user/user-model/user-dataset-explorer/user-dataset-staged-objects-list/user-dataset-staged-objects-list.component.html
@@ -0,0 +1,58 @@
+<!--
+ 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.
+-->
+
+<div class="staged-object-list-container">
+ <nz-list
+ nzBordered
+ nzSize="small"
+ *ngIf="datasetStagedObjects.length > 0">
+ <nz-list-item *ngFor="let obj of datasetStagedObjects">
+ <span>
+ <nz-tag [nzColor]="obj.diffType === 'added' ? 'green' : 'red'"> {{ obj.diffType }} </nz-tag>
+ </span>
+ <span
+ class="truncate-file-path"
+ nz-tooltip
+ [nzTooltipTitle]="fileTooltipTpl">
+ {{ obj.path }}
+ </span>
+ <ng-template #fileTooltipTpl>
+ <div>{{ obj.path }}</div>
+ <div *ngIf="getFileUploadTime(obj.path) as uploadTime">Upload time: {{ formatTime(uploadTime) }}</div>
+ </ng-template>
+ <!-- Small delete button with tooltip -->
+ <button
+ nz-button
+ nzType="link"
+ class="delete-button"
+ nz-tooltip
+ [nzTooltipTitle]="'Revert the change'"
+ (click)="onObjectReverted(obj)">
+ <i
+ nz-icon
+ nzType="delete"
+ nzTheme="outline"></i>
+ </button>
+ </nz-list-item>
+ </nz-list>
+
+ <nz-empty
+ *ngIf="datasetStagedObjects.length === 0"
+ nzNotFoundContent="No pending changes"></nz-empty>
+</div>
diff --git a/core/gui/src/app/common/type/dataset.ts b/core/gui/src/app/dashboard/component/user/user-model/user-dataset-explorer/user-dataset-staged-objects-list/user-dataset-staged-objects-list.component.scss
similarity index 53%
copy from core/gui/src/app/common/type/dataset.ts
copy to core/gui/src/app/dashboard/component/user/user-model/user-dataset-explorer/user-dataset-staged-objects-list/user-dataset-staged-objects-list.component.scss
index 7825ca2797..165ea73b31 100644
--- a/core/gui/src/app/common/type/dataset.ts
+++ b/core/gui/src/app/dashboard/component/user/user-model/user-dataset-explorer/user-dataset-staged-objects-list/user-dataset-staged-objects-list.component.scss
@@ -17,25 +17,27 @@
* under the License.
*/
-import { DatasetFileNode } from "./datasetVersionFileTree";
+/* Styles for the file tree container */
+.staged-object-list-container {
+ max-height: 200px; /* Adjust the max-height as needed */
+ overflow-y: auto; /* Enables vertical scrolling when content exceeds max-height */
+ overflow-x: auto; /* Prevents horizontal scrolling */
+}
-export interface DatasetVersion {
- dvid: number | undefined;
- did: number;
- creatorUid: number;
- name: string;
- versionHash: string | undefined;
- creationTime: number | undefined;
- fileNodes: DatasetFileNode[] | undefined;
+.truncate-file-path {
+ display: inline-block;
+ max-width: 250px; /* Adjust width as needed */
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
}
-export interface Dataset {
- did: number | undefined;
- ownerUid: number | undefined;
- name: string;
- isPublic: boolean;
- isDownloadable: boolean;
- storagePath: string | undefined;
- description: string;
- creationTime: number | undefined;
+.delete-button {
+ width: 24px; /* Minimum width for button */
+ height: 24px; /* Keep the button small */
+ padding: 0; /* Remove extra padding */
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-width: unset; /* Prevents unnecessary expansion */
}
diff --git a/core/gui/src/app/dashboard/component/user/user-model/user-dataset-explorer/user-dataset-staged-objects-list/user-dataset-staged-objects-list.component.ts b/core/gui/src/app/dashboard/component/user/user-model/user-dataset-explorer/user-dataset-staged-objects-list/user-dataset-staged-objects-list.component.ts
new file mode 100644
index 0000000000..7856ff47ba
--- /dev/null
+++ b/core/gui/src/app/dashboard/component/user/user-model/user-dataset-explorer/user-dataset-staged-objects-list/user-dataset-staged-objects-list.component.ts
@@ -0,0 +1,94 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core";
+import { DatasetStagedObject } from "../../../../../../common/type/dataset-staged-object";
+import { DatasetService } from "../../../../../service/user/dataset/dataset.service";
+import { NotificationService } from "../../../../../../common/service/notification/notification.service";
+import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy";
+import { formatTime } from "src/app/common/util/format.util";
+
+@UntilDestroy()
+@Component({
+ selector: "texera-dataset-staged-objects-list",
+ templateUrl: "./user-dataset-staged-objects-list.component.html",
+ styleUrls: ["./user-dataset-staged-objects-list.component.scss"],
+})
+export class UserDatasetStagedObjectsListComponent implements OnInit {
+ @Input() did?: number; // Dataset ID
+ @Input() set userMakeChangesEvent(event: EventEmitter<void>) {
+ if (event) {
+ event.pipe(untilDestroyed(this)).subscribe(() => {
+ this.fetchDatasetStagedObjects();
+ });
+ }
+ }
+ @Input() uploadTimeMap?: Map<string, number>;
+
+ @Output() stagedObjectsChanged = new EventEmitter<DatasetStagedObject[]>(); // Emits staged objects list
+
+ datasetStagedObjects: DatasetStagedObject[] = [];
+ formatTime = formatTime;
+
+ constructor(
+ private datasetService: DatasetService,
+ private notificationService: NotificationService
+ ) {}
+
+ ngOnInit(): void {
+ this.fetchDatasetStagedObjects();
+ }
+
+ private fetchDatasetStagedObjects(): void {
+ if (this.did != undefined) {
+ this.datasetService
+ .getDatasetDiff(this.did)
+ .pipe(untilDestroyed(this))
+ .subscribe(diffs => {
+ this.datasetStagedObjects = diffs;
+ // Emit the updated staged objects list
+ this.stagedObjectsChanged.emit(this.datasetStagedObjects);
+ });
+ }
+ }
+
+ onObjectReverted(objDiff: DatasetStagedObject) {
+ if (this.did) {
+ this.datasetService
+ .resetDatasetFileDiff(this.did, objDiff.path)
+ .pipe(untilDestroyed(this))
+ .subscribe({
+ next: (res: Response) => {
+ this.notificationService.success(`"${objDiff.diffType} ${objDiff.path}" is successfully reverted`);
+ this.fetchDatasetStagedObjects();
+ },
+ error: (err: unknown) => {
+ this.notificationService.error("Failed to delete the file");
+ },
+ });
+ }
+ }
+
+ getFileUploadTime(filePath: string): number | null {
+ if (!this.uploadTimeMap) return null;
+
+ const filename = filePath.split("/").pop() || filePath;
+ return this.uploadTimeMap.get(filename) || null;
+ }
+}
diff --git a/core/gui/src/app/dashboard/component/user/user-model/user-dataset-explorer/user-dataset-version-creator/user-model-version-creator.component.html b/core/gui/src/app/dashboard/component/user/user-model/user-dataset-explorer/user-dataset-version-creator/user-model-version-creator.component.html
new file mode 100644
index 0000000000..2e4d86da61
--- /dev/null
+++ b/core/gui/src/app/dashboard/component/user/user-model/user-dataset-explorer/user-dataset-version-creator/user-model-version-creator.component.html
@@ -0,0 +1,72 @@
+<!--
+ 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.
+-->
+
+<div>
+ <nz-spin
+ [nzSpinning]="isCreating"
+ nzSize="large"
+ nzTip="Creating...">
+ <div
+ class="texera-user-dataset-creator-form"
+ [ngClass]="{'disabled-backdrop': isCreating}">
+ <formly-form
+ [form]="form"
+ [fields]="fields"
+ [model]="model"></formly-form>
+ <div
+ *ngIf="!isCreatingVersion"
+ class="dataset-toggles">
+ <div class="toggle-item">
+ <label>Visibility:</label>
+ <nz-switch
+ [ngModel]="isModelPublic"
+ (ngModelChange)="onPublicStatusChange($event)"
+ nzCheckedChildren="public"
+ nzUnCheckedChildren="private"></nz-switch>
+ </div>
+ <div class="toggle-item">
+ <label>Downloadable:</label>
+ <nz-switch
+ [ngModel]="isModelDownloadable"
+ (ngModelChange)="onDownloadableStatusChange($event)"
+ nzCheckedChildren="enabled"
+ nzUnCheckedChildren="disabled"></nz-switch>
+ </div>
+ </div>
+ </div>
+
+ <button
+ nz-button
+ nzType="primary"
+ (click)="onClickCreate()"
+ [disabled]="isCreateButtonDisabled || isCreating"
+ class="create-btn">
+ Create
+ </button>
+
+ <button
+ nz-button
+ nzType="default"
+ (click)="onClickCancel()"
+ class="cancel-btn"
+ [disabled]="isCreating">
+ Cancel
+ </button>
+ </nz-spin>
+</div>
diff --git a/core/gui/src/app/dashboard/component/user/user-model/user-dataset-explorer/user-dataset-version-creator/user-model-version-creator.component.scss b/core/gui/src/app/dashboard/component/user/user-model/user-dataset-explorer/user-dataset-version-creator/user-model-version-creator.component.scss
new file mode 100644
index 0000000000..c6d9dda556
--- /dev/null
+++ b/core/gui/src/app/dashboard/component/user/user-model/user-dataset-explorer/user-dataset-version-creator/user-model-version-creator.component.scss
@@ -0,0 +1,86 @@
+/**
+ * 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.
+ */
+
+.texera-user-dataset-creator-form {
+ width: 80%;
+ margin: auto;
+}
+
+.create-btn {
+ background-color: #ffffff;
+ color: #007bff;
+ border: 1px solid lightgray;
+ padding: 5px 20px;
+ cursor: pointer;
+ margin-left: 15%;
+ margin-right: 5%;
+ width: 30%;
+ text-align: center;
+ &:hover {
+ background-color: darken(#007bff, 10%);
+ color: #ffffff;
+ }
+}
+
+.cancel-btn {
+ background-color: #ffffff;
+ color: #ff0000;
+ border: 1px solid lightgray;
+ padding: 5px 20px;
+ cursor: pointer;
+ margin-right: 15%;
+ margin-left: 5%;
+ margin-top: 10%;
+ width: 30%;
+ text-align: center;
+ &:hover {
+ background-color: darken(#ff0000, 10%);
+ color: #ffffff;
+ }
+}
+
+.file-uploader {
+ height: 40%;
+ width: 90%;
+ margin: auto;
+}
+
+.dataset-toggles {
+ margin-top: 16px;
+ margin-bottom: 16px;
+}
+
+.toggle-item {
+ display: flex;
+ align-items: center;
+ margin-bottom: 12px;
+
+ label {
+ margin-right: 12px;
+ min-width: 80px;
+ font-weight: 500;
+ }
+
+ .help-text {
+ margin-left: 8px;
+ font-size: 12px;
+ color: #999;
+ font-style: italic;
+ }
+}
diff --git a/core/gui/src/app/dashboard/component/user/user-model/user-dataset-explorer/user-dataset-version-creator/user-model-version-creator.component.ts b/core/gui/src/app/dashboard/component/user/user-model/user-dataset-explorer/user-dataset-version-creator/user-model-version-creator.component.ts
new file mode 100644
index 0000000000..9925a7a088
--- /dev/null
+++ b/core/gui/src/app/dashboard/component/user/user-model/user-dataset-explorer/user-dataset-version-creator/user-model-version-creator.component.ts
@@ -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.
+ */
+
+import { Component, EventEmitter, inject, Input, OnInit, Output } from "@angular/core";
+import { FormBuilder, FormGroup, Validators } from "@angular/forms";
+import { FormlyFieldConfig } from "@ngx-formly/core";
+import { DatasetService } from "../../../../../service/user/dataset/dataset.service";
+import { Dataset, Model } from "../../../../../../common/type/dataset";
+import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy";
+import { NotificationService } from "../../../../../../common/service/notification/notification.service";
+import { HttpErrorResponse } from "@angular/common/http";
+import { NZ_MODAL_DATA, NzModalRef } from "ng-zorro-antd/modal";
+import { ModelService } from "../../../../../service/user/model/model.service";
+import { formatSpeed, formatTime } from "../../../../../../common/util/format.util";
+
+@UntilDestroy()
+@Component({
+ selector: "texera-user-model-version-creator",
+ templateUrl: "./user-model-version-creator.component.html",
+ styleUrls: ["./user-model-version-creator.component.scss"],
+})
+export class UserModelVersionCreatorComponent implements OnInit {
+ readonly isCreatingVersion: boolean = inject(NZ_MODAL_DATA).isCreatingVersion;
+
+ readonly mid: number = inject(NZ_MODAL_DATA)?.mid ?? undefined;
+
+ isCreateButtonDisabled: boolean = false;
+
+ public form: FormGroup = new FormGroup({});
+ model: any = {};
+ fields: FormlyFieldConfig[] = [];
+ isModelPublic: boolean = false;
+ isModelDownloadable: boolean = false;
+
+ // used when creating the dataset
+ isDatasetNameSanitized: boolean = false;
+
+ // boolean to control if is uploading
+ isCreating: boolean = false;
+
+ constructor(
+ private modalRef: NzModalRef,
+ private modelService: ModelService,
+ private notificationService: NotificationService,
+ private formBuilder: FormBuilder
+ ) {}
+
+ ngOnInit() {
+ this.setFormFields();
+ this.isDatasetNameSanitized = false;
+ }
+
+ private setFormFields() {
+ this.fields = this.isCreatingVersion
+ ? [
+ // Fields when isCreatingVersion is true
+ {
+ key: "versionDescription",
+ type: "input",
+ defaultValue: "",
+ templateOptions: {
+ label: "Describe the new version",
+ required: false,
+ },
+ },
+ ]
+ : [
+ // Fields when isCreatingVersion is false
+ {
+ key: "name",
+ type: "input",
+ templateOptions: {
+ label: "Name",
+ required: true,
+ },
+ },
+ {
+ key: "description",
+ type: "input",
+ defaultValue: "",
+ templateOptions: {
+ label: "Description",
+ },
+ },
+ ];
+ }
+ get formControlNames(): string[] {
+ return Object.keys(this.form.controls);
+ }
+
+ datasetNameSanitization(datasetName: string): string {
+ // Remove leading spaces
+ let sanitizedDatasetName = datasetName.trimStart();
+
+ // Replace all characters that are not letters (a-z, A-Z), numbers (0-9) with a short dash "-"
+ sanitizedDatasetName = sanitizedDatasetName.replace(/[^a-zA-Z0-9]+/g, "-");
+
+ // Lower-case everything
+ sanitizedDatasetName = sanitizedDatasetName.toLowerCase();
+
+ // Track whether user’s input be changed
+ if (sanitizedDatasetName !== datasetName) {
+ this.isDatasetNameSanitized = true;
+ }
+
+ return sanitizedDatasetName;
+ }
+
+ private triggerValidation() {
+ Object.keys(this.form.controls).forEach(field => {
+ const control = this.form.get(field);
+ control?.markAsTouched({ onlySelf: true });
+ });
+ }
+
+ onClickCancel() {
+ this.modalRef.close(null);
+ }
+
+ onClickCreate() {
+ // check if the form is valid
+ this.triggerValidation();
+
+ if (!this.form.valid) {
+ return; // Stop further execution if the form is not valid
+ }
+
+ this.isCreating = true;
+ if (this.isCreatingVersion && this.mid) {
+ const versionName = this.form.get("versionDescription")?.value;
+ this.modelService
+ .createDatasetVersion(this.mid, versionName)
+ .pipe(untilDestroyed(this))
+ .subscribe({
+ next: res => {
+ this.notificationService.success("Version Created");
+ this.isCreating = false;
+ // creation succeed, emit created version
+ this.modalRef.close(res);
+ },
+ error: (res: unknown) => {
+ const err = res as HttpErrorResponse;
+ this.notificationService.error(`Version creation failed: ${err.error.message}`);
+ this.isCreating = false;
+ // creation failed, emit null value
+ this.modalRef.close(null);
+ },
+ });
+ } else {
+ // capture original and sanitized names
+ const originalName = this.form.get("name")?.value as string;
+ const sanitizedName = this.datasetNameSanitization(originalName);
+
+ const model: Model = {
+ name: sanitizedName,
+ description: this.form.get("description")?.value,
+ isPublic: this.isModelPublic,
+ isDownloadable: this.isModelDownloadable,
+ mid: undefined,
+ ownerUid: undefined,
+ storagePath: undefined,
+ creationTime: undefined,
+ };
+ this.modelService
+ .createModel(model)
+ .pipe(untilDestroyed(this))
+ .subscribe({
+ next: res => {
+ const msg = this.isDatasetNameSanitized
+ ? `Dataset '${originalName}' was sanitized to '${sanitizedName}' and created successfully.`
+ : `Dataset '${sanitizedName}' created successfully.`;
+
+ this.notificationService.success(msg);
+ this.isCreating = false;
+ // if creation succeed, emit the created dashboard dataset
+ this.modalRef.close(res);
+ },
+ error: (res: unknown) => {
+ const err = res as HttpErrorResponse;
+ this.notificationService.error(`Model ${model.name} creation failed: ${err.error.message}`);
+ this.isCreating = false;
+ // if creation failed, emit null value
+ this.modalRef.close(null);
+ },
+ });
+ }
+ }
+
+ onPublicStatusChange(newValue: boolean): void {
+ // Handle the change in dataset public status
+ this.isModelPublic = newValue;
+ }
+
+ onDownloadableStatusChange(newValue: boolean): void {
+ // Handle the change in dataset downloadable status
+ this.isModelDownloadable = newValue;
+ }
+
+ protected readonly formatSpeed = formatSpeed;
+ protected readonly formatTime = formatTime;
+
+
+
+}
diff --git a/core/gui/src/app/dashboard/component/user/user-model/user-dataset-explorer/user-dataset-version-filetree/user-dataset-version-filetree.component.html b/core/gui/src/app/dashboard/component/user/user-model/user-dataset-explorer/user-dataset-version-filetree/user-dataset-version-filetree.component.html
new file mode 100644
index 0000000000..f09cdb37d4
--- /dev/null
+++ b/core/gui/src/app/dashboard/component/user/user-model/user-dataset-explorer/user-dataset-version-filetree/user-dataset-version-filetree.component.html
@@ -0,0 +1,54 @@
+<!--
+ 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.
+-->
+
+<div class="file-tree-container">
+ <tree-root
+ #tree
+ [nodes]="fileTreeNodes"
+ [options]="fileTreeDisplayOptions">
+ <ng-template
+ #treeNodeTemplate
+ let-node>
+ <span title="{{ node.data.name }}">
+ <i
+ *ngIf="node.data.children"
+ nz-icon
+ nzType="folder"
+ nzTheme="outline"></i>
+ <i
+ *ngIf="!node.data.children"
+ nz-icon
+ nzType="file"
+ nzTheme="outline"></i>
+ {{ node.data.name }}
+ <button
+ nz-button
+ nzType="link"
+ *ngIf="isTreeNodeDeletable && !node.data.children"
+ class="delete-button"
+ (click)="onNodeDeleted(node.data)">
+ <i
+ nz-icon
+ nzType="delete"
+ nzTheme="outline"></i>
+ </button>
+ </span>
+ </ng-template>
+ </tree-root>
+</div>
diff --git a/core/gui/src/app/common/type/dataset.ts b/core/gui/src/app/dashboard/component/user/user-model/user-dataset-explorer/user-dataset-version-filetree/user-dataset-version-filetree.component.scss
similarity index 55%
copy from core/gui/src/app/common/type/dataset.ts
copy to core/gui/src/app/dashboard/component/user/user-model/user-dataset-explorer/user-dataset-version-filetree/user-dataset-version-filetree.component.scss
index 7825ca2797..edfaab6128 100644
--- a/core/gui/src/app/common/type/dataset.ts
+++ b/core/gui/src/app/dashboard/component/user/user-model/user-dataset-explorer/user-dataset-version-filetree/user-dataset-version-filetree.component.scss
@@ -17,25 +17,31 @@
* under the License.
*/
-import { DatasetFileNode } from "./datasetVersionFileTree";
+.file-display-padding {
+ width: 20px;
+}
-export interface DatasetVersion {
- dvid: number | undefined;
- did: number;
- creatorUid: number;
- name: string;
- versionHash: string | undefined;
- creationTime: number | undefined;
- fileNodes: DatasetFileNode[] | undefined;
+/* Styles for the delete button */
+.delete-button {
+ width: 15px;
+ margin-left: 5px;
}
-export interface Dataset {
- did: number | undefined;
- ownerUid: number | undefined;
- name: string;
- isPublic: boolean;
- isDownloadable: boolean;
- storagePath: string | undefined;
- description: string;
- creationTime: number | undefined;
+/* Styles for the file tree container */
+.file-tree-container {
+ max-height: 200px; /* Adjust the max-height as needed */
+ overflow-y: auto; /* Enables vertical scrolling when content exceeds max-height */
+ overflow-x: auto; /* Prevents horizontal scrolling */
}
+
+//tree-root .fa-file {
+// //padding-left: 4%; /* Adjust the value as needed */
+//}
+//
+//tree-root span {
+// display: inline-block;
+// max-width: 100%; /* Adjust the width as needed */
+// text-overflow: ellipsis;
+// white-space: nowrap;
+// overflow: hidden;
+//}
diff --git a/core/gui/src/app/dashboard/component/user/user-model/user-dataset-explorer/user-dataset-version-filetree/user-dataset-version-filetree.component.ts b/core/gui/src/app/dashboard/component/user/user-model/user-dataset-explorer/user-dataset-version-filetree/user-dataset-version-filetree.component.ts
new file mode 100644
index 0000000000..94e8c98c88
--- /dev/null
+++ b/core/gui/src/app/dashboard/component/user/user-model/user-dataset-explorer/user-dataset-version-filetree/user-dataset-version-filetree.component.ts
@@ -0,0 +1,77 @@
+/**
+ * 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 { UntilDestroy } from "@ngneat/until-destroy";
+import { AfterViewInit, Component, EventEmitter, Input, Output, ViewChild } from "@angular/core";
+import { DatasetFileNode, getFullPathFromDatasetFileNode } from "../../../../../../common/type/datasetVersionFileTree";
+import { ITreeOptions, TREE_ACTIONS } from "@ali-hm/angular-tree-component";
+
+@UntilDestroy()
+@Component({
+ selector: "texera-user-dataset-version-filetree",
+ templateUrl: "./user-dataset-version-filetree.component.html",
+ styleUrls: ["./user-dataset-version-filetree.component.scss"],
+})
+export class UserDatasetVersionFiletreeComponent implements AfterViewInit {
+ @Input()
+ public isTreeNodeDeletable: boolean = false;
+
+ @Input()
+ public fileTreeNodes: DatasetFileNode[] = [];
+
+ @Input()
+ public isExpandAllAfterViewInit = false;
+
+ @ViewChild("tree") tree: any;
+
+ public fileTreeDisplayOptions: ITreeOptions = {
+ displayField: "name",
+ hasChildrenField: "children",
+ actionMapping: {
+ mouse: {
+ click: (tree: any, node: any, $event: any) => {
+ if (node.hasChildren) {
+ TREE_ACTIONS.TOGGLE_EXPANDED(tree, node, $event);
+ } else {
+ this.selectedTreeNode.emit(node.data);
+ }
+ },
+ },
+ },
+ };
+
+ @Output()
+ public selectedTreeNode = new EventEmitter<DatasetFileNode>();
+
+ @Output()
+ public deletedTreeNode = new EventEmitter<DatasetFileNode>();
+
+ constructor() {}
+
+ onNodeDeleted(node: DatasetFileNode): void {
+ // look up for the DatasetVersionFileTreeNode
+ this.deletedTreeNode.emit(node);
+ }
+
+ ngAfterViewInit(): void {
+ if (this.isExpandAllAfterViewInit) {
+ this.tree.treeModel.expandAll();
+ }
+ }
+}
diff --git a/core/gui/src/app/dashboard/component/user/user-model/user-dataset-list-item/user-dataset-list-item.component.html b/core/gui/src/app/dashboard/component/user/user-model/user-dataset-list-item/user-dataset-list-item.component.html
new file mode 100644
index 0000000000..4f7d5e0195
--- /dev/null
+++ b/core/gui/src/app/dashboard/component/user/user-model/user-dataset-list-item/user-dataset-list-item.component.html
@@ -0,0 +1,154 @@
+<!--
+ 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.
+-->
+
+<nz-list-item class="dataset-list-item">
+ <nz-list-item-meta class="dataset-item-meta">
+ <!-- Margin need only when the checkbox is not visible -->
+ <nz-list-item-meta-avatar [ngStyle]="{ 'margin-left': editable ? 'none' : '16px' }">
+ <nz-avatar
+ [ngStyle]="{ 'background-color': 'grey', 'vertical-align': 'middle' }"
+ [nzGap]="4"
+ [nzText]="'' + dataset.did"
+ nzSize="default"></nz-avatar>
+ </nz-list-item-meta-avatar>
+
+ <!-- editable name of saved dataset -->
+ <nz-list-item-meta-title class="meta-title-container">
+ <div class="dataset-item-meta-title">
+ <a
+ *ngIf="!editingName; else customDatasetTitle "
+ [routerLink]="DASHBOARD_USER_DATASET + '/' + dataset.did"
+ class="dataset-name"
+ >{{ dataset.name }}</a
+ >
+ <ng-template #customDatasetTitle>
+ <input
+ #customName
+ (focusout)="confirmUpdateDatasetCustomName(customName.value)"
+ (keyup.enter)="confirmUpdateDatasetCustomName(customName.value)"
+ placeholder="{{ dataset.name }}"
+ value="{{ dataset.name }}" />
+ </ng-template>
+ <button
+ *ngIf="editable && entry.accessPrivilege === 'WRITE'"
+ (click)="editingName = true"
+ nz-button
+ mat-card-title="Customize Dataset Name"
+ nz-tooltip="Customize Dataset Name"
+ nzSize="small"
+ nzTooltipPlacement="bottom"
+ nzType="text">
+ <i
+ nz-icon
+ nzTheme="outline"
+ nzType="edit"></i>
+ </button>
+ <button
+ *ngIf="editable && entry.accessPrivilege === 'WRITE'"
+ (click)="editingDescription = true"
+ nz-button
+ nz-tooltip="Add Description"
+ nzSize="small"
+ nzTooltipPlacement="bottom"
+ nzType="text"
+ class="add-description-btn">
+ <i
+ nz-icon
+ nzTheme="outline"
+ nzType="plus-square"></i>
+ </button>
+ <i
+ class="dataset-is-owner-icon"
+ *ngIf="entry.isOwner"
+ nz-tooltip="You are the owner"
+ nzTooltipPlacement="bottom"
+ nz-icon
+ nzTheme="outline"
+ nzType="user"></i>
+ <i
+ *ngIf="!entry.isOwner"
+ nz-tooltip="{{
+ entry.accessPrivilege
+ }} Access"
+ nzTooltipPlacement="bottom"
+ nz-icon
+ nzTheme="outline"
+ nzType="team"></i>
+ </div>
+ </nz-list-item-meta-title>
+
+ <!-- editable description of saved dataset -->
+ <nz-list-item-meta-description>
+ <div class="dataset-item-meta-description">
+ <label
+ *ngIf="!editingDescription; else customDatasetDescription "
+ (click)="editingDescription = editable && entry.accessPrivilege == 'WRITE'"
+ class="dataset-description-label">
+ {{ dataset.description }}
+ </label>
+ <ng-template #customDatasetDescription>
+ <input
+ title="Description"
+ #customDescription
+ (focusout)="confirmUpdateDatasetCustomDescription(customDescription.value)"
+ (keyup.enter)="confirmUpdateDatasetCustomDescription(customDescription.value)"
+ class="dataset-editable-description-input"
+ value="{{ dataset.description }}"
+ maxlength="500" />
+ </ng-template>
+ </div>
+ </nz-list-item-meta-description>
+ </nz-list-item-meta>
+
+ <ul nz-list-item-actions>
+ <nz-list-item-action>
+ <button
+ (click)="onClickOpenShareAccess()"
+ nz-button
+ nz-tooltip="Share the dataset {{
+ dataset.name
+ }} to others"
+ nzTooltipPlacement="bottom"
+ type="button">
+ <i
+ nz-icon
+ nzTheme="outline"
+ nzType="share-alt"></i>
+ </button>
+ </nz-list-item-action>
+ <nz-list-item-action *ngIf="editable">
+ <button
+ (nzOnConfirm)="deleted.emit()"
+ nz-popconfirm
+ nzPopconfirmTitle="Confirm to delete this dataset."
+ [disabled]="!entry.isOwner"
+ class="delete-dataset-btn"
+ nz-button
+ nz-tooltip="Delete the dataset {{
+ dataset.name
+ }}"
+ nzTooltipPlacement="bottom">
+ <i
+ nz-icon
+ nzTheme="outline"
+ nzType="delete"></i>
+ </button>
+ </nz-list-item-action>
+ </ul>
+</nz-list-item>
diff --git a/core/gui/src/app/dashboard/component/user/user-model/user-dataset-list-item/user-dataset-list-item.component.scss b/core/gui/src/app/dashboard/component/user/user-model/user-dataset-list-item/user-dataset-list-item.component.scss
new file mode 100644
index 0000000000..e06ff48c19
--- /dev/null
+++ b/core/gui/src/app/dashboard/component/user/user-model/user-dataset-list-item/user-dataset-list-item.component.scss
@@ -0,0 +1,87 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+@import "../../../section-style";
+@import "../../../dashboard.component.scss";
+
+/**
+ * css for project label, shared by workflow section and file section
+**/
+.dataset-list-item {
+ margin-bottom: 10px;
+ min-height: 70px;
+ padding: 5px 0 5px 0;
+
+ .dataset-item-meta {
+ margin: 0 20px;
+ }
+
+ .dataset-item-meta-title {
+ display: flex;
+ align-items: center;
+
+ .dataset-name {
+ color: black;
+ font-size: 20px;
+ font-family: -apple-system, BlinkMacSystemFont, sans-serif;
+ text-align: center;
+ }
+
+ i {
+ position: relative;
+ font-size: 17px;
+ margin-bottom: 6px;
+ }
+
+ i.dataset-is-owner-icon {
+ margin-left: 7px;
+ }
+ }
+
+ .dataset-item-meta-description {
+ display: flex;
+ align-items: center;
+ padding: 2px 8px 2px 10px;
+ margin-bottom: 8px;
+
+ .dataset-description-label {
+ font-size: 13px;
+ font-family: -apple-system, BlinkMacSystemFont, sans-serif;
+
+ display: inline-block;
+ min-width: 17ch;
+ border: 0 none;
+ outline: none;
+
+ &:hover {
+ cursor: pointer;
+ box-shadow: 0 0 0 1px rgb(202, 202, 202);
+ }
+ }
+
+ .dataset-editable-description-input {
+ margin-bottom: 5px;
+ display: inline-block;
+ min-width: 17ch;
+ border: 0 none;
+ outline: none;
+ box-shadow: 0 0 0 2px #007bff;
+ }
+ }
+}
diff --git a/core/gui/src/app/dashboard/component/user/user-model/user-dataset-list-item/user-dataset-list-item.component.ts b/core/gui/src/app/dashboard/component/user/user-model/user-dataset-list-item/user-dataset-list-item.component.ts
new file mode 100644
index 0000000000..c18d37ee0c
--- /dev/null
+++ b/core/gui/src/app/dashboard/component/user/user-model/user-dataset-list-item/user-dataset-list-item.component.ts
@@ -0,0 +1,133 @@
+/**
+ * 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 { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy";
+import { Component, EventEmitter, Input, Output } from "@angular/core";
+import { Dataset } from "../../../../../common/type/dataset";
+import { DatasetService } from "../../../../service/user/dataset/dataset.service";
+import { ShareAccessComponent } from "../../share-access/share-access.component";
+import { NotificationService } from "../../../../../common/service/notification/notification.service";
+import { NzModalService } from "ng-zorro-antd/modal";
+import { DashboardDataset } from "../../../../type/dashboard-dataset.interface";
+import { DASHBOARD_USER_DATASET } from "../../../../../app-routing.constant";
+
+@UntilDestroy()
+@Component({
+ selector: "texera-user-dataset-list-item",
+ templateUrl: "./user-dataset-list-item.component.html",
+ styleUrls: ["./user-dataset-list-item.component.scss"],
+})
+export class UserDatasetListItemComponent {
+ protected readonly DASHBOARD_USER_DATASET = DASHBOARD_USER_DATASET;
+
+ private _entry?: DashboardDataset;
+
+ @Output()
+ refresh = new EventEmitter<void>();
+
+ @Input()
+ get entry(): DashboardDataset {
+ if (!this._entry) {
+ throw new Error("entry property must be provided to UserDatasetListItemComponent.");
+ }
+ return this._entry;
+ }
+
+ set entry(value: DashboardDataset) {
+ this._entry = value;
+ }
+
+ get dataset(): Dataset {
+ if (!this.entry.dataset) {
+ throw new Error(
+ "Incorrect type of DashboardEntry provided to UserDatasetListItemComponent. Entry must be dataset."
+ );
+ }
+ return this.entry.dataset;
+ }
+
+ @Input() editable = false;
+ @Output() deleted = new EventEmitter<void>();
+ @Output() duplicated = new EventEmitter<void>();
+
+ editingName = false;
+ editingDescription = false;
+
+ constructor(
+ private modalService: NzModalService,
+ private datasetService: DatasetService,
+ private notificationService: NotificationService
+ ) {}
+
+ public confirmUpdateDatasetCustomName(name: string) {
+ if (this.entry.dataset.name === name) {
+ return;
+ }
+
+ if (this.entry.dataset.did)
+ this.datasetService
+ .updateDatasetName(this.entry.dataset.did, name)
+ .pipe(untilDestroyed(this))
+ .subscribe({
+ next: () => {
+ this.entry.dataset.name = name;
+ this.editingName = false;
+ },
+ error: () => {
+ this.notificationService.error("Update dataset name failed");
+ this.editingName = false;
+ },
+ });
+ }
+
+ public confirmUpdateDatasetCustomDescription(description: string) {
+ if (this.entry.dataset.description === description) {
+ return;
+ }
+
+ if (this.entry.dataset.did)
+ this.datasetService
+ .updateDatasetDescription(this.entry.dataset.did, description)
+ .pipe(untilDestroyed(this))
+ .subscribe({
+ next: () => {
+ this.entry.dataset.description = description;
+ this.editingDescription = false;
+ },
+ error: () => {
+ this.notificationService.error("Update dataset description failed");
+ this.editingDescription = false;
+ },
+ });
+ }
+
+ public onClickOpenShareAccess() {
+ this.modalService.create({
+ nzContent: ShareAccessComponent,
+ nzData: {
+ writeAccess: this.entry.accessPrivilege === "WRITE",
+ type: "dataset",
+ id: this.dataset.did,
+ },
+ nzFooter: null,
+ nzTitle: "Share this dataset with others",
+ nzCentered: true,
+ });
+ }
+}
diff --git a/core/gui/src/app/dashboard/component/user/user-model/user-model.component.html b/core/gui/src/app/dashboard/component/user/user-model/user-model.component.html
new file mode 100644
index 0000000000..ca89b08ef9
--- /dev/null
+++ b/core/gui/src/app/dashboard/component/user/user-model/user-model.component.html
@@ -0,0 +1,61 @@
+<!--
+ 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.
+-->
+
+<div class="section-container subsection-grid-container">
+ <nz-card class="section-title">
+ <h2 class="page-title">Models</h2>
+ <div class="button-group">
+ <button
+ nz-button
+ class="create-btn"
+ (click)="onClickOpenModelAddComponent()"
+ title="Create Model">
+ <i
+ nz-icon
+ nzType="file-add"
+ nzTheme="outline"></i>
+ <span>Create Model</span>
+ </button>
+ <texera-filters #filters></texera-filters>
+ </div>
+ </nz-card>
+
+ <div class="section-search-bar">
+ <texera-filters-instructions></texera-filters-instructions>
+ <nz-select
+ #searchInput
+ class="search-input-box"
+ name="search-input-box"
+ nzMode="tags"
+ nzPlaceHolder="Search all models"
+ [nzBorderless]="true"
+ [nzOpen]="false"
+ ngDefaultControl
+ [(ngModel)]="filters.masterFilterList"
+ [nzAllowClear]="true">
+ </nz-select>
+ </div>
+
+ <texera-search-results
+ [editable]="true"
+ [isPrivateSearch]="true"
+ (deleted)="deleteModel($event)"
+ (refresh)="ngAfterViewInit()"
+ [currentUid]="currentUid"></texera-search-results>
+</div>
diff --git a/core/gui/src/app/dashboard/type/dashboard-dataset.interface.ts b/core/gui/src/app/dashboard/component/user/user-model/user-model.component.scss
similarity index 72%
copy from core/gui/src/app/dashboard/type/dashboard-dataset.interface.ts
copy to core/gui/src/app/dashboard/component/user/user-model/user-model.component.scss
index 2ea0058264..31c95188c4 100644
--- a/core/gui/src/app/dashboard/type/dashboard-dataset.interface.ts
+++ b/core/gui/src/app/dashboard/component/user/user-model/user-model.component.scss
@@ -17,13 +17,11 @@
* under the License.
*/
-import { Dataset, DatasetVersion } from "../../common/type/dataset";
-import { DatasetFileNode } from "../../common/type/datasetVersionFileTree";
+@import "../../dashboard.component";
+@import "../../section-style";
+@import "../../button-style";
-export interface DashboardDataset {
- isOwner: boolean;
- ownerEmail: string;
- dataset: Dataset;
- accessPrivilege: "READ" | "WRITE" | "NONE";
- size: number;
+.dataset-menu-bar {
+ height: 55px;
+ overflow-y: hidden;
}
diff --git a/core/gui/src/app/dashboard/component/user/user-model/user-model.component.ts b/core/gui/src/app/dashboard/component/user/user-model/user-model.component.ts
new file mode 100644
index 0000000000..b3a2a1bd9b
--- /dev/null
+++ b/core/gui/src/app/dashboard/component/user/user-model/user-model.component.ts
@@ -0,0 +1,207 @@
+/**
+ * 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 { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy";
+import { AfterViewInit, Component, ViewChild } from "@angular/core";
+import { UserService } from "../../../../common/service/user/user.service";
+import { Router } from "@angular/router";
+import { SearchService } from "../../../service/user/search.service";
+import { DatasetService } from "../../../service/user/dataset/dataset.service";
+import { SortMethod } from "../../../type/sort-method";
+import { DashboardEntry, UserInfo } from "../../../type/dashboard-entry";
+import { SearchResultsComponent } from "../search-results/search-results.component";
+import { FiltersComponent } from "../filters/filters.component";
+import { firstValueFrom } from "rxjs";
+import {
+ DASHBOARD_USER_DATASET,
+ DASHBOARD_USER_DATASET_CREATE,
+ DASHBOARD_USER_MODEL,
+} from "../../../../app-routing.constant";
+import { NzModalService } from "ng-zorro-antd/modal";
+import { FileSelectionComponent } from "../../../../workspace/component/file-selection/file-selection.component";
+import { DatasetFileNode, getFullPathFromDatasetFileNode } from "../../../../common/type/datasetVersionFileTree";
+import { UserModelVersionCreatorComponent } from "./user-dataset-explorer/user-dataset-version-creator/user-model-version-creator.component";
+import { DashboardModel } from "../../../type/dashboard-dataset.interface";
+import { NzMessageService } from "ng-zorro-antd/message";
+import { map, tap } from "rxjs/operators";
+
+@UntilDestroy()
+@Component({
+ selector: "texera-model-section",
+ templateUrl: "user-model.component.html",
+ styleUrls: ["user-model.component.scss"],
+})
+export class UserModelComponent implements AfterViewInit {
+ public sortMethod = SortMethod.EditTimeDesc;
+ lastSortMethod: SortMethod | null = null;
+ public isLogin = this.userService.isLogin();
+ public currentUid = this.userService.getCurrentUser()?.uid;
+ public hasMismatch = false; // Display warning when there are mismatched datasets
+
+ private _searchResultsComponent?: SearchResultsComponent;
+ @ViewChild(SearchResultsComponent) get searchResultsComponent(): SearchResultsComponent {
+ if (this._searchResultsComponent) {
+ return this._searchResultsComponent;
+ }
+ throw new Error("Property cannot be accessed before it is initialized.");
+ }
+
+ set searchResultsComponent(value: SearchResultsComponent) {
+ this._searchResultsComponent = value;
+ }
+
+ private _filters?: FiltersComponent;
+ @ViewChild(FiltersComponent) get filters(): FiltersComponent {
+ if (this._filters) {
+ return this._filters;
+ }
+ throw new Error("Property cannot be accessed before it is initialized.");
+ }
+
+ set filters(value: FiltersComponent) {
+ value.masterFilterListChange.pipe(untilDestroyed(this)).subscribe({ next: () => this.search() });
+ this._filters = value;
+ }
+
+ private masterFilterList: ReadonlyArray<string> | null = null;
+ constructor(
+ private modalService: NzModalService,
+ private userService: UserService,
+ private router: Router,
+ private searchService: SearchService,
+ private datasetService: DatasetService,
+ private message: NzMessageService
+ ) {
+ this.userService
+ .userChanged()
+ .pipe(untilDestroyed(this))
+ .subscribe(() => {
+ this.isLogin = this.userService.isLogin();
+ this.currentUid = this.userService.getCurrentUser()?.uid;
+ });
+ }
+
+ ngAfterViewInit() {
+ this.userService
+ .userChanged()
+ .pipe(untilDestroyed(this))
+ .subscribe(() => this.search());
+ }
+
+ /*
+ * Executes a dataset search with filtering, sorting.
+ *
+ * Parameters:
+ * - filterScope = "all" | "public" | "private" - Determines visibility scope for search:
+ * - "all": includes all datasets, public and private
+ * - "public": limits the search to public datasets
+ * - "private": limits the search to dataset where the user has direct access rights.
+ */
+ async search(forced: Boolean = false, filterScope: "all" | "public" | "private" = "private"): Promise<void> {
+ const sameList =
+ this.masterFilterList !== null &&
+ this.filters.masterFilterList.length === this.masterFilterList.length &&
+ this.filters.masterFilterList.every((v, i) => v === this.masterFilterList![i]);
+ if (!forced && sameList && this.sortMethod === this.lastSortMethod) {
+ // If the filter lists are the same, do no make the same request again.
+ return;
+ }
+ this.lastSortMethod = this.sortMethod;
+ this.masterFilterList = this.filters.masterFilterList;
+ if (!this.searchResultsComponent) {
+ throw new Error("searchResultsComponent is undefined.");
+ }
+ let filterParams = this.filters.getSearchFilterParameters();
+
+ // if the filter requires only public datasets, the public search should be invoked, and the search method should
+ // set the isLogin parameter to false in this case
+ const isLogin = filterScope === "public" ? false : this.isLogin;
+ const includePublic = filterScope === "all" || filterScope === "public";
+
+ this.searchResultsComponent.reset((start, count) => {
+ return firstValueFrom(
+ this.searchService
+ .executeSearch(
+ this.filters.getSearchKeywords(),
+ filterParams,
+ start,
+ count,
+ "model",
+ this.sortMethod,
+ isLogin,
+ includePublic
+ )
+ .pipe(
+ tap(({ hasMismatch }) => {
+ this.hasMismatch = hasMismatch ?? false;
+ if (this.hasMismatch) {
+ this.message.warning(
+ "There is a mismatch between some models in the database and LakeFS. Only matched models are displayed.",
+ { nzDuration: 4000 }
+ );
+ }
+ }),
+ map(({ entries, more }) => ({ entries, more }))
+ )
+ );
+ });
+ await this.searchResultsComponent.loadMore();
+ }
+
+ public onClickOpenModelAddComponent(): void {
+ const modal = this.modalService.create({
+ nzTitle: "Create New Model",
+ nzContent: UserModelVersionCreatorComponent,
+ nzFooter: null,
+ nzData: {
+ isCreatingVersion: false,
+ },
+ nzBodyStyle: {
+ resize: "both",
+ overflow: "auto",
+ minHeight: "200px",
+ minWidth: "550px",
+ maxWidth: "90vw",
+ maxHeight: "80vh",
+ },
+ nzWidth: "fit-content",
+ });
+ // Handle the selection from the modal
+ modal.afterClose.pipe(untilDestroyed(this)).subscribe(result => {
+ if (result != null) {
+ const dashboardModel: DashboardModel = result as DashboardModel;
+ this.router.navigate([`${DASHBOARD_USER_MODEL}/${dashboardModel.model.mid}`]);
+ }
+ });
+ }
+
+ public deleteModel(entry: DashboardEntry): void {
+ if (entry.model.model.mid == undefined) {
+ return;
+ }
+ this.datasetService
+ .deleteDatasets(entry.model.model.mid)
+ .pipe(untilDestroyed(this))
+ .subscribe(_ => {
+ this.searchResultsComponent.entries = this.searchResultsComponent.entries.filter(
+ modelEntry => modelEntry.model.model.mid !== entry.model.model.mid
+ );
+ });
+ }
+}
diff --git a/core/gui/src/app/dashboard/service/user/download/download.service.spec.ts b/core/gui/src/app/dashboard/service/user/download/download.service.spec.ts
index 3f59997078..156a125e5a 100644
--- a/core/gui/src/app/dashboard/service/user/download/download.service.spec.ts
+++ b/core/gui/src/app/dashboard/service/user/download/download.service.spec.ts
@@ -34,7 +34,7 @@ describe("DownloadService", () => {
let notificationServiceSpy: jasmine.SpyObj<NotificationService>;
beforeEach(() => {
- const datasetSpy = jasmine.createSpyObj("DatasetService", [
+ const datasetSpy = jasmine.createSpyObj("ModelService", [
"retrieveDatasetVersionSingleFile",
"retrieveDatasetVersionZip", // Add this method to the spy
]);
diff --git a/core/gui/src/app/dashboard/service/user/model/model.service.ts b/core/gui/src/app/dashboard/service/user/model/model.service.ts
new file mode 100644
index 0000000000..d0f963013f
--- /dev/null
+++ b/core/gui/src/app/dashboard/service/user/model/model.service.ts
@@ -0,0 +1,524 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { Injectable } from "@angular/core";
+import { HttpClient, HttpParams, HttpResponse } from "@angular/common/http";
+import { catchError, map, mergeMap, switchMap, tap, toArray } from "rxjs/operators";
+import { Dataset, DatasetVersion, Model } from "../../../../common/type/dataset";
+import { AppSettings } from "../../../../common/app-setting";
+import { EMPTY, forkJoin, from, Observable, of, throwError } from "rxjs";
+import { DashboardDataset } from "../../../type/dashboard-dataset.interface";
+import { DatasetFileNode } from "../../../../common/type/datasetVersionFileTree";
+import { DatasetStagedObject } from "../../../../common/type/dataset-staged-object";
+import { GuiConfigService } from "../../../../common/service/gui-config.service";
+
+export const MODEL_BASE_URL = "model";
+export const MODEL_CREATE_URL = MODEL_BASE_URL + "/create";
+export const DATASET_UPDATE_BASE_URL = MODEL_BASE_URL + "/update";
+export const DATASET_UPDATE_NAME_URL = DATASET_UPDATE_BASE_URL + "/name";
+export const DATASET_UPDATE_DESCRIPTION_URL = DATASET_UPDATE_BASE_URL + "/description";
+export const DATASET_UPDATE_PUBLICITY_URL = "update/publicity";
+export const DATASET_UPDATE_DOWNLOADABLE_URL = "update/downloadable";
+export const DATASET_LIST_URL = MODEL_BASE_URL + "/list";
+export const DATASET_SEARCH_URL = MODEL_BASE_URL + "/search";
+export const DATASET_DELETE_URL = MODEL_BASE_URL + "/delete";
+
+export const DATASET_VERSION_BASE_URL = "version";
+export const DATASET_VERSION_RETRIEVE_LIST_URL = DATASET_VERSION_BASE_URL + "/list";
+export const DATASET_VERSION_LATEST_URL = DATASET_VERSION_BASE_URL + "/latest";
+export const DEFAULT_DATASET_NAME = "Untitled dataset";
+export const DATASET_PUBLIC_VERSION_BASE_URL = "publicVersion";
+export const DATASET_PUBLIC_VERSION_RETRIEVE_LIST_URL = DATASET_PUBLIC_VERSION_BASE_URL + "/list";
+export const DATASET_GET_OWNERS_URL = MODEL_BASE_URL + "/user-dataset-owners";
+
+export interface MultipartUploadProgress {
+ filePath: string;
+ percentage: number;
+ status: "initializing" | "uploading" | "finished" | "aborted";
+ uploadId: string;
+ physicalAddress: string;
+ uploadSpeed?: number; // bytes per second
+ estimatedTimeRemaining?: number; // seconds
+ totalTime?: number; // total seconds taken
+}
+
+@Injectable({
+ providedIn: "root",
+})
+export class ModelService {
+ constructor(
+ private http: HttpClient,
+ private config: GuiConfigService
+ ) {}
+
+ public createModel(model: Model): Observable<DashboardDataset> {
+ return this.http.post<DashboardDataset>(`${AppSettings.getApiEndpoint()}/${MODEL_CREATE_URL}`, {
+ modelName: model.name,
+ modelDescription: model.description,
+ isModelPublic: model.isPublic,
+ isModelDownloadable: model.isDownloadable,
+ });
+ }
+
+ public getDataset(did: number, isLogin: boolean = true): Observable<DashboardDataset> {
+ const apiUrl = isLogin
+ ? `${AppSettings.getApiEndpoint()}/${MODEL_BASE_URL}/${did}`
+ : `${AppSettings.getApiEndpoint()}/${MODEL_BASE_URL}/public/${did}`;
+ return this.http.get<DashboardDataset>(apiUrl);
+ }
+
+ /**
+ * Retrieves a single file from a dataset version using a pre-signed URL.
+ * @param filePath Relative file path within the dataset.
+ * @param isLogin Determine whether a user is currently logged in
+ * @returns Observable<Blob>
+ */
+ public retrieveDatasetVersionSingleFile(filePath: string, isLogin: boolean = true): Observable<Blob> {
+ const endpointSegment = isLogin ? "presign-download" : "public-presign-download";
+ const endpoint = `${AppSettings.getApiEndpoint()}/${MODEL_BASE_URL}/${endpointSegment}?filePath=${encodeURIComponent(filePath)}`;
+
+ return this.http
+ .get<{ presignedUrl: string }>(endpoint)
+ .pipe(switchMap(({ presignedUrl }) => this.http.get(presignedUrl, { responseType: "blob" })));
+ }
+
+ /**
+ * Retrieves a zip file of a dataset version.
+ * @param did Dataset ID
+ * @param dvid (Optional) Dataset version ID. If omitted, the latest version is downloaded.
+ * @returns An Observable that emits a Blob containing the zip file.
+ */
+ public retrieveDatasetVersionZip(did: number, dvid?: number): Observable<Blob> {
+ let params = new HttpParams();
+
+ if (dvid !== undefined && dvid !== null) {
+ params = params.set("dvid", dvid.toString());
+ } else {
+ params = params.set("latest", "true");
+ }
+
+ return this.http.get(`${AppSettings.getApiEndpoint()}/dataset/${did}/versionZip`, {
+ params,
+ responseType: "blob",
+ });
+ }
+
+ public retrieveAccessibleDatasets(): Observable<DashboardDataset[]> {
+ return this.http.get<DashboardDataset[]>(`${AppSettings.getApiEndpoint()}/${DATASET_LIST_URL}`);
+ }
+ public createDatasetVersion(did: number, newVersion: string): Observable<DatasetVersion> {
+ return this.http
+ .post<{
+ datasetVersion: DatasetVersion;
+ fileNodes: DatasetFileNode[];
+ }>(`${AppSettings.getApiEndpoint()}/${MODEL_BASE_URL}/${did}/version/create`, newVersion, {
+ headers: { "Content-Type": "text/plain" },
+ })
+ .pipe(
+ map(response => {
+ response.datasetVersion.fileNodes = response.fileNodes;
+ return response.datasetVersion;
+ })
+ );
+ }
+
+ /**
+ * Handles multipart upload for large files using RxJS,
+ * with a concurrency limit on how many parts we process in parallel.
+ */
+ public multipartUpload(
+ datasetName: string,
+ filePath: string,
+ file: File,
+ partSize: number,
+ concurrencyLimit: number
+ ): Observable<MultipartUploadProgress> {
+ const partCount = Math.ceil(file.size / partSize);
+
+ return new Observable(observer => {
+ // Track upload progress for each part independently
+ const partProgress = new Map<number, number>();
+
+ // Progress tracking state
+ const startTime = Date.now();
+ const speedSamples: number[] = [];
+ let lastETA = 0;
+ let lastUpdateTime = 0;
+
+ // Calculate stats with smoothing
+ const calculateStats = (totalUploaded: number) => {
+ const now = Date.now();
+ const elapsed = (now - startTime) / 1000;
+
+ // Throttle updates to every 1s
+ const shouldUpdate = now - lastUpdateTime >= 1000;
+ if (!shouldUpdate) {
+ return null;
+ }
+ lastUpdateTime = now;
+
+ // Calculate speed with moving average
+ const currentSpeed = elapsed > 0 ? totalUploaded / elapsed : 0;
+ speedSamples.push(currentSpeed);
+ if (speedSamples.length > 5) speedSamples.shift();
+ const avgSpeed = speedSamples.reduce((a, b) => a + b, 0) / speedSamples.length;
+
+ // Calculate smooth ETA
+ const remaining = file.size - totalUploaded;
+ let eta = avgSpeed > 0 ? remaining / avgSpeed : 0;
+ eta = Math.min(eta, 24 * 60 * 60); // cap ETA at 24h, 86400 sec
+
+ // Smooth ETA changes (limit to 30% change)
+ if (lastETA > 0 && eta > 0) {
+ const maxChange = lastETA * 0.3;
+ const diff = Math.abs(eta - lastETA);
+ if (diff > maxChange) {
+ eta = lastETA + (eta > lastETA ? maxChange : -maxChange);
+ }
+ }
+ lastETA = eta;
+
+ // Near completion optimization
+ const percentComplete = (totalUploaded / file.size) * 100;
+ if (percentComplete > 95) {
+ eta = Math.min(eta, 10);
+ }
+
+ return {
+ uploadSpeed: avgSpeed,
+ estimatedTimeRemaining: Math.max(0, Math.round(eta)),
+ totalTime: elapsed,
+ };
+ };
+
+ const subscription = this.initiateMultipartUpload(datasetName, filePath, partCount)
+ .pipe(
+ switchMap(initiateResponse => {
+ const { uploadId, presignedUrls, physicalAddress } = initiateResponse;
+ if (!uploadId) {
+ observer.error(new Error("Failed to initiate multipart upload"));
+ return EMPTY;
+ }
+ observer.next({
+ filePath: filePath,
+ percentage: 0,
+ status: "initializing",
+ uploadId: uploadId,
+ physicalAddress: physicalAddress,
+ uploadSpeed: 0,
+ estimatedTimeRemaining: 0,
+ totalTime: 0,
+ });
+
+ // Keep track of all uploaded parts
+ const uploadedParts: { PartNumber: number; ETag: string }[] = [];
+
+ // 1) Convert presignedUrls into a stream of URLs
+ return from(presignedUrls).pipe(
+ // 2) Use mergeMap with concurrency limit to upload chunk by chunk
+ mergeMap((url, index) => {
+ const partNumber = index + 1;
+ const start = index * partSize;
+ const end = Math.min(start + partSize, file.size);
+ const chunk = file.slice(start, end);
+
+ // Upload the chunk
+ return new Observable(partObserver => {
+ const xhr = new XMLHttpRequest();
+
+ xhr.upload.addEventListener("progress", event => {
+ if (event.lengthComputable) {
+ // Update this specific part's progress
+ partProgress.set(partNumber, event.loaded);
+
+ // Calculate total progress across all parts
+ let totalUploaded = 0;
+ partProgress.forEach(bytes => (totalUploaded += bytes));
+ const percentage = Math.round((totalUploaded / file.size) * 100);
+ const stats = calculateStats(totalUploaded);
+
+ observer.next({
+ filePath,
+ percentage: Math.min(percentage, 99), // Cap at 99% until finalized
+ status: "uploading",
+ uploadId,
+ physicalAddress,
+ ...stats,
+ });
+ }
+ });
+
+ xhr.addEventListener("load", () => {
+ if (xhr.status === 200 || xhr.status === 201) {
+ const etag = xhr.getResponseHeader("ETag")?.replace(/"/g, "");
+ if (!etag) {
+ partObserver.error(new Error(`Missing ETag for part ${partNumber}`));
+ return;
+ }
+
+ // Mark this part as fully uploaded
+ partProgress.set(partNumber, chunk.size);
+ uploadedParts.push({ PartNumber: partNumber, ETag: etag });
+
+ // Recalculate progress
+ let totalUploaded = 0;
+ partProgress.forEach(bytes => (totalUploaded += bytes));
+ const percentage = Math.round((totalUploaded / file.size) * 100);
+ lastUpdateTime = 0;
+ const stats = calculateStats(totalUploaded);
+
+ observer.next({
+ filePath,
+ percentage: Math.min(percentage, 99),
+ status: "uploading",
+ uploadId,
+ physicalAddress,
+ ...stats,
+ });
+ partObserver.complete();
+ } else {
+ partObserver.error(new Error(`Failed to upload part ${partNumber}`));
+ }
+ });
+
+ xhr.addEventListener("error", () => {
+ // Remove failed part from progress
+ partProgress.delete(partNumber);
+ partObserver.error(new Error(`Failed to upload part ${partNumber}`));
+ });
+
+ xhr.open("PUT", url);
+ xhr.send(chunk);
+ });
+ }, concurrencyLimit),
+
+ // 3) Collect results from all uploads (like forkJoin, but respects concurrency)
+ toArray(),
+ // 4) Finalize if all parts succeeded
+ switchMap(() =>
+ this.finalizeMultipartUpload(datasetName, filePath, uploadId, uploadedParts, physicalAddress, false)
+ ),
+ tap(() => {
+ const finalTotalTime = (Date.now() - startTime) / 1000;
+ observer.next({
+ filePath,
+ percentage: 100,
+ status: "finished",
+ uploadId: uploadId,
+ physicalAddress: physicalAddress,
+ uploadSpeed: 0,
+ estimatedTimeRemaining: 0,
+ totalTime: finalTotalTime,
+ });
+ observer.complete();
+ }),
+ catchError((error: unknown) => {
+ // If an error occurred, abort the upload
+ const currentTotalTime = (Date.now() - startTime) / 1000;
+ observer.next({
+ filePath,
+ percentage: Math.round((uploadedParts.length / partCount) * 100),
+ status: "aborted",
+ uploadId: uploadId,
+ physicalAddress: physicalAddress,
+ uploadSpeed: 0,
+ estimatedTimeRemaining: 0,
+ totalTime: currentTotalTime,
+ });
+
+ return this.finalizeMultipartUpload(
+ datasetName,
+ filePath,
+ uploadId,
+ uploadedParts,
+ physicalAddress,
+ true
+ ).pipe(switchMap(() => throwError(() => error)));
+ })
+ );
+ })
+ )
+ .subscribe({
+ error: (err: unknown) => observer.error(err),
+ });
+ return () => subscription.unsubscribe();
+ });
+ }
+
+ /**
+ * Initiates a multipart upload and retrieves presigned URLs for each part.
+ * @param datasetName Dataset Name
+ * @param filePath File path within the dataset
+ * @param numParts Number of parts for the multipart upload
+ */
+ private initiateMultipartUpload(
+ datasetName: string,
+ filePath: string,
+ numParts: number
+ ): Observable<{ uploadId: string; presignedUrls: string[]; physicalAddress: string }> {
+ const params = new HttpParams()
+ .set("type", "init")
+ .set("datasetName", datasetName)
+ .set("filePath", encodeURIComponent(filePath))
+ .set("numParts", numParts.toString());
+
+ return this.http.post<{ uploadId: string; presignedUrls: string[]; physicalAddress: string }>(
+ `${AppSettings.getApiEndpoint()}/${MODEL_BASE_URL}/multipart-upload`,
+ {},
+ { params }
+ );
+ }
+
+ /**
+ * Completes or aborts a multipart upload, sending part numbers and ETags to the backend.
+ */
+ public finalizeMultipartUpload(
+ datasetName: string,
+ filePath: string,
+ uploadId: string,
+ parts: { PartNumber: number; ETag: string }[],
+ physicalAddress: string,
+ isAbort: boolean
+ ): Observable<Response> {
+ const params = new HttpParams()
+ .set("type", isAbort ? "abort" : "finish")
+ .set("datasetName", datasetName)
+ .set("filePath", encodeURIComponent(filePath))
+ .set("uploadId", uploadId);
+
+ return this.http.post<Response>(
+ `${AppSettings.getApiEndpoint()}/${MODEL_BASE_URL}/multipart-upload`,
+ { parts, physicalAddress },
+ { params }
+ );
+ }
+
+ /**
+ * Resets a dataset file difference in LakeFS.
+ * @param did Dataset ID
+ * @param filePath File path to reset
+ */
+ public resetDatasetFileDiff(did: number, filePath: string): Observable<Response> {
+ const params = new HttpParams().set("filePath", encodeURIComponent(filePath));
+
+ return this.http.put<Response>(`${AppSettings.getApiEndpoint()}/${MODEL_BASE_URL}/${did}/diff`, {}, { params });
+ }
+
+ /**
+ * Deletes a dataset file from LakeFS.
+ * @param did Dataset ID
+ * @param filePath File path to delete
+ */
+ public deleteDatasetFile(did: number, filePath: string): Observable<Response> {
+ const params = new HttpParams().set("filePath", encodeURIComponent(filePath));
+
+ return this.http.delete<Response>(`${AppSettings.getApiEndpoint()}/${MODEL_BASE_URL}/${did}/file`, { params });
+ }
+
+ /**
+ * Retrieves the list of uncommitted dataset changes (diffs).
+ * @param did Dataset ID
+ */
+ public getDatasetDiff(did: number): Observable<DatasetStagedObject[]> {
+ return this.http.get<DatasetStagedObject[]>(`${AppSettings.getApiEndpoint()}/${MODEL_BASE_URL}/${did}/diff`);
+ }
+
+ /**
+ * retrieve a list of versions of a dataset. The list is sorted so that the latest versions are at front.
+ * @param did
+ * @param isLogin
+ */
+ public retrieveDatasetVersionList(did: number, isLogin: boolean = true): Observable<DatasetVersion[]> {
+ const apiEndPont = isLogin
+ ? `${AppSettings.getApiEndpoint()}/${MODEL_BASE_URL}/${did}/${DATASET_VERSION_RETRIEVE_LIST_URL}`
+ : `${AppSettings.getApiEndpoint()}/${MODEL_BASE_URL}/${did}/${DATASET_PUBLIC_VERSION_RETRIEVE_LIST_URL}`;
+ return this.http.get<DatasetVersion[]>(apiEndPont);
+ }
+
+ /**
+ * retrieve the latest version of a dataset.
+ * @param did
+ */
+ public retrieveDatasetLatestVersion(did: number): Observable<DatasetVersion> {
+ return this.http
+ .get<{
+ datasetVersion: DatasetVersion;
+ fileNodes: DatasetFileNode[];
+ }>(`${AppSettings.getApiEndpoint()}/${MODEL_BASE_URL}/${did}/${DATASET_VERSION_LATEST_URL}`)
+ .pipe(
+ map(response => {
+ response.datasetVersion.fileNodes = response.fileNodes;
+ return response.datasetVersion;
+ })
+ );
+ }
+
+ /**
+ * retrieve a list of nodes that represent the files in the version
+ * @param did
+ * @param dvid
+ * @param isLogin
+ */
+ public retrieveDatasetVersionFileTree(
+ did: number,
+ dvid: number,
+ isLogin: boolean = true
+ ): Observable<{ fileNodes: DatasetFileNode[]; size: number }> {
+ const apiUrl = isLogin
+ ? `${AppSettings.getApiEndpoint()}/${MODEL_BASE_URL}/${did}/${DATASET_VERSION_BASE_URL}/${dvid}/rootFileNodes`
+ : `${AppSettings.getApiEndpoint()}/${MODEL_BASE_URL}/${did}/${DATASET_PUBLIC_VERSION_BASE_URL}/${dvid}/rootFileNodes`;
+ return this.http.get<{ fileNodes: DatasetFileNode[]; size: number }>(apiUrl);
+ }
+
+ public deleteDatasets(did: number): Observable<Response> {
+ return this.http.delete<Response>(`${AppSettings.getApiEndpoint()}/${MODEL_BASE_URL}/${did}`);
+ }
+
+ public updateDatasetName(did: number, name: string): Observable<Response> {
+ return this.http.post<Response>(`${AppSettings.getApiEndpoint()}/${DATASET_UPDATE_NAME_URL}`, {
+ did: did,
+ name: name,
+ });
+ }
+
+ public updateDatasetDescription(did: number, description: string): Observable<Response> {
+ return this.http.post<Response>(`${AppSettings.getApiEndpoint()}/${DATASET_UPDATE_DESCRIPTION_URL}`, {
+ did: did,
+ description: description,
+ });
+ }
+
+ public updateDatasetPublicity(did: number): Observable<Response> {
+ return this.http.post<Response>(
+ `${AppSettings.getApiEndpoint()}/${MODEL_BASE_URL}/${did}/${DATASET_UPDATE_PUBLICITY_URL}`,
+ {}
+ );
+ }
+
+ public updateDatasetDownloadable(did: number): Observable<Response> {
+ return this.http.post<Response>(
+ `${AppSettings.getApiEndpoint()}/${MODEL_BASE_URL}/${did}/${DATASET_UPDATE_DOWNLOADABLE_URL}`,
+ {}
+ );
+ }
+
+ public retrieveOwners(): Observable<string[]> {
+ return this.http.get<string[]>(`${AppSettings.getApiEndpoint()}/${DATASET_GET_OWNERS_URL}`);
+ }
+}
diff --git a/core/gui/src/app/dashboard/service/user/search.service.ts b/core/gui/src/app/dashboard/service/user/search.service.ts
index a48ff8991d..bc6aac5403 100644
--- a/core/gui/src/app/dashboard/service/user/search.service.ts
+++ b/core/gui/src/app/dashboard/service/user/search.service.ts
@@ -73,7 +73,7 @@ export class SearchService {
params: SearchFilterParameters,
start: number,
count: number,
- type: "workflow" | "project" | "file" | "dataset" | null,
+ type: "workflow" | "project" | "file" | "dataset" | "model" | null,
orderBy: SortMethod,
isLogin: boolean,
includePublic: boolean = false
@@ -125,17 +125,18 @@ export class SearchService {
params: SearchFilterParameters,
start: number,
count: number,
- type: "workflow" | "project" | "dataset" | "file" | null,
+ type: "workflow" | "project" | "dataset" | "file" | "model"| null,
orderBy: SortMethod,
isLogin: boolean,
includePublic: boolean
): Observable<SearchResultBatch> {
return this.search(keywords, params, start, count, type, orderBy, isLogin, includePublic).pipe(
switchMap(results => {
- const hasMismatch = type === "dataset" ? results.hasMismatch ?? false : undefined;
+ console.log("Search results:", results);
+ const hasMismatch = (type === "dataset" || type==="model") ? results.hasMismatch ?? false : undefined;
const filteredResults =
- type === "dataset" ? results.results.filter(i => i !== null && i.dataset != null) : results.results;
-
+ (type === "dataset") ? results.results.filter(i => i !== null && i.dataset != null) : (type === "model") ? results.results.filter(i => i !== null && i.model != null): results.results;
+ console.log("Filtered results:", filteredResults);
return this.extendSearchResultsWithHubActivityInfo(filteredResults, isLogin).pipe(
map(entries => ({
entries,
@@ -173,6 +174,7 @@ export class SearchService {
if (i.project) userIds.add(i.project.ownerId);
else if (i.workflow) userIds.add(i.workflow.ownerId);
else if (i.dataset?.dataset?.ownerUid != null) userIds.add(i.dataset.dataset.ownerUid);
+ else if (i.model?.model?.ownerUid != null) userIds.add(i.model.model.ownerUid);
});
const userInfo$ = userIds.size ? this.getUserInfo(Array.from(userIds)) : of({} as Record<number, UserInfo>);
@@ -188,7 +190,11 @@ export class SearchService {
} else if (i.dataset?.dataset?.did != null) {
entityTypes.push(EntityType.Dataset);
entityIds.push(i.dataset.dataset.did);
+ } else if (i.model?.model?.mid != null) {
+ entityTypes.push(EntityType.Model);
+ entityIds.push(i.model.model.mid);
}
+
});
const counts$ =
@@ -224,14 +230,19 @@ export class SearchService {
? new DashboardEntry(i.workflow)
: i.project
? new DashboardEntry(i.project)
- : new DashboardEntry(i.dataset!);
+ : i.dataset
+ ? new DashboardEntry(i.dataset)
+ : new DashboardEntry(i.model!);
const key = `${entry.type}:${entry.id}`;
const ownerId = i.workflow
? i.workflow.ownerId
: i.project
? i.project.ownerId
- : i.dataset!.dataset!.ownerUid!;
+ : i.dataset
+ ? i.dataset.dataset.ownerUid!
+ : i.model!.model.ownerUid!;
+
const ui = (userMap as any)[ownerId];
if (ui) {
entry.setOwnerName(ui.userName);
diff --git a/core/gui/src/app/dashboard/type/dashboard-dataset.interface.ts b/core/gui/src/app/dashboard/type/dashboard-dataset.interface.ts
index 2ea0058264..d739bb43de 100644
--- a/core/gui/src/app/dashboard/type/dashboard-dataset.interface.ts
+++ b/core/gui/src/app/dashboard/type/dashboard-dataset.interface.ts
@@ -17,7 +17,7 @@
* under the License.
*/
-import { Dataset, DatasetVersion } from "../../common/type/dataset";
+import { Dataset, Model, DatasetVersion } from "../../common/type/dataset";
import { DatasetFileNode } from "../../common/type/datasetVersionFileTree";
export interface DashboardDataset {
@@ -27,3 +27,12 @@ export interface DashboardDataset {
accessPrivilege: "READ" | "WRITE" | "NONE";
size: number;
}
+
+export interface DashboardModel{
+ isOwner: boolean;
+ ownerEmail: string;
+ model: Model;
+ accessPrivilege: "READ" | "WRITE" | "NONE";
+ size: number;
+}
+
diff --git a/core/gui/src/app/dashboard/type/dashboard-entry.ts b/core/gui/src/app/dashboard/type/dashboard-entry.ts
index e526ea01ba..1ba86eea88 100644
--- a/core/gui/src/app/dashboard/type/dashboard-entry.ts
+++ b/core/gui/src/app/dashboard/type/dashboard-entry.ts
@@ -20,8 +20,14 @@
import { DashboardFile } from "./dashboard-file.interface";
import { DashboardWorkflow } from "./dashboard-workflow.interface";
import { DashboardProject } from "./dashboard-project.interface";
-import { DashboardDataset } from "./dashboard-dataset.interface";
-import { isDashboardDataset, isDashboardFile, isDashboardProject, isDashboardWorkflow } from "./type-predicates";
+import { DashboardDataset, DashboardModel } from "./dashboard-dataset.interface";
+import {
+ isDashboardDataset,
+ isDashboardFile,
+ isDashboardModel,
+ isDashboardProject,
+ isDashboardWorkflow,
+} from "./type-predicates";
import { EntityType } from "../../hub/service/hub.service";
export interface UserInfo {
@@ -49,7 +55,7 @@ export class DashboardEntry {
isLiked: boolean;
accessibleUserIds: number[];
- constructor(public value: DashboardWorkflow | DashboardProject | DashboardFile | DashboardDataset) {
+ constructor(public value: DashboardWorkflow | DashboardProject | DashboardFile | DashboardDataset | DashboardModel) {
if (isDashboardWorkflow(value)) {
this.type = EntityType.Workflow;
this.id = value.workflow.wid;
@@ -122,6 +128,24 @@ export class DashboardEntry {
this.likeCount = 0;
this.isLiked = false;
this.accessibleUserIds = [];
+ } else if (isDashboardModel(value)) {
+ this.type = EntityType.Model;
+ this.id = value.model.mid;
+ this.name = value.model.name;
+ this.description = value.model.description;
+ this.creationTime = value.model.creationTime;
+ this.lastModifiedTime = value.model.creationTime;
+ this.accessLevel = value.accessPrivilege;
+ this.ownerName = "";
+ this.ownerEmail = value.ownerEmail;
+ this.ownerGoogleAvatar = "";
+ this.ownerId = value.model.ownerUid;
+ this.size = value.size;
+ this.viewCount = 0;
+ this.cloneCount = 0;
+ this.likeCount = 0;
+ this.isLiked = false;
+ this.accessibleUserIds = [];
} else {
throw new Error("Unexpected type in DashboardEntry.");
}
@@ -180,4 +204,11 @@ export class DashboardEntry {
}
return this.value;
}
+
+ get model(): DashboardModel {
+ if (!isDashboardModel(this.value)) {
+ throw new Error("Value is not of type DashboardModel");
+ }
+ return this.value;
+ }
}
diff --git a/core/gui/src/app/dashboard/type/search-filter-parameters.ts b/core/gui/src/app/dashboard/type/search-filter-parameters.ts
index 848bec2619..4eb5511805 100644
--- a/core/gui/src/app/dashboard/type/search-filter-parameters.ts
+++ b/core/gui/src/app/dashboard/type/search-filter-parameters.ts
@@ -37,7 +37,7 @@ export const toQueryStrings = (
params: SearchFilterParameters,
start?: number,
count?: number,
- type?: "workflow" | "project" | "file" | "dataset" | null,
+ type?: "workflow" | "project" | "file" | "dataset" | "model" | null,
orderBy?: SortMethod
): string => {
function* getQueryParameters(): Iterable<[name: string, value: string]> {
diff --git a/core/gui/src/app/dashboard/type/search-result.ts b/core/gui/src/app/dashboard/type/search-result.ts
index 268381b0a5..5720618cd8 100644
--- a/core/gui/src/app/dashboard/type/search-result.ts
+++ b/core/gui/src/app/dashboard/type/search-result.ts
@@ -20,15 +20,16 @@
import { DashboardFile } from "./dashboard-file.interface";
import { DashboardWorkflow } from "./dashboard-workflow.interface";
import { DashboardProject } from "./dashboard-project.interface";
-import { DashboardDataset } from "./dashboard-dataset.interface";
+import { DashboardDataset, DashboardModel } from "./dashboard-dataset.interface";
import { DashboardEntry } from "./dashboard-entry";
export interface SearchResultItem {
- resourceType: "workflow" | "project" | "file" | "dataset";
+ resourceType: "workflow" | "project" | "file" | "dataset" | "model";
workflow?: DashboardWorkflow;
project?: DashboardProject;
file?: DashboardFile;
dataset?: DashboardDataset;
+ model?: DashboardModel;
}
export interface SearchResult {
diff --git a/core/gui/src/app/dashboard/type/type-predicates.ts b/core/gui/src/app/dashboard/type/type-predicates.ts
index 6c9cf96263..53c7815940 100644
--- a/core/gui/src/app/dashboard/type/type-predicates.ts
+++ b/core/gui/src/app/dashboard/type/type-predicates.ts
@@ -20,7 +20,7 @@
import { DashboardWorkflow } from "./dashboard-workflow.interface";
import { DashboardProject } from "./dashboard-project.interface";
import { DashboardFile } from "./dashboard-file.interface";
-import { DashboardDataset } from "./dashboard-dataset.interface";
+import { DashboardDataset, DashboardModel } from "./dashboard-dataset.interface";
export function isDashboardWorkflow(value: any): value is DashboardWorkflow {
return value && typeof value.workflow === "object";
@@ -37,3 +37,7 @@ export function isDashboardFile(value: any): value is DashboardFile {
export function isDashboardDataset(value: any): value is DashboardDataset {
return value && typeof value.dataset === "object";
}
+
+export function isDashboardModel(value: any): value is DashboardModel {
+ return value && typeof value.model === "object";
+}
diff --git a/core/gui/src/app/hub/service/hub.service.ts b/core/gui/src/app/hub/service/hub.service.ts
index 231fb5e3b8..5bdd9eee75 100644
--- a/core/gui/src/app/hub/service/hub.service.ts
+++ b/core/gui/src/app/hub/service/hub.service.ts
@@ -30,6 +30,7 @@ export enum EntityType {
Dataset = "dataset",
Project = "project",
File = "file",
+ Model = "model",
}
export enum ActionType {
diff --git a/core/scripts/sql/texera_ddl.sql b/core/scripts/sql/texera_ddl.sql
index 7c6cf15c7c..906b6a2ab3 100644
--- a/core/scripts/sql/texera_ddl.sql
+++ b/core/scripts/sql/texera_ddl.sql
@@ -50,6 +50,9 @@ DROP TABLE IF EXISTS workflow_executions CASCADE;
DROP TABLE IF EXISTS dataset CASCADE;
DROP TABLE IF EXISTS dataset_user_access CASCADE;
DROP TABLE IF EXISTS dataset_version CASCADE;
+DROP TABLE IF EXISTS model CASCADE;
+DROP TABLE IF EXISTS model_user_access CASCADE;
+DROP TABLE IF EXISTS model_version CASCADE;
DROP TABLE IF EXISTS public_project CASCADE;
DROP TABLE IF EXISTS project_user_access CASCADE;
DROP TABLE IF EXISTS workflow_user_likes CASCADE;
@@ -59,6 +62,8 @@ DROP TABLE IF EXISTS workflow_user_activity CASCADE;
DROP TABLE IF EXISTS user_activity CASCADE;
DROP TABLE IF EXISTS dataset_user_likes CASCADE;
DROP TABLE IF EXISTS dataset_view_count CASCADE;
+DROP TABLE IF EXISTS model_user_likes CASCADE;
+DROP TABLE IF EXISTS model_view_count CASCADE;
DROP TABLE IF EXISTS site_settings CASCADE;
DROP TABLE IF EXISTS computing_unit_user_access CASCADE;
@@ -260,6 +265,43 @@ CREATE TABLE IF NOT EXISTS dataset_version
FOREIGN KEY (did) REFERENCES dataset(did) ON DELETE CASCADE
);
+-- model
+CREATE TABLE IF NOT EXISTS model
+(
+ mid SERIAL PRIMARY KEY,
+ owner_uid INT NOT NULL,
+ name VARCHAR(128) NOT NULL,
+ is_public BOOLEAN NOT NULL DEFAULT TRUE,
+ is_downloadable BOOLEAN NOT NULL DEFAULT TRUE,
+ description VARCHAR(512) NOT NULL,
+ creation_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (owner_uid) REFERENCES "user"(uid) ON DELETE CASCADE
+);
+
+-- model_user_access
+CREATE TABLE IF NOT EXISTS model_user_access
+(
+ mid INT NOT NULL,
+ uid INT NOT NULL,
+ privilege privilege_enum NOT NULL DEFAULT 'NONE',
+ PRIMARY KEY (mid, uid),
+ FOREIGN KEY (mid) REFERENCES model(mid) ON DELETE CASCADE,
+ FOREIGN KEY (uid) REFERENCES "user"(uid) ON DELETE CASCADE
+);
+
+-- model_version
+CREATE TABLE IF NOT EXISTS model_version
+(
+ mvid SERIAL PRIMARY KEY,
+ mid INT NOT NULL,
+ creator_uid INT NOT NULL,
+ name VARCHAR(128) NOT NULL,
+ version_hash VARCHAR(64) NOT NULL,
+ creation_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (mid) REFERENCES model(mid) ON DELETE CASCADE
+);
+
+
-- operator_executions (modified to match MySQL: no separate primary key; added console_messages_uri)
CREATE TABLE IF NOT EXISTS operator_executions
(
@@ -341,6 +383,25 @@ CREATE TABLE IF NOT EXISTS dataset_view_count
FOREIGN KEY (did) REFERENCES dataset(did) ON DELETE CASCADE
);
+-- model_user_likes table
+CREATE TABLE IF NOT EXISTS model_user_likes
+(
+ uid INTEGER NOT NULL,
+ mid INTEGER NOT NULL,
+ PRIMARY KEY (uid, mid),
+ FOREIGN KEY (uid) REFERENCES "user"(uid) ON DELETE CASCADE,
+ FOREIGN KEY (mid) REFERENCES model(mid) ON DELETE CASCADE
+);
+
+-- model_view_count table
+CREATE TABLE IF NOT EXISTS model_view_count
+(
+ mid INTEGER NOT NULL,
+ view_count INTEGER NOT NULL DEFAULT 0,
+ PRIMARY KEY (mid),
+ FOREIGN KEY (mid) REFERENCES model(mid) ON DELETE CASCADE
+);
+
-- site_settings table
CREATE TABLE IF NOT EXISTS site_settings
(