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 <[email protected]> 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 = "[email protected]"; + +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/[email protected]/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. */
