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/08/28 23:07:15 UTC
(texera) 02/02: feat(frontend): add the model detail page (#8072)
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
commit b59c7406d0a7d549a50c1f86888e85563f2d3446
Author: Tanishq Gandhi <56...@users.noreply.github.com>
AuthorDate: Fri Aug 28 22:55:31 2026 +0000
feat(frontend): add the model detail page (#8072)
### What changes were proposed in this PR?
**Refactor.** `user-dataset-file-renderer`'s `did`/`dvid` inputs become
`resourceId`/`versionId`, so a model no longer has to smuggle its ids
through dataset-shaped names. `download` moves onto the resource
descriptor: `card-item` and `list-item` both carried a
`workflow`/`dataset` branch, and both now ask `descriptor.download`.
One deliberate behavior change: the branch read
`entry.workflow.workflow.name`, which a rename leaves stale, so
downloading a just-renamed workflow produced a zip named after the old
name. It now reads `entry.name`. Covered by a test.
**The model detail page.** A read-only page at `/user/model/:mid`: name,
created-at, public/downloadable/framework/format tags, placeholder view
and like counters, cover image; a Model Card tab; and a Versions & Files
tab with the version picker, file tree, file preview, and single-file
and version-ZIP downloads. `ModelService` gains the read endpoints,
`DownloadService` the three model downloads, and
`ModelResourceDescriptor` a `privateRoute` — so the cards added in #8068
now open something.
The file renderer takes a `resourceType` input, but resolves its fetch
through a new `retrieveSingleFile` descriptor slot rather than growing a
`type === "model"` arm, which is the point of the registry.
**No Settings tab yet, on purpose.** Metadata editing (name,
description, framework, format) lands with upload in the next PR;
visibility, sharing and covers in the one after.
**View and like counters read a constant `0`, deliberately.** The hub
backend has no model entity — `hub/EntityType.scala` is `Seq(Workflow,
Dataset)` with a throwing `fromString`, `EntityTables.forType` matches
only those two, and there are no `model_user_likes` / `model_view_count`
tables, so every hub call from this page would 400. The tags are there
so the header matches the dataset page; they get wired up in the hub PR,
alongside #7930.
<img width="807" height="647" alt="Screenshot 2026-08-28 at 11 19 16 AM"
src="https://github.com/user-attachments/assets/a22ec4e3-eccc-4a5d-bcb5-3fc5147921f6"
/>
<img width="1210" height="602" alt="Screenshot 2026-08-28 at 11 18
45 AM"
src="https://github.com/user-attachments/assets/eaabec50-7c93-4d36-aa67-11650e4500d7"
/>
<img width="1228" height="687" alt="Screenshot 2026-08-28 at 11 18
33 AM"
src="https://github.com/user-attachments/assets/8974de85-9633-4439-8a4d-4dec0d03e129"
/>
### Any related issues, documentation, discussions?
Part of #6499. Depends on #8068.
### How was this PR tested?
New: 26 specs for the detail page, 7 for the new `ModelService` methods,
3 for the model downloads, 2 for the renderer's model branch, plus
registry coverage for `download` and `retrieveSingleFile`.
The refactor's proof is that all 353 existing `user-dataset` specs pass
unmodified. Four specs elsewhere needed scaffolding changes, none of
them changed expectations: three `card-item` template tests set `entry`
without running the input pipeline and now call `initializeEntry()` (a
pattern already used in that file), and one `list-item` test moved from
`(component as any).downloadService` to
`TestBed.inject(DownloadService)`,since the component no longer injects
it.
Full dashboard and hub suite: 1880 tests, all passing. Verified manually
against a local stack.
### Was this PR authored or co-authored using generative AI tooling?
Generated-by: Claude Code
---
frontend/src/app/app-routing.module.ts | 5 +
frontend/src/app/common/type/model.ts | 12 +
.../list-item/card-item/card-item.component.html | 2 +-
.../card-item/card-item.component.spec.ts | 5 +-
.../list-item/card-item/card-item.component.ts | 17 +-
.../user/list-item/list-item.component.html | 2 +-
.../user/list-item/list-item.component.spec.ts | 26 +-
.../user/list-item/list-item.component.ts | 17 +-
.../dataset-detail.component.html | 4 +-
.../user-dataset-file-renderer.component.spec.ts | 67 ++-
.../user-dataset-file-renderer.component.ts | 21 +-
.../model-detail.component.html | 357 ++++++++++++++++
.../model-detail.component.scss | 296 +++++++++++++
.../model-detail.component.spec.ts | 473 +++++++++++++++++++++
.../user-model-explorer/model-detail.component.ts | 353 +++++++++++++++
.../service/user/download/download.service.spec.ts | 45 ++
.../service/user/download/download.service.ts | 39 ++
.../service/user/model/model.service.spec.ts | 70 +++
.../dashboard/service/user/model/model.service.ts | 66 ++-
.../dataset-resource.descriptor.ts | 9 +-
.../resource-registry/model-resource.descriptor.ts | 14 +-
.../resource-registry.service.spec.ts | 42 +-
.../workflow-resource.descriptor.ts | 7 +-
.../src/app/dashboard/type/resource-descriptor.ts | 4 +
24 files changed, 1882 insertions(+), 71 deletions(-)
diff --git a/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts
index 6acbccebf1..cd936421d9 100644
--- a/frontend/src/app/app-routing.module.ts
+++ b/frontend/src/app/app-routing.module.ts
@@ -40,6 +40,7 @@ import { AdminGmailComponent } from "./dashboard/component/admin/gmail/admin-gma
import { DatasetDetailComponent } from "./dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component";
import { UserDatasetComponent } from "./dashboard/component/user/user-dataset/user-dataset.component";
import { UserModelComponent } from "./dashboard/component/user/user-model/user-model.component";
+import { ModelDetailComponent } from "./dashboard/component/user/user-model/user-model-explorer/model-detail.component";
import { HubWorkflowDetailComponent } from "./hub/component/workflow/detail/hub-workflow-detail.component";
import { LandingPageComponent } from "./hub/component/landing-page/landing-page.component";
import { USER_WORKFLOW } from "./app-routing.constant";
@@ -140,6 +141,10 @@ routes.push({
path: "model",
component: UserModelComponent,
},
+ {
+ path: "model/:mid",
+ component: ModelDetailComponent,
+ },
{
path: "compute",
component: UserComputingUnitComponent,
diff --git a/frontend/src/app/common/type/model.ts b/frontend/src/app/common/type/model.ts
index 05e979249d..2df770b624 100644
--- a/frontend/src/app/common/type/model.ts
+++ b/frontend/src/app/common/type/model.ts
@@ -17,6 +17,8 @@
* under the License.
*/
+import { DatasetFileNode } from "./datasetVersionFileTree";
+
export interface Model {
mid: number | undefined;
ownerUid: number | undefined;
@@ -30,3 +32,13 @@ export interface Model {
framework: string | undefined;
format: string | undefined;
}
+
+export interface ModelVersion {
+ mvid: number | undefined;
+ mid: number;
+ creatorUid: number;
+ name: string;
+ versionHash: string | undefined;
+ creationTime: number | undefined;
+ fileNodes: DatasetFileNode[] | undefined;
+}
diff --git a/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.html b/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.html
index c2e112601c..3846055a1f 100644
--- a/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.html
+++ b/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.html
@@ -232,7 +232,7 @@
nzType="text"
class="action-btn"
title="Download"
- *ngIf="entry.type === 'workflow' || entry.type === 'dataset'"
+ *ngIf="canDownload"
(click)="onClickDownload(); $event.stopPropagation()">
<i
nz-icon
diff --git a/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.spec.ts b/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.spec.ts
index fbc393d345..ea0bc05524 100644
--- a/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.spec.ts
+++ b/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.spec.ts
@@ -689,7 +689,7 @@ describe("CardItemComponent", () => {
it("onClickDownload downloads a workflow via the download service", () => {
const downloadService = TestBed.inject(DownloadService);
const downloadWorkflowSpy = vi.spyOn(downloadService, "downloadWorkflow").mockReturnValue(of({} as any));
- component.entry = makeWorkflowEntry({ id: 7, workflow: { isOwner: true, workflow: { name: "myflow" } } } as any);
+ component.entry = makeWorkflowEntry({ id: 7, name: "myflow" });
component.onClickDownload();
@@ -909,6 +909,7 @@ describe("CardItemComponent", () => {
component.entry = makeWorkflowEntry();
component.isPrivateSearch = true;
component.currentUid = 1;
+ component.initializeEntry(); // the Download button reads a per-kind capability off the entry
fixture.detectChanges();
const de = fixture.debugElement;
@@ -927,6 +928,7 @@ describe("CardItemComponent", () => {
component.entry = makeWorkflowEntry();
component.isPrivateSearch = true;
component.currentUid = 1;
+ component.initializeEntry();
fixture.detectChanges();
const detailSpy = vi.spyOn(component, "openDetailModal").mockImplementation(() => {});
@@ -1029,6 +1031,7 @@ describe("CardItemComponent", () => {
it("shows Download but hides Detail/Copy/checkbox for a dataset in private mode", () => {
component.entry = makeDatasetEntry();
component.isPrivateSearch = true;
+ component.initializeEntry();
fixture.detectChanges();
const de = fixture.debugElement;
diff --git a/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.ts b/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.ts
index 7061cd9050..5b3dfe16a5 100644
--- a/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.ts
+++ b/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.ts
@@ -46,7 +46,6 @@ import { WorkflowPersistService } from "src/app/common/service/workflow-persist/
import { firstValueFrom } from "rxjs";
import { HubWorkflowDetailComponent } from "../../../../../hub/component/workflow/detail/hub-workflow-detail.component";
import { ActionType, HubService } from "../../../../../hub/service/hub.service";
-import { DownloadService } from "src/app/dashboard/service/user/download/download.service";
import { formatSize } from "src/app/common/util/size-formatter.util";
import { formatRelativeTime, formatCount } from "src/app/common/util/format.util";
import { DatasetService } from "../../../../service/user/dataset/dataset.service";
@@ -78,6 +77,7 @@ export class CardItemComponent implements OnChanges {
public originalName: string = "";
public originalDescription: string | undefined = undefined;
public disableDelete: boolean = false;
+ public canDownload: boolean = false;
@Input() currentUid: number | undefined;
@ViewChild("nameInput") nameInput!: ElementRef;
@ViewChild("descriptionInput") descriptionInput!: ElementRef;
@@ -125,7 +125,6 @@ export class CardItemComponent implements OnChanges {
private datasetService: DatasetService,
private modal: NzModalService,
private hubService: HubService,
- private downloadService: DownloadService,
private cdr: ChangeDetectorRef,
private notificationService: NotificationService,
private workflowCoverService: WorkflowCoverService,
@@ -191,6 +190,7 @@ export class CardItemComponent implements OnChanges {
const descriptor = this.resourceRegistry.get(this.entry.type);
this.iconType = descriptor.iconType;
this.disableDelete = !descriptor.isOwner(this.entry);
+ this.canDownload = descriptor.download !== undefined;
this.entryLink = this.resourceRegistry.entryLink(this.entry, this.currentUid);
if (descriptor.hasSize && typeof this.entry.id === "number") {
this.size = this.entry.size;
@@ -285,16 +285,9 @@ export class CardItemComponent implements OnChanges {
}
public onClickDownload = (): void => {
- if (!this.entry.id) return;
-
- if (this.entry.type === "workflow") {
- this.downloadService
- .downloadWorkflow(this.entry.id, this.entry.workflow.workflow.name)
- .pipe(untilDestroyed(this))
- .subscribe();
- } else if (this.entry.type === "dataset") {
- this.downloadService.downloadDataset(this.entry.id, this.entry.name).pipe(untilDestroyed(this)).subscribe();
- }
+ const download = this.resourceRegistry.get(this.entry.type).download;
+ if (!this.entry.id || !download) return;
+ download(this.entry.id, this.entry.name).pipe(untilDestroyed(this)).subscribe();
};
onEditName(): void {
diff --git a/frontend/src/app/dashboard/component/user/list-item/list-item.component.html b/frontend/src/app/dashboard/component/user/list-item/list-item.component.html
index 7f75f16fe2..a223afe6e4 100644
--- a/frontend/src/app/dashboard/component/user/list-item/list-item.component.html
+++ b/frontend/src/app/dashboard/component/user/list-item/list-item.component.html
@@ -216,7 +216,7 @@
<button
nz-button
nzType="text"
- *ngIf="entry.type === 'workflow' || entry.type === 'dataset'"
+ *ngIf="canDownload"
title="Download"
(click)="onClickDownload()">
<i
diff --git a/frontend/src/app/dashboard/component/user/list-item/list-item.component.spec.ts b/frontend/src/app/dashboard/component/user/list-item/list-item.component.spec.ts
index eb38647b7f..5128396243 100644
--- a/frontend/src/app/dashboard/component/user/list-item/list-item.component.spec.ts
+++ b/frontend/src/app/dashboard/component/user/list-item/list-item.component.spec.ts
@@ -18,6 +18,7 @@
*/
import { ComponentFixture, TestBed } from "@angular/core/testing";
+import { DownloadService } from "src/app/dashboard/service/user/download/download.service";
import { By } from "@angular/platform-browser";
import { ListItemComponent } from "./list-item.component";
import {
@@ -566,10 +567,8 @@ describe("ListItemComponent", () => {
describe("download", () => {
it("downloads a workflow by id and name", () => {
- const download = vi
- .spyOn((component as any).downloadService, "downloadWorkflow")
- .mockReturnValue(of(undefined));
- feed(entryOf({ type: "workflow", workflow: { isOwner: true, workflow: { name: "flow" } } }));
+ const download = vi.spyOn(TestBed.inject(DownloadService), "downloadWorkflow").mockReturnValue(of({} as any));
+ feed(entryOf({ type: "workflow", name: "flow", workflow: { isOwner: true } }));
component.onClickDownload();
@@ -577,7 +576,7 @@ describe("ListItemComponent", () => {
});
it("downloads a dataset by id and name", () => {
- const download = vi.spyOn((component as any).downloadService, "downloadDataset").mockReturnValue(of(undefined));
+ const download = vi.spyOn(TestBed.inject(DownloadService), "downloadDataset").mockReturnValue(of(new Blob()));
feed(entryOf({ type: "dataset", dataset: { isOwner: true }, name: "set" }));
component.onClickDownload();
@@ -585,8 +584,23 @@ describe("ListItemComponent", () => {
expect(download).toHaveBeenCalledWith(7, "set");
});
+ it("downloads a renamed workflow under its new name", () => {
+ // The rename writes entry.name and leaves entry.workflow.workflow.name stale, so
+ // reading the payload here used to name the zip after the pre-rename workflow.
+ (workflowPersistService as any).updateWorkflowName.mockReturnValue(of({} as Response));
+ const download = vi.spyOn(TestBed.inject(DownloadService), "downloadWorkflow").mockReturnValue(of({} as any));
+ feed(
+ entryOf({ type: "workflow", name: "old-name", workflow: { isOwner: true, workflow: { name: "old-name" } } })
+ );
+
+ component.confirmUpdateCustomName("new-name");
+ component.onClickDownload();
+
+ expect(download).toHaveBeenCalledWith(7, "new-name");
+ });
+
it("downloads nothing for an entry that was never persisted", () => {
- const workflow = vi.spyOn((component as any).downloadService, "downloadWorkflow");
+ const workflow = vi.spyOn(TestBed.inject(DownloadService), "downloadWorkflow");
feed(entryOf({ type: "file", id: 0 }));
component.onClickDownload();
diff --git a/frontend/src/app/dashboard/component/user/list-item/list-item.component.ts b/frontend/src/app/dashboard/component/user/list-item/list-item.component.ts
index d141911596..4e7227e6ea 100644
--- a/frontend/src/app/dashboard/component/user/list-item/list-item.component.ts
+++ b/frontend/src/app/dashboard/component/user/list-item/list-item.component.ts
@@ -37,7 +37,6 @@ import { WorkflowPersistService } from "src/app/common/service/workflow-persist/
import { firstValueFrom } from "rxjs";
import { HubWorkflowDetailComponent } from "../../../../hub/component/workflow/detail/hub-workflow-detail.component";
import { ActionType, HubService } from "../../../../hub/service/hub.service";
-import { DownloadService } from "src/app/dashboard/service/user/download/download.service";
import { formatSize } from "src/app/common/util/size-formatter.util";
import { formatCount, formatRelativeTime } from "src/app/common/util/format.util";
import { DatasetService } from "../../../service/user/dataset/dataset.service";
@@ -84,6 +83,7 @@ export class ListItemComponent implements OnChanges {
public originalName: string = "";
public originalDescription: string | undefined = undefined;
public disableDelete: boolean = false;
+ public canDownload: boolean = false;
@Input() currentUid: number | undefined;
@ViewChild("nameInput") nameInput!: ElementRef;
@ViewChild("descriptionInput") descriptionInput!: ElementRef;
@@ -125,7 +125,6 @@ export class ListItemComponent implements OnChanges {
private datasetService: DatasetService,
private modal: NzModalService,
private hubService: HubService,
- private downloadService: DownloadService,
private cdr: ChangeDetectorRef,
private notificationService: NotificationService,
private resourceRegistry: ResourceRegistryService
@@ -135,6 +134,7 @@ export class ListItemComponent implements OnChanges {
const descriptor = this.resourceRegistry.get(this.entry.type);
this.iconType = descriptor.iconType;
this.disableDelete = !descriptor.isOwner(this.entry);
+ this.canDownload = descriptor.download !== undefined;
this.entryLink = this.resourceRegistry.entryLink(this.entry, this.currentUid);
if (descriptor.hasSize && typeof this.entry.id === "number") {
this.size = this.entry.size;
@@ -211,16 +211,9 @@ export class ListItemComponent implements OnChanges {
}
public onClickDownload = (): void => {
- if (!this.entry.id) return;
-
- if (this.entry.type === "workflow") {
- this.downloadService
- .downloadWorkflow(this.entry.id, this.entry.workflow.workflow.name)
- .pipe(untilDestroyed(this))
- .subscribe();
- } else if (this.entry.type === "dataset") {
- this.downloadService.downloadDataset(this.entry.id, this.entry.name).pipe(untilDestroyed(this)).subscribe();
- }
+ const download = this.resourceRegistry.get(this.entry.type).download;
+ if (!this.entry.id || !download) return;
+ download(this.entry.id, this.entry.name).pipe(untilDestroyed(this)).subscribe();
};
onEditName(): void {
diff --git a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.html b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.html
index d0595642aa..f47b8d43f9 100644
--- a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.html
+++ b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.html
@@ -360,8 +360,8 @@
<texera-user-dataset-file-renderer
*ngIf="selectedVersion"
[isMaximized]="isMaximized"
- [did]="did"
- [dvid]="selectedVersion.dvid"
+ [resourceId]="did"
+ [versionId]="selectedVersion.dvid"
[filePath]="currentDisplayedFileName"
[fileSize]="currentFileSize"
[isLogin]="isLogin"
diff --git a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-file-renderer/user-dataset-file-renderer.component.spec.ts b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-file-renderer/user-dataset-file-renderer.component.spec.ts
index 0aadb792dd..e5e2a6b2ad 100644
--- a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-file-renderer/user-dataset-file-renderer.component.spec.ts
+++ b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-file-renderer/user-dataset-file-renderer.component.spec.ts
@@ -21,6 +21,8 @@ import { ComponentFixture, TestBed } from "@angular/core/testing";
import { HttpClientTestingModule } from "@angular/common/http/testing";
import { getMimeType, MIME_TYPES, UserDatasetFileRendererComponent } from "./user-dataset-file-renderer.component";
import { DatasetService } from "../../../../../service/user/dataset/dataset.service";
+import { ModelService } from "../../../../../service/user/model/model.service";
+import { EntityType } from "../../../../../../hub/service/hub.service";
import { NotificationService } from "../../../../../../common/service/notification/notification.service";
import { DomSanitizer } from "@angular/platform-browser";
import { commonTestProviders } from "../../../../../../common/testing/test-utils";
@@ -39,6 +41,7 @@ describe("UserDatasetFileRendererComponent", () => {
imports: [UserDatasetFileRendererComponent, HttpClientTestingModule],
providers: [
DatasetService,
+ ModelService,
NotificationService,
{ provide: DomSanitizer, useValue: { bypassSecurityTrustUrl: vi.fn() } },
...commonTestProviders,
@@ -64,9 +67,9 @@ describe("UserDatasetFileRendererComponent", () => {
describe("reloadFileContent", () => {
it("flags an unsupported file type and does not hit the backend", () => {
const spy = vi.spyOn(TestBed.inject(DatasetService), "retrieveDatasetVersionSingleFile");
- // did/dvid are set so the early-return is what stops the request, not the missing ids.
- component.did = 1;
- component.dvid = 2;
+ // The ids are set so the early-return is what stops the request, not the missing ids.
+ component.resourceId = 1;
+ component.versionId = 2;
component.filePath = "archive.bin"; // -> OCTET_STREAM -> unsupported
component.reloadFileContent();
@@ -77,8 +80,8 @@ describe("UserDatasetFileRendererComponent", () => {
it("flags an oversized file and does not hit the backend", () => {
const spy = vi.spyOn(TestBed.inject(DatasetService), "retrieveDatasetVersionSingleFile");
- component.did = 1;
- component.dvid = 2;
+ component.resourceId = 1;
+ component.versionId = 2;
component.filePath = "notes.txt"; // TXT limit is 1 MB
component.fileSize = 5 * 1024 * 1024;
@@ -92,8 +95,8 @@ describe("UserDatasetFileRendererComponent", () => {
const datasetService = TestBed.inject(DatasetService);
const blob = new Blob(["hello"], { type: "text/plain" });
const spy = vi.spyOn(datasetService, "retrieveDatasetVersionSingleFile").mockReturnValue(of(blob));
- component.did = 1;
- component.dvid = 2;
+ component.resourceId = 1;
+ component.versionId = 2;
component.filePath = "notes.txt";
component.isLogin = true;
component.fileSize = 100;
@@ -104,6 +107,35 @@ describe("UserDatasetFileRendererComponent", () => {
expect(component.displayPlainText).toBe(true);
expect(component.isLoading).toBe(false);
});
+
+ it("fetches from the model endpoint when the file belongs to a model", () => {
+ const datasetSpy = vi.spyOn(TestBed.inject(DatasetService), "retrieveDatasetVersionSingleFile");
+ const modelSpy = vi
+ .spyOn(TestBed.inject(ModelService), "retrieveModelVersionSingleFile")
+ .mockReturnValue(of(new Blob(["weights"], { type: "text/plain" })));
+ component.resourceType = EntityType.Model;
+ component.resourceId = 1;
+ component.versionId = 2;
+ component.filePath = "/model/a/m/v1/notes.txt";
+ component.isLogin = true;
+
+ component.reloadFileContent();
+
+ expect(modelSpy).toHaveBeenCalledWith("/model/a/m/v1/notes.txt", true);
+ expect(datasetSpy).not.toHaveBeenCalled();
+ });
+
+ it("hits nothing for a kind that has no file preview", () => {
+ const datasetSpy = vi.spyOn(TestBed.inject(DatasetService), "retrieveDatasetVersionSingleFile");
+ component.resourceType = EntityType.Workflow;
+ component.resourceId = 1;
+ component.versionId = 2;
+ component.filePath = "notes.txt";
+
+ component.reloadFileContent();
+
+ expect(datasetSpy).not.toHaveBeenCalled();
+ });
});
describe("error handlers", () => {
@@ -255,15 +287,15 @@ describe("UserDatasetFileRendererComponent", () => {
expect(reloadSpy).toHaveBeenCalledTimes(1);
});
- it("reloads when both did and dvid change together", () => {
+ it("reloads when both resourceId and versionId change together", () => {
const reloadSpy = vi.spyOn(component, "reloadFileContent").mockImplementation(() => {});
- component.ngOnChanges({ did: chg(1, 2), dvid: chg(3, 4) } as SimpleChanges);
+ component.ngOnChanges({ resourceId: chg(1, 2), versionId: chg(3, 4) } as SimpleChanges);
expect(reloadSpy).toHaveBeenCalledTimes(1);
});
- it("does not reload when only did changes without dvid", () => {
+ it("does not reload when only resourceId changes without versionId", () => {
const reloadSpy = vi.spyOn(component, "reloadFileContent").mockImplementation(() => {});
- component.ngOnChanges({ did: chg(1, 2) } as SimpleChanges);
+ component.ngOnChanges({ resourceId: chg(1, 2) } as SimpleChanges);
expect(reloadSpy).not.toHaveBeenCalled();
});
@@ -328,8 +360,8 @@ describe("UserDatasetFileRendererComponent", () => {
function loadWith(filePath: string, blob: Blob) {
const datasetService = TestBed.inject(DatasetService);
vi.spyOn(datasetService, "retrieveDatasetVersionSingleFile").mockReturnValue(of(blob));
- component.did = 1;
- component.dvid = 2;
+ component.resourceId = 1;
+ component.versionId = 2;
component.filePath = filePath;
component.isLogin = false;
component.fileSize = undefined;
@@ -422,8 +454,8 @@ describe("UserDatasetFileRendererComponent", () => {
const spy = vi.spyOn(datasetService, "retrieveDatasetVersionSingleFile");
// A supported, in-limit file, so the two pre-checks pass and the id guard is the
// only thing left to stop the request.
- component.did = undefined;
- component.dvid = 2;
+ component.resourceId = undefined;
+ component.versionId = 2;
component.filePath = "notes.txt";
component.fileSize = 100;
@@ -568,8 +600,8 @@ describe("UserDatasetFileRendererComponent", () => {
vi.spyOn(datasetService, "retrieveDatasetVersionSingleFile").mockReturnValue(
of(new Blob(["ignored"], { type: MIME_TYPES.CSV }))
);
- component.did = 1;
- component.dvid = 2;
+ component.resourceId = 1;
+ component.versionId = 2;
component.filePath = "data.csv";
component.fileSize = undefined;
component.reloadFileContent();
@@ -616,6 +648,7 @@ describe("UserDatasetFileRendererComponent rendering", () => {
imports: [UserDatasetFileRendererComponent, HttpClientTestingModule, MarkdownModule.forRoot()],
providers: [
DatasetService,
+ ModelService,
NotificationService,
{
provide: DomSanitizer,
diff --git a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-file-renderer/user-dataset-file-renderer.component.ts b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-file-renderer/user-dataset-file-renderer.component.ts
index 861479ca5a..7a0886ff4f 100644
--- a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-file-renderer/user-dataset-file-renderer.component.ts
+++ b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-file-renderer/user-dataset-file-renderer.component.ts
@@ -18,7 +18,8 @@
*/
import { Component, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output, SimpleChanges } from "@angular/core";
-import { DatasetService } from "../../../../../service/user/dataset/dataset.service";
+import { ResourceRegistryService } from "../../../../../service/user/resource-registry/resource-registry.service";
+import { EntityType } from "../../../../../../hub/service/hub.service";
import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy";
import * as Papa from "papaparse";
import { ParseResult } from "papaparse";
@@ -145,11 +146,15 @@ export class UserDatasetFileRendererComponent implements OnInit, OnChanges, OnDe
@Input()
isMaximized: boolean = false;
+ // Which kind of resource the file belongs to; the registry turns it into the fetch.
@Input()
- did: number | undefined;
+ resourceType: EntityType = EntityType.Dataset;
@Input()
- dvid: number | undefined;
+ resourceId: number | undefined;
+
+ @Input()
+ versionId: number | undefined;
@Input()
filePath: string = "";
@@ -164,7 +169,7 @@ export class UserDatasetFileRendererComponent implements OnInit, OnChanges, OnDe
loadFile = new EventEmitter<{ file: string; prefix: string }>();
constructor(
- private datasetService: DatasetService,
+ private resourceRegistry: ResourceRegistryService,
private sanitizer: DomSanitizer,
private notificationService: NotificationService
) {}
@@ -174,7 +179,7 @@ export class UserDatasetFileRendererComponent implements OnInit, OnChanges, OnDe
}
ngOnChanges(changes: SimpleChanges): void {
- if ((changes.did && changes.dvid) || changes.filePath) {
+ if ((changes.resourceId && changes.versionId) || changes.filePath) {
this.reloadFileContent();
}
}
@@ -208,9 +213,9 @@ export class UserDatasetFileRendererComponent implements OnInit, OnChanges, OnDe
// Load file
this.isLoading = true;
- if (this.did && this.dvid && this.filePath != "") {
- this.datasetService
- .retrieveDatasetVersionSingleFile(this.filePath, this.isLogin)
+ const retrieveSingleFile = this.resourceRegistry.get(this.resourceType).retrieveSingleFile;
+ if (retrieveSingleFile && this.resourceId && this.versionId && this.filePath != "") {
+ retrieveSingleFile(this.filePath, this.isLogin)
.pipe(untilDestroyed(this))
.subscribe({
next: blob => {
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
new file mode 100644
index 0000000000..e394517d29
--- /dev/null
+++ b/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.html
@@ -0,0 +1,357 @@
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements. See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership. The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing,
+ software distributed under the License is distributed on an
+ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ KIND, either express or implied. See the License for the
+ specific language governing permissions and limitations
+ under the License.
+-->
+
+<div *ngIf="!isMaximized">
+ <nz-card>
+ <div class="model-header">
+ <div class="model-header-meta">
+ <h2>Model: {{modelName}}</h2>
+ <nz-card-meta
+ style="padding-top: 5px"
+ [nzDescription]="descriptionTemplate">
+ <ng-template #descriptionTemplate>
+ Created at:
+ <span
+ nz-tooltip
+ [nzTooltipTitle]="modelCreationTimeTooltip">
+ {{modelCreationTime}}
+ </span>
+ </ng-template>
+ </nz-card-meta>
+
+ <div class="status-tag-row">
+ <nz-tag
+ class="status-tag"
+ [ngClass]="{ 'tag-public': modelIsPublic }">
+ <i
+ nz-icon
+ [nzType]="modelIsPublic ? 'global' : 'lock'"
+ nzTheme="outline"></i>
+ {{ modelIsPublic ? "Public" : "Private" }}
+ </nz-tag>
+
+ <nz-tag
+ class="status-tag"
+ [ngClass]="{ 'tag-downloadable': modelIsDownloadable }">
+ <i
+ nz-icon
+ [nzType]="modelIsDownloadable ? 'download' : 'stop'"
+ nzTheme="outline"></i>
+ {{ modelIsDownloadable ? "Downloadable" : "Download restricted" }}
+ </nz-tag>
+
+ <nz-tag
+ *ngIf="modelFramework"
+ class="status-tag tag-framework">
+ <i
+ nz-icon
+ nzType="code"
+ nzTheme="outline"></i>
+ {{ modelFramework }}
+ </nz-tag>
+
+ <nz-tag
+ *ngIf="modelFormat"
+ class="status-tag tag-format">
+ <i
+ nz-icon
+ nzType="file-text"
+ nzTheme="outline"></i>
+ {{ modelFormat }}
+ </nz-tag>
+
+ <nz-tag class="status-tag">
+ <i
+ nz-icon
+ nzType="eye"
+ nzTheme="outline"></i>
+ {{ formatCount(viewCount) }}
+ </nz-tag>
+
+ <nz-tag class="status-tag">
+ <i
+ nz-icon
+ nzType="like"
+ nzTheme="outline"></i>
+ {{ formatCount(likeCount) }}
+ </nz-tag>
+ </div>
+ </div>
+ <img
+ *ngIf="coverImageUrl"
+ class="model-cover-image"
+ [src]="coverImageUrl"
+ alt="Model cover" />
+ </div>
+ </nz-card>
+</div>
+<nz-tabs>
+ <nz-tab nzTitle="Model Card">
+ <div class="data-card-tab-content">
+ <div class="data-card-columns">
+ <nz-card class="data-card data-card-main">
+ <h3 class="data-card-heading">Description</h3>
+ <div class="data-card-description">
+ <texera-markdown-description
+ *ngIf="modelDescription; else noDescription"
+ [description]="modelDescription"
+ [editable]="false"
+ [enableViewMore]="true">
+ </texera-markdown-description>
+ <ng-template #noDescription>
+ <span class="empty-description">No description provided</span>
+ </ng-template>
+ </div>
+ </nz-card>
+
+ <nz-card class="data-card data-card-details">
+ <div class="data-card-stats">
+ <div class="stat-row">
+ <span class="stat-label">Created</span>
+ <span class="stat-value">{{ modelCreationTime }}</span>
+ </div>
+ <div class="stat-row">
+ <span class="stat-label">Last updated</span>
+ <span class="stat-value">{{ latestVersionCreationTime || "—" }}</span>
+ </div>
+ <div class="stat-row">
+ <span class="stat-label">Versions</span>
+ <span class="stat-value">{{ versions.length }}</span>
+ </div>
+ <div class="stat-row">
+ <span class="stat-label">Framework</span>
+ <span class="stat-value">{{ modelFramework || "—" }}</span>
+ </div>
+ <div class="stat-row">
+ <span class="stat-label">Format</span>
+ <span class="stat-value">{{ modelFormat || "—" }}</span>
+ </div>
+ <div class="stat-row">
+ <span class="stat-label">Latest version file</span>
+ <span class="stat-value">{{ latestVersionFileName || "—" }}</span>
+ </div>
+ <div class="stat-row">
+ <span class="stat-label">Latest version size</span>
+ <span class="stat-value"
+ >{{ latestVersionSize === undefined ? "—" : formatSize(latestVersionSize) }}</span
+ >
+ </div>
+ </div>
+ </nz-card>
+ </div>
+ </div>
+ </nz-tab>
+ <nz-tab nzTitle="Versions & Files">
+ <div class="versions-tab-content">
+ <nz-layout>
+ <nz-content style="background-color: white">
+ <nz-card>
+ <div style="display: flex; justify-content: space-between; align-items: center">
+ <div class="file-info">
+ <h3 class="file-title">
+ <span class="file-title-main">
+ <b>{{ currentDisplayedFileName }}</b>
+
+ <button
+ nz-button
+ nzType="text"
+ nzSize="small"
+ class="copy-path-btn"
+ nz-tooltip
+ nzTooltipTitle="Copy file path"
+ *ngIf="currentDisplayedFileName"
+ (click)="copyCurrentFilePath()">
+ <i
+ nz-icon
+ nzType="copy"
+ nzTheme="twotone">
+ </i>
+ </button>
+ </span>
+
+ <span
+ *ngIf="currentFileSize"
+ class="file-size">
+ <i
+ nz-icon
+ nzType="file"
+ nzTheme="outline"
+ class="icon-file"></i>
+ {{ formatSize(currentFileSize) }}
+ </span>
+ </h3>
+ </div>
+ <div style="display: flex">
+ <button
+ nz-button
+ *ngIf="selectedVersion"
+ nz-tooltip="Download the file"
+ [disabled]="!isLogin || !isDownloadAllowed()"
+ (click)="onClickDownloadCurrentFile()">
+ <i
+ nz-icon
+ nzTheme="outline"
+ nzType="download">
+ </i>
+ </button>
+ <button
+ nz-button
+ *ngIf="!isMaximized && selectedVersion"
+ nz-tooltip="Maximize View"
+ (click)="onClickScaleTheView()">
+ <i
+ nz-icon
+ nzTheme="outline"
+ nzType="expand">
+ </i>
+ </button>
+ <button
+ nz-button
+ *ngIf="isMaximized && selectedVersion"
+ nz-tooltip="Minimize View"
+ (click)="onClickScaleTheView()">
+ <i
+ nz-icon
+ nzTheme="outline"
+ nzType="compress">
+ </i>
+ </button>
+ <button
+ *ngIf="!isRightBarCollapsed"
+ nz-button
+ nz-tooltip="Hide the right bar"
+ (click)="onClickHideRightBar()">
+ <i
+ nz-icon
+ nzTheme="outline"
+ nzType="right">
+ </i>
+ </button>
+ <button
+ *ngIf="isRightBarCollapsed"
+ nz-button
+ nz-tooltip="Show Tree"
+ (click)="onClickHideRightBar()">
+ <i
+ nz-icon
+ nzTheme="outline"
+ nzType="left">
+ </i>
+ </button>
+ </div>
+ </div>
+ </nz-card>
+ <nz-empty
+ class="empty-version-indicator"
+ *ngIf="!selectedVersion"
+ nzNotFoundContent="This model has no version yet"></nz-empty>
+
+ <texera-user-dataset-file-renderer
+ *ngIf="selectedVersion"
+ [isMaximized]="isMaximized"
+ [resourceType]="modelEntityType"
+ [resourceId]="mid"
+ [versionId]="selectedVersion.mvid"
+ [filePath]="currentDisplayedFileName"
+ [fileSize]="currentFileSize"
+ [isLogin]="isLogin"
+ class="file-renderer">
+ </texera-user-dataset-file-renderer>
+ </nz-content>
+ <nz-sider
+ *ngIf="!isRightBarCollapsed"
+ nzTheme="light"
+ [nzWidth]="siderWidth"
+ nz-resizable
+ [nzMinWidth]="MIN_SIDER_WIDTH"
+ [nzMaxWidth]="MAX_SIDER_WIDTH"
+ (nzResize)="onSideResize($event)">
+ <nz-resize-handle nzDirection="left">
+ <div class="sider-resize-line">
+ <i
+ class="sider-resize-handle"
+ nz-icon
+ nzType="more"
+ nzTheme="outline"></i>
+ </div>
+ </nz-resize-handle>
+ <div class="right-sider">
+ <nz-collapse nzGhost>
+ <nz-collapse-panel
+ nzHeader="Current Versions"
+ nzActive="true">
+ <div class="version-storage">
+ <h6 style="font-weight: lighter; font-size: 0.9em">Choose a Version:</h6>
+ <div class="select-and-button-container">
+ <nz-select
+ nzShowSearch
+ nzPlaceHolder="Select a version"
+ (ngModelChange)="onVersionSelected($event)"
+ [(ngModel)]="selectedVersion">
+ <nz-option
+ *ngFor="let version of versions"
+ [nzValue]="version"
+ [nzLabel]="version.name"></nz-option>
+ </nz-select>
+ <button
+ nz-button
+ nz-tooltip="Download this version as ZIP"
+ (click)="onClickDownloadVersionAsZip()"
+ *ngIf="selectedVersion"
+ [disabled]="!isLogin || !isDownloadAllowed()"
+ class="spaced-button">
+ <i
+ nz-icon
+ nzType="download"
+ nzTheme="outline"></i>
+ </button>
+ </div>
+ <ng-container *ngIf="selectedVersion">
+ <div class="version-size">
+ <i
+ nz-icon
+ nzType="database"
+ nzTheme="outline"
+ class="icon-database"></i>
+ Version Size: {{ formatSize(currentModelVersionSize) }}
+ </div>
+ <div
+ *ngIf="selectedVersionCreationTime"
+ class="version-date">
+ <i
+ nz-icon
+ nzType="calendar"
+ nzTheme="outline"
+ class="icon-database"></i>
+ Created at: {{ selectedVersionCreationTime }}
+ </div>
+ </ng-container>
+ </div>
+ <texera-user-dataset-version-filetree
+ [fileTreeNodes]="fileTreeNodeList"
+ (selectedTreeNode)="onVersionFileTreeNodeSelected($event)">
+ </texera-user-dataset-version-filetree>
+ </nz-collapse-panel>
+ </nz-collapse>
+ </div>
+ </nz-sider>
+ </nz-layout>
+ </div>
+ </nz-tab>
+</nz-tabs>
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
new file mode 100644
index 0000000000..ec85131c60
--- /dev/null
+++ b/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.scss
@@ -0,0 +1,296 @@
+/**
+ * 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.
+ */
+
+// Mirrors dataset-detail.component.scss; the two detail pages are the same layout.
+
+.version-storage {
+ padding: 0 15px;
+ margin-bottom: 25px;
+}
+
+.version-storage nz-select {
+ width: 100%;
+ margin: 0;
+}
+
+nz-layout {
+ height: 100%;
+}
+
+.right-sider {
+ height: 100%;
+ overflow-y: auto;
+ position: relative;
+ z-index: 0;
+}
+
+.sider-resize-line {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+ width: 5px;
+ border-right: 1px solid #e8e8e8;
+}
+
+.sider-resize-handle {
+ background: #fff;
+ border: 1px solid #ddd;
+ text-align: center;
+ font-size: 12px;
+ height: 20px;
+ line-height: 20px;
+}
+
+.file-renderer {
+ width: 95%;
+ height: 80%;
+ margin: auto;
+}
+
+.select-and-button-container {
+ display: flex;
+ align-items: stretch;
+ gap: 10px;
+}
+
+.spaced-button {
+ flex-shrink: 0;
+ width: 30px;
+ height: 24px;
+}
+
+nz-select {
+ flex-grow: 1;
+}
+
+.file-size,
+.version-size,
+.version-date {
+ font-size: 12px;
+ color: #8c8c8c;
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+}
+
+.version-date {
+ display: flex;
+}
+
+.version-size {
+ margin-top: 8px;
+}
+
+.icon-database,
+.icon-file {
+ font-size: 14px;
+}
+
+.empty-version-indicator {
+ margin-top: 15%;
+}
+
+.status-tag-row {
+ margin-top: 16px;
+ display: flex;
+ align-items: center;
+ gap: 5px;
+}
+
+.status-tag {
+ padding: 3px 10px 3px 3px;
+ font-size: 13px;
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ background: #fff;
+ border-radius: 5px;
+
+ i {
+ width: 22px;
+ height: 22px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 4px;
+ background: #f0f0f0;
+ }
+}
+
+.tag-public i {
+ color: #3b82f6;
+ background: #eff6ff;
+}
+
+.tag-downloadable i {
+ color: #22c55e;
+ background: #f0fdf4;
+}
+
+.tag-framework i {
+ color: #a855f7;
+ background: #faf5ff;
+}
+
+.tag-format i {
+ color: #f59e0b;
+ background: #fffbeb;
+}
+
+.file-title {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-bottom: 0;
+}
+
+.file-title-main {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ min-width: 0;
+}
+
+.copy-path-btn {
+ padding: 0 4px;
+ height: auto;
+ line-height: 1;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.model-header {
+ display: flex;
+ gap: 24px;
+}
+
+.model-header-meta {
+ flex: 1;
+}
+
+.model-cover-image {
+ width: 280px;
+ height: 120px;
+ object-fit: cover;
+ border-radius: 8px;
+ margin-right: 80px;
+}
+
+nz-tabs {
+ margin-top: 6px;
+}
+
+::ng-deep .ant-tabs-nav {
+ padding-left: 16px;
+}
+
+.data-card-tab-content,
+.versions-tab-content {
+ height: 100%;
+ overflow-y: auto;
+ padding-bottom: 24px;
+}
+
+// A wide description card beside a narrower stats card. The grow ratio with
+// basis 0 is the only thing setting their proportions.
+.data-card-columns {
+ display: flex;
+ align-items: flex-start;
+ gap: 20px;
+ margin-top: 24px;
+ width: 100%;
+ // Match the tab bar's left inset so the cards line up with the tab labels.
+ padding-left: 16px;
+}
+
+.data-card {
+ border-radius: 8px;
+ border: 1px solid #d9d9d9;
+ // Without this the default min-width:auto keeps the cards from shrinking to
+ // their flex share, skewing the ratio.
+ min-width: 0;
+}
+
+.data-card-main {
+ flex: 3 1 0;
+}
+
+.data-card-details {
+ flex: 1 1 0;
+}
+
+@media (max-width: 768px) {
+ .data-card-columns {
+ flex-direction: column;
+ align-items: stretch;
+ }
+}
+
+.data-card-heading {
+ font-size: 18px;
+ font-weight: 600;
+ color: rgba(0, 0, 0, 0.85);
+ margin: 0 0 20px;
+}
+
+.data-card-description {
+ font-size: 16px;
+ line-height: 1.6;
+ margin-bottom: 32px;
+}
+
+.empty-description {
+ color: rgba(0, 0, 0, 0.4);
+ font-style: italic;
+}
+
+.data-card-stats {
+ display: flex;
+ flex-direction: column;
+}
+
+.stat-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ gap: 16px;
+ padding: 12px 0;
+ border-bottom: 1px solid #f0f0f0;
+
+ &:last-child {
+ border-bottom: none;
+ }
+}
+
+.stat-label {
+ font-size: 13px;
+ font-weight: 600;
+ color: rgba(0, 0, 0, 0.85);
+ flex-shrink: 0;
+}
+
+.stat-value {
+ font-weight: 400;
+ color: rgba(0, 0, 0, 0.45);
+ text-align: right;
+ word-break: break-word;
+}
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
new file mode 100644
index 0000000000..017e980b8d
--- /dev/null
+++ b/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.spec.ts
@@ -0,0 +1,473 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { ComponentFixture, TestBed } from "@angular/core/testing";
+import { NoopAnimationsModule } from "@angular/platform-browser/animations";
+import { ActivatedRoute } from "@angular/router";
+import { of, throwError } from "rxjs";
+import { MarkdownService } from "ngx-markdown";
+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 { ModelService } from "../../../../service/user/model/model.service";
+import { DownloadService } from "../../../../service/user/download/download.service";
+import { DatasetFileNode } from "../../../../../common/type/datasetVersionFileTree";
+import { ModelVersion } from "../../../../../common/type/model";
+import { ModelDetailComponent } from "./model-detail.component";
+
+const MID = 5;
+const OWNER = "owner@texera.com";
+
+const aVersion = (mvid: number, name: string, creationTime = 1700000000000): ModelVersion => ({
+ mvid,
+ mid: MID,
+ creatorUid: 9,
+ name,
+ versionHash: `hash-${mvid}`,
+ creationTime,
+ fileNodes: undefined,
+});
+
+const aFile = (name: string, parentDir: string, size = 128): DatasetFileNode => ({
+ name,
+ type: "file",
+ parentDir,
+ size,
+});
+
+describe("ModelDetailComponent", () => {
+ let fixture: ComponentFixture<ModelDetailComponent>;
+ let component: ModelDetailComponent;
+ let modelService: Record<string, ReturnType<typeof vi.fn>>;
+ let downloadService: Record<string, ReturnType<typeof vi.fn>>;
+ let notificationService: Record<string, ReturnType<typeof vi.fn>>;
+
+ const dashboardModel = (overrides: Partial<Record<string, unknown>> = {}) => ({
+ isOwner: true,
+ ownerEmail: OWNER,
+ accessPrivilege: "WRITE",
+ size: 0,
+ ...overrides,
+ model: {
+ mid: MID,
+ ownerUid: 9,
+ name: "resnet-50",
+ repositoryName: "model-5",
+ isPublic: false,
+ isDownloadable: true,
+ description: "a description",
+ creationTime: 1699000000000,
+ coverImage: undefined,
+ framework: "pytorch",
+ format: "torchscript",
+ ...((overrides["model"] as object) ?? {}),
+ },
+ });
+
+ /** Rebuilds the fixture so per-test service stubs are in place before ngOnInit runs. */
+ const create = (): void => {
+ fixture = TestBed.createComponent(ModelDetailComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ };
+
+ beforeEach(() => {
+ TestBed.resetTestingModule();
+
+ modelService = {
+ getModel: vi.fn(() => of(dashboardModel())),
+ retrieveModelVersionList: vi.fn(() => of([])),
+ retrieveModelVersionFileTree: vi.fn(() => of({ fileNodes: [], size: 0 })),
+ // The real file renderer is rendered here, and it fetches whatever file is on screen.
+ retrieveModelVersionSingleFile: vi.fn(() => of(new Blob(["hi"], { type: "text/plain" }))),
+ getModelCoverUrl: vi.fn(() => of({ url: "http://cover" })),
+ };
+ downloadService = {
+ downloadModelSingleFile: vi.fn(() => of(new Blob())),
+ downloadModelVersion: vi.fn(() => of(new Blob())),
+ };
+ notificationService = { success: vi.fn(), error: vi.fn(), info: vi.fn() };
+
+ TestBed.configureTestingModule({
+ imports: [ModelDetailComponent, NoopAnimationsModule, ...commonTestImports],
+ providers: [
+ { provide: ActivatedRoute, useValue: { params: of({ mid: String(MID) }), data: of({}) } },
+ { provide: ModelService, useValue: modelService },
+ { provide: DownloadService, useValue: downloadService },
+ { provide: NotificationService, useValue: notificationService },
+ { provide: UserService, useClass: StubUserService },
+ { provide: MarkdownService, useValue: { parse: vi.fn(() => "") } },
+ ...commonTestProviders,
+ ],
+ });
+ });
+
+ afterEach(() => {
+ fixture?.destroy();
+ });
+
+ /** Applies state on top of what ngOnInit produced and renders it. */
+ const render = (state: Partial<ModelDetailComponent> = {}): HTMLElement => {
+ Object.assign(component, state);
+ fixture.detectChanges();
+ return fixture.nativeElement as HTMLElement;
+ };
+
+ // nz-tabs only instantiates the active tab, so a tab has to be opened before
+ // anything inside it exists to assert on.
+ const openTab = (title: string): HTMLElement => {
+ const tab = Array.from((fixture.nativeElement as HTMLElement).querySelectorAll<HTMLElement>(".ant-tabs-tab")).find(
+ el => (el.textContent ?? "").includes(title)
+ );
+ expect(tab, `expected a tab titled "${title}"`).toBeDefined();
+ tab!.click();
+ fixture.detectChanges();
+ return fixture.nativeElement as HTMLElement;
+ };
+
+ const q = <E extends Element>(root: ParentNode, selector: string): E => {
+ const el = root.querySelector(selector);
+ expect(el, `expected to find "${selector}"`).not.toBeNull();
+ return el as unknown as E;
+ };
+
+ // ─── loading the model ──────────────────────────────────────────────────────
+
+ it("reads the mid off the route as a number and loads the model", () => {
+ create();
+
+ expect(component.mid).toBe(MID);
+ expect(modelService["getModel"]).toHaveBeenCalledWith(MID, true);
+ expect(component.modelName).toBe("resnet-50");
+ expect(component.modelDescription).toBe("a description");
+ expect(component.modelFramework).toBe("pytorch");
+ expect(component.modelFormat).toBe("torchscript");
+ expect(component.ownerEmail).toBe(OWNER);
+ expect(component.userModelAccessLevel).toBe("WRITE");
+ expect(component.modelCreationTime).not.toBe("");
+ });
+
+ it("skips the cover fetch for a model that has none", () => {
+ create();
+
+ expect(modelService["getModelCoverUrl"]).not.toHaveBeenCalled();
+ expect(component.coverImageUrl).toBeNull();
+ });
+
+ it("resolves a presigned cover url only when the model carries a cover", () => {
+ modelService["getModel"] = vi.fn(() => of(dashboardModel({ model: { coverImage: "v1/cover.png" } })));
+ create();
+
+ expect(modelService["getModelCoverUrl"]).toHaveBeenCalledWith(MID);
+ expect(component.coverImageUrl).toBe("http://cover");
+ });
+
+ it("falls back to no cover when the presign call fails", () => {
+ modelService["getModel"] = vi.fn(() => of(dashboardModel({ model: { coverImage: "v1/cover.png" } })));
+ modelService["getModelCoverUrl"] = vi.fn(() => throwError(() => new Error("boom")));
+ create();
+
+ expect(component.coverImageUrl).toBeNull();
+ });
+
+ // ─── versions and the file tree ─────────────────────────────────────────────
+
+ it("selects the newest version and opens its first file", () => {
+ const versions = [aVersion(2, "v2", 1700000000000), aVersion(1, "v1", 1600000000000)];
+ modelService["retrieveModelVersionList"] = vi.fn(() => of(versions));
+ modelService["retrieveModelVersionFileTree"] = vi.fn(() =>
+ of({ fileNodes: [aFile("model.pt", `/model/${OWNER}/resnet-50/v2`, 512)], size: 512 })
+ );
+ create();
+
+ expect(component.selectedVersion).toBe(versions[0]);
+ expect(modelService["retrieveModelVersionFileTree"]).toHaveBeenCalledWith(MID, 2, true);
+ expect(component.currentDisplayedFileName).toBe(`/model/${OWNER}/resnet-50/v2/model.pt`);
+ expect(component.currentFileSize).toBe(512);
+ expect(component.currentModelVersionSize).toBe(512);
+ expect(component.latestVersionFileName).toBe(`/model/${OWNER}/resnet-50/v2/model.pt`);
+ expect(component.latestVersionSize).toBe(512);
+ expect(component.latestVersionCreationTime).not.toBe("");
+ });
+
+ it("descends into directories to find the file it opens first", () => {
+ modelService["retrieveModelVersionList"] = vi.fn(() => of([aVersion(1, "v1")]));
+ modelService["retrieveModelVersionFileTree"] = vi.fn(() =>
+ of({
+ fileNodes: [
+ {
+ name: "weights",
+ type: "directory",
+ parentDir: `/model/${OWNER}/resnet-50/v1`,
+ children: [aFile("model.pt", `/model/${OWNER}/resnet-50/v1/weights`)],
+ } as DatasetFileNode,
+ ],
+ size: 128,
+ })
+ );
+ create();
+
+ expect(component.currentDisplayedFileName).toBe(`/model/${OWNER}/resnet-50/v1/weights/model.pt`);
+ });
+
+ it("clears the open file when the selected version holds none", () => {
+ modelService["retrieveModelVersionList"] = vi.fn(() => of([aVersion(1, "v1")]));
+ create();
+
+ expect(component.currentDisplayedFileName).toBe("");
+ expect(component.currentFileSize).toBeUndefined();
+ expect(component.latestVersionFileName).toBe("");
+ });
+
+ it("leaves nothing selected for a model with no versions", () => {
+ create();
+
+ expect(component.versions).toEqual([]);
+ expect(component.selectedVersion).toBeUndefined();
+ expect(modelService["retrieveModelVersionFileTree"]).not.toHaveBeenCalled();
+ });
+
+ it("keeps the latest-version facts when an older version is selected", () => {
+ const versions = [aVersion(2, "v2"), aVersion(1, "v1")];
+ modelService["retrieveModelVersionList"] = vi.fn(() => of(versions));
+ modelService["retrieveModelVersionFileTree"] = vi.fn((_mid: number, mvid: number) =>
+ of({
+ fileNodes: [aFile(`v${mvid}.pt`, `/model/${OWNER}/resnet-50/v${mvid}`)],
+ size: mvid * 100,
+ })
+ );
+ create();
+ const latestFileName = component.latestVersionFileName;
+
+ component.onVersionSelected(versions[1]);
+
+ expect(component.currentDisplayedFileName).toBe(`/model/${OWNER}/resnet-50/v1/v1.pt`);
+ expect(component.currentModelVersionSize).toBe(100);
+ // The Model Card still describes the newest version, not the one being browsed.
+ expect(component.latestVersionFileName).toBe(latestFileName);
+ expect(component.latestVersionSize).toBe(200);
+ });
+
+ it("survives the version select being cleared", () => {
+ modelService["retrieveModelVersionList"] = vi.fn(() => of([aVersion(1, "v1")]));
+ create();
+
+ expect(() => component.onVersionSelected(undefined)).not.toThrow();
+ expect(component.selectedVersion).toBeUndefined();
+ });
+
+ it("reports a failure instead of rendering a blank page", () => {
+ // /model/list degrades an unreadable repository size to 0, while /model/{mid} throws,
+ // so a model that lists fine can still 500 here.
+ modelService["getModel"] = vi.fn(() => throwError(() => new Error("lakefs is down")));
+ modelService["retrieveModelVersionList"] = vi.fn(() => throwError(() => new Error("lakefs is down")));
+ create();
+
+ expect(notificationService["error"]).toHaveBeenCalledTimes(2);
+ });
+
+ it("refuses a route segment that is not a model id", () => {
+ TestBed.overrideProvider(ActivatedRoute, { useValue: { params: of({ mid: "abc" }), data: of({}) } });
+ create();
+
+ expect(component.mid).toBeUndefined();
+ expect(modelService["getModel"]).not.toHaveBeenCalled();
+ expect(notificationService["error"]).toHaveBeenCalled();
+ });
+
+ it("opens the file a tree node points at", () => {
+ create();
+
+ component.onVersionFileTreeNodeSelected(aFile("notes.txt", "/model/a/b/v1", 64));
+
+ expect(component.currentDisplayedFileName).toBe("/model/a/b/v1/notes.txt");
+ expect(component.currentFileSize).toBe(64);
+ });
+
+ // ─── downloads ──────────────────────────────────────────────────────────────
+
+ it("downloads the open file through the authenticated endpoint for an owner", () => {
+ modelService["retrieveModelVersionList"] = vi.fn(() => of([aVersion(1, "v1")]));
+ modelService["retrieveModelVersionFileTree"] = vi.fn(() =>
+ of({ fileNodes: [aFile("model.pt", `/model/${OWNER}/resnet-50/v1`)], size: 128 })
+ );
+ create();
+
+ component.onClickDownloadCurrentFile();
+
+ expect(downloadService["downloadModelSingleFile"]).toHaveBeenCalledWith(
+ `/model/${OWNER}/resnet-50/v1/model.pt`,
+ true
+ );
+ });
+
+ it("downloads a public model's file through the anonymous endpoint for a non-owner", () => {
+ modelService["getModel"] = vi.fn(() => of(dashboardModel({ isOwner: false, model: { isPublic: true } })));
+ modelService["retrieveModelVersionList"] = vi.fn(() => of([aVersion(1, "v1")]));
+ modelService["retrieveModelVersionFileTree"] = vi.fn(() =>
+ of({ fileNodes: [aFile("model.pt", `/model/${OWNER}/resnet-50/v1`)], size: 128 })
+ );
+ create();
+
+ component.onClickDownloadCurrentFile();
+
+ expect(downloadService["downloadModelSingleFile"]).toHaveBeenCalledWith(
+ `/model/${OWNER}/resnet-50/v1/model.pt`,
+ false
+ );
+ });
+
+ it("downloads the selected version as a zip", () => {
+ modelService["retrieveModelVersionList"] = vi.fn(() => of([aVersion(3, "v3")]));
+ create();
+
+ component.onClickDownloadVersionAsZip();
+
+ expect(downloadService["downloadModelVersion"]).toHaveBeenCalledWith(MID, 3, "resnet-50", "v3");
+ });
+
+ it("downloads nothing while no version is selected", () => {
+ create();
+
+ component.onClickDownloadCurrentFile();
+ component.onClickDownloadVersionAsZip();
+
+ expect(downloadService["downloadModelSingleFile"]).not.toHaveBeenCalled();
+ expect(downloadService["downloadModelVersion"]).not.toHaveBeenCalled();
+ });
+
+ it("allows a download for an owner, and for a grantee only while downloads are permitted", () => {
+ create();
+ expect(component.isDownloadAllowed()).toBe(true);
+
+ render({ isOwner: false, modelIsDownloadable: false, modelIsPublic: true });
+ expect(component.isDownloadAllowed()).toBe(false);
+
+ render({ isOwner: false, modelIsDownloadable: true, modelIsPublic: false, userModelAccessLevel: "NONE" });
+ expect(component.isDownloadAllowed()).toBe(false);
+
+ render({ isOwner: false, modelIsDownloadable: true, modelIsPublic: false, userModelAccessLevel: "READ" });
+ expect(component.isDownloadAllowed()).toBe(true);
+ });
+
+ // ─── the file path clipboard ────────────────────────────────────────────────
+
+ it("copies the open file's path and reports failure", async () => {
+ create();
+ const writeText = vi.fn(() => Promise.resolve());
+ Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true });
+
+ render({ currentDisplayedFileName: "/model/a/b/v1/model.pt" });
+ await component.copyCurrentFilePath();
+ expect(writeText).toHaveBeenCalledWith("/model/a/b/v1/model.pt");
+ expect(notificationService["success"]).toHaveBeenCalled();
+
+ writeText.mockImplementationOnce(() => Promise.reject(new Error("denied")));
+ await component.copyCurrentFilePath();
+ expect(notificationService["error"]).toHaveBeenCalled();
+
+ // Nothing open: no clipboard call at all.
+ writeText.mockClear();
+ render({ currentDisplayedFileName: "" });
+ await component.copyCurrentFilePath();
+ expect(writeText).not.toHaveBeenCalled();
+ });
+
+ // ─── template ───────────────────────────────────────────────────────────────
+
+ it("renders the model's name, tags and stats", () => {
+ create();
+ const root = render();
+
+ expect(q(root, "h2").textContent).toContain("resnet-50");
+ expect(root.textContent).toContain("Private");
+ expect(root.textContent).toContain("Downloadable");
+ expect(root.textContent).toContain("pytorch");
+ 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.
+ 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);
+ });
+
+ it("dashes out the latest-version facts for a model with no versions", () => {
+ create();
+ const stats = q(render(), ".data-card-stats").textContent ?? "";
+
+ // "0 B" would assert a zero-byte version that does not exist; the card already
+ // uses an em dash for an absent framework or format.
+ expect(stats).not.toContain("0 B");
+ expect(stats.match(/—/g)?.length).toBe(3);
+ });
+
+ it("shows the empty-version notice until a version exists", () => {
+ create();
+ const root = openTab("Versions & Files");
+
+ expect(q(root, "nz-empty")).toBeTruthy();
+ expect(root.querySelector("texera-user-dataset-file-renderer")).toBeNull();
+ });
+
+ it("hands the file renderer the model kind and the selected version", () => {
+ modelService["retrieveModelVersionList"] = vi.fn(() => of([aVersion(1, "v1")]));
+ modelService["retrieveModelVersionFileTree"] = vi.fn(() =>
+ of({ fileNodes: [aFile("notes.txt", `/model/${OWNER}/resnet-50/v1`)], size: 128 })
+ );
+ create();
+ const root = openTab("Versions & Files");
+
+ const renderer = q(root, "texera-user-dataset-file-renderer");
+ expect(renderer).toBeTruthy();
+ expect(modelService["retrieveModelVersionSingleFile"]).toHaveBeenCalledWith(
+ `/model/${OWNER}/resnet-50/v1/notes.txt`,
+ true
+ );
+ });
+
+ it("renders a cover image only once one resolves", () => {
+ create();
+ expect(fixture.nativeElement.querySelector(".model-cover-image")).toBeNull();
+
+ const root = render({ coverImageUrl: "http://cover" });
+ expect(q<HTMLImageElement>(root, ".model-cover-image").src).toContain("http://cover");
+ });
+
+ it("collapses and restores the right sider, and maximizes the preview", () => {
+ create();
+ const root = openTab("Versions & Files");
+
+ expect(root.querySelector("nz-sider")).not.toBeNull();
+ component.onClickHideRightBar();
+ fixture.detectChanges();
+ expect(fixture.nativeElement.querySelector("nz-sider")).toBeNull();
+
+ component.onClickScaleTheView();
+ fixture.detectChanges();
+ expect(component.isMaximized).toBe(true);
+ expect(fixture.nativeElement.querySelector(".model-header")).toBeNull();
+ });
+});
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
new file mode 100644
index 0000000000..d1f918ff6d
--- /dev/null
+++ b/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.ts
@@ -0,0 +1,353 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { Component, OnInit } from "@angular/core";
+import { ActivatedRoute } from "@angular/router";
+import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy";
+import { switchMap } from "rxjs/operators";
+import { format } from "date-fns";
+import { NgIf, NgClass, NgFor } from "@angular/common";
+import { FormsModule } from "@angular/forms";
+import { NzResizeEvent, NzResizableDirective, NzResizeHandleComponent } from "ng-zorro-antd/resizable";
+import { NzCardComponent, NzCardMetaComponent } from "ng-zorro-antd/card";
+import { NzTooltipDirective } from "ng-zorro-antd/tooltip";
+import { NzTagComponent } from "ng-zorro-antd/tag";
+import { ɵNzTransitionPatchDirective } from "ng-zorro-antd/core/transition-patch";
+import { NzIconDirective } from "ng-zorro-antd/icon";
+import { NzButtonComponent } from "ng-zorro-antd/button";
+import { NzWaveDirective } from "ng-zorro-antd/core/wave";
+import { NzLayoutComponent, NzContentComponent, NzSiderComponent } from "ng-zorro-antd/layout";
+import { NzEmptyComponent } from "ng-zorro-antd/empty";
+import { NzCollapseComponent, NzCollapsePanelComponent } from "ng-zorro-antd/collapse";
+import { NzSelectComponent, NzOptionComponent } from "ng-zorro-antd/select";
+import { NzTabsComponent, NzTabComponent } from "ng-zorro-antd/tabs";
+
+import { ModelService } from "../../../../service/user/model/model.service";
+import { DownloadService } from "../../../../service/user/download/download.service";
+import { NotificationService } from "../../../../../common/service/notification/notification.service";
+import { UserService } from "../../../../../common/service/user/user.service";
+import { EntityType } 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";
+import { ModelVersion } from "../../../../../common/type/model";
+import { DatasetFileNode, getFullPathFromDatasetFileNode } from "../../../../../common/type/datasetVersionFileTree";
+import { MarkdownDescriptionComponent } from "../../markdown-description/markdown-description.component";
+import { UserDatasetFileRendererComponent } from "../../user-dataset/user-dataset-explorer/user-dataset-file-renderer/user-dataset-file-renderer.component";
+import { UserDatasetVersionFiletreeComponent } from "../../user-dataset/user-dataset-explorer/user-dataset-version-filetree/user-dataset-version-filetree.component";
+
+@UntilDestroy()
+@Component({
+ templateUrl: "./model-detail.component.html",
+ styleUrls: ["./model-detail.component.scss"],
+ imports: [
+ NgIf,
+ NgFor,
+ NgClass,
+ FormsModule,
+ NzCardComponent,
+ NzCardMetaComponent,
+ NzTooltipDirective,
+ NzTagComponent,
+ ɵNzTransitionPatchDirective,
+ NzIconDirective,
+ NzButtonComponent,
+ NzWaveDirective,
+ NzLayoutComponent,
+ NzContentComponent,
+ NzSiderComponent,
+ NzResizableDirective,
+ NzResizeHandleComponent,
+ NzEmptyComponent,
+ NzCollapseComponent,
+ NzCollapsePanelComponent,
+ NzSelectComponent,
+ NzOptionComponent,
+ NzTabsComponent,
+ NzTabComponent,
+ MarkdownDescriptionComponent,
+ UserDatasetFileRendererComponent,
+ UserDatasetVersionFiletreeComponent,
+ ],
+})
+export class ModelDetailComponent implements OnInit {
+ public mid: number | undefined;
+ public modelName: string = "";
+ public modelDescription: string = "";
+ public modelCreationTime: string = "";
+ public modelCreationTimeTooltip: string = "";
+ public modelIsPublic: boolean = false;
+ public modelIsDownloadable: boolean = true;
+ public modelFramework: string | undefined;
+ public modelFormat: string | undefined;
+ public userModelAccessLevel: "READ" | "WRITE" | "NONE" = "NONE";
+ public ownerEmail: string = "";
+ public isOwner: boolean = false;
+ public coverImageUrl: string | null = null;
+
+ public versions: ReadonlyArray<ModelVersion> = [];
+ public selectedVersion: ModelVersion | undefined;
+ public selectedVersionCreationTime: string = "";
+ public fileTreeNodeList: DatasetFileNode[] = [];
+ public currentModelVersionSize: number | undefined;
+
+ // The Model Card's latest-version facts, read off the head of the version list.
+ public latestVersionCreationTime: string = "";
+ public latestVersionFileName: string = "";
+ public latestVersionSize: number | undefined;
+
+ public currentDisplayedFileName: string = "";
+ public currentFileSize: number | undefined;
+
+ // 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 isRightBarCollapsed = false;
+ public isMaximized = false;
+
+ public isLogin: boolean = this.userService.isLogin();
+ public currentUid: number | undefined = this.userService.getCurrentUser()?.uid;
+
+ public readonly modelEntityType = EntityType.Model;
+
+ formatSize = formatSize;
+ formatCount = formatCount;
+
+ constructor(
+ private route: ActivatedRoute,
+ private modelService: ModelService,
+ private downloadService: DownloadService,
+ private notificationService: NotificationService,
+ private userService: UserService
+ ) {
+ this.userService
+ .userChanged()
+ .pipe(untilDestroyed(this))
+ .subscribe(() => {
+ this.isLogin = this.userService.isLogin();
+ this.currentUid = this.userService.getCurrentUser()?.uid;
+ });
+ }
+
+ // Resizable sider holding the version picker and the file tree.
+ MAX_SIDER_WIDTH = 600;
+ MIN_SIDER_WIDTH = 150;
+ siderWidth = 400;
+ id = -1;
+
+ onSideResize({ width }: NzResizeEvent): void {
+ cancelAnimationFrame(this.id);
+ this.id = requestAnimationFrame(() => {
+ this.siderWidth = width!;
+ });
+ }
+
+ ngOnInit(): void {
+ this.route.params
+ .pipe(
+ switchMap(params => {
+ // Route params are strings, and the segment is whatever the URL carried: reject
+ // anything that is not a positive integer once, here, rather than at each use.
+ const mid = Number(params["mid"]);
+ this.mid = Number.isInteger(mid) && mid > 0 ? mid : undefined;
+ if (this.mid === undefined) {
+ this.notificationService.error("This is not a valid model id");
+ return this.route.data;
+ }
+ this.retrieveModelInfo();
+ this.retrieveModelVersionList();
+ return this.route.data;
+ }),
+ untilDestroyed(this)
+ )
+ .subscribe();
+ }
+
+ retrieveModelInfo(): void {
+ if (!this.mid) {
+ return;
+ }
+ const mid = this.mid;
+ this.modelService
+ .getModel(mid, this.isLogin)
+ .pipe(untilDestroyed(this))
+ .subscribe({
+ next: dashboardModel => {
+ const model = dashboardModel.model;
+ this.modelName = model.name;
+ this.modelDescription = model.description;
+ this.modelIsPublic = model.isPublic;
+ this.modelIsDownloadable = model.isDownloadable;
+ this.modelFramework = model.framework;
+ this.modelFormat = model.format;
+ this.userModelAccessLevel = dashboardModel.accessPrivilege;
+ this.ownerEmail = dashboardModel.ownerEmail;
+ this.isOwner = dashboardModel.isOwner;
+ if (model.coverImage) {
+ this.loadCoverImageUrl(mid);
+ } else {
+ this.coverImageUrl = null;
+ }
+ if (typeof model.creationTime === "number") {
+ const date = new Date(model.creationTime);
+ this.modelCreationTime = format(date, "MM/dd/yyyy HH:mm:ss");
+ const timeZoneName =
+ new Intl.DateTimeFormat("en-US", { timeZoneName: "long" }).format(date).split(", ").pop() || "";
+ this.modelCreationTimeTooltip = `${format(date, "zzzz")} (${timeZoneName})`;
+ }
+ },
+ error: (err: unknown) => this.notificationService.error(extractErrorMessage(err)),
+ });
+ }
+
+ private loadCoverImageUrl(mid: number): void {
+ this.modelService
+ .getModelCoverUrl(mid)
+ .pipe(untilDestroyed(this))
+ .subscribe({
+ next: ({ url }) => (this.coverImageUrl = url),
+ error: () => (this.coverImageUrl = null),
+ });
+ }
+
+ retrieveModelVersionList(): void {
+ if (!this.mid) {
+ return;
+ }
+ this.modelService
+ .retrieveModelVersionList(this.mid, this.isLogin)
+ .pipe(untilDestroyed(this))
+ .subscribe({
+ next: versions => {
+ this.versions = versions;
+ if (versions.length === 0) {
+ return;
+ }
+ const latest = versions[0];
+ this.latestVersionCreationTime = this.formatCreationTime(latest);
+ this.onVersionSelected(latest);
+ },
+ error: (err: unknown) => this.notificationService.error(extractErrorMessage(err)),
+ });
+ }
+
+ onVersionSelected(version: ModelVersion | undefined): void {
+ this.selectedVersion = version;
+ if (!this.mid || !version?.mvid) {
+ return;
+ }
+ this.modelService
+ .retrieveModelVersionFileTree(this.mid, version.mvid, this.isLogin)
+ .pipe(untilDestroyed(this))
+ .subscribe({
+ next: data => {
+ this.fileTreeNodeList = data.fileNodes;
+ this.currentModelVersionSize = data.size;
+ this.selectedVersionCreationTime = this.formatCreationTime(version);
+
+ const firstFile = this.getFirstFileNode(this.fileTreeNodeList);
+ if (version === this.versions[0]) {
+ this.latestVersionFileName = firstFile ? getFullPathFromDatasetFileNode(firstFile) : "";
+ this.latestVersionSize = data.size;
+ }
+ if (!firstFile) {
+ this.currentDisplayedFileName = "";
+ this.currentFileSize = undefined;
+ return;
+ }
+ this.loadFileContent(firstFile);
+ },
+ error: (err: unknown) => this.notificationService.error(extractErrorMessage(err)),
+ });
+ }
+
+ onVersionFileTreeNodeSelected(node: DatasetFileNode): void {
+ this.loadFileContent(node);
+ }
+
+ loadFileContent(node: DatasetFileNode): void {
+ this.currentDisplayedFileName = getFullPathFromDatasetFileNode(node);
+ this.currentFileSize = node.size;
+ }
+
+ // Walk from the first node into directories until reaching a file.
+ private getFirstFileNode(nodes: DatasetFileNode[]): DatasetFileNode | undefined {
+ let currentNode: DatasetFileNode | undefined = nodes[0];
+ while (currentNode && currentNode.type === "directory" && currentNode.children) {
+ currentNode = currentNode.children[0];
+ }
+ return currentNode;
+ }
+
+ private formatCreationTime(version: ModelVersion): string {
+ return typeof version.creationTime === "number"
+ ? format(new Date(version.creationTime), "MM/dd/yyyy HH:mm:ss")
+ : "";
+ }
+
+ onClickDownloadCurrentFile = (): void => {
+ if (!this.mid || !this.selectedVersion?.mvid) {
+ return;
+ }
+ const shouldUsePublicEndpoint = this.modelIsPublic && !this.isOwner;
+ this.downloadService
+ .downloadModelSingleFile(this.currentDisplayedFileName, !shouldUsePublicEndpoint)
+ .pipe(untilDestroyed(this))
+ .subscribe();
+ };
+
+ onClickDownloadVersionAsZip(): void {
+ if (!this.mid || !this.selectedVersion?.mvid) {
+ return;
+ }
+ this.downloadService
+ .downloadModelVersion(this.mid, this.selectedVersion.mvid, this.modelName, this.selectedVersion.name)
+ .pipe(untilDestroyed(this))
+ .subscribe();
+ }
+
+ async copyCurrentFilePath(): Promise<void> {
+ if (!this.currentDisplayedFileName) {
+ return;
+ }
+ try {
+ await navigator.clipboard.writeText(this.currentDisplayedFileName);
+ this.notificationService.success("File path copied to clipboard");
+ } catch {
+ this.notificationService.error("Failed to copy file path");
+ }
+ }
+
+ onClickScaleTheView(): void {
+ this.isMaximized = !this.isMaximized;
+ }
+
+ onClickHideRightBar(): void {
+ this.isRightBarCollapsed = !this.isRightBarCollapsed;
+ }
+
+ isDownloadAllowed(): boolean {
+ if (this.isOwner) {
+ return true;
+ }
+ return this.modelIsDownloadable && (this.modelIsPublic || this.userModelAccessLevel !== "NONE");
+ }
+}
diff --git a/frontend/src/app/dashboard/service/user/download/download.service.spec.ts b/frontend/src/app/dashboard/service/user/download/download.service.spec.ts
index 0cbdf9ee57..b295a16b6d 100644
--- a/frontend/src/app/dashboard/service/user/download/download.service.spec.ts
+++ b/frontend/src/app/dashboard/service/user/download/download.service.spec.ts
@@ -21,6 +21,7 @@ import { TestBed } from "@angular/core/testing";
import { HttpClientTestingModule, HttpTestingController } from "@angular/common/http/testing";
import { DownloadService, EXPORT_BASE_URL } from "./download.service";
import { DatasetService } from "../dataset/dataset.service";
+import { ModelService } from "../model/model.service";
import { FileSaverService } from "../file/file-saver.service";
import { NotificationService } from "../../../../common/service/notification/notification.service";
import { WorkflowPersistService } from "../../../../common/service/workflow-persist/workflow-persist.service";
@@ -39,6 +40,7 @@ const EXPORT_OPERATORS = [{ id: "op1", outputType: "csv" }];
describe("DownloadService", () => {
let downloadService: DownloadService;
let datasetServiceSpy: Mocked<DatasetService>;
+ let modelServiceSpy: Mocked<ModelService>;
let fileSaverServiceSpy: Mocked<FileSaverService>;
let notificationServiceSpy: Mocked<NotificationService>;
let workflowPersistServiceSpy: Mocked<WorkflowPersistService>;
@@ -46,6 +48,7 @@ describe("DownloadService", () => {
beforeEach(() => {
const datasetSpy = { retrieveDatasetVersionSingleFile: vi.fn(), retrieveDatasetVersionZip: vi.fn() };
+ const modelSpy = { retrieveModelVersionSingleFile: vi.fn(), retrieveModelVersionZip: vi.fn() };
const fileSaverSpy = { saveAs: vi.fn() };
const notificationSpy = { info: vi.fn(), success: vi.fn(), error: vi.fn() };
const workflowPersistSpy = { retrieveWorkflow: vi.fn() };
@@ -55,6 +58,7 @@ describe("DownloadService", () => {
providers: [
DownloadService,
{ provide: DatasetService, useValue: datasetSpy },
+ { provide: ModelService, useValue: modelSpy },
{ provide: FileSaverService, useValue: fileSaverSpy },
{ provide: NotificationService, useValue: notificationSpy },
{ provide: WorkflowPersistService, useValue: workflowPersistSpy },
@@ -64,6 +68,7 @@ describe("DownloadService", () => {
downloadService = TestBed.inject(DownloadService);
datasetServiceSpy = TestBed.inject(DatasetService) as unknown as Mocked<DatasetService>;
+ modelServiceSpy = TestBed.inject(ModelService) as unknown as Mocked<ModelService>;
fileSaverServiceSpy = TestBed.inject(FileSaverService) as unknown as Mocked<FileSaverService>;
notificationServiceSpy = TestBed.inject(NotificationService) as unknown as Mocked<NotificationService>;
workflowPersistServiceSpy = TestBed.inject(WorkflowPersistService) as unknown as Mocked<WorkflowPersistService>;
@@ -175,6 +180,46 @@ describe("DownloadService", () => {
expect(notificationServiceSpy.error).toHaveBeenCalledWith("Error downloading version 'v1.0' as ZIP");
});
+ // ─── model downloads ──────────────────────────────────────────────────────
+
+ it("downloads a model's latest version, a chosen version, and a single file", async () => {
+ const zip = new Blob(["model"], { type: "application/zip" });
+ const file = new Blob(["weights"]);
+ modelServiceSpy.retrieveModelVersionZip.mockReturnValue(of(zip));
+ modelServiceSpy.retrieveModelVersionSingleFile.mockReturnValue(of(file));
+
+ expect(await firstValueFrom(downloadService.downloadModel(4, "resnet-50"))).toBe(zip);
+ expect(modelServiceSpy.retrieveModelVersionZip).toHaveBeenCalledWith(4);
+ expect(fileSaverServiceSpy.saveAs).toHaveBeenCalledWith(zip, "resnet-50.zip");
+
+ expect(await firstValueFrom(downloadService.downloadModelVersion(4, 2, "resnet-50", "v2"))).toBe(zip);
+ expect(modelServiceSpy.retrieveModelVersionZip).toHaveBeenCalledWith(4, 2);
+ expect(fileSaverServiceSpy.saveAs).toHaveBeenCalledWith(zip, "resnet-50-v2.zip");
+
+ expect(await firstValueFrom(downloadService.downloadModelSingleFile("/model/a/m/v2/model.pt"))).toBe(file);
+ expect(modelServiceSpy.retrieveModelVersionSingleFile).toHaveBeenCalledWith("/model/a/m/v2/model.pt", true);
+ expect(fileSaverServiceSpy.saveAs).toHaveBeenCalledWith(file, "model.pt");
+ });
+
+ it("passes the logged-out flag through to the model file endpoint", async () => {
+ modelServiceSpy.retrieveModelVersionSingleFile.mockReturnValue(of(new Blob()));
+
+ await firstValueFrom(downloadService.downloadModelSingleFile("/model/a/m/v2/model.pt", false));
+
+ expect(modelServiceSpy.retrieveModelVersionSingleFile).toHaveBeenCalledWith("/model/a/m/v2/model.pt", false);
+ });
+
+ it("emits the model error notification and rethrows on retrieve failure", async () => {
+ modelServiceSpy.retrieveModelVersionZip.mockReturnValue(throwError(() => new Error("fail")));
+
+ await expect(firstValueFrom(downloadService.downloadModel(4, "resnet-50"))).rejects.toThrow("fail");
+
+ expect(fileSaverServiceSpy.saveAs).not.toHaveBeenCalled();
+ expect(notificationServiceSpy.error).toHaveBeenCalledWith(
+ "Error downloading the latest version of the model as ZIP"
+ );
+ });
+
// ─── downloadWorkflow ─────────────────────────────────────────────────────
it("downloads a workflow as a JSON blob named after the workflow", async () => {
diff --git a/frontend/src/app/dashboard/service/user/download/download.service.ts b/frontend/src/app/dashboard/service/user/download/download.service.ts
index fb5d67fb70..65430e5b54 100644
--- a/frontend/src/app/dashboard/service/user/download/download.service.ts
+++ b/frontend/src/app/dashboard/service/user/download/download.service.ts
@@ -23,6 +23,7 @@ import { catchError, map, switchMap, tap } from "rxjs/operators";
import { FileSaverService } from "../file/file-saver.service";
import { NotificationService } from "../../../../common/service/notification/notification.service";
import { DatasetService } from "../dataset/dataset.service";
+import { ModelService } from "../model/model.service";
import { WorkflowPersistService } from "src/app/common/service/workflow-persist/workflow-persist.service";
import JSZip from "jszip";
import { Workflow } from "../../../../common/type/workflow";
@@ -57,6 +58,7 @@ export class DownloadService {
private fileSaverService: FileSaverService,
private notificationService: NotificationService,
private datasetService: DatasetService,
+ private modelService: ModelService,
private workflowPersistService: WorkflowPersistService,
private http: HttpClient
) {}
@@ -102,6 +104,43 @@ export class DownloadService {
);
}
+ downloadModel(id: number, name: string): Observable<Blob> {
+ return this.downloadWithNotification(
+ () => this.modelService.retrieveModelVersionZip(id),
+ `${name}.zip`,
+ "Starting to download the latest version of the model as ZIP",
+ "The latest version of the model has been downloaded as ZIP",
+ "Error downloading the latest version of the model as ZIP"
+ );
+ }
+
+ downloadModelVersion(
+ modelId: number,
+ modelVersionId: number,
+ modelName: string,
+ versionName: string
+ ): Observable<Blob> {
+ return this.downloadWithNotification(
+ () => this.modelService.retrieveModelVersionZip(modelId, modelVersionId),
+ `${modelName}-${versionName}.zip`,
+ `Starting to download version ${versionName} as ZIP`,
+ `Version ${versionName} has been downloaded as ZIP`,
+ `Error downloading version '${versionName}' as ZIP`
+ );
+ }
+
+ downloadModelSingleFile(filePath: string, isLogin: boolean = true): Observable<Blob> {
+ const DEFAULT_FILE_NAME = "download";
+ const fileName = filePath.split("/").pop() || DEFAULT_FILE_NAME;
+ return this.downloadWithNotification(
+ () => this.modelService.retrieveModelVersionSingleFile(filePath, isLogin),
+ fileName,
+ `Starting to download file ${filePath}`,
+ `File ${filePath} has been downloaded`,
+ `Error downloading file '${filePath}'`
+ );
+ }
+
downloadWorkflowsAsZip(workflowEntries: Array<{ id: number; name: string }>): Observable<Blob> {
return this.downloadWithNotification(
() => this.createWorkflowsZip(workflowEntries),
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 f99a8ff4ed..903455a702 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
@@ -156,6 +156,76 @@ describe("ModelService", () => {
describe.flush({});
});
+ it("reads a model from the authenticated or the public endpoint", () => {
+ service.getModel(7).subscribe();
+ const authenticated = http.expectOne(`${API}/model/7`);
+ expect(authenticated.request.method).toBe("GET");
+ authenticated.flush({});
+
+ service.getModel(7, false).subscribe();
+ http.expectOne(`${API}/model/public/7`).flush({});
+ });
+
+ it("lists versions from the endpoint matching the caller's login state", () => {
+ service.retrieveModelVersionList(7).subscribe();
+ http.expectOne(`${API}/model/7/version/list`).flush([]);
+
+ service.retrieveModelVersionList(7, false).subscribe();
+ http.expectOne(`${API}/model/7/publicVersion/list`).flush([]);
+ });
+
+ it("reads a version's root file nodes with its size", async () => {
+ const tree = { fileNodes: [], size: 42 };
+ const pending = firstValueFrom(service.retrieveModelVersionFileTree(7, 3));
+ http.expectOne(`${API}/model/7/version/3/rootFileNodes`).flush(tree);
+ expect(await pending).toEqual(tree);
+
+ service.retrieveModelVersionFileTree(7, 3, false).subscribe();
+ http.expectOne(`${API}/model/7/publicVersion/3/rootFileNodes`).flush(tree);
+ });
+
+ it("asks for exactly one of mvid or latest on a version zip", () => {
+ // The backend answers a request carrying both, or neither, with a 400.
+ service.retrieveModelVersionZip(7, 3).subscribe();
+ const byId = http.expectOne(req => req.url === `${API}/model/7/versionZip`);
+ expect(byId.request.params.get("mvid")).toBe("3");
+ expect(byId.request.params.has("latest")).toBe(false);
+ byId.flush(new Blob());
+
+ service.retrieveModelVersionZip(7).subscribe();
+ const latest = http.expectOne(req => req.url === `${API}/model/7/versionZip`);
+ expect(latest.request.params.get("latest")).toBe("true");
+ expect(latest.request.params.has("mvid")).toBe(false);
+ latest.flush(new Blob());
+ });
+
+ it("fetches a single file by following the presigned url it is handed", async () => {
+ const blob = new Blob(["weights"]);
+ const pending = firstValueFrom(service.retrieveModelVersionSingleFile("/model/a/m/v1/model.pt"));
+
+ const presign = http.expectOne(
+ `${API}/model/presign-download?filePath=${encodeURIComponent("/model/a/m/v1/model.pt")}`
+ );
+ presign.flush({ presignedUrl: "http://minio/model.pt" });
+ http.expectOne("http://minio/model.pt").flush(blob);
+
+ expect(await pending).toEqual(blob);
+ });
+
+ it("uses the anonymous presign endpoint for a logged-out viewer", () => {
+ service.retrieveModelVersionSingleFile("/model/a/m/v1/model.pt", false).subscribe();
+ http
+ .expectOne(`${API}/model/public-presign-download?filePath=${encodeURIComponent("/model/a/m/v1/model.pt")}`)
+ .flush({ presignedUrl: "http://minio/model.pt" });
+ http.expectOne("http://minio/model.pt").flush(new Blob());
+ });
+
+ it("reads the presigned cover url, which is null for a model without one", async () => {
+ const pending = firstValueFrom(service.getModelCoverUrl(7));
+ http.expectOne(`${API}/model/7/cover-url`).flush({ url: null });
+ expect(await pending).toEqual({ url: null });
+ });
+
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" });
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 c83f136919..c5d26b5ecd 100644
--- a/frontend/src/app/dashboard/service/user/model/model.service.ts
+++ b/frontend/src/app/dashboard/service/user/model/model.service.ts
@@ -18,11 +18,13 @@
*/
import { Injectable } from "@angular/core";
-import { HttpClient } from "@angular/common/http";
+import { HttpClient, HttpParams } from "@angular/common/http";
import { Observable } from "rxjs";
+import { switchMap } from "rxjs/operators";
import { AppSettings } from "../../../../common/app-setting";
-import { Model } from "../../../../common/type/model";
+import { Model, ModelVersion } from "../../../../common/type/model";
import { DashboardModel } from "../../../type/dashboard-model.interface";
+import { DatasetFileNode } from "../../../../common/type/datasetVersionFileTree";
export const MODEL_BASE_URL = "model";
export const MODEL_CREATE_URL = MODEL_BASE_URL + "/create";
@@ -31,6 +33,11 @@ export const MODEL_UPDATE_NAME_URL = MODEL_UPDATE_BASE_URL + "/name";
export const MODEL_UPDATE_DESCRIPTION_URL = MODEL_UPDATE_BASE_URL + "/description";
export const MODEL_LIST_URL = MODEL_BASE_URL + "/list";
+export const MODEL_VERSION_BASE_URL = "version";
+export const MODEL_VERSION_RETRIEVE_LIST_URL = MODEL_VERSION_BASE_URL + "/list";
+export const MODEL_PUBLIC_VERSION_BASE_URL = "publicVersion";
+export const MODEL_PUBLIC_VERSION_RETRIEVE_LIST_URL = MODEL_PUBLIC_VERSION_BASE_URL + "/list";
+
export const DEFAULT_MODEL_NAME = "Untitled-model";
export const MODEL_NAME_MAX_LENGTH = 128;
@@ -73,6 +80,13 @@ export class ModelService {
});
}
+ public getModel(mid: number, isLogin: boolean = true): Observable<DashboardModel> {
+ const apiUrl = isLogin
+ ? `${AppSettings.getApiEndpoint()}/${MODEL_BASE_URL}/${mid}`
+ : `${AppSettings.getApiEndpoint()}/${MODEL_BASE_URL}/public/${mid}`;
+ return this.http.get<DashboardModel>(apiUrl);
+ }
+
public retrieveAccessibleModels(): Observable<DashboardModel[]> {
return this.http.get<DashboardModel[]>(`${AppSettings.getApiEndpoint()}/${MODEL_LIST_URL}`);
}
@@ -94,4 +108,52 @@ export class ModelService {
description: description,
});
}
+
+ /** A model's versions, newest first; an anonymous caller gets the public-only endpoint. */
+ public retrieveModelVersionList(mid: number, isLogin: boolean = true): Observable<ModelVersion[]> {
+ const listUrl = isLogin ? MODEL_VERSION_RETRIEVE_LIST_URL : MODEL_PUBLIC_VERSION_RETRIEVE_LIST_URL;
+ return this.http.get<ModelVersion[]>(`${AppSettings.getApiEndpoint()}/${MODEL_BASE_URL}/${mid}/${listUrl}`);
+ }
+
+ public retrieveModelVersionFileTree(
+ mid: number,
+ mvid: number,
+ isLogin: boolean = true
+ ): Observable<{ fileNodes: DatasetFileNode[]; size: number }> {
+ const versionSegment = isLogin ? MODEL_VERSION_BASE_URL : MODEL_PUBLIC_VERSION_BASE_URL;
+ return this.http.get<{ fileNodes: DatasetFileNode[]; size: number }>(
+ `${AppSettings.getApiEndpoint()}/${MODEL_BASE_URL}/${mid}/${versionSegment}/${mvid}/rootFileNodes`
+ );
+ }
+
+ /** A model version as a zip. The backend requires exactly one of mvid or latest. */
+ public retrieveModelVersionZip(mid: number, mvid?: number): Observable<Blob> {
+ const params =
+ mvid !== undefined && mvid !== null
+ ? new HttpParams().set("mvid", mvid.toString())
+ : new HttpParams().set("latest", "true");
+
+ return this.http.get(`${AppSettings.getApiEndpoint()}/${MODEL_BASE_URL}/${mid}/versionZip`, {
+ params,
+ responseType: "blob",
+ });
+ }
+
+ /**
+ * A single file of a model version, fetched through a presigned URL.
+ *
+ * @param filePath Logical path of the file, e.g. "/model/bob@texera.com/resnet/v1/model.pt".
+ */
+ public retrieveModelVersionSingleFile(filePath: string, isLogin: boolean = true): Observable<Blob> {
+ const endpointSegment = isLogin ? "presign-download" : "public-presign-download";
+ const endpoint = `${AppSettings.getApiEndpoint()}/${MODEL_BASE_URL}/${endpointSegment}?filePath=${encodeURIComponent(filePath)}`;
+
+ return this.http
+ .get<{ presignedUrl: string }>(endpoint)
+ .pipe(switchMap(({ presignedUrl }) => this.http.get(presignedUrl, { responseType: "blob" })));
+ }
+
+ public getModelCoverUrl(mid: number): Observable<{ url: string | null }> {
+ return this.http.get<{ url: string | null }>(`${AppSettings.getApiEndpoint()}/${MODEL_BASE_URL}/${mid}/cover-url`);
+ }
}
diff --git a/frontend/src/app/dashboard/service/user/resource-registry/dataset-resource.descriptor.ts b/frontend/src/app/dashboard/service/user/resource-registry/dataset-resource.descriptor.ts
index 727ff7c280..4c4078c1b2 100644
--- a/frontend/src/app/dashboard/service/user/resource-registry/dataset-resource.descriptor.ts
+++ b/frontend/src/app/dashboard/service/user/resource-registry/dataset-resource.descriptor.ts
@@ -23,6 +23,7 @@ import { ResourceDescriptor } from "../../../type/resource-descriptor";
import { EntityType } from "../../../../hub/service/hub.service";
import { DatasetService, DEFAULT_DATASET_NAME, validateDatasetName } from "../dataset/dataset.service";
import { HUB_DATASET_RESULT_DETAIL, USER_DATASET } from "../../../../app-routing.constant";
+import { DownloadService } from "../download/download.service";
@Injectable({
providedIn: "root",
@@ -35,7 +36,10 @@ export class DatasetResourceDescriptor implements ResourceDescriptor {
readonly hasSize = true;
readonly defaultName = DEFAULT_DATASET_NAME;
- constructor(private datasetService: DatasetService) {}
+ constructor(
+ private datasetService: DatasetService,
+ private downloadService: DownloadService
+ ) {}
isOwner = (entry: DashboardEntry): boolean => entry.dataset.isOwner;
validateName = validateDatasetName;
@@ -43,5 +47,8 @@ export class DatasetResourceDescriptor implements ResourceDescriptor {
updateDescription = (id: number, description: string) =>
this.datasetService.updateDatasetDescription(id, description);
retrieveOwners = () => this.datasetService.retrieveOwners();
+ download = (id: number, name: string) => this.downloadService.downloadDataset(id, name);
+ retrieveSingleFile = (filePath: string, isLogin: boolean) =>
+ this.datasetService.retrieveDatasetVersionSingleFile(filePath, isLogin);
// No dataset-id endpoint exists, so `retrieveIds` stays absent and the id filter hides itself.
}
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 25b36b03ab..90258baa1f 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,6 +23,8 @@ import { ResourceDescriptor } from "../../../type/resource-descriptor";
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 { DownloadService } from "../download/download.service";
@Injectable({
providedIn: "root",
@@ -30,16 +32,22 @@ import { MODEL_ICON } from "../../../../common/icon/model-icon";
export class ModelResourceDescriptor implements ResourceDescriptor {
readonly type = EntityType.Model;
readonly iconType = MODEL_ICON;
- // `privateRoute` is deliberately absent: /user/model/:mid has no component yet, so
- // entryLink returns [] and a model card does not navigate.
+ readonly privateRoute = USER_MODEL;
+ // `hubRoute` is deliberately absent: models reach the hub with the rest of the hub UI.
readonly hasSize = true;
readonly defaultName = DEFAULT_MODEL_NAME;
- constructor(private modelService: ModelService) {}
+ constructor(
+ private modelService: ModelService,
+ private downloadService: DownloadService
+ ) {}
isOwner = (entry: DashboardEntry): boolean => entry.model.isOwner;
validateName = validateModelName;
rename = (id: number, name: string) => this.modelService.updateModelName(id, name);
updateDescription = (id: number, description: string) => this.modelService.updateModelDescription(id, description);
+ download = (id: number, name: string) => this.downloadService.downloadModel(id, name);
+ retrieveSingleFile = (filePath: string, isLogin: boolean) =>
+ this.modelService.retrieveModelVersionSingleFile(filePath, isLogin);
// `retrieveOwners` arrives with the share modal and the filters, which need 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 4701cf643e..ee91d5f4ab 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
@@ -26,10 +26,12 @@ import { EntityType } from "../../../../hub/service/hub.service";
import { DatasetService } from "../dataset/dataset.service";
import { ModelService } from "../model/model.service";
import { WorkflowPersistService } from "../../../../common/service/workflow-persist/workflow-persist.service";
+import { DownloadService } from "../download/download.service";
import {
HUB_DATASET_RESULT_DETAIL,
HUB_WORKFLOW_RESULT_DETAIL,
USER_DATASET,
+ USER_MODEL,
USER_PROJECT,
USER_WORKSPACE,
} from "../../../../app-routing.constant";
@@ -44,6 +46,7 @@ describe("ResourceRegistryService", () => {
let workflowPersistService: { [k: string]: ReturnType<typeof vi.fn> };
let datasetService: { [k: string]: ReturnType<typeof vi.fn> };
let modelService: { [k: string]: ReturnType<typeof vi.fn> };
+ let downloadService: { [k: string]: ReturnType<typeof vi.fn> };
beforeEach(() => {
// Partial spies on purpose: the descriptors must not touch these until a caller asks.
@@ -57,16 +60,25 @@ describe("ResourceRegistryService", () => {
updateDatasetName: vi.fn().mockReturnValue(of({})),
updateDatasetDescription: vi.fn().mockReturnValue(of({})),
retrieveOwners: vi.fn().mockReturnValue(of(["ds-owner"])),
+ retrieveDatasetVersionSingleFile: vi.fn().mockReturnValue(of(new Blob())),
};
modelService = {
updateModelName: vi.fn().mockReturnValue(of({})),
updateModelDescription: vi.fn().mockReturnValue(of({})),
+ retrieveModelVersionSingleFile: vi.fn().mockReturnValue(of(new Blob())),
+ };
+
+ downloadService = {
+ downloadWorkflow: vi.fn().mockReturnValue(of({})),
+ downloadDataset: vi.fn().mockReturnValue(of(new Blob())),
+ downloadModel: vi.fn().mockReturnValue(of(new Blob())),
};
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [
+ { provide: DownloadService, useValue: downloadService },
{ provide: WorkflowPersistService, useValue: workflowPersistService },
{ provide: DatasetService, useValue: datasetService },
{ provide: ModelService, useValue: modelService },
@@ -107,6 +119,19 @@ describe("ResourceRegistryService", () => {
}
});
+ it("offers a download and a file preview only for the kinds that hold files", () => {
+ for (const type of [EntityType.Workflow, EntityType.Dataset, EntityType.Model]) {
+ expect(registry.get(type).download).toBeDefined();
+ }
+ for (const type of [EntityType.Project, EntityType.File]) {
+ expect(registry.get(type).download).toBeUndefined();
+ }
+ // Workflows are one file, not a version tree, so nothing previews them.
+ expect(registry.get(EntityType.Workflow).retrieveSingleFile).toBeUndefined();
+ expect(registry.get(EntityType.Dataset).retrieveSingleFile).toBeDefined();
+ expect(registry.get(EntityType.Model).retrieveSingleFile).toBeDefined();
+ });
+
it("offers an id filter only where the backend has an id endpoint", () => {
expect(registry.get(EntityType.Workflow).retrieveIds).toBeDefined();
expect(registry.get(EntityType.Dataset).retrieveIds).toBeUndefined();
@@ -130,6 +155,11 @@ describe("ResourceRegistryService", () => {
registry.get(EntityType.Dataset).updateDescription!(2, "d");
registry.get(EntityType.Model).rename!(3, "m");
registry.get(EntityType.Model).updateDescription!(3, "d");
+ registry.get(EntityType.Workflow).download!(1, "wf");
+ registry.get(EntityType.Dataset).download!(2, "ds");
+ registry.get(EntityType.Model).download!(3, "m");
+ registry.get(EntityType.Dataset).retrieveSingleFile!("/dataset/a/ds/v1/f.csv", true);
+ registry.get(EntityType.Model).retrieveSingleFile!("/model/a/m/v1/f.pt", false);
expect(workflowPersistService["updateWorkflowName"]).toHaveBeenCalledWith(1, "wf");
expect(workflowPersistService["updateWorkflowDescription"]).toHaveBeenCalledWith(1, "d");
@@ -137,6 +167,11 @@ describe("ResourceRegistryService", () => {
expect(datasetService["updateDatasetDescription"]).toHaveBeenCalledWith(2, "d");
expect(modelService["updateModelName"]).toHaveBeenCalledWith(3, "m");
expect(modelService["updateModelDescription"]).toHaveBeenCalledWith(3, "d");
+ expect(downloadService["downloadWorkflow"]).toHaveBeenCalledWith(1, "wf");
+ expect(downloadService["downloadDataset"]).toHaveBeenCalledWith(2, "ds");
+ expect(downloadService["downloadModel"]).toHaveBeenCalledWith(3, "m");
+ expect(datasetService["retrieveDatasetVersionSingleFile"]).toHaveBeenCalledWith("/dataset/a/ds/v1/f.csv", true);
+ expect(modelService["retrieveModelVersionSingleFile"]).toHaveBeenCalledWith("/model/a/m/v1/f.pt", false);
});
it("reads ownership off the kind's own payload", () => {
@@ -167,10 +202,9 @@ describe("ResourceRegistryService", () => {
expect(registry.entryLink(entry({ type: EntityType.Project, id: 3 }), undefined)).toEqual([USER_PROJECT, "3"]);
});
- it("leaves models unrouted until they have a page to open", () => {
- // /user/model/:mid has no component yet; a link would hit the ** wildcard and land on Workflows.
- expect(registry.get(EntityType.Model).privateRoute).toBeUndefined();
- expect(registry.entryLink(entry({ type: EntityType.Model, id: 9 }), 42)).toEqual([]);
+ 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("leaves an unroutable or unsaved entry unlinked", () => {
diff --git a/frontend/src/app/dashboard/service/user/resource-registry/workflow-resource.descriptor.ts b/frontend/src/app/dashboard/service/user/resource-registry/workflow-resource.descriptor.ts
index 79e1e4bec1..07b8614796 100644
--- a/frontend/src/app/dashboard/service/user/resource-registry/workflow-resource.descriptor.ts
+++ b/frontend/src/app/dashboard/service/user/resource-registry/workflow-resource.descriptor.ts
@@ -26,6 +26,7 @@ import {
WorkflowPersistService,
} from "../../../../common/service/workflow-persist/workflow-persist.service";
import { HUB_WORKFLOW_RESULT_DETAIL, USER_WORKSPACE } from "../../../../app-routing.constant";
+import { DownloadService } from "../download/download.service";
@Injectable({
providedIn: "root",
@@ -38,7 +39,10 @@ export class WorkflowResourceDescriptor implements ResourceDescriptor {
readonly hasSize = true;
readonly defaultName = DEFAULT_WORKFLOW_NAME;
- constructor(private workflowPersistService: WorkflowPersistService) {}
+ constructor(
+ private workflowPersistService: WorkflowPersistService,
+ private downloadService: DownloadService
+ ) {}
// Bound lazily, never in the constructor: specs hand these descriptors partial service spies,
// and reading an absent method up front would fail the whole TestBed.
@@ -48,4 +52,5 @@ export class WorkflowResourceDescriptor implements ResourceDescriptor {
this.workflowPersistService.updateWorkflowDescription(id, description);
retrieveOwners = () => this.workflowPersistService.retrieveOwners();
retrieveIds = () => this.workflowPersistService.retrieveWorkflowIDs();
+ download = (id: number, name: string) => this.downloadService.downloadWorkflow(id, name);
}
diff --git a/frontend/src/app/dashboard/type/resource-descriptor.ts b/frontend/src/app/dashboard/type/resource-descriptor.ts
index bfba83a167..c2cdc39aa9 100644
--- a/frontend/src/app/dashboard/type/resource-descriptor.ts
+++ b/frontend/src/app/dashboard/type/resource-descriptor.ts
@@ -43,6 +43,10 @@ export interface ResourceDescriptor {
validateName?(name: string): string | null;
rename?(id: number, name: string): Observable<unknown>;
updateDescription?(id: number, description: string): Observable<unknown>;
+ /** Downloads the whole resource; absent when the kind has nothing to download. */
+ download?(id: number, name: string): Observable<unknown>;
+ /** Fetches one file of a version for preview, by its logical path. */
+ retrieveSingleFile?(filePath: string, isLogin: boolean): Observable<Blob>;
/** Owners of this kind, for the filter dropdown. */
retrieveOwners?(): Observable<string[]>;
/** Entry ids of this kind; absent when the backend exposes no such endpoint. */