diff --git a/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts index 6acbccebf1e..cd936421d9e 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 05e979249d9..2df770b6244 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 c2e112601c7..3846055a1f0 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()"> { 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 7061cd9050f..5b3dfe16a57 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 7f75f16fe28..a223afe6e49 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 @@ + + + + + {{ formatSize(currentFileSize) }} + + + +
+ + + + + +
+ + + + + + + + + +
+ +
+
+
+ + +
+
Choose a Version:
+
+ + + + +
+ +
+ + Version Size: {{ formatSize(currentModelVersionSize) }} +
+
+ + Created at: {{ selectedVersionCreationTime }} +
+
+
+ + +
+
+
+
+ + + + 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 00000000000..ec85131c60c --- /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 00000000000..017e980b8d1 --- /dev/null +++ b/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.spec.ts @@ -0,0 +1,473 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { NoopAnimationsModule } from "@angular/platform-browser/animations"; +import { ActivatedRoute } from "@angular/router"; +import { of, throwError } from "rxjs"; +import { MarkdownService } from "ngx-markdown"; +import { commonTestImports, commonTestProviders } from "../../../../../common/testing/test-utils"; +import { NotificationService } from "../../../../../common/service/notification/notification.service"; +import { UserService } from "../../../../../common/service/user/user.service"; +import { StubUserService } from "../../../../../common/service/user/stub-user.service"; +import { ModelService } from "../../../../service/user/model/model.service"; +import { DownloadService } from "../../../../service/user/download/download.service"; +import { DatasetFileNode } from "../../../../../common/type/datasetVersionFileTree"; +import { ModelVersion } from "../../../../../common/type/model"; +import { ModelDetailComponent } from "./model-detail.component"; + +const MID = 5; +const OWNER = "owner@texera.com"; + +const aVersion = (mvid: number, name: string, creationTime = 1700000000000): ModelVersion => ({ + mvid, + mid: MID, + creatorUid: 9, + name, + versionHash: `hash-${mvid}`, + creationTime, + fileNodes: undefined, +}); + +const aFile = (name: string, parentDir: string, size = 128): DatasetFileNode => ({ + name, + type: "file", + parentDir, + size, +}); + +describe("ModelDetailComponent", () => { + let fixture: ComponentFixture; + let component: ModelDetailComponent; + let modelService: Record>; + let downloadService: Record>; + let notificationService: Record>; + + const dashboardModel = (overrides: Partial> = {}) => ({ + 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 = {}): 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(".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 = (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(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 00000000000..d1f918ff6d0 --- /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 = []; + 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 { + 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 0cbdf9ee57f..b295a16b6d8 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; + let modelServiceSpy: Mocked; let fileSaverServiceSpy: Mocked; let notificationServiceSpy: Mocked; let workflowPersistServiceSpy: Mocked; @@ -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; + modelServiceSpy = TestBed.inject(ModelService) as unknown as Mocked; fileSaverServiceSpy = TestBed.inject(FileSaverService) as unknown as Mocked; notificationServiceSpy = TestBed.inject(NotificationService) as unknown as Mocked; workflowPersistServiceSpy = TestBed.inject(WorkflowPersistService) as unknown as Mocked; @@ -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 fb5d67fb70e..65430e5b54d 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 { + 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 { + 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 { + 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 { 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 f99a8ff4ed9..903455a702a 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 c83f1369193..c5d26b5ecd4 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 { + const apiUrl = isLogin + ? `${AppSettings.getApiEndpoint()}/${MODEL_BASE_URL}/${mid}` + : `${AppSettings.getApiEndpoint()}/${MODEL_BASE_URL}/public/${mid}`; + return this.http.get(apiUrl); + } + public retrieveAccessibleModels(): Observable { return this.http.get(`${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 { + const listUrl = isLogin ? MODEL_VERSION_RETRIEVE_LIST_URL : MODEL_PUBLIC_VERSION_RETRIEVE_LIST_URL; + return this.http.get(`${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 { + const params = + mvid !== undefined && mvid !== null + ? new HttpParams().set("mvid", mvid.toString()) + : new HttpParams().set("latest", "true"); + + return this.http.get(`${AppSettings.getApiEndpoint()}/${MODEL_BASE_URL}/${mid}/versionZip`, { + params, + responseType: "blob", + }); + } + + /** + * A single file of a model version, fetched through a presigned URL. + * + * @param filePath Logical path of the file, e.g. "/model/bob@texera.com/resnet/v1/model.pt". + */ + public retrieveModelVersionSingleFile(filePath: string, isLogin: boolean = true): Observable { + 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 727ff7c2805..4c4078c1b26 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 25b36b03ab4..90258baa1fc 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 4701cf643e4..ee91d5f4ab5 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 }; let datasetService: { [k: string]: ReturnType }; let modelService: { [k: string]: ReturnType }; + let downloadService: { [k: string]: ReturnType }; 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 79e1e4bec17..07b86147960 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 bfba83a1679..c2cdc39aa9a 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; updateDescription?(id: number, description: string): Observable; + /** Downloads the whole resource; absent when the kind has nothing to download. */ + download?(id: number, name: string): Observable; + /** Fetches one file of a version for preview, by its logical path. */ + retrieveSingleFile?(filePath: string, isLogin: boolean): Observable; /** Owners of this kind, for the filter dropdown. */ retrieveOwners?(): Observable; /** Entry ids of this kind; absent when the backend exposes no such endpoint. */