diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index 0dfbd0c5cea..ecbcda33ad7 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -182,7 +182,7 @@ import { } from "@abacritt/angularx-social-login"; import { catchError, firstValueFrom, lastValueFrom, of } from "rxjs"; import { HubSearchResultComponent } from "./hub/component/hub-search-result/hub-search-result.component"; -import { UserDatasetStagedObjectsListComponent } from "./dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-staged-objects-list/user-dataset-staged-objects-list.component"; +import { StagedObjectsListComponent } from "./dashboard/component/user/staged-objects-list/staged-objects-list.component"; import { NzEmptyModule } from "ng-zorro-antd/empty"; import { NzDividerModule } from "ng-zorro-antd/divider"; import { NzProgressModule } from "ng-zorro-antd/progress"; @@ -322,7 +322,7 @@ registerLocaleData(en); DatasetDetailComponent, UserDatasetVersionFiletreeComponent, UserDatasetFileRendererComponent, - UserDatasetStagedObjectsListComponent, + StagedObjectsListComponent, NzModalCommentBoxComponent, LeftPanelComponent, ContextMenuComponent, diff --git a/frontend/src/app/dashboard/component/admin/settings/admin-settings.component.html b/frontend/src/app/dashboard/component/admin/settings/admin-settings.component.html index 2bfc1daaf00..fcd41e2a5d1 100644 --- a/frontend/src/app/dashboard/component/admin/settings/admin-settings.component.html +++ b/frontend/src/app/dashboard/component/admin/settings/admin-settings.component.html @@ -252,9 +252,11 @@

General Settings

- - - Dataset + + + {{ group.label }} General Settings
Configuration Guidelines
- • Concurrent parts × part size ≈ in-flight data per file
- • Keep usage within your client memory/bandwidth limits
- • Use larger part size for large files to avoid >10,000 parts
- • Out-of-range inputs will be auto-adjusted to the nearest valid value

+ • Concurrent parts × part size ≈ in-flight data per file
+ • Keep usage within your client memory/bandwidth limits
+ • Use larger part size for large files to avoid >10,000 parts
+ • Out-of-range inputs will be auto-adjusted to the nearest valid value

Learn more: General Settings
Concurrent Files: General Settings File Size:
General Settings
Concurrent Parts: General Settings
Number of parts uploaded in parallel. Range: 1 - {{ MAX_TOTAL_PARTS | number }}. Current configuration will use - approximately {{ partsAtMax | number }} parts for maximum file size. + approximately {{ partsAtMax(group.form) | number }} parts for maximum file size.
Part Size:
General Settings
Size of each chunk during multipart upload. Range: {{ MIN_PART_SIZE_MiB }} MiB - {{ MAX_PART_SIZE_MiB | number }} - MiB (5 GiB). Minimum required for current configuration: {{ requiredMinPartSizeMiB | number }} MiB. + MiB (5 GiB). Minimum required for current configuration: {{ requiredMinPartSizeMiB(group.form) | number }} MiB.
- In-flight data per file: ~{{ maxConcurrentChunks * chunkSizeMiB | number }} MiB + In-flight data per file: ~{{ group.form.maxConcurrentChunks * group.form.chunkSizeMiB | number }} MiB
diff --git a/frontend/src/app/dashboard/component/admin/settings/admin-settings.component.spec.ts b/frontend/src/app/dashboard/component/admin/settings/admin-settings.component.spec.ts index 637a516ee8a..cbba96d505e 100644 --- a/frontend/src/app/dashboard/component/admin/settings/admin-settings.component.spec.ts +++ b/frontend/src/app/dashboard/component/admin/settings/admin-settings.component.spec.ts @@ -18,7 +18,7 @@ */ import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { AdminSettingsComponent } from "./admin-settings.component"; +import { AdminSettingsComponent, UploadSettingsForm } from "./admin-settings.component"; import { HttpClientTestingModule, HttpTestingController } from "@angular/common/http/testing"; import { NzCardModule } from "ng-zorro-antd/card"; import { NzMessageService } from "ng-zorro-antd/message"; @@ -32,6 +32,10 @@ describe("AdminSettingsComponent", () => { const SETTINGS_URL = "/api/config/settings"; + const formOf = (label: string): UploadSettingsForm => component.uploadGroups.find(g => g.label === label)!.form; + const datasetForm = () => formOf("Dataset"); + const modelForm = () => formOf("Model"); + beforeEach(async () => { await TestBed.configureTestingModule({ imports: [AdminSettingsComponent, HttpClientTestingModule, NzCardModule], @@ -49,9 +53,10 @@ describe("AdminSettingsComponent", () => { expect(component).toBeTruthy(); }); - it("renders MiB unit beside both size-based inputs", () => { + it("renders MiB unit beside every size-based input", () => { const units = fixture.nativeElement.querySelectorAll(".input-with-unit .unit"); - expect(units.length).toBe(2); + // File size and part size, once per upload card. + expect(units.length).toBe(2 * component.uploadGroups.length); units.forEach((el: HTMLElement) => { expect(el.textContent?.trim()).toBe("MiB"); }); @@ -70,6 +75,10 @@ describe("AdminSettingsComponent", () => { dataset_single_file_upload_max_size_mib: "128", dataset_max_number_of_concurrent_uploading_file_chunks: "7", dataset_multipart_upload_chunk_size_mib: "64", + model_max_number_of_concurrent_uploading_file: "2", + model_single_file_upload_max_size_mib: "4096", + model_max_number_of_concurrent_uploading_file_chunks: "4", + model_multipart_upload_chunk_size_mib: "32", csv_parser_max_columns: "4096", }); @@ -78,10 +87,15 @@ describe("AdminSettingsComponent", () => { expect(component.faviconData).toBe("fav.ico"); expect(component.sidebarTabs.hub_enabled).toBe(true); expect(component.sidebarTabs.home_enabled).toBe(false); - expect(component.maxConcurrentFiles).toBe(5); - expect(component.maxFileSizeMiB).toBe(128); - expect(component.maxConcurrentChunks).toBe(7); - expect(component.chunkSizeMiB).toBe(64); + expect(datasetForm().maxConcurrentFiles).toBe(5); + expect(datasetForm().maxFileSizeMiB).toBe(128); + expect(datasetForm().maxConcurrentChunks).toBe(7); + expect(datasetForm().chunkSizeMiB).toBe(64); + // Each family reads only its own keys, so the model card cannot inherit the dataset ceiling. + expect(modelForm().maxConcurrentFiles).toBe(2); + expect(modelForm().maxFileSizeMiB).toBe(4096); + expect(modelForm().maxConcurrentChunks).toBe(4); + expect(modelForm().chunkSizeMiB).toBe(32); expect(component.csvMaxColumns).toBe(4096); }); @@ -91,8 +105,10 @@ describe("AdminSettingsComponent", () => { }); expect(component.logoData).toBeNull(); - expect(component.maxFileSizeMiB).toBe(20); - expect(component.maxConcurrentFiles).toBe(3); + expect(datasetForm().maxFileSizeMiB).toBe(20); + expect(datasetForm().maxConcurrentFiles).toBe(3); + // The model default is the 2 GiB ceiling #8000 gave models, not the dataset's 20 MiB. + expect(modelForm().maxFileSizeMiB).toBe(2048); expect(component.csvMaxColumns).toBe(512); }); @@ -111,7 +127,7 @@ describe("AdminSettingsComponent", () => { csv_parser_max_columns: "0", }); - expect(component.maxConcurrentFiles).toBe(0); + expect(datasetForm().maxConcurrentFiles).toBe(0); expect(component.csvMaxColumns).toBe(0); }); @@ -287,11 +303,22 @@ describe("AdminSettingsComponent", () => { }); }); - describe("dataset settings", () => { - it("saveDatasetSettings PUTs the four upload settings and notifies success", () => { - completeLoad(); // defaults (20 / 3 / 10 / 50) are valid + describe.each([ + ["Dataset", "dataset", 20], + ["Model", "model", 2048], + ])("%s upload settings", (label, prefix, defaultMaxFileSizeMiB) => { + const group = () => component.uploadGroups.find(g => g.label === label)!; + const keys = [ + `${prefix}_max_number_of_concurrent_uploading_file`, + `${prefix}_single_file_upload_max_size_mib`, + `${prefix}_max_number_of_concurrent_uploading_file_chunks`, + `${prefix}_multipart_upload_chunk_size_mib`, + ]; + + it("PUTs the four upload settings of its own family and notifies success", () => { + completeLoad(); // the initializer defaults are valid - component.saveDatasetSettings(); + component.saveUploadSettings(group()); const expectPut = (key: string, value: string) => { const req = httpTestingController.expectOne(updateUrl(key)); @@ -299,109 +326,97 @@ describe("AdminSettingsComponent", () => { expect(req.request.body).toEqual({ value }); req.flush(null); }; - expectPut("dataset_max_number_of_concurrent_uploading_file", "3"); - expectPut("dataset_single_file_upload_max_size_mib", "20"); - expectPut("dataset_max_number_of_concurrent_uploading_file_chunks", "10"); - expectPut("dataset_multipart_upload_chunk_size_mib", "50"); + expectPut(keys[0], "3"); + expectPut(keys[1], String(defaultMaxFileSizeMiB)); + expectPut(keys[2], "10"); + expectPut(keys[3], "50"); - expect(msgSuccess).toHaveBeenCalledWith("Dataset upload settings saved successfully."); + // Saving one family must not touch the other's keys. + httpTestingController.expectNone((req: { method: string }) => req.method === "PUT"); + expect(msgSuccess).toHaveBeenCalledWith(`${label} upload settings saved successfully.`); }); - it("saveDatasetSettings refuses to save before the bulk load completes", () => { + it("refuses to save before the bulk load completes", () => { // The ngOnInit GET is left outstanding on purpose: settingsLoaded is still false. const pending = httpTestingController.expectOne(SETTINGS_URL); - component.saveDatasetSettings(); + component.saveUploadSettings(group()); httpTestingController.expectNone((req: { method: string }) => req.method === "PUT"); expect(msgError).toHaveBeenCalledWith("Settings have not loaded; refresh before saving."); pending.flush({}); }); - it("saveDatasetSettings rejects non-positive values without saving", () => { + it("rejects non-positive values without saving", () => { completeLoad(); - component.maxFileSizeMiB = 0; + group().form.maxFileSizeMiB = 0; - component.saveDatasetSettings(); + component.saveUploadSettings(group()); httpTestingController.expectNone((req: { method: string }) => req.method === "PUT"); expect(msgError).toHaveBeenCalledWith("Please enter valid integer values."); }); - it("saveDatasetSettings rejects a configuration that would exceed the 10,000-part limit", () => { + it("rejects a configuration that would exceed the 10,000-part limit", () => { completeLoad(); - component.maxFileSizeMiB = 100000; - component.chunkSizeMiB = 1; + group().form.maxFileSizeMiB = 100000; + group().form.chunkSizeMiB = 1; - component.saveDatasetSettings(); + component.saveUploadSettings(group()); httpTestingController.expectNone((req: { method: string }) => req.method === "PUT"); expect(msgError).toHaveBeenCalled(); }); - it("saveDatasetSettings notifies an error when a request fails", () => { + it("notifies an error when a request fails", () => { completeLoad(); - component.saveDatasetSettings(); + component.saveUploadSettings(group()); // Fail the last of the four PUTs so forkJoin errors with every request flushed. - const keys = [ - "dataset_max_number_of_concurrent_uploading_file", - "dataset_single_file_upload_max_size_mib", - "dataset_max_number_of_concurrent_uploading_file_chunks", - "dataset_multipart_upload_chunk_size_mib", - ]; keys.forEach((key, i) => { const req = httpTestingController.expectOne(updateUrl(key)); if (i === keys.length - 1) req.flush("boom", HTTP_ERROR); else req.flush(null); }); - expect(msgError).toHaveBeenCalledWith("Failed to save dataset settings."); + expect(msgError).toHaveBeenCalledWith(`Failed to save ${prefix} settings.`); }); - it("resetDatasetSettings POSTs a reset for all four upload settings", () => { + it("POSTs a reset for all four upload settings", () => { completeLoad(); - component.resetDatasetSettings(); + component.resetUploadSettings(group()); - [ - "dataset_max_number_of_concurrent_uploading_file", - "dataset_single_file_upload_max_size_mib", - "dataset_max_number_of_concurrent_uploading_file_chunks", - "dataset_multipart_upload_chunk_size_mib", - ].forEach(key => httpTestingController.expectOne(resetUrl(key)).flush(null)); - expect(msgInfo).toHaveBeenCalledWith("Resetting dataset settings..."); + keys.forEach(key => httpTestingController.expectOne(resetUrl(key)).flush(null)); + expect(msgInfo).toHaveBeenCalledWith(`Resetting ${prefix} settings...`); }); }); - // The issue labels these lines as `resetTabs`; they are actually the two computed - // getters that sit just below it, so the tests target the getters. + // The issue labels these lines as `resetTabs`; they are actually the two part-size + // computations that sit just below it, so the tests target those. describe("computed part-size properties", () => { it("partsAtMax is 0 unless both the total size and the chunk size are set", () => { completeLoad(); - component.maxFileSizeMiB = 0; - component.chunkSizeMiB = 8; - expect(component.partsAtMax).toBe(0); - - component.maxFileSizeMiB = 100; - component.chunkSizeMiB = 0; - expect(component.partsAtMax).toBe(0); - - component.maxFileSizeMiB = 100; - component.chunkSizeMiB = 8; - expect(component.partsAtMax).toBe(13); + expect(component.partsAtMax({ ...datasetForm(), maxFileSizeMiB: 0, chunkSizeMiB: 8 })).toBe(0); + expect(component.partsAtMax({ ...datasetForm(), maxFileSizeMiB: 100, chunkSizeMiB: 0 })).toBe(0); + expect(component.partsAtMax({ ...datasetForm(), maxFileSizeMiB: 100, chunkSizeMiB: 8 })).toBe(13); }); it("requiredMinPartSizeMiB falls back to the floor when no total size is set", () => { completeLoad(); - component.maxFileSizeMiB = 0; - expect(component.requiredMinPartSizeMiB).toBe(component.MIN_PART_SIZE_MiB); + expect(component.requiredMinPartSizeMiB({ ...datasetForm(), maxFileSizeMiB: 0 })).toBe( + component.MIN_PART_SIZE_MiB + ); // Above the floor the parts limit takes over: 10,000 parts must cover the total. - component.maxFileSizeMiB = component.MIN_PART_SIZE_MiB * component.MAX_TOTAL_PARTS * 2; - expect(component.requiredMinPartSizeMiB).toBe(component.MIN_PART_SIZE_MiB * 2); + expect( + component.requiredMinPartSizeMiB({ + ...datasetForm(), + maxFileSizeMiB: component.MIN_PART_SIZE_MiB * component.MAX_TOTAL_PARTS * 2, + }) + ).toBe(component.MIN_PART_SIZE_MiB * 2); }); }); @@ -568,14 +583,12 @@ describe("AdminSettingsComponent wiring", () => { "about_enabled", ] as const; - /** Numeric inputs in the order the template renders them. */ - const NUMBER_FIELDS = [ - "maxConcurrentFiles", - "maxFileSizeMiB", - "maxConcurrentChunks", - "chunkSizeMiB", - "csvMaxColumns", - ] as const; + /** Numeric inputs in the order the template renders them: each upload card, then the CSV card. */ + const UPLOAD_FIELDS = ["maxConcurrentFiles", "maxFileSizeMiB", "maxConcurrentChunks", "chunkSizeMiB"] as const; + const numberFields = (): Array<() => number> => [ + ...component.uploadGroups.flatMap(group => UPLOAD_FIELDS.map(field => () => group.form[field])), + () => component.csvMaxColumns, + ]; beforeEach(async () => { TestBed.resetTestingModule(); @@ -685,17 +698,18 @@ describe("AdminSettingsComponent wiring", () => { describe("numeric settings", () => { it("gives every number input its own field, in template order", () => { - expect(numberInputs().length).toBe(NUMBER_FIELDS.length); + const fields = numberFields(); + expect(numberInputs().length).toBe(fields.length); - NUMBER_FIELDS.forEach((field, i) => { + fields.forEach((_, i) => { numberInputs()[i].triggerEventHandler("ngModelChange", 42 + i); - - expect((component as any)[field]).toBe(42 + i); }); + fields.forEach((read, i) => expect(read()).toBe(42 + i)); - // Distinct values, so a shared target would have collapsed them. - const values = NUMBER_FIELDS.map(f => (component as any)[f]); - expect(new Set(values).size).toBe(NUMBER_FIELDS.length); + // Distinct values, so a shared target — including one shared across the two upload + // cards — would have collapsed them. + const values = fields.map(read => read()); + expect(new Set(values).size).toBe(fields.length); }); }); @@ -704,17 +718,18 @@ describe("AdminSettingsComponent wiring", () => { const spies = { saveLogos: vi.spyOn(component, "saveLogos").mockImplementation(() => {}), saveTabs: vi.spyOn(component, "saveTabs").mockImplementation(() => {}), - saveDatasetSettings: vi.spyOn(component, "saveDatasetSettings").mockImplementation(() => {}), + saveUploadSettings: vi.spyOn(component, "saveUploadSettings").mockImplementation(() => {}), saveCsvSettings: vi.spyOn(component, "saveCsvSettings").mockImplementation(() => {}), }; const saves = buttonsLabelled("Save"); - expect(saves.length).toBe(4); + expect(saves.length).toBe(2 + component.uploadGroups.length + 1); saves.forEach(b => b.click()); expect(spies.saveLogos).toHaveBeenCalledTimes(1); expect(spies.saveTabs).toHaveBeenCalledTimes(1); - expect(spies.saveDatasetSettings).toHaveBeenCalledTimes(1); + // Each upload card passes its own group, so the two cards cannot save each other's keys. + expect(spies.saveUploadSettings.mock.calls.map(call => call[0])).toEqual(component.uploadGroups); expect(spies.saveCsvSettings).toHaveBeenCalledTimes(1); }); @@ -722,17 +737,17 @@ describe("AdminSettingsComponent wiring", () => { const spies = { resetBranding: vi.spyOn(component, "resetBranding").mockImplementation(() => {}), resetTabs: vi.spyOn(component, "resetTabs").mockImplementation(() => {}), - resetDatasetSettings: vi.spyOn(component, "resetDatasetSettings").mockImplementation(() => {}), + resetUploadSettings: vi.spyOn(component, "resetUploadSettings").mockImplementation(() => {}), resetCsvSettings: vi.spyOn(component, "resetCsvSettings").mockImplementation(() => {}), }; const resets = buttonsLabelled("Reset"); - expect(resets.length).toBe(4); + expect(resets.length).toBe(2 + component.uploadGroups.length + 1); resets.forEach(b => b.click()); expect(spies.resetBranding).toHaveBeenCalledTimes(1); expect(spies.resetTabs).toHaveBeenCalledTimes(1); - expect(spies.resetDatasetSettings).toHaveBeenCalledTimes(1); + expect(spies.resetUploadSettings.mock.calls.map(call => call[0])).toEqual(component.uploadGroups); expect(spies.resetCsvSettings).toHaveBeenCalledTimes(1); }); }); diff --git a/frontend/src/app/dashboard/component/admin/settings/admin-settings.component.ts b/frontend/src/app/dashboard/component/admin/settings/admin-settings.component.ts index 100ba45d036..744aa7ab266 100644 --- a/frontend/src/app/dashboard/component/admin/settings/admin-settings.component.ts +++ b/frontend/src/app/dashboard/component/admin/settings/admin-settings.component.ts @@ -24,19 +24,57 @@ import { NotificationService } from "../../../../common/service/notification/not import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy"; import { SidebarTabs } from "../../../../common/type/gui-config"; import { parseIntOrDefault } from "../../../../common/util/format.util"; +import { + DATASET_FILE_RESOURCE_ENDPOINT, + FileResourceEndpoint, + MODEL_FILE_RESOURCE_ENDPOINT, +} from "../../../service/user/file-resource/file-resource-endpoint"; import { forkJoin } from "rxjs"; import { NzCardComponent } from "ng-zorro-antd/card"; import { NzSpaceCompactItemDirective } from "ng-zorro-antd/space"; import { NzButtonComponent } from "ng-zorro-antd/button"; import { NzWaveDirective } from "ng-zorro-antd/core/wave"; import { ɵNzTransitionPatchDirective } from "ng-zorro-antd/core/transition-patch"; -import { NgIf, DecimalPipe } from "@angular/common"; +import { NgFor, NgIf, DecimalPipe } from "@angular/common"; import { NzIconDirective } from "ng-zorro-antd/icon"; import { NzSwitchComponent } from "ng-zorro-antd/switch"; import { FormsModule } from "@angular/forms"; import { NzTooltipDirective } from "ng-zorro-antd/tooltip"; import { NzInputNumberComponent } from "ng-zorro-antd/input-number"; +/** The four multipart-upload settings one resource family exposes on this page. */ +export interface UploadSettingsForm { + maxConcurrentFiles: number; + maxFileSizeMiB: number; + maxConcurrentChunks: number; + chunkSizeMiB: number; +} + +/** One upload card: a resource family, the site_settings keys it owns, and the edited values. */ +export interface UploadSettingsGroup { + /** Title case of the endpoint's own label, so a family is never named twice. */ + readonly label: string; + readonly endpoint: FileResourceEndpoint; + readonly form: UploadSettingsForm; +} + +// Shared across families in default.conf; only the per-file ceiling differs, and the endpoint +// descriptor already carries that. +const DEFAULT_MAX_CONCURRENT_FILES = 3; +const DEFAULT_MAX_CONCURRENT_CHUNKS = 10; +const DEFAULT_CHUNK_SIZE_MIB = 50; + +const uploadGroup = (endpoint: FileResourceEndpoint): UploadSettingsGroup => ({ + label: endpoint.label.charAt(0).toUpperCase() + endpoint.label.slice(1), + endpoint, + form: { + maxConcurrentFiles: DEFAULT_MAX_CONCURRENT_FILES, + maxFileSizeMiB: endpoint.defaultMaxFileSizeMiB, + maxConcurrentChunks: DEFAULT_MAX_CONCURRENT_CHUNKS, + chunkSizeMiB: DEFAULT_CHUNK_SIZE_MIB, + }, +}); + @UntilDestroy() @Component({ selector: "texera-settings", @@ -49,6 +87,7 @@ import { NzInputNumberComponent } from "ng-zorro-antd/input-number"; NzWaveDirective, ɵNzTransitionPatchDirective, NgIf, + NgFor, NzIconDirective, NzSwitchComponent, FormsModule, @@ -77,10 +116,11 @@ export class AdminSettingsComponent implements OnInit { about_enabled: false, }; - maxConcurrentFiles: number = 3; - maxFileSizeMiB: number = 20; - maxConcurrentChunks: number = 10; - chunkSizeMiB: number = 50; + // One card per resource family; adding a family here adds its card, with no new save/reset code. + readonly uploadGroups: UploadSettingsGroup[] = [ + uploadGroup(DATASET_FILE_RESOURCE_ENDPOINT), + uploadGroup(MODEL_FILE_RESOURCE_ENDPOINT), + ]; csvMaxColumns: number = 512; @@ -123,19 +163,18 @@ export class AdminSettingsComponent implements OnInit { (Object.keys(this.sidebarTabs) as (keyof SidebarTabs)[]).forEach( tab => (this.sidebarTabs[tab] = settings[tab] === "true") ); - this.maxConcurrentFiles = parseIntOrDefault( - settings["dataset_max_number_of_concurrent_uploading_file"], - this.maxConcurrentFiles - ); - this.maxFileSizeMiB = parseIntOrDefault( - settings["dataset_single_file_upload_max_size_mib"], - this.maxFileSizeMiB - ); - this.maxConcurrentChunks = parseIntOrDefault( - settings["dataset_max_number_of_concurrent_uploading_file_chunks"], - this.maxConcurrentChunks - ); - this.chunkSizeMiB = parseIntOrDefault(settings["dataset_multipart_upload_chunk_size_mib"], this.chunkSizeMiB); + this.uploadGroups.forEach(({ endpoint, form }) => { + form.maxConcurrentFiles = parseIntOrDefault( + settings[endpoint.maxConcurrentFilesSettingKey], + form.maxConcurrentFiles + ); + form.maxFileSizeMiB = parseIntOrDefault(settings[endpoint.maxFileSizeSettingKey], form.maxFileSizeMiB); + form.maxConcurrentChunks = parseIntOrDefault( + settings[endpoint.maxConcurrentChunksSettingKey], + form.maxConcurrentChunks + ); + form.chunkSizeMiB = parseIntOrDefault(settings[endpoint.chunkSizeSettingKey], form.chunkSizeMiB); + }); this.csvMaxColumns = parseIntOrDefault(settings["csv_parser_max_columns"], this.csvMaxColumns); this.settingsLoaded = true; }, @@ -227,73 +266,69 @@ export class AdminSettingsComponent implements OnInit { } // Computed properties - get partsAtMax(): number { - if (!this.maxFileSizeMiB || !this.chunkSizeMiB) return 0; - return Math.ceil(this.maxFileSizeMiB / this.chunkSizeMiB); + partsAtMax(form: UploadSettingsForm): number { + if (!form.maxFileSizeMiB || !form.chunkSizeMiB) return 0; + return Math.ceil(form.maxFileSizeMiB / form.chunkSizeMiB); } - get requiredMinPartSizeMiB(): number { - if (!this.maxFileSizeMiB) return this.MIN_PART_SIZE_MiB; - const byPartsLimit = Math.ceil(this.maxFileSizeMiB / this.MAX_TOTAL_PARTS); + requiredMinPartSizeMiB(form: UploadSettingsForm): number { + if (!form.maxFileSizeMiB) return this.MIN_PART_SIZE_MiB; + const byPartsLimit = Math.ceil(form.maxFileSizeMiB / this.MAX_TOTAL_PARTS); return Math.max(this.MIN_PART_SIZE_MiB, byPartsLimit); } - saveDatasetSettings(): void { + // The four key/value pairs of one group, in the order the card lists them. + private settingEntries({ endpoint, form }: UploadSettingsGroup): Array<[string, number]> { + return [ + [endpoint.maxConcurrentFilesSettingKey, form.maxConcurrentFiles], + [endpoint.maxFileSizeSettingKey, form.maxFileSizeMiB], + [endpoint.maxConcurrentChunksSettingKey, form.maxConcurrentChunks], + [endpoint.chunkSizeSettingKey, form.chunkSizeMiB], + ]; + } + + saveUploadSettings(group: UploadSettingsGroup): void { if (!this.settingsLoaded) { this.message.error("Settings have not loaded; refresh before saving."); return; } + const { form } = group; if ( - this.maxFileSizeMiB < 1 || - this.maxConcurrentFiles < 1 || - this.maxConcurrentChunks < 1 || - this.chunkSizeMiB < 1 + form.maxFileSizeMiB < 1 || + form.maxConcurrentFiles < 1 || + form.maxConcurrentChunks < 1 || + form.chunkSizeMiB < 1 ) { this.message.error("Please enter valid integer values."); return; } - if (this.partsAtMax > this.MAX_TOTAL_PARTS) { + if (this.partsAtMax(form) > this.MAX_TOTAL_PARTS) { this.message.error( - `This setting would create ${this.partsAtMax.toLocaleString()} parts (exceeds 10,000 limit). ` + - `Increase "Part Size" to at least ${this.requiredMinPartSizeMiB} MiB or reduce "File Size".` + `This setting would create ${this.partsAtMax(form).toLocaleString()} parts (exceeds 10,000 limit). ` + + `Increase "Part Size" to at least ${this.requiredMinPartSizeMiB(form)} MiB or reduce "File Size".` ); return; } - const saveRequests = [ - this.adminSettingsService.updateSetting( - "dataset_max_number_of_concurrent_uploading_file", - this.maxConcurrentFiles.toString() - ), - this.adminSettingsService.updateSetting( - "dataset_single_file_upload_max_size_mib", - this.maxFileSizeMiB.toString() - ), - this.adminSettingsService.updateSetting( - "dataset_max_number_of_concurrent_uploading_file_chunks", - this.maxConcurrentChunks.toString() - ), - this.adminSettingsService.updateSetting("dataset_multipart_upload_chunk_size_mib", this.chunkSizeMiB.toString()), - ]; + const saveRequests = this.settingEntries(group).map(([key, value]) => + this.adminSettingsService.updateSetting(key, value.toString()) + ); forkJoin(saveRequests) .pipe(untilDestroyed(this)) .subscribe({ - next: () => this.message.success("Dataset upload settings saved successfully."), - error: () => this.message.error("Failed to save dataset settings."), + next: () => this.message.success(`${group.label} upload settings saved successfully.`), + error: () => this.message.error(`Failed to save ${group.endpoint.label} settings.`), }); } - resetDatasetSettings(): void { - [ - "dataset_max_number_of_concurrent_uploading_file", - "dataset_single_file_upload_max_size_mib", - "dataset_max_number_of_concurrent_uploading_file_chunks", - "dataset_multipart_upload_chunk_size_mib", - ].forEach(setting => this.adminSettingsService.resetSetting(setting).pipe(untilDestroyed(this)).subscribe({})); + resetUploadSettings(group: UploadSettingsGroup): void { + this.settingEntries(group).forEach(([key]) => + this.adminSettingsService.resetSetting(key).pipe(untilDestroyed(this)).subscribe({}) + ); - this.message.info("Resetting dataset settings..."); + this.message.info(`Resetting ${group.endpoint.label} settings...`); setTimeout(() => window.location.reload(), this.RELOAD_DELAY); } diff --git a/frontend/src/app/dashboard/component/user/files-uploader/files-uploader.component.spec.ts b/frontend/src/app/dashboard/component/user/files-uploader/files-uploader.component.spec.ts index f0f9eeed191..f895435475c 100644 --- a/frontend/src/app/dashboard/component/user/files-uploader/files-uploader.component.spec.ts +++ b/frontend/src/app/dashboard/component/user/files-uploader/files-uploader.component.spec.ts @@ -27,7 +27,11 @@ import { NzAlertComponent } from "ng-zorro-antd/alert"; import { NzModalService } from "ng-zorro-antd/modal"; import { commonTestProviders } from "../../../../common/testing/test-utils"; import { AdminSettingsService } from "../../../service/admin/settings/admin-settings.service"; -import { DatasetService } from "../../../service/user/dataset/dataset.service"; +import { MultipartUploadService } from "../../../service/user/file-resource/multipart-upload.service"; +import { + DATASET_FILE_RESOURCE_ENDPOINT, + FileResourceEndpoint, +} from "../../../service/user/file-resource/file-resource-endpoint"; import { NotificationService } from "../../../../common/service/notification/notification.service"; import { FileUploadItem } from "../../../type/dashboard-file.interface"; import { FilesUploaderComponent } from "./files-uploader.component"; @@ -61,14 +65,32 @@ const droppedFile = (relativePath: string, file: File): NgxFileDropEntry => }, }) as unknown as NgxFileDropEntry; +/** A resource family that is neither dataset nor model, to prove the uploader is parameterized. */ +const WIDGET_ENDPOINT: FileResourceEndpoint = { + baseUrl: "widget", + label: "widget", + nameParamKey: "widgetName", + maxFileSizeSettingKey: "widget_single_file_upload_max_size_mib", + defaultMaxFileSizeMiB: 64, + chunkSizeSettingKey: "widget_multipart_upload_chunk_size_mib", + maxConcurrentChunksSettingKey: "widget_max_number_of_concurrent_uploading_file_chunks", + maxConcurrentFilesSettingKey: "widget_max_number_of_concurrent_uploading_file", +}; + describe("FilesUploaderComponent", () => { let component: FilesUploaderComponent; let modals: CapturedModal[]; - let datasetService: { + let uploadService: { listMultipartUploads: ReturnType; findExistingUploadFiles: ReturnType; }; + /** Builds an initialized uploader; the size ceiling is only read in ngOnInit. */ + let buildUploader: ( + adminSettingsService: AdminSettingsService, + notificationService?: NotificationService + ) => FilesUploaderComponent; + beforeEach(() => { modals = []; const modal = { @@ -80,30 +102,37 @@ describe("FilesUploaderComponent", () => { const adminSettingsService = { getPublicSetting: vi.fn().mockReturnValue(of("20")), } as unknown as AdminSettingsService; - datasetService = { + uploadService = { listMultipartUploads: vi.fn().mockReturnValue(of(["failed.csv"])), findExistingUploadFiles: vi.fn().mockReturnValue(of(["done.csv"])), }; + buildUploader = (settings, notify = { error: vi.fn() } as unknown as NotificationService) => { + const uploader = new FilesUploaderComponent( + notify, + settings, + uploadService as unknown as MultipartUploadService, + { create: vi.fn() } as unknown as NzModalService + ); + uploader.ngOnInit(); + return uploader; + }; + component = new FilesUploaderComponent( { error: vi.fn() } as unknown as NotificationService, adminSettingsService, - datasetService as unknown as DatasetService, + uploadService as unknown as MultipartUploadService, modal ); + component.ngOnInit(); component.ownerEmail = "owner@example.com"; - component.datasetName = "dataset"; - component.did = 7; + component.resourceName = "dataset"; + component.resourceId = 7; }); it("keeps the default upload size limit when the public setting is missing, and parses it when present", () => { const build = (value: string | null) => - new FilesUploaderComponent( - { error: vi.fn() } as unknown as NotificationService, - { getPublicSetting: vi.fn().mockReturnValue(of(value)) } as unknown as AdminSettingsService, - datasetService as unknown as DatasetService, - { create: vi.fn() } as unknown as NzModalService - ); + buildUploader({ getPublicSetting: vi.fn().mockReturnValue(of(value)) } as unknown as AdminSettingsService); expect(build(null).singleFileUploadMaxSizeMiB).toBe(20); expect(build("128").singleFileUploadMaxSizeMiB).toBe(128); @@ -114,16 +143,26 @@ describe("FilesUploaderComponent", () => { it("keeps the default upload size limit when the setting request fails", () => { // The component swallows the error on purpose so a settings outage cannot stop uploads. + const uploader = buildUploader({ + getPublicSetting: vi.fn().mockReturnValue(throwError(() => new Error("settings unavailable"))), + } as unknown as AdminSettingsService); + + expect(uploader.singleFileUploadMaxSizeMiB).toBe(20); + }); + + it("takes the ceiling key and fallback from the configured endpoint", () => { + const getPublicSetting = vi.fn().mockReturnValue(of(null)); const uploader = new FilesUploaderComponent( { error: vi.fn() } as unknown as NotificationService, - { - getPublicSetting: vi.fn().mockReturnValue(throwError(() => new Error("settings unavailable"))), - } as unknown as AdminSettingsService, - datasetService as unknown as DatasetService, + { getPublicSetting } as unknown as AdminSettingsService, + uploadService as unknown as MultipartUploadService, { create: vi.fn() } as unknown as NzModalService ); + uploader.endpoint = WIDGET_ENDPOINT; + uploader.ngOnInit(); - expect(uploader.singleFileUploadMaxSizeMiB).toBe(20); + expect(getPublicSetting).toHaveBeenCalledWith("widget_single_file_upload_max_size_mib"); + expect(uploader.singleFileUploadMaxSizeMiB).toBe(64); }); it("asks to resume failed multipart files and skip completed matching files in one retry batch", async () => { @@ -149,8 +188,8 @@ describe("FilesUploaderComponent", () => { }); it("asks both questions when the same file has an active upload session and an existing match", async () => { - datasetService.listMultipartUploads.mockReturnValue(of(["same.csv"])); - datasetService.findExistingUploadFiles.mockReturnValue(of(["same.csv"])); + uploadService.listMultipartUploads.mockReturnValue(of(["same.csv"])); + uploadService.findExistingUploadFiles.mockReturnValue(of(["same.csv"])); const emitted = new Promise(resolve => component.uploadedFiles.subscribe(resolve)); component.fileDropped([droppedFile("same.csv", new File(["same"], "same.csv"))]); @@ -176,7 +215,7 @@ describe("FilesUploaderComponent", () => { * session the user asked to discard. */ it("marks a file for force-restart when Restart is chosen", async () => { - datasetService.findExistingUploadFiles.mockReturnValue(of([])); + uploadService.findExistingUploadFiles.mockReturnValue(of([])); const emitted = new Promise(resolve => component.uploadedFiles.subscribe(resolve)); component.fileDropped([droppedFile("failed.csv", new File(["half"], "failed.csv"))]); @@ -192,7 +231,7 @@ describe("FilesUploaderComponent", () => { it("leaves the restart flag unset when Resume is chosen", async () => { // The counterpart of the test above: same file, other button. Without this pair, a // markForceRestart call added to the Resume branch would go unnoticed. - datasetService.findExistingUploadFiles.mockReturnValue(of([])); + uploadService.findExistingUploadFiles.mockReturnValue(of([])); const emitted = new Promise(resolve => component.uploadedFiles.subscribe(resolve)); component.fileDropped([droppedFile("failed.csv", new File(["half"], "failed.csv"))]); @@ -206,8 +245,8 @@ describe("FilesUploaderComponent", () => { }); it("restarts every remaining conflicting file after one Restart For All choice", async () => { - datasetService.listMultipartUploads.mockReturnValue(of(["one.csv", "two.csv"])); - datasetService.findExistingUploadFiles.mockReturnValue(of([])); + uploadService.listMultipartUploads.mockReturnValue(of(["one.csv", "two.csv"])); + uploadService.findExistingUploadFiles.mockReturnValue(of([])); const emitted = new Promise(resolve => component.uploadedFiles.subscribe(resolve)); component.fileDropped([ @@ -227,8 +266,8 @@ describe("FilesUploaderComponent", () => { }); it("resumes every remaining conflicting file after one Resume For All choice", async () => { - datasetService.listMultipartUploads.mockReturnValue(of(["one.csv", "two.csv"])); - datasetService.findExistingUploadFiles.mockReturnValue(of([])); + uploadService.listMultipartUploads.mockReturnValue(of(["one.csv", "two.csv"])); + uploadService.findExistingUploadFiles.mockReturnValue(of([])); const emitted = new Promise(resolve => component.uploadedFiles.subscribe(resolve)); component.fileDropped([ @@ -247,8 +286,8 @@ describe("FilesUploaderComponent", () => { }); it("passes a non-conflicting file straight through without prompting", async () => { - datasetService.listMultipartUploads.mockReturnValue(of(["other.csv"])); - datasetService.findExistingUploadFiles.mockReturnValue(of([])); + uploadService.listMultipartUploads.mockReturnValue(of(["other.csv"])); + uploadService.findExistingUploadFiles.mockReturnValue(of([])); const emitted = new Promise(resolve => component.uploadedFiles.subscribe(resolve)); component.fileDropped([droppedFile("clean.csv", new File(["clean"], "clean.csv"))]); @@ -260,8 +299,8 @@ describe("FilesUploaderComponent", () => { }); it("skips all matching files after one Skip For All choice", async () => { - datasetService.listMultipartUploads.mockReturnValue(of([])); - datasetService.findExistingUploadFiles.mockReturnValue(of(["one.csv", "two.csv"])); + uploadService.listMultipartUploads.mockReturnValue(of([])); + uploadService.findExistingUploadFiles.mockReturnValue(of(["one.csv", "two.csv"])); const emitted = new Promise(resolve => component.uploadedFiles.subscribe(resolve)); component.fileDropped([ @@ -280,8 +319,8 @@ describe("FilesUploaderComponent", () => { }); it("uploads all matching files after one Upload For All choice", async () => { - datasetService.listMultipartUploads.mockReturnValue(of([])); - datasetService.findExistingUploadFiles.mockReturnValue(of(["one.csv", "two.csv"])); + uploadService.listMultipartUploads.mockReturnValue(of([])); + uploadService.findExistingUploadFiles.mockReturnValue(of(["one.csv", "two.csv"])); const emitted = new Promise(resolve => component.uploadedFiles.subscribe(resolve)); component.fileDropped([ @@ -343,11 +382,9 @@ describe("FilesUploaderComponent", () => { it("rejects a single oversized file and reports it in the banner", async () => { const notify = { error: vi.fn() }; - component = new FilesUploaderComponent( - notify as unknown as NotificationService, + component = buildUploader( { getPublicSetting: vi.fn().mockReturnValue(of("0")) } as unknown as AdminSettingsService, - datasetService as unknown as DatasetService, - { create: vi.fn() } as unknown as NzModalService + notify as unknown as NotificationService ); const emitted = emissionOf(); @@ -360,12 +397,9 @@ describe("FilesUploaderComponent", () => { }); it("pluralises the failure banner for more than one rejected file", async () => { - component = new FilesUploaderComponent( - { error: vi.fn() } as unknown as NotificationService, - { getPublicSetting: vi.fn().mockReturnValue(of("0")) } as unknown as AdminSettingsService, - datasetService as unknown as DatasetService, - { create: vi.fn() } as unknown as NzModalService - ); + component = buildUploader({ + getPublicSetting: vi.fn().mockReturnValue(of("0")), + } as unknown as AdminSettingsService); const emitted = emissionOf(); component.fileDropped([ @@ -406,23 +440,56 @@ describe("FilesUploaderComponent", () => { const emissionOf = (): Promise => new Promise(resolve => component.uploadedFiles.subscribe(resolve)); - it("skips both lookups when the uploader has no dataset context", async () => { - // The standalone (dataset-creation) usage: no owner/name and no did yet. + it("skips both lookups when the uploader has no resource context", async () => { + // The standalone (dataset-creation) usage: no owner/name and no id yet. component.ownerEmail = ""; - component.datasetName = ""; - component.did = undefined; + component.resourceName = ""; + component.resourceId = undefined; const emitted = emissionOf(); component.fileDropped([droppedFile("fresh.csv", new File(["new"], "fresh.csv"))]); expect((await emitted).map(item => item.name)).toEqual(["fresh.csv"]); - expect(datasetService.listMultipartUploads).not.toHaveBeenCalled(); - expect(datasetService.findExistingUploadFiles).not.toHaveBeenCalled(); + expect(uploadService.listMultipartUploads).not.toHaveBeenCalled(); + expect(uploadService.findExistingUploadFiles).not.toHaveBeenCalled(); + }); + + it("addresses both lookups through the configured endpoint", async () => { + component.endpoint = WIDGET_ENDPOINT; + uploadService.listMultipartUploads.mockReturnValue(of([])); + uploadService.findExistingUploadFiles.mockReturnValue(of([])); + const emitted = emissionOf(); + + component.fileDropped([droppedFile("w.bin", new File(["w"], "w.bin"))]); + await emitted; + + expect(uploadService.listMultipartUploads).toHaveBeenCalledWith(WIDGET_ENDPOINT, "owner@example.com", "dataset"); + expect(uploadService.findExistingUploadFiles).toHaveBeenCalledWith(WIDGET_ENDPOINT, 7, [ + { path: "w.bin", sizeBytes: 1 }, + ]); + }); + + it("defaults to the dataset endpoint when the embedder sets none", async () => { + uploadService.listMultipartUploads.mockReturnValue(of([])); + uploadService.findExistingUploadFiles.mockReturnValue(of([])); + const emitted = emissionOf(); + + component.fileDropped([droppedFile("d.csv", new File(["d"], "d.csv"))]); + await emitted; + + expect(uploadService.listMultipartUploads).toHaveBeenCalledWith( + DATASET_FILE_RESOURCE_ENDPOINT, + "owner@example.com", + "dataset" + ); + expect(uploadService.findExistingUploadFiles).toHaveBeenCalledWith(DATASET_FILE_RESOURCE_ENDPOINT, 7, [ + { path: "d.csv", sizeBytes: 1 }, + ]); }); it("treats a failed lookup as nothing to reconcile", async () => { - datasetService.listMultipartUploads.mockReturnValue(throwError(() => new Error("offline"))); - datasetService.findExistingUploadFiles.mockReturnValue(throwError(() => new Error("offline"))); + uploadService.listMultipartUploads.mockReturnValue(throwError(() => new Error("offline"))); + uploadService.findExistingUploadFiles.mockReturnValue(throwError(() => new Error("offline"))); const emitted = emissionOf(); component.fileDropped([droppedFile("failed.csv", new File(["half"], "failed.csv"))]); @@ -433,8 +500,8 @@ describe("FilesUploaderComponent", () => { }); it("treats a null lookup result as nothing to reconcile", async () => { - datasetService.listMultipartUploads.mockReturnValue(of(null)); - datasetService.findExistingUploadFiles.mockReturnValue(of(null)); + uploadService.listMultipartUploads.mockReturnValue(of(null)); + uploadService.findExistingUploadFiles.mockReturnValue(of(null)); const emitted = emissionOf(); component.fileDropped([droppedFile("failed.csv", new File(["half"], "failed.csv"))]); @@ -444,7 +511,7 @@ describe("FilesUploaderComponent", () => { }); it("reports an unexpected failure of the whole drop", async () => { - datasetService.listMultipartUploads.mockImplementation(() => { + uploadService.listMultipartUploads.mockImplementation(() => { throw new Error("lookup exploded"); }); @@ -456,7 +523,7 @@ describe("FilesUploaderComponent", () => { }); it("reports an unexpected failure that carries no message", async () => { - datasetService.listMultipartUploads.mockImplementation(() => { + uploadService.listMultipartUploads.mockImplementation(() => { throw "lookup exploded"; }); @@ -471,12 +538,9 @@ describe("FilesUploaderComponent", () => { // @UntilDestroy() supplies the ngOnDestroy that ends the `untilDestroyed(this)` // subscription; without it a late setting would still be applied to a dead component. const setting = new Subject(); - const uploader = new FilesUploaderComponent( - { error: vi.fn() } as unknown as NotificationService, - { getPublicSetting: vi.fn().mockReturnValue(setting) } as unknown as AdminSettingsService, - datasetService as unknown as DatasetService, - { create: vi.fn() } as unknown as NzModalService - ); + const uploader = buildUploader({ + getPublicSetting: vi.fn().mockReturnValue(setting), + } as unknown as AdminSettingsService); setting.next("50"); expect(uploader.singleFileUploadMaxSizeMiB).toBe(50); @@ -489,8 +553,8 @@ describe("FilesUploaderComponent", () => { describe("dialog titles for paths without a file name", () => { it("falls back to the whole path when the conflicting path ends in a separator", async () => { - datasetService.listMultipartUploads.mockReturnValue(of(["folder/"])); - datasetService.findExistingUploadFiles.mockReturnValue(of(["folder/"])); + uploadService.listMultipartUploads.mockReturnValue(of(["folder/"])); + uploadService.findExistingUploadFiles.mockReturnValue(of(["folder/"])); const emitted = new Promise(resolve => component.uploadedFiles.subscribe(resolve)); component.fileDropped([droppedFile("folder/", new File(["x"], "x"))]); @@ -524,7 +588,7 @@ describe("FilesUploaderComponent rendered", () => { { provide: NotificationService, useValue: { error: vi.fn() } }, { provide: AdminSettingsService, useValue: { getPublicSetting: vi.fn().mockReturnValue(of("20")) } }, { - provide: DatasetService, + provide: MultipartUploadService, useValue: { listMultipartUploads: vi.fn().mockReturnValue(of([])), findExistingUploadFiles: vi.fn().mockReturnValue(of([])), diff --git a/frontend/src/app/dashboard/component/user/files-uploader/files-uploader.component.ts b/frontend/src/app/dashboard/component/user/files-uploader/files-uploader.component.ts index 10aed3dfd84..f7d81b30a87 100644 --- a/frontend/src/app/dashboard/component/user/files-uploader/files-uploader.component.ts +++ b/frontend/src/app/dashboard/component/user/files-uploader/files-uploader.component.ts @@ -17,7 +17,7 @@ * under the License. */ -import { Component, EventEmitter, Input, Output } from "@angular/core"; +import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core"; import { firstValueFrom } from "rxjs"; import { NgxFileDropEntry, NgxFileDropModule } from "ngx-file-drop"; import { NzModalRef, NzModalService } from "ng-zorro-antd/modal"; @@ -26,7 +26,11 @@ import { DatasetFileNode } from "../../../../common/type/datasetVersionFileTree" import { NotificationService } from "../../../../common/service/notification/notification.service"; import { AdminSettingsService } from "../../../service/admin/settings/admin-settings.service"; import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy"; -import { DatasetService } from "../../../service/user/dataset/dataset.service"; +import { MultipartUploadService } from "../../../service/user/file-resource/multipart-upload.service"; +import { + DATASET_FILE_RESOURCE_ENDPOINT, + FileResourceEndpoint, +} from "../../../service/user/file-resource/file-resource-endpoint"; import { formatSize } from "../../../../common/util/size-formatter.util"; import { parseIntOrDefault } from "../../../../common/util/format.util"; import { @@ -55,18 +59,18 @@ import { ɵNzTransitionPatchDirective } from "ng-zorro-antd/core/transition-patc ɵNzTransitionPatchDirective, ], }) -export class FilesUploaderComponent { +export class FilesUploaderComponent implements OnInit { @Input() showUploadAlert: boolean = false; /** - * Optional context fields supplied by the embedding component. When the - * uploader is used inside `DatasetDetailComponent`, the parent passes - * `ownerEmail` and `datasetName` so the uploader can address staged files - * under the right owner/dataset path. When used standalone (e.g. dataset - * creation flow), they default to empty. + * Optional context supplied by the embedding component so the uploader can address staged files + * under the right owner/resource path. When used standalone (e.g. dataset creation flow) they + * default to empty and the conflict lookups are skipped. */ @Input() ownerEmail: string = ""; - @Input() datasetName: string = ""; - @Input() did: number | undefined; + @Input() resourceName: string = ""; + @Input() resourceId: number | undefined; + /** Which resource family the ids above belong to. */ + @Input() endpoint: FileResourceEndpoint = DATASET_FILE_RESOURCE_ENDPOINT; @Output() uploadedFiles = new EventEmitter(); @@ -75,17 +79,21 @@ export class FilesUploaderComponent { fileUploadingFinished: boolean = false; fileUploadBannerType: "error" | "success" | "info" | "warning" = "success"; fileUploadBannerMessage: string = ""; - singleFileUploadMaxSizeMiB: number = 20; + singleFileUploadMaxSizeMiB: number = DATASET_FILE_RESOURCE_ENDPOINT.defaultMaxFileSizeMiB; constructor( private notificationService: NotificationService, private adminSettingsService: AdminSettingsService, - private datasetService: DatasetService, + private multipartUploadService: MultipartUploadService, private modal: NzModalService - ) { - // A missing key or failed fetch keeps the initializer default above. + ) {} + + // The ceiling is read here rather than in the constructor because `endpoint` is an @Input, and it + // decides both the setting key and the fallback. A missing key or failed fetch keeps the fallback. + ngOnInit(): void { + this.singleFileUploadMaxSizeMiB = this.endpoint.defaultMaxFileSizeMiB; this.adminSettingsService - .getPublicSetting("dataset_single_file_upload_max_size_mib") + .getPublicSetting(this.endpoint.maxFileSizeSettingKey) .pipe(untilDestroyed(this)) .subscribe({ next: value => (this.singleFileUploadMaxSizeMiB = parseIntOrDefault(value, this.singleFileUploadMaxSizeMiB)), @@ -179,7 +187,7 @@ export class FilesUploaderComponent { fileName, path: item.name, size: formatSize(item.file.size), - hint: "A file with the same path and size exists in this dataset. Skip only if you expect it is the same file.", + hint: `A file with the same path and size exists in this ${this.endpoint.label}. Skip only if you expect it is the same file.`, }, nzFooter: [ ...(showForAll ? [button("Upload For All", "uploadAll"), button("Skip For All", "skipAll")] : []), @@ -273,10 +281,10 @@ export class FilesUploaderComponent { this.fileUploadBannerMessage = bannerMessage; } - private getOwnerAndName(): { ownerEmail: string; datasetName: string } { + private getOwnerAndName(): { ownerEmail: string; resourceName: string } { return { ownerEmail: this.ownerEmail, - datasetName: this.datasetName, + resourceName: this.resourceName, }; } @@ -314,7 +322,7 @@ export class FilesUploaderComponent { Promise.allSettled(filePromises) .then(async results => { - const { ownerEmail, datasetName } = this.getOwnerAndName(); + const { ownerEmail, resourceName } = this.getOwnerAndName(); const successfulUploads = results .filter((r): r is PromiseFulfilledResult => r.status === "fulfilled") @@ -322,13 +330,16 @@ export class FilesUploaderComponent { .filter((item): item is FileUploadItem => item !== null); const activePathsPromise: Promise = - ownerEmail && datasetName - ? firstValueFrom(this.datasetService.listMultipartUploads(ownerEmail, datasetName)).catch(() => []) + ownerEmail && resourceName + ? firstValueFrom( + this.multipartUploadService.listMultipartUploads(this.endpoint, ownerEmail, resourceName) + ).catch(() => []) : Promise.resolve([]); - const existingPathsPromise: Promise = this.did + const existingPathsPromise: Promise = this.resourceId ? firstValueFrom( - this.datasetService.findExistingUploadFiles( - this.did, + this.multipartUploadService.findExistingUploadFiles( + this.endpoint, + this.resourceId, successfulUploads.map(item => ({ path: item.name, sizeBytes: item.file.size })) ) ).catch(() => []) diff --git a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-staged-objects-list/user-dataset-staged-objects-list.component.html b/frontend/src/app/dashboard/component/user/staged-objects-list/staged-objects-list.component.html similarity index 91% rename from frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-staged-objects-list/user-dataset-staged-objects-list.component.html rename to frontend/src/app/dashboard/component/user/staged-objects-list/staged-objects-list.component.html index a6785abd50d..200cbaa3ac2 100644 --- a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-staged-objects-list/user-dataset-staged-objects-list.component.html +++ b/frontend/src/app/dashboard/component/user/staged-objects-list/staged-objects-list.component.html @@ -19,12 +19,12 @@
+ [class.has-list]="stagedObjects.length > 0"> + *cdkVirtualFor="let obj of stagedObjects; trackBy: trackByStagedObject"> {{ obj.diffType }} @@ -65,6 +65,6 @@
diff --git a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-staged-objects-list/user-dataset-staged-objects-list.component.scss b/frontend/src/app/dashboard/component/user/staged-objects-list/staged-objects-list.component.scss similarity index 100% rename from frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-staged-objects-list/user-dataset-staged-objects-list.component.scss rename to frontend/src/app/dashboard/component/user/staged-objects-list/staged-objects-list.component.scss diff --git a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-staged-objects-list/user-dataset-staged-objects-list.component.spec.ts b/frontend/src/app/dashboard/component/user/staged-objects-list/staged-objects-list.component.spec.ts similarity index 73% rename from frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-staged-objects-list/user-dataset-staged-objects-list.component.spec.ts rename to frontend/src/app/dashboard/component/user/staged-objects-list/staged-objects-list.component.spec.ts index 54c25e3bb2e..d476e389da8 100644 --- a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-staged-objects-list/user-dataset-staged-objects-list.component.spec.ts +++ b/frontend/src/app/dashboard/component/user/staged-objects-list/staged-objects-list.component.spec.ts @@ -23,20 +23,21 @@ import { By } from "@angular/platform-browser"; import { CdkVirtualScrollViewport } from "@angular/cdk/scrolling"; import { NzTooltipDirective } from "ng-zorro-antd/tooltip"; import { of, throwError } from "rxjs"; -import { UserDatasetStagedObjectsListComponent } from "./user-dataset-staged-objects-list.component"; -import { DatasetService } from "../../../../../service/user/dataset/dataset.service"; -import { NotificationService } from "../../../../../../common/service/notification/notification.service"; -import { DatasetStagedObject } from "../../../../../../common/type/dataset-staged-object"; -import { commonTestImports, commonTestProviders } from "../../../../../../common/testing/test-utils"; - -describe("UserDatasetStagedObjectsListComponent", () => { - let fixture: ComponentFixture; - let component: UserDatasetStagedObjectsListComponent; - let getDatasetDiffSpy: ReturnType; - let resetDatasetFileDiffSpy: ReturnType; +import { StagedObjectsListComponent } from "./staged-objects-list.component"; +import { StagedFileService } from "../../../service/user/file-resource/staged-file.service"; +import { DATASET_FILE_RESOURCE_ENDPOINT } from "../../../service/user/file-resource/file-resource-endpoint"; +import { NotificationService } from "../../../../common/service/notification/notification.service"; +import { DatasetStagedObject } from "../../../../common/type/dataset-staged-object"; +import { commonTestImports, commonTestProviders } from "../../../../common/testing/test-utils"; + +describe("StagedObjectsListComponent", () => { + let fixture: ComponentFixture; + let component: StagedObjectsListComponent; + let getDiffSpy: ReturnType; + let resetFileDiffSpy: ReturnType; const renderList = async () => { - component.did = 1; + component.resourceId = 1; fixture.detectChanges(); await fixture.whenStable(); fixture.detectChanges(); @@ -48,44 +49,44 @@ describe("UserDatasetStagedObjectsListComponent", () => { ]; beforeEach(() => { - getDatasetDiffSpy = vi.fn(() => of(stagedObjects)); - resetDatasetFileDiffSpy = vi.fn(() => of({})); + getDiffSpy = vi.fn(() => of(stagedObjects)); + resetFileDiffSpy = vi.fn(() => of({})); TestBed.configureTestingModule({ - imports: [UserDatasetStagedObjectsListComponent, ...commonTestImports], + imports: [StagedObjectsListComponent, ...commonTestImports], providers: [ { - provide: DatasetService, - useValue: { getDatasetDiff: getDatasetDiffSpy, resetDatasetFileDiff: resetDatasetFileDiffSpy }, + provide: StagedFileService, + useValue: { getDiff: getDiffSpy, resetFileDiff: resetFileDiffSpy }, }, { provide: NotificationService, useValue: { success: vi.fn(), error: vi.fn() } }, ...commonTestProviders, ], }); - fixture = TestBed.createComponent(UserDatasetStagedObjectsListComponent); + fixture = TestBed.createComponent(StagedObjectsListComponent); component = fixture.componentInstance; }); it("fetches staged objects on init and emits them", () => { - component.did = 1; + component.resourceId = 1; const emitted: DatasetStagedObject[][] = []; component.stagedObjectsChanged.subscribe((objects: DatasetStagedObject[]) => emitted.push(objects)); component.ngOnInit(); - expect(getDatasetDiffSpy).toHaveBeenCalledWith(1); - expect(component.datasetStagedObjects).toEqual(stagedObjects); + expect(getDiffSpy).toHaveBeenCalledWith(DATASET_FILE_RESOURCE_ENDPOINT, 1); + expect(component.stagedObjects).toEqual(stagedObjects); expect(emitted).toEqual([stagedObjects]); }); - it("does not fetch staged objects when did is undefined", () => { - component.did = undefined; + it("does not fetch staged objects when resourceId is undefined", () => { + component.resourceId = undefined; component.ngOnInit(); - expect(getDatasetDiffSpy).not.toHaveBeenCalled(); - expect(component.datasetStagedObjects).toEqual([]); + expect(getDiffSpy).not.toHaveBeenCalled(); + expect(component.stagedObjects).toEqual([]); }); // #5586: one change event per finished file must not mean one dataset-diff @@ -93,17 +94,17 @@ describe("UserDatasetStagedObjectsListComponent", () => { it("coalesces bursts of change events into one refetch per audit window", () => { vi.useFakeTimers(); try { - component.did = 1; + component.resourceId = 1; const changes = new EventEmitter(); component.userMakeChangesEvent = changes; for (let i = 0; i < 50; i++) { changes.emit(); } - expect(getDatasetDiffSpy).not.toHaveBeenCalled(); + expect(getDiffSpy).not.toHaveBeenCalled(); - vi.advanceTimersByTime(UserDatasetStagedObjectsListComponent.REFRESH_AUDIT_TIME_MS); - expect(getDatasetDiffSpy).toHaveBeenCalledTimes(1); + vi.advanceTimersByTime(StagedObjectsListComponent.REFRESH_AUDIT_TIME_MS); + expect(getDiffSpy).toHaveBeenCalledTimes(1); } finally { vi.useRealTimers(); } @@ -130,8 +131,8 @@ describe("UserDatasetStagedObjectsListComponent", () => { (fixture.nativeElement.querySelector(".delete-button") as HTMLButtonElement).click(); - expect(resetDatasetFileDiffSpy).toHaveBeenCalledWith(1, "dir/a.txt"); - expect(getDatasetDiffSpy).toHaveBeenCalledTimes(2); + expect(resetFileDiffSpy).toHaveBeenCalledWith(DATASET_FILE_RESOURCE_ENDPOINT, 1, "dir/a.txt"); + expect(getDiffSpy).toHaveBeenCalledTimes(2); }); it("shows the full path and upload time in the row tooltip", async () => { @@ -159,21 +160,21 @@ describe("UserDatasetStagedObjectsListComponent", () => { describe("branch coverage", () => { it("ignores a userMakeChangesEvent that is not provided", () => { // The setter guards on the event; assigning nothing must not subscribe or refetch. - component.did = 1; + component.resourceId = 1; component.userMakeChangesEvent = undefined as unknown as EventEmitter; - expect(getDatasetDiffSpy).not.toHaveBeenCalled(); + expect(getDiffSpy).not.toHaveBeenCalled(); }); it("does not revert an object when no dataset id is set", () => { - component.did = undefined; + component.resourceId = undefined; component.onObjectReverted(stagedObjects[0]); - expect(resetDatasetFileDiffSpy).not.toHaveBeenCalled(); + expect(resetFileDiffSpy).not.toHaveBeenCalled(); }); it("notifies when reverting a staged object fails", () => { - resetDatasetFileDiffSpy.mockReturnValue(throwError(() => new Error("boom"))); + resetFileDiffSpy.mockReturnValue(throwError(() => new Error("boom"))); const notificationService = TestBed.inject(NotificationService); - component.did = 1; + component.resourceId = 1; component.onObjectReverted(stagedObjects[0]); diff --git a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-staged-objects-list/user-dataset-staged-objects-list.component.ts b/frontend/src/app/dashboard/component/user/staged-objects-list/staged-objects-list.component.ts similarity index 71% rename from frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-staged-objects-list/user-dataset-staged-objects-list.component.ts rename to frontend/src/app/dashboard/component/user/staged-objects-list/staged-objects-list.component.ts index f92ba316f0e..a69e928c08f 100644 --- a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-staged-objects-list/user-dataset-staged-objects-list.component.ts +++ b/frontend/src/app/dashboard/component/user/staged-objects-list/staged-objects-list.component.ts @@ -19,9 +19,13 @@ import { Component, EventEmitter, Input, OnInit, Output, ViewChild } from "@angular/core"; import { auditTime } from "rxjs/operators"; -import { DatasetStagedObject } from "../../../../../../common/type/dataset-staged-object"; -import { DatasetService } from "../../../../../service/user/dataset/dataset.service"; -import { NotificationService } from "../../../../../../common/service/notification/notification.service"; +import { DatasetStagedObject } from "../../../../common/type/dataset-staged-object"; +import { StagedFileService } from "../../../service/user/file-resource/staged-file.service"; +import { + DATASET_FILE_RESOURCE_ENDPOINT, + FileResourceEndpoint, +} from "../../../service/user/file-resource/file-resource-endpoint"; +import { NotificationService } from "../../../../common/service/notification/notification.service"; import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy"; import { formatTime } from "src/app/common/util/format.util"; import { NgIf } from "@angular/common"; @@ -37,9 +41,9 @@ import { CdkFixedSizeVirtualScroll, CdkVirtualForOf, CdkVirtualScrollViewport } @UntilDestroy() @Component({ - selector: "texera-dataset-staged-objects-list", - templateUrl: "./user-dataset-staged-objects-list.component.html", - styleUrls: ["./user-dataset-staged-objects-list.component.scss"], + selector: "texera-staged-objects-list", + templateUrl: "./staged-objects-list.component.html", + styleUrls: ["./staged-objects-list.component.scss"], imports: [ NgIf, NzListComponent, @@ -56,26 +60,26 @@ import { CdkFixedSizeVirtualScroll, CdkVirtualForOf, CdkVirtualScrollViewport } NzEmptyComponent, ], }) -export class UserDatasetStagedObjectsListComponent implements OnInit { +export class StagedObjectsListComponent implements OnInit { // Coalesces change events so a bulk upload refetches the diff at most once // per window, not once per finished file. static readonly REFRESH_AUDIT_TIME_MS = 1000; - @Input() did?: number; // Dataset ID + @Input() resourceId?: number; + /** Which resource family `resourceId` belongs to. */ + @Input() endpoint: FileResourceEndpoint = DATASET_FILE_RESOURCE_ENDPOINT; @Input() set userMakeChangesEvent(event: EventEmitter) { if (event) { - event - .pipe(auditTime(UserDatasetStagedObjectsListComponent.REFRESH_AUDIT_TIME_MS), untilDestroyed(this)) - .subscribe(() => { - this.fetchDatasetStagedObjects(); - }); + event.pipe(auditTime(StagedObjectsListComponent.REFRESH_AUDIT_TIME_MS), untilDestroyed(this)).subscribe(() => { + this.fetchStagedObjects(); + }); } } @Input() uploadTimeMap?: Map; @Output() stagedObjectsChanged = new EventEmitter(); // Emits staged objects list - datasetStagedObjects: DatasetStagedObject[] = []; + stagedObjects: DatasetStagedObject[] = []; formatTime = formatTime; // Row height must match .staged-object-row in the SCSS. @@ -85,7 +89,7 @@ export class UserDatasetStagedObjectsListComponent implements OnInit { @ViewChild(CdkVirtualScrollViewport) private viewport?: CdkVirtualScrollViewport; get stagedListHeightPx(): number { - return Math.min(this.datasetStagedObjects.length * this.STAGED_ROW_HEIGHT_PX, this.STAGED_LIST_MAX_HEIGHT_PX); + return Math.min(this.stagedObjects.length * this.STAGED_ROW_HEIGHT_PX, this.STAGED_LIST_MAX_HEIGHT_PX); } // The viewport measures height 0 when created inside a hidden ancestor @@ -95,36 +99,36 @@ export class UserDatasetStagedObjectsListComponent implements OnInit { } constructor( - private datasetService: DatasetService, + private stagedFileService: StagedFileService, private notificationService: NotificationService ) {} ngOnInit(): void { - this.fetchDatasetStagedObjects(); + this.fetchStagedObjects(); } - private fetchDatasetStagedObjects(): void { - if (this.did != undefined) { - this.datasetService - .getDatasetDiff(this.did) + private fetchStagedObjects(): void { + if (this.resourceId != undefined) { + this.stagedFileService + .getDiff(this.endpoint, this.resourceId) .pipe(untilDestroyed(this)) .subscribe(diffs => { - this.datasetStagedObjects = diffs; + this.stagedObjects = diffs; // Emit the updated staged objects list - this.stagedObjectsChanged.emit(this.datasetStagedObjects); + this.stagedObjectsChanged.emit(this.stagedObjects); }); } } onObjectReverted(objDiff: DatasetStagedObject) { - if (this.did) { - this.datasetService - .resetDatasetFileDiff(this.did, objDiff.path) + if (this.resourceId) { + this.stagedFileService + .resetFileDiff(this.endpoint, this.resourceId, objDiff.path) .pipe(untilDestroyed(this)) .subscribe({ next: (res: Response) => { this.notificationService.success(`"${objDiff.diffType} ${objDiff.path}" is successfully reverted`); - this.fetchDatasetStagedObjects(); + this.fetchStagedObjects(); }, error: (err: unknown) => { this.notificationService.error("Failed to delete the file"); 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 f47b8d43f97..777a1537654 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 @@ -448,165 +448,16 @@
Choose a Version:
- - + - - - - - - - - -
- {{ fileName }} - -
-
-
- - - - -
-
-
- {{ task.status }}: {{ task.filePath }} - -
- -
- - - {{ formatSpeed(task.uploadSpeed) }} - - {{ formatTime(task.totalTime ?? 0) }} elapsed, - {{ formatTime(task.estimatedTimeRemaining ?? 0) }} left - - - - Upload time: {{ formatTime(task.totalTime ?? 0) }} - -
-
-
-
- - - - - - - -
- - - - -
-
- - -
-
- -
-
-
-
+ [resourceId]="did" + [ownerEmail]="ownerEmail" + [resourceName]="datasetName" + [endpoint]="datasetEndpoint" + [createVersion]="createDatasetVersion" + (versionCreated)="onVersionCreated()"> +
diff --git a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.scss b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.scss index b9378d661ef..0bd67b60672 100644 --- a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.scss +++ b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.scss @@ -17,22 +17,6 @@ * under the License. */ -.create-dataset-version-button { - display: flex; /* Use flexbox for centering */ - align-items: center; /* Center vertically */ - justify-content: center; /* Center horizontally */ - color: white; - border: none; - padding: 12px 40px; /* Increase padding for a wider button */ - border-radius: 25px; - cursor: pointer; - transition: background-color 0.3s; - margin: 18px auto 0 auto; /* Auto margins for horizontal centering */ - width: 200px; /* Adjust width as needed */ - font-size: 18px; /* Make text slightly bigger */ - font-weight: bold; /* Optional: Make text bold */ -} - .version-storage { padding: 0 15px; margin-bottom: 25px; @@ -143,79 +127,6 @@ nz-select { margin-top: 15%; } -.upload-progress-wrapper { - max-height: 25vh; - overflow-y: auto; - padding-right: 4px; -} - -// Rows must stay exactly PENDING_ROW_HEIGHT_PX tall for the fixed-size scroll -// strategy. The gutter lives on the rows: padding on the viewport never -// reaches its absolutely positioned content wrapper. -.upload-progress-wrapper-pending { - .pending-file-row { - height: 32px; - display: flex; - align-items: center; - justify-content: space-between; - padding-right: 4px; - - .pending-file-name { - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - } - } -} - -.version-creator { - margin-top: 20px; - padding: 20px; -} - -.version-input-container label { - font-size: 15px; -} - -.version-input-container { - display: flex; - align-items: center; - gap: 10px; -} - -.version-input { - padding: 6px; -} - -.upload-stats { - font-size: 13px; - margin-bottom: 20px; - nz-progress { - width: 97%; - display: inline-block; - } -} - -:host ::ng-deep .upload-stats .ant-tag { - border: none; -} - -.fixed-width-speed { - display: inline-block; - min-width: 5ch; - text-align: right; -} - -.fixed-width-time { - display: inline-block; - min-width: 2ch; - text-align: right; -} - -.section-divider { - margin: 8px 0; -} - .status-tag-row { margin-top: 16px; display: flex; diff --git a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.spec.ts b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.spec.ts index 7aae86e1033..aeaa8392582 100644 --- a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.spec.ts +++ b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.spec.ts @@ -27,12 +27,9 @@ import { NzModalService } from "ng-zorro-antd/modal"; import { NzResizableDirective } from "ng-zorro-antd/resizable"; import { NzTooltipDirective } from "ng-zorro-antd/tooltip"; import { MarkdownService } from "ngx-markdown"; -import { - DatasetDetailComponent, - ABORT_RETRY_BACKOFF_BASE_MS, - ABORT_RETRY_MAX_ATTEMPTS, -} from "./dataset-detail.component"; +import { DatasetDetailComponent } from "./dataset-detail.component"; import { DatasetService, MultipartUploadProgress } from "../../../../service/user/dataset/dataset.service"; +import { VersionUploaderComponent } from "../../version-uploader/version-uploader.component"; import { NotificationService } from "../../../../../common/service/notification/notification.service"; import { DownloadService } from "../../../../service/user/download/download.service"; import { UserService } from "../../../../../common/service/user/user.service"; @@ -50,37 +47,10 @@ import { NzResizeEvent } from "ng-zorro-antd/resizable"; import { format } from "date-fns"; import { USER_DATASET } from "../../../../../app-routing.constant"; -describe("DatasetDetailComponent upload queue", () => { +describe("DatasetDetailComponent rendered explorer", () => { let fixture: ComponentFixture; let component: DatasetDetailComponent; - let uploadSubjects: Subject[]; - let uploadedPaths: string[]; - let multipartUploadSpy: ReturnType; - - const makeFileItem = (name: string): FileUploadItem => ({ - file: new File(["x"], name), - name, - description: "", - uploadProgress: 0, - isUploadingFlag: false, - restart: false, - }); - - const dropFiles = (...names: string[]) => component.onNewUploadFilesChanged(names.map(makeFileItem)); - - const finishUpload = (index: number, filePath: string, totalTime = 1) => - uploadSubjects[index].next({ filePath, percentage: 100, status: "finished", totalTime }); - beforeEach(() => { - uploadSubjects = []; - uploadedPaths = []; - multipartUploadSpy = vi.fn((_ownerEmail: string, _datasetName: string, filePath: string) => { - const progress = new Subject(); - uploadSubjects.push(progress); - uploadedPaths.push(filePath); - return progress.asObservable(); - }); - TestBed.configureTestingModule({ imports: [DatasetDetailComponent, ...commonTestImports], providers: [ @@ -89,8 +59,6 @@ describe("DatasetDetailComponent upload queue", () => { { provide: DatasetService, useValue: { - multipartUpload: multipartUploadSpy, - finalizeMultipartUpload: vi.fn(() => of({})), getDataset: vi.fn(() => of({ dataset: { name: "test-dataset", description: "", isPublic: false, isDownloadable: true }, @@ -145,377 +113,6 @@ describe("DatasetDetailComponent upload queue", () => { * A failed upload has to tell the user why, mark the task failed without leaving its bar at * 100%, and free the concurrency slot — otherwise the queue stalls behind a dead upload. */ - describe("a failed upload", () => { - const notification = () => TestBed.inject(NotificationService) as unknown as { error: ReturnType }; - - /** Fails the in-flight upload of `name` with the given HTTP status. */ - const failUpload = (index: number, status: number) => - uploadSubjects[index].error(new HttpErrorResponse({ status })); - - it("names the 409 conflict so the user knows to retry", () => { - dropFiles("a.csv"); - - failUpload(0, HttpStatusCode.Conflict); - - expect(notification().error).toHaveBeenCalledWith(expect.stringContaining("Upload blocked (409)")); - }); - - it("falls back to a generic message for any other failure", () => { - dropFiles("a.csv"); - - failUpload(0, HttpStatusCode.InternalServerError); - - expect(notification().error).toHaveBeenCalledWith("Upload failed. Please retry."); - }); - - it("marks the task failed and keeps its progress rather than showing it complete", () => { - dropFiles("a.csv"); - // a partially-uploaded file: the bar must not jump to 100 when it fails - uploadSubjects[0].next({ filePath: "a.csv", percentage: 42, status: "uploading" }); - - failUpload(0, HttpStatusCode.InternalServerError); - - const task = component.uploadTasks.find(t => t.filePath === "a.csv"); - expect(task?.status).toBe("failed"); - expect(task?.percentage).toBe(42); - }); - - it("frees the concurrency slot so a queued upload can start", () => { - // maxConcurrentFiles is 3, so a fourth file waits for a slot - dropFiles("a.csv", "b.csv", "c.csv", "d.csv"); - expect(uploadedPaths).toEqual(["a.csv", "b.csv", "c.csv"]); - - failUpload(0, HttpStatusCode.InternalServerError); - - expect(uploadedPaths).toContain("d.csv"); - }); - - it("still reports the failure when the task is no longer in the list", () => { - dropFiles("a.csv"); - component.uploadTasks = []; // the taskIndex === -1 arm - - expect(() => failUpload(0, HttpStatusCode.InternalServerError)).not.toThrow(); - expect(notification().error).toHaveBeenCalled(); - }); - }); - - /** - * A progress event and the five-second hide timer both address a row by its index in - * `uploadTasks`, and that row can already be gone — dismissed by the user — by the time - * either arrives, so both lookups have to survive the miss. The completion path also has - * to pick a key for `uploadTimeMap` out of a name that may carry directories. - */ - describe("progress bookkeeping", () => { - beforeEach(() => { - // The completion path arms a 5s row-hide timer; keep it off the real clock. - vi.useFakeTimers(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - it("ignores progress for a row that is no longer listed", () => { - dropFiles("a.csv"); - component.uploadTasks = []; // dismissed while a chunk was still in flight - - expect(() => uploadSubjects[0].next({ filePath: "a.csv", percentage: 50, status: "uploading" })).not.toThrow(); - // The late event must not resurrect the row or write a phantom index into the list. - expect(component.uploadTasks).toEqual([]); - expect(Object.keys(component.uploadTasks)).toHaveLength(0); - }); - - it("keys the upload time by the last path segment, falling back to the whole name", () => { - // Taking the segment after the last "/" yields "" for a name that ends in one, and - // keying the map under "" would collide every such upload onto one entry; the - // fallback keeps the name the caller gave instead. - dropFiles("nested/dir/"); - - finishUpload(0, "nested/dir/", 7); - - expect(component.uploadTimeMap.get("nested/dir/")).toBe(7); - expect(component.uploadTimeMap.has("")).toBe(false); - - // The reader of this map (user-dataset-staged-objects-list) looks a row up by - // `filePath.split("/").pop() || filePath`, so an ordinary nested name has to be - // keyed by its last segment here or the per-file time silently stops rendering. - dropFiles("dir/sub/a.csv"); - - finishUpload(1, "dir/sub/a.csv", 9); - - expect(component.uploadTimeMap.get("a.csv")).toBe(9); - expect(component.uploadTimeMap.has("dir/sub/a.csv")).toBe(false); - }); - - it("ignores a hide request for a row that is gone", () => { - // Every one of scheduleHide's call sites already checks the index, so the -1 arm - // pins a defensive no-op rather than a reachable scenario: without the guard the - // lookup would read `filePath` off undefined and throw. The valid-index call that - // follows keeps a scheduleHide which does nothing at all from passing this test. - dropFiles("a.csv"); - const before = [...component.uploadTasks]; - - expect(() => (component as any).scheduleHide(-1)).not.toThrow(); - expect(component.uploadTasks).toEqual(before); - - (component as any).scheduleHide(0); - vi.advanceTimersByTime(5000); - - expect(component.uploadTasks).toEqual([]); - }); - }); - - /** - * Aborting an in-flight upload has to survive the backend still finalizing the previous attempt: - * the abort call is retried on 409 up to ABORT_RETRY_MAX_ATTEMPTS, a 404 means it is already gone, - * and the caller's callback must fire exactly once down every one of those paths. - */ - describe("aborting an upload", () => { - let finalize: ReturnType; - - beforeEach(() => { - vi.useFakeTimers(); - finalize = TestBed.inject(DatasetService).finalizeMultipartUpload as unknown as ReturnType; - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - /** Starts an upload and reports progress, leaving one task in flight. */ - function inFlight(name = "a.txt") { - dropFiles(name); - uploadSubjects[0].next({ filePath: name, percentage: 10, status: "uploading", totalTime: 0 }); - return component.uploadTasks.find(t => t.filePath === name)!; - } - - const conflict = () => throwError(() => ({ status: 409 }) as any); - const gone = () => throwError(() => ({ status: 404 }) as any); - - it("marks the task aborted and tells the caller once", () => { - const task = inFlight(); - const onAborted = vi.fn(); - - component.onClickAbortUploadProgress(task as any, onAborted); - - expect(finalize).toHaveBeenCalledWith("owner@texera.com", "test-dataset", "a.txt", true); - expect(component.uploadTasks.find(t => t.filePath === "a.txt")!.status).toBe("aborted"); - expect(onAborted).toHaveBeenCalledTimes(1); - - // The aborted row goes on the same five-second hide timer a finished one does, so - // it clears itself out of the list instead of sitting there for the rest of the session. - vi.advanceTimersByTime(5000); - - expect(component.uploadTasks.find(t => t.filePath === "a.txt")).toBeUndefined(); - }); - - it("stops listening to the upload it aborted", () => { - const task = inFlight(); - - component.onClickAbortUploadProgress(task as any); - - // The progress stream is unsubscribed, so a late event cannot resurrect the task. - uploadSubjects[0].next({ filePath: "a.txt", percentage: 100, status: "finished", totalTime: 1 }); - expect(component.uploadTasks.find(t => t.filePath === "a.txt")!.status).toBe("aborted"); - }); - - it("treats a 404 as already aborted rather than an error", () => { - finalize.mockReturnValueOnce(gone()); - const task = inFlight(); - const onAborted = vi.fn(); - - component.onClickAbortUploadProgress(task as any, onAborted); - - expect(onAborted).toHaveBeenCalledTimes(1); - expect(finalize).toHaveBeenCalledTimes(1); - }); - - it("retries a 409 after a backoff and finishes once the server catches up", () => { - // The server is still finalizing the previous attempt; the abort has to wait it out. - finalize.mockReturnValueOnce(conflict()); - const task = inFlight(); - const onAborted = vi.fn(); - - component.onClickAbortUploadProgress(task as any, onAborted); - expect(onAborted).not.toHaveBeenCalled(); - - vi.advanceTimersByTime(ABORT_RETRY_BACKOFF_BASE_MS); - - expect(finalize).toHaveBeenCalledTimes(2); - expect(onAborted).toHaveBeenCalledTimes(1); - }); - - it("backs off further on each successive conflict", () => { - finalize.mockReturnValue(conflict()); - const task = inFlight(); - - component.onClickAbortUploadProgress(task as any); - expect(finalize).toHaveBeenCalledTimes(1); - - // First wait is BASE * 1, the second BASE * 2, so BASE alone is not enough for the third call. - vi.advanceTimersByTime(ABORT_RETRY_BACKOFF_BASE_MS); - expect(finalize).toHaveBeenCalledTimes(2); - - vi.advanceTimersByTime(ABORT_RETRY_BACKOFF_BASE_MS); - expect(finalize).toHaveBeenCalledTimes(2); - - vi.advanceTimersByTime(ABORT_RETRY_BACKOFF_BASE_MS); - expect(finalize).toHaveBeenCalledTimes(3); - }); - - it("gives up after the attempt limit but still reports the abort", () => { - // Without the bound this would retry forever against a permanently conflicted server. - finalize.mockReturnValue(conflict()); - const task = inFlight(); - const onAborted = vi.fn(); - - component.onClickAbortUploadProgress(task as any, onAborted); - vi.advanceTimersByTime(ABORT_RETRY_BACKOFF_BASE_MS * ABORT_RETRY_MAX_ATTEMPTS * (ABORT_RETRY_MAX_ATTEMPTS + 1)); - - expect(finalize).toHaveBeenCalledTimes(ABORT_RETRY_MAX_ATTEMPTS + 1); - expect(onAborted).toHaveBeenCalledTimes(1); - }); - - it("reports the abort once even on an error the retry does not cover", () => { - finalize.mockReturnValueOnce(throwError(() => ({ status: 500 }) as any)); - const task = inFlight(); - const onAborted = vi.fn(); - - component.onClickAbortUploadProgress(task as any, onAborted); - - expect(onAborted).toHaveBeenCalledTimes(1); - expect(finalize).toHaveBeenCalledTimes(1); - }); - - it("frees the concurrency slot so a queued upload can start", () => { - // Aborting has to release the slot as an ordinary completion would; otherwise the queue - // stalls behind an upload that is no longer running. - dropFiles("a.txt", "b.txt", "c.txt", "d.txt"); - expect(uploadedPaths).toEqual(["a.txt", "b.txt", "c.txt"]); - uploadSubjects[0].next({ filePath: "a.txt", percentage: 10, status: "uploading", totalTime: 0 }); - const task = component.uploadTasks.find(t => t.filePath === "a.txt")!; - - component.onClickAbortUploadProgress(task as any); - - expect(uploadedPaths).toContain("d.txt"); - }); - - it("cancelExistingUpload aborts an upload that is still running", () => { - inFlight("b.txt"); - const onCanceled = vi.fn(); - - component.cancelExistingUpload("b.txt", onCanceled); - - expect(finalize).toHaveBeenCalledWith("owner@texera.com", "test-dataset", "b.txt", true); - expect(onCanceled).toHaveBeenCalledTimes(1); - }); - - it("frees the slot of an upload aborted before its first part went out", () => { - // A task sits at "initializing" until the service reports its first progress. - // Cancelling in that window still has to hand the slot to whatever is queued - // behind it, or the queue stalls on an upload that never started. - dropFiles("a.txt", "b.txt", "c.txt", "d.txt"); - expect(uploadedPaths).toEqual(["a.txt", "b.txt", "c.txt"]); - const initializing = component.uploadTasks.find(t => t.filePath === "a.txt")!; - expect(initializing.status).toBe("initializing"); - - component.onClickAbortUploadProgress(initializing as any); - - expect(uploadedPaths).toContain("d.txt"); - expect(component.activeCount).toBe(3); - }); - - it("does not free a second slot when a finished upload's row is dismissed", () => { - // The row's button becomes "Close" once the upload is done, and the slot was - // already released by the completion; releasing it a second time would let a - // fourth upload run past the concurrency cap. - dropFiles("a.txt", "b.txt", "c.txt", "d.txt"); - finishUpload(0, "a.txt"); - expect(uploadedPaths).toContain("d.txt"); - const finished = component.uploadTasks.find(t => t.filePath === "a.txt")!; - expect(finished.status).toBe("finished"); - - component.onClickAbortUploadProgress(finished as any); - - expect(component.activeCount).toBe(3); - const dismissed = component.uploadTasks.find(t => t.filePath === "a.txt")!; - expect(dismissed.status).toBe("aborted"); - - // The row lingers for five seconds after being dismissed, so the same X is - // still there to be clicked again — and that click must not release either. - component.onClickAbortUploadProgress(dismissed as any); - - expect(component.activeCount).toBe(3); - }); - - it("does not free a second slot when a failed upload's row is dismissed", () => { - // The failure handler already released this upload's slot and let the queued - // fourth file start; dismissing the row it left behind must not release a - // second slot, or a fifth upload would run past the cap of three. - dropFiles("a.txt", "b.txt", "c.txt", "d.txt"); - uploadSubjects[0].error(new HttpErrorResponse({ status: 500 })); - expect(uploadedPaths).toContain("d.txt"); - const failed = component.uploadTasks.find(t => t.filePath === "a.txt")!; - expect(failed.status).toBe("failed"); - - component.onClickAbortUploadProgress(failed as any); - - expect(component.activeCount).toBe(3); - expect(multipartUploadSpy).toHaveBeenCalledTimes(4); - }); - - it("cancelExistingUpload aborts an upload whose first part has not gone out", () => { - // Until the service reports a first chunk the task sits at "initializing". - // A re-drop in that window has to abort that attempt rather than fall - // through and race a second multipart upload against it for the same path, - // which is exactly the 409 the upload error handler warns about. - dropFiles("b.txt"); - expect(component.uploadTasks.find(t => t.filePath === "b.txt")!.status).toBe("initializing"); - const onCanceled = vi.fn(); - - component.cancelExistingUpload("b.txt", onCanceled); - - expect(finalize).toHaveBeenCalledWith("owner@texera.com", "test-dataset", "b.txt", true); - expect(component.uploadTasks.find(t => t.filePath === "b.txt")!.status).toBe("aborted"); - // The slot goes back to the queue instead of being held by an attempt that - // is no longer running. - expect(component.activeCount).toBe(0); - expect(onCanceled).toHaveBeenCalledTimes(1); - }); - - it("tells the caller once even when the abort call reports more than once", () => { - // The callback is latched so that it fires exactly once no matter how many of the - // subscription's handlers reach it. HttpClient itself delivers a single response, - // so this drives the latch directly: a response followed by a stream failure runs - // the next handler and then the error handler, and both of them report done. - finalize.mockReturnValueOnce( - concat( - of({}), - throwError(() => ({ status: 500 }) as any) - ) - ); - const task = inFlight(); - const onAborted = vi.fn(); - - component.onClickAbortUploadProgress(task as any, onAborted); - - expect(onAborted).toHaveBeenCalledTimes(1); - }); - - it("aborts a task whose row was already dropped without resurrecting it", () => { - const task = inFlight(); - component.uploadTasks = []; // the row was dismissed before the abort was clicked - - component.onClickAbortUploadProgress(task as any); - - expect(finalize).toHaveBeenCalledWith("owner@texera.com", "test-dataset", "a.txt", true); - // Writing "aborted" back at a missing index would leave a phantom "-1" property on - // the array, which neither a throw nor `.length` would reveal. - expect(component.uploadTasks).toEqual([]); - expect(Object.keys(component.uploadTasks)).toHaveLength(0); - }); - }); - /** * The explorer's toolbar and upload panel are template-only: whether a download is offered at all, * which of the maximize/minimize pair is showing, and what an in-flight upload reports. The suite @@ -678,64 +275,15 @@ describe("DatasetDetailComponent upload queue", () => { }); }); - describe("upload progress", () => { - /** - * Puts one task on the panel in the given state and opens it. The panel is gated on the - * separate activeUploads counter rather than on uploadTasks, and ng-zorro collapses it by - * default, so both have to be arranged before its body exists. - */ - function withTask(over: Record): HTMLElement { - const el = render(c => { - (c as any).activeUploads = 1; - (c as any).uploadTasks = [ - { - filePath: "big.csv", - percentage: 40, - status: "uploading", - uploadSpeed: 1024, - totalTime: 12, - estimatedTimeRemaining: 30, - ...over, - }, - ]; - }); - const header = Array.from(el.querySelectorAll(".ant-collapse-header")).find(h => - (h.textContent || "").includes("Uploading:") - ); - header!.click(); - fixture.detectChanges(); - return el; - } - - it("shows no statistics while an upload is still initializing", () => { - // There is nothing to report yet; showing a 0 B/s row reads as a stalled upload. - const el = withTask({ status: "initializing" }); - - expect(el.querySelector(".upload-stats")).toBeNull(); - }); - - it("reports speed and both timings while an upload runs", () => { - const el = withTask({ status: "uploading" }); - - const stats = el.querySelector(".upload-stats")!; - expect(stats.textContent).toContain("elapsed"); - expect(stats.textContent).toContain("left"); - expect(stats.querySelector(".fixed-width-speed")).not.toBeNull(); - }); - - it("replaces the live figures with a total once the upload finishes", () => { - const el = withTask({ status: "finished" }); - - const stats = el.querySelector(".upload-stats")!; - expect(stats.textContent).toContain("Upload time:"); - expect(stats.textContent).not.toContain("left"); - }); + it("stages a file deletion and lets the panel count it", () => { + render(); // the panel lives in the Versions & Files tab, which nz-tabs renders lazily + const node: DatasetFileNode = { name: "a.txt", type: "file", parentDir: "/owner@texera.com/test-dataset/v1" }; - it("reports a total for an aborted upload too", () => { - const el = withTask({ status: "aborted" }); + component.onPreviouslyUploadedFileDeleted(node); - expect(el.querySelector(".upload-stats")!.textContent).toContain("Upload time:"); - }); + const panel = fixture.debugElement.query(By.directive(VersionUploaderComponent)) + .componentInstance as VersionUploaderComponent; + expect(panel.pendingChangesCount).toBe(1); }); }); @@ -788,247 +336,6 @@ describe("DatasetDetailComponent upload queue", () => { expect(onAdd).toHaveBeenCalledTimes(1); }); }); - - it("starts at most maxConcurrentFiles uploads immediately and queues the rest", () => { - dropFiles("f1.txt", "f2.txt", "f3.txt", "f4.txt", "f5.txt"); - - expect(multipartUploadSpy).toHaveBeenCalledTimes(3); - expect(uploadedPaths).toEqual(["f1.txt", "f2.txt", "f3.txt"]); - expect(component.activeCount).toBe(3); - expect(component.queuedCount).toBe(2); - expect(component.queuedFileNames).toEqual(["f4.txt", "f5.txt"]); - }); - - it("does nothing when an empty file list is dropped", () => { - dropFiles(); - - expect(multipartUploadSpy).not.toHaveBeenCalled(); - expect(component.activeCount).toBe(0); - expect(component.queuedCount).toBe(0); - expect(component.queuedFileNames).toEqual([]); - }); - - it("starts no upload at all when the route carried no dataset id", () => { - // Every multipart call is addressed to a dataset, so without one there is - // nowhere to upload into: the drop is refused outright rather than leaving - // rows on the panel for uploads that were never started. - component.did = undefined; - - dropFiles("f1.txt", "f2.txt"); - - expect(multipartUploadSpy).not.toHaveBeenCalled(); - expect(component.uploadTasks).toEqual([]); - expect(component.activeCount).toBe(0); - }); - - it("starts the next queued upload when an active upload finishes", () => { - dropFiles("f1.txt", "f2.txt", "f3.txt", "f4.txt", "f5.txt"); - - finishUpload(0, "f1.txt"); - - expect(multipartUploadSpy).toHaveBeenCalledTimes(4); - expect(uploadedPaths[3]).toBe("f4.txt"); - expect(component.activeCount).toBe(3); - expect(component.queuedCount).toBe(1); - expect(component.queuedFileNames).toEqual(["f5.txt"]); - }); - - it("removes a cancelled file from the pending queue without starting it", () => { - dropFiles("f1.txt", "f2.txt", "f3.txt", "f4.txt", "f5.txt"); - - component.cancelExistingUpload("f4.txt"); - - expect(multipartUploadSpy).toHaveBeenCalledTimes(3); - expect(component.queuedCount).toBe(1); - expect(component.queuedFileNames).toEqual(["f5.txt"]); - }); - - it("ignores cancellation of a file that is neither active nor queued", () => { - dropFiles("f1.txt", "f2.txt", "f3.txt", "f4.txt"); - - component.cancelExistingUpload("missing.txt"); - - expect(component.activeCount).toBe(3); - expect(component.queuedCount).toBe(1); - expect(component.queuedFileNames).toEqual(["f4.txt"]); - }); - - // #5586: the template reads queuedFileNames on every change-detection pass, - // so it must not allocate a new array unless the queue changed. - it("keeps the same queuedFileNames array reference while the queue is unchanged", () => { - dropFiles("f1.txt", "f2.txt", "f3.txt", "f4.txt", "f5.txt"); - - const firstRead = component.queuedFileNames; - - expect(component.queuedFileNames).toBe(firstRead); - }); - - it("exposes a new queuedFileNames array after the queue changes", () => { - dropFiles("f1.txt", "f2.txt", "f3.txt", "f4.txt", "f5.txt"); - const beforeCancel = component.queuedFileNames; - - component.cancelExistingUpload("f4.txt"); - - expect(component.queuedFileNames).not.toBe(beforeCancel); - expect(component.queuedFileNames).toEqual(["f5.txt"]); - }); - - it("identifies pending queue entries by file name in trackByPendingFile", () => { - expect(component.trackByPendingFile(0, "dir/a.txt")).toBe("dir/a.txt"); - }); - - // A resumed upload with no missing parts finishes with totalTime exactly 0; - // the slot must still be released. - it("releases the concurrency slot when a finished upload reports totalTime 0", () => { - dropFiles("f1.txt", "f2.txt", "f3.txt", "f4.txt"); - - finishUpload(0, "f1.txt", 0); - - expect(multipartUploadSpy).toHaveBeenCalledTimes(4); - expect(uploadedPaths[3]).toBe("f4.txt"); - expect(component.activeCount).toBe(3); - expect(component.queuedCount).toBe(0); - }); - - // The Pending header updates per file, so the Finished header must too — it - // cannot wait for the throttled staged-objects refetch. - it("updates the Finished count immediately when uploads finish", () => { - dropFiles("f1.txt", "f2.txt", "f3.txt", "f4.txt"); - expect(component.pendingChangesCount).toBe(0); - - finishUpload(0, "f1.txt"); - expect(component.pendingChangesCount).toBe(1); - - finishUpload(1, "f2.txt"); - expect(component.pendingChangesCount).toBe(2); - }); - - it("reconciles the optimistic Finished count with a diff response", () => { - dropFiles("f1.txt", "f2.txt", "f3.txt"); - finishUpload(0, "f1.txt"); - finishUpload(1, "f2.txt"); - - const diff: DatasetStagedObject[] = [{ path: "f1.txt", pathType: "file", diffType: "added", sizeBytes: 1 }]; - component.onStagedObjectsUpdated(diff); - - // f1 is confirmed by the response; f2 stays counted until a response includes it. - expect(component.pendingChangesCount).toBe(2); - - component.onStagedObjectsUpdated([...diff, { path: "f2.txt", pathType: "file", diffType: "added", sizeBytes: 1 }]); - expect(component.pendingChangesCount).toBe(2); - }); - - it("keeps an in-progress upload's slot while progress events stream in", () => { - dropFiles("f1.txt", "f2.txt", "f3.txt", "f4.txt"); - - uploadSubjects[0].next({ filePath: "f1.txt", percentage: 50, status: "uploading" }); - - expect(component.uploadTasks.find(t => t.filePath === "f1.txt")?.percentage).toBe(50); - expect(component.activeCount).toBe(3); - expect(component.queuedCount).toBe(1); - }); - - it("does not double-count a finished upload already confirmed by a diff response", () => { - dropFiles("f1.txt"); - finishUpload(0, "f1.txt"); - component.onStagedObjectsUpdated([{ path: "f1.txt", pathType: "file", diffType: "added", sizeBytes: 1 }]); - expect(component.pendingChangesCount).toBe(1); - - dropFiles("f1.txt"); // re-upload the already-staged file - finishUpload(1, "f1.txt"); - - expect(component.pendingChangesCount).toBe(1); - }); - - it("does not start queued uploads beyond a lowered concurrency limit", () => { - dropFiles("f1.txt", "f2.txt", "f3.txt", "f4.txt"); - component.maxConcurrentFiles = 1; - - finishUpload(0, "f1.txt"); - - expect(component.activeCount).toBe(2); - expect(component.queuedCount).toBe(1); - expect(multipartUploadSpy).toHaveBeenCalledTimes(3); - }); - - it("clears the Finished count when a version is created", () => { - dropFiles("f1.txt"); - finishUpload(0, "f1.txt"); - expect(component.pendingChangesCount).toBe(1); - - component.versionName = "v1"; - component.onClickOpenVersionCreator(); - - expect(component.pendingChangesCount).toBe(0); - }); - - it("does not remove a re-uploaded file's active task when hiding its finished predecessor", () => { - vi.useFakeTimers(); - try { - dropFiles("a.txt"); - finishUpload(0, "a.txt"); // schedules the finished row to hide in 5s - - dropFiles("a.txt"); // re-upload the same name within the 5s window - vi.advanceTimersByTime(5000); - - expect(component.uploadTasks).toHaveLength(1); - expect(component.uploadTasks[0].status).not.toBe("finished"); - expect(component.activeCount).toBe(1); - - finishUpload(1, "a.txt"); - expect(component.activeCount).toBe(0); - } finally { - vi.useRealTimers(); - } - }); - - it("renders the virtualized pending list and re-measures viewports on panel expand", async () => { - dropFiles("f1.txt", "f2.txt", "f3.txt", "f4.txt", "f5.txt"); - - // The upload UI lives in the "Versions & Files" tab; nz-tabs does not render a - // tab's content into the DOM until it has been selected at least once. - const tabButtons: NodeListOf = fixture.nativeElement.querySelectorAll(".ant-tabs-tab-btn"); - const versionsTab = Array.from(tabButtons).find(tab => tab.textContent?.includes("Versions & Files")); - expect(versionsTab).toBeTruthy(); - (versionsTab as HTMLElement).click(); - fixture.detectChanges(); - - // Flush the viewport's init microtask, then render the rows. - await Promise.resolve(); - fixture.detectChanges(); - - expect(component.pendingListHeightPx).toBe(2 * component.PENDING_ROW_HEIGHT_PX); - const rows = fixture.nativeElement.querySelectorAll(".pending-file-row"); - expect(rows.length).toBe(2); - - // Expand the Pending / Uploading / Finished panels. - const headers: NodeListOf = fixture.nativeElement.querySelectorAll( - ".upload-status-panels .ant-collapse-header" - ); - expect(headers.length).toBe(3); - headers.forEach(header => header.click()); - fixture.detectChanges(); - // Flush the checkViewportSize timers. - await new Promise(resolve => setTimeout(resolve)); - - // Collapsing again must be a no-op for the re-measure handler. - headers.forEach(header => header.click()); - fixture.detectChanges(); - - // Cancel a queued file from its row. - const cancelButton = fixture.nativeElement.querySelector(".pending-file-row button") as HTMLButtonElement; - cancelButton.click(); - expect(component.queuedCount).toBe(1); - expect(component.queuedFileNames).toEqual(["f5.txt"]); - }); - - it("counts a staged file deletion immediately", () => { - const node: DatasetFileNode = { name: "a.txt", type: "file", parentDir: "/owner@texera.com/test-dataset/v1" }; - - component.onPreviouslyUploadedFileDeleted(node); - - expect(component.pendingChangesCount).toBe(1); - }); }); describe("DatasetDetailComponent behavior", () => { @@ -1164,10 +471,9 @@ describe("DatasetDetailComponent behavior", () => { expect(component.likeCount).toBe(7); expect(component.viewCount).toBe(42); expect(hubServiceStub.isLiked).not.toHaveBeenCalled(); - expect(adminSettingsServiceStub.getPublicSetting).not.toHaveBeenCalled(); }); - it("fetches liked status and upload settings for a logged-in user", () => { + it("fetches liked status for a logged-in user", () => { hubServiceStub.isLiked.mockReturnValue(of([{ isLiked: true }])); createComponent({ did: 5 }); @@ -1176,19 +482,6 @@ describe("DatasetDetailComponent behavior", () => { expect(hubServiceStub.isLiked).toHaveBeenCalled(); expect(component.isLiked).toBe(true); - expect(adminSettingsServiceStub.getPublicSetting).toHaveBeenCalled(); - }); - - it("keeps the default upload settings when the public settings are missing", () => { - adminSettingsServiceStub.getPublicSetting.mockReturnValue(of(null)); - - createComponent({ did: 5 }); - login(); - fixture.detectChanges(); - - expect(component.chunkSizeMiB).toBe(50); - expect(component.maxConcurrentChunks).toBe(10); - expect(component.maxConcurrentFiles).toBe(3); }); it("makes no hub calls when the route carries no did", () => { @@ -1222,46 +515,6 @@ describe("DatasetDetailComponent behavior", () => { // or some other action's: nothing else in the suite pins these arguments. expect(hubServiceStub.getCounts).toHaveBeenCalledWith([EntityType.Dataset], [5], [ActionType.Like]); }); - - it("leaves the chunk size untouched when only that setting fails to load", () => { - // A distinct value per key, so a setting that lands in the wrong field is - // visible: 7 chunks and 2 files cannot stand in for one another. - adminSettingsServiceStub.getPublicSetting.mockImplementation((key: string) => - key === "dataset_multipart_upload_chunk_size_mib" - ? throwError(() => new Error("boom")) - : of(key === "dataset_max_number_of_concurrent_uploading_file_chunks" ? "7" : "2") - ); - - createComponent({ did: 5 }); - // A sentinel the class default cannot supply, so "the failed fetch wrote - // nothing" is distinguishable from "it wrote the default back". - component.chunkSizeMiB = 42; - login(); - fixture.detectChanges(); - - expect(component.chunkSizeMiB).toBe(42); - expect(component.maxConcurrentChunks).toBe(7); - expect(component.maxConcurrentFiles).toBe(2); - }); - - it("leaves both concurrency limits untouched when their settings fail to load", () => { - adminSettingsServiceStub.getPublicSetting.mockImplementation((key: string) => - key === "dataset_multipart_upload_chunk_size_mib" ? of("128") : throwError(() => new Error("boom")) - ); - - createComponent({ did: 5 }); - // Sentinels again. A failed fetch that wrote anything here — a reset or a - // NaN — would stall the queue outright, since `activeUploads < NaN` is - // never true, and a plain default-valued assertion could not see it. - component.maxConcurrentChunks = 41; - component.maxConcurrentFiles = 40; - login(); - fixture.detectChanges(); - - expect(component.chunkSizeMiB).toBe(128); - expect(component.maxConcurrentChunks).toBe(41); - expect(component.maxConcurrentFiles).toBe(40); - }); }); describe("retrieveDatasetInfo", () => { @@ -1747,49 +1000,27 @@ describe("DatasetDetailComponent behavior", () => { }); }); - describe("onClickOpenVersionCreator", () => { - it("creates a version, clears the name, refreshes the list and emits a change on success", () => { + describe("creating a version", () => { + it("hands the panel a call that commits through DatasetService", () => { datasetServiceStub.createDatasetVersion.mockReturnValue(of(makeVersion())); - datasetServiceStub.retrieveDatasetVersionList.mockReturnValue(of([])); createComponent(); component.did = 5; - component.versionName = "v2"; - const emit = vi.fn(); - component.userMakeChanges.subscribe(emit); - component.onClickOpenVersionCreator(); + component.createDatasetVersion("v2").subscribe(); expect(datasetServiceStub.createDatasetVersion).toHaveBeenCalledWith(5, "v2"); - expect(notificationServiceStub.success).toHaveBeenCalledWith("Version Created"); - expect(component.versionName).toBe(""); - expect(component.isCreatingVersion).toBe(false); - expect(datasetServiceStub.retrieveDatasetVersionList).toHaveBeenCalled(); - expect(datasetServiceStub.retrieveDatasetLatestVersion).toHaveBeenCalled(); - expect(emit).toHaveBeenCalled(); - }); - - it("surfaces the backend message and resets the in-progress flag on failure", () => { - datasetServiceStub.createDatasetVersion.mockReturnValue(throwError(() => ({ error: { message: "boom" } }))); - createComponent(); - component.did = 5; - component.versionName = "v2"; - - component.onClickOpenVersionCreator(); - - expect(notificationServiceStub.error).toHaveBeenCalledWith("Version creation failed: boom"); - expect(component.isCreatingVersion).toBe(false); }); - it("ignores a second click while a version creation is already in progress", () => { - datasetServiceStub.createDatasetVersion.mockReturnValue(new Subject()); + it("reloads the version list and the latest-version facts once the panel reports one", () => { + datasetServiceStub.retrieveDatasetVersionList.mockClear(); + datasetServiceStub.retrieveDatasetLatestVersion.mockClear(); createComponent(); component.did = 5; - component.onClickOpenVersionCreator(); - component.onClickOpenVersionCreator(); + component.onVersionCreated(); - expect(datasetServiceStub.createDatasetVersion).toHaveBeenCalledTimes(1); - expect(component.isCreatingVersion).toBe(true); + expect(datasetServiceStub.retrieveDatasetVersionList).toHaveBeenCalled(); + expect(datasetServiceStub.retrieveDatasetLatestVersion).toHaveBeenCalled(); }); }); @@ -1852,23 +1083,9 @@ describe("DatasetDetailComponent behavior", () => { }); }); - describe("staged objects and view flags", () => { + describe("view flags", () => { beforeEach(() => createComponent()); - it("tracks the pending-change count from staged objects", () => { - const staged: DatasetStagedObject[] = [ - { path: "a", pathType: "file", diffType: "added", sizeBytes: 1 }, - { path: "b", pathType: "file", diffType: "added", sizeBytes: 1 }, - ]; - component.onStagedObjectsUpdated(staged); - expect(component.pendingChangesCount).toBe(2); - expect(component.userHasPendingChanges).toBe(true); - - component.onStagedObjectsUpdated([]); - expect(component.pendingChangesCount).toBe(0); - expect(component.userHasPendingChanges).toBe(false); - }); - it("toggles the maximize, right-bar and precise-view-count flags", () => { expect(component.isMaximized).toBe(false); component.onClickScaleTheView(); @@ -2182,15 +1399,7 @@ describe("DatasetDetailComponent behavior", () => { }); }); - describe("upload status, version-node selection, and trackBy", () => { - it("getUploadStatus maps the upload status to a progress state", () => { - expect(component.getUploadStatus("uploading")).toBe("active"); - expect(component.getUploadStatus("initializing")).toBe("active"); - expect(component.getUploadStatus("aborted")).toBe("exception"); - expect(component.getUploadStatus("failed")).toBe("exception"); - expect(component.getUploadStatus("finished")).toBe("success"); - }); - + describe("version-node selection", () => { it("onVersionFileTreeNodeSelected loads the selected node's content", () => { const node = { name: "file.csv", type: "file" } as unknown as Parameters< typeof component.onVersionFileTreeNodeSelected @@ -2203,11 +1412,6 @@ describe("DatasetDetailComponent behavior", () => { expect(loadSpy).toHaveBeenCalledWith(node); }); - - it("trackByTask returns the task's file path", () => { - const task = { filePath: "owner/data/file.csv" } as unknown as Parameters[1]; - expect(component.trackByTask(0, task)).toBe("owner/data/file.csv"); - }); }); describe("onPreviouslyUploadedFileDeleted", () => { @@ -2230,35 +1434,24 @@ describe("DatasetDetailComponent behavior", () => { datasetServiceStub.deleteDatasetFile.mockReturnValue(throwError(() => new Error("boom"))); createComponent(); component.did = 5; - const emit = vi.fn(); - component.userMakeChanges.subscribe(emit); component.onPreviouslyUploadedFileDeleted(node); expect(notificationServiceStub.error).toHaveBeenCalledWith("Failed to delete the file"); - // A file the backend still holds is not a staged change, so counting it would - // offer a version to create out of a deletion that never happened. - expect(component.pendingChangesCount).toBe(0); - expect(component.userHasPendingChanges).toBe(false); - expect(emit).not.toHaveBeenCalled(); + // A file the backend still holds is not a staged change, so reporting it to the panel + // would offer a version to create out of a deletion that never happened. + expect(notificationServiceStub.success).not.toHaveBeenCalled(); }); - it("stages the deletion under the same path the next diff response confirms", () => { + it("deletes by the path relative to the version root, which is what the diff reports", () => { createComponent(); component.did = 5; component.onPreviouslyUploadedFileDeleted(node); + // Only an exact match retires the locally staged entry the panel counts, so any + // other form of this path would leave the Finished header double-counting. expect(datasetServiceStub.deleteDatasetFile).toHaveBeenCalledWith(5, "nested/a.txt"); - expect(component.pendingChangesCount).toBe(1); - - // The diff response reports staged paths relative to the version root, and - // only an exact match retires the locally staged entry. A key in any other - // form never reconciles, so the Finished header counts this one deletion - // twice until the next version is created. - component.onStagedObjectsUpdated([{ path: "nested/a.txt", pathType: "file", diffType: "removed", sizeBytes: 0 }]); - - expect(component.pendingChangesCount).toBe(1); }); }); @@ -3281,176 +2474,6 @@ describe("DatasetDetailComponent rendered template", () => { }); }); - describe("upload panel", () => { - beforeEach(() => render({ did: 5, userDatasetAccessLevel: "WRITE" })); - - it("starts an upload for a file the uploader hands over", () => { - const el = openTab("Versions & Files"); - const uploader = fixture.debugElement.query(By.css("texera-user-files-uploader")); - - uploader.triggerEventHandler("uploadedFiles", [makeFileItem("new.csv")]); - fixture.detectChanges(); - - // The chunk size and the chunk concurrency are both plain numbers, so - // asserting their exact values is the only way to notice them exchanged: - // 10-byte chunks, or 52 million parallel requests, would look identical to - // expect.any(Number). Nobody is signed in here, so the component keeps its - // built-in defaults rather than the admin settings. - expect(component.chunkSizeMiB).toBe(50); - expect(component.maxConcurrentChunks).toBe(10); - expect(datasetService.multipartUpload).toHaveBeenCalledWith( - OWNER, - "ds", - "new.csv", - expect.anything(), - 50 * 1024 * 1024, - 10, - false - ); - expect(text(el)).toContain("Uploading: 1 file(s)"); - }); - - /** Renders the given in-flight tasks and expands the "Uploading" panel. */ - const withTasks = (...tasks: Array>): HTMLElement => { - const el = render({ - uploadTasks: tasks.map(t => ({ - percentage: 40, - status: "uploading", - uploadSpeed: 1024, - totalTime: 12, - estimatedTimeRemaining: 30, - ...t, - })) as never, - }); - (component as unknown as { activeUploads: number }).activeUploads = tasks.length; - openTab("Versions & Files"); - openPanel("Uploading:"); - return el; - }; - - it("aborts the upload whose own row button was clicked", () => { - withTasks({ filePath: "first.csv" }, { filePath: "second.csv" }); - - const rows = fixture.debugElement.queryAll(By.css(".upload-progress-wrapper > div")); - expect(rows.length).toBe(2); - // Each row has to name its own task: identifying the row by position alone - // would not notice every row rendering the first task's name and status. - expect(rows.map(row => text(row.query(By.css(".progress-header")).nativeElement))).toEqual([ - "uploading: first.csv", - "uploading: second.csv", - ]); - - const abort = rows[1].query(By.css(".progress-header button")); - // A live upload is cancelled, not dismissed; the finished row below says "Close". - expect(abort.injector.get(NzTooltipDirective).directiveTitle).toBe("Cancel the upload"); - - abort.nativeElement.click(); - fixture.detectChanges(); - - expect(datasetService.finalizeMultipartUpload).toHaveBeenCalledTimes(1); - expect(datasetService.finalizeMultipartUpload).toHaveBeenCalledWith(OWNER, "ds", "second.csv", true); - }); - - it("reports the elapsed time, the time remaining and the speed in their own slots", () => { - // Distinguishable timings, so the two spans cannot stand in for each other: - // showing 90s elapsed on a 12s-old upload is the defect this guards. - const el = withTasks({ - filePath: "big.csv", - totalTime: 12, - estimatedTimeRemaining: 90, - uploadSpeed: 5 * 1024 * 1024, - }); - const stats = q(el, ".upload-stats"); - - expect(Array.from(stats.querySelectorAll(".fixed-width-time")).map(text)).toEqual(["12s", "1m30s left"]); - expect(text(q(stats, ".fixed-width-speed"))).toBe("5.0 MB/s"); - }); - - it("floors both live timings at one second while an upload reports none", () => { - const el = withTasks({ filePath: "big.csv", totalTime: undefined, estimatedTimeRemaining: undefined }); - - const times = Array.from(q(el, ".upload-stats").querySelectorAll(".fixed-width-time")).map(text); - expect(times).toEqual(["1s", "1s left"]); - }); - - it("reports the total time of a finished upload", () => { - const el = withTasks({ filePath: "big.csv", status: "finished", totalTime: 75 }); - - expect(text(q(el, ".upload-stats"))).toContain("Upload time: 1m15s"); - // A finished row is dismissed rather than cancelled. - const button = fixture.debugElement.query(By.css(".upload-progress-wrapper > div .progress-header button")); - expect(button.injector.get(NzTooltipDirective).directiveTitle).toBe("Close"); - }); - - it("floors the total of a finished upload that timed nothing", () => { - const el = withTasks({ filePath: "big.csv", status: "finished", totalTime: undefined }); - - expect(text(q(el, ".upload-stats"))).toContain("Upload time: 1s"); - }); - }); - - describe("version creator", () => { - /** Renders the creator, which only appears with staged changes to commit. */ - const withPendingChanges = (state: Partial = {}): HTMLElement => { - const el = render({ did: 5, userDatasetAccessLevel: "WRITE", userHasPendingChanges: true, ...state }); - openTab("Versions & Files"); - return el; - }; - - const typeName = (el: HTMLElement, value: string): HTMLInputElement => { - const input = q(el, ".version-input"); - input.value = value; - input.dispatchEvent(new Event("input")); - fixture.detectChanges(); - return input; - }; - - it("offers the creator only once there is something to commit", () => { - const el = render({ did: 5, userDatasetAccessLevel: "WRITE", userHasPendingChanges: false }); - openTab("Versions & Files"); - expect(el.querySelector(".version-creator")).toBeNull(); - - render({ userHasPendingChanges: true }); - - expect(el.querySelector(".version-creator")).not.toBeNull(); - expect(text(q(el, ".create-dataset-version-button"))).toBe("Submit"); - }); - - it("creates a version named by the creator's own input", () => { - const el = withPendingChanges(); - - typeName(el, "second cut"); - q(el, ".create-dataset-version-button").click(); - - expect(datasetService.createDatasetVersion).toHaveBeenCalledWith(5, "second cut"); - }); - - it("submits the version straight from the name field with Enter", () => { - const el = withPendingChanges(); - - typeName(el, "from the keyboard").dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); - - expect(datasetService.createDatasetVersion).toHaveBeenCalledWith(5, "from the keyboard"); - }); - - it("spins the submit button and locks the name field while a version is being created", async () => { - const el = withPendingChanges(); - expect(q(el, ".create-dataset-version-button").classList).not.toContain("ant-btn-loading"); - expect(q(el, ".version-input").disabled).toBe(false); - - render({ isCreatingVersion: true }); - // NgModel routes the input's `disabled` binding through control.disable(), - // which it defers to a microtask, so the DOM lags the render by one turn. - await Promise.resolve(); - fixture.detectChanges(); - - expect(q(el, ".create-dataset-version-button").classList).toContain("ant-btn-loading"); - // Renaming a version mid-creation would be applied to nothing, so the - // field is locked for as long as the request is in flight. - expect(q(el, ".version-input").disabled).toBe(true); - }); - }); - describe("settings tab", () => { it("persists a description edited on the Settings tab", () => { render({ did: 5, userDatasetAccessLevel: "WRITE", datasetDescription: "old" }); diff --git a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.ts b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.ts index 2f2e8b209ec..535156b9541 100644 --- a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.ts +++ b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.ts @@ -17,16 +17,12 @@ * under the License. */ -import { Component, EventEmitter, OnInit, Output, ViewChild } from "@angular/core"; +import { Component, OnInit, ViewChild } from "@angular/core"; import { ActivatedRoute, Router } from "@angular/router"; import { USER_DATASET } from "../../../../../app-routing.constant"; import { extractErrorMessage } from "../../../../../common/util/error"; import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy"; -import { - DatasetService, - MultipartUploadProgress, - validateDatasetName, -} from "../../../../service/user/dataset/dataset.service"; +import { DatasetService, validateDatasetName } from "../../../../service/user/dataset/dataset.service"; import { NzResizeEvent, NzResizableDirective, NzResizeHandleComponent } from "ng-zorro-antd/resizable"; import { DatasetFileNode, @@ -41,13 +37,10 @@ import { formatSize } from "src/app/common/util/size-formatter.util"; import { UserService } from "../../../../../common/service/user/user.service"; import { isDefined } from "../../../../../common/util/predicate"; import { ActionType, EntityType, HubService, LikedStatus } from "../../../../../hub/service/hub.service"; -import { FileUploadItem } from "../../../../type/dashboard-file.interface"; -import { DatasetStagedObject } from "../../../../../common/type/dataset-staged-object"; import { NzModalService } from "ng-zorro-antd/modal"; -import { AdminSettingsService } from "../../../../service/admin/settings/admin-settings.service"; -import { HttpErrorResponse, HttpStatusCode } from "@angular/common/http"; -import { EMPTY, Subscription } from "rxjs"; -import { formatCount, formatSpeed, formatTime, parseIntOrDefault } from "src/app/common/util/format.util"; +import { HttpErrorResponse } from "@angular/common/http"; +import { EMPTY, Observable, Subscription } from "rxjs"; +import { formatCount, formatSpeed, formatTime } from "src/app/common/util/format.util"; import { replaceOneImmutable } from "src/app/common/util/array-utils"; import { format } from "date-fns"; import { NgIf, NgClass, NgFor } from "@angular/common"; @@ -74,15 +67,11 @@ import { NzCollapseComponent, NzCollapsePanelComponent } from "ng-zorro-antd/col import { NzSelectComponent, NzOptionComponent } from "ng-zorro-antd/select"; import { UserDatasetVersionFiletreeComponent } from "./user-dataset-version-filetree/user-dataset-version-filetree.component"; import { NzDividerComponent } from "ng-zorro-antd/divider"; -import { FilesUploaderComponent } from "../../files-uploader/files-uploader.component"; -import { NzProgressComponent } from "ng-zorro-antd/progress"; -import { UserDatasetStagedObjectsListComponent } from "./user-dataset-staged-objects-list/user-dataset-staged-objects-list.component"; +import { VersionUploaderComponent } from "../../version-uploader/version-uploader.component"; +import { DATASET_FILE_RESOURCE_ENDPOINT } from "../../../../service/user/file-resource/file-resource-endpoint"; import { NzInputDirective } from "ng-zorro-antd/input"; -import { CdkFixedSizeVirtualScroll, CdkVirtualForOf, CdkVirtualScrollViewport } from "@angular/cdk/scrolling"; export const THROTTLE_TIME_MS = 1000; -export const ABORT_RETRY_MAX_ATTEMPTS = 10; -export const ABORT_RETRY_BACKOFF_BASE_MS = 100; @UntilDestroy() @Component({ @@ -120,13 +109,8 @@ export const ABORT_RETRY_BACKOFF_BASE_MS = 100; NzOptionComponent, UserDatasetVersionFiletreeComponent, NzDividerComponent, - FilesUploaderComponent, - NzProgressComponent, - UserDatasetStagedObjectsListComponent, + VersionUploaderComponent, NzInputDirective, - CdkVirtualScrollViewport, - CdkFixedSizeVirtualScroll, - CdkVirtualForOf, NzDropdownDirective, NzDropdownMenuComponent, NzMenuDirective, @@ -177,47 +161,9 @@ export class DatasetDetailComponent implements OnInit { public viewCount: number = 0; public displayPreciseViewCount = false; - userHasPendingChanges: boolean = false; - pendingChangesCount: number = 0; - // Staged paths from the last diff response, plus locally staged paths not yet - // in one: counted together so the Finished header keeps pace with the - // real-time Pending header between throttled refetches. - private confirmedStagedPaths = new Set(); - private unconfirmedStagedPaths = new Set(); - - // Uploading setting - chunkSizeMiB: number = 50; - maxConcurrentChunks: number = 10; - private uploadSubscriptions = new Map(); - uploadTimeMap = new Map(); - - // Cap number of concurrent files uploads - maxConcurrentFiles: number = 3; - private activeUploads: number = 0; - // FIFO queue of uploads waiting for a concurrency slot, keyed by file name. - private pendingQueue = new Map void>(); - private pendingQueueDirty = false; - private queuedFileNamesSnapshot: string[] = []; - - // Row height must match .pending-file-row in the SCSS. - readonly PENDING_ROW_HEIGHT_PX = 32; - readonly PENDING_LIST_MAX_HEIGHT_PX = 160; + readonly datasetEndpoint = DATASET_FILE_RESOURCE_ENDPOINT; - @ViewChild(CdkVirtualScrollViewport) private pendingViewport?: CdkVirtualScrollViewport; - - versionName: string = ""; - isCreatingVersion: boolean = false; - - public activeMultipartFilePaths: string[] = []; - - // List of upload tasks – each task tracked by its filePath - public uploadTasks: Array< - MultipartUploadProgress & { - filePath: string; - } - > = []; - - @Output() userMakeChanges = new EventEmitter(); + @ViewChild(VersionUploaderComponent) private versionUploader?: VersionUploaderComponent; constructor( private route: ActivatedRoute, @@ -227,8 +173,7 @@ export class DatasetDetailComponent implements OnInit { private notificationService: NotificationService, private downloadService: DownloadService, private userService: UserService, - private hubService: HubService, - private adminSettingsService: AdminSettingsService + private hubService: HubService ) { this.userService .userChanged() @@ -294,37 +239,15 @@ export class DatasetDetailComponent implements OnInit { .subscribe((isLiked: LikedStatus[]) => { this.isLiked = isLiked.length > 0 ? isLiked[0].isLiked : false; }); - - this.loadUploadSettings(); } - public onClickOpenVersionCreator() { - if (this.did && !this.isCreatingVersion) { - this.isCreatingVersion = true; + /** Commits the staged files; the panel owns the rest of the version flow. */ + createDatasetVersion = (versionName: string): Observable => + this.datasetService.createDatasetVersion(this.did!, versionName); - this.datasetService - .createDatasetVersion(this.did, this.versionName?.trim() || "") - .pipe(untilDestroyed(this)) - .subscribe({ - next: res => { - this.notificationService.success("Version Created"); - this.isCreatingVersion = false; - this.versionName = ""; - // A new version consumes all staged changes. - this.confirmedStagedPaths.clear(); - this.unconfirmedStagedPaths.clear(); - this.refreshPendingChanges(); - this.retrieveDatasetVersionList(); - this.retrieveLatestVersionFile(); - this.userMakeChanges.emit(); - }, - error: (res: unknown) => { - const err = res as HttpErrorResponse; - this.notificationService.error(`Version creation failed: ${err.error.message}`); - this.isCreatingVersion = false; - }, - }); - } + onVersionCreated(): void { + this.retrieveDatasetVersionList(); + this.retrieveLatestVersionFile(); } public onClickDownloadVersionAsZip() { @@ -501,28 +424,6 @@ export class DatasetDetailComponent implements OnInit { this.isRightBarCollapsed = !this.isRightBarCollapsed; } - onStagedObjectsUpdated(stagedObjects: DatasetStagedObject[]) { - this.confirmedStagedPaths = new Set(stagedObjects.map(obj => obj.path)); - for (const path of this.confirmedStagedPaths) { - this.unconfirmedStagedPaths.delete(path); - } - this.refreshPendingChanges(); - } - - // Reflects a locally staged change (finished upload or file deletion) in the - // Finished header immediately, ahead of the next diff response. - private markPathStaged(path: string): void { - if (!this.confirmedStagedPaths.has(path)) { - this.unconfirmedStagedPaths.add(path); - } - this.refreshPendingChanges(); - } - - private refreshPendingChanges(): void { - this.pendingChangesCount = this.confirmedStagedPaths.size + this.unconfirmedStagedPaths.size; - this.userHasPendingChanges = this.pendingChangesCount > 0; - } - onVersionSelected(version: DatasetVersion): void { this.selectedVersion = version; if (this.did && this.selectedVersion.dvid) @@ -572,319 +473,18 @@ export class DatasetDetailComponent implements OnInit { return this.datasetIsDownloadable && (this.datasetIsPublic || this.userDatasetAccessLevel !== "NONE"); } - // Track multiple file by unique key - trackByTask(_: number, task: MultipartUploadProgress & { filePath: string }): string { - return task.filePath; - } - - trackByPendingFile(_: number, fileName: string): string { - return fileName; - } - - // A missing key or failed fetch keeps the field defaults; NaN here would - // silently stall the upload queue (`activeUploads < NaN` is always false). - private loadUploadSettings(): void { - this.adminSettingsService - .getPublicSetting("dataset_multipart_upload_chunk_size_mib") - .pipe(untilDestroyed(this)) - .subscribe({ - next: value => (this.chunkSizeMiB = parseIntOrDefault(value, this.chunkSizeMiB)), - error: () => {}, - }); - this.adminSettingsService - .getPublicSetting("dataset_max_number_of_concurrent_uploading_file_chunks") - .pipe(untilDestroyed(this)) - .subscribe({ - next: value => (this.maxConcurrentChunks = parseIntOrDefault(value, this.maxConcurrentChunks)), - error: () => {}, - }); - this.adminSettingsService - .getPublicSetting("dataset_max_number_of_concurrent_uploading_file") - .pipe(untilDestroyed(this)) - .subscribe({ - next: value => (this.maxConcurrentFiles = parseIntOrDefault(value, this.maxConcurrentFiles)), - error: () => {}, - }); - } - - onNewUploadFilesChanged(files: FileUploadItem[]) { - if (this.did) { - files.forEach(file => { - // Check if currently uploading - const continueWithUpload = () => { - // Create upload function - const startUpload = () => { - this.removeFromPendingQueue(file.name); - - // Add an initializing task placeholder to uploadTasks - this.uploadTasks.unshift({ - filePath: file.name, - percentage: 0, - status: "initializing", - }); - // Start multipart upload - const subscription = this.datasetService - .multipartUpload( - this.ownerEmail, - this.datasetName, - file.name, - file.file, - this.chunkSizeMiB * 1024 * 1024, - this.maxConcurrentChunks, - file.restart - ) - .pipe(untilDestroyed(this)) - .subscribe({ - next: progress => { - // Find the task - const taskIndex = this.uploadTasks.findIndex(t => t.filePath === file.name); - - if (taskIndex !== -1) { - // Update the task with new progress info - this.uploadTasks[taskIndex] = { - ...this.uploadTasks[taskIndex], - ...progress, - percentage: progress.percentage ?? this.uploadTasks[taskIndex].percentage ?? 0, - }; - - // totalTime may be exactly 0 (resumed upload with no missing - // parts); a truthiness check would leak the concurrency slot. - if (progress.status === "finished" && progress.totalTime !== undefined) { - const filename = file.name.split("/").pop() || file.name; - this.uploadTimeMap.set(filename, progress.totalTime); - this.markPathStaged(file.name); - this.userMakeChanges.emit(); - this.scheduleHide(taskIndex); - this.onUploadComplete(); - } - } - }, - error: (res: unknown) => { - const err = res as HttpErrorResponse; - - if (err?.status === HttpStatusCode.Conflict) { - this.notificationService.error( - "Upload blocked (409). Another upload is likely in progress for this file (another tab/browser), or the server is finalizing a previous upload. Please retry in a moment." - ); - } else { - this.notificationService.error("Upload failed. Please retry."); - } - // Handle upload error - const taskIndex = this.uploadTasks.findIndex(t => t.filePath === file.name); - - if (taskIndex !== -1) { - this.uploadTasks[taskIndex] = { - ...this.uploadTasks[taskIndex], - percentage: this.uploadTasks[taskIndex].percentage ?? 0, // was 100 - status: "failed", - }; - this.scheduleHide(taskIndex); - } - this.onUploadComplete(); - }, - complete: () => { - const taskIndex = this.uploadTasks.findIndex(t => t.filePath === file.name); - if (taskIndex !== -1 && this.uploadTasks[taskIndex].status !== "finished") { - this.uploadTasks[taskIndex].status = "finished"; - this.markPathStaged(file.name); - this.userMakeChanges.emit(); - this.scheduleHide(taskIndex); - this.onUploadComplete(); - } - }, - }); - // Store the subscription for later cleanup - this.uploadSubscriptions.set(file.name, subscription); - }; - - // Queue management - if (this.activeUploads < this.maxConcurrentFiles) { - this.activeUploads++; - startUpload(); - } else { - this.pendingQueue.set(file.name, startUpload); - this.pendingQueueDirty = true; - } - }; - - // Check if currently uploading - this.cancelExistingUpload(file.name, continueWithUpload); - }); - } - } - - cancelExistingUpload(fileName: string, onCanceled?: () => void): void { - const task = this.uploadTasks.find(t => t.filePath === fileName); - if (task) { - if (task.status === "uploading" || task.status === "initializing") { - this.onClickAbortUploadProgress(task, onCanceled); - return; - } - } - // Remove from pending queue if present - this.removeFromPendingQueue(fileName); - if (onCanceled) { - onCanceled(); - } - } - - private processNextQueuedUpload(): void { - if (this.activeUploads < this.maxConcurrentFiles) { - const next = this.pendingQueue.entries().next(); - if (!next.done) { - const [fileName, startUpload] = next.value; - this.pendingQueue.delete(fileName); - this.pendingQueueDirty = true; - this.activeUploads++; - startUpload(); - } - } - } - - private onUploadComplete(): void { - this.activeUploads--; - this.processNextQueuedUpload(); - } - - private removeFromPendingQueue(fileName: string): void { - if (this.pendingQueue.delete(fileName)) { - this.pendingQueueDirty = true; - } - } - - // Stable array for the template: rebuilt at most once per queue change so - // change detection does not allocate a new array per pass (#5586). - get queuedFileNames(): string[] { - if (this.pendingQueueDirty) { - this.queuedFileNamesSnapshot = Array.from(this.pendingQueue.keys()); - this.pendingQueueDirty = false; - } - return this.queuedFileNamesSnapshot; - } - - get queuedCount(): number { - return this.pendingQueue.size; - } - - get pendingListHeightPx(): number { - return Math.min(this.queuedCount * this.PENDING_ROW_HEIGHT_PX, this.PENDING_LIST_MAX_HEIGHT_PX); - } - - // The viewport initializes inside the collapsed (display: none) panel and - // measures height 0; the CDK only re-measures on window resize. - onPendingPanelActiveChange(active: boolean): void { - if (active) { - setTimeout(() => this.pendingViewport?.checkViewportSize()); - } - } - - get activeCount(): number { - return this.activeUploads; - } - - get hasAnyActivity(): boolean { - return this.pendingChangesCount > 0 || this.activeCount > 0 || this.queuedCount > 0; - } - - // Hide a task row after 5s - private scheduleHide(idx: number) { - if (idx === -1) { - return; - } - const task = this.uploadTasks[idx]; - this.uploadSubscriptions.delete(task.filePath); - // Remove by identity, not filePath: a same-named re-upload within the - // window has its own row, which must survive this timer. - setTimeout(() => { - this.uploadTasks = this.uploadTasks.filter(t => t !== task); - }, 5000); - } - - onClickAbortUploadProgress(task: MultipartUploadProgress & { filePath: string }, onAborted?: () => void) { - const subscription = this.uploadSubscriptions.get(task.filePath); - if (subscription) { - subscription.unsubscribe(); - this.uploadSubscriptions.delete(task.filePath); - } - - if (task.status === "uploading" || task.status === "initializing") { - this.onUploadComplete(); - } - - let doneCalled = false; - const done = () => { - if (doneCalled) { - return; - } - doneCalled = true; - if (onAborted) { - onAborted(); - } - }; - - const abortWithRetry = (attempt: number) => { - this.datasetService - .finalizeMultipartUpload( - this.ownerEmail, - this.datasetName, - task.filePath, - true // abort flag - ) - .pipe(untilDestroyed(this)) - .subscribe({ - next: () => { - this.notificationService.info(`${task.filePath} uploading has been terminated`); - done(); - }, - error: (res: unknown) => { - const err = res as HttpErrorResponse; - // Already gone, treat as done - if (err.status === 404) { - done(); - return; - } - - // Backend is still finalizing/aborting; retry with a tiny backoff - if (err.status === HttpStatusCode.Conflict && attempt < ABORT_RETRY_MAX_ATTEMPTS) { - setTimeout(() => abortWithRetry(attempt + 1), ABORT_RETRY_BACKOFF_BASE_MS * (attempt + 1)); - return; - } - - // Keep current UX: still consider it "aborted" client-side - done(); - }, - }); - }; - - abortWithRetry(0); - - const idx = this.uploadTasks.findIndex(t => t.filePath === task.filePath); - if (idx !== -1) { - this.uploadTasks[idx] = { ...this.uploadTasks[idx], status: "aborted" }; - this.scheduleHide(idx); - } - } - - getUploadStatus(status: MultipartUploadProgress["status"]): "active" | "exception" | "success" { - return status === "uploading" || status === "initializing" - ? "active" - : status === "aborted" || status === "failed" - ? "exception" - : "success"; - } - onPreviouslyUploadedFileDeleted(node: DatasetFileNode) { if (this.did) { + const relativePath = getRelativePathFromDatasetFileNode(node); this.datasetService - .deleteDatasetFile(this.did, getRelativePathFromDatasetFileNode(node)) + .deleteDatasetFile(this.did, relativePath) .pipe(untilDestroyed(this)) .subscribe({ next: (res: Response) => { this.notificationService.success( `File ${node.name} is successfully deleted. You may finalize it or revert it at the "Create Version" panel` ); - this.markPathStaged(getRelativePathFromDatasetFileNode(node)); - this.userMakeChanges.emit(); + this.versionUploader?.notePathStaged(relativePath); }, error: (err: unknown) => { this.notificationService.error("Failed to delete the file"); diff --git a/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.html b/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.html index e394517d293..94be936b129 100644 --- a/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.html +++ b/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.html @@ -345,13 +345,119 @@
Choose a Version:
+ [isTreeNodeDeletable]="userHasWriteAccess()" + (selectedTreeNode)="onVersionFileTreeNodeSelected($event)" + (deletedTreeNode)="onPreviouslyUploadedFileDeleted($event)"> + + + +
+ +
+ +

General

+ +
+
+ +

Letters, numbers, underscores and hyphens.

+

+ An upload is in progress — finish or cancel it in Versions & Files first, or it will be left + incomplete. +

+
+
+ + +
+
+ + + +
+ +
Shown on cards.
+ + +
+
+ + +

Model type

+ +
+
+ +

The library this model was trained with.

+
+
+ + + +
+
+ + + +
+
+ +

How the model weights are serialized.

+
+
+ + + +
+
+
+
+
diff --git a/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.scss b/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.scss index ec85131c60c..edea7d38b54 100644 --- a/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.scss +++ b/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.scss @@ -294,3 +294,64 @@ nz-tabs { text-align: right; word-break: break-word; } + +.settings-tab-content { + height: 100%; + overflow-y: auto; + padding-bottom: 24px; +} + +.settings-general-card { + max-width: 700px; + margin: 24px auto 0; + border-radius: 16px; + border: 1px solid #d9d9d9; + + .settings-card-title { + font-weight: 600; + margin-bottom: 20px; + } + + .settings-field-label { + display: block; + font-weight: 600; + } + + .settings-field-hint { + color: rgba(0, 0, 0, 0.45); + font-size: 12px; + margin-bottom: 8px; + } + + .settings-name-row { + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + + .settings-name-label label { + font-weight: 600; + } + + .settings-hint { + color: rgba(0, 0, 0, 0.45); + font-size: 12px; + margin: 4px 0 0; + } + + .settings-name-controls { + display: flex; + gap: 8px; + flex-shrink: 0; + + input, + button { + border-radius: 8px; + } + } + } + + .settings-select { + min-width: 200px; + } +} diff --git a/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.spec.ts b/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.spec.ts index 017e980b8d1..93a89bfd0ca 100644 --- a/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.spec.ts +++ b/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.spec.ts @@ -22,12 +22,19 @@ import { NoopAnimationsModule } from "@angular/platform-browser/animations"; import { ActivatedRoute } from "@angular/router"; import { of, throwError } from "rxjs"; import { MarkdownService } from "ngx-markdown"; +import { NzModalService } from "ng-zorro-antd/modal"; +import { By } from "@angular/platform-browser"; import { commonTestImports, commonTestProviders } from "../../../../../common/testing/test-utils"; import { NotificationService } from "../../../../../common/service/notification/notification.service"; import { UserService } from "../../../../../common/service/user/user.service"; import { StubUserService } from "../../../../../common/service/user/stub-user.service"; -import { ModelService } from "../../../../service/user/model/model.service"; +import { MODEL_FORMATS, MODEL_FRAMEWORKS, ModelService } from "../../../../service/user/model/model.service"; import { DownloadService } from "../../../../service/user/download/download.service"; +import { AdminSettingsService } from "../../../../service/admin/settings/admin-settings.service"; +import { MultipartUploadService } from "../../../../service/user/file-resource/multipart-upload.service"; +import { StagedFileService } from "../../../../service/user/file-resource/staged-file.service"; +import { MODEL_FILE_RESOURCE_ENDPOINT } from "../../../../service/user/file-resource/file-resource-endpoint"; +import { VersionUploaderComponent } from "../../version-uploader/version-uploader.component"; import { DatasetFileNode } from "../../../../../common/type/datasetVersionFileTree"; import { ModelVersion } from "../../../../../common/type/model"; import { ModelDetailComponent } from "./model-detail.component"; @@ -58,6 +65,9 @@ describe("ModelDetailComponent", () => { let modelService: Record>; let downloadService: Record>; let notificationService: Record>; + let multipartUploadService: Record>; + let stagedFileService: Record>; + let adminSettingsService: Record>; const dashboardModel = (overrides: Partial> = {}) => ({ isOwner: true, @@ -104,6 +114,19 @@ describe("ModelDetailComponent", () => { downloadModelVersion: vi.fn(() => of(new Blob())), }; notificationService = { success: vi.fn(), error: vi.fn(), info: vi.fn() }; + multipartUploadService = { + multipartUpload: vi.fn(() => of({ filePath: "f", percentage: 100, status: "finished", totalTime: 1 })), + listMultipartUploads: vi.fn(() => of([])), + findExistingUploadFiles: vi.fn(() => of([])), + finalizeMultipartUpload: vi.fn(() => of({})), + }; + stagedFileService = { + getDiff: vi.fn(() => of([])), + resetFileDiff: vi.fn(() => of({})), + deleteFile: vi.fn(() => of({})), + }; + // Every model upload key is absent in this stub, so the component keeps its own defaults. + adminSettingsService = { getPublicSetting: vi.fn(() => of("")) }; TestBed.configureTestingModule({ imports: [ModelDetailComponent, NoopAnimationsModule, ...commonTestImports], @@ -114,6 +137,10 @@ describe("ModelDetailComponent", () => { { provide: NotificationService, useValue: notificationService }, { provide: UserService, useClass: StubUserService }, { provide: MarkdownService, useValue: { parse: vi.fn(() => "") } }, + { provide: NzModalService, useValue: {} }, + { provide: MultipartUploadService, useValue: multipartUploadService }, + { provide: StagedFileService, useValue: stagedFileService }, + { provide: AdminSettingsService, useValue: adminSettingsService }, ...commonTestProviders, ], }); @@ -456,6 +483,306 @@ describe("ModelDetailComponent", () => { expect(q(root, ".model-cover-image").src).toContain("http://cover"); }); + // ─── uploading files and cutting a version ────────────────────────────────── + // + // The panel itself is covered by version-uploader.component.spec.ts; what matters here is that + // the page hands it the model's own addressing, and what the page still owns around it. + + it("hands the version uploader the model endpoint and the model's identity", () => { + create(); + const root = openTab("Versions & Files"); + + const panel = q(root, "texera-version-uploader"); + const uploader = fixture.debugElement.query(By.directive(VersionUploaderComponent)) + .componentInstance as VersionUploaderComponent; + + expect(panel).toBeTruthy(); + expect(uploader.endpoint).toBe(MODEL_FILE_RESOURCE_ENDPOINT); + expect(uploader.resourceId).toBe(MID); + expect(uploader.resourceName).toBe("resnet-50"); + expect(uploader.ownerEmail).toBe(OWNER); + }); + + it("reads its upload settings from the model keys, not the dataset ones", () => { + create(); + openTab("Versions & Files"); + const keys = new Set(adminSettingsService["getPublicSetting"].mock.calls.map(call => call[0])); + + // Three from the panel's tuning, plus the file-picker's per-file ceiling — the 2 GiB model + // limit added by #8000, which models used to inherit from the dataset's 20 MiB. + expect(keys).toEqual( + new Set([ + MODEL_FILE_RESOURCE_ENDPOINT.chunkSizeSettingKey, + MODEL_FILE_RESOURCE_ENDPOINT.maxConcurrentChunksSettingKey, + MODEL_FILE_RESOURCE_ENDPOINT.maxConcurrentFilesSettingKey, + MODEL_FILE_RESOURCE_ENDPOINT.maxFileSizeSettingKey, + ]) + ); + expect([...keys].every(key => key.startsWith("model_"))).toBe(true); + }); + + it("shows the upload panel only to a user who can write", () => { + create(); + expect(openTab("Versions & Files").querySelector("texera-version-uploader")).toBeTruthy(); + + modelService["getModel"] = vi.fn(() => of(dashboardModel({ accessPrivilege: "READ", isOwner: false }))); + create(); + expect(openTab("Versions & Files").querySelector("texera-version-uploader")).toBeNull(); + }); + + it("commits a version through ModelService", () => { + modelService["createModelVersion"] = vi.fn(() => of(aVersion(1, "v1"))); + create(); + + component.createModelVersion("v1").subscribe(); + + expect(modelService["createModelVersion"]).toHaveBeenCalledWith(MID, "v1"); + }); + + it("reloads the version list once the panel reports a new version", () => { + create(); + expect(modelService["retrieveModelVersionList"]).toHaveBeenCalledTimes(1); + + component.onVersionCreated(); + + expect(modelService["retrieveModelVersionList"]).toHaveBeenCalledTimes(2); + }); + + it("stages a deletion of an already-committed file", () => { + create(); + openTab("Versions & Files"); + + component.onPreviouslyUploadedFileDeleted(aFile("model.pt", `/model/${OWNER}/resnet-50/v1`)); + + expect(stagedFileService["deleteFile"]).toHaveBeenCalledWith(MODEL_FILE_RESOURCE_ENDPOINT, MID, "model.pt"); + expect(notificationService["success"]).toHaveBeenCalled(); + }); + + it("stages nothing and reports the failure when the deletion is rejected", () => { + stagedFileService["deleteFile"] = vi.fn(() => throwError(() => new Error("boom"))); + create(); + openTab("Versions & Files"); + + component.onPreviouslyUploadedFileDeleted(aFile("model.pt", `/model/${OWNER}/resnet-50/v1`)); + + expect(notificationService["error"]).toHaveBeenCalledWith("Failed to delete the file"); + }); + + it("deletes nothing without a model id", () => { + create(); + component.mid = undefined; + + component.onPreviouslyUploadedFileDeleted(aFile("model.pt", `/model/${OWNER}/resnet-50/v1`)); + + expect(stagedFileService["deleteFile"]).not.toHaveBeenCalled(); + }); + + // ─── the Settings tab ─────────────────────────────────────────────────────── + + it("hides the Settings tab from a reader", () => { + modelService["getModel"] = vi.fn(() => of(dashboardModel({ accessPrivilege: "READ", isOwner: false }))); + create(); + + const titles = Array.from( + (fixture.nativeElement as HTMLElement).querySelectorAll(".ant-tabs-tab") + ).map(el => el.textContent ?? ""); + expect(titles.some(title => title.includes("Settings"))).toBe(false); + }); + + it("renames the model from the Settings tab", () => { + modelService["updateModelName"] = vi.fn(() => of({})); + create(); + component.editedModelName = "resnet-101"; + + component.onSaveModelName(); + + expect(modelService["updateModelName"]).toHaveBeenCalledWith(MID, "resnet-101"); + expect(component.modelName).toBe("resnet-101"); + }); + + it("refreshes the file tree after a rename, so paths carry the new name", () => { + modelService["updateModelName"] = vi.fn(() => of({})); + modelService["retrieveModelVersionList"] = vi.fn(() => of([aVersion(1, "v1")])); + modelService["retrieveModelVersionFileTree"] = vi.fn(() => + of({ fileNodes: [aFile("model.pt", `/model/${OWNER}/resnet-50/v1`)], size: 4 }) + ); + create(); + modelService["retrieveModelVersionFileTree"].mockClear(); + // The renamed model's tree comes back under the new path. + modelService["retrieveModelVersionFileTree"] = vi.fn(() => + of({ fileNodes: [aFile("model.pt", `/model/${OWNER}/resnet-101/v1`)], size: 4 }) + ); + component.editedModelName = "resnet-101"; + + component.onSaveModelName(); + + // Preview and single-file download resolve a model by (owner, name), so a tree still + // holding the old name would 404 on every file until the page was reloaded. + expect(modelService["retrieveModelVersionFileTree"]).toHaveBeenCalledWith(MID, 1, true); + expect(component.currentDisplayedFileName).toBe(`/model/${OWNER}/resnet-101/v1/model.pt`); + }); + + it("lists the newest version's objects once on load, not once per consumer", () => { + modelService["retrieveModelVersionList"] = vi.fn(() => of([aVersion(2, "v2"), aVersion(1, "v1")])); + modelService["retrieveModelVersionFileTree"] = vi.fn(() => + of({ fileNodes: [aFile("model.pt", `/model/${OWNER}/resnet-50/v2`)], size: 4 }) + ); + create(); + + // The file tree and the Model Card both describe v2 here, so one response serves both. + expect(modelService["retrieveModelVersionFileTree"]).toHaveBeenCalledTimes(1); + expect(component.latestVersionFileName).toBe(`/model/${OWNER}/resnet-50/v2/model.pt`); + expect(component.latestVersionSize).toBe(4); + }); + + it("refreshes the Model Card too when an older version is on screen, without moving the picker", () => { + const versions = [aVersion(2, "v2"), aVersion(1, "v1")]; + modelService["updateModelName"] = vi.fn(() => of({})); + modelService["retrieveModelVersionList"] = vi.fn(() => of(versions)); + let name = "resnet-50"; + modelService["retrieveModelVersionFileTree"] = vi.fn((_mid: number, mvid: number) => + of({ fileNodes: [aFile(`v${mvid}.pt`, `/model/${OWNER}/${name}/v${mvid}`)], size: mvid * 100 }) + ); + create(); + component.onVersionSelected(versions[1]); + expect(component.latestVersionFileName).toBe(`/model/${OWNER}/resnet-50/v2/v2.pt`); + + name = "resnet-101"; + component.editedModelName = name; + component.onSaveModelName(); + + // The Model Card describes the newest version, which is not the one being browsed — deriving + // its facts from the selection would have left this path on the old name. + expect(component.latestVersionFileName).toBe(`/model/${OWNER}/resnet-101/v2/v2.pt`); + expect(component.currentDisplayedFileName).toBe(`/model/${OWNER}/resnet-101/v1/v1.pt`); + // Renaming is not a reason to move the user off the version they opened. + expect(component.selectedVersion).toBe(versions[1]); + }); + + it("reopens the file you were reading after a rename, not the version's first", () => { + modelService["updateModelName"] = vi.fn(() => of({})); + modelService["retrieveModelVersionList"] = vi.fn(() => of([aVersion(1, "v1")])); + let name = "resnet-50"; + const tree = () => ({ + fileNodes: [ + aFile("first.txt", `/model/${OWNER}/${name}/v1`), + { + name: "weights", + type: "directory" as const, + parentDir: `/model/${OWNER}/${name}/v1`, + children: [aFile("model.pt", `/model/${OWNER}/${name}/v1/weights`)], + }, + ], + size: 8, + }); + modelService["retrieveModelVersionFileTree"] = vi.fn(() => of(tree())); + create(); + // Open a nested file that is not the one the tree opens by default. + component.onVersionFileTreeNodeSelected(aFile("model.pt", `/model/${OWNER}/resnet-50/v1/weights`)); + expect(component.currentDisplayedFileName).toBe(`/model/${OWNER}/resnet-50/v1/weights/model.pt`); + + name = "resnet-101"; + component.editedModelName = name; + component.onSaveModelName(); + + // Same file, new path — renaming should not lose the reader's place. + expect(component.currentDisplayedFileName).toBe(`/model/${OWNER}/resnet-101/v1/weights/model.pt`); + }); + + it("falls back to the first file when a rename outlives the file that was open", () => { + modelService["updateModelName"] = vi.fn(() => of({})); + modelService["retrieveModelVersionList"] = vi.fn(() => of([aVersion(1, "v1")])); + modelService["retrieveModelVersionFileTree"] = vi.fn(() => + of({ fileNodes: [aFile("only.txt", `/model/${OWNER}/resnet-101/v1`)], size: 4 }) + ); + create(); + component.editedModelName = "resnet-101"; + + component.onSaveModelName(); + + expect(component.currentDisplayedFileName).toBe(`/model/${OWNER}/resnet-101/v1/only.txt`); + }); + + it("skips the tree refresh for a model with no versions", () => { + modelService["updateModelName"] = vi.fn(() => of({})); + create(); + modelService["retrieveModelVersionFileTree"].mockClear(); + component.editedModelName = "resnet-101"; + + component.onSaveModelName(); + + expect(modelService["retrieveModelVersionFileTree"]).not.toHaveBeenCalled(); + expect(component.modelName).toBe("resnet-101"); + }); + + it("refuses to rename while an upload is in flight", () => { + modelService["updateModelName"] = vi.fn(() => of({})); + create(); + component.uploadsInFlight = true; + component.editedModelName = "resnet-101"; + + component.onSaveModelName(); + + // The engine captured the old name when the upload started; renaming now would strand its + // remaining parts, and the abort — which reads the new name — could not clean them up. + expect(modelService["updateModelName"]).not.toHaveBeenCalled(); + expect(notificationService["error"]).toHaveBeenCalled(); + expect(component.modelName).toBe("resnet-50"); + }); + + it("rejects an invalid name without calling the server", () => { + modelService["updateModelName"] = vi.fn(() => of({})); + create(); + component.editedModelName = "not a valid name"; + + component.onSaveModelName(); + + expect(modelService["updateModelName"]).not.toHaveBeenCalled(); + expect(notificationService["error"]).toHaveBeenCalled(); + expect(component.modelName).toBe("resnet-50"); + }); + + it("restores the previous description when the update fails", () => { + modelService["updateModelDescription"] = vi.fn(() => throwError(() => new Error("boom"))); + create(); + + component.onModelDescriptionChange("a new description"); + + expect(component.modelDescription).toBe("a description"); + expect(notificationService["error"]).toHaveBeenCalled(); + }); + + it("skips the description update when nothing changed", () => { + modelService["updateModelDescription"] = vi.fn(() => of({})); + create(); + + component.onModelDescriptionChange("a description"); + + expect(modelService["updateModelDescription"]).not.toHaveBeenCalled(); + }); + + it("saves the framework and format, and rolls back a rejected one", () => { + modelService["updateModelFramework"] = vi.fn(() => of({})); + modelService["updateModelFormat"] = vi.fn(() => throwError(() => new Error("boom"))); + create(); + + component.onFrameworkChange("onnx"); + component.onFormatChange("safetensors"); + + expect(modelService["updateModelFramework"]).toHaveBeenCalledWith(MID, "onnx"); + expect(component.modelFramework).toBe("onnx"); + expect(component.modelFormat).toBe("torchscript"); + }); + + it("offers exactly the frameworks and formats the backend accepts", () => { + create(); + const root = openTab("Settings"); + + expect(root.querySelectorAll("nz-select").length).toBe(2); + expect(component.frameworks).toEqual(MODEL_FRAMEWORKS); + expect(component.formats).toEqual(MODEL_FORMATS); + }); + it("collapses and restores the right sider, and maximizes the preview", () => { create(); const root = openTab("Versions & Files"); diff --git a/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.ts b/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.ts index d1f918ff6d0..cd5fa68a860 100644 --- a/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.ts +++ b/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.ts @@ -17,10 +17,11 @@ * under the License. */ -import { Component, OnInit } from "@angular/core"; +import { Component, OnInit, ViewChild } from "@angular/core"; import { ActivatedRoute } from "@angular/router"; import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy"; import { switchMap } from "rxjs/operators"; +import { Observable } from "rxjs"; import { format } from "date-fns"; import { NgIf, NgClass, NgFor } from "@angular/common"; import { FormsModule } from "@angular/forms"; @@ -37,9 +38,18 @@ 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 { NzDividerComponent } from "ng-zorro-antd/divider"; +import { NzInputDirective } from "ng-zorro-antd/input"; -import { ModelService } from "../../../../service/user/model/model.service"; +import { + MODEL_FORMATS, + MODEL_FRAMEWORKS, + ModelService, + validateModelName, +} from "../../../../service/user/model/model.service"; import { DownloadService } from "../../../../service/user/download/download.service"; +import { StagedFileService } from "../../../../service/user/file-resource/staged-file.service"; +import { MODEL_FILE_RESOURCE_ENDPOINT } from "../../../../service/user/file-resource/file-resource-endpoint"; import { NotificationService } from "../../../../../common/service/notification/notification.service"; import { UserService } from "../../../../../common/service/user/user.service"; import { EntityType } from "../../../../../hub/service/hub.service"; @@ -47,8 +57,13 @@ 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 { + DatasetFileNode, + getFullPathFromDatasetFileNode, + getRelativePathFromDatasetFileNode, +} from "../../../../../common/type/datasetVersionFileTree"; import { MarkdownDescriptionComponent } from "../../markdown-description/markdown-description.component"; +import { VersionUploaderComponent } from "../../version-uploader/version-uploader.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"; @@ -81,7 +96,10 @@ import { UserDatasetVersionFiletreeComponent } from "../../user-dataset/user-dat NzOptionComponent, NzTabsComponent, NzTabComponent, + NzDividerComponent, + NzInputDirective, MarkdownDescriptionComponent, + VersionUploaderComponent, UserDatasetFileRendererComponent, UserDatasetVersionFiletreeComponent, ], @@ -89,6 +107,7 @@ import { UserDatasetVersionFiletreeComponent } from "../../user-dataset/user-dat export class ModelDetailComponent implements OnInit { public mid: number | undefined; public modelName: string = ""; + public editedModelName: string = ""; public modelDescription: string = ""; public modelCreationTime: string = ""; public modelCreationTimeTooltip: string = ""; @@ -114,6 +133,8 @@ export class ModelDetailComponent implements OnInit { public currentDisplayedFileName: string = ""; public currentFileSize: number | undefined; + // Path within the version, which survives a rename — unlike currentDisplayedFileName. + private openFileRelativePath: string = ""; // Placeholders until models reach the hub. The hub backend has no model entity type // (`hub/EntityType.scala` is Workflow and Dataset only), so nothing can populate these yet. @@ -127,6 +148,15 @@ export class ModelDetailComponent implements OnInit { public currentUid: number | undefined = this.userService.getCurrentUser()?.uid; public readonly modelEntityType = EntityType.Model; + public readonly modelEndpoint = MODEL_FILE_RESOURCE_ENDPOINT; + public readonly frameworks = MODEL_FRAMEWORKS; + public readonly formats = MODEL_FORMATS; + + @ViewChild(VersionUploaderComponent) private versionUploader?: VersionUploaderComponent; + + // Renaming mid-upload strands the in-flight parts under the old name, so the Settings tab + // blocks it until the panel is idle. + public uploadsInFlight = false; formatSize = formatSize; formatCount = formatCount; @@ -135,6 +165,7 @@ export class ModelDetailComponent implements OnInit { private route: ActivatedRoute, private modelService: ModelService, private downloadService: DownloadService, + private stagedFileService: StagedFileService, private notificationService: NotificationService, private userService: UserService ) { @@ -193,6 +224,7 @@ export class ModelDetailComponent implements OnInit { next: dashboardModel => { const model = dashboardModel.model; this.modelName = model.name; + this.editedModelName = model.name; this.modelDescription = model.description; this.modelIsPublic = model.isPublic; this.modelIsDownloadable = model.isDownloadable; @@ -241,15 +273,18 @@ export class ModelDetailComponent implements OnInit { if (versions.length === 0) { return; } - const latest = versions[0]; - this.latestVersionCreationTime = this.formatCreationTime(latest); - this.onVersionSelected(latest); + this.latestVersionCreationTime = this.formatCreationTime(versions[0]); + this.onVersionSelected(versions[0]); }, error: (err: unknown) => this.notificationService.error(extractErrorMessage(err)), }); } - onVersionSelected(version: ModelVersion | undefined): void { + /** + * @param preferredRelativePath reopens this file rather than the version's first, when the + * refetched tree still holds it. Used after a rename, which invalidates every path. + */ + onVersionSelected(version: ModelVersion | undefined, preferredRelativePath?: string): void { this.selectedVersion = version; if (!this.mid || !version?.mvid) { return; @@ -263,22 +298,56 @@ export class ModelDetailComponent implements OnInit { this.currentModelVersionSize = data.size; this.selectedVersionCreationTime = this.formatCreationTime(version); - const firstFile = this.getFirstFileNode(this.fileTreeNodeList); + // The Model Card describes the newest version, so when that is the one just fetched its + // facts come from this response rather than a second identical request. if (version === this.versions[0]) { - this.latestVersionFileName = firstFile ? getFullPathFromDatasetFileNode(firstFile) : ""; - this.latestVersionSize = data.size; + this.applyLatestVersionFacts(data); } - if (!firstFile) { + + const preferred = preferredRelativePath + ? this.findFileByRelativePath(this.fileTreeNodeList, preferredRelativePath) + : undefined; + const target = preferred ?? this.getFirstFileNode(this.fileTreeNodeList); + if (!target) { this.currentDisplayedFileName = ""; this.currentFileSize = undefined; + this.openFileRelativePath = ""; return; } - this.loadFileContent(firstFile); + this.loadFileContent(target); }, error: (err: unknown) => this.notificationService.error(extractErrorMessage(err)), }); } + private applyLatestVersionFacts(data: { fileNodes: DatasetFileNode[]; size: number }): void { + const firstFile = this.getFirstFileNode(data.fileNodes); + this.latestVersionFileName = firstFile ? getFullPathFromDatasetFileNode(firstFile) : ""; + this.latestVersionSize = data.size; + } + + /** + * Refreshes the Model Card when the newest version is *not* the one on screen. Whenever they + * coincide, onVersionSelected fills it in from the tree it already fetched. + */ + private retrieveLatestVersionFacts(): void { + const latest = this.versions[0]; + if (!this.mid || !latest?.mvid) { + this.latestVersionCreationTime = ""; + this.latestVersionFileName = ""; + this.latestVersionSize = undefined; + return; + } + this.latestVersionCreationTime = this.formatCreationTime(latest); + this.modelService + .retrieveModelVersionFileTree(this.mid, latest.mvid, this.isLogin) + .pipe(untilDestroyed(this)) + .subscribe({ + next: data => this.applyLatestVersionFacts(data), + error: (err: unknown) => this.notificationService.error(extractErrorMessage(err)), + }); + } + onVersionFileTreeNodeSelected(node: DatasetFileNode): void { this.loadFileContent(node); } @@ -286,6 +355,20 @@ export class ModelDetailComponent implements OnInit { loadFileContent(node: DatasetFileNode): void { this.currentDisplayedFileName = getFullPathFromDatasetFileNode(node); this.currentFileSize = node.size; + this.openFileRelativePath = getRelativePathFromDatasetFileNode(node); + } + + private findFileByRelativePath(nodes: DatasetFileNode[], relativePath: string): DatasetFileNode | undefined { + for (const node of nodes) { + if (node.type === "file" && getRelativePathFromDatasetFileNode(node) === relativePath) { + return node; + } + const inChildren = node.children && this.findFileByRelativePath(node.children, relativePath); + if (inChildren) { + return inChildren; + } + } + return undefined; } // Walk from the first node into directories until reaching a file. @@ -350,4 +433,135 @@ export class ModelDetailComponent implements OnInit { } return this.modelIsDownloadable && (this.modelIsPublic || this.userModelAccessLevel !== "NONE"); } + + userHasWriteAccess(): boolean { + return this.userModelAccessLevel === "WRITE"; + } + + onPreviouslyUploadedFileDeleted(node: DatasetFileNode): void { + if (!this.mid) { + return; + } + const relativePath = getRelativePathFromDatasetFileNode(node); + this.stagedFileService + .deleteFile(this.modelEndpoint, this.mid, relativePath) + .pipe(untilDestroyed(this)) + .subscribe({ + next: () => { + this.notificationService.success( + `File ${node.name} is successfully deleted. You may finalize it or revert it at the "Create Version" panel` + ); + // Undefined only when the panel is not rendered, which is the same write-access + // condition that gates the tree's delete control. + this.versionUploader?.notePathStaged(relativePath); + }, + error: () => this.notificationService.error("Failed to delete the file"), + }); + } + + /** Commits the staged files; the panel owns the rest of the version flow. */ + createModelVersion = (versionName: string): Observable => + this.modelService.createModelVersion(this.mid!, versionName); + + onVersionCreated(): void { + this.retrieveModelVersionList(); + } + + // =========================================================================== + // Settings + // =========================================================================== + + onSaveModelName(): void { + if (!this.mid) { + return; + } + if (this.uploadsInFlight) { + this.notificationService.error("Finish or cancel the upload in progress before renaming this model"); + return; + } + const name = this.editedModelName; + const nameError = validateModelName(name); + if (nameError) { + this.notificationService.error(nameError); + return; + } + + this.modelService + .updateModelName(this.mid, name) + .pipe(untilDestroyed(this)) + .subscribe({ + next: () => { + this.modelName = name; + this.editedModelName = name; + // Every file path embeds the model name, and preview and single-file download resolve + // a model by (owner, name) — a stale tree 404s until reload. + // Reopen whatever was on screen: only the paths changed, not the files. + this.onVersionSelected(this.selectedVersion, this.openFileRelativePath); + // That call covers the card only when the newest version is the one on screen. + if (this.selectedVersion !== this.versions[0]) { + this.retrieveLatestVersionFacts(); + } + this.notificationService.success(`Model name updated to '${name}'`); + }, + error: (err: unknown) => this.notificationService.error(extractErrorMessage(err)), + }); + } + + onModelDescriptionChange(description: string): void { + const updatedDescription = description ?? ""; + const previousDescription = this.modelDescription; + + if (!this.mid || previousDescription === updatedDescription) { + return; + } + this.modelDescription = updatedDescription; + + this.modelService + .updateModelDescription(this.mid, updatedDescription) + .pipe(untilDestroyed(this)) + .subscribe({ + error: () => { + this.modelDescription = previousDescription; + this.notificationService.error("Failed to update model description"); + }, + }); + } + + onFrameworkChange(framework: string): void { + const previous = this.modelFramework; + if (!this.mid || previous === framework) { + return; + } + this.modelFramework = framework; + + this.modelService + .updateModelFramework(this.mid, framework) + .pipe(untilDestroyed(this)) + .subscribe({ + next: () => this.notificationService.success(`Framework set to '${framework}'`), + error: (err: unknown) => { + this.modelFramework = previous; + this.notificationService.error(extractErrorMessage(err)); + }, + }); + } + + onFormatChange(modelFormat: string): void { + const previous = this.modelFormat; + if (!this.mid || previous === modelFormat) { + return; + } + this.modelFormat = modelFormat; + + this.modelService + .updateModelFormat(this.mid, modelFormat) + .pipe(untilDestroyed(this)) + .subscribe({ + next: () => this.notificationService.success(`Format set to '${modelFormat}'`), + error: (err: unknown) => { + this.modelFormat = previous; + this.notificationService.error(extractErrorMessage(err)); + }, + }); + } } diff --git a/frontend/src/app/dashboard/component/user/version-uploader/version-uploader.component.html b/frontend/src/app/dashboard/component/user/version-uploader/version-uploader.component.html new file mode 100644 index 00000000000..384d706f600 --- /dev/null +++ b/frontend/src/app/dashboard/component/user/version-uploader/version-uploader.component.html @@ -0,0 +1,176 @@ + + + + + + + + + + + +
+ {{ fileName }} + +
+
+
+ + + + +
+
+
+ {{ task.status }}: {{ task.filePath }} + +
+ +
+ + + {{ formatSpeed(task.uploadSpeed) }} - + {{ formatTime(task.totalTime ?? 0) }} elapsed, + {{ formatTime(task.estimatedTimeRemaining ?? 0) }} left + + + + Upload time: {{ formatTime(task.totalTime ?? 0) }} + +
+
+
+
+ + + + + + + +
+ + + + +
+
+ + +
+
+ +
+
+
+
diff --git a/frontend/src/app/dashboard/component/user/version-uploader/version-uploader.component.scss b/frontend/src/app/dashboard/component/user/version-uploader/version-uploader.component.scss new file mode 100644 index 00000000000..788b226a408 --- /dev/null +++ b/frontend/src/app/dashboard/component/user/version-uploader/version-uploader.component.scss @@ -0,0 +1,107 @@ +/** + * 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. + */ + +.upload-progress-wrapper { + max-height: 25vh; + overflow-y: auto; + padding-right: 4px; +} + +// Rows must stay exactly PENDING_ROW_HEIGHT_PX tall for the fixed-size scroll strategy. The gutter +// lives on the rows: padding on the viewport never reaches its absolutely positioned content wrapper. +.upload-progress-wrapper-pending { + .pending-file-row { + height: 32px; + display: flex; + align-items: center; + justify-content: space-between; + padding-right: 4px; + + .pending-file-name { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + } +} + +.upload-stats { + font-size: 13px; + margin-bottom: 20px; + + nz-progress { + width: 97%; + display: inline-block; + } +} + +:host ::ng-deep .upload-stats .ant-tag { + border: none; +} + +.fixed-width-speed { + display: inline-block; + min-width: 5ch; + text-align: right; +} + +.fixed-width-time { + display: inline-block; + min-width: 2ch; + text-align: right; +} + +.section-divider { + margin: 8px 0; +} + +.version-creator { + margin-top: 20px; + padding: 20px; +} + +.version-input-container { + display: flex; + align-items: center; + gap: 10px; + + label { + font-size: 15px; + } +} + +.version-input { + padding: 6px; +} + +.create-version-button { + display: flex; /* Use flexbox for centering */ + align-items: center; /* Center vertically */ + justify-content: center; /* Center horizontally */ + color: white; + border: none; + padding: 12px 40px; /* Increase padding for a wider button */ + border-radius: 25px; + cursor: pointer; + transition: background-color 0.3s; + margin: 18px auto 0 auto; /* Auto margins for horizontal centering */ + width: 200px; /* Adjust width as needed */ + font-size: 18px; /* Make text slightly bigger */ + font-weight: bold; /* Optional: Make text bold */ +} diff --git a/frontend/src/app/dashboard/component/user/version-uploader/version-uploader.component.spec.ts b/frontend/src/app/dashboard/component/user/version-uploader/version-uploader.component.spec.ts new file mode 100644 index 00000000000..754bf14eebd --- /dev/null +++ b/frontend/src/app/dashboard/component/user/version-uploader/version-uploader.component.spec.ts @@ -0,0 +1,1205 @@ +/** + * 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 { HttpErrorResponse, HttpStatusCode } from "@angular/common/http"; +import { By } from "@angular/platform-browser"; +import { NzModalService } from "ng-zorro-antd/modal"; +import { NzTooltipDirective } from "ng-zorro-antd/tooltip"; +import { concat, Observable, of, Subject, throwError } from "rxjs"; +import { + ABORT_RETRY_BACKOFF_BASE_MS, + ABORT_RETRY_MAX_ATTEMPTS, + VersionUploaderComponent, +} from "./version-uploader.component"; +import { + MultipartUploadProgress, + MultipartUploadService, +} from "../../../service/user/file-resource/multipart-upload.service"; +import { StagedFileService } from "../../../service/user/file-resource/staged-file.service"; +import { + DATASET_FILE_RESOURCE_ENDPOINT, + MODEL_FILE_RESOURCE_ENDPOINT, +} from "../../../service/user/file-resource/file-resource-endpoint"; +import { AdminSettingsService } from "../../../service/admin/settings/admin-settings.service"; +import { NotificationService } from "../../../../common/service/notification/notification.service"; +import { DatasetStagedObject } from "../../../../common/type/dataset-staged-object"; +import { FileUploadItem } from "../../../type/dashboard-file.interface"; +import { commonTestImports, commonTestProviders } from "../../../../common/testing/test-utils"; + +describe("VersionUploaderComponent", () => { + let fixture: ComponentFixture; + let component: VersionUploaderComponent; + let uploadSubjects: Subject[]; + let uploadedPaths: string[]; + let multipartUploadSpy: ReturnType; + let createVersionSpy: ReturnType; + const asCreateVersion = (spy: ReturnType) => spy as unknown as (name: string) => Observable; + + const makeFileItem = (name: string): FileUploadItem => ({ + file: new File(["x"], name), + name, + description: "", + uploadProgress: 0, + isUploadingFlag: false, + restart: false, + }); + + const dropFiles = (...names: string[]) => component.onNewUploadFilesChanged(names.map(makeFileItem)); + + const finishUpload = (index: number, filePath: string, totalTime = 1) => + uploadSubjects[index].next({ filePath, percentage: 100, status: "finished", totalTime }); + + beforeEach(() => { + uploadSubjects = []; + uploadedPaths = []; + multipartUploadSpy = vi.fn((_endpoint: unknown, _ownerEmail: string, _resourceName: string, filePath: string) => { + const progress = new Subject(); + uploadSubjects.push(progress); + uploadedPaths.push(filePath); + return progress.asObservable(); + }); + createVersionSpy = vi.fn(() => of({})); + + TestBed.configureTestingModule({ + imports: [VersionUploaderComponent, ...commonTestImports], + providers: [ + { provide: NzModalService, useValue: {} }, + { + provide: MultipartUploadService, + useValue: { + multipartUpload: multipartUploadSpy, + finalizeMultipartUpload: vi.fn(() => of({})), + listMultipartUploads: vi.fn(() => of([])), + findExistingUploadFiles: vi.fn(() => of([])), + }, + }, + { provide: StagedFileService, useValue: { getDiff: vi.fn(() => of([])), resetFileDiff: vi.fn(() => of({})) } }, + { provide: NotificationService, useValue: { success: vi.fn(), error: vi.fn(), info: vi.fn() } }, + // maxConcurrentFiles becomes 3, the cap the queue tests below rely on. + { provide: AdminSettingsService, useValue: { getPublicSetting: vi.fn(() => of("3")) } }, + ...commonTestProviders, + ], + }); + + fixture = TestBed.createComponent(VersionUploaderComponent); + component = fixture.componentInstance; + component.resourceId = 1; + component.ownerEmail = "owner@texera.com"; + component.resourceName = "test-dataset"; + component.endpoint = DATASET_FILE_RESOURCE_ENDPOINT; + component.createVersion = asCreateVersion(createVersionSpy); + fixture.detectChanges(); + }); + + describe("a failed upload", () => { + const notification = () => TestBed.inject(NotificationService) as unknown as { error: ReturnType }; + + /** Fails the in-flight upload of `name` with the given HTTP status. */ + const failUpload = (index: number, status: number) => + uploadSubjects[index].error(new HttpErrorResponse({ status })); + + it("names the 409 conflict so the user knows to retry", () => { + dropFiles("a.csv"); + + failUpload(0, HttpStatusCode.Conflict); + + expect(notification().error).toHaveBeenCalledWith(expect.stringContaining("Upload blocked (409)")); + }); + + it("falls back to a generic message for any other failure", () => { + dropFiles("a.csv"); + + failUpload(0, HttpStatusCode.InternalServerError); + + expect(notification().error).toHaveBeenCalledWith("Upload failed. Please retry."); + }); + + it("marks the task failed and keeps its progress rather than showing it complete", () => { + dropFiles("a.csv"); + // a partially-uploaded file: the bar must not jump to 100 when it fails + uploadSubjects[0].next({ filePath: "a.csv", percentage: 42, status: "uploading" }); + + failUpload(0, HttpStatusCode.InternalServerError); + + const task = component.uploadTasks.find(t => t.filePath === "a.csv"); + expect(task?.status).toBe("failed"); + expect(task?.percentage).toBe(42); + }); + + it("frees the concurrency slot so a queued upload can start", () => { + // maxConcurrentFiles is 3, so a fourth file waits for a slot + dropFiles("a.csv", "b.csv", "c.csv", "d.csv"); + expect(uploadedPaths).toEqual(["a.csv", "b.csv", "c.csv"]); + + failUpload(0, HttpStatusCode.InternalServerError); + + expect(uploadedPaths).toContain("d.csv"); + }); + + it("still reports the failure when the task is no longer in the list", () => { + dropFiles("a.csv"); + component.uploadTasks = []; // the taskIndex === -1 arm + + expect(() => failUpload(0, HttpStatusCode.InternalServerError)).not.toThrow(); + expect(notification().error).toHaveBeenCalled(); + }); + }); + + /** + * A progress event and the five-second hide timer both address a row by its index in + * `uploadTasks`, and that row can already be gone — dismissed by the user — by the time + * either arrives, so both lookups have to survive the miss. The completion path also has + * to pick a key for `uploadTimeMap` out of a name that may carry directories. + */ + describe("progress bookkeeping", () => { + beforeEach(() => { + // The completion path arms a 5s row-hide timer; keep it off the real clock. + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("ignores progress for a row that is no longer listed", () => { + dropFiles("a.csv"); + component.uploadTasks = []; // dismissed while a chunk was still in flight + + expect(() => uploadSubjects[0].next({ filePath: "a.csv", percentage: 50, status: "uploading" })).not.toThrow(); + // The late event must not resurrect the row or write a phantom index into the list. + expect(component.uploadTasks).toEqual([]); + expect(Object.keys(component.uploadTasks)).toHaveLength(0); + }); + + it("keys the upload time by the last path segment, falling back to the whole name", () => { + // Taking the segment after the last "/" yields "" for a name that ends in one, and + // keying the map under "" would collide every such upload onto one entry; the + // fallback keeps the name the caller gave instead. + dropFiles("nested/dir/"); + + finishUpload(0, "nested/dir/", 7); + + expect(component.uploadTimeMap.get("nested/dir/")).toBe(7); + expect(component.uploadTimeMap.has("")).toBe(false); + + // The reader of this map (staged-objects-list) looks a row up by + // `filePath.split("/").pop() || filePath`, so an ordinary nested name has to be + // keyed by its last segment here or the per-file time silently stops rendering. + dropFiles("dir/sub/a.csv"); + + finishUpload(1, "dir/sub/a.csv", 9); + + expect(component.uploadTimeMap.get("a.csv")).toBe(9); + expect(component.uploadTimeMap.has("dir/sub/a.csv")).toBe(false); + }); + + it("ignores a hide request for a row that is gone", () => { + // Every one of scheduleHide's call sites already checks the index, so the -1 arm + // pins a defensive no-op rather than a reachable scenario: without the guard the + // lookup would read `filePath` off undefined and throw. The valid-index call that + // follows keeps a scheduleHide which does nothing at all from passing this test. + dropFiles("a.csv"); + const before = [...component.uploadTasks]; + + expect(() => (component as any).scheduleHide(-1)).not.toThrow(); + expect(component.uploadTasks).toEqual(before); + + (component as any).scheduleHide(0); + vi.advanceTimersByTime(5000); + + expect(component.uploadTasks).toEqual([]); + }); + }); + + /** + * Aborting an in-flight upload has to survive the backend still finalizing the previous attempt: + * the abort call is retried on 409 up to ABORT_RETRY_MAX_ATTEMPTS, a 404 means it is already gone, + * and the caller's callback must fire exactly once down every one of those paths. + */ + describe("aborting an upload", () => { + let finalize: ReturnType; + + beforeEach(() => { + vi.useFakeTimers(); + finalize = TestBed.inject(MultipartUploadService).finalizeMultipartUpload as unknown as ReturnType; + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + /** Starts an upload and reports progress, leaving one task in flight. */ + function inFlight(name = "a.txt") { + dropFiles(name); + uploadSubjects[0].next({ filePath: name, percentage: 10, status: "uploading", totalTime: 0 }); + return component.uploadTasks.find(t => t.filePath === name)!; + } + + const conflict = () => throwError(() => ({ status: 409 }) as any); + const gone = () => throwError(() => ({ status: 404 }) as any); + + it("marks the task aborted and tells the caller once", () => { + const task = inFlight(); + const onAborted = vi.fn(); + + component.onClickAbortUploadProgress(task as any, onAborted); + + expect(finalize).toHaveBeenCalledWith( + DATASET_FILE_RESOURCE_ENDPOINT, + "owner@texera.com", + "test-dataset", + "a.txt", + true + ); + expect(component.uploadTasks.find(t => t.filePath === "a.txt")!.status).toBe("aborted"); + expect(onAborted).toHaveBeenCalledTimes(1); + + // The aborted row goes on the same five-second hide timer a finished one does, so + // it clears itself out of the list instead of sitting there for the rest of the session. + vi.advanceTimersByTime(5000); + + expect(component.uploadTasks.find(t => t.filePath === "a.txt")).toBeUndefined(); + }); + + it("stops listening to the upload it aborted", () => { + const task = inFlight(); + + component.onClickAbortUploadProgress(task as any); + + // The progress stream is unsubscribed, so a late event cannot resurrect the task. + uploadSubjects[0].next({ filePath: "a.txt", percentage: 100, status: "finished", totalTime: 1 }); + expect(component.uploadTasks.find(t => t.filePath === "a.txt")!.status).toBe("aborted"); + }); + + it("treats a 404 as already aborted rather than an error", () => { + finalize.mockReturnValueOnce(gone()); + const task = inFlight(); + const onAborted = vi.fn(); + + component.onClickAbortUploadProgress(task as any, onAborted); + + expect(onAborted).toHaveBeenCalledTimes(1); + expect(finalize).toHaveBeenCalledTimes(1); + }); + + it("retries a 409 after a backoff and finishes once the server catches up", () => { + // The server is still finalizing the previous attempt; the abort has to wait it out. + finalize.mockReturnValueOnce(conflict()); + const task = inFlight(); + const onAborted = vi.fn(); + + component.onClickAbortUploadProgress(task as any, onAborted); + expect(onAborted).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(ABORT_RETRY_BACKOFF_BASE_MS); + + expect(finalize).toHaveBeenCalledTimes(2); + expect(onAborted).toHaveBeenCalledTimes(1); + }); + + it("backs off further on each successive conflict", () => { + finalize.mockReturnValue(conflict()); + const task = inFlight(); + + component.onClickAbortUploadProgress(task as any); + expect(finalize).toHaveBeenCalledTimes(1); + + // First wait is BASE * 1, the second BASE * 2, so BASE alone is not enough for the third call. + vi.advanceTimersByTime(ABORT_RETRY_BACKOFF_BASE_MS); + expect(finalize).toHaveBeenCalledTimes(2); + + vi.advanceTimersByTime(ABORT_RETRY_BACKOFF_BASE_MS); + expect(finalize).toHaveBeenCalledTimes(2); + + vi.advanceTimersByTime(ABORT_RETRY_BACKOFF_BASE_MS); + expect(finalize).toHaveBeenCalledTimes(3); + }); + + it("gives up after the attempt limit but still reports the abort", () => { + // Without the bound this would retry forever against a permanently conflicted server. + finalize.mockReturnValue(conflict()); + const task = inFlight(); + const onAborted = vi.fn(); + + component.onClickAbortUploadProgress(task as any, onAborted); + vi.advanceTimersByTime(ABORT_RETRY_BACKOFF_BASE_MS * ABORT_RETRY_MAX_ATTEMPTS * (ABORT_RETRY_MAX_ATTEMPTS + 1)); + + expect(finalize).toHaveBeenCalledTimes(ABORT_RETRY_MAX_ATTEMPTS + 1); + expect(onAborted).toHaveBeenCalledTimes(1); + }); + + it("reports the abort once even on an error the retry does not cover", () => { + finalize.mockReturnValueOnce(throwError(() => ({ status: 500 }) as any)); + const task = inFlight(); + const onAborted = vi.fn(); + + component.onClickAbortUploadProgress(task as any, onAborted); + + expect(onAborted).toHaveBeenCalledTimes(1); + expect(finalize).toHaveBeenCalledTimes(1); + }); + + it("frees the concurrency slot so a queued upload can start", () => { + // Aborting has to release the slot as an ordinary completion would; otherwise the queue + // stalls behind an upload that is no longer running. + dropFiles("a.txt", "b.txt", "c.txt", "d.txt"); + expect(uploadedPaths).toEqual(["a.txt", "b.txt", "c.txt"]); + uploadSubjects[0].next({ filePath: "a.txt", percentage: 10, status: "uploading", totalTime: 0 }); + const task = component.uploadTasks.find(t => t.filePath === "a.txt")!; + + component.onClickAbortUploadProgress(task as any); + + expect(uploadedPaths).toContain("d.txt"); + }); + + it("cancelExistingUpload aborts an upload that is still running", () => { + inFlight("b.txt"); + const onCanceled = vi.fn(); + + component.cancelExistingUpload("b.txt", onCanceled); + + expect(finalize).toHaveBeenCalledWith( + DATASET_FILE_RESOURCE_ENDPOINT, + "owner@texera.com", + "test-dataset", + "b.txt", + true + ); + expect(onCanceled).toHaveBeenCalledTimes(1); + }); + + it("frees the slot of an upload aborted before its first part went out", () => { + // A task sits at "initializing" until the service reports its first progress. + // Cancelling in that window still has to hand the slot to whatever is queued + // behind it, or the queue stalls on an upload that never started. + dropFiles("a.txt", "b.txt", "c.txt", "d.txt"); + expect(uploadedPaths).toEqual(["a.txt", "b.txt", "c.txt"]); + const initializing = component.uploadTasks.find(t => t.filePath === "a.txt")!; + expect(initializing.status).toBe("initializing"); + + component.onClickAbortUploadProgress(initializing as any); + + expect(uploadedPaths).toContain("d.txt"); + expect(component.activeCount).toBe(3); + }); + + it("does not free a second slot when a finished upload's row is dismissed", () => { + // The row's button becomes "Close" once the upload is done, and the slot was + // already released by the completion; releasing it a second time would let a + // fourth upload run past the concurrency cap. + dropFiles("a.txt", "b.txt", "c.txt", "d.txt"); + finishUpload(0, "a.txt"); + expect(uploadedPaths).toContain("d.txt"); + const finished = component.uploadTasks.find(t => t.filePath === "a.txt")!; + expect(finished.status).toBe("finished"); + + component.onClickAbortUploadProgress(finished as any); + + expect(component.activeCount).toBe(3); + const dismissed = component.uploadTasks.find(t => t.filePath === "a.txt")!; + expect(dismissed.status).toBe("aborted"); + + // The row lingers for five seconds after being dismissed, so the same X is + // still there to be clicked again — and that click must not release either. + component.onClickAbortUploadProgress(dismissed as any); + + expect(component.activeCount).toBe(3); + }); + + it("does not free a second slot when a failed upload's row is dismissed", () => { + // The failure handler already released this upload's slot and let the queued + // fourth file start; dismissing the row it left behind must not release a + // second slot, or a fifth upload would run past the cap of three. + dropFiles("a.txt", "b.txt", "c.txt", "d.txt"); + uploadSubjects[0].error(new HttpErrorResponse({ status: 500 })); + expect(uploadedPaths).toContain("d.txt"); + const failed = component.uploadTasks.find(t => t.filePath === "a.txt")!; + expect(failed.status).toBe("failed"); + + component.onClickAbortUploadProgress(failed as any); + + expect(component.activeCount).toBe(3); + expect(multipartUploadSpy).toHaveBeenCalledTimes(4); + }); + + it("cancelExistingUpload aborts an upload whose first part has not gone out", () => { + // Until the service reports a first chunk the task sits at "initializing". + // A re-drop in that window has to abort that attempt rather than fall + // through and race a second multipart upload against it for the same path, + // which is exactly the 409 the upload error handler warns about. + dropFiles("b.txt"); + expect(component.uploadTasks.find(t => t.filePath === "b.txt")!.status).toBe("initializing"); + const onCanceled = vi.fn(); + + component.cancelExistingUpload("b.txt", onCanceled); + + expect(finalize).toHaveBeenCalledWith( + DATASET_FILE_RESOURCE_ENDPOINT, + "owner@texera.com", + "test-dataset", + "b.txt", + true + ); + expect(component.uploadTasks.find(t => t.filePath === "b.txt")!.status).toBe("aborted"); + // The slot goes back to the queue instead of being held by an attempt that + // is no longer running. + expect(component.activeCount).toBe(0); + expect(onCanceled).toHaveBeenCalledTimes(1); + }); + + it("tells the caller once even when the abort call reports more than once", () => { + // The callback is latched so that it fires exactly once no matter how many of the + // subscription's handlers reach it. HttpClient itself delivers a single response, + // so this drives the latch directly: a response followed by a stream failure runs + // the next handler and then the error handler, and both of them report done. + finalize.mockReturnValueOnce( + concat( + of({}), + throwError(() => ({ status: 500 }) as any) + ) + ); + const task = inFlight(); + const onAborted = vi.fn(); + + component.onClickAbortUploadProgress(task as any, onAborted); + + expect(onAborted).toHaveBeenCalledTimes(1); + }); + + it("aborts a task whose row was already dropped without resurrecting it", () => { + const task = inFlight(); + component.uploadTasks = []; // the row was dismissed before the abort was clicked + + component.onClickAbortUploadProgress(task as any); + + expect(finalize).toHaveBeenCalledWith( + DATASET_FILE_RESOURCE_ENDPOINT, + "owner@texera.com", + "test-dataset", + "a.txt", + true + ); + // Writing "aborted" back at a missing index would leave a phantom "-1" property on + // the array, which neither a throw nor `.length` would reveal. + expect(component.uploadTasks).toEqual([]); + expect(Object.keys(component.uploadTasks)).toHaveLength(0); + }); + }); + + const settingsStub = () => + TestBed.inject(AdminSettingsService) as unknown as { getPublicSetting: ReturnType }; + + /** Rebuilds the fixture so a per-test settings stub is in place before ngOnInit runs. */ + const rebuild = (seed?: (c: VersionUploaderComponent) => void): void => { + fixture.destroy(); + fixture = TestBed.createComponent(VersionUploaderComponent); + component = fixture.componentInstance; + component.resourceId = 1; + component.ownerEmail = "owner@texera.com"; + component.resourceName = "test-dataset"; + component.endpoint = DATASET_FILE_RESOURCE_ENDPOINT; + component.createVersion = asCreateVersion(createVersionSpy); + seed?.(component); + fixture.detectChanges(); + }; + + /** The panel is the only thing that knows which resource family it is addressing. */ + describe("resource addressing", () => { + it("passes its endpoint to the upload engine, whichever family it serves", () => { + component.endpoint = MODEL_FILE_RESOURCE_ENDPOINT; + component.resourceName = "resnet-50"; + + dropFiles("weights/model.pt"); + + expect(multipartUploadSpy.mock.calls[0][0]).toBe(MODEL_FILE_RESOURCE_ENDPOINT); + expect(multipartUploadSpy.mock.calls[0][2]).toBe("resnet-50"); + }); + + it("reads its upload tuning from its own family's settings keys", () => { + const settings = TestBed.inject(AdminSettingsService) as unknown as { + getPublicSetting: ReturnType; + }; + + // The last is the file picker's own per-file ceiling, which it reads off the same endpoint. + expect(settings.getPublicSetting.mock.calls.map(call => call[0])).toEqual([ + DATASET_FILE_RESOURCE_ENDPOINT.chunkSizeSettingKey, + DATASET_FILE_RESOURCE_ENDPOINT.maxConcurrentChunksSettingKey, + DATASET_FILE_RESOURCE_ENDPOINT.maxConcurrentFilesSettingKey, + DATASET_FILE_RESOURCE_ENDPOINT.maxFileSizeSettingKey, + ]); + expect(component.maxConcurrentFiles).toBe(3); + }); + + it("keeps the default upload settings when the public settings are missing", () => { + settingsStub().getPublicSetting.mockReturnValue(of(null)); + rebuild(); + + expect(component.chunkSizeMiB).toBe(50); + expect(component.maxConcurrentChunks).toBe(10); + expect(component.maxConcurrentFiles).toBe(3); + }); + + it("leaves the chunk size untouched when only that setting fails to load", () => { + // A distinct value per key, so a setting that lands in the wrong field is visible: + // 7 chunks and 2 files cannot stand in for one another. + settingsStub().getPublicSetting.mockImplementation((key: string) => + key === DATASET_FILE_RESOURCE_ENDPOINT.chunkSizeSettingKey + ? throwError(() => new Error("boom")) + : of(key === DATASET_FILE_RESOURCE_ENDPOINT.maxConcurrentChunksSettingKey ? "7" : "2") + ); + // A sentinel the class default cannot supply, so "the failed fetch wrote nothing" is + // distinguishable from "it wrote the default back". + rebuild(c => (c.chunkSizeMiB = 42)); + + expect(component.chunkSizeMiB).toBe(42); + expect(component.maxConcurrentChunks).toBe(7); + expect(component.maxConcurrentFiles).toBe(2); + }); + + it("leaves both concurrency limits untouched when their settings fail to load", () => { + settingsStub().getPublicSetting.mockImplementation((key: string) => + key === DATASET_FILE_RESOURCE_ENDPOINT.chunkSizeSettingKey ? of("128") : throwError(() => new Error("boom")) + ); + rebuild(c => { + c.maxConcurrentChunks = 41; + c.maxConcurrentFiles = 40; + }); + + // A failed fetch that wrote anything here — a reset, or a NaN — would stall the queue + // outright, since `activeUploads < NaN` is never true. + expect(component.chunkSizeMiB).toBe(128); + expect(component.maxConcurrentChunks).toBe(41); + expect(component.maxConcurrentFiles).toBe(40); + }); + }); + + describe("in-flight signalling", () => { + it("tells the host while an upload is running, so a rename cannot strand it", () => { + const inFlight: boolean[] = []; + component.uploadsInFlightChange.subscribe((v: boolean) => inFlight.push(v)); + + dropFiles("a.csv"); + expect(inFlight.at(-1)).toBe(true); + + component.onClickAbortUploadProgress(component.uploadTasks[0]); + expect(inFlight.at(-1)).toBe(false); + }); + + it("stays flagged until the last of several uploads finishes", () => { + const inFlight: boolean[] = []; + component.uploadsInFlightChange.subscribe((v: boolean) => inFlight.push(v)); + + dropFiles("a.csv", "b.csv"); + finishUpload(0, "a.csv"); + expect(inFlight.at(-1)).toBe(true); + + finishUpload(1, "b.csv"); + expect(inFlight.at(-1)).toBe(false); + }); + }); + + describe("staged changes", () => { + it("tracks the pending-change count from the diff response", () => { + const staged: DatasetStagedObject[] = [ + { path: "a", pathType: "file", diffType: "added", sizeBytes: 1 }, + { path: "b", pathType: "file", diffType: "added", sizeBytes: 1 }, + ]; + + component.onStagedObjectsUpdated(staged); + expect(component.pendingChangesCount).toBe(2); + expect(component.userHasPendingChanges).toBe(true); + + component.onStagedObjectsUpdated([]); + expect(component.pendingChangesCount).toBe(0); + expect(component.userHasPendingChanges).toBe(false); + }); + + it("counts a change the host staged elsewhere, such as a file-tree deletion", () => { + component.notePathStaged("nested/a.txt"); + + expect(component.pendingChangesCount).toBe(1); + expect(component.userHasPendingChanges).toBe(true); + }); + }); + + describe("creating a version", () => { + it("commits through the host's callback, trimming the name", () => { + component.versionName = " v2 "; + + component.onClickCreateVersion(); + + expect(createVersionSpy).toHaveBeenCalledWith("v2"); + }); + + it("lets the backend name the version when the box is empty", () => { + component.versionName = " "; + + component.onClickCreateVersion(); + + expect(createVersionSpy).toHaveBeenCalledWith(""); + }); + + it("clears the staged state, tells the host to reload, and re-enables the button", () => { + const versionCreated = vi.fn(); + component.versionCreated.subscribe(versionCreated); + dropFiles("a.csv"); + finishUpload(0, "a.csv"); + expect(component.pendingChangesCount).toBe(1); + + component.onClickCreateVersion(); + + expect(component.versionName).toBe(""); + expect(component.pendingChangesCount).toBe(0); + expect(component.isCreatingVersion).toBe(false); + expect(versionCreated).toHaveBeenCalledTimes(1); + }); + + it("keeps the staged changes and re-enables the button when creation is rejected", () => { + createVersionSpy.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 400 }))); + const versionCreated = vi.fn(); + component.versionCreated.subscribe(versionCreated); + dropFiles("a.csv"); + finishUpload(0, "a.csv"); + + component.onClickCreateVersion(); + + expect(component.isCreatingVersion).toBe(false); + // The user can retry, so what they staged must survive the failure. + expect(component.pendingChangesCount).toBe(1); + expect(versionCreated).not.toHaveBeenCalled(); + }); + + it("ignores a second submit while one is in flight", () => { + createVersionSpy.mockReturnValue(new Subject()); + + component.onClickCreateVersion(); + component.onClickCreateVersion(); + + expect(createVersionSpy).toHaveBeenCalledTimes(1); + }); + + it("commits nothing before the host supplies a resource id", () => { + component.resourceId = undefined; + + component.onClickCreateVersion(); + + expect(createVersionSpy).not.toHaveBeenCalled(); + }); + }); + + describe("progress state for the template", () => { + it("maps the upload status to a progress state", () => { + expect(component.getUploadStatus("uploading")).toBe("active"); + expect(component.getUploadStatus("initializing")).toBe("active"); + expect(component.getUploadStatus("aborted")).toBe("exception"); + expect(component.getUploadStatus("failed")).toBe("exception"); + expect(component.getUploadStatus("finished")).toBe("success"); + }); + + it("tracks a task by its file path", () => { + const task = { filePath: "owner/data/file.csv" } as unknown as Parameters[1]; + expect(component.trackByTask(0, task)).toBe("owner/data/file.csv"); + }); + }); + + it("starts at most maxConcurrentFiles uploads immediately and queues the rest", () => { + dropFiles("f1.txt", "f2.txt", "f3.txt", "f4.txt", "f5.txt"); + + expect(multipartUploadSpy).toHaveBeenCalledTimes(3); + expect(uploadedPaths).toEqual(["f1.txt", "f2.txt", "f3.txt"]); + expect(component.activeCount).toBe(3); + expect(component.queuedCount).toBe(2); + expect(component.queuedFileNames).toEqual(["f4.txt", "f5.txt"]); + }); + + it("does nothing when an empty file list is dropped", () => { + dropFiles(); + + expect(multipartUploadSpy).not.toHaveBeenCalled(); + expect(component.activeCount).toBe(0); + expect(component.queuedCount).toBe(0); + expect(component.queuedFileNames).toEqual([]); + }); + + it("starts no upload at all before the host supplies a resource id", () => { + // Every multipart call is addressed to one resource, so without an id there is + // nowhere to upload into: the drop is refused outright rather than leaving + // rows on the panel for uploads that were never started. + component.resourceId = undefined; + + dropFiles("f1.txt", "f2.txt"); + + expect(multipartUploadSpy).not.toHaveBeenCalled(); + expect(component.uploadTasks).toEqual([]); + expect(component.activeCount).toBe(0); + }); + + it("starts the next queued upload when an active upload finishes", () => { + dropFiles("f1.txt", "f2.txt", "f3.txt", "f4.txt", "f5.txt"); + + finishUpload(0, "f1.txt"); + + expect(multipartUploadSpy).toHaveBeenCalledTimes(4); + expect(uploadedPaths[3]).toBe("f4.txt"); + expect(component.activeCount).toBe(3); + expect(component.queuedCount).toBe(1); + expect(component.queuedFileNames).toEqual(["f5.txt"]); + }); + + it("removes a cancelled file from the pending queue without starting it", () => { + dropFiles("f1.txt", "f2.txt", "f3.txt", "f4.txt", "f5.txt"); + + component.cancelExistingUpload("f4.txt"); + + expect(multipartUploadSpy).toHaveBeenCalledTimes(3); + expect(component.queuedCount).toBe(1); + expect(component.queuedFileNames).toEqual(["f5.txt"]); + }); + + it("ignores cancellation of a file that is neither active nor queued", () => { + dropFiles("f1.txt", "f2.txt", "f3.txt", "f4.txt"); + + component.cancelExistingUpload("missing.txt"); + + expect(component.activeCount).toBe(3); + expect(component.queuedCount).toBe(1); + expect(component.queuedFileNames).toEqual(["f4.txt"]); + }); + + // #5586: the template reads queuedFileNames on every change-detection pass, + // so it must not allocate a new array unless the queue changed. + it("keeps the same queuedFileNames array reference while the queue is unchanged", () => { + dropFiles("f1.txt", "f2.txt", "f3.txt", "f4.txt", "f5.txt"); + + const firstRead = component.queuedFileNames; + + expect(component.queuedFileNames).toBe(firstRead); + }); + + it("exposes a new queuedFileNames array after the queue changes", () => { + dropFiles("f1.txt", "f2.txt", "f3.txt", "f4.txt", "f5.txt"); + const beforeCancel = component.queuedFileNames; + + component.cancelExistingUpload("f4.txt"); + + expect(component.queuedFileNames).not.toBe(beforeCancel); + expect(component.queuedFileNames).toEqual(["f5.txt"]); + }); + + it("identifies pending queue entries by file name in trackByPendingFile", () => { + expect(component.trackByPendingFile(0, "dir/a.txt")).toBe("dir/a.txt"); + }); + + // A resumed upload with no missing parts finishes with totalTime exactly 0; + // the slot must still be released. + it("releases the concurrency slot when a finished upload reports totalTime 0", () => { + dropFiles("f1.txt", "f2.txt", "f3.txt", "f4.txt"); + + finishUpload(0, "f1.txt", 0); + + expect(multipartUploadSpy).toHaveBeenCalledTimes(4); + expect(uploadedPaths[3]).toBe("f4.txt"); + expect(component.activeCount).toBe(3); + expect(component.queuedCount).toBe(0); + }); + + // The Pending header updates per file, so the Finished header must too — it + // cannot wait for the throttled staged-objects refetch. + it("updates the Finished count immediately when uploads finish", () => { + dropFiles("f1.txt", "f2.txt", "f3.txt", "f4.txt"); + expect(component.pendingChangesCount).toBe(0); + + finishUpload(0, "f1.txt"); + expect(component.pendingChangesCount).toBe(1); + + finishUpload(1, "f2.txt"); + expect(component.pendingChangesCount).toBe(2); + }); + + it("reconciles the optimistic Finished count with a diff response", () => { + dropFiles("f1.txt", "f2.txt", "f3.txt"); + finishUpload(0, "f1.txt"); + finishUpload(1, "f2.txt"); + + const diff: DatasetStagedObject[] = [{ path: "f1.txt", pathType: "file", diffType: "added", sizeBytes: 1 }]; + component.onStagedObjectsUpdated(diff); + + // f1 is confirmed by the response; f2 stays counted until a response includes it. + expect(component.pendingChangesCount).toBe(2); + + component.onStagedObjectsUpdated([...diff, { path: "f2.txt", pathType: "file", diffType: "added", sizeBytes: 1 }]); + expect(component.pendingChangesCount).toBe(2); + }); + + it("keeps an in-progress upload's slot while progress events stream in", () => { + dropFiles("f1.txt", "f2.txt", "f3.txt", "f4.txt"); + + uploadSubjects[0].next({ filePath: "f1.txt", percentage: 50, status: "uploading" }); + + expect(component.uploadTasks.find(t => t.filePath === "f1.txt")?.percentage).toBe(50); + expect(component.activeCount).toBe(3); + expect(component.queuedCount).toBe(1); + }); + + it("does not double-count a finished upload already confirmed by a diff response", () => { + dropFiles("f1.txt"); + finishUpload(0, "f1.txt"); + component.onStagedObjectsUpdated([{ path: "f1.txt", pathType: "file", diffType: "added", sizeBytes: 1 }]); + expect(component.pendingChangesCount).toBe(1); + + dropFiles("f1.txt"); // re-upload the already-staged file + finishUpload(1, "f1.txt"); + + expect(component.pendingChangesCount).toBe(1); + }); + + it("does not start queued uploads beyond a lowered concurrency limit", () => { + dropFiles("f1.txt", "f2.txt", "f3.txt", "f4.txt"); + component.maxConcurrentFiles = 1; + + finishUpload(0, "f1.txt"); + + expect(component.activeCount).toBe(2); + expect(component.queuedCount).toBe(1); + expect(multipartUploadSpy).toHaveBeenCalledTimes(3); + }); + + it("clears the Finished count when a version is created", () => { + dropFiles("f1.txt"); + finishUpload(0, "f1.txt"); + expect(component.pendingChangesCount).toBe(1); + + component.versionName = "v1"; + component.onClickCreateVersion(); + + expect(component.pendingChangesCount).toBe(0); + }); + + it("does not remove a re-uploaded file's active task when hiding its finished predecessor", () => { + vi.useFakeTimers(); + try { + dropFiles("a.txt"); + finishUpload(0, "a.txt"); // schedules the finished row to hide in 5s + + dropFiles("a.txt"); // re-upload the same name within the 5s window + vi.advanceTimersByTime(5000); + + expect(component.uploadTasks).toHaveLength(1); + expect(component.uploadTasks[0].status).not.toBe("finished"); + expect(component.activeCount).toBe(1); + + finishUpload(1, "a.txt"); + expect(component.activeCount).toBe(0); + } finally { + vi.useRealTimers(); + } + }); + + it("renders the virtualized pending list and re-measures viewports on panel expand", async () => { + dropFiles("f1.txt", "f2.txt", "f3.txt", "f4.txt", "f5.txt"); + + // Flush the viewport's init microtask, then render the rows. + fixture.detectChanges(); + await Promise.resolve(); + fixture.detectChanges(); + + expect(component.pendingListHeightPx).toBe(2 * component.PENDING_ROW_HEIGHT_PX); + const rows = fixture.nativeElement.querySelectorAll(".pending-file-row"); + expect(rows.length).toBe(2); + + // Expand the Pending / Uploading / Finished panels. + const headers: NodeListOf = fixture.nativeElement.querySelectorAll( + ".upload-status-panels .ant-collapse-header" + ); + expect(headers.length).toBe(3); + headers.forEach(header => header.click()); + fixture.detectChanges(); + // Flush the checkViewportSize timers. + await new Promise(resolve => setTimeout(resolve)); + + // Collapsing again must be a no-op for the re-measure handler. + headers.forEach(header => header.click()); + fixture.detectChanges(); + + // Cancel a queued file from its row. + const cancelButton = fixture.nativeElement.querySelector(".pending-file-row button") as HTMLButtonElement; + cancelButton.click(); + expect(component.queuedCount).toBe(1); + expect(component.queuedFileNames).toEqual(["f5.txt"]); + }); + + describe("rendered progress", () => { + /** + * Puts one task on the panel in the given state and opens it. The panel is gated on the + * separate activeUploads counter rather than on uploadTasks, and ng-zorro collapses it by + * default, so both have to be arranged before its body exists. + */ + function withTask(over: Record): HTMLElement { + (component as any).activeUploads = 1; + component.uploadTasks = [ + { + filePath: "big.csv", + percentage: 40, + status: "uploading", + uploadSpeed: 1024, + totalTime: 12, + estimatedTimeRemaining: 30, + ...over, + } as any, + ]; + fixture.detectChanges(); + const el = fixture.nativeElement as HTMLElement; + const header = Array.from(el.querySelectorAll(".ant-collapse-header")).find(h => + (h.textContent || "").includes("Uploading:") + ); + header!.click(); + fixture.detectChanges(); + return el; + } + + it("shows no statistics while an upload is still initializing", () => { + // There is nothing to report yet; showing a 0 B/s row reads as a stalled upload. + const el = withTask({ status: "initializing" }); + + expect(el.querySelector(".upload-stats")).toBeNull(); + }); + + it("reports speed and both timings while an upload runs", () => { + const el = withTask({ status: "uploading" }); + + const stats = el.querySelector(".upload-stats")!; + expect(stats.textContent).toContain("elapsed"); + expect(stats.textContent).toContain("left"); + expect(stats.querySelector(".fixed-width-speed")).not.toBeNull(); + }); + + it("replaces the live figures with a total once the upload finishes", () => { + const el = withTask({ status: "finished" }); + + const stats = el.querySelector(".upload-stats")!; + expect(stats.textContent).toContain("Upload time:"); + expect(stats.textContent).not.toContain("left"); + }); + + it("reports a total for an aborted upload too", () => { + const el = withTask({ status: "aborted" }); + + expect(el.querySelector(".upload-stats")!.textContent).toContain("Upload time:"); + }); + }); + + describe("rendered panel", () => { + const host = (): HTMLElement => 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; + }; + + const text = (el: Element | null | undefined): string => (el?.textContent ?? "").replace(/\s+/g, " ").trim(); + + /** ng-zorro collapses every status panel by default, so its body has to be opened first. */ + const openPanel = (header: string): void => { + const found = Array.from(host().querySelectorAll(".ant-collapse-header")).find(h => + (h.textContent || "").includes(header) + ); + expect(found, `expected a collapse panel headed "${header}"`).toBeDefined(); + found!.click(); + fixture.detectChanges(); + }; + + describe("upload panel", () => { + it("starts an upload for a file the uploader hands over", () => { + const el = host(); + const uploader = fixture.debugElement.query(By.css("texera-user-files-uploader")); + // Distinct tuning values, so the two cannot stand in for each other below. + component.chunkSizeMiB = 50; + component.maxConcurrentChunks = 10; + + uploader.triggerEventHandler("uploadedFiles", [makeFileItem("new.csv")]); + fixture.detectChanges(); + + // Both are plain numbers, so asserting their exact values is the only way to notice + // them exchanged: 10-byte chunks, or 52 million parallel requests, would look + // identical to expect.any(Number). + expect(multipartUploadSpy).toHaveBeenCalledWith( + DATASET_FILE_RESOURCE_ENDPOINT, + "owner@texera.com", + "test-dataset", + "new.csv", + expect.anything(), + 50 * 1024 * 1024, + 10, + false + ); + expect(text(el)).toContain("Uploading: 1 file(s)"); + }); + + /** Renders the given in-flight tasks and expands the "Uploading" panel. */ + const withTasks = (...tasks: Array>): HTMLElement => { + component.uploadTasks = tasks.map(t => ({ + percentage: 40, + status: "uploading", + uploadSpeed: 1024, + totalTime: 12, + estimatedTimeRemaining: 30, + ...t, + })) as never; + (component as unknown as { activeUploads: number }).activeUploads = tasks.length; + fixture.detectChanges(); + openPanel("Uploading:"); + return host(); + }; + + it("aborts the upload whose own row button was clicked", () => { + withTasks({ filePath: "first.csv" }, { filePath: "second.csv" }); + + const rows = fixture.debugElement.queryAll(By.css(".upload-progress-wrapper > div")); + expect(rows.length).toBe(2); + // Each row has to name its own task: identifying the row by position alone + // would not notice every row rendering the first task's name and status. + expect(rows.map(row => text(row.query(By.css(".progress-header")).nativeElement))).toEqual([ + "uploading: first.csv", + "uploading: second.csv", + ]); + + const abort = rows[1].query(By.css(".progress-header button")); + // A live upload is cancelled, not dismissed; the finished row below says "Close". + expect(abort.injector.get(NzTooltipDirective).directiveTitle).toBe("Cancel the upload"); + + abort.nativeElement.click(); + fixture.detectChanges(); + + const finalize = TestBed.inject(MultipartUploadService).finalizeMultipartUpload as unknown as ReturnType< + typeof vi.fn + >; + expect(finalize).toHaveBeenCalledTimes(1); + expect(finalize).toHaveBeenCalledWith( + DATASET_FILE_RESOURCE_ENDPOINT, + "owner@texera.com", + "test-dataset", + "second.csv", + true + ); + }); + + it("reports the elapsed time, the time remaining and the speed in their own slots", () => { + // Distinguishable timings, so the two spans cannot stand in for each other: + // showing 90s elapsed on a 12s-old upload is the defect this guards. + const el = withTasks({ + filePath: "big.csv", + totalTime: 12, + estimatedTimeRemaining: 90, + uploadSpeed: 5 * 1024 * 1024, + }); + const stats = q(el, ".upload-stats"); + + expect(Array.from(stats.querySelectorAll(".fixed-width-time")).map(text)).toEqual(["12s", "1m30s left"]); + expect(text(q(stats, ".fixed-width-speed"))).toBe("5.0 MB/s"); + }); + + it("floors both live timings at one second while an upload reports none", () => { + const el = withTasks({ filePath: "big.csv", totalTime: undefined, estimatedTimeRemaining: undefined }); + + const times = Array.from(q(el, ".upload-stats").querySelectorAll(".fixed-width-time")).map(text); + expect(times).toEqual(["1s", "1s left"]); + }); + + it("reports the total time of a finished upload", () => { + const el = withTasks({ filePath: "big.csv", status: "finished", totalTime: 75 }); + + expect(text(q(el, ".upload-stats"))).toContain("Upload time: 1m15s"); + // A finished row is dismissed rather than cancelled. + const button = fixture.debugElement.query(By.css(".upload-progress-wrapper > div .progress-header button")); + expect(button.injector.get(NzTooltipDirective).directiveTitle).toBe("Close"); + }); + + it("floors the total of a finished upload that timed nothing", () => { + const el = withTasks({ filePath: "big.csv", status: "finished", totalTime: undefined }); + + expect(text(q(el, ".upload-stats"))).toContain("Upload time: 1s"); + }); + }); + + describe("version creator", () => { + /** Renders the creator, which only appears with staged changes to commit. */ + const withPendingChanges = (): HTMLElement => { + component.userHasPendingChanges = true; + fixture.detectChanges(); + return host(); + }; + + const typeName = (el: HTMLElement, value: string): HTMLInputElement => { + const input = q(el, ".version-input"); + input.value = value; + input.dispatchEvent(new Event("input")); + fixture.detectChanges(); + return input; + }; + + it("offers the creator only once there is something to commit", () => { + const el = host(); + expect(el.querySelector(".version-creator")).toBeNull(); + + component.userHasPendingChanges = true; + fixture.detectChanges(); + + expect(el.querySelector(".version-creator")).not.toBeNull(); + expect(text(q(el, ".create-version-button"))).toBe("Submit"); + }); + + it("creates a version named by the creator's own input", () => { + const el = withPendingChanges(); + + typeName(el, "second cut"); + q(el, ".create-version-button").click(); + + expect(createVersionSpy).toHaveBeenCalledWith("second cut"); + }); + + it("submits the version straight from the name field with Enter", () => { + const el = withPendingChanges(); + + typeName(el, "from the keyboard").dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + + expect(createVersionSpy).toHaveBeenCalledWith("from the keyboard"); + }); + + it("spins the submit button and locks the name field while a version is being created", async () => { + const el = withPendingChanges(); + expect(q(el, ".create-version-button").classList).not.toContain("ant-btn-loading"); + expect(q(el, ".version-input").disabled).toBe(false); + + component.isCreatingVersion = true; + fixture.detectChanges(); + // NgModel routes the input's `disabled` binding through control.disable(), + // which it defers to a microtask, so the DOM lags the render by one turn. + await Promise.resolve(); + fixture.detectChanges(); + + expect(q(el, ".create-version-button").classList).toContain("ant-btn-loading"); + // Renaming a version mid-creation would be applied to nothing, so the + // field is locked for as long as the request is in flight. + expect(q(el, ".version-input").disabled).toBe(true); + }); + }); + }); +}); diff --git a/frontend/src/app/dashboard/component/user/version-uploader/version-uploader.component.ts b/frontend/src/app/dashboard/component/user/version-uploader/version-uploader.component.ts new file mode 100644 index 00000000000..cfdaca02e6f --- /dev/null +++ b/frontend/src/app/dashboard/component/user/version-uploader/version-uploader.component.ts @@ -0,0 +1,480 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Component, EventEmitter, Input, OnInit, Output, ViewChild } from "@angular/core"; +import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy"; +import { Observable, Subscription } from "rxjs"; +import { HttpErrorResponse, HttpStatusCode } from "@angular/common/http"; +import { NgFor, NgIf } from "@angular/common"; +import { FormsModule } from "@angular/forms"; +import { CdkFixedSizeVirtualScroll, CdkVirtualForOf, CdkVirtualScrollViewport } from "@angular/cdk/scrolling"; +import { NzButtonComponent } from "ng-zorro-antd/button"; +import { NzCollapseComponent, NzCollapsePanelComponent } from "ng-zorro-antd/collapse"; +import { NzDividerComponent } from "ng-zorro-antd/divider"; +import { NzIconDirective } from "ng-zorro-antd/icon"; +import { NzInputDirective } from "ng-zorro-antd/input"; +import { NzProgressComponent } from "ng-zorro-antd/progress"; +import { NzTagComponent } from "ng-zorro-antd/tag"; +import { NzTooltipDirective } from "ng-zorro-antd/tooltip"; +import { ɵNzTransitionPatchDirective } from "ng-zorro-antd/core/transition-patch"; + +import { AdminSettingsService } from "../../../service/admin/settings/admin-settings.service"; +import { + MultipartUploadProgress, + MultipartUploadService, +} from "../../../service/user/file-resource/multipart-upload.service"; +import { + DATASET_FILE_RESOURCE_ENDPOINT, + FileResourceEndpoint, +} from "../../../service/user/file-resource/file-resource-endpoint"; +import { NotificationService } from "../../../../common/service/notification/notification.service"; +import { extractErrorMessage } from "../../../../common/util/error"; +import { formatSpeed, formatTime, parseIntOrDefault } from "src/app/common/util/format.util"; +import { DatasetStagedObject } from "../../../../common/type/dataset-staged-object"; +import { FileUploadItem } from "../../../type/dashboard-file.interface"; +import { FilesUploaderComponent } from "../files-uploader/files-uploader.component"; +import { StagedObjectsListComponent } from "../staged-objects-list/staged-objects-list.component"; + +export const ABORT_RETRY_MAX_ATTEMPTS = 10; +export const ABORT_RETRY_BACKOFF_BASE_MS = 100; +export const FINISHED_TASK_HIDE_DELAY_MS = 5000; + +/** + * The "Create New Version" panel of a versioned resource's detail page: pick files, watch them + * upload, review what is staged, and commit it as a version. Resource-agnostic — addressing comes + * from `endpoint`, and only the version-creation call is passed in, because each resource kind + * types its own response payload. + */ +@UntilDestroy() +@Component({ + selector: "texera-version-uploader", + templateUrl: "./version-uploader.component.html", + styleUrls: ["./version-uploader.component.scss"], + imports: [ + NgIf, + NgFor, + FormsModule, + NzButtonComponent, + NzCollapseComponent, + NzCollapsePanelComponent, + NzDividerComponent, + NzIconDirective, + NzInputDirective, + NzProgressComponent, + NzTagComponent, + NzTooltipDirective, + ɵNzTransitionPatchDirective, + CdkVirtualScrollViewport, + CdkFixedSizeVirtualScroll, + CdkVirtualForOf, + FilesUploaderComponent, + StagedObjectsListComponent, + ], +}) +export class VersionUploaderComponent implements OnInit { + @Input() resourceId: number | undefined; + @Input() resourceName: string = ""; + @Input() ownerEmail: string = ""; + /** Which resource family the ids above belong to. */ + @Input() endpoint: FileResourceEndpoint = DATASET_FILE_RESOURCE_ENDPOINT; + /** Commits the staged files as a new version. */ + @Input() createVersion!: (versionName: string) => Observable; + + /** The host reloads its version list off this. */ + @Output() versionCreated = new EventEmitter(); + /** + * True while any upload is mid-flight. The engine captured the resource name when the upload + * started, so a rename in that window strands the remaining part/finish calls under the old + * name — and the abort, which reads the new one, cannot clean them up either. + */ + @Output() uploadsInFlightChange = new EventEmitter(); + + userHasPendingChanges: boolean = false; + pendingChangesCount: number = 0; + // Staged paths from the last diff response, plus locally staged paths not yet in one: counted + // together so the Finished header keeps pace with the real-time Pending header. + private confirmedStagedPaths = new Set(); + private unconfirmedStagedPaths = new Set(); + + // Upload tuning, overridden by this resource family's settings in Admin -> Settings. + chunkSizeMiB: number = 50; + maxConcurrentChunks: number = 10; + maxConcurrentFiles: number = 3; + private uploadSubscriptions = new Map(); + uploadTimeMap = new Map(); + + private activeUploads: number = 0; + // FIFO queue of uploads waiting for a concurrency slot, keyed by file name. + private pendingQueue = new Map void>(); + private pendingQueueDirty = false; + private queuedFileNamesSnapshot: string[] = []; + + // Row height must match .pending-file-row in the SCSS. + readonly PENDING_ROW_HEIGHT_PX = 32; + readonly PENDING_LIST_MAX_HEIGHT_PX = 160; + + @ViewChild(CdkVirtualScrollViewport) private pendingViewport?: CdkVirtualScrollViewport; + + versionName: string = ""; + isCreatingVersion: boolean = false; + + // One row per in-flight or recently finished upload, keyed by file path. + uploadTasks: Array = []; + + // Coalesced by the staged list, which refetches the diff at most once per window. + userMakeChanges = new EventEmitter(); + + formatTime = formatTime; + formatSpeed = formatSpeed; + + constructor( + private multipartUploadService: MultipartUploadService, + private adminSettingsService: AdminSettingsService, + private notificationService: NotificationService + ) {} + + ngOnInit(): void { + this.loadUploadSettings(); + } + + // A missing key or failed fetch keeps the field defaults; NaN here would silently stall the + // upload queue (`activeUploads < NaN` is always false). + private loadUploadSettings(): void { + this.adminSettingsService + .getPublicSetting(this.endpoint.chunkSizeSettingKey) + .pipe(untilDestroyed(this)) + .subscribe({ + next: value => (this.chunkSizeMiB = parseIntOrDefault(value, this.chunkSizeMiB)), + error: () => {}, + }); + this.adminSettingsService + .getPublicSetting(this.endpoint.maxConcurrentChunksSettingKey) + .pipe(untilDestroyed(this)) + .subscribe({ + next: value => (this.maxConcurrentChunks = parseIntOrDefault(value, this.maxConcurrentChunks)), + error: () => {}, + }); + this.adminSettingsService + .getPublicSetting(this.endpoint.maxConcurrentFilesSettingKey) + .pipe(untilDestroyed(this)) + .subscribe({ + next: value => (this.maxConcurrentFiles = parseIntOrDefault(value, this.maxConcurrentFiles)), + error: () => {}, + }); + } + + onNewUploadFilesChanged(files: FileUploadItem[]): void { + if (!this.resourceId) { + return; + } + files.forEach(file => { + const continueWithUpload = () => { + const startUpload = () => { + this.removeFromPendingQueue(file.name); + this.uploadTasks.unshift({ filePath: file.name, percentage: 0, status: "initializing" }); + + const subscription = this.multipartUploadService + .multipartUpload( + this.endpoint, + this.ownerEmail, + this.resourceName, + file.name, + file.file, + this.chunkSizeMiB * 1024 * 1024, + this.maxConcurrentChunks, + file.restart + ) + .pipe(untilDestroyed(this)) + .subscribe({ + next: progress => { + const taskIndex = this.uploadTasks.findIndex(t => t.filePath === file.name); + if (taskIndex === -1) { + return; + } + this.uploadTasks[taskIndex] = { + ...this.uploadTasks[taskIndex], + ...progress, + percentage: progress.percentage ?? this.uploadTasks[taskIndex].percentage ?? 0, + }; + // totalTime may be exactly 0 (resumed upload with no missing parts); a truthiness + // check would leak the concurrency slot. + if (progress.status === "finished" && progress.totalTime !== undefined) { + const filename = file.name.split("/").pop() || file.name; + this.uploadTimeMap.set(filename, progress.totalTime); + this.notePathStaged(file.name); + this.scheduleHide(taskIndex); + this.onUploadComplete(); + } + }, + error: (res: unknown) => { + const err = res as HttpErrorResponse; + if (err?.status === HttpStatusCode.Conflict) { + this.notificationService.error( + "Upload blocked (409). Another upload is likely in progress for this file (another tab/browser), or the server is finalizing a previous upload. Please retry in a moment." + ); + } else { + this.notificationService.error("Upload failed. Please retry."); + } + const taskIndex = this.uploadTasks.findIndex(t => t.filePath === file.name); + if (taskIndex !== -1) { + this.uploadTasks[taskIndex] = { + ...this.uploadTasks[taskIndex], + percentage: this.uploadTasks[taskIndex].percentage ?? 0, + status: "failed", + }; + this.scheduleHide(taskIndex); + } + this.onUploadComplete(); + }, + complete: () => { + const taskIndex = this.uploadTasks.findIndex(t => t.filePath === file.name); + if (taskIndex !== -1 && this.uploadTasks[taskIndex].status !== "finished") { + this.uploadTasks[taskIndex].status = "finished"; + this.notePathStaged(file.name); + this.scheduleHide(taskIndex); + this.onUploadComplete(); + } + }, + }); + this.uploadSubscriptions.set(file.name, subscription); + }; + + if (this.activeUploads < this.maxConcurrentFiles) { + this.setActiveUploads(this.activeUploads + 1); + startUpload(); + } else { + this.pendingQueue.set(file.name, startUpload); + this.pendingQueueDirty = true; + } + }; + + this.cancelExistingUpload(file.name, continueWithUpload); + }); + } + + cancelExistingUpload(fileName: string, onCanceled?: () => void): void { + const task = this.uploadTasks.find(t => t.filePath === fileName); + if (task && (task.status === "uploading" || task.status === "initializing")) { + this.onClickAbortUploadProgress(task, onCanceled); + return; + } + this.removeFromPendingQueue(fileName); + onCanceled?.(); + } + + private processNextQueuedUpload(): void { + if (this.activeUploads >= this.maxConcurrentFiles) { + return; + } + const next = this.pendingQueue.entries().next(); + if (!next.done) { + const [fileName, startUpload] = next.value; + this.pendingQueue.delete(fileName); + this.pendingQueueDirty = true; + this.setActiveUploads(this.activeUploads + 1); + startUpload(); + } + } + + private onUploadComplete(): void { + this.setActiveUploads(this.activeUploads - 1); + this.processNextQueuedUpload(); + } + + private setActiveUploads(count: number): void { + this.activeUploads = count; + this.uploadsInFlightChange.emit(count > 0); + } + + private removeFromPendingQueue(fileName: string): void { + if (this.pendingQueue.delete(fileName)) { + this.pendingQueueDirty = true; + } + } + + // Stable array for the template: rebuilt at most once per queue change so change detection does + // not allocate a new array per pass (#5586). + get queuedFileNames(): string[] { + if (this.pendingQueueDirty) { + this.queuedFileNamesSnapshot = Array.from(this.pendingQueue.keys()); + this.pendingQueueDirty = false; + } + return this.queuedFileNamesSnapshot; + } + + get queuedCount(): number { + return this.pendingQueue.size; + } + + get activeCount(): number { + return this.activeUploads; + } + + get pendingListHeightPx(): number { + return Math.min(this.queuedCount * this.PENDING_ROW_HEIGHT_PX, this.PENDING_LIST_MAX_HEIGHT_PX); + } + + get hasAnyActivity(): boolean { + return this.pendingChangesCount > 0 || this.activeCount > 0 || this.queuedCount > 0; + } + + // The viewport initializes inside the collapsed (display: none) panel and measures height 0; the + // CDK only re-measures on window resize. + onPendingPanelActiveChange(active: boolean): void { + if (active) { + setTimeout(() => this.pendingViewport?.checkViewportSize()); + } + } + + // Hide a finished, failed or aborted row after a short delay. + private scheduleHide(idx: number): void { + if (idx === -1) { + return; + } + const task = this.uploadTasks[idx]; + this.uploadSubscriptions.delete(task.filePath); + // Remove by identity, not filePath: a same-named re-upload within the window has its own row, + // which must survive this timer. + setTimeout(() => { + this.uploadTasks = this.uploadTasks.filter(t => t !== task); + }, FINISHED_TASK_HIDE_DELAY_MS); + } + + onClickAbortUploadProgress(task: MultipartUploadProgress & { filePath: string }, onAborted?: () => void): void { + const subscription = this.uploadSubscriptions.get(task.filePath); + if (subscription) { + subscription.unsubscribe(); + this.uploadSubscriptions.delete(task.filePath); + } + + if (task.status === "uploading" || task.status === "initializing") { + this.onUploadComplete(); + } + + let doneCalled = false; + const done = () => { + if (doneCalled) { + return; + } + doneCalled = true; + onAborted?.(); + }; + + const abortWithRetry = (attempt: number) => { + this.multipartUploadService + .finalizeMultipartUpload(this.endpoint, this.ownerEmail, this.resourceName, task.filePath, true) + .pipe(untilDestroyed(this)) + .subscribe({ + next: () => { + this.notificationService.info(`${task.filePath} uploading has been terminated`); + done(); + }, + error: (res: unknown) => { + const err = res as HttpErrorResponse; + // Already gone, treat as done. + if (err.status === HttpStatusCode.NotFound) { + done(); + return; + } + // Backend is still finalizing/aborting; retry with a tiny backoff. + if (err.status === HttpStatusCode.Conflict && attempt < ABORT_RETRY_MAX_ATTEMPTS) { + setTimeout(() => abortWithRetry(attempt + 1), ABORT_RETRY_BACKOFF_BASE_MS * (attempt + 1)); + return; + } + done(); + }, + }); + }; + + abortWithRetry(0); + + const idx = this.uploadTasks.findIndex(t => t.filePath === task.filePath); + if (idx !== -1) { + this.uploadTasks[idx] = { ...this.uploadTasks[idx], status: "aborted" }; + this.scheduleHide(idx); + } + } + + getUploadStatus(status: MultipartUploadProgress["status"]): "active" | "exception" | "success" { + return status === "uploading" || status === "initializing" + ? "active" + : status === "aborted" || status === "failed" + ? "exception" + : "success"; + } + + trackByTask(_: number, task: MultipartUploadProgress & { filePath: string }): string { + return task.filePath; + } + + trackByPendingFile(_: number, fileName: string): string { + return fileName; + } + + onStagedObjectsUpdated(stagedObjects: DatasetStagedObject[]): void { + this.confirmedStagedPaths = new Set(stagedObjects.map(obj => obj.path)); + for (const path of this.confirmedStagedPaths) { + this.unconfirmedStagedPaths.delete(path); + } + this.refreshPendingChanges(); + } + + /** + * Counts a change staged outside this panel — the host stages a deletion from the file tree — + * in the Finished header immediately, ahead of the next diff response. + */ + notePathStaged(path: string): void { + if (!this.confirmedStagedPaths.has(path)) { + this.unconfirmedStagedPaths.add(path); + } + this.refreshPendingChanges(); + this.userMakeChanges.emit(); + } + + private refreshPendingChanges(): void { + this.pendingChangesCount = this.confirmedStagedPaths.size + this.unconfirmedStagedPaths.size; + this.userHasPendingChanges = this.pendingChangesCount > 0; + } + + onClickCreateVersion(): void { + if (!this.resourceId || this.isCreatingVersion) { + return; + } + this.isCreatingVersion = true; + this.createVersion(this.versionName?.trim() || "") + .pipe(untilDestroyed(this)) + .subscribe({ + next: () => { + this.notificationService.success("Version Created"); + this.isCreatingVersion = false; + this.versionName = ""; + // A new version consumes all staged changes. + this.confirmedStagedPaths.clear(); + this.unconfirmedStagedPaths.clear(); + this.refreshPendingChanges(); + this.versionCreated.emit(); + this.userMakeChanges.emit(); + }, + error: (err: unknown) => { + this.notificationService.error(`Version creation failed: ${extractErrorMessage(err)}`); + this.isCreatingVersion = false; + }, + }); + } +} diff --git a/frontend/src/app/dashboard/service/user/dataset/dataset.service.spec.ts b/frontend/src/app/dashboard/service/user/dataset/dataset.service.spec.ts index 1af4773e6da..181bd3b79b0 100644 --- a/frontend/src/app/dashboard/service/user/dataset/dataset.service.spec.ts +++ b/frontend/src/app/dashboard/service/user/dataset/dataset.service.spec.ts @@ -22,6 +22,7 @@ import { HttpClientTestingModule, HttpTestingController } from "@angular/common/ import { firstValueFrom } from "rxjs"; import { DATASET_BASE_URL, DatasetService, MultipartUploadProgress, validateDatasetName } from "./dataset.service"; +import { FakeXMLHttpRequest } from "../file-resource/testing/fake-xml-http-request"; import { AppSettings } from "../../../../common/app-setting"; import { AuthService } from "../../../../common/service/user/auth.service"; import { commonTestProviders } from "../../../../common/testing/test-utils"; @@ -75,63 +76,6 @@ const SAMPLE_FILE_NODES: DatasetFileNode[] = [ { name: "root", type: "directory", parentDir: "", children: [] as DatasetFileNode[] } as DatasetFileNode, ]; -class FakeXMLHttpRequest { - static instances: FakeXMLHttpRequest[] = []; - - // Capturing upload target so tests can drive `upload.progress` events. - readonly upload = { - listeners: new Map(), - addEventListener(type: string, listener: EventListener): void { - this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]); - }, - }; - status = 0; - url = ""; - readonly requestHeaders = new Map(); - private listeners = new Map(); - - open(_method: string, url: string): void { - this.url = url; - } - - setRequestHeader(name: string, value: string): void { - this.requestHeaders.set(name, value); - } - - send(): void { - FakeXMLHttpRequest.instances.push(this); - } - - abort(): void {} - - addEventListener(type: string, listener: EventListener): void { - this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]); - } - - /** Drives the `upload.progress` listener registered by the service. */ - emitProgress(loaded: number, lengthComputable = true): void { - const event = { lengthComputable, loaded } as unknown as Event; - for (const listener of this.upload.listeners.get("progress") ?? []) { - listener(event); - } - } - - respond(status: number): void { - this.status = status; - this.emit("load"); - } - - fail(): void { - this.emit("error"); - } - - private emit(type: string): void { - for (const listener of this.listeners.get(type) ?? []) { - listener(new Event(type)); - } - } -} - describe("validateDatasetName", () => { it("returns null for a valid name", () => { expect(validateDatasetName("my-dataset_1")).toBeNull(); diff --git a/frontend/src/app/dashboard/service/user/dataset/dataset.service.ts b/frontend/src/app/dashboard/service/user/dataset/dataset.service.ts index 9d21e41977a..a370158369c 100644 --- a/frontend/src/app/dashboard/service/user/dataset/dataset.service.ts +++ b/frontend/src/app/dashboard/service/user/dataset/dataset.service.ts @@ -19,15 +19,17 @@ import { Injectable } from "@angular/core"; import { HttpClient, HttpErrorResponse, HttpParams } from "@angular/common/http"; -import { catchError, map, mergeMap, switchMap, tap, toArray } from "rxjs/operators"; +import { map, switchMap } from "rxjs/operators"; import { Contributor, Dataset, DatasetVersion } from "../../../../common/type/dataset"; import { AppSettings } from "../../../../common/app-setting"; -import { EMPTY, from, Observable, throwError } from "rxjs"; +import { EMPTY, Observable } from "rxjs"; import { DashboardDataset } from "../../../type/dashboard-dataset.interface"; import { DatasetFileNode } from "../../../../common/type/datasetVersionFileTree"; import { DatasetStagedObject } from "../../../../common/type/dataset-staged-object"; import { GuiConfigService } from "../../../../common/service/gui-config.service"; -import { AuthService } from "src/app/common/service/user/auth.service"; +import { MultipartUploadProgress, MultipartUploadService } from "../file-resource/multipart-upload.service"; +import { StagedFileService } from "../file-resource/staged-file.service"; +import { DATASET_FILE_RESOURCE_ENDPOINT } from "../file-resource/file-resource-endpoint"; export const DATASET_BASE_URL = "dataset"; export const DATASET_CREATE_URL = DATASET_BASE_URL + "/create"; @@ -70,14 +72,8 @@ export const DATASET_PUBLIC_VERSION_BASE_URL = "publicVersion"; export const DATASET_PUBLIC_VERSION_RETRIEVE_LIST_URL = DATASET_PUBLIC_VERSION_BASE_URL + "/list"; export const DATASET_GET_OWNERS_URL = DATASET_BASE_URL + "/user-dataset-owners"; -export interface MultipartUploadProgress { - filePath: string; - percentage: number; - status: "initializing" | "uploading" | "finished" | "aborted" | "failed"; - uploadSpeed?: number; // bytes per second - estimatedTimeRemaining?: number; // seconds - totalTime?: number; // total seconds taken -} +// Re-exported so existing importers keep resolving it from here. +export type { MultipartUploadProgress }; @Injectable({ providedIn: "root", @@ -85,7 +81,9 @@ export interface MultipartUploadProgress { export class DatasetService { constructor( private http: HttpClient, - private config: GuiConfigService + private config: GuiConfigService, + private multipartUploadService: MultipartUploadService, + private stagedFileService: StagedFileService ) {} public createDataset(dataset: Dataset, contributors: Contributor[] = []): Observable { @@ -162,14 +160,8 @@ export class DatasetService { } /** - * Handles multipart upload for large files using RxJS, - * with a concurrency limit on how many parts we process in parallel. - * - * Backend flow: - * POST /dataset/multipart-upload?type=init&ownerEmail=...&datasetName=...&filePath=...&numParts=N - * POST /dataset/multipart-upload/part?ownerEmail=...&datasetName=...&filePath=...&partNumber= (body: raw chunk) - * POST /dataset/multipart-upload?type=finish&ownerEmail=...&datasetName=...&filePath=... - * POST /dataset/multipart-upload?type=abort&ownerEmail=...&datasetName=...&filePath=... + * Multipart upload for large files. The engine lives in MultipartUploadService; this keeps the + * dataset-facing signature so existing callers are untouched. */ public multipartUpload( ownerEmail: string, @@ -180,278 +172,24 @@ export class DatasetService { concurrencyLimit: number, restart: boolean ): Observable { - const partCount = Math.ceil(file.size / partSize); - - return new Observable(observer => { - // Track upload progress (bytes) for each part independently - const partProgress = new Map(); - - let baselineUploaded = 0; - // Progress tracking state - let startTime: number | null = null; - const speedSamples: number[] = []; - let lastETA = 0; - let lastUpdateTime = 0; - - const lastStats = { - uploadSpeed: 0, - estimatedTimeRemaining: 0, - totalTime: 0, - }; - - const getTotalTime = () => (startTime ? (Date.now() - startTime) / 1000 : 0); - - // Calculate stats with smoothing and simple throttling (~1s) - const calculateStats = (totalUploaded: number) => { - if (startTime === null) { - startTime = Date.now(); - } - - const now = Date.now(); - const elapsed = getTotalTime(); - - const shouldUpdate = now - lastUpdateTime >= 1000; - if (!shouldUpdate) { - // keep totalTime fresh even when throttled - lastStats.totalTime = elapsed; - return lastStats; - } - lastUpdateTime = now; - - const sessionUploaded = Math.max(0, totalUploaded - baselineUploaded); - const currentSpeed = elapsed > 0 ? sessionUploaded / elapsed : 0; - speedSamples.push(currentSpeed); - if (speedSamples.length > 5) { - speedSamples.shift(); - } - const avgSpeed = speedSamples.length > 0 ? speedSamples.reduce((a, b) => a + b, 0) / speedSamples.length : 0; - - const remaining = file.size - totalUploaded; - let eta = avgSpeed > 0 ? remaining / avgSpeed : 0; - eta = Math.min(eta, 24 * 60 * 60); // cap ETA at 24h - - if (lastETA > 0 && eta > 0) { - const maxChange = lastETA * 0.3; - const diff = Math.abs(eta - lastETA); - if (diff > maxChange) { - eta = lastETA + (eta > lastETA ? maxChange : -maxChange); - } - } - lastETA = eta; - - const percentComplete = (totalUploaded / file.size) * 100; - if (percentComplete > 95) { - eta = Math.min(eta, 10); - } - - lastStats.uploadSpeed = avgSpeed; - lastStats.estimatedTimeRemaining = Math.max(0, Math.round(eta)); - lastStats.totalTime = elapsed; - - return lastStats; - }; - - // 1. INIT: ask backend to create a LakeFS multipart upload session - const initParams = new HttpParams() - .set("type", "init") - .set("ownerEmail", ownerEmail) - .set("datasetName", datasetName) - .set("filePath", encodeURIComponent(filePath)) - .set("fileSizeBytes", file.size.toString()) - .set("partSizeBytes", partSize.toString()) - .set("restart", restart); - - const init$ = this.http.post<{ missingParts: number[]; completedPartsCount: number }>( - `${AppSettings.getApiEndpoint()}/${DATASET_BASE_URL}/multipart-upload`, - {}, - { params: initParams } - ); - - const subscription = init$ - .pipe( - switchMap(initResp => { - const missingParts = (initResp?.missingParts ?? []).slice(); - const completedPartsCount = initResp?.completedPartsCount ?? 0; - - const missingBytes = missingParts.reduce((sum, partNumber) => { - const start = (partNumber - 1) * partSize; - const end = Math.min(start + partSize, file.size); - return sum + (end - start); - }, 0); - - baselineUploaded = file.size - missingBytes; - const baselinePct = partCount > 0 ? Math.round((completedPartsCount / partCount) * 100) : 0; - - observer.next({ - filePath, - percentage: baselinePct, - status: "initializing", - uploadSpeed: 0, - estimatedTimeRemaining: 0, - totalTime: 0, - }); - // 2. Upload each part to /multipart-upload/part using XMLHttpRequest - return from(missingParts).pipe( - mergeMap(partNumber => { - const start = (partNumber - 1) * partSize; - const end = Math.min(start + partSize, file.size); - const chunk = file.slice(start, end); - - return new Observable(partObserver => { - const xhr = new XMLHttpRequest(); - - xhr.upload.addEventListener("progress", event => { - if (event.lengthComputable) { - partProgress.set(partNumber, event.loaded); - - let totalUploaded = baselineUploaded; // CHANGED - partProgress.forEach(bytes => { - totalUploaded += bytes; - }); - - const percentage = Math.round((totalUploaded / file.size) * 100); - const stats = calculateStats(totalUploaded); - - observer.next({ - filePath, - percentage: Math.min(percentage, 99), - status: "uploading", - ...stats, - }); - } - }); - - xhr.addEventListener("load", () => { - if (xhr.status === 200 || xhr.status === 204) { - // Mark part as fully uploaded - partProgress.set(partNumber, chunk.size); - - let totalUploaded = baselineUploaded; - partProgress.forEach(bytes => { - totalUploaded += bytes; - }); - - // Force stats recompute on completion - lastUpdateTime = 0; - const percentage = Math.round((totalUploaded / file.size) * 100); - const stats = calculateStats(totalUploaded); - - observer.next({ - filePath, - percentage: Math.min(percentage, 99), - status: "uploading", - ...stats, - }); - - partObserver.complete(); - } else { - partObserver.error(new Error(`Failed to upload part ${partNumber} (HTTP ${xhr.status})`)); - } - }); - - xhr.addEventListener("error", () => { - // Remove failed part from progress - partProgress.delete(partNumber); - partObserver.error(new Error(`Failed to upload part ${partNumber}`)); - }); - - const partUrl = - `${AppSettings.getApiEndpoint()}/${DATASET_BASE_URL}/multipart-upload/part` + - `?ownerEmail=${encodeURIComponent(ownerEmail)}` + - `&datasetName=${encodeURIComponent(datasetName)}` + - `&filePath=${encodeURIComponent(filePath)}` + - `&partNumber=${partNumber}`; - - xhr.open("POST", partUrl); - xhr.setRequestHeader("Content-Type", "application/octet-stream"); - const token = AuthService.getAccessToken(); - if (token) { - xhr.setRequestHeader("Authorization", `Bearer ${token}`); - } - xhr.send(chunk); - return () => { - try { - xhr.abort(); - } catch {} - }; - }); - }, concurrencyLimit), - toArray(), // wait for all parts - // 3. FINISH: notify backend that all parts are done - switchMap(() => { - const finishParams = new HttpParams() - .set("type", "finish") - .set("ownerEmail", ownerEmail) - .set("datasetName", datasetName) - .set("filePath", encodeURIComponent(filePath)); - - return this.http.post( - `${AppSettings.getApiEndpoint()}/${DATASET_BASE_URL}/multipart-upload`, - {}, - { params: finishParams } - ); - }), - tap(() => { - const totalTime = getTotalTime(); - observer.next({ - filePath, - percentage: 100, - status: "finished", - uploadSpeed: 0, - estimatedTimeRemaining: 0, - totalTime, - }); - observer.complete(); - }), - catchError((error: unknown) => { - // On error, compute best-effort percentage from bytes we've seen - let totalUploaded = baselineUploaded; - partProgress.forEach(bytes => { - totalUploaded += bytes; - }); - const percentage = file.size > 0 ? Math.round((totalUploaded / file.size) * 100) : 0; - - observer.next({ - filePath, - percentage, - status: "failed", - uploadSpeed: 0, - estimatedTimeRemaining: 0, - totalTime: getTotalTime(), - }); - - return throwError(() => error); - }) - ); - }) - ) - .subscribe({ - error: (err: unknown) => observer.error(err), - }); - - return () => subscription.unsubscribe(); - }); + return this.multipartUploadService.multipartUpload( + DATASET_FILE_RESOURCE_ENDPOINT, + ownerEmail, + datasetName, + filePath, + file, + partSize, + concurrencyLimit, + restart + ); } public listMultipartUploads(ownerEmail: string, datasetName: string): Observable { - const params = new HttpParams().set("type", "list").set("ownerEmail", ownerEmail).set("datasetName", datasetName); - - return this.http - .post<{ - filePaths: string[]; - }>(`${AppSettings.getApiEndpoint()}/${DATASET_BASE_URL}/multipart-upload`, {}, { params }) - .pipe(map(res => res?.filePaths ?? [])); + return this.multipartUploadService.listMultipartUploads(DATASET_FILE_RESOURCE_ENDPOINT, ownerEmail, datasetName); } public findExistingUploadFiles(did: number, files: { path: string; sizeBytes: number }[]): Observable { - return this.http - .post<{ filePaths: string[] }>( - `${AppSettings.getApiEndpoint()}/${DATASET_BASE_URL}/${did}/existing-upload-files`, - { - files, - } - ) - .pipe(map(res => res?.filePaths ?? [])); + return this.multipartUploadService.findExistingUploadFiles(DATASET_FILE_RESOURCE_ENDPOINT, did, files); } public finalizeMultipartUpload( @@ -460,16 +198,12 @@ export class DatasetService { filePath: string, isAbort: boolean ): Observable { - const params = new HttpParams() - .set("type", isAbort ? "abort" : "finish") - .set("ownerEmail", ownerEmail) - .set("datasetName", datasetName) - .set("filePath", encodeURIComponent(filePath)); - - return this.http.post( - `${AppSettings.getApiEndpoint()}/${DATASET_BASE_URL}/multipart-upload`, - {}, - { params } + return this.multipartUploadService.finalizeMultipartUpload( + DATASET_FILE_RESOURCE_ENDPOINT, + ownerEmail, + datasetName, + filePath, + isAbort ); } @@ -479,9 +213,7 @@ export class DatasetService { * @param filePath File path to reset */ public resetDatasetFileDiff(did: number, filePath: string): Observable { - const params = new HttpParams().set("filePath", encodeURIComponent(filePath)); - - return this.http.put(`${AppSettings.getApiEndpoint()}/${DATASET_BASE_URL}/${did}/diff`, {}, { params }); + return this.stagedFileService.resetFileDiff(DATASET_FILE_RESOURCE_ENDPOINT, did, filePath); } /** @@ -490,9 +222,7 @@ export class DatasetService { * @param filePath File path to delete */ public deleteDatasetFile(did: number, filePath: string): Observable { - const params = new HttpParams().set("filePath", encodeURIComponent(filePath)); - - return this.http.delete(`${AppSettings.getApiEndpoint()}/${DATASET_BASE_URL}/${did}/file`, { params }); + return this.stagedFileService.deleteFile(DATASET_FILE_RESOURCE_ENDPOINT, did, filePath); } /** @@ -500,7 +230,7 @@ export class DatasetService { * @param did Dataset ID */ public getDatasetDiff(did: number): Observable { - return this.http.get(`${AppSettings.getApiEndpoint()}/${DATASET_BASE_URL}/${did}/diff`); + return this.stagedFileService.getDiff(DATASET_FILE_RESOURCE_ENDPOINT, did); } /** diff --git a/frontend/src/app/dashboard/service/user/file-resource/file-resource-endpoint.ts b/frontend/src/app/dashboard/service/user/file-resource/file-resource-endpoint.ts new file mode 100644 index 00000000000..9aa89ffbdfa --- /dev/null +++ b/frontend/src/app/dashboard/service/user/file-resource/file-resource-endpoint.ts @@ -0,0 +1,62 @@ +/** + * 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. + */ + +/** + * Addresses the file-upload endpoints of one versioned resource kind. Resource families differ only + * in their base path and in the query-param name carrying the resource name, so the upload engine is + * parameterized by this rather than duplicated per family. + */ +export interface FileResourceEndpoint { + /** Path segment under the API root, e.g. "dataset". */ + readonly baseUrl: string; + /** How to name this resource kind in user-facing copy, e.g. "dataset". */ + readonly label: string; + /** Query-param name carrying the resource name, e.g. "datasetName". */ + readonly nameParamKey: string; + /** site_settings key holding this resource's per-file upload ceiling, in MiB. */ + readonly maxFileSizeSettingKey: string; + /** Fallback ceiling in MiB when that setting is absent or unparsable. */ + readonly defaultMaxFileSizeMiB: number; + /** site_settings keys tuning this resource's multipart upload. */ + readonly chunkSizeSettingKey: string; + readonly maxConcurrentChunksSettingKey: string; + readonly maxConcurrentFilesSettingKey: string; +} + +export const DATASET_FILE_RESOURCE_ENDPOINT: FileResourceEndpoint = { + baseUrl: "dataset", + label: "dataset", + nameParamKey: "datasetName", + maxFileSizeSettingKey: "dataset_single_file_upload_max_size_mib", + defaultMaxFileSizeMiB: 20, + chunkSizeSettingKey: "dataset_multipart_upload_chunk_size_mib", + maxConcurrentChunksSettingKey: "dataset_max_number_of_concurrent_uploading_file_chunks", + maxConcurrentFilesSettingKey: "dataset_max_number_of_concurrent_uploading_file", +}; + +export const MODEL_FILE_RESOURCE_ENDPOINT: FileResourceEndpoint = { + baseUrl: "model", + label: "model", + nameParamKey: "modelName", + maxFileSizeSettingKey: "model_single_file_upload_max_size_mib", + defaultMaxFileSizeMiB: 2048, + chunkSizeSettingKey: "model_multipart_upload_chunk_size_mib", + maxConcurrentChunksSettingKey: "model_max_number_of_concurrent_uploading_file_chunks", + maxConcurrentFilesSettingKey: "model_max_number_of_concurrent_uploading_file", +}; diff --git a/frontend/src/app/dashboard/service/user/file-resource/multipart-upload.service.spec.ts b/frontend/src/app/dashboard/service/user/file-resource/multipart-upload.service.spec.ts new file mode 100644 index 00000000000..05795a092e0 --- /dev/null +++ b/frontend/src/app/dashboard/service/user/file-resource/multipart-upload.service.spec.ts @@ -0,0 +1,314 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { TestBed } from "@angular/core/testing"; +import { HttpClientTestingModule, HttpTestingController } from "@angular/common/http/testing"; +import { firstValueFrom } from "rxjs"; + +import { MultipartUploadProgress, MultipartUploadService } from "./multipart-upload.service"; +import { DATASET_FILE_RESOURCE_ENDPOINT, FileResourceEndpoint } from "./file-resource-endpoint"; +import { FakeXMLHttpRequest } from "./testing/fake-xml-http-request"; +import { commonTestProviders } from "../../../../common/testing/test-utils"; +import { AuthService } from "src/app/common/service/user/auth.service"; + +const API = "api"; + +/** + * A second endpoint that is neither dataset nor model. Using a synthetic one proves the engine is + * genuinely parameterized rather than accidentally dataset-shaped. + */ +const WIDGET_ENDPOINT: FileResourceEndpoint = { + baseUrl: "widget", + label: "widget", + nameParamKey: "widgetName", + maxFileSizeSettingKey: "widget_single_file_upload_max_size_mib", + defaultMaxFileSizeMiB: 64, + chunkSizeSettingKey: "widget_multipart_upload_chunk_size_mib", + maxConcurrentChunksSettingKey: "widget_max_number_of_concurrent_uploading_file_chunks", + maxConcurrentFilesSettingKey: "widget_max_number_of_concurrent_uploading_file", +}; + +describe("MultipartUploadService", () => { + let service: MultipartUploadService; + let http: HttpTestingController; + + const isType = + (endpoint: FileResourceEndpoint, type: string) => (r: { url: string; params: { get(k: string): string | null } }) => + r.url === `${API}/${endpoint.baseUrl}/multipart-upload` && r.params.get("type") === type; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [MultipartUploadService, ...commonTestProviders], + }); + service = TestBed.inject(MultipartUploadService); + http = TestBed.inject(HttpTestingController); + FakeXMLHttpRequest.reset(); + }); + + afterEach(() => { + http.verify(); + }); + + // ─── the endpoint seam ──────────────────────────────────────────────────── + + it("addresses init, part and finish through the supplied endpoint", async () => { + vi.stubGlobal("XMLHttpRequest", FakeXMLHttpRequest); + const file = new File([new Uint8Array(4)], "w.bin"); + const done = new Promise((resolve, reject) => { + service + .multipartUpload(WIDGET_ENDPOINT, "o@e.com", "my-widget", "w.bin", file, 4, 1, false) + .subscribe({ error: reject, complete: resolve }); + }); + + const init = http.expectOne(isType(WIDGET_ENDPOINT, "init")); + expect(init.request.params.get("widgetName")).toBe("my-widget"); + expect(init.request.params.get("datasetName")).toBeNull(); + init.flush({ missingParts: [1], completedPartsCount: 0 }); + + // The part URL is built by hand rather than through HttpParams, so it needs asserting + // separately — a hardcoded name key here would only break for non-dataset resources. + const xhr = FakeXMLHttpRequest.instances[0]; + expect(xhr.url.startsWith(`${API}/widget/multipart-upload/part`)).toBe(true); + expect(xhr.params().get("widgetName")).toBe("my-widget"); + expect(xhr.params().get("datasetName")).toBeNull(); + xhr.respond(204); + + const finish = http.expectOne(isType(WIDGET_ENDPOINT, "finish")); + expect(finish.request.params.get("widgetName")).toBe("my-widget"); + finish.flush({}); + await done; + }); + + it("uses the endpoint for listMultipartUploads", async () => { + const pending = firstValueFrom(service.listMultipartUploads(WIDGET_ENDPOINT, "o@e.com", "my-widget")); + const req = http.expectOne(isType(WIDGET_ENDPOINT, "list")); + expect(req.request.params.get("widgetName")).toBe("my-widget"); + req.flush({ filePaths: ["a"] }); + expect(await pending).toEqual(["a"]); + }); + + it("uses the endpoint for findExistingUploadFiles", async () => { + const pending = firstValueFrom( + service.findExistingUploadFiles(WIDGET_ENDPOINT, 7, [{ path: "a.csv", sizeBytes: 1 }]) + ); + const req = http.expectOne(`${API}/widget/7/existing-upload-files`); + expect(req.request.body).toEqual({ files: [{ path: "a.csv", sizeBytes: 1 }] }); + req.flush({ filePaths: ["a.csv"] }); + expect(await pending).toEqual(["a.csv"]); + }); + + it("uses the endpoint for finalizeMultipartUpload and distinguishes abort from finish", () => { + service.finalizeMultipartUpload(WIDGET_ENDPOINT, "o@e.com", "my-widget", "f", true).subscribe(); + const abort = http.expectOne(isType(WIDGET_ENDPOINT, "abort")); + expect(abort.request.params.get("widgetName")).toBe("my-widget"); + abort.flush({}); + + service.finalizeMultipartUpload(WIDGET_ENDPOINT, "o@e.com", "my-widget", "f", false).subscribe(); + http.expectOne(isType(WIDGET_ENDPOINT, "finish")).flush({}); + }); + + // ─── upload flow ────────────────────────────────────────────────────────── + + it("emits progress, attaches the auth header, and finishes at 100%", async () => { + const tokenSpy = vi.spyOn(AuthService, "getAccessToken").mockReturnValue("tok123"); + vi.stubGlobal("XMLHttpRequest", FakeXMLHttpRequest); + try { + const file = new File([new Uint8Array(8)], "d.bin"); + const emissions: MultipartUploadProgress[] = []; + const done = new Promise((resolve, reject) => { + service + .multipartUpload(DATASET_FILE_RESOURCE_ENDPOINT, "o@e.com", "ds", "d.bin", file, 8, 1, false) + .subscribe({ next: p => emissions.push(p), error: reject, complete: resolve }); + }); + + http.expectOne(isType(DATASET_FILE_RESOURCE_ENDPOINT, "init")).flush({ + missingParts: [1], + completedPartsCount: 0, + }); + + const xhr = FakeXMLHttpRequest.instances[0]; + expect(xhr.requestHeaders.get("Content-Type")).toBe("application/octet-stream"); + expect(xhr.requestHeaders.get("Authorization")).toBe("Bearer tok123"); + + xhr.emitProgress(4); + xhr.respond(200); + http.expectOne(isType(DATASET_FILE_RESOURCE_ENDPOINT, "finish")).flush({}); + await done; + + expect(emissions[0]).toMatchObject({ status: "initializing" }); + expect(emissions.some(e => e.status === "uploading" && e.percentage > 0 && e.percentage <= 99)).toBe(true); + expect(emissions.at(-1)).toMatchObject({ status: "finished", percentage: 100 }); + } finally { + tokenSpy.mockRestore(); + } + }); + + it("omits the auth header when there is no access token", () => { + const tokenSpy = vi.spyOn(AuthService, "getAccessToken").mockReturnValue(null); + vi.stubGlobal("XMLHttpRequest", FakeXMLHttpRequest); + try { + const file = new File([new Uint8Array(4)], "anon.bin"); + const subscription = service + .multipartUpload(DATASET_FILE_RESOURCE_ENDPOINT, "o@e.com", "ds", "anon.bin", file, 4, 1, false) + .subscribe(); + http.expectOne(isType(DATASET_FILE_RESOURCE_ENDPOINT, "init")).flush({ + missingParts: [1], + completedPartsCount: 0, + }); + + expect(FakeXMLHttpRequest.instances[0].requestHeaders.has("Authorization")).toBe(false); + subscription.unsubscribe(); + } finally { + tokenSpy.mockRestore(); + } + }); + + it("resumes by uploading only the parts the backend reports missing", async () => { + vi.stubGlobal("XMLHttpRequest", FakeXMLHttpRequest); + const file = new File(["abcdefgh"], "resume.txt"); + const emissions: MultipartUploadProgress[] = []; + const done = new Promise((resolve, reject) => { + service + .multipartUpload(DATASET_FILE_RESOURCE_ENDPOINT, "o@e.com", "ds", "resume.txt", file, 4, 1, false) + .subscribe({ next: p => emissions.push(p), error: reject, complete: resolve }); + }); + + // One of two parts already landed, so the baseline starts at 50%. + http.expectOne(isType(DATASET_FILE_RESOURCE_ENDPOINT, "init")).flush({ + missingParts: [2], + completedPartsCount: 1, + }); + + expect(emissions[0]).toMatchObject({ percentage: 50, status: "initializing" }); + expect(FakeXMLHttpRequest.instances.map(x => x.params().get("partNumber"))).toEqual(["2"]); + FakeXMLHttpRequest.instances[0].respond(204); + + http.expectOne(isType(DATASET_FILE_RESOURCE_ENDPOINT, "finish")).flush({}); + await done; + expect(emissions.at(-1)).toMatchObject({ percentage: 100, status: "finished" }); + }); + + it("finishes without uploading anything when no parts are missing", async () => { + vi.stubGlobal("XMLHttpRequest", FakeXMLHttpRequest); + const file = new File([new Uint8Array(4)], "c.bin"); + const done = new Promise((resolve, reject) => { + service + .multipartUpload(DATASET_FILE_RESOURCE_ENDPOINT, "o@e.com", "ds", "c.bin", file, 4, 1, false) + .subscribe({ error: reject, complete: resolve }); + }); + + // A sparse payload also exercises the nullish-coalescing defaults. + http.expectOne(isType(DATASET_FILE_RESOURCE_ENDPOINT, "init")).flush({}); + expect(FakeXMLHttpRequest.instances.length).toBe(0); + http.expectOne(isType(DATASET_FILE_RESOURCE_ENDPOINT, "finish")).flush({}); + await done; + }); + + // ─── failure and teardown ───────────────────────────────────────────────── + + it("fails the upload when a part returns a non-2xx status", async () => { + vi.stubGlobal("XMLHttpRequest", FakeXMLHttpRequest); + const file = new File([new Uint8Array(4)], "e.bin"); + const emissions: MultipartUploadProgress[] = []; + const outcome = new Promise(resolve => { + service + .multipartUpload(DATASET_FILE_RESOURCE_ENDPOINT, "o@e.com", "ds", "e.bin", file, 4, 1, false) + .subscribe({ next: p => emissions.push(p), error: resolve, complete: () => resolve(null) }); + }); + + http.expectOne(isType(DATASET_FILE_RESOURCE_ENDPOINT, "init")).flush({ + missingParts: [1], + completedPartsCount: 0, + }); + FakeXMLHttpRequest.instances[0].respond(500); + + const err = await outcome; + expect((err as Error).message).toContain("HTTP 500"); + expect(emissions.at(-1)).toMatchObject({ status: "failed" }); + }); + + it("fails the upload when a part errors at the transport level", async () => { + vi.stubGlobal("XMLHttpRequest", FakeXMLHttpRequest); + const file = new File([new Uint8Array(4)], "x.bin"); + const outcome = new Promise(resolve => { + service + .multipartUpload(DATASET_FILE_RESOURCE_ENDPOINT, "o@e.com", "ds", "x.bin", file, 4, 1, false) + .subscribe({ error: resolve, complete: () => resolve(null) }); + }); + + http.expectOne(isType(DATASET_FILE_RESOURCE_ENDPOINT, "init")).flush({ + missingParts: [1], + completedPartsCount: 0, + }); + FakeXMLHttpRequest.instances[0].fail(); + + expect(await outcome).toBeInstanceOf(Error); + }); + + it("aborts the in-flight part request when the caller unsubscribes", () => { + vi.stubGlobal("XMLHttpRequest", FakeXMLHttpRequest); + const file = new File([new Uint8Array(4)], "t.bin"); + const subscription = service + .multipartUpload(DATASET_FILE_RESOURCE_ENDPOINT, "o@e.com", "ds", "t.bin", file, 4, 1, false) + .subscribe(); + + http.expectOne(isType(DATASET_FILE_RESOURCE_ENDPOINT, "init")).flush({ + missingParts: [1], + completedPartsCount: 0, + }); + + const xhr = FakeXMLHttpRequest.instances[0]; + expect(xhr.aborted).toBe(false); + + // Teardown must reach the inner per-part closure, not just the outer subscription. + subscription.unsubscribe(); + + expect(xhr.aborted).toBe(true); + }); + + // ─── payload tolerance ──────────────────────────────────────────────────── + + it("tolerates null payloads from the list and existing-files endpoints", async () => { + const listPending = firstValueFrom(service.listMultipartUploads(DATASET_FILE_RESOURCE_ENDPOINT, "o@e.com", "ds")); + http.expectOne(isType(DATASET_FILE_RESOURCE_ENDPOINT, "list")).flush(null); + expect(await listPending).toEqual([]); + + const existingPending = firstValueFrom(service.findExistingUploadFiles(DATASET_FILE_RESOURCE_ENDPOINT, 7, [])); + http.expectOne(`${API}/dataset/7/existing-upload-files`).flush(null); + expect(await existingPending).toEqual([]); + }); + + it("percent-encodes the file path on both the init params and the part URL", async () => { + vi.stubGlobal("XMLHttpRequest", FakeXMLHttpRequest); + const nested = "folder sub/a+b.csv"; + const file = new File([new Uint8Array(4)], "a.csv"); + const subscription = service + .multipartUpload(DATASET_FILE_RESOURCE_ENDPOINT, "o@e.com", "ds", nested, file, 4, 1, false) + .subscribe(); + + const init = http.expectOne(isType(DATASET_FILE_RESOURCE_ENDPOINT, "init")); + // HttpParams decodes on read, so this asserts the pre-encoding the backend expects. + expect(init.request.params.get("filePath")).toBe(encodeURIComponent(nested)); + init.flush({ missingParts: [1], completedPartsCount: 0 }); + + // The hand-built part URL encodes once, so reading it back yields the raw path. + expect(FakeXMLHttpRequest.instances[0].params().get("filePath")).toBe(nested); + subscription.unsubscribe(); + }); +}); diff --git a/frontend/src/app/dashboard/service/user/file-resource/multipart-upload.service.ts b/frontend/src/app/dashboard/service/user/file-resource/multipart-upload.service.ts new file mode 100644 index 00000000000..cfae34b82e7 --- /dev/null +++ b/frontend/src/app/dashboard/service/user/file-resource/multipart-upload.service.ts @@ -0,0 +1,371 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Injectable } from "@angular/core"; +import { HttpClient, HttpParams } from "@angular/common/http"; +import { catchError, map, mergeMap, switchMap, tap, toArray } from "rxjs/operators"; +import { from, Observable, throwError } from "rxjs"; +import { AppSettings } from "../../../../common/app-setting"; +import { AuthService } from "src/app/common/service/user/auth.service"; +import { FileResourceEndpoint } from "./file-resource-endpoint"; + +export interface MultipartUploadProgress { + filePath: string; + percentage: number; + status: "initializing" | "uploading" | "finished" | "aborted" | "failed"; + uploadSpeed?: number; // bytes per second + estimatedTimeRemaining?: number; // seconds + totalTime?: number; // total seconds taken +} + +/** + * The multipart upload engine, shared by every versioned resource kind. Addressing comes from the + * FileResourceEndpoint the caller supplies; everything else here is resource-agnostic. + */ +@Injectable({ + providedIn: "root", +}) +export class MultipartUploadService { + constructor(private http: HttpClient) {} + + /** + * Handles multipart upload for large files using RxJS, + * with a concurrency limit on how many parts we process in parallel. + * + * Backend flow, where {base} and {nameKey} come from the endpoint: + * POST /{base}/multipart-upload?type=init&ownerEmail=...&{nameKey}=...&filePath=...&numParts=N + * POST /{base}/multipart-upload/part?ownerEmail=...&{nameKey}=...&filePath=...&partNumber= (body: raw chunk) + * POST /{base}/multipart-upload?type=finish&ownerEmail=...&{nameKey}=...&filePath=... + * POST /{base}/multipart-upload?type=abort&ownerEmail=...&{nameKey}=...&filePath=... + */ + public multipartUpload( + endpoint: FileResourceEndpoint, + ownerEmail: string, + resourceName: string, + filePath: string, + file: File, + partSize: number, + concurrencyLimit: number, + restart: boolean + ): Observable { + const partCount = Math.ceil(file.size / partSize); + + return new Observable(observer => { + // Track upload progress (bytes) for each part independently + const partProgress = new Map(); + + let baselineUploaded = 0; + // Progress tracking state + let startTime: number | null = null; + const speedSamples: number[] = []; + let lastETA = 0; + let lastUpdateTime = 0; + + const lastStats = { + uploadSpeed: 0, + estimatedTimeRemaining: 0, + totalTime: 0, + }; + + const getTotalTime = () => (startTime ? (Date.now() - startTime) / 1000 : 0); + + // Calculate stats with smoothing and simple throttling (~1s) + const calculateStats = (totalUploaded: number) => { + if (startTime === null) { + startTime = Date.now(); + } + + const now = Date.now(); + const elapsed = getTotalTime(); + + const shouldUpdate = now - lastUpdateTime >= 1000; + if (!shouldUpdate) { + // keep totalTime fresh even when throttled + lastStats.totalTime = elapsed; + return lastStats; + } + lastUpdateTime = now; + + const sessionUploaded = Math.max(0, totalUploaded - baselineUploaded); + const currentSpeed = elapsed > 0 ? sessionUploaded / elapsed : 0; + speedSamples.push(currentSpeed); + if (speedSamples.length > 5) { + speedSamples.shift(); + } + const avgSpeed = speedSamples.length > 0 ? speedSamples.reduce((a, b) => a + b, 0) / speedSamples.length : 0; + + const remaining = file.size - totalUploaded; + let eta = avgSpeed > 0 ? remaining / avgSpeed : 0; + eta = Math.min(eta, 24 * 60 * 60); // cap ETA at 24h + + if (lastETA > 0 && eta > 0) { + const maxChange = lastETA * 0.3; + const diff = Math.abs(eta - lastETA); + if (diff > maxChange) { + eta = lastETA + (eta > lastETA ? maxChange : -maxChange); + } + } + lastETA = eta; + + const percentComplete = (totalUploaded / file.size) * 100; + if (percentComplete > 95) { + eta = Math.min(eta, 10); + } + + lastStats.uploadSpeed = avgSpeed; + lastStats.estimatedTimeRemaining = Math.max(0, Math.round(eta)); + lastStats.totalTime = elapsed; + + return lastStats; + }; + + // 1. INIT: ask backend to create a LakeFS multipart upload session + const initParams = new HttpParams() + .set("type", "init") + .set("ownerEmail", ownerEmail) + .set(endpoint.nameParamKey, resourceName) + .set("filePath", encodeURIComponent(filePath)) + .set("fileSizeBytes", file.size.toString()) + .set("partSizeBytes", partSize.toString()) + .set("restart", restart); + + const init$ = this.http.post<{ missingParts: number[]; completedPartsCount: number }>( + `${AppSettings.getApiEndpoint()}/${endpoint.baseUrl}/multipart-upload`, + {}, + { params: initParams } + ); + + const subscription = init$ + .pipe( + switchMap(initResp => { + const missingParts = (initResp?.missingParts ?? []).slice(); + const completedPartsCount = initResp?.completedPartsCount ?? 0; + + const missingBytes = missingParts.reduce((sum, partNumber) => { + const start = (partNumber - 1) * partSize; + const end = Math.min(start + partSize, file.size); + return sum + (end - start); + }, 0); + + baselineUploaded = file.size - missingBytes; + const baselinePct = partCount > 0 ? Math.round((completedPartsCount / partCount) * 100) : 0; + + observer.next({ + filePath, + percentage: baselinePct, + status: "initializing", + uploadSpeed: 0, + estimatedTimeRemaining: 0, + totalTime: 0, + }); + // 2. Upload each part to /multipart-upload/part using XMLHttpRequest + return from(missingParts).pipe( + mergeMap(partNumber => { + const start = (partNumber - 1) * partSize; + const end = Math.min(start + partSize, file.size); + const chunk = file.slice(start, end); + + return new Observable(partObserver => { + const xhr = new XMLHttpRequest(); + + xhr.upload.addEventListener("progress", event => { + if (event.lengthComputable) { + partProgress.set(partNumber, event.loaded); + + let totalUploaded = baselineUploaded; // CHANGED + partProgress.forEach(bytes => { + totalUploaded += bytes; + }); + + const percentage = Math.round((totalUploaded / file.size) * 100); + const stats = calculateStats(totalUploaded); + + observer.next({ + filePath, + percentage: Math.min(percentage, 99), + status: "uploading", + ...stats, + }); + } + }); + + xhr.addEventListener("load", () => { + if (xhr.status === 200 || xhr.status === 204) { + // Mark part as fully uploaded + partProgress.set(partNumber, chunk.size); + + let totalUploaded = baselineUploaded; + partProgress.forEach(bytes => { + totalUploaded += bytes; + }); + + // Force stats recompute on completion + lastUpdateTime = 0; + const percentage = Math.round((totalUploaded / file.size) * 100); + const stats = calculateStats(totalUploaded); + + observer.next({ + filePath, + percentage: Math.min(percentage, 99), + status: "uploading", + ...stats, + }); + + partObserver.complete(); + } else { + partObserver.error(new Error(`Failed to upload part ${partNumber} (HTTP ${xhr.status})`)); + } + }); + + xhr.addEventListener("error", () => { + // Remove failed part from progress + partProgress.delete(partNumber); + partObserver.error(new Error(`Failed to upload part ${partNumber}`)); + }); + + const partUrl = + `${AppSettings.getApiEndpoint()}/${endpoint.baseUrl}/multipart-upload/part` + + `?ownerEmail=${encodeURIComponent(ownerEmail)}` + + `&${endpoint.nameParamKey}=${encodeURIComponent(resourceName)}` + + `&filePath=${encodeURIComponent(filePath)}` + + `&partNumber=${partNumber}`; + + xhr.open("POST", partUrl); + xhr.setRequestHeader("Content-Type", "application/octet-stream"); + const token = AuthService.getAccessToken(); + if (token) { + xhr.setRequestHeader("Authorization", `Bearer ${token}`); + } + xhr.send(chunk); + return () => { + try { + xhr.abort(); + } catch {} + }; + }); + }, concurrencyLimit), + toArray(), // wait for all parts + // 3. FINISH: notify backend that all parts are done + switchMap(() => { + const finishParams = new HttpParams() + .set("type", "finish") + .set("ownerEmail", ownerEmail) + .set(endpoint.nameParamKey, resourceName) + .set("filePath", encodeURIComponent(filePath)); + + return this.http.post( + `${AppSettings.getApiEndpoint()}/${endpoint.baseUrl}/multipart-upload`, + {}, + { params: finishParams } + ); + }), + tap(() => { + const totalTime = getTotalTime(); + observer.next({ + filePath, + percentage: 100, + status: "finished", + uploadSpeed: 0, + estimatedTimeRemaining: 0, + totalTime, + }); + observer.complete(); + }), + catchError((error: unknown) => { + // On error, compute best-effort percentage from bytes we've seen + let totalUploaded = baselineUploaded; + partProgress.forEach(bytes => { + totalUploaded += bytes; + }); + const percentage = file.size > 0 ? Math.round((totalUploaded / file.size) * 100) : 0; + + observer.next({ + filePath, + percentage, + status: "failed", + uploadSpeed: 0, + estimatedTimeRemaining: 0, + totalTime: getTotalTime(), + }); + + return throwError(() => error); + }) + ); + }) + ) + .subscribe({ + error: (err: unknown) => observer.error(err), + }); + + return () => subscription.unsubscribe(); + }); + } + + public listMultipartUploads( + endpoint: FileResourceEndpoint, + ownerEmail: string, + resourceName: string + ): Observable { + const params = new HttpParams() + .set("type", "list") + .set("ownerEmail", ownerEmail) + .set(endpoint.nameParamKey, resourceName); + + return this.http + .post<{ + filePaths: string[]; + }>(`${AppSettings.getApiEndpoint()}/${endpoint.baseUrl}/multipart-upload`, {}, { params }) + .pipe(map(res => res?.filePaths ?? [])); + } + + public findExistingUploadFiles( + endpoint: FileResourceEndpoint, + resourceId: number, + files: { path: string; sizeBytes: number }[] + ): Observable { + return this.http + .post<{ filePaths: string[] }>( + `${AppSettings.getApiEndpoint()}/${endpoint.baseUrl}/${resourceId}/existing-upload-files`, + { + files, + } + ) + .pipe(map(res => res?.filePaths ?? [])); + } + + public finalizeMultipartUpload( + endpoint: FileResourceEndpoint, + ownerEmail: string, + resourceName: string, + filePath: string, + isAbort: boolean + ): Observable { + const params = new HttpParams() + .set("type", isAbort ? "abort" : "finish") + .set("ownerEmail", ownerEmail) + .set(endpoint.nameParamKey, resourceName) + .set("filePath", encodeURIComponent(filePath)); + + return this.http.post( + `${AppSettings.getApiEndpoint()}/${endpoint.baseUrl}/multipart-upload`, + {}, + { params } + ); + } +} diff --git a/frontend/src/app/dashboard/service/user/file-resource/staged-file.service.spec.ts b/frontend/src/app/dashboard/service/user/file-resource/staged-file.service.spec.ts new file mode 100644 index 00000000000..d7ac2d2d8f3 --- /dev/null +++ b/frontend/src/app/dashboard/service/user/file-resource/staged-file.service.spec.ts @@ -0,0 +1,91 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { TestBed } from "@angular/core/testing"; +import { HttpClientTestingModule, HttpTestingController } from "@angular/common/http/testing"; +import { firstValueFrom } from "rxjs"; +import { StagedFileService } from "./staged-file.service"; +import { + DATASET_FILE_RESOURCE_ENDPOINT, + FileResourceEndpoint, + MODEL_FILE_RESOURCE_ENDPOINT, +} from "./file-resource-endpoint"; +import { DatasetStagedObject } from "../../../../common/type/dataset-staged-object"; +import { commonTestProviders } from "../../../../common/testing/test-utils"; + +const API = "api"; + +describe("StagedFileService", () => { + let service: StagedFileService; + let http: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [StagedFileService, ...commonTestProviders], + }); + service = TestBed.inject(StagedFileService); + http = TestBed.inject(HttpTestingController); + }); + + afterEach(() => http.verify()); + + const endpoints: Array<[string, FileResourceEndpoint]> = [ + ["dataset", DATASET_FILE_RESOURCE_ENDPOINT], + ["model", MODEL_FILE_RESOURCE_ENDPOINT], + ]; + + for (const [name, endpoint] of endpoints) { + it(`lists the staged objects of a ${name}`, async () => { + const staged: DatasetStagedObject[] = [{ path: "a.txt", pathType: "file", diffType: "added", sizeBytes: 1 }]; + const pending = firstValueFrom(service.getDiff(endpoint, 7)); + + http.expectOne(`${API}/${endpoint.baseUrl}/7/diff`).flush(staged); + + expect(await pending).toEqual(staged); + }); + + it(`reverts one staged ${name} path`, () => { + service.resetFileDiff(endpoint, 7, "dir/a b.txt").subscribe(); + + // The path is encoded into the query param, so a space or slash survives the round trip. + const req = http.expectOne( + `${API}/${endpoint.baseUrl}/7/diff?filePath=${encodeURIComponent(encodeURIComponent("dir/a b.txt"))}` + ); + expect(req.request.method).toBe("PUT"); + req.flush({}); + }); + + it(`stages a deletion of a committed ${name} file`, () => { + service.deleteFile(endpoint, 7, "dir/a.txt").subscribe(); + + const req = http.expectOne( + `${API}/${endpoint.baseUrl}/7/file?filePath=${encodeURIComponent(encodeURIComponent("dir/a.txt"))}` + ); + expect(req.request.method).toBe("DELETE"); + req.flush({}); + }); + } + + it("surfaces a server error rather than swallowing it", async () => { + const outcome = firstValueFrom(service.getDiff(MODEL_FILE_RESOURCE_ENDPOINT, 7)).catch((err: unknown) => err); + http.expectOne(`${API}/model/7/diff`).flush({ message: "nope" }, { status: 500, statusText: "Server Error" }); + expect(await outcome).toMatchObject({ status: 500 }); + }); +}); diff --git a/frontend/src/app/dashboard/service/user/file-resource/staged-file.service.ts b/frontend/src/app/dashboard/service/user/file-resource/staged-file.service.ts new file mode 100644 index 00000000000..47ea46c878b --- /dev/null +++ b/frontend/src/app/dashboard/service/user/file-resource/staged-file.service.ts @@ -0,0 +1,62 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Injectable } from "@angular/core"; +import { HttpClient, HttpParams } from "@angular/common/http"; +import { Observable } from "rxjs"; +import { AppSettings } from "../../../../common/app-setting"; +import { DatasetStagedObject } from "../../../../common/type/dataset-staged-object"; +import { FileResourceEndpoint } from "./file-resource-endpoint"; + +/** + * The uncommitted-change operations every versioned resource shares: list what is staged, revert one + * staged path, and stage a deletion. Addressing comes from the caller's FileResourceEndpoint. + */ +@Injectable({ + providedIn: "root", +}) +export class StagedFileService { + constructor(private http: HttpClient) {} + + /** Uncommitted changes, i.e. what a new version would consume. */ + public getDiff(endpoint: FileResourceEndpoint, resourceId: number): Observable { + return this.http.get( + `${AppSettings.getApiEndpoint()}/${endpoint.baseUrl}/${resourceId}/diff` + ); + } + + public resetFileDiff(endpoint: FileResourceEndpoint, resourceId: number, filePath: string): Observable { + const params = new HttpParams().set("filePath", encodeURIComponent(filePath)); + + return this.http.put( + `${AppSettings.getApiEndpoint()}/${endpoint.baseUrl}/${resourceId}/diff`, + {}, + { params } + ); + } + + /** Stages a deletion of an already-committed file; the next version applies it. */ + public deleteFile(endpoint: FileResourceEndpoint, resourceId: number, filePath: string): Observable { + const params = new HttpParams().set("filePath", encodeURIComponent(filePath)); + + return this.http.delete(`${AppSettings.getApiEndpoint()}/${endpoint.baseUrl}/${resourceId}/file`, { + params, + }); + } +} diff --git a/frontend/src/app/dashboard/service/user/file-resource/testing/fake-xml-http-request.ts b/frontend/src/app/dashboard/service/user/file-resource/testing/fake-xml-http-request.ts new file mode 100644 index 00000000000..9d7edb5a861 --- /dev/null +++ b/frontend/src/app/dashboard/service/user/file-resource/testing/fake-xml-http-request.ts @@ -0,0 +1,90 @@ +/** + * 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. + */ +/** + * Stand-in for XMLHttpRequest, which the multipart engine uses directly for per-part upload + * progress. Install with `vi.stubGlobal("XMLHttpRequest", FakeXMLHttpRequest)`. + */ +export class FakeXMLHttpRequest { + static instances: FakeXMLHttpRequest[] = []; + + /** Capturing upload target so tests can drive `upload.progress` events. */ + readonly upload = { + listeners: new Map(), + addEventListener(type: string, listener: EventListener): void { + this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]); + }, + }; + status = 0; + url = ""; + aborted = false; + readonly requestHeaders = new Map(); + private listeners = new Map(); + + static reset(): void { + FakeXMLHttpRequest.instances = []; + } + + open(_method: string, url: string): void { + this.url = url; + } + + setRequestHeader(name: string, value: string): void { + this.requestHeaders.set(name, value); + } + + send(): void { + FakeXMLHttpRequest.instances.push(this); + } + + abort(): void { + this.aborted = true; + } + + addEventListener(type: string, listener: EventListener): void { + this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]); + } + + /** Drives the `upload.progress` listener registered by the engine. */ + emitProgress(loaded: number, lengthComputable = true): void { + const event = { lengthComputable, loaded } as unknown as Event; + for (const listener of this.upload.listeners.get("progress") ?? []) { + listener(event); + } + } + + respond(status: number): void { + this.status = status; + this.emit("load"); + } + + fail(): void { + this.emit("error"); + } + + /** Query params of the URL this request was opened with. */ + params(): URLSearchParams { + return new URL(this.url, "http://localhost").searchParams; + } + + private emit(type: string): void { + for (const listener of this.listeners.get(type) ?? []) { + listener(new Event(type)); + } + } +} 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 903455a702a..418052af201 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 @@ -226,6 +226,37 @@ describe("ModelService", () => { expect(await pending).toEqual({ url: null }); }); + it("posts a version name as text/plain and folds the file nodes into the version", async () => { + const pending = firstValueFrom(service.createModelVersion(7, "v2")); + const req = http.expectOne(`${API}/model/7/version/create`); + + expect(req.request.method).toBe("POST"); + expect(req.request.body).toBe("v2"); + expect(req.request.headers.get("Content-Type")).toBe("text/plain"); + + const fileNodes = [{ name: "model.pt", type: "file", parentDir: "/model/a/m/v2", size: 4 }]; + req.flush({ modelVersion: { mvid: 2, mid: 7, creatorUid: 1, name: "v2" }, fileNodes }); + + expect(await pending).toMatchObject({ mvid: 2, name: "v2", fileNodes }); + }); + + it("lets the backend name the version when none is given", () => { + service.createModelVersion(7, "").subscribe(); + expect(http.expectOne(`${API}/model/7/version/create`).request.body).toBe(""); + }); + + it("updates the framework and the format through their own endpoints", () => { + service.updateModelFramework(7, "onnx").subscribe(); + const framework = http.expectOne(`${API}/model/update/framework`); + expect(framework.request.body).toEqual({ mid: 7, framework: "onnx" }); + framework.flush({}); + + service.updateModelFormat(7, "safetensors").subscribe(); + const format = http.expectOne(`${API}/model/update/format`); + expect(format.request.body).toEqual({ mid: 7, format: "safetensors" }); + format.flush({}); + }); + 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 c5d26b5ecd4..b4095139002 100644 --- a/frontend/src/app/dashboard/service/user/model/model.service.ts +++ b/frontend/src/app/dashboard/service/user/model/model.service.ts @@ -20,7 +20,7 @@ import { Injectable } from "@angular/core"; import { HttpClient, HttpParams } from "@angular/common/http"; import { Observable } from "rxjs"; -import { switchMap } from "rxjs/operators"; +import { map, switchMap } from "rxjs/operators"; import { AppSettings } from "../../../../common/app-setting"; import { Model, ModelVersion } from "../../../../common/type/model"; import { DashboardModel } from "../../../type/dashboard-model.interface"; @@ -31,6 +31,8 @@ export const MODEL_CREATE_URL = MODEL_BASE_URL + "/create"; export const MODEL_UPDATE_BASE_URL = MODEL_BASE_URL + "/update"; 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_UPDATE_FRAMEWORK_URL = MODEL_UPDATE_BASE_URL + "/framework"; +export const MODEL_UPDATE_FORMAT_URL = MODEL_UPDATE_BASE_URL + "/format"; export const MODEL_LIST_URL = MODEL_BASE_URL + "/list"; export const MODEL_VERSION_BASE_URL = "version"; @@ -109,6 +111,40 @@ export class ModelService { }); } + public updateModelFramework(mid: number, framework: string): Observable { + return this.http.post(`${AppSettings.getApiEndpoint()}/${MODEL_UPDATE_FRAMEWORK_URL}`, { + mid: mid, + framework: framework, + }); + } + + public updateModelFormat(mid: number, format: string): Observable { + return this.http.post(`${AppSettings.getApiEndpoint()}/${MODEL_UPDATE_FORMAT_URL}`, { + mid: mid, + format: format, + }); + } + + /** + * Commits the model's staged changes as a new version. The backend rejects this with a 400 when + * nothing is staged, and names the version itself when `newVersion` is blank. + */ + public createModelVersion(mid: number, newVersion: string): Observable { + return this.http + .post<{ + modelVersion: ModelVersion; + fileNodes: DatasetFileNode[]; + }>(`${AppSettings.getApiEndpoint()}/${MODEL_BASE_URL}/${mid}/${MODEL_VERSION_BASE_URL}/create`, newVersion, { + headers: { "Content-Type": "text/plain" }, + }) + .pipe( + map(response => { + response.modelVersion.fileNodes = response.fileNodes; + return response.modelVersion; + }) + ); + } + /** 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;