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
+ (click)="saveUploadSettings(group)">
Save
+ (click)="resetUploadSettings(group)">
Reset
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 @@
0">
+ [class.has-list]="stagedObjects.length > 0">
0"
+ *ngIf="stagedObjects.length > 0"
class="staged-object-viewport"
[itemSize]="STAGED_ROW_HEIGHT_PX"
[minBufferPx]="STAGED_LIST_MAX_HEIGHT_PX"
@@ -33,7 +33,7 @@
+ *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:
-
-
+
-
-
-
-
-
- 0"
- [nzHeader]="'Pending: ' + queuedCount + ' file(s)'"
- (nzActiveChange)="onPendingPanelActiveChange($event)">
-
-
-
- {{ fileName }}
-
-
-
-
-
-
-
- 0">
-
- 0"
- [nzHeader]="'Uploading: ' + activeCount + ' file(s)'">
-
-
-
-
-
-
-
- {{ formatSpeed(task.uploadSpeed) }} -
- {{ formatTime(task.totalTime ?? 0) }} elapsed,
- {{ formatTime(task.estimatedTimeRemaining ?? 0) }} left
-
-
-
- Upload time: {{ formatTime(task.totalTime ?? 0) }}
-
-
-
-
-
-
- 0">
-
-
-
-
-
-
-
-
-
-
-
-
- Version:
-
-
-
-
- Submit
-
-
-
-
-
+ [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