This is an automated email from the ASF dual-hosted git repository.
linxinyuan pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/texera.git
The following commit(s) were added to refs/heads/master by this push:
new 594f9ef660 feat: enable file upload speed and time display (#3662)
594f9ef660 is described below
commit 594f9ef6603bf5d0b073cb69c5af9bf079b360dc
Author: Xuan Gu <[email protected]>
AuthorDate: Tue Aug 19 21:05:32 2025 -0700
feat: enable file upload speed and time display (#3662)
### **Purpose**
This PR enhances the upload experience by displaying real-time speed,
elapsed time, and estimated time remaining (ETA) in a compact tag, so
users can understand progress at a glance and reference the final upload
duratione via tooltip after completion.
### **Changes**
- dataset.service.ts: add uploadSpeed, totalTime, estimatedTimeRemaining
to MultipartUploadProgress; apply smoothing for speed/ETA; cap ETA at
24h; add basic bounds checks.
- Speed Calculation: The upload speed is calculated using a moving
average of the last 5 speed samples, where each sample represents the
overall average speed (total bytes / total time).
- ETA Calculation: The estimated time remaining uses the average speed
to calculate remaining time, with a 30% change rate limiter to prevent
sudden jumps. When the upload reaches 95% completion, the ETA is capped
at 10 seconds for better user experience near completion.
- Update Throttling: Progress updates are throttled to once per second
- dataset-detail.component.ts/html/scss: render compact status tag
\<speed> - \<time> elapsed, \<time> left; apply fixed/min width to
time/speed to reduce jitter; update the task row auto-hide duration from
3 seconds to 5 seconds.
- user-dataset-staged-objects-list.component.ts/html: show post-upload
duration in the file tooltip (full path + upload time)
- gui/src/app/common/util/format.util.ts: add formatting utilities for
time and speed.
### **Demonstration**
**Single File:**
https://github.com/user-attachments/assets/5df33ea8-a792-4815-8c21-b987c4913f25
**Speed-limited (throttled):**
https://github.com/user-attachments/assets/db2cd75d-0efe-427f-9f56-861d7bfb26e8
**Multiple Files:**
https://github.com/user-attachments/assets/b474c0b9-5bcc-4334-8f73-70ca93f2187b
---------
Signed-off-by: Xuan Gu <[email protected]>
Co-authored-by: Xinyuan Lin <[email protected]>
---
core/gui/src/app/common/util/format.util.ts | 56 +++++++++++++++++
.../dataset-detail.component.html | 23 ++++++-
.../dataset-detail.component.scss | 21 +++++++
.../dataset-detail.component.ts | 12 +++-
...user-dataset-staged-objects-list.component.html | 8 ++-
.../user-dataset-staged-objects-list.component.ts | 10 +++
.../service/user/dataset/dataset.service.ts | 71 ++++++++++++++++++++++
7 files changed, 192 insertions(+), 9 deletions(-)
diff --git a/core/gui/src/app/common/util/format.util.ts
b/core/gui/src/app/common/util/format.util.ts
new file mode 100644
index 0000000000..2ac5f22979
--- /dev/null
+++ b/core/gui/src/app/common/util/format.util.ts
@@ -0,0 +1,56 @@
+/**
+ * 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.
+ */
+
+const BYTES_PER_UNIT = 1024;
+
+/**
+ * Format upload speed
+ */
+export const formatSpeed = (bytesPerSecond = 0) => {
+ if (bytesPerSecond <= 0) return "0.0 MB/s";
+
+ const mbps = bytesPerSecond / (BYTES_PER_UNIT * BYTES_PER_UNIT);
+ return `${mbps.toFixed(1)} MB/s`;
+};
+
+/**
+ * Format time duration
+ */
+export const formatTime = (seconds?: number): string => {
+ if (!seconds || seconds <= 0) return "1s";
+ const s = Math.max(1, Math.round(seconds));
+
+ // Under 1 minute: show seconds only
+ if (s < 60) {
+ return `${s}s`;
+ }
+
+ // Under 1 hour: show minutes (and seconds if not zero)
+ if (s < 3600) {
+ const m = Math.floor(s / 60);
+ const sec = s % 60;
+ return sec === 0 ? `${m}m` : `${m}m${sec.toString().padStart(2, "0")}s`;
+ }
+
+ // 1 hour+: show hours (and minutes if not zero)
+ const h = Math.floor(s / 3600);
+ const min = Math.floor((s % 3600) / 60);
+
+ return min === 0 ? `${h}h` : `${h}h${min}m`;
+};
diff --git
a/core/gui/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.html
b/core/gui/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.html
index 348f5daf42..e0bee86403 100644
---
a/core/gui/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.html
+++
b/core/gui/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.html
@@ -279,12 +279,29 @@
nzTheme="outline"></i>
</button>
</div>
- <nz-progress
- [nzPercent]="task.percentage"
- [nzStatus]="getUploadStatus(task.status)"></nz-progress>
+ <div
+ class="upload-stats"
+ *ngIf="task.status !== 'initializing'">
+ <nz-progress
+ [nzPercent]="task.percentage"
+ [nzStatus]="getUploadStatus(task.status)"></nz-progress>
+ <nz-tag
+ *ngIf="task.status === 'uploading'"
+ [nzColor]="'blue'">
+ <span class="fixed-width-speed">{{
formatSpeed(task.uploadSpeed) }}</span> -
+ <span class="fixed-width-time">{{ formatTime(task.totalTime
?? 0) }}</span> elapsed,
+ <span class="fixed-width-time">{{
formatTime(task.estimatedTimeRemaining ?? 0) }} left</span>
+ </nz-tag>
+
+ <nz-tag *ngIf="(task.status === 'finished' || task.status ===
'aborted')">
+ Upload time: {{ formatTime(task.totalTime ?? 0) }}
+ </nz-tag>
+ </div>
</div>
</div>
+
<texera-dataset-staged-objects-list
+ [uploadTimeMap]="uploadTimeMap"
[did]="did"
[userMakeChangesEvent]="userMakeChanges"
(stagedObjectsChanged)="onStagedObjectsUpdated($event)"></texera-dataset-staged-objects-list>
diff --git
a/core/gui/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.scss
b/core/gui/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.scss
index 7e1ba30bdf..68087749ae 100644
---
a/core/gui/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.scss
+++
b/core/gui/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.scss
@@ -224,3 +224,24 @@ nz-select {
font-style: italic;
}
}
+
+.upload-stats {
+ font-size: 13px;
+ margin-bottom: 20px;
+}
+
+: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;
+}
diff --git
a/core/gui/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.ts
b/core/gui/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.ts
index 2d7d73c017..9849bb2306 100644
---
a/core/gui/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.ts
+++
b/core/gui/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.ts
@@ -42,6 +42,7 @@ import { UserDatasetVersionCreatorComponent } from
"./user-dataset-version-creat
import { AdminSettingsService } from
"../../../../service/admin/settings/admin-settings.service";
import { HttpErrorResponse } from "@angular/common/http";
import { Subscription } from "rxjs";
+import { formatSpeed, formatTime } from "src/app/common/util/format.util";
export const THROTTLE_TIME_MS = 1000;
@@ -85,6 +86,7 @@ export class DatasetDetailComponent implements OnInit {
chunkSizeMB: number = 50;
maxConcurrentChunks: number = 10;
private uploadSubscriptions = new Map<string, Subscription>();
+ uploadTimeMap = new Map<string, number>();
versionName: string = "";
isCreatingVersion: boolean = false;
@@ -409,7 +411,9 @@ export class DatasetDetailComponent implements OnInit {
};
// Auto‑hide when upload is truly finished
- if (progress.status === "finished") {
+ if (progress.status === "finished" && progress.totalTime) {
+ const filename = file.name.split("/").pop() || file.name;
+ this.uploadTimeMap.set(filename, progress.totalTime);
this.userMakeChanges.emit();
this.scheduleHide(taskIndex);
}
@@ -443,7 +447,7 @@ export class DatasetDetailComponent implements OnInit {
}
}
- // Hide a task row after 3s (stores timer to clear on destroy) and clean up
its subscription
+ // Hide a task row after 5s (stores timer to clear on destroy) and clean up
its subscription
private scheduleHide(idx: number) {
if (idx === -1) {
return;
@@ -452,7 +456,7 @@ export class DatasetDetailComponent implements OnInit {
this.uploadSubscriptions.delete(key);
const handle = window.setTimeout(() => {
this.uploadTasks = this.uploadTasks.filter(t => t.filePath !== key);
- }, 3000);
+ }, 5000);
this.autoHideTimers.push(handle);
}
@@ -515,6 +519,8 @@ export class DatasetDetailComponent implements OnInit {
}
return count.toString();
}
+ formatTime = formatTime;
+ formatSpeed = formatSpeed;
toggleLike(): void {
const userId = this.currentUid;
diff --git
a/core/gui/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-staged-objects-list/user-dataset-staged-objects-list.component.html
b/core/gui/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-staged-objects-list/user-dataset-staged-objects-list.component.html
index 80b40378ac..5b1dece350 100644
---
a/core/gui/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-staged-objects-list/user-dataset-staged-objects-list.component.html
+++
b/core/gui/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-staged-objects-list/user-dataset-staged-objects-list.component.html
@@ -28,12 +28,14 @@
</span>
<span
class="truncate-file-path"
- [attr.data-fullpath]="obj.path"
nz-tooltip
- [nzTooltipTitle]="obj.path">
+ [nzTooltipTitle]="fileTooltipTpl">
{{ obj.path }}
</span>
-
+ <ng-template #fileTooltipTpl>
+ <div>{{ obj.path }}</div>
+ <div *ngIf="getFileUploadTime(obj.path) as uploadTime">Upload time: {{
formatTime(uploadTime) }}</div>
+ </ng-template>
<!-- Small delete button with tooltip -->
<button
nz-button
diff --git
a/core/gui/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-staged-objects-list/user-dataset-staged-objects-list.component.ts
b/core/gui/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-staged-objects-list/user-dataset-staged-objects-list.component.ts
index 62e8150b8f..7856ff47ba 100644
---
a/core/gui/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-staged-objects-list/user-dataset-staged-objects-list.component.ts
+++
b/core/gui/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-staged-objects-list/user-dataset-staged-objects-list.component.ts
@@ -22,6 +22,7 @@ import { DatasetStagedObject } from
"../../../../../../common/type/dataset-stage
import { DatasetService } from
"../../../../../service/user/dataset/dataset.service";
import { NotificationService } from
"../../../../../../common/service/notification/notification.service";
import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy";
+import { formatTime } from "src/app/common/util/format.util";
@UntilDestroy()
@Component({
@@ -38,10 +39,12 @@ export class UserDatasetStagedObjectsListComponent
implements OnInit {
});
}
}
+ @Input() uploadTimeMap?: Map<string, number>;
@Output() stagedObjectsChanged = new EventEmitter<DatasetStagedObject[]>();
// Emits staged objects list
datasetStagedObjects: DatasetStagedObject[] = [];
+ formatTime = formatTime;
constructor(
private datasetService: DatasetService,
@@ -81,4 +84,11 @@ export class UserDatasetStagedObjectsListComponent
implements OnInit {
});
}
}
+
+ getFileUploadTime(filePath: string): number | null {
+ if (!this.uploadTimeMap) return null;
+
+ const filename = filePath.split("/").pop() || filePath;
+ return this.uploadTimeMap.get(filename) || null;
+ }
}
diff --git a/core/gui/src/app/dashboard/service/user/dataset/dataset.service.ts
b/core/gui/src/app/dashboard/service/user/dataset/dataset.service.ts
index 329a6e5947..c50c08ea0d 100644
--- a/core/gui/src/app/dashboard/service/user/dataset/dataset.service.ts
+++ b/core/gui/src/app/dashboard/service/user/dataset/dataset.service.ts
@@ -53,6 +53,9 @@ export interface MultipartUploadProgress {
status: "initializing" | "uploading" | "finished" | "aborted";
uploadId: string;
physicalAddress: string;
+ uploadSpeed?: number; // bytes per second
+ estimatedTimeRemaining?: number; // seconds
+ totalTime?: number; // total seconds taken
}
@Injectable({
@@ -152,6 +155,58 @@ export class DatasetService {
// Track upload progress for each part independently
const partProgress = new Map<number, number>();
+ // Progress tracking state
+ const startTime = Date.now();
+ const speedSamples: number[] = [];
+ let lastETA = 0;
+ let lastUpdateTime = 0;
+
+ // Calculate stats with smoothing
+ const calculateStats = (totalUploaded: number) => {
+ const now = Date.now();
+ const elapsed = (now - startTime) / 1000;
+
+ // Throttle updates to every 1s
+ const shouldUpdate = now - lastUpdateTime >= 1000;
+ if (!shouldUpdate) {
+ return null;
+ }
+ lastUpdateTime = now;
+
+ // Calculate speed with moving average
+ const currentSpeed = elapsed > 0 ? totalUploaded / elapsed : 0;
+ speedSamples.push(currentSpeed);
+ if (speedSamples.length > 5) speedSamples.shift();
+ const avgSpeed = speedSamples.reduce((a, b) => a + b, 0) /
speedSamples.length;
+
+ // Calculate smooth ETA
+ const remaining = file.size - totalUploaded;
+ let eta = avgSpeed > 0 ? remaining / avgSpeed : 0;
+ eta = Math.min(eta, 24 * 60 * 60); // cap ETA at 24h, 86400 sec
+
+ // Smooth ETA changes (limit to 30% change)
+ 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;
+
+ // Near completion optimization
+ const percentComplete = (totalUploaded / file.size) * 100;
+ if (percentComplete > 95) {
+ eta = Math.min(eta, 10);
+ }
+
+ return {
+ uploadSpeed: avgSpeed,
+ estimatedTimeRemaining: Math.max(0, Math.round(eta)),
+ totalTime: elapsed,
+ };
+ };
+
const subscription = this.initiateMultipartUpload(datasetName, filePath,
partCount)
.pipe(
switchMap(initiateResponse => {
@@ -166,6 +221,9 @@ export class DatasetService {
status: "initializing",
uploadId: uploadId,
physicalAddress: physicalAddress,
+ uploadSpeed: 0,
+ estimatedTimeRemaining: 0,
+ totalTime: 0,
});
// Keep track of all uploaded parts
@@ -193,6 +251,7 @@ export class DatasetService {
let totalUploaded = 0;
partProgress.forEach(bytes => (totalUploaded += bytes));
const percentage = Math.round((totalUploaded /
file.size) * 100);
+ const stats = calculateStats(totalUploaded);
observer.next({
filePath,
@@ -200,6 +259,7 @@ export class DatasetService {
status: "uploading",
uploadId,
physicalAddress,
+ ...stats,
});
}
});
@@ -220,6 +280,8 @@ export class DatasetService {
let totalUploaded = 0;
partProgress.forEach(bytes => (totalUploaded += bytes));
const percentage = Math.round((totalUploaded /
file.size) * 100);
+ lastUpdateTime = 0;
+ const stats = calculateStats(totalUploaded);
observer.next({
filePath,
@@ -227,6 +289,7 @@ export class DatasetService {
status: "uploading",
uploadId,
physicalAddress,
+ ...stats,
});
partObserver.complete();
} else {
@@ -252,23 +315,31 @@ export class DatasetService {
this.finalizeMultipartUpload(datasetName, filePath, uploadId,
uploadedParts, physicalAddress, false)
),
tap(() => {
+ const finalTotalTime = (Date.now() - startTime) / 1000;
observer.next({
filePath,
percentage: 100,
status: "finished",
uploadId: uploadId,
physicalAddress: physicalAddress,
+ uploadSpeed: 0,
+ estimatedTimeRemaining: 0,
+ totalTime: finalTotalTime,
});
observer.complete();
}),
catchError((error: unknown) => {
// If an error occurred, abort the upload
+ const currentTotalTime = (Date.now() - startTime) / 1000;
observer.next({
filePath,
percentage: Math.round((uploadedParts.length / partCount) *
100),
status: "aborted",
uploadId: uploadId,
physicalAddress: physicalAddress,
+ uploadSpeed: 0,
+ estimatedTimeRemaining: 0,
+ totalTime: currentTotalTime,
});
return this.finalizeMultipartUpload(