You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@texera.apache.org by gi...@apache.org on 2026/09/01 21:40:36 UTC

(texera) branch main updated: feat(frontend, amber): add models to the hub and unified search (#7930)

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

github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/texera.git


The following commit(s) were added to refs/heads/main by this push:
     new 246f7bc500 feat(frontend, amber): add models to the hub and unified search (#7930)
246f7bc500 is described below

commit 246f7bc500ff778c02145a34159a70778978fef8
Author: Tanishq Gandhi <56...@users.noreply.github.com>
AuthorDate: Tue Sep 1 21:22:55 2026 +0000

    feat(frontend, amber): add models to the hub and unified search (#7930)
    
    ### What changes were proposed in this PR?
    
    Models can already be created, versioned, uploaded to, shared and
    published. What they cannot be is **found**. There is no Models section
    in the hub, models never appear in search results, and the Models page
    under Your Work filters a list in the browser rather than querying the
    server. This PR adds that missing half, backend and frontend together.
    
    **Backend.** Two new tables, `model_user_likes` and `model_view_count`,
    give models the same engagement tracking workflows and datasets have,
    and `model` joins the full-text index block so its name and description
    are searchable (`43.sql`, with `texera_ddl.sql` covering a fresh
    database). Adding `EntityType.Model` and one registry entry is enough to
    wire up every hub endpoint — counts, likes, views, tops and user-access
    — because that layer is already resource-agnostic.
    `ModelSearchQueryBuilder` teaches unified search about models; it is a
    projection over shared query logic, and `framework` and `format` are the
    only columns unique to it. The search endpoint gains a model result slot
    and a `modelId` filter.
    
    **Frontend.** Models now appear in the hub: a sidebar entry, a count and
    a "Top Loved Models" section on the landing page, and a browse page that
    lists public models as cards or as a list. Unified search gains a Model
    type button, and a model card in the results links to the model's page.
    The Models page under Your Work now searches through the server like the
    Datasets page does, which gives it the shared filters bar — owner, id
    and creation date — and drops about 90 lines of in-browser filtering,
    sorting and owner lookup. The model detail page's view and
    like counters, previously hardcoded zeros, now show real numbers and the
    like button works.
    
    **One refactor, worth reading first.** Two pieces of shared hub code
    were guessing at things they can now be told. The hub result page worked
    out which kind of resource it was showing by looking for substrings in
    the URL, which is fragile once a third kind exists — it now reads the
    kind off the route definition. And the hub landing page's cards had
    their own copy of the "link to my page if I have access, otherwise to
    the hub" logic, which the resource registry
    already provides; they now call it. Neither changes behaviour for
    workflows or datasets.
    
    - The hub's Owner filter offers owners drawn from your own access list
    rather than from what
    the hub actually shows. Pre-existing, affects workflows and datasets
    too, and fixed in a
      follow-up so this PR does not touch their rendering.
    
    
    <img width="1477" height="920" alt="Screenshot 2026-09-01 at 11 45
    12 AM"
    src="https://github.com/user-attachments/assets/435071d4-0685-497b-a6e0-3906fbd19ff9"
    />
    
    <img width="1259" height="493" alt="Screenshot 2026-09-01 at 11 44
    35 AM"
    src="https://github.com/user-attachments/assets/dda1a237-2daa-4819-b436-95b143d2d3ea"
    />
    
    <img width="930" height="229" alt="Screenshot 2026-09-01 at 11 23 03 AM"
    src="https://github.com/user-attachments/assets/d6fe0919-7d92-473d-9a18-9afa63995556"
    />
    
    <img width="335" height="164" alt="Screenshot 2026-09-01 at 11 24 42 AM"
    src="https://github.com/user-attachments/assets/be5d44e5-096a-4b42-9895-da84cd37cdcf"
    />
    
    <img width="1705" height="290" alt="Screenshot 2026-09-01 at 11 13
    11 AM"
    src="https://github.com/user-attachments/assets/b9f65317-5bc0-43a1-be5b-ef58a1b4dd49"
    />
    
    ### Any related issues, documentation, discussions?
    
    Closes #6501 (umbrella #6494).
    
    ### How was this PR tested?
    
    **Backend.** 518 tests pass across the dashboard package. New:
    `ModelSearchQueryBuilderSpec` pins the projection, the access scoping
    and the id filter; model cases in `VersionedResourceTablesSpec`,
    `EntityTablesSpec`, `EntityTypeSpec` and `HubEntityModelSpec`; and hub
    coverage in `HubResourceSpec` for likes, view counts, `getCounts`,
    `getTops` and `user-access`, including the case where a workflow, a
    dataset and a model share an id.
    `UnifiedResourceSchemaSpec` and `DatasetSearchQueryBuilderSpec` confirm
    the shared-builder change leaves the dataset and workflow SQL unchanged.
    `ConfigResourceSpec` and
    `DefaultsConfigSpec` cover the new setting. `43.sql` applied twice for
    idempotence; `scalafmtCheckAll` clean.
    
    **Frontend.** 2574 tests pass across `src/app/hub` (183),
    `src/app/dashboard` (1734) and `src/app/common` (657); `tsc --noEmit`
    and Prettier clean. New coverage for model rows through the search
    pipeline and the unknown-kind row that used to throw; the route-based
    kind resolution; the hub landing cards' links for all three kinds,
    including two kinds sharing an id; the model count and Top Loved Models;
    the hub sidebar entry; the Models page's server query and filters bar;
    and the detail page's view, like and toggle behaviour.
    
    Manually verified: the hub Models browse page, the landing page's count
    and Top Loved Models, liking a model and watching the count follow, the
    view counter incrementing on the detail page, the Model button in
    search, and the new admin toggle showing and hiding the hub entry.
    Regression checks on hub Workflows and Datasets: the right kind is
    listed, the card/list toggle appears only for datasets, and the sort
    options are unchanged.
    
    ### Was this PR authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code (Claude Opus 5)
---
 .../web/resource/dashboard/DashboardResource.scala |  16 +-
 .../dashboard/ModelSearchQueryBuilder.scala        |  43 ++++
 .../resource/dashboard/SearchQueryBuilder.scala    |   1 +
 .../resource/dashboard/UnifiedResourceSchema.scala |  11 +-
 .../VersionedResourceSearchQueryBuilder.scala      |  12 +-
 .../dashboard/VersionedResourceTables.scala        |  38 +++-
 .../web/resource/dashboard/hub/EntityTables.scala  |  36 +++
 .../web/resource/dashboard/hub/EntityType.scala    |   3 +-
 .../dashboard/user/model/ModelResource.scala       |  35 +++
 .../dashboard/ModelSearchQueryBuilderSpec.scala    | 165 ++++++++++++++
 .../dashboard/UnifiedResourceSchemaSpec.scala      |  32 +--
 .../dashboard/VersionedResourceTablesSpec.scala    |  72 +++++-
 .../resource/dashboard/hub/EntityTablesSpec.scala  |  50 +++++
 .../resource/dashboard/hub/EntityTypeSpec.scala    |   4 +
 .../dashboard/hub/HubEntityModelSpec.scala         |   5 +-
 .../resource/dashboard/hub/HubResourceSpec.scala   | 176 ++++++++++++++-
 common/config/src/main/resources/default.conf      |   5 +
 .../texera/common/config/DefaultsConfigSpec.scala  |   2 +
 .../service/resource/ConfigResourceSpec.scala      |   1 +
 frontend/src/app/app-routing.constant.ts           |   3 +
 frontend/src/app/app-routing.module.ts             |  17 ++
 frontend/src/app/common/type/gui-config.ts         |   1 +
 .../admin/settings/admin-settings.component.html   |   9 +
 .../settings/admin-settings.component.spec.ts      |   5 +-
 .../admin/settings/admin-settings.component.ts     |   1 +
 .../component/dashboard.component.spec.ts          |   2 +
 .../app/dashboard/component/dashboard.component.ts |   1 +
 .../component/user/search/search.component.html    |  11 +
 .../component/user/search/search.component.spec.ts |   9 +-
 .../component/user/search/search.component.ts      |   8 +-
 .../model-detail.component.html                    |   7 +-
 .../model-detail.component.scss                    |  16 ++
 .../model-detail.component.spec.ts                 |  68 +++++-
 .../user-model-explorer/model-detail.component.ts  |  51 ++++-
 .../user/user-model/user-model.component.html      |   7 +-
 .../user/user-model/user-model.component.spec.ts   | 241 ++++++++-------------
 .../user/user-model/user-model.component.ts        | 147 ++++++-------
 .../service/user/model/model.service.spec.ts       |  10 +-
 .../dashboard/service/user/model/model.service.ts  |   5 -
 .../resource-registry/model-resource.descriptor.ts |   4 +-
 .../resource-registry.service.spec.ts              |   8 +-
 .../dashboard/service/user/search.service.spec.ts  |  81 +++++++
 .../app/dashboard/service/user/search.service.ts   |  36 ++-
 .../dashboard/service/user/stub-search.service.ts  |   4 +-
 .../app/dashboard/type/search-filter-parameters.ts |   4 +-
 frontend/src/app/dashboard/type/search-result.ts   |   4 +-
 .../browse-section/browse-section.component.html   |   2 +-
 .../browse-section.component.spec.ts               |  68 ++++--
 .../browse-section/browse-section.component.ts     |  66 ++----
 .../hub-search-result.component.html               |  14 +-
 .../hub-search-result.component.spec.ts            | 132 +++++++----
 .../hub-search-result.component.ts                 |  43 ++--
 frontend/src/app/hub/component/hub.component.html  |  12 +
 .../src/app/hub/component/hub.component.spec.ts    |  27 ++-
 frontend/src/app/hub/component/hub.component.ts    |   5 +-
 .../landing-page/landing-page.component.html       |  13 +-
 .../landing-page/landing-page.component.spec.ts    |  59 +++--
 .../landing-page/landing-page.component.ts         |  21 +-
 sql/changelog.xml                                  |   5 +
 sql/texera_ddl.sql                                 |  27 ++-
 sql/updates/43.sql                                 |  78 +++++++
 61 files changed, 1557 insertions(+), 482 deletions(-)

diff --git a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/DashboardResource.scala b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/DashboardResource.scala
index 1dbb80f545..192418e27c 100644
--- a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/DashboardResource.scala
+++ b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/DashboardResource.scala
@@ -26,6 +26,7 @@ import org.apache.texera.dao.jooq.generated.tables.pojos._
 import org.apache.texera.web.resource.dashboard.DashboardResource._
 import org.apache.texera.web.resource.dashboard.SearchQueryBuilder.{ALL_RESOURCE_TYPE, context}
 import org.apache.texera.web.resource.dashboard.user.dataset.DatasetResource.DashboardDataset
+import org.apache.texera.web.resource.dashboard.user.model.ModelResource.DashboardModel
 import org.apache.texera.web.resource.dashboard.user.workflow.WorkflowResource.DashboardWorkflow
 import org.jooq.{Field, OrderField}
 
@@ -39,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, avatar: Option[String])
@@ -63,6 +65,7 @@ object DashboardResource {
    * @param workflowIDs       A list of workflow IDs to include in the search results.
    * @param operators         A list of operators to include in the search results.
    * @param projectIds        A list of project IDs to include in the search results.
+   * @param modelIds          A list of model IDs to include in the search results.
    * @param offset            The number of initial results to skip. This is useful for implementing pagination.
    * @param count             The maximum number of results to return.
    * @param orderBy           The order in which to sort the results. Acceptable values are 'NameAsc', 'NameDesc', 'CreateTimeDesc', and 'EditTimeDesc'.
@@ -79,6 +82,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"
@@ -99,11 +103,14 @@ 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}")
     }
 
@@ -122,13 +129,16 @@ object DashboardResource {
             ProjectSearchQueryBuilder.toEntry(uid, record)
           case SearchQueryBuilder.DATASET_RESOURCE_TYPE =>
             DatasetSearchQueryBuilder.toEntry(uid, record)
+          case SearchQueryBuilder.MODEL_RESOURCE_TYPE =>
+            ModelSearchQueryBuilder.toEntry(uid, record)
         }
       })
 
     val entries = allEntries.filter(_ != null)
     val hasMismatch =
       params.resourceType match {
-        case SearchQueryBuilder.DATASET_RESOURCE_TYPE | SearchQueryBuilder.ALL_RESOURCE_TYPE =>
+        case SearchQueryBuilder.DATASET_RESOURCE_TYPE | SearchQueryBuilder.MODEL_RESOURCE_TYPE |
+            SearchQueryBuilder.ALL_RESOURCE_TYPE =>
           allEntries.exists(_ == null)
         case _ =>
           false
diff --git a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/ModelSearchQueryBuilder.scala b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/ModelSearchQueryBuilder.scala
new file mode 100644
index 0000000000..7254512aa8
--- /dev/null
+++ b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/ModelSearchQueryBuilder.scala
@@ -0,0 +1,43 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.texera.web.resource.dashboard
+
+import org.apache.texera.dao.jooq.generated.Tables.MODEL
+import org.jooq.Field
+
+/**
+  * Query logic and projection live in [[VersionedResourceSearchQueryBuilder]]; only the columns
+  * the [[VersionedResourceTables]] descriptor does not already name are here.
+  */
+object ModelSearchQueryBuilder
+    extends VersionedResourceSearchQueryBuilder(VersionedResourceTables.ModelTables) {
+
+  override protected val repositoryNameColumn: Field[String] = MODEL.REPOSITORY_NAME
+
+  override protected val isDownloadableColumn: Field[java.lang.Boolean] = MODEL.IS_DOWNLOADABLE
+
+  override protected val coverImageColumn: Field[String] = MODEL.COVER_IMAGE
+
+  override protected val frameworkColumn: Field[String] = MODEL.FRAMEWORK
+
+  override protected val formatColumn: Field[String] = MODEL.FORMAT
+}
+
+class ModelSearchQueryBuilder {}
diff --git a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/SearchQueryBuilder.scala b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/SearchQueryBuilder.scala
index e8a651cb82..a4a337893e 100644
--- a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/SearchQueryBuilder.scala
+++ b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/SearchQueryBuilder.scala
@@ -46,6 +46,7 @@ object SearchQueryBuilder {
   val WORKFLOW_RESOURCE_TYPE = "workflow"
   val PROJECT_RESOURCE_TYPE = "project"
   val DATASET_RESOURCE_TYPE = ResourceType.Dataset.toString
+  val MODEL_RESOURCE_TYPE = ResourceType.Model.toString
   val ALL_RESOURCE_TYPE = ""
 }
 
diff --git a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/UnifiedResourceSchema.scala b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/UnifiedResourceSchema.scala
index 7ffa2982d0..545908e7aa 100644
--- a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/UnifiedResourceSchema.scala
+++ b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/UnifiedResourceSchema.scala
@@ -82,7 +82,9 @@ object UnifiedResourceSchema {
       workflowCoverImage: Field[String] = DSL.cast(null, classOf[String]),
       // Workflow-only: which view the workflow opens in by default, so the listing can
       // mark the row and route it accordingly.
-      workflowDefaultView: Field[DefaultViewEnum] = DSL.cast(null, classOf[DefaultViewEnum])
+      workflowDefaultView: Field[DefaultViewEnum] = DSL.cast(null, classOf[DefaultViewEnum]),
+      modelFramework: Field[String] = DSL.cast(null, classOf[String]),
+      modelFormat: Field[String] = DSL.cast(null, classOf[String])
   ): UnifiedResourceSchema = {
     new UnifiedResourceSchema(
       Seq(
@@ -114,7 +116,9 @@ object UnifiedResourceSchema {
         versionedResourceCoverImage -> versionedResourceCoverImage
           .as("versioned_resource_cover_image"),
         workflowCoverImage -> workflowCoverImage.as("workflow_cover_image"),
-        workflowDefaultView -> workflowDefaultView.as("workflow_default_view")
+        workflowDefaultView -> workflowDefaultView.as("workflow_default_view"),
+        modelFramework -> modelFramework.as("model_framework"),
+        modelFormat -> modelFormat.as("model_format")
       )
     )
   }
@@ -161,6 +165,9 @@ object UnifiedResourceSchema {
   * - `isVersionedResourceDownloadable`: Indicates if the resource is downloadable, as a `Boolean`.
   * - `versionedResourceUserAccess`: Access privileges for the resource, as a `PrivilegeEnum`
   * - `versionedResourceCoverImage`: Cover image path of the resource, as a `String`.
+  *
+  * Attributes specific to models:
+  * - `modelFramework` / `modelFormat`: The model's framework and serialization format, as `String`s.
   */
 class UnifiedResourceSchema private (
     fieldMappingSeq: Seq[(Field[_], Field[_])]
diff --git a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/VersionedResourceSearchQueryBuilder.scala b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/VersionedResourceSearchQueryBuilder.scala
index 4dd17c9221..aa60f11c8e 100644
--- a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/VersionedResourceSearchQueryBuilder.scala
+++ b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/VersionedResourceSearchQueryBuilder.scala
@@ -46,6 +46,14 @@ abstract class VersionedResourceSearchQueryBuilder[Rec <: Record, P](
 
   protected val coverImageColumn: Field[String]
 
+  /**
+    * Projected for display rather than indexed, and only models carry them today. A resource
+    * without such a column leaves its union slot null, which is what every other builder does.
+    */
+  protected val frameworkColumn: Field[String] = DSL.cast(null, classOf[String])
+
+  protected val formatColumn: Field[String] = DSL.cast(null, classOf[String])
+
   /**
     * Built here rather than per-subclass, and `final` so a subclass cannot replace it, because
     * `hydrate` reads columns the subclass would otherwise have to remember to project. `userEmail`
@@ -69,7 +77,9 @@ abstract class VersionedResourceSearchQueryBuilder[Rec <: Record, P](
       isVersionedResourcePublic = tables.isPublicColumn,
       isVersionedResourceDownloadable = isDownloadableColumn,
       versionedResourceUserAccess = tables.access.privilegeColumn,
-      versionedResourceCoverImage = coverImageColumn
+      versionedResourceCoverImage = coverImageColumn,
+      modelFramework = frameworkColumn,
+      modelFormat = formatColumn
     )
 
   /**
diff --git a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/VersionedResourceTables.scala b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/VersionedResourceTables.scala
index 27287db7cc..7394c6db8a 100644
--- a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/VersionedResourceTables.scala
+++ b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/VersionedResourceTables.scala
@@ -21,17 +21,18 @@ package org.apache.texera.web.resource.dashboard
 
 import com.typesafe.scalalogging.LazyLogging
 import org.apache.texera.amber.core.storage.util.LakeFSStorageClient
-import org.apache.texera.dao.jooq.generated.Tables.DATASET
+import org.apache.texera.dao.jooq.generated.Tables.{DATASET, MODEL}
 import org.apache.texera.dao.jooq.generated.enums.PrivilegeEnum
 import org.apache.texera.dao.jooq.generated.tables.User.USER
-import org.apache.texera.dao.jooq.generated.tables.pojos.{Dataset, User}
-import org.apache.texera.dao.jooq.generated.tables.records.DatasetRecord
+import org.apache.texera.dao.jooq.generated.tables.pojos.{Dataset, Model, User}
+import org.apache.texera.dao.jooq.generated.tables.records.{DatasetRecord, ModelRecord}
 import org.apache.texera.web.resource.dashboard.DashboardResource.{
   DashboardClickableFileEntry,
   SearchQueryParams
 }
 import org.apache.texera.web.resource.dashboard.hub.EntityTables.{AccessTable, BaseEntityTable}
 import org.apache.texera.web.resource.dashboard.user.dataset.DatasetResource.DashboardDataset
+import org.apache.texera.web.resource.dashboard.user.model.ModelResource.DashboardModel
 import org.jooq._
 
 import java.sql.Timestamp
@@ -144,4 +145,35 @@ object VersionedResourceTables {
         dataset = Some(DashboardDataset(resource, ownerEmail, accessPrivilege, isOwner, size))
       )
   }
+
+  case object ModelTables extends VersionedResourceTables[ModelRecord, Model] {
+    override val resourceType: String = SearchQueryBuilder.MODEL_RESOURCE_TYPE
+    override val table: Table[ModelRecord] = MODEL
+    override val idColumn: TableField[ModelRecord, Integer] = MODEL.MID
+    override val isPublicColumn: TableField[ModelRecord, java.lang.Boolean] = MODEL.IS_PUBLIC
+    override val nameColumn: TableField[ModelRecord, String] = MODEL.NAME
+    override val descriptionColumn: TableField[ModelRecord, String] = MODEL.DESCRIPTION
+    override val creationTimeColumn: TableField[ModelRecord, Timestamp] = MODEL.CREATION_TIME
+    override val ownerUidColumn: TableField[ModelRecord, Integer] = MODEL.OWNER_UID
+    override val access: AccessTable = AccessTable.ModelAccessTable
+
+    override def searchIds(params: SearchQueryParams): java.util.List[Integer] = params.modelIds
+
+    override def pojo(record: Record): Model = record.into(MODEL).into(classOf[Model])
+    override def idOf(resource: Model): Integer = resource.getMid
+    override def ownerUidOf(resource: Model): Integer = resource.getOwnerUid
+    override def repositoryNameOf(resource: Model): String = resource.getRepositoryName
+
+    override def entry(
+        resource: Model,
+        ownerEmail: String,
+        accessPrivilege: PrivilegeEnum,
+        isOwner: Boolean,
+        size: Long
+    ): DashboardClickableFileEntry =
+      DashboardClickableFileEntry(
+        resourceType = resourceType,
+        model = Some(DashboardModel(resource, ownerEmail, accessPrivilege, isOwner, size))
+      )
+  }
 }
diff --git a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/hub/EntityTables.scala b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/hub/EntityTables.scala
index 4a2d749fc1..0e557f40f7 100644
--- a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/hub/EntityTables.scala
+++ b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/hub/EntityTables.scala
@@ -67,10 +67,22 @@ object EntityTables {
     )
   }
 
+  case object ModelTableSet extends EntityTableSet {
+    override val base: BaseEntityTable = VersionedResourceTables.ModelTables
+    override val like: LikeTable = LikeTable.ModelLikeTable
+    override val viewCount: ViewCountTable = ViewCountTable.ModelViewCountTable
+    override val access: AccessTable = AccessTable.ModelAccessTable
+    override val cloneTable: Option[CloneTable] = None
+    override val versionedResource: Option[VersionedResourceTables[_ <: Record, _]] = Some(
+      VersionedResourceTables.ModelTables
+    )
+  }
+
   def apply(entityType: EntityType): EntityTableSet =
     entityType match {
       case EntityType.Workflow => WorkflowTableSet
       case EntityType.Dataset  => DatasetTableSet
+      case EntityType.Model    => ModelTableSet
     }
 
   // ==================== BASE TABLE ====================
@@ -122,6 +134,13 @@ 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 = EntityTables(entityType).like
   }
 
@@ -170,6 +189,14 @@ object EntityTables {
         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 = EntityTables(entityType).viewCount
   }
 
@@ -204,6 +231,15 @@ object EntityTables {
         DATASET_USER_ACCESS.PRIVILEGE
     }
 
+    case object ModelAccessTable extends AccessTable {
+      override type R = ModelUserAccessRecord
+      override val table: Table[ModelUserAccessRecord] = MODEL_USER_ACCESS
+      override val idColumn: TableField[ModelUserAccessRecord, Integer] = MODEL_USER_ACCESS.MID
+      override val uidColumn: TableField[ModelUserAccessRecord, Integer] = MODEL_USER_ACCESS.UID
+      override val privilegeColumn: TableField[ModelUserAccessRecord, PrivilegeEnum] =
+        MODEL_USER_ACCESS.PRIVILEGE
+    }
+
     def apply(entityType: EntityType): AccessTable = EntityTables(entityType).access
   }
 }
diff --git a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/hub/EntityType.scala b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/hub/EntityType.scala
index bd83b7837e..cd8ad31bb4 100644
--- a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/hub/EntityType.scala
+++ b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/hub/EntityType.scala
@@ -36,8 +36,9 @@ sealed trait EntityType {
 object EntityType {
   case object Workflow extends EntityType { val value = "workflow" }
   case object Dataset extends EntityType { val value: String = ResourceType.Dataset.toString }
+  case object Model extends EntityType { val value: String = ResourceType.Model.toString }
 
-  private val values = Seq(Workflow, Dataset)
+  private val values = Seq(Workflow, Dataset, Model)
 
   @JsonCreator
   def fromString(s: String): EntityType =
diff --git a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/user/model/ModelResource.scala b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/user/model/ModelResource.scala
new file mode 100644
index 0000000000..748f619655
--- /dev/null
+++ b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/user/model/ModelResource.scala
@@ -0,0 +1,35 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.texera.web.resource.dashboard.user.model
+
+import org.apache.texera.dao.jooq.generated.tables.pojos.Model
+import org.jooq.EnumType
+
+object ModelResource {
+  // Mirrors file-service's ModelResource.DashboardModel; amber does not depend on file-service.
+  // 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/amber/src/test/scala/org/apache/texera/web/resource/dashboard/ModelSearchQueryBuilderSpec.scala b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/ModelSearchQueryBuilderSpec.scala
new file mode 100644
index 0000000000..50d03678d4
--- /dev/null
+++ b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/ModelSearchQueryBuilderSpec.scala
@@ -0,0 +1,165 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.texera.web.resource.dashboard
+
+import org.apache.texera.dao.MockTexeraDB
+import org.apache.texera.web.resource.dashboard.DashboardResource.SearchQueryParams
+import org.scalatest.BeforeAndAfterAll
+import org.scalatest.flatspec.AnyFlatSpec
+import org.scalatest.matchers.should.Matchers
+
+import scala.jdk.CollectionConverters._
+
+/**
+  * `ModelSearchQueryBuilder` is a projection over the shared query logic, so this suite pins what
+  * is model-specific: the columns each schema slot carries, the tables the access scoping reads,
+  * and the id list the filter comes from. The FROM/WHERE/hydration behaviour itself is covered
+  * once, in `DatasetSearchQueryBuilderSpec` and `VersionedResourceTablesSpec`.
+  *
+  * SQL is rendered, never executed, so no lakeFS stub is needed here. Keyword assertions stay on
+  * the tokens and the `coalesce(...) || ' ' || coalesce(...)` expression, which
+  * `FulltextSearchQueryUtils` builds before its pgroonga/tsvector branch — an assertion on either
+  * arm would depend on which suites ran earlier in the JVM.
+  */
+class ModelSearchQueryBuilderSpec
+    extends AnyFlatSpec
+    with Matchers
+    with BeforeAndAfterAll
+    with MockTexeraDB {
+
+  private val callerUid: Integer = Integer.valueOf(9201)
+
+  override protected def beforeAll(): Unit = {
+    initializeDBAndReplaceDSLContext()
+  }
+
+  override protected def afterAll(): Unit = {
+    shutdownDB()
+  }
+
+  private def params(keywords: String*): SearchQueryParams =
+    SearchQueryParams(keywords = keywords.toList.asJava)
+
+  private def sqlFor(
+      uid: Integer,
+      includePublic: Boolean,
+      p: SearchQueryParams = params()
+  ): String =
+    getDSLContext
+      .renderInlined(ModelSearchQueryBuilder.constructQuery(uid, p, includePublic))
+      .toLowerCase
+      .replace("\"", "")
+
+  "the projection" should "carry every model column under the alias its schema slot names" in {
+    val sql = sqlFor(callerUid, includePublic = true)
+
+    sql should include("'model' as resourcetype")
+    sql should include("model.name as resourcename")
+    sql should include("model.description as resourcedescription")
+    sql should include("model.creation_time as resourcecreationtime")
+    sql should include("model.owner_uid as resourceownerid")
+    sql should include("model.repository_name as repository_name")
+    // Shared with the other versioned resources, hence the resource-neutral aliases.
+    sql should include("model.mid as versioned_resource_id")
+    sql should include("model.is_public as is_versioned_resource_public")
+    sql should include("model.is_downloadable as is_versioned_resource_downloadable")
+    sql should include("model_user_access.privilege as user_versioned_resource_access")
+    sql should include("model.cover_image as versioned_resource_cover_image")
+  }
+
+  it should "carry framework and format, which no other resource projects" in {
+    val sql = sqlFor(callerUid, includePublic = true)
+
+    sql should include("model.framework as model_framework")
+    sql should include("model.format as model_format")
+  }
+
+  it should "leave the workflow, project and dataset-only slots null" in {
+    // The union requires all builders to project the same 26 columns in the same order.
+    val sql = sqlFor(callerUid, includePublic = true)
+
+    sql should include("cast(null as int) as wid")
+    sql should include("cast(null as int) as pid")
+    sql should include("cast(null as varchar) as dataset_storage_path")
+    sql should include("cast(null as varchar) as workflow_cover_image")
+  }
+
+  "access scoping" should "read the model's own tables" in {
+    val sql = sqlFor(callerUid, includePublic = true)
+
+    sql should include("from texera_db.model")
+    sql should include regex "model_user_access\\.mid = [\\w.]*model\\.mid"
+    sql should include("model_user_access.uid = 9201")
+  }
+
+  it should "restrict an anonymous caller to public models" in {
+    val sql = sqlFor(null, includePublic = false)
+
+    sql should include("model.is_public = true")
+    sql should not include "model_user_access.uid is not null"
+  }
+
+  it should "return only granted models when public ones are excluded" in {
+    val sql = sqlFor(callerUid, includePublic = false)
+
+    sql should include("model_user_access.uid is not null")
+    sql should not include "model.is_public = true"
+  }
+
+  it should "add public models to the granted ones when they are included" in {
+    val sql = sqlFor(callerUid, includePublic = true)
+
+    sql should include("model.is_public = true")
+    sql should include("model_user_access.uid is not null")
+  }
+
+  "the id filter" should "come from modelIds, and ignore datasetIds" in {
+    val sql = sqlFor(
+      callerUid,
+      includePublic = true,
+      SearchQueryParams(
+        modelIds = List(Integer.valueOf(77)).asJava,
+        datasetIds = List(Integer.valueOf(88)).asJava
+      )
+    )
+
+    // jOOQ collapses a single-element IN to an equality.
+    sql should include("model.mid = 77")
+    sql should not include "88"
+  }
+
+  "the keyword filter" should "search the model's name and its description" in {
+    val sql = sqlFor(callerUid, includePublic = true, params("resnet"))
+
+    sql should include("resnet")
+    sql should include regex "coalesce\\([\\w.]*model\\.name.*coalesce\\([\\w.]*model\\.description"
+  }
+
+  it should "not filter at all when no keyword is given" in {
+    sqlFor(callerUid, includePublic = true) should not include "coalesce"
+  }
+
+  "the query" should "dedupe with selectDistinct rather than a group by" in {
+    val sql = sqlFor(callerUid, includePublic = true)
+
+    sql should include("select distinct")
+    sql should not include "group by"
+  }
+}
diff --git a/amber/src/test/scala/org/apache/texera/web/resource/dashboard/UnifiedResourceSchemaSpec.scala b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/UnifiedResourceSchemaSpec.scala
index 9334fc5b0d..59d7432e98 100644
--- a/amber/src/test/scala/org/apache/texera/web/resource/dashboard/UnifiedResourceSchemaSpec.scala
+++ b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/UnifiedResourceSchemaSpec.scala
@@ -79,7 +79,7 @@ class UnifiedResourceSchemaSpec extends AnyFlatSpec with Matchers {
 
   // Sentinels for the three slots that have no convenient distinct table
   // column of the right type; every other slot uses a real generated column so
-  // that all 25 originals render differently from one another.
+  // that all 27 originals render differently from one another.
   private val sentinelResourceType: Field[String] = JDSL.inline("s-resource-type")
   private val sentinelProjects: Field[String] = JDSL.inline("s-projects")
   private val sentinelStoragePath: Field[String] = JDSL.inline("s-storage-path")
@@ -109,7 +109,9 @@ class UnifiedResourceSchemaSpec extends AnyFlatSpec with Matchers {
     versionedResourceUserAccess = DATASET_USER_ACCESS.PRIVILEGE,
     versionedResourceCoverImage = DATASET.COVER_IMAGE,
     workflowCoverImage = WORKFLOW_COVER_IMAGE.IMAGE,
-    workflowDefaultView = WORKFLOW.DEFAULT_VIEW
+    workflowDefaultView = WORKFLOW.DEFAULT_VIEW,
+    modelFramework = MODEL.FRAMEWORK,
+    modelFormat = MODEL.FORMAT
   )
 
   // Expected projection, in order: alias -> the original it must be built from.
@@ -138,13 +140,15 @@ class UnifiedResourceSchemaSpec extends AnyFlatSpec with Matchers {
     "user_versioned_resource_access" -> DATASET_USER_ACCESS.PRIVILEGE,
     "versioned_resource_cover_image" -> DATASET.COVER_IMAGE,
     "workflow_cover_image" -> WORKFLOW_COVER_IMAGE.IMAGE,
-    "workflow_default_view" -> WORKFLOW.DEFAULT_VIEW
+    "workflow_default_view" -> WORKFLOW.DEFAULT_VIEW,
+    "model_framework" -> MODEL.FRAMEWORK,
+    "model_format" -> MODEL.FORMAT
   )
 
   // -- apply(): the projection ------------------------------------------------
 
-  "apply" should "expose all 25 slots as aliases, in the order the UNION ALL depends on" in {
-    sentinelSchema.allFields should have size 25
+  "apply" should "expose all 27 slots as aliases, in the order the UNION ALL depends on" in {
+    sentinelSchema.allFields should have size 27
     sentinelSchema.allFields.map(_.getName) shouldBe expectedProjection.map(_._1)
   }
 
@@ -164,7 +168,7 @@ class UnifiedResourceSchemaSpec extends AnyFlatSpec with Matchers {
     // about datasets still union with one that does: the column count and
     // types have to line up.
     val defaults = UnifiedResourceSchema()
-    defaults.allFields should have size 25
+    defaults.allFields should have size 27
     val rendered = ctx.renderInlined(JDSL.select(defaults.allFields: _*))
     rendered should include("'' as \"resourceType\"")
     rendered should include("cast(null as timestamp) as \"resourceCreationTime\"")
@@ -199,12 +203,12 @@ class UnifiedResourceSchemaSpec extends AnyFlatSpec with Matchers {
   }
 
   it should "collapse the all-defaults projection down to one alias per distinct default" in {
-    // 25 slots, but only seven structurally distinct default expressions, so the
+    // 27 slots, but only seven structurally distinct default expressions, so the
     // de-dup collapses the map to seven entries. Worth pinning because it is
     // surprising, and because it is what makes the keep-first rule observable at
-    // all: allFields stays at 25 while the translation map does not.
+    // all: allFields stays at 27 while the translation map does not.
     val defaults = UnifiedResourceSchema()
-    defaults.allFields should have size 25
+    defaults.allFields should have size 27
     translatedAliases(defaults) shouldBe Seq(
       "resourceType", // DSL.inline("")
       "resourceCreationTime", // cast(null as timestamp)
@@ -216,7 +220,7 @@ class UnifiedResourceSchemaSpec extends AnyFlatSpec with Matchers {
     )
   }
 
-  it should "keep every distinct original when the caller supplies 25 distinct Fields" in {
+  it should "keep every distinct original when the caller supplies 27 distinct Fields" in {
     // Nothing to collapse here, which is the control case for the two tests
     // above: the shrinkage they observe comes from duplicate originals only.
     translatedAliases(sentinelSchema) shouldBe expectedProjection.map(_._1)
@@ -224,7 +228,7 @@ class UnifiedResourceSchemaSpec extends AnyFlatSpec with Matchers {
 
   it should "drop exactly the duplicated slots of the production workflow projection" in {
     val workflowSchema = WorkflowSearchQueryBuilder.mappedResourceSchema
-    workflowSchema.allFields should have size 25
+    workflowSchema.allFields should have size 27
     val aliases = translatedAliases(workflowSchema)
     // `uid` duplicates ownerId (WORKFLOW_OF_USER.UID); the rest are slots the
     // builder left at their default, and the defaults collide by type.
@@ -235,7 +239,9 @@ class UnifiedResourceSchemaSpec extends AnyFlatSpec with Matchers {
       "versioned_resource_id",
       "repository_name",
       "is_versioned_resource_downloadable",
-      "versioned_resource_cover_image"
+      "versioned_resource_cover_image",
+      "model_framework",
+      "model_format"
     )
     aliases should contain("resourceOwnerId")
   }
@@ -244,7 +250,7 @@ class UnifiedResourceSchemaSpec extends AnyFlatSpec with Matchers {
 
   "jOOQ Field equality" should "be structural, which is what makes the de-dup collapse anything" in {
     // If jOOQ ever switched to identity equality, translatedFieldSet would keep
-    // all 25 slots and translateRecord would start reading duplicated columns —
+    // all 27 slots and translateRecord would start reading duplicated columns —
     // the tests above would flip, and this one says why.
     JDSL.cast(null, classOf[Integer]) shouldBe JDSL.cast(null, classOf[Integer])
     JDSL.inline("") shouldBe JDSL.inline("")
diff --git a/amber/src/test/scala/org/apache/texera/web/resource/dashboard/VersionedResourceTablesSpec.scala b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/VersionedResourceTablesSpec.scala
index a509f03352..a20e331dde 100644
--- a/amber/src/test/scala/org/apache/texera/web/resource/dashboard/VersionedResourceTablesSpec.scala
+++ b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/VersionedResourceTablesSpec.scala
@@ -21,7 +21,7 @@ package org.apache.texera.web.resource.dashboard
 
 import org.apache.texera.dao.MockTexeraDB
 import org.apache.texera.dao.jooq.generated.enums.PrivilegeEnum
-import org.apache.texera.dao.jooq.generated.tables.pojos.Dataset
+import org.apache.texera.dao.jooq.generated.tables.pojos.{Dataset, Model}
 import org.apache.texera.web.resource.dashboard.DashboardResource.SearchQueryParams
 import org.apache.texera.web.resource.dashboard.hub.EntityTables
 import org.scalatest.BeforeAndAfterAll
@@ -42,6 +42,7 @@ class VersionedResourceTablesSpec
     with MockTexeraDB {
 
   private val tables = VersionedResourceTables.DatasetTables
+  private val modelTables = VersionedResourceTables.ModelTables
 
   override protected def beforeAll(): Unit = {
     initializeDBAndReplaceDSLContext()
@@ -53,6 +54,14 @@ class VersionedResourceTablesSpec
 
   private def rendered(sql: String): String = sql.toLowerCase.replace("\"", "")
 
+  private def model(mid: Int, ownerUid: Int, repositoryName: String): Model = {
+    val m = new Model
+    m.setMid(Integer.valueOf(mid))
+    m.setOwnerUid(Integer.valueOf(ownerUid))
+    m.setRepositoryName(repositoryName)
+    m
+  }
+
   private def dataset(did: Int, ownerUid: Int, repositoryName: String): Dataset = {
     val d = new Dataset
     d.setDid(Integer.valueOf(did))
@@ -137,4 +146,65 @@ class VersionedResourceTablesSpec
 
     sql should include("dataset_user_access.uid = 42")
   }
+
+  "ModelTables" should "name every column the shared query logic reads" in {
+    modelTables.table.getName shouldBe "model"
+    modelTables.idColumn.getName shouldBe "mid"
+    modelTables.isPublicColumn.getName shouldBe "is_public"
+    modelTables.nameColumn.getName shouldBe "name"
+    modelTables.descriptionColumn.getName shouldBe "description"
+    modelTables.creationTimeColumn.getName shouldBe "creation_time"
+    modelTables.ownerUidColumn.getName shouldBe "owner_uid"
+  }
+
+  it should "share the hub's access table rather than naming it twice" in {
+    modelTables.access shouldBe EntityTables.AccessTable.ModelAccessTable
+  }
+
+  it should "tag its rows with the resource type DashboardResource dispatches on" in {
+    modelTables.resourceType shouldBe SearchQueryBuilder.MODEL_RESOURCE_TYPE
+    modelTables.resourceType shouldBe "model"
+  }
+
+  it should "read its id filter out of modelIds, not the dataset's list" in {
+    val modelIds = List(Integer.valueOf(3)).asJava
+    val datasetIds = List(Integer.valueOf(4)).asJava
+    val params = SearchQueryParams(datasetIds = datasetIds, modelIds = modelIds)
+
+    modelTables.searchIds(params) shouldBe modelIds
+    tables.searchIds(params) shouldBe datasetIds
+  }
+
+  it should "expose the POJO fields the hub de-dupes and sizes by" in {
+    val m = model(31, 42, "model-31")
+    modelTables.idOf(m) shouldBe Integer.valueOf(31)
+    modelTables.ownerUidOf(m) shouldBe Integer.valueOf(42)
+    modelTables.repositoryNameOf(m) shouldBe "model-31"
+  }
+
+  it should "wrap the model in the model slot, leaving the dataset slot empty" in {
+    val m = model(31, 42, "model-31")
+    val entry =
+      modelTables.entry(m, "owner@test.com", PrivilegeEnum.WRITE, isOwner = false, size = 2048L)
+
+    entry.resourceType shouldBe "model"
+    entry.workflow shouldBe None
+    entry.project shouldBe None
+    entry.dataset shouldBe None
+    val dashboardModel = entry.model.getOrElse(fail("expected a model entry"))
+    dashboardModel.model shouldBe m
+    dashboardModel.ownerEmail shouldBe "owner@test.com"
+    dashboardModel.accessPrivilege shouldBe PrivilegeEnum.WRITE
+    dashboardModel.isOwner shouldBe false
+    dashboardModel.size shouldBe 2048L
+  }
+
+  it should "join the model's own access and owner rows" in {
+    val sql = rendered(getDSLContext.render(modelTables.joinWithAccessAndOwner(None)))
+
+    sql should include regex "model_user_access\\.mid = [\\w.]*model\\.mid"
+    sql should include regex "user\\.uid = [\\w.]*model\\.owner_uid"
+    sql.split("left outer join").length shouldBe 3
+  }
+
 }
diff --git a/amber/src/test/scala/org/apache/texera/web/resource/dashboard/hub/EntityTablesSpec.scala b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/hub/EntityTablesSpec.scala
index f60f92ebd9..b888def78c 100644
--- a/amber/src/test/scala/org/apache/texera/web/resource/dashboard/hub/EntityTablesSpec.scala
+++ b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/hub/EntityTablesSpec.scala
@@ -38,6 +38,11 @@ class EntityTablesSpec extends AnyFlatSpec with Matchers {
       VersionedResourceTables.DatasetTables
   }
 
+  it should "dispatch Model → the model's search descriptor" in {
+    EntityTables.BaseEntityTable(EntityType.Model) shouldBe
+      VersionedResourceTables.ModelTables
+  }
+
   "BaseEntityTable.WorkflowTable" should "wire up id and isPublic columns from WORKFLOW" in {
     val t = EntityTables.BaseEntityTable.WorkflowTable
     t.idColumn.getName shouldBe "wid"
@@ -50,6 +55,12 @@ class EntityTablesSpec extends AnyFlatSpec with Matchers {
     t.isPublicColumn.getName shouldBe "is_public"
   }
 
+  "VersionedResourceTables.ModelTables" should "wire up id and isPublic columns from MODEL" in {
+    val t = VersionedResourceTables.ModelTables
+    t.idColumn.getName shouldBe "mid"
+    t.isPublicColumn.getName shouldBe "is_public"
+  }
+
   // -- LikeTable --------------------------------------------------------------
 
   "EntityTables.LikeTable.apply" should "dispatch Workflow → WorkflowLikeTable" in {
@@ -62,6 +73,11 @@ class EntityTablesSpec extends AnyFlatSpec with Matchers {
       EntityTables.LikeTable.DatasetLikeTable
   }
 
+  it should "dispatch Model → ModelLikeTable" in {
+    EntityTables.LikeTable(EntityType.Model) shouldBe
+      EntityTables.LikeTable.ModelLikeTable
+  }
+
   "LikeTable variants" should "expose uid and the per-entity id column" in {
     val w = EntityTables.LikeTable.WorkflowLikeTable
     w.uidColumn.getName shouldBe "uid"
@@ -70,6 +86,10 @@ class EntityTablesSpec extends AnyFlatSpec with Matchers {
     val d = EntityTables.LikeTable.DatasetLikeTable
     d.uidColumn.getName shouldBe "uid"
     d.idColumn.getName shouldBe "did"
+
+    val m = EntityTables.LikeTable.ModelLikeTable
+    m.uidColumn.getName shouldBe "uid"
+    m.idColumn.getName shouldBe "mid"
   }
 
   // -- CloneTable -------------------------------------------------------------
@@ -79,6 +99,14 @@ class EntityTablesSpec extends AnyFlatSpec with Matchers {
       EntityTables.CloneTable.WorkflowCloneTable
   }
 
+  it should "throw IllegalArgumentException for Model, which is cloneless like Dataset" in {
+    val ex = intercept[IllegalArgumentException] {
+      EntityTables.CloneTable(EntityType.Model)
+    }
+    ex.getMessage should include("Unsupported entity type")
+    ex.getMessage should include("clone")
+  }
+
   it should "throw IllegalArgumentException for Dataset because there is no DatasetClone table" in {
     // The asymmetry is intentional today: dataset clones aren't a modelled
     // entity. CloneTable.apply stays for recordCloneAction.
@@ -107,6 +135,14 @@ class EntityTablesSpec extends AnyFlatSpec with Matchers {
     dataset.access shouldBe EntityTables.AccessTable.DatasetAccessTable
     dataset.cloneTable shouldBe None
     dataset.versionedResource shouldBe Some(VersionedResourceTables.DatasetTables)
+
+    val model = EntityTables(EntityType.Model)
+    model.base shouldBe VersionedResourceTables.ModelTables
+    model.like shouldBe EntityTables.LikeTable.ModelLikeTable
+    model.viewCount shouldBe EntityTables.ViewCountTable.ModelViewCountTable
+    model.access shouldBe EntityTables.AccessTable.ModelAccessTable
+    model.cloneTable shouldBe None
+    model.versionedResource shouldBe Some(VersionedResourceTables.ModelTables)
   }
 
   // -- AccessTable ------------------------------------------------------------
@@ -121,6 +157,11 @@ class EntityTablesSpec extends AnyFlatSpec with Matchers {
     d.idColumn.getName shouldBe "did"
     d.uidColumn.getName shouldBe "uid"
     d.privilegeColumn.getName shouldBe "privilege"
+
+    val m = EntityTables.AccessTable(EntityType.Model)
+    m.idColumn.getName shouldBe "mid"
+    m.uidColumn.getName shouldBe "uid"
+    m.privilegeColumn.getName shouldBe "privilege"
   }
 
   // -- ViewCountTable ---------------------------------------------------------
@@ -135,6 +176,11 @@ class EntityTablesSpec extends AnyFlatSpec with Matchers {
       EntityTables.ViewCountTable.DatasetViewCountTable
   }
 
+  it should "dispatch Model → ModelViewCountTable" in {
+    EntityTables.ViewCountTable(EntityType.Model) shouldBe
+      EntityTables.ViewCountTable.ModelViewCountTable
+  }
+
   "ViewCountTable variants" should "expose id and view_count columns" in {
     val w = EntityTables.ViewCountTable.WorkflowViewCountTable
     w.idColumn.getName shouldBe "wid"
@@ -143,5 +189,9 @@ class EntityTablesSpec extends AnyFlatSpec with Matchers {
     val d = EntityTables.ViewCountTable.DatasetViewCountTable
     d.idColumn.getName shouldBe "did"
     d.viewCountColumn.getName shouldBe "view_count"
+
+    val m = EntityTables.ViewCountTable.ModelViewCountTable
+    m.idColumn.getName shouldBe "mid"
+    m.viewCountColumn.getName shouldBe "view_count"
   }
 }
diff --git a/amber/src/test/scala/org/apache/texera/web/resource/dashboard/hub/EntityTypeSpec.scala b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/hub/EntityTypeSpec.scala
index 2817aa75b1..5c526724fa 100644
--- a/amber/src/test/scala/org/apache/texera/web/resource/dashboard/hub/EntityTypeSpec.scala
+++ b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/hub/EntityTypeSpec.scala
@@ -28,21 +28,25 @@ class EntityTypeSpec extends AnyFlatSpec with Matchers {
   "EntityType.value" should "be the lowercase form of the case-object name" in {
     EntityType.Workflow.value shouldBe "workflow"
     EntityType.Dataset.value shouldBe "dataset"
+    EntityType.Model.value shouldBe "model"
   }
 
   "EntityType.toString" should "mirror value (used by error messages and SQL strings)" in {
     EntityType.Workflow.toString shouldBe "workflow"
     EntityType.Dataset.toString shouldBe "dataset"
+    EntityType.Model.toString shouldBe "model"
   }
 
   "EntityType.fromString" should "round-trip the canonical lowercase value" in {
     EntityType.fromString("workflow") shouldBe EntityType.Workflow
     EntityType.fromString("dataset") shouldBe EntityType.Dataset
+    EntityType.fromString("model") shouldBe EntityType.Model
   }
 
   it should "accept mixed case (equalsIgnoreCase)" in {
     EntityType.fromString("Workflow") shouldBe EntityType.Workflow
     EntityType.fromString("DATASET") shouldBe EntityType.Dataset
+    EntityType.fromString("Model") shouldBe EntityType.Model
   }
 
   it should "throw IllegalArgumentException for an unknown value" in {
diff --git a/amber/src/test/scala/org/apache/texera/web/resource/dashboard/hub/HubEntityModelSpec.scala b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/hub/HubEntityModelSpec.scala
index 03a0908532..e3826c80cb 100644
--- a/amber/src/test/scala/org/apache/texera/web/resource/dashboard/hub/HubEntityModelSpec.scala
+++ b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/hub/HubEntityModelSpec.scala
@@ -110,12 +110,13 @@ class HubEntityModelSpec extends AnyFlatSpec {
   "EntityType subtypes" should "expose their lowercase string value" in {
     assert(EntityType.Workflow.value == "workflow")
     assert(EntityType.Dataset.value == "dataset")
+    assert(EntityType.Model.value == "model")
   }
 
   it should "have toString equal to value (override pin)" in {
     // Same stable-name pattern as ActionType — don't use the SUT
     // (toString) in the failure message.
-    val all: List[EntityType] = List(EntityType.Workflow, EntityType.Dataset)
+    val all: List[EntityType] = List(EntityType.Workflow, EntityType.Dataset, EntityType.Model)
     all.foreach { e =>
       val name = e.getClass.getSimpleName
       assert(e.toString == e.value, s"$name.toString = '${e.toString}' but value = '${e.value}'")
@@ -125,11 +126,13 @@ class HubEntityModelSpec extends AnyFlatSpec {
   "EntityType.fromString" should "match each subtype exactly" in {
     assert(EntityType.fromString("workflow") == EntityType.Workflow)
     assert(EntityType.fromString("dataset") == EntityType.Dataset)
+    assert(EntityType.fromString("model") == EntityType.Model)
   }
 
   it should "match case-insensitively" in {
     assert(EntityType.fromString("WORKFLOW") == EntityType.Workflow)
     assert(EntityType.fromString("Dataset") == EntityType.Dataset)
+    assert(EntityType.fromString("MODEL") == EntityType.Model)
   }
 
   it should "throw IllegalArgumentException for an unknown kind, naming the input" in {
diff --git a/amber/src/test/scala/org/apache/texera/web/resource/dashboard/hub/HubResourceSpec.scala b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/hub/HubResourceSpec.scala
index c7f7c772a4..19b0f1595a 100644
--- a/amber/src/test/scala/org/apache/texera/web/resource/dashboard/hub/HubResourceSpec.scala
+++ b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/hub/HubResourceSpec.scala
@@ -130,6 +130,7 @@ class HubResourceSpec
 
   private val thirdUid = 9003
   private val Ds: EntityType = EntityType.Dataset
+  private val Md: EntityType = EntityType.Model
 
   private def hub: HubResource = new HubResource()
 
@@ -189,6 +190,23 @@ class HubResourceSpec
     Integer.valueOf(dsId)
   }
 
+  private def seedModel(
+      modelId: Int,
+      name: String,
+      isPublic: Boolean = true,
+      owner: Int = ownerUid
+  ): Integer = {
+    getDSLContext
+      .insertInto(MODEL)
+      .set(MODEL.MID, Integer.valueOf(modelId))
+      .set(MODEL.OWNER_UID, Integer.valueOf(owner))
+      .set(MODEL.NAME, name)
+      .set(MODEL.DESCRIPTION, s"desc of $name")
+      .set(MODEL.IS_PUBLIC, java.lang.Boolean.valueOf(isPublic))
+      .execute()
+    Integer.valueOf(modelId)
+  }
+
   private def grantWorkflowAccess(wfId: Int, uid: Int, privilege: PrivilegeEnum): Unit =
     getDSLContext
       .insertInto(WORKFLOW_USER_ACCESS)
@@ -205,6 +223,14 @@ class HubResourceSpec
       .set(DATASET_USER_ACCESS.PRIVILEGE, privilege)
       .execute()
 
+  private def grantModelAccess(modelId: Int, uid: Int, privilege: PrivilegeEnum): Unit =
+    getDSLContext
+      .insertInto(MODEL_USER_ACCESS)
+      .set(MODEL_USER_ACCESS.MID, Integer.valueOf(modelId))
+      .set(MODEL_USER_ACCESS.UID, Integer.valueOf(uid))
+      .set(MODEL_USER_ACCESS.PRIVILEGE, privilege)
+      .execute()
+
   private def seedWorkflowLike(wfId: Int, uid: Int): Unit =
     getDSLContext
       .insertInto(WORKFLOW_USER_LIKES)
@@ -219,6 +245,13 @@ class HubResourceSpec
       .set(DATASET_USER_LIKES.UID, Integer.valueOf(uid))
       .execute()
 
+  private def seedModelLike(modelId: Int, uid: Int): Unit =
+    getDSLContext
+      .insertInto(MODEL_USER_LIKES)
+      .set(MODEL_USER_LIKES.MID, Integer.valueOf(modelId))
+      .set(MODEL_USER_LIKES.UID, Integer.valueOf(uid))
+      .execute()
+
   private def seedWorkflowClone(wfId: Int, uid: Int): Unit =
     getDSLContext
       .insertInto(WORKFLOW_USER_CLONES)
@@ -240,6 +273,13 @@ class HubResourceSpec
       .set(DATASET_VIEW_COUNT.VIEW_COUNT, Integer.valueOf(count))
       .execute()
 
+  private def seedModelViewCount(modelId: Int, count: Int): Unit =
+    getDSLContext
+      .insertInto(MODEL_VIEW_COUNT)
+      .set(MODEL_VIEW_COUNT.MID, Integer.valueOf(modelId))
+      .set(MODEL_VIEW_COUNT.VIEW_COUNT, Integer.valueOf(count))
+      .execute()
+
   private def workflowLikeUids(wfId: Int): Set[Int] =
     getDSLContext
       .select(WORKFLOW_USER_LIKES.UID)
@@ -260,6 +300,16 @@ class HubResourceSpec
       .map(_.intValue())
       .toSet
 
+  private def modelLikeUids(modelId: Int): Set[Int] =
+    getDSLContext
+      .select(MODEL_USER_LIKES.UID)
+      .from(MODEL_USER_LIKES)
+      .where(MODEL_USER_LIKES.MID.eq(Integer.valueOf(modelId)))
+      .fetchInto(classOf[Integer])
+      .asScala
+      .map(_.intValue())
+      .toSet
+
   private def workflowCloneUids(wfId: Int): Seq[Int] =
     getDSLContext
       .select(WORKFLOW_USER_CLONES.UID)
@@ -288,6 +338,15 @@ class HubResourceSpec
         .fetchOne(DATASET_VIEW_COUNT.VIEW_COUNT)
     ).map(_.intValue())
 
+  private def modelViewCount(modelId: Int): Option[Int] =
+    Option(
+      getDSLContext
+        .select(MODEL_VIEW_COUNT.VIEW_COUNT)
+        .from(MODEL_VIEW_COUNT)
+        .where(MODEL_VIEW_COUNT.MID.eq(Integer.valueOf(modelId)))
+        .fetchOne(MODEL_VIEW_COUNT.VIEW_COUNT)
+    ).map(_.intValue())
+
   /** (uid, resourceType, resourceId, action literal, ip) for every audit row. */
   private def auditRows(): Seq[(Integer, String, Int, String, String)] =
     getDSLContext
@@ -350,8 +409,8 @@ class HubResourceSpec
     ctx.deleteFrom(WORKFLOW_USER_CLONES).where(WORKFLOW_USER_CLONES.WID.eq(wid)).execute()
     ctx.deleteFrom(WORKFLOW_VIEW_COUNT).where(WORKFLOW_VIEW_COUNT.WID.eq(wid)).execute()
     ctx.deleteFrom(USER_ACTION).where(USER_ACTION.RESOURCE_ID.eq(wid)).execute()
-    // The cases added below seed workflows and datasets of their own (ids in the
-    // 81xxxx / 82xxxx / 83xxxx ranges) plus audit rows pointing at them. MockTexeraDB
+    // The cases added below seed workflows, datasets and models of their own (ids in the
+    // 81xxxx / 82xxxx / 83xxxx / 84xxxx ranges) plus audit rows pointing at them. MockTexeraDB
     // never truncates, so those have to go too or a later test would observe an
     // earlier one's rows. Deleting the parents cascades to their like, clone,
     // view-count, ownership and access children; user_action has no such FK and
@@ -360,6 +419,7 @@ class HubResourceSpec
     ctx.deleteFrom(USER_ACTION).where(USER_ACTION.RESOURCE_ID.ne(wid)).execute()
     ctx.deleteFrom(WORKFLOW).where(WORKFLOW.WID.ne(wid)).execute()
     ctx.deleteFrom(DATASET).execute()
+    ctx.deleteFrom(MODEL).execute()
   }
 
   "recordUserAction" should "insert a user_action row for the entity" in {
@@ -493,6 +553,38 @@ class HubResourceSpec
     workflowLikeUids(830001) shouldBe empty
   }
 
+  it should "route the like to the model table when a model shares the id" in {
+    seedWorkflow(830002, "wf_like_shared_with_model")
+    seedDataset(830002, "ds_like_shared_with_model")
+    seedModel(830002, "md_like_shared_id")
+
+    recordLikeAction(
+      req,
+      Integer.valueOf(ownerUid),
+      UserRequest(Integer.valueOf(830002), Md),
+      isLike = true
+    ) shouldBe true
+
+    modelLikeUids(830002) shouldBe Set(ownerUid)
+    datasetLikeUids(830002) shouldBe empty
+    workflowLikeUids(830002) shouldBe empty
+  }
+
+  it should "unlike a model, leaving other likers alone" in {
+    seedModel(840101, "md_unlike")
+    seedModelLike(840101, likerUid)
+    seedModelLike(840101, ownerUid)
+
+    recordLikeAction(
+      req,
+      Integer.valueOf(ownerUid),
+      UserRequest(Integer.valueOf(840101), Md),
+      isLike = false
+    ) shouldBe true
+
+    modelLikeUids(840101) shouldBe Set(likerUid)
+  }
+
   "recordCloneAction" should "record a clone row and a user_action row" in {
     recordCloneAction(req, Integer.valueOf(likerUid), Integer.valueOf(wid), Wf)
     cloneRows shouldBe 1
@@ -546,6 +638,20 @@ class HubResourceSpec
     )
   }
 
+  it should "keep model likes apart from the other entity types" in {
+    seedModel(840501, "md_is_liked")
+    seedModelLike(840501, ownerUid)
+    seedDataset(820920, "ds_is_liked")
+
+    val responses = isLikedHelper(
+      Integer.valueOf(ownerUid),
+      ids(840501, 820920),
+      types(Md, Ds)
+    ).asScala.map(r => (r.entityType, r.entityId.intValue()) -> r.isLiked).toMap
+
+    responses shouldBe Map((Md, 840501) -> true, (Ds, 820920) -> false)
+  }
+
   it should "not report another user's like as the caller's" in {
     seedWorkflow(810303, "wf_liked_by_other")
     seedWorkflowLike(810303, likerUid)
@@ -585,6 +691,14 @@ class HubResourceSpec
     hub.getCount(Wf).intValue() shouldBe 3
   }
 
+  it should "count models from the model table" in {
+    seedModel(840201, "md_public")
+    seedModel(840202, "md_private", isPublic = false)
+    seedDataset(820510, "ds_noise")
+
+    hub.getCount(Md).intValue() shouldBe 1
+  }
+
   it should "count datasets from the dataset table, not the workflow table" in {
     // Deliberately unequal cardinalities so a table mix-up cannot pass.
     seedWorkflow(810504, "wf_noise_a", isPublic = true, withOwnerRows = false)
@@ -634,6 +748,21 @@ class HubResourceSpec
     datasetViewCount(830001) shouldBe Some(1)
   }
 
+  it should "keep model view counts apart from the dataset and workflow rows" in {
+    seedWorkflow(830003, "wf_view_shared_with_model")
+    seedDataset(830003, "ds_view_shared_with_model")
+    seedModel(830003, "md_view_shared_id")
+    val shared = Integer.valueOf(830003)
+
+    hub.postView(req, ViewRequest(shared, Integer.valueOf(ownerUid), Md))
+    hub.postView(req, ViewRequest(shared, Integer.valueOf(ownerUid), Md))
+    hub.postView(req, ViewRequest(shared, Integer.valueOf(ownerUid), Ds))
+
+    modelViewCount(830003) shouldBe Some(2)
+    datasetViewCount(830003) shouldBe Some(1)
+    workflowViewCount(830003) shouldBe None
+  }
+
   "getCounts" should "report the like count for the entity" in {
     recordLikeAction(req, Integer.valueOf(likerUid), UserRequest(wid, Wf), isLike = true)
 
@@ -725,6 +854,20 @@ class HubResourceSpec
     )
   }
 
+  it should "report view and like counts for a model, and zero clones" in {
+    seedModel(840301, "md_counts")
+    seedModelViewCount(840301, 5)
+    Seq(ownerUid, likerUid).foreach(seedModelLike(840301, _))
+
+    val counts = hub.getCounts(types(Md), ids(840301), null).asScala.head.counts
+
+    counts.asScala.toMap shouldBe Map(
+      (ActionType.View: ActionType) -> 5,
+      (ActionType.Like: ActionType) -> 2,
+      (ActionType.Clone: ActionType) -> 0
+    )
+  }
+
   it should "answer a mixed batch with one response per requested pair" in {
     seedWorkflow(810805, "wf_mixed")
     seedDataset(820802, "ds_mixed")
@@ -768,6 +911,16 @@ class HubResourceSpec
     tops("clone").asScala shouldBe empty
   }
 
+  it should "answer for a model without failing on its absent clone table" in {
+    // Models are cloneless like datasets; with no likes seeded the descriptor path is not
+    // asked to size a lakeFS repository, which this suite has no stub for.
+    val tops = hub.getTops(Md, null, null, null).asScala
+
+    tops.keySet shouldBe Set("like", "clone")
+    tops("like").asScala shouldBe empty
+    tops("clone").asScala shouldBe empty
+  }
+
   it should "return an empty clone bucket when clone is asked for explicitly" in {
     seedDataset(820850, "ds_no_clones")
     seedDatasetLike(820850, likerUid)
@@ -865,6 +1018,25 @@ class HubResourceSpec
     )
   }
 
+  it should "read a model's grantees from model_user_access" in {
+    seedModel(840401, "md_access")
+    seedDataset(820910, "ds_access_noise")
+    // Disjoint grantee sets so reading the wrong access table cannot pass.
+    grantModelAccess(840401, likerUid, PrivilegeEnum.WRITE)
+    grantDatasetAccess(820910, thirdUid, PrivilegeEnum.READ)
+
+    val byEntity = hub
+      .userAccess(types(Md, Ds), ids(840401, 820910))
+      .asScala
+      .map(r => (r.entityType, r.entityId.intValue()) -> r.userIds.asScala.map(_.intValue()).toSet)
+      .toMap
+
+    byEntity shouldBe Map(
+      (Md, 840401) -> Set(likerUid),
+      (Ds, 820910) -> Set(thirdUid)
+    )
+  }
+
   it should "still emit a response with an empty uid list for an entity nobody can access" in {
     seedWorkflow(810902, "wf_no_access", withOwnerRows = false)
 
diff --git a/common/config/src/main/resources/default.conf b/common/config/src/main/resources/default.conf
index f46fbf0a18..077a2c53ca 100644
--- a/common/config/src/main/resources/default.conf
+++ b/common/config/src/main/resources/default.conf
@@ -51,6 +51,11 @@ gui {
     dataset_enabled = true
     dataset_enabled = ${?GUI_TABS_DATASET_ENABLED}
 
+    # Hides the hub's Models entry, not the route. Separate from `models_enabled`, which is
+    # the Your Work entry: an instance can browse public models without offering its own.
+    model_enabled = false
+    model_enabled = ${?GUI_TABS_MODEL_ENABLED}
+
     # config for your work tabs
     your_work_enabled = true
     your_work_enabled = ${?GUI_TABS_YOUR_WORK_ENABLED}
diff --git a/common/config/src/test/scala/org/apache/texera/common/config/DefaultsConfigSpec.scala b/common/config/src/test/scala/org/apache/texera/common/config/DefaultsConfigSpec.scala
index e675ebcda2..52da44ca12 100644
--- a/common/config/src/test/scala/org/apache/texera/common/config/DefaultsConfigSpec.scala
+++ b/common/config/src/test/scala/org/apache/texera/common/config/DefaultsConfigSpec.scala
@@ -51,6 +51,8 @@ class DefaultsConfigSpec extends AnyFlatSpec with Matchers {
       defaults.get("model_single_file_upload_max_size_mib") shouldBe Some("2048")
     )
     ifUnset("GUI_TABS_MODELS_ENABLED")(defaults.get("models_enabled") shouldBe Some("false"))
+    // the hub's own model flag, distinct from the Your Work one above
+    ifUnset("GUI_TABS_MODEL_ENABLED")(defaults.get("model_enabled") shouldBe Some("false"))
     // management-only keys are flattened too (used by reset + the startup seeder)
     ifUnset("OPERATOR_CSV_PARSER_MAX_COLUMNS")(
       defaults.get("csv_parser_max_columns") shouldBe Some("512")
diff --git a/config-service/src/test/scala/org/apache/texera/service/resource/ConfigResourceSpec.scala b/config-service/src/test/scala/org/apache/texera/service/resource/ConfigResourceSpec.scala
index 7ebce642e7..629b48d250 100644
--- a/config-service/src/test/scala/org/apache/texera/service/resource/ConfigResourceSpec.scala
+++ b/config-service/src/test/scala/org/apache/texera/service/resource/ConfigResourceSpec.scala
@@ -414,6 +414,7 @@ class ConfigResourceSpec
       "home_enabled",
       "workflow_enabled",
       "dataset_enabled",
+      "model_enabled",
       "your_work_enabled",
       "projects_enabled",
       "workflows_enabled",
diff --git a/frontend/src/app/app-routing.constant.ts b/frontend/src/app/app-routing.constant.ts
index cea975e683..1855d1cb45 100644
--- a/frontend/src/app/app-routing.constant.ts
+++ b/frontend/src/app/app-routing.constant.ts
@@ -28,6 +28,9 @@ export const HUB_WORKFLOW_RESULT_DETAIL = `${HUB_WORKFLOW_RESULT}/detail`;
 export const HUB_DATASET = `${HUB}/dataset`;
 export const HUB_DATASET_RESULT = `${HUB_DATASET}/result`;
 export const HUB_DATASET_RESULT_DETAIL = `${HUB_DATASET_RESULT}/detail`;
+export const HUB_MODEL = `${HUB}/model`;
+export const HUB_MODEL_RESULT = `${HUB_MODEL}/result`;
+export const HUB_MODEL_RESULT_DETAIL = `${HUB_MODEL_RESULT}/detail`;
 
 export const USER = "/user";
 export const USER_WORKSPACE = `${USER}/workflow`;
diff --git a/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts
index d7f9af1ffa..05be4d2183 100644
--- a/frontend/src/app/app-routing.module.ts
+++ b/frontend/src/app/app-routing.module.ts
@@ -43,6 +43,7 @@ import { HubWorkflowDetailComponent } from "./hub/component/workflow/detail/hub-
 import { LandingPageComponent } from "./hub/component/landing-page/landing-page.component";
 import { USER_WORKFLOW } from "./app-routing.constant";
 import { HubSearchResultComponent } from "./hub/component/hub-search-result/hub-search-result.component";
+import { EntityType } from "./hub/service/hub.service";
 import { AdminSettingsComponent } from "./dashboard/component/admin/settings/admin-settings.component";
 
 const routes: Routes = [];
@@ -81,6 +82,7 @@ routes.push({
             {
               path: "result",
               component: HubSearchResultComponent,
+              data: { entityType: EntityType.Workflow },
             },
             {
               path: "result/detail/:id",
@@ -94,6 +96,7 @@ routes.push({
             {
               path: "result",
               component: HubSearchResultComponent,
+              data: { entityType: EntityType.Dataset },
             },
             {
               path: "result/detail/:did",
@@ -101,6 +104,20 @@ routes.push({
             },
           ],
         },
+        {
+          path: "model",
+          children: [
+            {
+              path: "result",
+              component: HubSearchResultComponent,
+              data: { entityType: EntityType.Model },
+            },
+            {
+              path: "result/detail/:mid",
+              component: ModelDetailComponent,
+            },
+          ],
+        },
       ],
     },
     {
diff --git a/frontend/src/app/common/type/gui-config.ts b/frontend/src/app/common/type/gui-config.ts
index 8d21a11491..ae3e99a174 100644
--- a/frontend/src/app/common/type/gui-config.ts
+++ b/frontend/src/app/common/type/gui-config.ts
@@ -54,6 +54,7 @@ export interface SidebarTabs {
   home_enabled: boolean;
   workflow_enabled: boolean;
   dataset_enabled: boolean;
+  model_enabled: boolean;
   your_work_enabled: boolean;
   workflows_enabled: boolean;
   compute_enabled: boolean;
diff --git a/frontend/src/app/dashboard/component/admin/settings/admin-settings.component.html b/frontend/src/app/dashboard/component/admin/settings/admin-settings.component.html
index 709321b3fe..0c4b2e0c0a 100644
--- a/frontend/src/app/dashboard/component/admin/settings/admin-settings.component.html
+++ b/frontend/src/app/dashboard/component/admin/settings/admin-settings.component.html
@@ -147,6 +147,15 @@
           [nzDisabled]="!sidebarTabs.hub_enabled">
         </nz-switch>
       </div>
+
+      <div class="submenu-item">
+        <span>Models</span>
+        <nz-switch
+          class="tab-switch"
+          [(ngModel)]="sidebarTabs.model_enabled"
+          [nzDisabled]="!sidebarTabs.hub_enabled">
+        </nz-switch>
+      </div>
     </details>
 
     <details>
diff --git a/frontend/src/app/dashboard/component/admin/settings/admin-settings.component.spec.ts b/frontend/src/app/dashboard/component/admin/settings/admin-settings.component.spec.ts
index b275285c4d..4dfea1cd85 100644
--- a/frontend/src/app/dashboard/component/admin/settings/admin-settings.component.spec.ts
+++ b/frontend/src/app/dashboard/component/admin/settings/admin-settings.component.spec.ts
@@ -574,6 +574,7 @@ describe("AdminSettingsComponent wiring", () => {
     "home_enabled",
     "workflow_enabled",
     "dataset_enabled",
+    "model_enabled",
     "your_work_enabled",
     "workflows_enabled",
     "datasets_enabled",
@@ -650,7 +651,9 @@ describe("AdminSettingsComponent wiring", () => {
     });
 
     it("locks the Hub children until Hub itself is on", () => {
-      const hubChildren = [1, 2, 3];
+      const hubChildren = (["home_enabled", "workflow_enabled", "dataset_enabled", "model_enabled"] as const).map(key =>
+        SWITCH_KEYS.indexOf(key)
+      );
 
       component.sidebarTabs.hub_enabled = false;
       fixture.detectChanges();
diff --git a/frontend/src/app/dashboard/component/admin/settings/admin-settings.component.ts b/frontend/src/app/dashboard/component/admin/settings/admin-settings.component.ts
index cf12137217..c91a15141c 100644
--- a/frontend/src/app/dashboard/component/admin/settings/admin-settings.component.ts
+++ b/frontend/src/app/dashboard/component/admin/settings/admin-settings.component.ts
@@ -105,6 +105,7 @@ export class AdminSettingsComponent implements OnInit {
     home_enabled: false,
     workflow_enabled: false,
     dataset_enabled: false,
+    model_enabled: false,
     your_work_enabled: false,
     workflows_enabled: false,
     compute_enabled: false,
diff --git a/frontend/src/app/dashboard/component/dashboard.component.spec.ts b/frontend/src/app/dashboard/component/dashboard.component.spec.ts
index b1f28ca251..84795e0db1 100644
--- a/frontend/src/app/dashboard/component/dashboard.component.spec.ts
+++ b/frontend/src/app/dashboard/component/dashboard.component.spec.ts
@@ -280,6 +280,7 @@ describe("DashboardComponent", () => {
       home_enabled: true,
       workflow_enabled: true,
       dataset_enabled: true,
+      model_enabled: true,
       your_work_enabled: true,
       workflows_enabled: true,
       datasets_enabled: true,
@@ -302,6 +303,7 @@ describe("DashboardComponent", () => {
       home_enabled: true,
       workflow_enabled: true,
       dataset_enabled: true,
+      model_enabled: true,
       your_work_enabled: true,
       workflows_enabled: true,
       datasets_enabled: true,
diff --git a/frontend/src/app/dashboard/component/dashboard.component.ts b/frontend/src/app/dashboard/component/dashboard.component.ts
index 79d371db11..e4fd8bad7b 100644
--- a/frontend/src/app/dashboard/component/dashboard.component.ts
+++ b/frontend/src/app/dashboard/component/dashboard.component.ts
@@ -103,6 +103,7 @@ export class DashboardComponent implements OnInit {
     home_enabled: false,
     workflow_enabled: false,
     dataset_enabled: false,
+    model_enabled: false,
     your_work_enabled: false,
     workflows_enabled: false,
     datasets_enabled: false,
diff --git a/frontend/src/app/dashboard/component/user/search/search.component.html b/frontend/src/app/dashboard/component/user/search/search.component.html
index 59323882e8..421a2799a5 100644
--- a/frontend/src/app/dashboard/component/user/search/search.component.html
+++ b/frontend/src/app/dashboard/component/user/search/search.component.html
@@ -64,6 +64,17 @@
             nzType="database"></span>
           Dataset
         </button>
+
+        <button
+          nz-button
+          nzType="default"
+          [ngClass]="{'selected': selectedType === 'model'}"
+          (click)="filterByType('model')">
+          <span
+            nz-icon
+            [nzType]="MODEL_ICON"></span>
+          Model
+        </button>
       </div>
 
       <texera-filters #filters></texera-filters>
diff --git a/frontend/src/app/dashboard/component/user/search/search.component.spec.ts b/frontend/src/app/dashboard/component/user/search/search.component.spec.ts
index 3eafea2569..2ce35c4326 100644
--- a/frontend/src/app/dashboard/component/user/search/search.component.spec.ts
+++ b/frontend/src/app/dashboard/component/user/search/search.component.spec.ts
@@ -43,6 +43,7 @@ import { StubOperatorMetadataService } from "src/app/workspace/service/operator-
 import { WorkflowPersistService } from "src/app/common/service/workflow-persist/workflow-persist.service";
 import { StubWorkflowPersistService } from "src/app/common/service/workflow-persist/stub-workflow-persist.service";
 import { SortButtonComponent } from "../sort-button/sort-button.component";
+import { MODEL_ICON } from "../../../../common/icon/model-icon";
 
 // Lightweight stand-in for FiltersComponent. It registers itself under the real
 // FiltersComponent token so SearchComponent's `@ViewChild(FiltersComponent)`
@@ -357,19 +358,19 @@ describe("SearchComponent rendered template", () => {
     fixture.detectChanges();
   });
 
-  it("renders the three resource-type buttons, each with its own label and icon", () => {
-    expect(labels()).toEqual(["All", "Workflow", "Dataset"]);
+  it("renders the four resource-type buttons, each with its own label and icon", () => {
+    expect(labels()).toEqual(["All", "Workflow", "Dataset", "Model"]);
     // nz-icon turns nzType into an `anticon-<type>` class, so this pins the icon
     // each button asks for — and that "All" asks for none.
     const icons = typeButtons().map(button => {
       const icon = button.querySelector("span[nz-icon]");
       return icon && Array.from(icon.classList).find(name => name.startsWith("anticon-"));
     });
-    expect(icons).toEqual([null, "anticon-project", "anticon-database"]);
+    expect(icons).toEqual([null, "anticon-project", "anticon-database", `anticon-${MODEL_ICON}`]);
   });
 
   it("highlights only the All button before a resource type is chosen", () => {
-    expect(selectedFlags()).toEqual([true, false, false]);
+    expect(selectedFlags()).toEqual([true, false, false, false]);
   });
 
   it("moves the highlight onto whichever resource-type button is clicked", () => {
diff --git a/frontend/src/app/dashboard/component/user/search/search.component.ts b/frontend/src/app/dashboard/component/user/search/search.component.ts
index 6f4ab8bb3c..37c970ca24 100644
--- a/frontend/src/app/dashboard/component/user/search/search.component.ts
+++ b/frontend/src/app/dashboard/component/user/search/search.component.ts
@@ -34,6 +34,7 @@ import { ɵNzTransitionPatchDirective } from "ng-zorro-antd/core/transition-patc
 import { NzIconDirective } from "ng-zorro-antd/icon";
 import { SortButtonComponent } from "../sort-button/sort-button.component";
 import { NzWaveDirective } from "ng-zorro-antd/core/wave";
+import { MODEL_ICON } from "../../../../common/icon/model-icon";
 
 @UntilDestroy()
 @Component({
@@ -53,6 +54,7 @@ import { NzWaveDirective } from "ng-zorro-antd/core/wave";
   ],
 })
 export class SearchComponent implements AfterViewInit {
+  protected readonly MODEL_ICON = MODEL_ICON;
   public searchParam: string = "";
   sortMethod = SortMethod.EditTimeDesc;
   lastSortMethod: SortMethod | null = null;
@@ -61,8 +63,8 @@ export class SearchComponent implements AfterViewInit {
   currentUid = this.userService.getCurrentUser()?.uid;
   searchKeywords: string[] = [];
 
-  selectedType: "workflow" | "dataset" | null = null;
-  lastSelectedType: "workflow" | "dataset" | null = null;
+  selectedType: "workflow" | "dataset" | "model" | null = null;
+  lastSelectedType: "workflow" | "dataset" | "model" | null = null;
 
   public masterFilterList: ReadonlyArray<string> = [];
   @ViewChild(SearchResultsComponent) searchResultsComponent?: SearchResultsComponent;
@@ -146,7 +148,7 @@ export class SearchComponent implements AfterViewInit {
     await this.searchResultsComponent.loadMore();
   }
 
-  filterByType(type: "workflow" | "dataset" | null): void {
+  filterByType(type: "workflow" | "dataset" | "model" | null): void {
     this.selectedType = type;
     this.search();
   }
diff --git a/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.html b/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.html
index 1ab224dc02..97515e372d 100644
--- a/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.html
+++ b/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.html
@@ -84,11 +84,14 @@
             {{ formatCount(viewCount) }}
           </nz-tag>
 
-          <nz-tag class="status-tag">
+          <nz-tag
+            class="status-tag like-tag"
+            [ngClass]="{ liked: isLiked, disabled: !isLogin }"
+            (click)="isLogin && toggleLike()">
             <i
               nz-icon
               nzType="like"
-              nzTheme="outline"></i>
+              [nzTheme]="isLiked ? 'fill' : 'outline'"></i>
             {{ formatCount(likeCount) }}
           </nz-tag>
         </div>
diff --git a/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.scss b/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.scss
index edea7d38b5..8adfac83c3 100644
--- a/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.scss
+++ b/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.scss
@@ -114,6 +114,22 @@ nz-select {
   gap: 5px;
 }
 
+.like-tag {
+  cursor: pointer;
+}
+
+.like-tag.liked {
+  i {
+    color: #ef4444;
+    background: #fef2f2;
+  }
+}
+
+.like-tag.disabled {
+  cursor: not-allowed;
+  opacity: 0.55;
+}
+
 .status-tag {
   padding: 3px 10px 3px 3px;
   font-size: 13px;
diff --git a/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.spec.ts b/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.spec.ts
index 95f24e6ecb..3e5ab819f7 100644
--- a/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.spec.ts
+++ b/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.spec.ts
@@ -29,7 +29,7 @@ import { By } from "@angular/platform-browser";
 import { commonTestImports, commonTestProviders } from "../../../../../common/testing/test-utils";
 import { NotificationService } from "../../../../../common/service/notification/notification.service";
 import { UserService } from "../../../../../common/service/user/user.service";
-import { StubUserService } from "../../../../../common/service/user/stub-user.service";
+import { MOCK_USER_ID, StubUserService } from "../../../../../common/service/user/stub-user.service";
 import { MODEL_FORMATS, MODEL_FRAMEWORKS, ModelService } from "../../../../service/user/model/model.service";
 import { DownloadService } from "../../../../service/user/download/download.service";
 import { AdminSettingsService } from "../../../../service/admin/settings/admin-settings.service";
@@ -41,6 +41,7 @@ import { MarkdownDescriptionComponent } from "../../markdown-description/markdow
 import { DatasetFileNode } from "../../../../../common/type/datasetVersionFileTree";
 import { ModelVersion } from "../../../../../common/type/model";
 import { Role, User } from "../../../../../common/type/user";
+import { ActionType, EntityType, HubService } from "../../../../../hub/service/hub.service";
 import { ModelDetailComponent } from "./model-detail.component";
 
 const MID = 5;
@@ -72,6 +73,7 @@ describe("ModelDetailComponent", () => {
   let multipartUploadService: Record<string, ReturnType<typeof vi.fn>>;
   let stagedFileService: Record<string, ReturnType<typeof vi.fn>>;
   let adminSettingsService: Record<string, ReturnType<typeof vi.fn>>;
+  let hubService: Record<string, ReturnType<typeof vi.fn>>;
 
   const dashboardModel = (overrides: Partial<Record<string, unknown>> = {}) => ({
     isOwner: true,
@@ -131,6 +133,12 @@ describe("ModelDetailComponent", () => {
     };
     // Every model upload key is absent in this stub, so the component keeps its own defaults.
     adminSettingsService = { getPublicSetting: vi.fn(() => of("")) };
+    hubService = {
+      postView: vi.fn(() => of(7)),
+      getCounts: vi.fn(() => of([{ entityId: MID, entityType: EntityType.Model, counts: { like: 3 } }])),
+      isLiked: vi.fn(() => of([{ entityId: MID, entityType: EntityType.Model, isLiked: true }])),
+      toggleLike: vi.fn(() => of({ liked: false, likeCount: 2 })),
+    };
 
     TestBed.configureTestingModule({
       imports: [ModelDetailComponent, NoopAnimationsModule, ...commonTestImports],
@@ -145,6 +153,7 @@ describe("ModelDetailComponent", () => {
         { provide: MultipartUploadService, useValue: multipartUploadService },
         { provide: StagedFileService, useValue: stagedFileService },
         { provide: AdminSettingsService, useValue: adminSettingsService },
+        { provide: HubService, useValue: hubService },
         ...commonTestProviders,
       ],
     });
@@ -208,6 +217,45 @@ describe("ModelDetailComponent", () => {
     expect(component.modelCreationTime).not.toBe("");
   });
 
+  it("records a view and loads the like state when the page opens", () => {
+    create();
+
+    expect(hubService["postView"]).toHaveBeenCalledWith(MID, MOCK_USER_ID, EntityType.Model);
+    expect(component.viewCount).toBe(7);
+    expect(hubService["getCounts"]).toHaveBeenCalledWith([EntityType.Model], [MID], [ActionType.Like]);
+    expect(component.likeCount).toBe(3);
+    expect(hubService["isLiked"]).toHaveBeenCalledWith([MID], [EntityType.Model]);
+    expect(component.isLiked).toBe(true);
+  });
+
+  it("posts an anonymous view as uid 0 and does not ask whether it is liked", () => {
+    vi.spyOn(TestBed.inject(UserService), "getCurrentUser").mockReturnValue(undefined);
+    create();
+
+    expect(hubService["postView"]).toHaveBeenCalledWith(MID, 0, EntityType.Model);
+    expect(hubService["isLiked"]).not.toHaveBeenCalled();
+    expect(component.isLiked).toBe(false);
+  });
+
+  it("takes both the like flag and the count from the toggle's response", () => {
+    create();
+
+    component.toggleLike();
+
+    expect(hubService["toggleLike"]).toHaveBeenCalledWith(MID, EntityType.Model, true);
+    expect(component.isLiked).toBe(false);
+    expect(component.likeCount).toBe(2);
+  });
+
+  it("does not try to like as an anonymous viewer", () => {
+    vi.spyOn(TestBed.inject(UserService), "getCurrentUser").mockReturnValue(undefined);
+    create();
+
+    component.toggleLike();
+
+    expect(hubService["toggleLike"]).not.toHaveBeenCalled();
+  });
+
   it("skips the cover fetch for a model that has none", () => {
     create();
 
@@ -447,15 +495,21 @@ describe("ModelDetailComponent", () => {
     expect(root.textContent).toContain("torchscript");
   });
 
-  it("shows view and like counters as placeholder zeros", () => {
-    // The hub backend has no model entity type yet, so nothing populates these and
-    // nothing may call the hub from this page.
+  it("renders the view and like counters the hub reported", () => {
     create();
     const tags = q(render(), ".status-tag-row").textContent ?? "";
 
-    expect(tags.match(/\b0\b/g)?.length).toBe(2);
-    expect(component.viewCount).toBe(0);
-    expect(component.likeCount).toBe(0);
+    expect(tags).toContain("7");
+    expect(tags).toContain("3");
+  });
+
+  it("makes the like tag clickable only for a signed-in viewer", () => {
+    create();
+    const like = q(render(), ".like-tag") as HTMLElement;
+    expect(like.classList).not.toContain("disabled");
+
+    like.click();
+    expect(hubService["toggleLike"]).toHaveBeenCalled();
   });
 
   it("dashes out the latest-version facts for a model with no versions", () => {
diff --git a/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.ts b/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.ts
index e21551c649..37bab19df4 100644
--- a/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.ts
+++ b/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.ts
@@ -53,7 +53,7 @@ import { StagedFileService } from "../../../../service/user/file-resource/staged
 import { MODEL_FILE_RESOURCE_ENDPOINT } from "../../../../service/user/file-resource/file-resource-endpoint";
 import { NotificationService } from "../../../../../common/service/notification/notification.service";
 import { UserService } from "../../../../../common/service/user/user.service";
-import { EntityType } from "../../../../../hub/service/hub.service";
+import { ActionType, EntityType, HubService } from "../../../../../hub/service/hub.service";
 import { extractErrorMessage } from "../../../../../common/util/error";
 import { formatCount } from "src/app/common/util/format.util";
 import { formatSize } from "src/app/common/util/size-formatter.util";
@@ -138,10 +138,9 @@ export class ModelDetailComponent implements OnInit {
   // Path within the version, which survives a rename — unlike currentDisplayedFileName.
   private openFileRelativePath: string = "";
 
-  // Placeholders until models reach the hub. The hub backend has no model entity type
-  // (`hub/EntityType.scala` is Workflow and Dataset only), so nothing can populate these yet.
-  public readonly viewCount: number = 0;
-  public readonly likeCount: number = 0;
+  public viewCount: number = 0;
+  public likeCount: number = 0;
+  public isLiked: boolean = false;
 
   public isRightBarCollapsed = false;
   public isMaximized = false;
@@ -169,7 +168,8 @@ export class ModelDetailComponent implements OnInit {
     private downloadService: DownloadService,
     private stagedFileService: StagedFileService,
     private notificationService: NotificationService,
-    private userService: UserService
+    private userService: UserService,
+    private hubService: HubService
   ) {
     this.userService
       .userChanged()
@@ -207,6 +207,7 @@ export class ModelDetailComponent implements OnInit {
           }
           this.retrieveModelInfo();
           this.retrieveModelVersionList();
+          this.loadHubActivity();
           return this.route.data;
         }),
         untilDestroyed(this)
@@ -214,6 +215,44 @@ export class ModelDetailComponent implements OnInit {
       .subscribe();
   }
 
+  /** Opening the page counts as a view; the like state needs a signed-in viewer. */
+  private loadHubActivity(): void {
+    if (!this.mid) {
+      return;
+    }
+    const mid = this.mid;
+    this.hubService
+      .postView(mid, this.currentUid ?? 0, EntityType.Model)
+      .pipe(untilDestroyed(this))
+      .subscribe(count => (this.viewCount = count));
+
+    this.hubService
+      .getCounts([EntityType.Model], [mid], [ActionType.Like])
+      .pipe(untilDestroyed(this))
+      .subscribe(counts => (this.likeCount = counts[0]?.counts.like ?? 0));
+
+    if (this.currentUid === undefined) {
+      return;
+    }
+    this.hubService
+      .isLiked([mid], [EntityType.Model])
+      .pipe(untilDestroyed(this))
+      .subscribe(statuses => (this.isLiked = statuses[0]?.isLiked ?? false));
+  }
+
+  toggleLike(): void {
+    if (!this.mid || this.currentUid === undefined) {
+      return;
+    }
+    this.hubService
+      .toggleLike(this.mid, EntityType.Model, this.isLiked)
+      .pipe(untilDestroyed(this))
+      .subscribe(({ liked, likeCount }) => {
+        this.isLiked = liked;
+        this.likeCount = likeCount;
+      });
+  }
+
   retrieveModelInfo(): void {
     if (!this.mid) {
       return;
diff --git a/frontend/src/app/dashboard/component/user/user-model/user-model.component.html b/frontend/src/app/dashboard/component/user/user-model/user-model.component.html
index 0c8ff84010..8b2f95d76a 100644
--- a/frontend/src/app/dashboard/component/user/user-model/user-model.component.html
+++ b/frontend/src/app/dashboard/component/user/user-model/user-model.component.html
@@ -58,10 +58,14 @@
             nzTheme="outline"></i>
         </button>
       </nz-space-compact>
+      <texera-filters
+        [entityType]="entityType"
+        #filters></texera-filters>
     </div>
   </nz-card>
 
   <div class="section-search-bar">
+    <texera-filters-instructions></texera-filters-instructions>
     <nz-select
       #searchInput
       class="search-input-box"
@@ -71,8 +75,7 @@
       nzVariant="borderless"
       [nzOpen]="false"
       ngDefaultControl
-      [(ngModel)]="searchKeywords"
-      (ngModelChange)="search()"
+      [(ngModel)]="filters.masterFilterList"
       [nzAllowClear]="true">
     </nz-select>
   </div>
diff --git a/frontend/src/app/dashboard/component/user/user-model/user-model.component.spec.ts b/frontend/src/app/dashboard/component/user/user-model/user-model.component.spec.ts
index f41e1635f1..479b13a9b4 100644
--- a/frontend/src/app/dashboard/component/user/user-model/user-model.component.spec.ts
+++ b/frontend/src/app/dashboard/component/user/user-model/user-model.component.spec.ts
@@ -17,15 +17,17 @@
  * under the License.
  */
 
-import { of, Subject, throwError } from "rxjs";
+import { of, Subject } from "rxjs";
 import { ComponentFixture, TestBed } from "@angular/core/testing";
 import { By } from "@angular/platform-browser";
 import { provideRouter } from "@angular/router";
 import { NzModalService } from "ng-zorro-antd/modal";
+import { NzMessageService } from "ng-zorro-antd/message";
 import { en_US, NZ_I18N } from "ng-zorro-antd/i18n";
 import { commonTestImports, commonTestProviders } from "../../../../common/testing/test-utils";
 import { SearchResultsComponent } from "../search-results/search-results.component";
 import { CardItemComponent } from "../list-item/card-item/card-item.component";
+import { FiltersComponent } from "../filters/filters.component";
 import { NgModel } from "@angular/forms";
 import { UserService } from "../../../../common/service/user/user.service";
 import { StubUserService } from "../../../../common/service/user/stub-user.service";
@@ -48,12 +50,11 @@ describe("UserModelComponent", () => {
   let getCurrentUserSpy: ReturnType<typeof vi.fn>;
 
   let modalServiceMock: { create: ReturnType<typeof vi.fn> };
-  let searchServiceMock: { getUserInfo: ReturnType<typeof vi.fn> };
-  let modelServiceMock: {
-    deleteModel: ReturnType<typeof vi.fn>;
-    retrieveAccessibleModels: ReturnType<typeof vi.fn>;
-  };
+  let searchServiceMock: { executeSearch: ReturnType<typeof vi.fn> };
+  let modelServiceMock: { deleteModel: ReturnType<typeof vi.fn> };
+  let messageMock: { warning: ReturnType<typeof vi.fn> };
 
+  let filtersStub: any;
   let searchResultsStub: any;
   let capturedLoadMoreFn: LoadMoreFn | null;
   let modalAfterClose: Subject<unknown>;
@@ -64,29 +65,6 @@ describe("UserModelComponent", () => {
       model: { model: { mid, name } },
     }) as unknown as DashboardEntry;
 
-  /** A /model/list row. */
-  const listedModel = (mid: number, overrides: Partial<Record<string, unknown>> = {}): any => ({
-    isOwner: true,
-    ownerEmail: "owner@example.com",
-    accessPrivilege: "WRITE",
-    size: 0,
-    ...overrides,
-    model: {
-      mid,
-      ownerUid: 1,
-      name: `model-${mid}`,
-      description: "",
-      framework: "pytorch",
-      format: "torchscript",
-      creationTime: mid,
-      isPublic: false,
-      isDownloadable: false,
-      repositoryName: undefined,
-      coverImage: undefined,
-      ...((overrides["model"] as object) ?? {}),
-    },
-  });
-
   beforeEach(() => {
     userChangedSubject = new Subject<User | undefined>();
     isLoginSpy = vi.fn(() => true);
@@ -100,20 +78,27 @@ describe("UserModelComponent", () => {
 
     modalAfterClose = new Subject<unknown>();
     modalServiceMock = { create: vi.fn(() => ({ afterClose: modalAfterClose.asObservable() })) };
-    searchServiceMock = { getUserInfo: vi.fn(() => of({})) };
-    modelServiceMock = {
-      deleteModel: vi.fn(() => of({} as Response)),
-      retrieveAccessibleModels: vi.fn(() => of([])),
+    searchServiceMock = {
+      executeSearch: vi.fn(() => of({ entries: [], more: false, hasMismatch: false })),
     };
+    modelServiceMock = { deleteModel: vi.fn(() => of({} as Response)) };
+    messageMock = { warning: vi.fn() };
 
     component = new UserModelComponent(
       modalServiceMock as any,
       userServiceMock as any,
       searchServiceMock as any,
-      modelServiceMock as any
+      modelServiceMock as any,
+      messageMock as any
     );
 
     capturedLoadMoreFn = null;
+    filtersStub = {
+      masterFilterList: [] as string[],
+      masterFilterListChange: new Subject<void>(),
+      getSearchKeywords: vi.fn(() => ["kw1"]),
+      getSearchFilterParameters: vi.fn(() => ({ ids: [1, 2] })),
+    };
     searchResultsStub = {
       entries: [] as DashboardEntry[],
       reset: vi.fn((fn: LoadMoreFn) => {
@@ -122,6 +107,7 @@ describe("UserModelComponent", () => {
       loadMore: vi.fn(async () => {}),
     };
 
+    component.filters = filtersStub;
     component.searchResultsComponent = searchResultsStub;
   });
 
@@ -152,143 +138,97 @@ describe("UserModelComponent", () => {
     expect(searchSpy).toHaveBeenCalledTimes(1);
   });
 
-  // ─── listing ──────────────────────────────────────────────────────────────
+  // ─── searching ────────────────────────────────────────────────────────────
 
-  it("lists the accessible models rather than going through unified search", async () => {
-    // Models must not reach global search before the hub backend lands, so this page reads
-    // /model/list directly.
-    modelServiceMock.retrieveAccessibleModels.mockReturnValue(of([listedModel(1), listedModel(2)]));
+  it("searches models through unified search, with the filters bar's keywords and parameters", async () => {
+    filtersStub.getSearchKeywords.mockReturnValue(["resnet", "vision"]);
+    filtersStub.getSearchFilterParameters.mockReturnValue({ ids: [1, 2] });
+    component.sortMethod = SortMethod.NameAsc;
 
     await component.search();
-    const page = await capturedLoadMoreFn!(0, 20);
-
-    expect(modelServiceMock.retrieveAccessibleModels).toHaveBeenCalled();
-    expect(page.entries.map((e: DashboardEntry) => e.id)).toEqual([2, 1]);
-    expect(page.more).toBe(false);
+    expect(searchResultsStub.reset).toHaveBeenCalledTimes(1);
+    expect(searchResultsStub.loadMore).toHaveBeenCalledTimes(1);
+
+    await capturedLoadMoreFn!(7, 25);
+
+    expect(searchServiceMock.executeSearch).toHaveBeenCalledWith(
+      ["resnet", "vision"],
+      { ids: [1, 2] },
+      7,
+      25,
+      "model",
+      SortMethod.NameAsc,
+      true,
+      false
+    );
   });
 
-  it("fetches the list once and re-slices it, refetching only when forced", async () => {
-    modelServiceMock.retrieveAccessibleModels.mockReturnValue(of([listedModel(1)]));
+  it("never asks for public models, even when the viewer is signed out", async () => {
+    // This page lists what you own or were granted; public models belong to the hub.
+    component.isLogin = false;
 
     await component.search();
     await capturedLoadMoreFn!(0, 20);
-    await capturedLoadMoreFn!(0, 20);
-    expect(modelServiceMock.retrieveAccessibleModels).toHaveBeenCalledTimes(1);
 
-    await component.search(true);
-    await capturedLoadMoreFn!(0, 20);
-    expect(modelServiceMock.retrieveAccessibleModels).toHaveBeenCalledTimes(2);
+    const args = searchServiceMock.executeSearch.mock.calls[0];
+    expect(args[6]).toBe(false); // isLogin
+    expect(args[7]).toBe(false); // includePublic
   });
 
-  it("reports more pages while the filtered list runs past the window", async () => {
-    modelServiceMock.retrieveAccessibleModels.mockReturnValue(of([listedModel(1), listedModel(2), listedModel(3)]));
+  it("skips a repeated search with the same filters and sort, but honors forced", async () => {
+    filtersStub.masterFilterList = ["a"];
 
     await component.search();
-
-    expect((await capturedLoadMoreFn!(0, 2)).more).toBe(true);
-    expect((await capturedLoadMoreFn!(2, 2)).more).toBe(false);
-  });
-
-  it("sorts by name in both directions, and by newest first otherwise", async () => {
-    const names = async (): Promise<string[]> => {
-      await component.search(true);
-      return (await capturedLoadMoreFn!(0, 20)).entries.map((e: DashboardEntry) => e.name);
-    };
-    modelServiceMock.retrieveAccessibleModels.mockReturnValue(
-      of([listedModel(1, { model: { name: "b" } }), listedModel(2, { model: { name: "a" } })])
-    );
-
-    component.sortMethod = SortMethod.NameAsc;
-    expect(await names()).toEqual(["a", "b"]);
-
-    component.sortMethod = SortMethod.NameDesc;
-    expect(await names()).toEqual(["b", "a"]);
-
-    component.sortMethod = SortMethod.CreateTimeDesc;
-    expect(await names()).toEqual(["a", "b"]); // mid 2 was created later
-  });
-
-  // ─── keyword matching ─────────────────────────────────────────────────────
-
-  it("matches keywords against name, description, framework, format and owner", async () => {
-    const matches = async (keyword: string): Promise<number[]> => {
-      component.searchKeywords = [keyword];
-      await component.search();
-      return (await capturedLoadMoreFn!(0, 20)).entries.map((e: DashboardEntry) => e.id!);
-    };
-    modelServiceMock.retrieveAccessibleModels.mockReturnValue(
-      of([
-        listedModel(1, { model: { name: "resnet", description: "vision", framework: "pytorch" } }),
-        listedModel(2, { ownerEmail: "someone@else.com", model: { name: "tree", format: "joblib" } }),
-      ])
-    );
-
-    expect(await matches("resnet")).toEqual([1]);
-    expect(await matches("VISION")).toEqual([1]); // case-insensitive
-    expect(await matches("joblib")).toEqual([2]);
-    expect(await matches("else.com")).toEqual([2]);
-    expect(await matches("   ")).toEqual([2, 1]); // a blank term filters nothing
-  });
-
-  it("requires every keyword to match, not just one", async () => {
-    modelServiceMock.retrieveAccessibleModels.mockReturnValue(
-      of([listedModel(1, { model: { name: "resnet", description: "vision" } }), listedModel(2)])
-    );
-    component.searchKeywords = ["resnet", "vision"];
+    expect(searchResultsStub.reset).toHaveBeenCalledTimes(1);
 
     await component.search();
-    expect((await capturedLoadMoreFn!(0, 20)).entries.map((e: DashboardEntry) => e.id)).toEqual([1]);
+    expect(searchResultsStub.reset).toHaveBeenCalledTimes(1);
 
-    component.searchKeywords = ["resnet", "absent"];
-    await component.search();
-    expect((await capturedLoadMoreFn!(0, 20)).entries).toEqual([]);
+    await component.search(true);
+    expect(searchResultsStub.reset).toHaveBeenCalledTimes(2);
   });
 
-  // ─── owner names ──────────────────────────────────────────────────────────
-
-  it("labels each card with its owner's display name and avatar", async () => {
-    modelServiceMock.retrieveAccessibleModels.mockReturnValue(of([listedModel(1)]));
-    searchServiceMock.getUserInfo.mockReturnValue(of({ 1: { userName: "ada", avatar: "a.png" } }));
+  it("re-searches when the sort method changes even if the filter list is identical", async () => {
+    filtersStub.masterFilterList = ["a"];
 
     await component.search();
-    const [entry] = (await capturedLoadMoreFn!(0, 20)).entries;
+    component.sortMethod = SortMethod.NameAsc;
+    await component.search();
 
-    expect(searchServiceMock.getUserInfo).toHaveBeenCalledWith([1]);
-    expect(entry.ownerName).toBe("ada");
-    // DashboardEntry starts every entry with an empty avatar, so only a lookup that actually
-    // forwards the value can produce this — asserting "" alone would pass with the write removed.
-    expect(entry.ownerAvatar).toBe("a.png");
+    expect(searchResultsStub.reset).toHaveBeenCalledTimes(2);
   });
 
-  it("leaves the avatar empty for an owner who has not set one", async () => {
-    // getUserInfo omits `avatar` for such a user; forwarding undefined would put the string
-    // "undefined" in the avatar slot.
-    modelServiceMock.retrieveAccessibleModels.mockReturnValue(of([listedModel(1)]));
-    searchServiceMock.getUserInfo.mockReturnValue(of({ 1: { userName: "ada" } }));
-
-    await component.search();
-    const [entry] = (await capturedLoadMoreFn!(0, 20)).entries;
+  it("re-searches when the filters bar reports a new filter list", async () => {
+    const searchSpy = vi.spyOn(component, "search").mockResolvedValue();
+    filtersStub.masterFilterListChange.next();
 
-    expect(entry.ownerName).toBe("ada");
-    expect(entry.ownerAvatar).toBe("");
+    expect(searchSpy).toHaveBeenCalled();
   });
 
-  it("still lists the models when the owner lookup fails", async () => {
-    modelServiceMock.retrieveAccessibleModels.mockReturnValue(of([listedModel(1)]));
-    searchServiceMock.getUserInfo.mockReturnValue(throwError(() => new Error("offline")));
+  // ─── mismatch warning ─────────────────────────────────────────────────────
+
+  it("warns when the backend could not match every model, and records it", async () => {
+    searchServiceMock.executeSearch.mockReturnValue(of({ entries: [], more: false, hasMismatch: true }));
 
     await component.search();
+    await capturedLoadMoreFn!(0, 20);
 
-    expect((await capturedLoadMoreFn!(0, 20)).entries).toHaveLength(1);
+    expect(component.hasMismatch).toBe(true);
+    expect(messageMock.warning).toHaveBeenCalledTimes(1);
+    const [message, options] = messageMock.warning.mock.calls[0];
+    expect(message).toContain("models");
+    expect(options).toEqual({ nzDuration: 4000 });
   });
 
-  it("skips the owner lookup when there is nobody to look up", async () => {
-    modelServiceMock.retrieveAccessibleModels.mockReturnValue(of([]));
+  it("clears the mismatch flag and stays quiet when the response carries none", async () => {
+    component.hasMismatch = true;
+    searchServiceMock.executeSearch.mockReturnValue(of({ entries: [], more: false }));
 
     await component.search();
     await capturedLoadMoreFn!(0, 20);
 
-    expect(searchServiceMock.getUserInfo).not.toHaveBeenCalled();
+    expect(component.hasMismatch).toBe(false);
+    expect(messageMock.warning).not.toHaveBeenCalled();
   });
 
   // ─── view mode ────────────────────────────────────────────────────────────
@@ -373,11 +313,15 @@ describe("UserModelComponent rendering", () => {
       providers: [
         { provide: NzModalService, useValue: { create: modalCreate } },
         { provide: UserService, useClass: StubUserService },
-        { provide: SearchService, useValue: { getUserInfo: vi.fn(() => of({})) } },
+        {
+          provide: SearchService,
+          useValue: { executeSearch: vi.fn(() => of({ entries: [], more: false, hasMismatch: false })) },
+        },
         {
           provide: ModelService,
-          useValue: { deleteModel: vi.fn(() => of({} as Response)), retrieveAccessibleModels: vi.fn(() => of([])) },
+          useValue: { deleteModel: vi.fn(() => of({} as Response)), retrieveOwners: vi.fn(() => of([])) },
         },
+        { provide: NzMessageService, useValue: { warning: vi.fn() } },
         { provide: NZ_I18N, useValue: en_US },
         provideRouter([]),
         ...commonTestProviders,
@@ -406,26 +350,25 @@ describe("UserModelComponent rendering", () => {
     expect(modalCreate).toHaveBeenCalled();
   });
 
-  it("renders no shared filter bar", () => {
-    // The dropdowns it offers (owner, id, ctime, operator, project) are server-side criteria, and
-    // this page filters client-side. It returns with the hub and unified-search work.
-    expect(host().querySelector("texera-filters")).toBeNull();
+  it("renders the shared filter bar, scoped to models", () => {
+    // The dropdowns it offers are server-side criteria, which the page can use now that it
+    // searches through the backend rather than filtering the list in the browser.
+    const filters = fixture.debugElement.query(By.directive(FiltersComponent));
+    expect(filters, "no texera-filters in the toolbar").not.toBeNull();
+    expect(filters.componentInstance.entityType).toBe(EntityType.Model);
   });
 
-  it("writes the tags typed into the search box back to the keyword list, and re-filters", () => {
+  it("writes the tags typed into the search box back to the filter list", () => {
     // nz-select's value arrives through its ControlValueAccessor, so the edit has to be driven at
-    // the NgModel. With a one-way binding the tags would render but never reach the filter.
-    const searchSpy = vi.spyOn(component, "search").mockResolvedValue();
+    // the NgModel. With a one-way binding the tags would render but never reach the filter list.
     const select = fixture.debugElement.query(By.css("nz-select"));
     expect(select, "no nz-select in the search bar").not.toBeNull();
     // Free-text keywords only exist in tag mode.
     expect(select.componentInstance.nzMode).toBe("tags");
 
     select.injector.get(NgModel).viewToModelUpdate(["resnet", "vision"]);
-    fixture.detectChanges();
 
-    expect(component.searchKeywords).toEqual(["resnet", "vision"]);
-    expect(searchSpy).toHaveBeenCalled();
+    expect(component.filters.masterFilterList).toEqual(["resnet", "vision"]);
   });
 
   it("starts in card view and moves the highlight when the list view is chosen", () => {
@@ -452,7 +395,7 @@ describe("UserModelComponent rendering", () => {
     expect(results.currentUid).toBe(component.currentUid);
   });
 
-  /** A /model/list row, in the shape DashboardEntry accepts. */
+  /** A search hit, in the shape DashboardEntry accepts. */
   function modelEntry(mid = 3, name = "resnet"): DashboardEntry {
     return new DashboardEntry({
       isOwner: true,
diff --git a/frontend/src/app/dashboard/component/user/user-model/user-model.component.ts b/frontend/src/app/dashboard/component/user/user-model/user-model.component.ts
index 907bc4ca3e..3fe71c7258 100644
--- a/frontend/src/app/dashboard/component/user/user-model/user-model.component.ts
+++ b/frontend/src/app/dashboard/component/user/user-model/user-model.component.ts
@@ -20,8 +20,9 @@
 import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy";
 import { AfterViewInit, Component, ViewChild } from "@angular/core";
 import { firstValueFrom } from "rxjs";
-import { isDefined } from "../../../../common/util/predicate";
+import { map, tap } from "rxjs/operators";
 import { NzModalService } from "ng-zorro-antd/modal";
+import { NzMessageService } from "ng-zorro-antd/message";
 import { NzCardComponent } from "ng-zorro-antd/card";
 import { NzSpaceCompactItemDirective, NzSpaceCompactComponent } from "ng-zorro-antd/space";
 import { NzButtonComponent } from "ng-zorro-antd/button";
@@ -35,11 +36,13 @@ import { SearchService } from "../../../service/user/search.service";
 import { ModelService } from "../../../service/user/model/model.service";
 import { SortMethod } from "../../../type/sort-method";
 import { DashboardEntry } from "../../../type/dashboard-entry";
-import { DashboardModel } from "../../../type/dashboard-model.interface";
 import { SearchResultsComponent } from "../search-results/search-results.component";
 import { CardItemComponent } from "../list-item/card-item/card-item.component";
+import { FiltersComponent } from "../filters/filters.component";
+import { FiltersInstructionsComponent } from "../filters-instructions/filters-instructions.component";
 import { SortButtonComponent } from "../sort-button/sort-button.component";
 import { UserModelCreatorComponent } from "./user-model-creator/user-model-creator.component";
+import { EntityType } from "../../../../hub/service/hub.service";
 
 @UntilDestroy()
 @Component({
@@ -54,6 +57,8 @@ import { UserModelCreatorComponent } from "./user-model-creator/user-model-creat
     NzWaveDirective,
     ɵNzTransitionPatchDirective,
     NzIconDirective,
+    FiltersComponent,
+    FiltersInstructionsComponent,
     SortButtonComponent,
     NzSelectComponent,
     FormsModule,
@@ -62,12 +67,15 @@ import { UserModelCreatorComponent } from "./user-model-creator/user-model-creat
   ],
 })
 export class UserModelComponent implements AfterViewInit {
+  public readonly entityType = EntityType.Model;
   private static readonly VIEW_MODE_STORAGE_KEY = "texera.userModel.viewMode";
   // Models carry no "last modified" timestamp, so sorting by edit time would leave the key NULL on
   // every row. Newest-first is the useful default, as on the Datasets page.
   public sortMethod = SortMethod.CreateTimeDesc;
+  lastSortMethod: SortMethod | null = null;
   public isLogin = this.userService.isLogin();
   public currentUid = this.userService.getCurrentUser()?.uid;
+  public hasMismatch = false; // Display a warning when some models could not be matched in LakeFS
   public viewType: "list" | "card" =
     localStorage.getItem(UserModelComponent.VIEW_MODE_STORAGE_KEY) === "list" ? "list" : "card";
 
@@ -83,16 +91,27 @@ export class UserModelComponent implements AfterViewInit {
     this._searchResultsComponent = value;
   }
 
-  /** Free-text terms from the search box; matched client-side against the listed models. */
-  public searchKeywords: string[] = [];
+  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.");
+  }
 
-  private cachedModels: DashboardModel[] | null = null;
+  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 searchService: SearchService,
-    private modelService: ModelService
+    private modelService: ModelService,
+    private message: NzMessageService
   ) {
     this.userService
       .userChanged()
@@ -119,89 +138,54 @@ export class UserModelComponent implements AfterViewInit {
   }
 
   /**
-   * Re-filters and re-sorts the accessible models.
-   *
-   * Models are listed from /model/list and filtered here rather than through unified search: the
-   * backend does not accept `resourceType=model` until the hub work lands, and models must not
-   * surface in global search before then.
+   * Runs a model search, with the keywords and filter parameters the filters bar holds.
    *
-   * @param forced discards the cached list and refetches
+   * @param forced searches again even when neither the filter list nor the sort method changed
    */
   async search(forced: Boolean = false): Promise<void> {
-    if (forced) {
-      this.cachedModels = null;
+    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 not make the same request again.
+      return;
     }
-
-    this.searchResultsComponent.reset(async (start, count) => {
-      const models = await this.accessibleModels();
-      const matching = this.sortModels(models.filter(model => this.matchesKeyword(model)));
-      const entries = matching.slice(start, start + count).map(model => new DashboardEntry(model));
-      await this.attachOwnerNames(entries);
-      return { entries, more: start + count < matching.length };
+    this.lastSortMethod = this.sortMethod;
+    this.masterFilterList = this.filters.masterFilterList;
+    const filterParams = this.filters.getSearchFilterParameters();
+
+    this.searchResultsComponent.reset((start, count) => {
+      return firstValueFrom(
+        this.searchService
+          .executeSearch(
+            this.filters.getSearchKeywords(),
+            filterParams,
+            start,
+            count,
+            "model",
+            this.sortMethod,
+            this.isLogin,
+            // This page shows what you own or were granted, never what is merely public.
+            false
+          )
+          .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();
   }
 
-  private async accessibleModels(): Promise<DashboardModel[]> {
-    if (this.cachedModels === null) {
-      this.cachedModels = await firstValueFrom(this.modelService.retrieveAccessibleModels());
-    }
-    return this.cachedModels;
-  }
-
-  private matchesKeyword(model: DashboardModel): boolean {
-    const keywords = this.searchKeywords.map(keyword => keyword.trim().toLowerCase()).filter(keyword => keyword !== "");
-    if (keywords.length === 0) {
-      return true;
-    }
-    const haystack = [
-      model.model.name,
-      model.model.description,
-      model.model.framework,
-      model.model.format,
-      model.ownerEmail,
-    ]
-      .filter((field): field is string => typeof field === "string")
-      .map(field => field.toLowerCase());
-
-    return keywords.every(keyword => haystack.some(field => field.includes(keyword)));
-  }
-
-  private sortModels(models: DashboardModel[]): DashboardModel[] {
-    const byName = (a: DashboardModel, b: DashboardModel) => a.model.name.localeCompare(b.model.name);
-    const byCreation = (a: DashboardModel, b: DashboardModel) =>
-      (a.model.creationTime ?? 0) - (b.model.creationTime ?? 0);
-
-    switch (this.sortMethod) {
-      case SortMethod.NameAsc:
-        return [...models].sort(byName);
-      case SortMethod.NameDesc:
-        return [...models].sort((a, b) => byName(b, a));
-      default:
-        return [...models].sort((a, b) => byCreation(b, a));
-    }
-  }
-
-  /** Resolves owner uids to display names, so cards show a username rather than the placeholder. */
-  private async attachOwnerNames(entries: DashboardEntry[]): Promise<void> {
-    const ownerIds = Array.from(new Set(entries.map(entry => entry.model.model.ownerUid).filter(isDefined)));
-    if (ownerIds.length === 0) {
-      return;
-    }
-    try {
-      const userInfo = await firstValueFrom(this.searchService.getUserInfo(ownerIds));
-      entries.forEach(entry => {
-        const info = userInfo[entry.model.model.ownerUid!];
-        if (info) {
-          entry.setOwnerName(info.userName);
-          entry.setOwnerAvatar(info.avatar ?? "");
-        }
-      });
-    } catch {
-      // Decoration only: a lookup failure must not empty the list.
-    }
-  }
-
   public onClickOpenModelAddComponent(): void {
     const modal = this.modalService.create({
       nzTitle: "Create New Model",
@@ -235,7 +219,6 @@ export class UserModelComponent implements AfterViewInit {
       .deleteModel(mid)
       .pipe(untilDestroyed(this))
       .subscribe(() => {
-        this.cachedModels = this.cachedModels?.filter(model => model.model.mid !== mid) ?? null;
         this.searchResultsComponent.entries = this.searchResultsComponent.entries.filter(
           modelEntry => modelEntry.model.model.mid !== mid
         );
diff --git a/frontend/src/app/dashboard/service/user/model/model.service.spec.ts b/frontend/src/app/dashboard/service/user/model/model.service.spec.ts
index d764bebb59..866b4f5cb3 100644
--- a/frontend/src/app/dashboard/service/user/model/model.service.spec.ts
+++ b/frontend/src/app/dashboard/service/user/model/model.service.spec.ts
@@ -132,12 +132,6 @@ describe("ModelService", () => {
     expect(await pending).toEqual(created);
   });
 
-  it("lists the accessible models, including when the user has none", async () => {
-    const pending = firstValueFrom(service.retrieveAccessibleModels());
-    http.expectOne(`${API}/model/list`).flush([]);
-    expect(await pending).toEqual([]);
-  });
-
   it("deletes by id", () => {
     service.deleteModel(7).subscribe();
     expect(http.expectOne(`${API}/model/7`).request.method).toBe("DELETE");
@@ -284,8 +278,8 @@ describe("ModelService", () => {
   });
 
   it("surfaces a server error rather than swallowing it", async () => {
-    const outcome = firstValueFrom(service.retrieveAccessibleModels()).catch((err: unknown) => err);
-    http.expectOne(`${API}/model/list`).flush({ message: "nope" }, { status: 500, statusText: "Server Error" });
+    const outcome = firstValueFrom(service.getModel(7)).catch((err: unknown) => err);
+    http.expectOne(`${API}/model/7`).flush({ message: "nope" }, { status: 500, statusText: "Server Error" });
     expect(await outcome).toMatchObject({ status: 500 });
   });
 });
diff --git a/frontend/src/app/dashboard/service/user/model/model.service.ts b/frontend/src/app/dashboard/service/user/model/model.service.ts
index 9bb9d19c88..11fa1d4c7d 100644
--- a/frontend/src/app/dashboard/service/user/model/model.service.ts
+++ b/frontend/src/app/dashboard/service/user/model/model.service.ts
@@ -36,7 +36,6 @@ export const MODEL_UPDATE_FORMAT_URL = MODEL_UPDATE_BASE_URL + "/format";
 export const MODEL_UPDATE_PUBLICITY_URL = "update/publicity";
 export const MODEL_UPDATE_DOWNLOADABLE_URL = "update/downloadable";
 export const MODEL_UPDATE_COVER_URL = "update/cover";
-export const MODEL_LIST_URL = MODEL_BASE_URL + "/list";
 export const MODEL_GET_OWNERS_URL = MODEL_BASE_URL + "/user-model-owners";
 
 export const MODEL_VERSION_BASE_URL = "version";
@@ -93,10 +92,6 @@ export class ModelService {
     return this.http.get<DashboardModel>(apiUrl);
   }
 
-  public retrieveAccessibleModels(): Observable<DashboardModel[]> {
-    return this.http.get<DashboardModel[]>(`${AppSettings.getApiEndpoint()}/${MODEL_LIST_URL}`);
-  }
-
   public deleteModel(mid: number): Observable<Response> {
     return this.http.delete<Response>(`${AppSettings.getApiEndpoint()}/${MODEL_BASE_URL}/${mid}`);
   }
diff --git a/frontend/src/app/dashboard/service/user/resource-registry/model-resource.descriptor.ts b/frontend/src/app/dashboard/service/user/resource-registry/model-resource.descriptor.ts
index 6edd17d6ac..877abec967 100644
--- a/frontend/src/app/dashboard/service/user/resource-registry/model-resource.descriptor.ts
+++ b/frontend/src/app/dashboard/service/user/resource-registry/model-resource.descriptor.ts
@@ -23,7 +23,7 @@ import { ResourceAffordances, ResourceDescriptor } from "../../../type/resource-
 import { EntityType } from "../../../../hub/service/hub.service";
 import { DEFAULT_MODEL_NAME, ModelService, validateModelName } from "../model/model.service";
 import { MODEL_ICON } from "../../../../common/icon/model-icon";
-import { USER_MODEL } from "../../../../app-routing.constant";
+import { HUB_MODEL_RESULT_DETAIL, USER_MODEL } from "../../../../app-routing.constant";
 import { DownloadService } from "../download/download.service";
 import { map } from "rxjs/operators";
 
@@ -34,7 +34,7 @@ export class ModelResourceDescriptor implements ResourceDescriptor {
   readonly type = EntityType.Model;
   readonly iconType = MODEL_ICON;
   readonly privateRoute = USER_MODEL;
-  // `hubRoute` is deliberately absent: models reach the hub with the rest of the hub UI.
+  readonly hubRoute = HUB_MODEL_RESULT_DETAIL;
   readonly hasSize = true;
   readonly defaultName = DEFAULT_MODEL_NAME;
   // Publishing a model grants read access only; there is no clone action for it.
diff --git a/frontend/src/app/dashboard/service/user/resource-registry/resource-registry.service.spec.ts b/frontend/src/app/dashboard/service/user/resource-registry/resource-registry.service.spec.ts
index fbfd351fef..65efc56709 100644
--- a/frontend/src/app/dashboard/service/user/resource-registry/resource-registry.service.spec.ts
+++ b/frontend/src/app/dashboard/service/user/resource-registry/resource-registry.service.spec.ts
@@ -29,6 +29,7 @@ import { WorkflowPersistService } from "../../../../common/service/workflow-pers
 import { DownloadService } from "../download/download.service";
 import {
   HUB_DATASET_RESULT_DETAIL,
+  HUB_MODEL_RESULT_DETAIL,
   HUB_WORKFLOW_RESULT_DETAIL,
   USER_DATASET,
   USER_MODEL,
@@ -235,9 +236,10 @@ describe("ResourceRegistryService", () => {
     expect(registry.entryLink(dataset, 99)).toEqual([HUB_DATASET_RESULT_DETAIL, "5"]);
   });
 
-  it("routes a model to its detail page, which has no hub twin yet", () => {
-    expect(registry.get(EntityType.Model).hubRoute).toBeUndefined();
-    expect(registry.entryLink(entry({ type: EntityType.Model, id: 9 }), 42)).toEqual([USER_MODEL, "9"]);
+  it("routes a model the same way, to its own page or to the hub", () => {
+    const model = entry({ type: EntityType.Model, id: 9, accessibleUserIds: [42] });
+    expect(registry.entryLink(model, 42)).toEqual([USER_MODEL, "9"]);
+    expect(registry.entryLink(model, 99)).toEqual([HUB_MODEL_RESULT_DETAIL, "9"]);
   });
 
   it("leaves an unroutable or unsaved entry unlinked", () => {
diff --git a/frontend/src/app/dashboard/service/user/search.service.spec.ts b/frontend/src/app/dashboard/service/user/search.service.spec.ts
index 067ab73b88..c06350379d 100644
--- a/frontend/src/app/dashboard/service/user/search.service.spec.ts
+++ b/frontend/src/app/dashboard/service/user/search.service.spec.ts
@@ -31,6 +31,7 @@ import { SortMethod } from "../../type/sort-method";
 import { SearchResult, SearchResultItem } from "../../type/search-result";
 import { DashboardWorkflow } from "../../type/dashboard-workflow.interface";
 import { DashboardDataset } from "../../type/dashboard-dataset.interface";
+import { DashboardModel } from "../../type/dashboard-model.interface";
 
 const API = "api";
 
@@ -94,6 +95,29 @@ function makeDatasetItem(did: number, ownerUid: number): SearchResultItem {
   return { resourceType: "dataset", dataset };
 }
 
+function makeModelItem(mid: number, ownerUid: number): SearchResultItem {
+  const model: DashboardModel = {
+    isOwner: true,
+    ownerEmail: "o@example.com",
+    accessPrivilege: "WRITE",
+    size: 23,
+    model: {
+      mid,
+      ownerUid,
+      name: `m-${mid}`,
+      repositoryName: undefined,
+      isPublic: false,
+      isDownloadable: false,
+      description: "",
+      creationTime: 0,
+      coverImage: undefined,
+      framework: "pytorch",
+      format: "safetensors",
+    },
+  };
+  return { resourceType: "model", model };
+}
+
 describe("SearchService", () => {
   let service: SearchService;
   let http: HttpTestingController;
@@ -177,6 +201,23 @@ describe("SearchService", () => {
       // `more` and the rest of the envelope must survive the filter untouched.
       expect(got.more).toBe(true);
     });
+
+    it("keeps model rows, which the funnel used to drop", async () => {
+      const model = makeModelItem(12, 7);
+      const pending = firstValueFrom(
+        service.search(["k"], makeEmptyFilter(), 0, 10, null, SortMethod.NameAsc, true, false)
+      );
+      http.expectOne(r => r.url.startsWith(`${API}/dashboard/search`)).flush({ results: [model], more: false });
+
+      expect((await pending).results).toEqual([model]);
+    });
+
+    it("sends resourceType=model for a model search", () => {
+      firstValueFrom(service.search([], makeEmptyFilter(), 0, 10, "model", SortMethod.NameAsc, true, true));
+      const req = http.expectOne(r => r.url.startsWith(`${API}/dashboard/search`));
+      expect(req.request.urlWithParams).toContain("resourceType=model");
+      req.flush({ results: [], more: false });
+    });
   });
 
   // ─── getUserInfo ──────────────────────────────────────────────────────────
@@ -210,6 +251,23 @@ describe("SearchService", () => {
       expect(batch.hasMismatch).toBe(true);
     });
 
+    it("filters null/mismatched models and surfaces hasMismatch", async () => {
+      const modelItem = makeModelItem(10, 5);
+      const flagged: any = { resourceType: "model", model: null };
+      const result: SearchResult = { results: [modelItem, flagged, null as any], more: true, hasMismatch: true };
+      vi.spyOn(service, "search").mockReturnValue(of(result));
+      vi.spyOn(service, "getUserInfo").mockReturnValue(of({} as any));
+
+      const batch = await firstValueFrom(
+        service.executeSearch([], makeEmptyFilter(), 0, 10, "model", SortMethod.NameAsc, true, false)
+      );
+
+      expect(batch.entries).toHaveLength(1);
+      expect(batch.entries[0].id).toBe(10);
+      expect(batch.entries[0].type).toBe(EntityType.Model);
+      expect(batch.hasMismatch).toBe(true);
+    });
+
     it("leaves hasMismatch undefined and skips filtering for non-dataset searches", async () => {
       const wf = makeWorkflowItem(11, 7);
       vi.spyOn(service, "search").mockReturnValue(of({ results: [wf], more: false, hasMismatch: true }));
@@ -293,6 +351,29 @@ describe("SearchService", () => {
       expect(entry.ownerName).toBe("carol");
     });
 
+    it("uses Model entity routing and pulls ownerUid for model items", async () => {
+      const model = makeModelItem(30, 9);
+      const userInfoSpy = vi.spyOn(service, "getUserInfo").mockReturnValue(of({ 9: { userName: "dave" } }));
+      hubSpy.getUserAccess.mockReturnValue(of([{ entityId: 30, entityType: EntityType.Model, userIds: [42] }]));
+
+      const [entry] = await firstValueFrom(service.extendSearchResultsWithHubActivityInfo([model], true));
+
+      expect(userInfoSpy).toHaveBeenCalledWith([9]);
+      expect(hubSpy.getUserAccess.mock.calls[0][0]).toEqual([EntityType.Model]);
+      expect(entry.type).toBe(EntityType.Model);
+      expect(entry.accessibleUserIds).toEqual([42]);
+      expect(entry.ownerName).toBe("dave");
+    });
+
+    it("names the resource type when an item carries no payload", async () => {
+      const orphan = { resourceType: "computing-unit" } as SearchResultItem;
+      vi.spyOn(service, "getUserInfo").mockReturnValue(of({} as any));
+
+      await expect(firstValueFrom(service.extendSearchResultsWithHubActivityInfo([orphan], true))).rejects.toThrowError(
+        /computing-unit/
+      );
+    });
+
     it("does not request sizes when there are no workflow items", async () => {
       const ds = makeDatasetItem(20, 8);
       vi.spyOn(service, "getUserInfo").mockReturnValue(of({} as any));
diff --git a/frontend/src/app/dashboard/service/user/search.service.ts b/frontend/src/app/dashboard/service/user/search.service.ts
index 7faa68fb92..04d65ff975 100644
--- a/frontend/src/app/dashboard/service/user/search.service.ts
+++ b/frontend/src/app/dashboard/service/user/search.service.ts
@@ -59,7 +59,7 @@ export class SearchService {
    * @param params - Additional search filter parameters.
    * @param start - The starting index for paginated results.
    * @param count - The number of results to retrieve.
-   * @param type - The type of resource to search for ("workflow", "dataset", "file", or null (all resource type)).
+   * @param type - The type of resource to search for ("workflow", "dataset", "file", "model", or null (all resource type)).
    * @param orderBy - Specifies the sorting method.
    * @param isLogin - Indicates if the user is logged in.
    *    - `isLogin = true`: Use the authenticated search endpoint, retrieving both user-accessible and public resources based on `includePublic`.
@@ -73,7 +73,7 @@ export class SearchService {
     params: SearchFilterParameters,
     start: number,
     count: number,
-    type: "workflow" | "file" | "dataset" | null,
+    type: "workflow" | "file" | "dataset" | "model" | null,
     orderBy: SortMethod,
     isLogin: boolean,
     includePublic: boolean = false
@@ -106,7 +106,9 @@ export class SearchService {
           // and the search bar are unaffected. Not corrected here: that belongs in the
           // `LoadMoreFunction` contract shared by all five callers of `SearchResultsComponent.reset`,
           // and removing the backend half (#7461) ends the only condition that produces such rows.
-          results: result.results.filter(item => item.workflow != null || item.file != null || item.dataset != null),
+          results: result.results.filter(
+            item => item.workflow != null || item.file != null || item.dataset != null || item.model != null
+          ),
         }))
       );
   }
@@ -132,7 +134,7 @@ export class SearchService {
    * @param params        Additional search filter parameters.
    * @param start         The starting index for paginated results.
    * @param count         The number of results to retrieve.
-   * @param type          The type of resource to search for ("workflow", "dataset", "file", or null (all resource type)).
+   * @param type          The type of resource to search for ("workflow", "dataset", "file", "model", or null (all resource type)).
    * @param orderBy       Specifies the sorting method.
    * @param isLogin       Indicates if the user is logged in.
    * @param includePublic Specifies whether to include public resources in the search results.
@@ -147,16 +149,21 @@ export class SearchService {
     params: SearchFilterParameters,
     start: number,
     count: number,
-    type: "workflow" | "dataset" | "file" | null,
+    type: "workflow" | "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;
-        const filteredResults =
-          type === "dataset" ? results.results.filter(i => i !== null && i.dataset != null) : results.results;
+        // A dataset or model whose repository is gone comes back with no payload; the backend flags
+        // that as a mismatch and the row is dropped here.
+        const mismatchable = type === "dataset" || type === "model";
+        const payloadOf = (i: SearchResultItem) => (type === "dataset" ? i.dataset : i.model);
+        const hasMismatch = mismatchable ? results.hasMismatch ?? false : undefined;
+        const filteredResults = mismatchable
+          ? results.results.filter(i => i !== null && payloadOf(i) != null)
+          : results.results;
 
         return this.extendSearchResultsWithHubActivityInfo(filteredResults, isLogin).pipe(
           map(entries => ({
@@ -194,6 +201,7 @@ export class SearchService {
     items.forEach(i => {
       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>);
 
@@ -206,6 +214,9 @@ 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);
       }
     });
 
@@ -238,11 +249,14 @@ export class SearchService {
         access.forEach(r => (accessMap[`${r.entityType}:${r.entityId}`] = r.userIds));
 
         return items.map(i => {
-          const entry = i.workflow ? new DashboardEntry(i.workflow) : new DashboardEntry(i.dataset!);
+          const payload = i.workflow ?? i.dataset ?? i.model;
+          if (!payload) {
+            throw new Error(`Search result carries no payload for resource type ${i.resourceType}.`);
+          }
+          const entry = new DashboardEntry(payload);
 
           const key = `${entry.type}:${entry.id}`;
-          const ownerId = i.workflow ? i.workflow.ownerId : i.dataset!.dataset!.ownerUid!;
-          const ui = (userMap as any)[ownerId];
+          const ui = entry.ownerId != null ? (userMap as any)[entry.ownerId] : undefined;
           if (ui) {
             entry.setOwnerName(ui.userName);
             entry.setOwnerAvatar(ui.avatar ?? "");
diff --git a/frontend/src/app/dashboard/service/user/stub-search.service.ts b/frontend/src/app/dashboard/service/user/stub-search.service.ts
index 7bf771cd6f..18e07d7ddf 100644
--- a/frontend/src/app/dashboard/service/user/stub-search.service.ts
+++ b/frontend/src/app/dashboard/service/user/stub-search.service.ts
@@ -43,7 +43,7 @@ export class StubSearchService {
     params: SearchFilterParameters,
     start: number,
     count: number,
-    type: "workflow" | "file" | "dataset" | null,
+    type: "workflow" | "file" | "dataset" | "model" | null,
     orderBy: SortMethod,
     isLogin: boolean = true,
     includePublic: boolean = false
@@ -79,7 +79,7 @@ export class StubSearchService {
     params: SearchFilterParameters,
     start: number,
     count: number,
-    type: "workflow" | "dataset" | "file" | null,
+    type: "workflow" | "dataset" | "file" | "model" | null,
     orderBy: SortMethod,
     isLogin: boolean,
     includePublic: boolean
diff --git a/frontend/src/app/dashboard/type/search-filter-parameters.ts b/frontend/src/app/dashboard/type/search-filter-parameters.ts
index 8052ce80fd..7ead364e4f 100644
--- a/frontend/src/app/dashboard/type/search-filter-parameters.ts
+++ b/frontend/src/app/dashboard/type/search-filter-parameters.ts
@@ -35,7 +35,7 @@ export const toQueryStrings = (
   params: SearchFilterParameters,
   start?: number,
   count?: number,
-  type?: "workflow" | "file" | "dataset" | null,
+  type?: "workflow" | "file" | "dataset" | "model" | null,
   orderBy?: SortMethod
 ): string => {
   const toLocalDate = (date: Date) =>
@@ -84,7 +84,7 @@ export const searchTestEntries = (
   keywords: string[],
   params: SearchFilterParameters,
   testEntries: DashboardEntry[],
-  type: "workflow" | "file" | "dataset" | null
+  type: "workflow" | "file" | "dataset" | "model" | null
 ): DashboardEntry[] => {
   const endOfDay = (date: Date) => {
     date.setHours(23);
diff --git a/frontend/src/app/dashboard/type/search-result.ts b/frontend/src/app/dashboard/type/search-result.ts
index 4267b8fb50..905aa47bc0 100644
--- a/frontend/src/app/dashboard/type/search-result.ts
+++ b/frontend/src/app/dashboard/type/search-result.ts
@@ -20,15 +20,15 @@
 import { DashboardFile } from "./dashboard-file.interface";
 import { DashboardWorkflow } from "./dashboard-workflow.interface";
 import { DashboardDataset } from "./dashboard-dataset.interface";
+import { DashboardModel } from "./dashboard-model.interface";
 import { DashboardEntry } from "./dashboard-entry";
 
 export interface SearchResultItem {
-  // "model" is listed because EntityType carries it; nothing populates a model result until the hub
-  // and unified-search backend lands.
   resourceType: "workflow" | "file" | "dataset" | "model" | "computing-unit";
   workflow?: DashboardWorkflow;
   file?: DashboardFile;
   dataset?: DashboardDataset;
+  model?: DashboardModel;
 }
 
 export interface SearchResult {
diff --git a/frontend/src/app/hub/component/browse-section/browse-section.component.html b/frontend/src/app/hub/component/browse-section/browse-section.component.html
index 073f9ab5ed..9cc8ce764e 100644
--- a/frontend/src/app/hub/component/browse-section/browse-section.component.html
+++ b/frontend/src/app/hub/component/browse-section/browse-section.component.html
@@ -24,7 +24,7 @@
   <div class="entity-cards">
     <nz-card
       nzHoverable
-      [routerLink]="entity.id !== undefined ? entityRoutes[entity.id] : []"
+      [routerLink]="routeFor(entity)"
       [nzCover]="coverTemplate"
       class="entity-card"
       *ngFor="let entity of entities">
diff --git a/frontend/src/app/hub/component/browse-section/browse-section.component.spec.ts b/frontend/src/app/hub/component/browse-section/browse-section.component.spec.ts
index a4b1ad301a..07feb44e51 100644
--- a/frontend/src/app/hub/component/browse-section/browse-section.component.spec.ts
+++ b/frontend/src/app/hub/component/browse-section/browse-section.component.spec.ts
@@ -28,10 +28,13 @@ import { WorkflowPersistService } from "../../../common/service/workflow-persist
 import { DatasetService } from "../../../dashboard/service/user/dataset/dataset.service";
 import { commonTestProviders } from "../../../common/testing/test-utils";
 import { DashboardEntry } from "../../../dashboard/type/dashboard-entry";
+import { ModelService } from "../../../dashboard/service/user/model/model.service";
 import {
   HUB_DATASET_RESULT_DETAIL,
+  HUB_MODEL_RESULT_DETAIL,
   HUB_WORKFLOW_RESULT_DETAIL,
   USER_DATASET,
+  USER_MODEL,
   USER_WORKSPACE,
 } from "../../../app-routing.constant";
 
@@ -49,6 +52,7 @@ describe("BrowseSectionComponent", () => {
         { provide: WorkflowPersistService, useValue: {} },
         // The cover now comes from the descriptor, so the double has to answer for it.
         { provide: DatasetService, useValue: { getDatasetCoverUrl: () => of({ url: PRESIGNED_COVER }) } },
+        { provide: ModelService, useValue: { getModelCoverUrl: () => of({ url: PRESIGNED_COVER }) } },
         ...commonTestProviders,
       ],
     });
@@ -61,46 +65,65 @@ describe("BrowseSectionComponent", () => {
     expect(component).toBeTruthy();
   });
 
-  describe("entityRoutes initialization", () => {
+  describe("card routing", () => {
+    function entry(id: number | undefined, type: string, owners: number[]): DashboardEntry {
+      return { id, type, accessibleUserIds: owners } as unknown as DashboardEntry;
+    }
+
+    const routeOf = (entity: DashboardEntry): string[] => component.routeFor(entity);
+
     it("routes owned workflows to the user workspace", () => {
       component.currentUid = 1;
-      component.entities = [{ id: 100, type: "workflow", accessibleUserIds: [1] } as unknown as DashboardEntry];
-      component.ngOnInit();
-      expect(component.entityRoutes[100]).toEqual([USER_WORKSPACE, "100"]);
+      expect(routeOf(entry(100, "workflow", [1]))).toEqual([USER_WORKSPACE, "100"]);
     });
 
     it("routes non-owned workflows to the hub workflow detail page", () => {
       component.currentUid = 1;
-      component.entities = [{ id: 101, type: "workflow", accessibleUserIds: [2] } as unknown as DashboardEntry];
-      component.ngOnInit();
-      expect(component.entityRoutes[101]).toEqual([HUB_WORKFLOW_RESULT_DETAIL, "101"]);
+      expect(routeOf(entry(101, "workflow", [2]))).toEqual([HUB_WORKFLOW_RESULT_DETAIL, "101"]);
     });
 
     it("routes owned datasets to the user dataset page", () => {
       component.currentUid = 1;
-      component.entities = [{ id: 200, type: "dataset", accessibleUserIds: [1] } as unknown as DashboardEntry];
-      component.ngOnInit();
-      expect(component.entityRoutes[200]).toEqual([USER_DATASET, "200"]);
+      expect(routeOf(entry(200, "dataset", [1]))).toEqual([USER_DATASET, "200"]);
     });
 
     it("routes non-owned datasets to the hub dataset detail page", () => {
       component.currentUid = 1;
-      component.entities = [{ id: 201, type: "dataset", accessibleUserIds: [2] } as unknown as DashboardEntry];
-      component.ngOnInit();
-      expect(component.entityRoutes[201]).toEqual([HUB_DATASET_RESULT_DETAIL, "201"]);
+      expect(routeOf(entry(201, "dataset", [2]))).toEqual([HUB_DATASET_RESULT_DETAIL, "201"]);
     });
-  });
 
-  describe("initializeEntry edge cases", () => {
-    it("skips entries whose id is not a number", () => {
-      component.entities = [{ id: undefined, type: "dataset", accessibleUserIds: [] } as unknown as DashboardEntry];
-      component.ngOnInit();
-      expect(Object.keys(component.entityRoutes)).toHaveLength(0);
+    it("routes owned models to the user model page", () => {
+      component.currentUid = 1;
+      expect(routeOf(entry(300, "model", [1]))).toEqual([USER_MODEL, "300"]);
+    });
+
+    it("routes non-owned models to the hub model detail page", () => {
+      component.currentUid = 1;
+      expect(routeOf(entry(301, "model", [2]))).toEqual([HUB_MODEL_RESULT_DETAIL, "301"]);
+    });
+
+    it("hands back the same array each time, so the routerLink binding does not churn", () => {
+      const workflow = entry(500, "workflow", [2]);
+      expect(component.routeFor(workflow)).toBe(component.routeFor(workflow));
+    });
+
+    it("recomputes the routes once the viewer changes", () => {
+      const workflow = entry(501, "workflow", [1]);
+      component.currentUid = 2;
+      expect(component.routeFor(workflow)).toEqual([HUB_WORKFLOW_RESULT_DETAIL, "501"]);
+
+      component.currentUid = 1;
+      component.ngOnChanges({} as any);
+
+      expect(component.routeFor(workflow)).toEqual([USER_WORKSPACE, "501"]);
+    });
+
+    it("gives no route to an entry whose id is not a number", () => {
+      expect(routeOf(entry(undefined, "dataset", []))).toEqual([]);
     });
 
-    it("throws on an unexpected entity type", () => {
-      const bad = { id: 7, type: "not-a-real-type", accessibleUserIds: [] } as unknown as DashboardEntry;
-      expect(() => (component as any).initializeEntry(bad)).toThrowError("Unexpected type in DashboardEntry.");
+    it("gives no route to a kind the registry does not carry, rather than throwing", () => {
+      expect(routeOf(entry(7, "computing-unit", []))).toEqual([]);
     });
   });
 
@@ -147,6 +170,7 @@ describe("BrowseSectionComponent rendering", () => {
         { provide: WorkflowPersistService, useValue: {} },
         // The cover now comes from the descriptor, so the double has to answer for it.
         { provide: DatasetService, useValue: { getDatasetCoverUrl: () => of({ url: PRESIGNED_COVER }) } },
+        { provide: ModelService, useValue: { getModelCoverUrl: () => of({ url: PRESIGNED_COVER }) } },
         ...commonTestProviders,
       ],
     });
diff --git a/frontend/src/app/hub/component/browse-section/browse-section.component.ts b/frontend/src/app/hub/component/browse-section/browse-section.component.ts
index 42274e8244..65f2075b21 100644
--- a/frontend/src/app/hub/component/browse-section/browse-section.component.ts
+++ b/frontend/src/app/hub/component/browse-section/browse-section.component.ts
@@ -21,12 +21,6 @@ import { ChangeDetectorRef, Component, Input, OnChanges, OnInit, SimpleChanges }
 import { DashboardEntry } from "../../../dashboard/type/dashboard-entry";
 import { ResourceRegistryService } from "../../../dashboard/service/user/resource-registry/resource-registry.service";
 import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy";
-import {
-  HUB_DATASET_RESULT_DETAIL,
-  HUB_WORKFLOW_RESULT_DETAIL,
-  USER_DATASET,
-  USER_WORKSPACE,
-} from "../../../app-routing.constant";
 import { NgIf, NgFor, NgStyle, DatePipe } from "@angular/common";
 import { NzCardComponent } from "ng-zorro-antd/card";
 import { RouterLink } from "@angular/router";
@@ -57,14 +51,10 @@ export class BrowseSectionComponent implements OnInit, OnChanges {
   @Input() currentUid: number | undefined;
 
   defaultBackground: string = "../../../../../assets/card_background.jpg";
-  protected readonly HUB_WORKFLOW_RESULT_DETAIL = HUB_WORKFLOW_RESULT_DETAIL;
-  protected readonly USER_WORKSPACE = USER_WORKSPACE;
-  protected readonly HUB_DATASET_RESULT_DETAIL = HUB_DATASET_RESULT_DETAIL;
-  protected readonly USER_DATASET = USER_DATASET;
-  entityRoutes: { [key: number]: string[] } = {};
 
-  /** Keyed by type and id: ids are only unique within a kind, and sections may mix kinds. */
+  /** Both maps are keyed by type and id: ids are only unique within a kind, and sections may mix kinds. */
   private coverImageUrls = new Map<string, string>();
+  private entityRoutes = new Map<string, string[]>();
 
   constructor(
     private resourceRegistry: ResourceRegistryService,
@@ -72,45 +62,32 @@ export class BrowseSectionComponent implements OnInit, OnChanges {
   ) {}
 
   ngOnInit(): void {
-    this.entities.forEach(entity => {
-      this.initializeEntry(entity);
-    });
     this.loadCoverImages();
   }
 
   ngOnChanges(changes: SimpleChanges): void {
-    this.entities.forEach(entity => {
-      this.initializeEntry(entity);
-    });
+    // The routes depend on the viewer as well as on the entities, and both arrive as inputs.
+    this.entityRoutes.clear();
     this.loadCoverImages();
   }
 
-  private initializeEntry(entity: DashboardEntry): void {
-    if (typeof entity.id !== "number") {
-      return;
-    }
-
-    const entityId = entity.id;
-    const owners = entity.accessibleUserIds;
-
-    if (entity.type === "workflow") {
-      if (this.currentUid !== undefined && owners.includes(this.currentUid)) {
-        this.entityRoutes[entityId] = [this.USER_WORKSPACE, String(entityId)];
-      } else {
-        this.entityRoutes[entityId] = [this.HUB_WORKFLOW_RESULT_DETAIL, String(entityId)];
-      }
-    } else if (entity.type === "dataset") {
-      if (this.currentUid !== undefined && owners.includes(this.currentUid)) {
-        this.entityRoutes[entityId] = [this.USER_DATASET, String(entityId)];
-      } else {
-        this.entityRoutes[entityId] = [this.HUB_DATASET_RESULT_DETAIL, String(entityId)];
-      }
-    } else {
-      throw new Error("Unexpected type in DashboardEntry.");
+  /**
+   * A card links to your own copy of the resource when you can reach it, and to the hub otherwise.
+   * Cached so the `routerLink` binding keeps one array identity across change-detection runs.
+   */
+  routeFor(entity: DashboardEntry): string[] {
+    const key = this.cacheKey(entity);
+    let route = this.entityRoutes.get(key);
+    if (route === undefined) {
+      // `find`, not `get`: a section may hold a kind the registry does not carry, and one such
+      // row must not take the whole landing page down with it.
+      route = this.resourceRegistry.find(entity.type) ? this.resourceRegistry.entryLink(entity, this.currentUid) : [];
+      this.entityRoutes.set(key, route);
     }
+    return route;
   }
 
-  private coverCacheKey(entity: DashboardEntry): string {
+  private cacheKey(entity: DashboardEntry): string {
     return `${entity.type}:${entity.id}`;
   }
 
@@ -123,15 +100,14 @@ export class BrowseSectionComponent implements OnInit, OnChanges {
         (entity): entity is DashboardEntry & { id: number } =>
           entity.coverImageUrl !== undefined &&
           entity.id !== undefined &&
-          !this.coverImageUrls.has(this.coverCacheKey(entity))
+          !this.coverImageUrls.has(this.cacheKey(entity))
       )
       .forEach(entity => {
-        // `find`, not `get`: a section may hold a kind the registry does not carry.
         const coverUrl = this.resourceRegistry.find(entity.type)?.coverUrl;
         if (!coverUrl) {
           return;
         }
-        const key = this.coverCacheKey(entity);
+        const key = this.cacheKey(entity);
         coverUrl(entity.id)
           .pipe(untilDestroyed(this))
           .subscribe(url => {
@@ -144,6 +120,6 @@ export class BrowseSectionComponent implements OnInit, OnChanges {
   }
 
   getCoverImage(entity: DashboardEntry): string {
-    return this.coverImageUrls.get(this.coverCacheKey(entity)) || this.defaultBackground;
+    return this.coverImageUrls.get(this.cacheKey(entity)) || this.defaultBackground;
   }
 }
diff --git a/frontend/src/app/hub/component/hub-search-result/hub-search-result.component.html b/frontend/src/app/hub/component/hub-search-result/hub-search-result.component.html
index 6f4a273ad2..14c4caa044 100644
--- a/frontend/src/app/hub/component/hub-search-result/hub-search-result.component.html
+++ b/frontend/src/app/hub/component/hub-search-result/hub-search-result.component.html
@@ -21,14 +21,14 @@
   <div class="search-section">
     <div class="filter">
       <texera-sort-button
-        [showEditTime]="searchType !== 'dataset'"
-        [showExecutionTime]="searchType !== 'dataset'"
+        [showEditTime]="!isVersionedResource"
+        [showExecutionTime]="!isVersionedResource"
         (sortMethodChange)="sortMethod = $event; search()"></texera-sort-button>
       <texera-filters
-        [entityType]="filterEntityType"
+        [entityType]="entityType"
         #filters></texera-filters>
       <div
-        *ngIf="searchType === 'dataset'"
+        *ngIf="isVersionedResource"
         class="view-toggle">
         <button
           nz-button
@@ -55,7 +55,7 @@
   </div>
 
   <ng-template
-    #datasetCardTpl
+    #cardTpl
     let-entry>
     <texera-card-item
       [entry]="entry"
@@ -68,8 +68,8 @@
       [showResourceTypes]="true"
       [searchKeywords]="searchKeywords"
       [currentUid]="this.currentUid"
-      [viewMode]="searchType === 'dataset' ? viewMode : 'list'"
-      [cardTemplate]="searchType === 'dataset' ? datasetCardTpl : undefined">
+      [viewMode]="isVersionedResource ? viewMode : 'list'"
+      [cardTemplate]="isVersionedResource ? cardTpl : undefined">
     </texera-search-results>
   </div>
 </div>
diff --git a/frontend/src/app/hub/component/hub-search-result/hub-search-result.component.spec.ts b/frontend/src/app/hub/component/hub-search-result/hub-search-result.component.spec.ts
index f12e055f1a..a1c34fa140 100644
--- a/frontend/src/app/hub/component/hub-search-result/hub-search-result.component.spec.ts
+++ b/frontend/src/app/hub/component/hub-search-result/hub-search-result.component.spec.ts
@@ -19,7 +19,7 @@
 
 import { Component, EventEmitter, forwardRef, Input, Output, TemplateRef } from "@angular/core";
 import { ComponentFixture, fakeAsync, TestBed, tick } from "@angular/core/testing";
-import { provideRouter, Router } from "@angular/router";
+import { ActivatedRoute, provideRouter } from "@angular/router";
 import { HttpClientTestingModule } from "@angular/common/http/testing";
 import { NoopAnimationsModule } from "@angular/platform-browser/animations";
 import { By } from "@angular/platform-browser";
@@ -122,12 +122,11 @@ describe("HubSearchResultComponent", () => {
   let fixture: ComponentFixture<HubSearchResultComponent>;
   let component: HubSearchResultComponent;
 
-  let routerMock: { url: string };
   let searchServiceMock: { executeSearch: ReturnType<typeof vi.fn> };
   let setItemSpy: ReturnType<typeof vi.spyOn>;
 
-  function configure(url: string): void {
-    routerMock = { url };
+  /** `entityType` undefined stands for a route that declares no kind. */
+  function configure(entityType?: EntityType): void {
     searchServiceMock = {
       executeSearch: vi.fn().mockReturnValue(of({ entries: [], more: false })),
     };
@@ -144,7 +143,7 @@ describe("HubSearchResultComponent", () => {
     TestBed.configureTestingModule({
       imports: [HubSearchResultComponent, NzIconModule.forChild([BarsOutline, AppstoreOutline])],
       providers: [
-        { provide: Router, useValue: routerMock },
+        { provide: ActivatedRoute, useValue: { snapshot: { data: { entityType } } } },
         { provide: SearchService, useValue: searchServiceMock },
         { provide: UserService, useClass: StubUserService },
         ...commonTestProviders,
@@ -152,8 +151,8 @@ describe("HubSearchResultComponent", () => {
     });
   }
 
-  function build(url: string, detectChanges: boolean = true): void {
-    configure(url);
+  function build(entityType?: EntityType, detectChanges: boolean = true): void {
+    configure(entityType);
     fixture = TestBed.createComponent(HubSearchResultComponent);
     component = fixture.componentInstance;
     if (detectChanges) {
@@ -195,22 +194,37 @@ describe("HubSearchResultComponent", () => {
     document.querySelectorAll(".cdk-overlay-container").forEach(el => el.remove());
   });
 
-  describe("ngOnInit / searchType resolution", () => {
-    it("resolves 'dataset' and defaults sortMethod to CreateTimeDesc when the url contains 'dataset'", () => {
-      build("/dashboard/dataset");
+  describe("ngOnInit / entityType resolution", () => {
+    it("takes 'dataset' from the route and defaults sortMethod to CreateTimeDesc", () => {
+      build(EntityType.Dataset);
+      expect(component.entityType).toBe(EntityType.Dataset);
       expect(component.searchType).toBe("dataset");
       expect(component.sortMethod).toBe(SortMethod.CreateTimeDesc);
     });
 
-    it("resolves 'workflow' and keeps the default EditTimeDesc when the url contains 'workflow'", () => {
-      build("/dashboard/workflow");
+    it("takes 'workflow' from the route and keeps the default EditTimeDesc", () => {
+      build(EntityType.Workflow);
+      expect(component.entityType).toBe(EntityType.Workflow);
       expect(component.searchType).toBe("workflow");
       expect(component.sortMethod).toBe(SortMethod.EditTimeDesc);
     });
 
-    it("keeps the default 'workflow' searchType when the url matches neither branch", () => {
-      build("/dashboard/other");
-      expect(component.searchType).toBe("workflow");
+    it("takes 'model' from the route and treats it as a versioned resource", () => {
+      build(EntityType.Model);
+      expect(component.entityType).toBe(EntityType.Model);
+      expect(component.searchType).toBe("model");
+      expect(component.isVersionedResource).toBe(true);
+      expect(component.sortMethod).toBe(SortMethod.CreateTimeDesc);
+    });
+
+    it("does not treat workflows as a versioned resource", () => {
+      build(EntityType.Workflow);
+      expect(component.isVersionedResource).toBe(false);
+    });
+
+    it("falls back to workflow when the route declares no kind", () => {
+      build(undefined);
+      expect(component.entityType).toBe(EntityType.Workflow);
       expect(component.sortMethod).toBe(SortMethod.EditTimeDesc);
     });
   });
@@ -218,25 +232,25 @@ describe("HubSearchResultComponent", () => {
   describe("viewMode initialization", () => {
     it("reads 'card' from localStorage", () => {
       localStorage.setItem(VIEW_MODE_STORAGE_KEY, "card");
-      build("/dashboard/dataset");
+      build(EntityType.Dataset);
       expect(component.viewMode).toBe("card");
     });
 
     it("falls back to 'list' when localStorage holds a non-'card' value", () => {
       localStorage.setItem(VIEW_MODE_STORAGE_KEY, "grid");
-      build("/dashboard/dataset");
+      build(EntityType.Dataset);
       expect(component.viewMode).toBe("list");
     });
 
     it("falls back to 'list' when localStorage is empty", () => {
-      build("/dashboard/dataset");
+      build(EntityType.Dataset);
       expect(component.viewMode).toBe("list");
     });
   });
 
   describe("setViewMode", () => {
     it("is a no-op (no localStorage write) when the mode is unchanged", () => {
-      build("/dashboard/dataset");
+      build(EntityType.Dataset);
       expect(component.viewMode).toBe("list");
       setItemSpy.mockClear();
 
@@ -247,7 +261,7 @@ describe("HubSearchResultComponent", () => {
     });
 
     it("updates viewMode and persists to localStorage when the mode changes", () => {
-      build("/dashboard/dataset");
+      build(EntityType.Dataset);
       setItemSpy.mockClear();
 
       component.setViewMode("card");
@@ -260,7 +274,7 @@ describe("HubSearchResultComponent", () => {
 
   describe("template rendering", () => {
     it("renders the dataset view-toggle when searchType is 'dataset'", () => {
-      build("/dashboard/dataset");
+      build(EntityType.Dataset);
       const host = fixture.nativeElement as HTMLElement;
       expect(host.querySelector(".view-toggle")).not.toBeNull();
       expect(host.querySelector("texera-search-results")).not.toBeNull();
@@ -268,7 +282,7 @@ describe("HubSearchResultComponent", () => {
     });
 
     it("hides the dataset view-toggle when searchType is 'workflow'", () => {
-      build("/dashboard/workflow");
+      build(EntityType.Workflow);
       const host = fixture.nativeElement as HTMLElement;
       expect(host.querySelector(".view-toggle")).toBeNull();
     });
@@ -276,7 +290,7 @@ describe("HubSearchResultComponent", () => {
 
   describe("search", () => {
     it("early-returns when filters is unset", async () => {
-      build("/dashboard/workflow");
+      build(EntityType.Workflow);
       const results = makeSearchResultsMock();
       attachChildren(undefined, results);
 
@@ -287,7 +301,7 @@ describe("HubSearchResultComponent", () => {
     });
 
     it("early-returns when searchResultsComponent is unset", async () => {
-      build("/dashboard/workflow");
+      build(EntityType.Workflow);
       const filters = makeFiltersMock();
       attachChildren(filters, undefined);
 
@@ -298,7 +312,7 @@ describe("HubSearchResultComponent", () => {
     });
 
     it("resets with a loader and calls loadMore on the first search", async () => {
-      build("/dashboard/workflow");
+      build(EntityType.Workflow);
       const filters = makeFiltersMock();
       const results = makeSearchResultsMock();
       attachChildren(filters, results);
@@ -312,7 +326,7 @@ describe("HubSearchResultComponent", () => {
     });
 
     it("skips a repeated search with the same filter list and sortMethod, but honors forced=true", async () => {
-      build("/dashboard/workflow");
+      build(EntityType.Workflow);
       const filters = makeFiltersMock();
       const results = makeSearchResultsMock();
       attachChildren(filters, results);
@@ -330,7 +344,7 @@ describe("HubSearchResultComponent", () => {
     });
 
     it("re-searches when the sortMethod changes even if the filter list is identical", async () => {
-      build("/dashboard/workflow");
+      build(EntityType.Workflow);
       const filters = makeFiltersMock();
       const results = makeSearchResultsMock();
       attachChildren(filters, results);
@@ -344,7 +358,7 @@ describe("HubSearchResultComponent", () => {
     });
 
     it("forwards the filter parameters into the executeSearch loader", async () => {
-      build("/dashboard/workflow");
+      build(EntityType.Workflow);
       const filters = makeFiltersMock();
       const results = makeSearchResultsMock();
       attachChildren(filters, results);
@@ -365,6 +379,27 @@ describe("HubSearchResultComponent", () => {
         true
       );
     });
+
+    it("searches for models when the route named the model kind", async () => {
+      build(EntityType.Model);
+      const results = makeSearchResultsMock();
+      attachChildren(makeFiltersMock(), results);
+
+      await component.search();
+      const loader = results.reset.mock.calls[0][0] as (start: number, count: number) => Promise<unknown>;
+      await loader(0, 20);
+
+      expect(searchServiceMock.executeSearch).toHaveBeenCalledWith(
+        [""],
+        {},
+        0,
+        20,
+        "model",
+        SortMethod.CreateTimeDesc,
+        false,
+        true
+      );
+    });
   });
 });
 
@@ -436,7 +471,7 @@ describe("HubSearchResultComponent rendered template", () => {
     } as unknown as DashboardEntry;
   }
 
-  function render(url: string, storedViewMode?: string): void {
+  function render(entityType: EntityType, storedViewMode?: string): void {
     TestBed.resetTestingModule();
     localStorage.clear();
     if (storedViewMode !== undefined) {
@@ -453,6 +488,7 @@ describe("HubSearchResultComponent rendered template", () => {
       ],
       providers: [
         provideRouter([]),
+        { provide: ActivatedRoute, useValue: { snapshot: { data: { entityType } } } },
         { provide: SearchService, useValue: { executeSearch } },
         { provide: UserService, useClass: StubUserService },
         { provide: OperatorMetadataService, useClass: StubOperatorMetadataService },
@@ -468,9 +504,6 @@ describe("HubSearchResultComponent rendered template", () => {
       ],
     });
 
-    // ngOnInit derives searchType from Router.url; an own property shadows the real getter.
-    Object.defineProperty(TestBed.inject(Router), "url", { get: () => url });
-
     fixture = TestBed.createComponent(HubSearchResultComponent);
     fixture.detectChanges();
   }
@@ -495,7 +528,7 @@ describe("HubSearchResultComponent rendered template", () => {
   it("renders the real children, not the stubbed selectors", () => {
     // If these resolve to empty stub templates the component was re-JITed and the
     // template's coverage has silently gone back to zero.
-    render("/dashboard/dataset");
+    render(EntityType.Dataset);
 
     expect(host().querySelector("texera-sort-button button#sortDropdown")).not.toBeNull();
     expect(host().querySelector("texera-filters button")).not.toBeNull();
@@ -503,7 +536,7 @@ describe("HubSearchResultComponent rendered template", () => {
   });
 
   it("renders both dataset view-toggle buttons, each with its own label and icon", () => {
-    render("/dashboard/dataset");
+    render(EntityType.Dataset);
 
     expect(toggleButtons().map(button => button.title)).toEqual(["List view", "Card view"]);
     // nz-icon turns nzType into an `anticon-<type>` class, so this pins which icon each button asks for.
@@ -513,8 +546,15 @@ describe("HubSearchResultComponent rendered template", () => {
     ]);
   });
 
+  it("renders the view toggle for models, the same as for datasets", () => {
+    render(EntityType.Model);
+
+    expect(host().querySelector(".view-toggle")).not.toBeNull();
+    expect(toggleButtons().map(button => button.title)).toEqual(["List view", "Card view"]);
+  });
+
   it("omits the view toggle entirely when the search type is workflow", () => {
-    render("/dashboard/workflow");
+    render(EntityType.Workflow);
 
     expect(host().querySelector(".view-toggle")).toBeNull();
     expect(toggleButtons()).toEqual([]);
@@ -523,7 +563,7 @@ describe("HubSearchResultComponent rendered template", () => {
   });
 
   it("highlights whichever view-toggle button matches the current view mode", () => {
-    render("/dashboard/dataset");
+    render(EntityType.Dataset);
     expect(toggleTypes()).toEqual(["primary", "default"]);
 
     toggleButtons()[1].click();
@@ -536,7 +576,15 @@ describe("HubSearchResultComponent rendered template", () => {
   });
 
   it("hides the edit-time and execution-time sort options for datasets", fakeAsync(() => {
-    render("/dashboard/dataset");
+    render(EntityType.Dataset);
+
+    openSortMenu();
+
+    expect(sortMenuLabels()).toEqual(["By Create Time", "A -> Z", "Z -> A"]);
+  }));
+
+  it("hides the edit-time and execution-time sort options for models too", fakeAsync(() => {
+    render(EntityType.Model);
 
     openSortMenu();
 
@@ -544,7 +592,7 @@ describe("HubSearchResultComponent rendered template", () => {
   }));
 
   it("offers the edit-time and execution-time sort options for workflows", fakeAsync(() => {
-    render("/dashboard/workflow");
+    render(EntityType.Workflow);
 
     openSortMenu();
 
@@ -552,7 +600,7 @@ describe("HubSearchResultComponent rendered template", () => {
   }));
 
   it("re-runs the search with the sort method the sort button emits", () => {
-    render("/dashboard/workflow");
+    render(EntityType.Workflow);
     const sortButton = fixture.debugElement.query(By.directive(SortButtonComponent))
       .componentInstance as SortButtonComponent;
 
@@ -565,7 +613,7 @@ describe("HubSearchResultComponent rendered template", () => {
   });
 
   it("renders every dataset entry through the card template in card mode", async () => {
-    render("/dashboard/dataset", "card");
+    render(EntityType.Dataset, "card");
 
     await loadEntries([makeDatasetEntry(7, "alpha"), makeDatasetEntry(8, "beta")]);
 
@@ -578,7 +626,7 @@ describe("HubSearchResultComponent rendered template", () => {
   });
 
   it("keeps the workflow search type on the list view even when card mode is stored", () => {
-    render("/dashboard/workflow", "card");
+    render(EntityType.Workflow, "card");
     expect(fixture.componentInstance.viewMode).toBe("card");
 
     expect(host().querySelector("cdk-virtual-scroll-viewport")).not.toBeNull();
@@ -589,7 +637,7 @@ describe("HubSearchResultComponent rendered template", () => {
   });
 
   it("hands the resource types, the filter keywords and the signed-in uid to the results list", () => {
-    render("/dashboard/workflow");
+    render(EntityType.Workflow);
     const filters = fixture.debugElement.query(By.directive(FiltersComponent)).componentInstance as FiltersComponent;
 
     // Committing a filter list is what the real filter bar does on every change, and it
diff --git a/frontend/src/app/hub/component/hub-search-result/hub-search-result.component.ts b/frontend/src/app/hub/component/hub-search-result/hub-search-result.component.ts
index 0e29f8290d..cfe9c59e3a 100644
--- a/frontend/src/app/hub/component/hub-search-result/hub-search-result.component.ts
+++ b/frontend/src/app/hub/component/hub-search-result/hub-search-result.component.ts
@@ -18,7 +18,7 @@
  */
 
 import { AfterViewInit, Component, Input, OnInit, ViewChild } from "@angular/core";
-import { Router } from "@angular/router";
+import { ActivatedRoute } from "@angular/router";
 import { NgIf } from "@angular/common";
 import { NzButtonComponent } from "ng-zorro-antd/button";
 import { NzIconDirective } from "ng-zorro-antd/icon";
@@ -38,7 +38,8 @@ import { map } from "rxjs/operators";
 import { SortButtonComponent } from "../../../dashboard/component/user/sort-button/sort-button.component";
 import { EntityType } from "../../service/hub.service";
 
-const HUB_DATASET_VIEW_MODE_STORAGE_KEY = "texera.hub.dataset.viewMode";
+/** One key for every kind the hub browses; the "dataset" in it is historical. */
+const HUB_VIEW_MODE_STORAGE_KEY = "texera.hub.dataset.viewMode";
 
 @UntilDestroy()
 @Component({
@@ -57,22 +58,30 @@ const HUB_DATASET_VIEW_MODE_STORAGE_KEY = "texera.hub.dataset.viewMode";
   ],
 })
 export class HubSearchResultComponent implements OnInit, AfterViewInit {
-  public searchType: "dataset" | "workflow" = "workflow";
+  /** The kind this page browses, named by the route rather than sniffed out of the URL. */
+  public entityType: EntityType = EntityType.Workflow;
 
-  /** `searchType` as an EntityType, for the filters bar. */
-  public get filterEntityType(): EntityType {
-    return this.searchType === "dataset" ? EntityType.Dataset : EntityType.Workflow;
+  /** The search API takes the literal resource type, which is exactly what the enum holds. */
+  public get searchType(): "workflow" | "dataset" | "model" {
+    return this.entityType as "workflow" | "dataset" | "model";
+  }
+
+  /**
+   * Datasets and models are the versioned resources: neither carries a modified or execution time,
+   * and both are worth browsing as cards because both have a cover image.
+   */
+  public get isVersionedResource(): boolean {
+    return this.entityType === EntityType.Dataset || this.entityType === EntityType.Model;
   }
 
   public searchKeywords: string[] = [];
   currentUid = this.userService.getCurrentUser()?.uid;
-  public viewMode: SearchResultsViewMode =
-    localStorage.getItem(HUB_DATASET_VIEW_MODE_STORAGE_KEY) === "card" ? "card" : "list";
+  public viewMode: SearchResultsViewMode = localStorage.getItem(HUB_VIEW_MODE_STORAGE_KEY) === "card" ? "card" : "list";
 
   setViewMode(mode: SearchResultsViewMode): void {
     if (this.viewMode === mode) return;
     this.viewMode = mode;
-    localStorage.setItem(HUB_DATASET_VIEW_MODE_STORAGE_KEY, mode);
+    localStorage.setItem(HUB_VIEW_MODE_STORAGE_KEY, mode);
   }
 
   private isLogin = false;
@@ -101,7 +110,7 @@ export class HubSearchResultComponent implements OnInit, AfterViewInit {
   constructor(
     private userService: UserService,
     private searchService: SearchService,
-    private router: Router
+    private route: ActivatedRoute
   ) {
     this.userService
       .userChanged()
@@ -112,14 +121,11 @@ export class HubSearchResultComponent implements OnInit, AfterViewInit {
   }
 
   ngOnInit() {
-    const url = this.router.url;
-    if (url.includes("dataset")) {
-      this.searchType = "dataset";
-      // Datasets have no last-modified/execution time, so EditTimeDesc leaves the sort key NULL.
-      // Default to CreateTimeDesc so newly created datasets appear first.
+    this.entityType = this.route.snapshot.data["entityType"] ?? EntityType.Workflow;
+    if (this.isVersionedResource) {
+      // These have no last-modified/execution time, so EditTimeDesc leaves the sort key NULL.
+      // Default to CreateTimeDesc so the newest appear first.
       this.sortMethod = SortMethod.CreateTimeDesc;
-    } else if (url.includes("workflow")) {
-      this.searchType = "workflow";
     }
   }
 
@@ -131,8 +137,7 @@ export class HubSearchResultComponent implements OnInit, AfterViewInit {
   }
 
   /**
-   * Searches dataset or workflow based on the `searchType` determined from the full URL.
-   * @returns
+   * Searches the kind this page was routed for.
    *
    * todo: Integrate the search functions from different interfaces into a single method.
    */
diff --git a/frontend/src/app/hub/component/hub.component.html b/frontend/src/app/hub/component/hub.component.html
index 7343cd7d71..d6b7e439ed 100644
--- a/frontend/src/app/hub/component/hub.component.html
+++ b/frontend/src/app/hub/component/hub.component.html
@@ -54,4 +54,16 @@
       nzType="database"></span>
     <span>Datasets</span>
   </li>
+  <li
+    *ngIf="sidebarTabs.model_enabled"
+    nz-menu-item
+    nz-tooltip="Search public models"
+    nzMatchRouter="true"
+    nzTooltipPlacement="right"
+    [routerLink]="HUB_MODEL_RESULT">
+    <span
+      nz-icon
+      [nzType]="MODEL_ICON"></span>
+    <span>Models</span>
+  </li>
 </ul>
diff --git a/frontend/src/app/hub/component/hub.component.spec.ts b/frontend/src/app/hub/component/hub.component.spec.ts
index 02779057ef..0e6f2df163 100644
--- a/frontend/src/app/hub/component/hub.component.spec.ts
+++ b/frontend/src/app/hub/component/hub.component.spec.ts
@@ -30,7 +30,7 @@ import { HubComponent } from "./hub.component";
 import { commonTestProviders } from "../../common/testing/test-utils";
 import { GuiConfigService } from "../../common/service/gui-config.service";
 import { SidebarTabs } from "../../common/type/gui-config";
-import { HOME, HUB_DATASET_RESULT, HUB_WORKFLOW_RESULT } from "../../app-routing.constant";
+import { HOME, HUB_DATASET_RESULT, HUB_MODEL_RESULT, HUB_WORKFLOW_RESULT } from "../../app-routing.constant";
 
 // Full SidebarTabs with all flags off; tests enable only the ones they need.
 function makeSidebarTabs(overrides: Partial<SidebarTabs> = {}): SidebarTabs {
@@ -39,6 +39,7 @@ function makeSidebarTabs(overrides: Partial<SidebarTabs> = {}): SidebarTabs {
     home_enabled: false,
     workflow_enabled: false,
     dataset_enabled: false,
+    model_enabled: false,
     your_work_enabled: false,
     workflows_enabled: false,
     compute_enabled: false,
@@ -138,13 +139,25 @@ describe("HubComponent", () => {
     expect(labels[0]).toContain("Datasets");
   });
 
-  it("renders all three menu items when home, workflow, and dataset flags are enabled", () => {
-    setup(false, makeSidebarTabs({ home_enabled: true, workflow_enabled: true, dataset_enabled: true }));
+  it("renders only the Models item when model_enabled is the only flag set", () => {
+    // The hub has its own flag: an instance can browse public models without offering its own.
+    setup(false, makeSidebarTabs({ model_enabled: true }));
     const labels = renderedMenuLabels();
-    expect(labels.length).toBe(3);
+    expect(labels.length).toBe(1);
+    expect(labels[0]).toContain("Models");
+  });
+
+  it("renders all four menu items when home, workflow, dataset and model flags are enabled", () => {
+    setup(
+      false,
+      makeSidebarTabs({ home_enabled: true, workflow_enabled: true, dataset_enabled: true, model_enabled: true })
+    );
+    const labels = renderedMenuLabels();
+    expect(labels.length).toBe(4);
     expect(labels.some(l => l.includes("Home"))).toBe(true);
     expect(labels.some(l => l.includes("Workflows"))).toBe(true);
     expect(labels.some(l => l.includes("Datasets"))).toBe(true);
+    expect(labels.some(l => l.includes("Models"))).toBe(true);
   });
 
   it("excludes disabled tabs while rendering enabled ones", () => {
@@ -157,9 +170,13 @@ describe("HubComponent", () => {
   });
 
   it("binds each menu item's routerLink to the correct routing constant", () => {
-    setup(false, makeSidebarTabs({ home_enabled: true, workflow_enabled: true, dataset_enabled: true }));
+    setup(
+      false,
+      makeSidebarTabs({ home_enabled: true, workflow_enabled: true, dataset_enabled: true, model_enabled: true })
+    );
     expect(routerLinkFor("Home")).toBe(HOME);
     expect(routerLinkFor("Workflows")).toBe(HUB_WORKFLOW_RESULT);
     expect(routerLinkFor("Datasets")).toBe(HUB_DATASET_RESULT);
+    expect(routerLinkFor("Models")).toBe(HUB_MODEL_RESULT);
   });
 });
diff --git a/frontend/src/app/hub/component/hub.component.ts b/frontend/src/app/hub/component/hub.component.ts
index ee006fa255..50f2b044cb 100644
--- a/frontend/src/app/hub/component/hub.component.ts
+++ b/frontend/src/app/hub/component/hub.component.ts
@@ -18,7 +18,7 @@
  */
 
 import { Component, Input } from "@angular/core";
-import { HOME, HUB_DATASET_RESULT, HUB_WORKFLOW_RESULT } from "../../app-routing.constant";
+import { HOME, HUB_DATASET_RESULT, HUB_MODEL_RESULT, HUB_WORKFLOW_RESULT } from "../../app-routing.constant";
 import { GuiConfigService } from "../../common/service/gui-config.service";
 import { SidebarTabs } from "../../common/type/gui-config";
 import { NgIf } from "@angular/common";
@@ -27,6 +27,7 @@ import { ɵNzTransitionPatchDirective } from "ng-zorro-antd/core/transition-patc
 import { NzTooltipDirective } from "ng-zorro-antd/tooltip";
 import { RouterLink } from "@angular/router";
 import { NzIconDirective } from "ng-zorro-antd/icon";
+import { MODEL_ICON } from "../../common/icon/model-icon";
 
 @Component({
   selector: "texera-hub",
@@ -40,6 +41,8 @@ export class HubComponent {
   protected readonly HOME = HOME;
   protected readonly HUB_WORKFLOW_RESULT = HUB_WORKFLOW_RESULT;
   protected readonly HUB_DATASET_RESULT = HUB_DATASET_RESULT;
+  protected readonly HUB_MODEL_RESULT = HUB_MODEL_RESULT;
+  protected readonly MODEL_ICON = MODEL_ICON;
 
   constructor(protected config: GuiConfigService) {}
 }
diff --git a/frontend/src/app/hub/component/landing-page/landing-page.component.html b/frontend/src/app/hub/component/landing-page/landing-page.component.html
index bbf8f21014..0a7fa2b953 100644
--- a/frontend/src/app/hub/component/landing-page/landing-page.component.html
+++ b/frontend/src/app/hub/component/landing-page/landing-page.component.html
@@ -29,12 +29,18 @@
           (click)="navigateToSearch('workflow')">
           {{workflowCount}} workflows
         </a>
-        and
+        ,
         <a
           href="javascript:void(0)"
           (click)="navigateToSearch('dataset')">
           {{datasetCount}} datasets
         </a>
+        and
+        <a
+          href="javascript:void(0)"
+          (click)="navigateToSearch('model')">
+          {{modelCount}} models
+        </a>
         provided by Texera and our community.
       </p>
     </div>
@@ -56,5 +62,10 @@
       sectionTitle="Top Loved Datasets"
       [currentUid]="currentUid">
     </texera-browse-section>
+    <texera-browse-section
+      [entities]="topLovedModels"
+      sectionTitle="Top Loved Models"
+      [currentUid]="currentUid">
+    </texera-browse-section>
   </div>
 </div>
diff --git a/frontend/src/app/hub/component/landing-page/landing-page.component.spec.ts b/frontend/src/app/hub/component/landing-page/landing-page.component.spec.ts
index d6822ce2db..08fbee2955 100644
--- a/frontend/src/app/hub/component/landing-page/landing-page.component.spec.ts
+++ b/frontend/src/app/hub/component/landing-page/landing-page.component.spec.ts
@@ -33,7 +33,8 @@ import { UserService } from "../../../common/service/user/user.service";
 import { StubUserService } from "../../../common/service/user/stub-user.service";
 import { WorkflowPersistService } from "../../../common/service/workflow-persist/workflow-persist.service";
 import { DatasetService } from "../../../dashboard/service/user/dataset/dataset.service";
-import { HOME, HUB_DATASET_RESULT, HUB_WORKFLOW_RESULT } from "../../../app-routing.constant";
+import { ModelService } from "../../../dashboard/service/user/model/model.service";
+import { HOME, HUB_DATASET_RESULT, HUB_MODEL_RESULT, HUB_WORKFLOW_RESULT } from "../../../app-routing.constant";
 import { commonTestProviders } from "../../../common/testing/test-utils";
 
 describe("LandingPageComponent", () => {
@@ -49,27 +50,33 @@ describe("LandingPageComponent", () => {
   let userService: StubUserService;
   let routerNavigateSpy: ReturnType<typeof vi.fn>;
 
-  // Workflow tops are returned for both Like and Clone; dataset tops for Like only.
+  // Workflow tops are returned for both Like and Clone; dataset and model tops for Like only.
   // Each call to `extendSearchResultsWithHubActivityInfo` is given a tag so the
   // tests can assert which action bucket each enriched payload landed in.
   const workflowLikeItems = [{ id: "wf-like-item" }] as any;
   const workflowCloneItems = [{ id: "wf-clone-item" }] as any;
   const datasetLikeItems = [{ id: "ds-like-item" }] as any;
+  const modelLikeItems = [{ id: "m-like-item" }] as any;
   const workflowLikeEnriched = [{ id: "wf-like-enriched" }] as any;
   const workflowCloneEnriched = [{ id: "wf-clone-enriched" }] as any;
   const datasetLikeEnriched = [{ id: "ds-like-enriched" }] as any;
+  const modelLikeEnriched = [{ id: "m-like-enriched" }] as any;
 
   function configureModule() {
     hubServiceStub = {
       getCount: vi.fn((entityType: EntityType) => {
         if (entityType === EntityType.Workflow) return of(42);
         if (entityType === EntityType.Dataset) return of(7);
+        if (entityType === EntityType.Model) return of(3);
         return of(0);
       }),
       getTops: vi.fn((entityType: EntityType, _actions: ActionType[], _uid?: number) => {
         if (entityType === EntityType.Workflow) {
           return of({ [ActionType.Like]: workflowLikeItems, [ActionType.Clone]: workflowCloneItems });
         }
+        if (entityType === EntityType.Model) {
+          return of({ [ActionType.Like]: modelLikeItems });
+        }
         return of({ [ActionType.Like]: datasetLikeItems });
       }),
     };
@@ -79,6 +86,7 @@ describe("LandingPageComponent", () => {
         if (items === workflowLikeItems) return of(workflowLikeEnriched);
         if (items === workflowCloneItems) return of(workflowCloneEnriched);
         if (items === datasetLikeItems) return of(datasetLikeEnriched);
+        if (items === modelLikeItems) return of(modelLikeEnriched);
         return of([]);
       }),
     };
@@ -91,6 +99,7 @@ describe("LandingPageComponent", () => {
         { provide: UserService, useClass: StubUserService },
         { provide: WorkflowPersistService, useValue: {} },
         { provide: DatasetService, useValue: {} },
+        { provide: ModelService, useValue: {} },
         ...commonTestProviders,
       ],
     });
@@ -131,25 +140,27 @@ describe("LandingPageComponent", () => {
     expect(component.currentUid).toBe(99);
   });
 
-  it("ngOnInit invokes getWorkflowCount and loadTops", () => {
+  it("ngOnInit invokes loadCounts and loadTops", () => {
     build();
-    const countSpy = vi.spyOn(component, "getWorkflowCount");
+    const countSpy = vi.spyOn(component, "loadCounts");
     const loadSpy = vi.spyOn(component, "loadTops").mockResolvedValue(undefined as any);
     component.ngOnInit();
     expect(countSpy).toHaveBeenCalledTimes(1);
     expect(loadSpy).toHaveBeenCalledTimes(1);
   });
 
-  it("getWorkflowCount populates workflowCount and datasetCount from HubService.getCount", () => {
+  it("loadCounts populates the workflow, dataset and model counts from HubService.getCount", () => {
     build();
-    component.getWorkflowCount();
+    component.loadCounts();
     expect(hubServiceStub.getCount).toHaveBeenCalledWith(EntityType.Workflow);
     expect(hubServiceStub.getCount).toHaveBeenCalledWith(EntityType.Dataset);
+    expect(hubServiceStub.getCount).toHaveBeenCalledWith(EntityType.Model);
     expect(component.workflowCount).toBe(42);
     expect(component.datasetCount).toBe(7);
+    expect(component.modelCount).toBe(3);
   });
 
-  it("loadTops resolves workflow Like/Clone and dataset Like buckets", async () => {
+  it("loadTops resolves workflow Like/Clone and dataset and model Like buckets", async () => {
     build();
     await component.loadTops();
 
@@ -159,10 +170,12 @@ describe("LandingPageComponent", () => {
       component.currentUid
     );
     expect(hubServiceStub.getTops).toHaveBeenCalledWith(EntityType.Dataset, [ActionType.Like], component.currentUid);
+    expect(hubServiceStub.getTops).toHaveBeenCalledWith(EntityType.Model, [ActionType.Like], component.currentUid);
 
     expect(component.topLovedWorkflows).toBe(workflowLikeEnriched);
     expect(component.topClonedWorkflows).toBe(workflowCloneEnriched);
     expect(component.topLovedDatasets).toBe(datasetLikeEnriched);
+    expect(component.topLovedModels).toBe(modelLikeEnriched);
   });
 
   it("loadTops swallows errors and logs them via console.error", async () => {
@@ -175,6 +188,7 @@ describe("LandingPageComponent", () => {
     expect(component.topLovedWorkflows).toEqual([]);
     expect(component.topClonedWorkflows).toEqual([]);
     expect(component.topLovedDatasets).toEqual([]);
+    expect(component.topLovedModels).toEqual([]);
     errorSpy.mockRestore();
   });
 
@@ -204,6 +218,12 @@ describe("LandingPageComponent", () => {
     expect(routerNavigateSpy).toHaveBeenCalledWith([HUB_DATASET_RESULT]);
   });
 
+  it("navigateToSearch routes to the model hub result for 'model'", () => {
+    build();
+    component.navigateToSearch("model");
+    expect(routerNavigateSpy).toHaveBeenCalledWith([HUB_MODEL_RESULT]);
+  });
+
   it("navigateToSearch routes to the dashboard home for an unknown type", () => {
     build();
     component.navigateToSearch("something-else");
@@ -233,6 +253,7 @@ describe("LandingPageComponent", () => {
   it("loadTops falls back to empty lists when the returned maps are missing keys", async () => {
     build();
     vi.spyOn(component, "getTopLovedEntries")
+      .mockResolvedValueOnce({} as any)
       .mockResolvedValueOnce({} as any)
       .mockResolvedValueOnce({} as any);
 
@@ -241,6 +262,7 @@ describe("LandingPageComponent", () => {
     expect(component.topLovedWorkflows).toEqual([]);
     expect(component.topClonedWorkflows).toEqual([]);
     expect(component.topLovedDatasets).toEqual([]);
+    expect(component.topLovedModels).toEqual([]);
   });
 
   /**
@@ -250,23 +272,24 @@ describe("LandingPageComponent", () => {
    * unexercised. A swapped or deleted binding here is invisible to the tests above.
    */
   describe("rendered template", () => {
-    /** The two intro anchors, in template order: workflows then datasets. */
+    /** The three intro anchors, in template order: workflows, datasets then models. */
     function links() {
       const found = fixture.debugElement.queryAll(By.css("a"));
-      expect(found.length).toBe(2);
+      expect(found.length).toBe(3);
       return found;
     }
 
-    it("shows the workflow and dataset counts, each in its own link", () => {
+    it("shows the workflow, dataset and model counts, each in its own link", () => {
       build();
-      fixture.detectChanges(); // ngOnInit -> getWorkflowCount
+      fixture.detectChanges(); // ngOnInit -> loadCounts
 
-      // The two counts differ (42 vs 7), so a swapped interpolation cannot pass.
+      // The three counts differ (42 vs 7 vs 3), so a swapped interpolation cannot pass.
       expect(links()[0].nativeElement.textContent.trim()).toBe("42 workflows");
       expect(links()[1].nativeElement.textContent.trim()).toBe("7 datasets");
+      expect(links()[2].nativeElement.textContent.trim()).toBe("3 models");
     });
 
-    it("routes to the workflow hub from the first link and the dataset hub from the second", () => {
+    it("routes to the workflow, dataset and model hubs from the three links in order", () => {
       build();
       fixture.detectChanges();
 
@@ -275,6 +298,9 @@ describe("LandingPageComponent", () => {
 
       links()[1].triggerEventHandler("click", {});
       expect(routerNavigateSpy).toHaveBeenLastCalledWith([HUB_DATASET_RESULT]);
+
+      links()[2].triggerEventHandler("click", {});
+      expect(routerNavigateSpy).toHaveBeenLastCalledWith([HUB_MODEL_RESULT]);
     });
 
     it("hands each browse section its own entity list, title and viewer id", () => {
@@ -284,28 +310,33 @@ describe("LandingPageComponent", () => {
       const loved: DashboardEntry[] = [];
       const cloned: DashboardEntry[] = [];
       const lovedDatasets: DashboardEntry[] = [];
+      const lovedModels: DashboardEntry[] = [];
       component.topLovedWorkflows = loved;
       component.topClonedWorkflows = cloned;
       component.topLovedDatasets = lovedDatasets;
+      component.topLovedModels = lovedModels;
 
       fixture.detectChanges();
 
       const sections = fixture.debugElement
         .queryAll(By.directive(BrowseSectionComponent))
         .map(d => d.componentInstance as BrowseSectionComponent);
-      expect(sections.length).toBe(3);
+      expect(sections.length).toBe(4);
       expect(sections.map(s => s.sectionTitle)).toEqual([
         "Top Loved Workflows",
         "Top Cloned Workflows",
         "Top Loved Datasets",
+        "Top Loved Models",
       ]);
       expect(sections[0].entities).toBe(loved);
       expect(sections[1].entities).toBe(cloned);
       expect(sections[2].entities).toBe(lovedDatasets);
+      expect(sections[3].entities).toBe(lovedModels);
       expect(sections.map(s => s.currentUid)).toEqual([
         component.currentUid,
         component.currentUid,
         component.currentUid,
+        component.currentUid,
       ]);
     });
   });
diff --git a/frontend/src/app/hub/component/landing-page/landing-page.component.ts b/frontend/src/app/hub/component/landing-page/landing-page.component.ts
index 2f8a2fff7d..d0f706ee1d 100644
--- a/frontend/src/app/hub/component/landing-page/landing-page.component.ts
+++ b/frontend/src/app/hub/component/landing-page/landing-page.component.ts
@@ -24,7 +24,7 @@ import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy";
 import { Router } from "@angular/router";
 import { SearchService } from "../../../dashboard/service/user/search.service";
 import { DashboardEntry } from "../../../dashboard/type/dashboard-entry";
-import { HOME, HUB_DATASET_RESULT, HUB_WORKFLOW_RESULT } from "../../../app-routing.constant";
+import { HOME, HUB_DATASET_RESULT, HUB_MODEL_RESULT, HUB_WORKFLOW_RESULT } from "../../../app-routing.constant";
 import { UserService } from "../../../common/service/user/user.service";
 import { BrowseSectionComponent } from "../browse-section/browse-section.component";
 
@@ -40,9 +40,11 @@ export class LandingPageComponent implements OnInit {
   public currentUid = this.userService.getCurrentUser()?.uid;
   public workflowCount: number = 0;
   public datasetCount: number = 0;
+  public modelCount: number = 0;
   public topLovedWorkflows: DashboardEntry[] = [];
   public topClonedWorkflows: DashboardEntry[] = [];
   public topLovedDatasets: DashboardEntry[] = [];
+  public topLovedModels: DashboardEntry[] = [];
 
   constructor(
     private hubService: HubService,
@@ -60,26 +62,28 @@ export class LandingPageComponent implements OnInit {
   }
 
   ngOnInit(): void {
-    this.getWorkflowCount();
+    this.loadCounts();
     this.loadTops();
   }
 
   async loadTops() {
     try {
-      const [workflowEntries, datasetEntries] = await Promise.all([
+      const [workflowEntries, datasetEntries, modelEntries] = await Promise.all([
         this.getTopLovedEntries(EntityType.Workflow, [ActionType.Like, ActionType.Clone]),
         this.getTopLovedEntries(EntityType.Dataset, [ActionType.Like]),
+        this.getTopLovedEntries(EntityType.Model, [ActionType.Like]),
       ]);
 
       this.topLovedWorkflows = workflowEntries["like"] || [];
       this.topClonedWorkflows = workflowEntries["clone"] || [];
       this.topLovedDatasets = datasetEntries["like"] || [];
+      this.topLovedModels = modelEntries["like"] || [];
     } catch (error) {
       console.error("Failed to load top entries:", error);
     }
   }
 
-  getWorkflowCount(): void {
+  loadCounts(): void {
     this.hubService
       .getCount(EntityType.Workflow)
       .pipe(untilDestroyed(this))
@@ -92,6 +96,12 @@ export class LandingPageComponent implements OnInit {
       .subscribe((count: number) => {
         this.datasetCount = count;
       });
+    this.hubService
+      .getCount(EntityType.Model)
+      .pipe(untilDestroyed(this))
+      .subscribe((count: number) => {
+        this.modelCount = count;
+      });
   }
 
   public async getTopLovedEntries(
@@ -120,6 +130,9 @@ export class LandingPageComponent implements OnInit {
       case "dataset":
         path = HUB_DATASET_RESULT;
         break;
+      case "model":
+        path = HUB_MODEL_RESULT;
+        break;
       default:
         path = HOME;
     }
diff --git a/sql/changelog.xml b/sql/changelog.xml
index 8427de8170..2ca50a4f89 100644
--- a/sql/changelog.xml
+++ b/sql/changelog.xml
@@ -124,6 +124,11 @@
         <sqlFile path="sql/updates/42.sql"/>
     </changeSet>
 
+    <!-- Add model hub engagement tables (likes, view counts) and the model full-text index -->
+    <changeSet id="43" author="tanishqgandhi1908">
+        <sqlFile path="sql/updates/43.sql"/>
+    </changeSet>
+
     <!-- Form View: per-workflow default view (canvas or form) -->
     <changeSet id="44" author="yangzhang75">
         <sqlFile path="sql/updates/44.sql"/>
diff --git a/sql/texera_ddl.sql b/sql/texera_ddl.sql
index e4efa5277b..2448f95371 100644
--- a/sql/texera_ddl.sql
+++ b/sql/texera_ddl.sql
@@ -79,6 +79,8 @@ DROP TABLE IF EXISTS workflow_view_count CASCADE;
 DROP TABLE IF EXISTS user_action 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;
 DROP TABLE IF EXISTS notebook CASCADE;
@@ -624,6 +626,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
 (
@@ -688,7 +709,7 @@ BEGIN
   FOR r IN
     SELECT indexname FROM pg_indexes
     WHERE (indexdef ILIKE '%USING gin%' OR indexdef ILIKE '%USING pgroonga%')
-    AND tablename IN ('workflow', 'user', 'project', 'dataset', 'dataset_version')
+    AND tablename IN ('workflow', 'user', 'project', 'dataset', 'dataset_version', 'model')
   LOOP
     EXECUTE format('DROP INDEX IF EXISTS %I;', r.indexname);
   END LOOP;
@@ -718,12 +739,12 @@ BEGIN
            CASE
              WHEN tablename = 'workflow' THEN
                '(COALESCE(name, '''') || '' '' || COALESCE(description, '''') || '' '' || COALESCE(content, ''''))'
-             WHEN tablename IN ('project', 'dataset') THEN
+             WHEN tablename IN ('project', 'dataset', 'model') THEN
                '(COALESCE(name, '''') || '' '' || COALESCE(description, ''''))'
              ELSE
                'COALESCE(name, '''')'
            END AS index_column
-    FROM (VALUES ('workflow'), ('user'), ('project'), ('dataset'), ('dataset_version')) AS t(tablename)
+    FROM (VALUES ('workflow'), ('user'), ('project'), ('dataset'), ('dataset_version'), ('model')) AS t(tablename)
   LOOP
     -- Create PGroonga index with proper TokenFilterStem usage
     EXECUTE format(
diff --git a/sql/updates/43.sql b/sql/updates/43.sql
new file mode 100644
index 0000000000..5dc548e819
--- /dev/null
+++ b/sql/updates/43.sql
@@ -0,0 +1,78 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+\c texera_db
+
+SET search_path TO texera_db;
+
+BEGIN;
+
+-- Hub engagement tables for models, mirroring dataset_user_likes / dataset_view_count.
+
+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
+);
+
+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
+);
+
+COMMIT;
+
+-- Full-text index for models. texera_ddl.sql builds this for a fresh database; a database
+-- upgraded through the migrations needs it created here. The indexed expression must match
+-- the predicate ModelSearchQueryBuilder renders (name || ' ' || description) exactly, or the
+-- planner falls back to a sequential scan.
+CREATE EXTENSION IF NOT EXISTS pgroonga;
+
+DO $$
+DECLARE
+  index_expression TEXT := '((COALESCE(name, '''') || '' '' || COALESCE(description, '''')))';
+  plugin_status TEXT;
+BEGIN
+  WITH plugin_registration AS (
+    SELECT pgroonga_command('plugin_register token_filters/stem') AS result
+  )
+  SELECT
+    CASE
+      WHEN result::jsonb @> '[true]' THEN 'Plugin registered successfully'
+      ELSE 'Plugin registration failed'
+    END INTO plugin_status
+  FROM plugin_registration;
+
+  DROP INDEX IF EXISTS idx_model_pgroonga;
+
+  IF plugin_status = 'Plugin registered successfully' THEN
+    EXECUTE 'CREATE INDEX idx_model_pgroonga ON model USING pgroonga ' || index_expression ||
+            ' WITH (tokenizer = ''TokenMecab'', plugins=''token_filters/stem'''
+            ', token_filters=''TokenFilterStem'')';
+  ELSE
+    EXECUTE 'CREATE INDEX idx_model_pgroonga ON model USING pgroonga ' || index_expression ||
+            ' WITH (tokenizer = ''TokenMecab'')';
+  END IF;
+END $$;