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(

Reply via email to