No uploads in progress
diff --git a/client/src/components/Panels/Upload/UploadProgressIndicator.vue b/client/src/components/Panels/Upload/UploadProgressIndicator.vue
index fdea00a93ac..fcb4e0b5e0d 100644
--- a/client/src/components/Panels/Upload/UploadProgressIndicator.vue
+++ b/client/src/components/Panels/Upload/UploadProgressIndicator.vue
@@ -11,11 +11,13 @@ const emit = defineEmits<{
(e: "show-details"): void;
}>();
+const hasActiveUploads = computed(() => uploads.value.some((f) => f.status !== "completed" && f.status !== "error"));
+
const statusIcon = computed(() => {
if (errorCount.value > 0) {
return faTimes;
}
- if (uploadingCount.value > 0) {
+ if (hasActiveUploads.value) {
return faSpinner;
}
return faCheck;
@@ -25,14 +27,14 @@ const statusClass = computed(() => {
if (errorCount.value > 0) {
return "text-danger";
}
- if (uploadingCount.value > 0) {
+ if (hasActiveUploads.value) {
return "text-primary";
}
return "text-success";
});
const statusText = computed(() => {
- if (uploadingCount.value > 0 || uploads.value.some((f) => f.status === "queued")) {
+ if (hasActiveUploads.value) {
return "Uploading";
}
if (uploads.value.length > 0 && completedCount.value === uploads.value.length) {
diff --git a/client/src/components/Panels/Upload/uploadProgressUi.ts b/client/src/components/Panels/Upload/uploadProgressUi.ts
index ab62ffc9211..68d83eff4df 100644
--- a/client/src/components/Panels/Upload/uploadProgressUi.ts
+++ b/client/src/components/Panels/Upload/uploadProgressUi.ts
@@ -1,6 +1,7 @@
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import {
faCheck,
+ faClock,
faCloud,
faExclamationTriangle,
faLayerGroup,
@@ -45,7 +46,7 @@ export interface BatchWithProgress extends CollectionBatchState {
const FILE_PROGRESS_UI: Record
= {
queued: {
- icon: faSpinner,
+ icon: faClock,
textClass: "text-muted",
spin: false,
},
diff --git a/client/src/components/Panels/Upload/uploadState.ts b/client/src/components/Panels/Upload/uploadState.ts
index 5f5200c22ee..28159e2ff29 100644
--- a/client/src/components/Panels/Upload/uploadState.ts
+++ b/client/src/components/Panels/Upload/uploadState.ts
@@ -192,7 +192,7 @@ export function useUploadState() {
upload,
}));
- return [...batchItems, ...standaloneItems].sort((a, b) => a.createdAt - b.createdAt);
+ return [...batchItems, ...standaloneItems].sort((a, b) => b.createdAt - a.createdAt);
});
/**
@@ -309,7 +309,7 @@ export function useUploadState() {
*/
function updateProgress(id: string, progress: number) {
const item = items.value.find((u) => u.id === id);
- if (item) {
+ if (item && item.status !== "completed") {
item.progress = Math.max(0, Math.min(100, Math.round(progress)));
if (item.progress >= 100 && item.status !== "error") {
item.status = "completed";
diff --git a/client/src/composables/upload/useUploadBatchOperations.ts b/client/src/composables/upload/useUploadBatchOperations.ts
index 741bbcf1efc..c0a13c45341 100644
--- a/client/src/composables/upload/useUploadBatchOperations.ts
+++ b/client/src/composables/upload/useUploadBatchOperations.ts
@@ -152,8 +152,9 @@ export function useUploadBatchOperations(options: UploadBatchOperationsOptions =
collectionType: batch.type,
},
{
- progress: (percentage) => {
- ids.forEach((id) => uploadState.updateProgress(id, percentage));
+ uploadIds: ids,
+ perFileProgress: (fileId, percentage) => {
+ uploadState.updateProgress(fileId, percentage);
},
success: () => {
ids.forEach((id) => uploadState.updateProgress(id, 100));
diff --git a/client/src/composables/upload/useUploadSubmission.ts b/client/src/composables/upload/useUploadSubmission.ts
index 510b8ad9c14..f363a4c3651 100644
--- a/client/src/composables/upload/useUploadSubmission.ts
+++ b/client/src/composables/upload/useUploadSubmission.ts
@@ -10,7 +10,6 @@ import {
markTrackedCompleted,
markTrackedError,
splitTrackedUploadsByType,
- updateTrackedProgress,
} from "@/composables/upload/uploadTracking";
import { useUploadBatchOperations } from "@/composables/upload/useUploadBatchOperations";
import { errorMessageAsString } from "@/utils/simple-error";
@@ -103,7 +102,10 @@ export function useUploadSubmission() {
},
progress: (percentage) => {
onProgress?.(percentage);
- updateTrackedProgress(uploadState, apiIds, percentage);
+ },
+ uploadIds: apiIds,
+ perFileProgress: (fileId, percentage) => {
+ uploadState.updateProgress(fileId, percentage);
},
};
diff --git a/client/src/utils/upload.ts b/client/src/utils/upload.ts
index 07bcbb29a46..01efcbb4374 100644
--- a/client/src/utils/upload.ts
+++ b/client/src/utils/upload.ts
@@ -198,10 +198,18 @@ export interface BuildPayloadOptions {
// Configuration Types
// ============================================================================
+/** Per-file progress tracking options */
+interface PerFileProgressOptions {
+ /** Upload item IDs corresponding to files (one per file, for per-file progress tracking) */
+ uploadIds?: string[];
+ /** Callback for per-file progress updates (fileId, percentage) */
+ perFileProgress?: (fileId: string, percentage: number) => void;
+}
+
/**
* Configuration for upload submission.
*/
-export interface UploadSubmitConfig extends FetchDatasetsCallbacks {
+export interface UploadSubmitConfig extends FetchDatasetsCallbacks, PerFileProgressOptions {
/** The upload payload data */
data: UploadDataPayload;
/** Whether this is a composite upload */
@@ -213,7 +221,7 @@ export interface UploadSubmitConfig extends FetchDatasetsCallbacks {
/**
* Configuration for the uploadDatasets function.
*/
-export interface UploadDatasetsConfig extends FetchDatasetsCallbacks, BuildPayloadOptions {
+export interface UploadDatasetsConfig extends FetchDatasetsCallbacks, BuildPayloadOptions, PerFileProgressOptions {
/** Chunk size for TUS uploads in bytes (default: 10MB) */
chunkSize?: number;
}
@@ -833,14 +841,27 @@ function toApiPayload(data: UploadPayload): FetchDataPayload {
/**
* Uploads files via TUS protocol, then submits the complete payload.
+ *
+ * @param data - Upload payload containing files and targets
+ * @param tusEndpoint - TUS upload endpoint URL
+ * @param chunkSize - Chunk size for TUS uploads in bytes
+ * @param callbacks - Standard fetch callbacks (success, error, warning, progress)
+ * @param uploadIds - Optional array of upload item IDs (one per file) for per-file progress tracking
+ * @param perFileProgress - Optional callback for per-file progress updates
*/
async function uploadFilesViaTus(
data: UploadPayload,
tusEndpoint: string,
chunkSize: number,
callbacks: FetchDatasetsCallbacks,
+ uploadIds?: string[],
+ perFileProgress?: (fileId: string, percentage: number) => void,
): Promise {
const files = data.files || [];
+ const hasPerFileTracking = uploadIds && perFileProgress && uploadIds.length === files.length;
+
+ // Track per-file progress for aggregate calculation
+ const fileProgressMap = new Map();
// Build API payload with TUS session info
const apiPayload: Record = {
@@ -857,12 +878,28 @@ async function uploadFilesViaTus(
continue;
}
+ const fileId = hasPerFileTracking ? uploadIds[index] : undefined;
+
const result = await createTusUpload({
file,
endpoint: tusEndpoint,
historyId: data.history_id,
chunkSize,
- onProgress: callbacks.progress || (() => {}),
+ onProgress: (percentage: number) => {
+ if (hasPerFileTracking && fileId) {
+ fileProgressMap.set(fileId, percentage);
+ perFileProgress!(fileId, percentage);
+
+ // Compute aggregate progress from all files uploaded so far
+ const values = Array.from(fileProgressMap.values());
+ const aggregate = Math.round(
+ values.reduce((sum: number, p: number) => sum + p, 0) / values.length,
+ );
+ callbacks.progress?.(aggregate);
+ } else {
+ callbacks.progress?.(percentage);
+ }
+ },
onError: (err: Error) => {
callbacks.error?.(err);
},
@@ -899,6 +936,8 @@ export async function submitUpload(config: UploadSubmitConfig): Promise {
progress = () => {},
isComposite = false,
chunkSize = DEFAULT_CHUNK_SIZE,
+ uploadIds,
+ perFileProgress,
} = config;
// Initial validation
@@ -915,7 +954,7 @@ export async function submitUpload(config: UploadSubmitConfig): Promise {
if (hasFiles || isComposite) {
// Upload files via TUS, then submit payload
- await uploadFilesViaTus(data, tusEndpoint, chunkSize, callbacks);
+ await uploadFilesViaTus(data, tusEndpoint, chunkSize, callbacks, uploadIds, perFileProgress);
} else if (data.targets && data.targets.length > 0) {
const firstTarget = data.targets[0];
@@ -945,7 +984,7 @@ export async function submitUpload(config: UploadSubmitConfig): Promise {
blob.name = String(firstElement.name || DEFAULT_FILE_NAME);
const filesData: UploadPayload = { ...data, files: [blob] };
- await uploadFilesViaTus(filesData, tusEndpoint, chunkSize, callbacks);
+ await uploadFilesViaTus(filesData, tusEndpoint, chunkSize, callbacks, uploadIds, perFileProgress);
}
}
}
@@ -983,7 +1022,17 @@ export async function submitUpload(config: UploadSubmitConfig): Promise {
* ```
*/
export async function uploadDatasets(items: ApiUploadItem[], config: UploadDatasetsConfig = {}): Promise {
- const { composite = false, compositeName, chunkSize, success, error, warning, progress } = config;
+ const {
+ composite = false,
+ compositeName,
+ chunkSize,
+ success,
+ error,
+ warning,
+ progress,
+ uploadIds,
+ perFileProgress,
+ } = config;
try {
// Build the API-ready payload from upload items
@@ -1006,6 +1055,8 @@ export async function uploadDatasets(items: ApiUploadItem[], config: UploadDatas
error,
warning,
progress,
+ uploadIds,
+ perFileProgress,
});
} catch (err) {
const errorMessage = errorMessageAsString(err);
@@ -1071,7 +1122,7 @@ export async function uploadCollectionDatasets(
collectionOptions: CollectionUploadOptions,
config: UploadDatasetsConfig = {},
): Promise {
- const { chunkSize, success, error, warning, progress } = config;
+ const { chunkSize, success, error, warning, progress, uploadIds, perFileProgress } = config;
try {
const payload = buildCollectionUploadPayload(items, collectionOptions);
@@ -1090,6 +1141,8 @@ export async function uploadCollectionDatasets(
error,
warning,
progress,
+ uploadIds,
+ perFileProgress,
});
} catch (err) {
config.error?.(errorMessageAsString(err));