Merge pull request #22919 from davelopez/26.1/fix_upload_progress_usability

[26.1] Fix upload progress usability
This commit is contained in:
Marius van den Beek
2026-06-15 21:06:37 +02:00
committed by GitHub
7 changed files with 97 additions and 47 deletions
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from "vue";
import { BPagination } from "bootstrap-vue";
import { usePagination } from "@/composables/pagination";
import { useUploadBatchOperations } from "@/composables/upload/useUploadBatchOperations";
import { useUserLocalStorage } from "@/composables/userLocalStorage";
@@ -14,11 +15,14 @@ import BreadcrumbHeading from "@/components/Common/BreadcrumbHeading.vue";
const uploadBatchOperations = useUploadBatchOperations();
const uploadState = useUploadState();
const { orderedUploadItems, batchesWithProgress, standaloneUploads, activeItems, hasCompleted } = uploadState;
const { orderedUploadItems, batchesWithProgress, activeItems, hasCompleted } = uploadState;
const { paginatedItems, currentPage, itemsPerPage, showPagination, onPageChange } = usePagination(orderedUploadItems, {
itemsPerPage: 24,
});
const breadcrumbItems = [getUploadRootBreadcrumb("/upload"), { title: "Upload Progress" }];
const fileListRef = ref<HTMLElement | null>(null);
const expandedBatches = useUserLocalStorage<string[]>("uploadPanel.expandedBatches", []);
function cleanupExpandedBatches() {
@@ -55,29 +59,6 @@ function onClearAll() {
async function retryBatch(batchId: string) {
await uploadBatchOperations.retryCollectionCreation(batchId);
}
const uploadItemCount = computed(() => {
return batchesWithProgress.value.length + standaloneUploads.value.length;
});
watch(uploadItemCount, async (newCount, oldCount) => {
if (newCount > oldCount) {
await nextTick();
fileListRef.value?.scrollTo({
top: fileListRef.value.scrollHeight,
behavior: "smooth",
});
}
});
onMounted(() => {
nextTick(() => {
fileListRef.value?.scrollTo({
top: fileListRef.value.scrollHeight,
behavior: "auto",
});
});
});
</script>
<template>
@@ -93,10 +74,8 @@ onMounted(() => {
<div class="upload-progress-content flex-grow-1 overflow-auto p-3">
<div v-if="activeItems.length > 0 || batchesWithProgress.length > 0" class="h-100 d-flex flex-column">
<div ref="fileListRef" class="file-details-list flex-grow-1 overflow-auto">
<div
v-for="item in orderedUploadItems"
:key="item.type === 'batch' ? item.batch.id : item.upload.id">
<div class="file-details-list flex-grow-1 overflow-auto pt-1">
<div v-for="item in paginatedItems" :key="item.type === 'batch' ? item.batch.id : item.upload.id">
<BatchUploadGroup
v-if="item.type === 'batch'"
:batch="item.batch"
@@ -107,6 +86,18 @@ onMounted(() => {
<UploadFileRow v-else :file="item.upload" />
</div>
</div>
<div v-if="showPagination" class="d-flex justify-content-center mt-3">
<BPagination
:value="currentPage"
:total-rows="orderedUploadItems.length"
:per-page="itemsPerPage"
align="center"
size="sm"
first-number
last-number
@change="onPageChange" />
</div>
</div>
<div v-else class="d-flex flex-column align-items-center justify-content-center h-100 text-muted">
<p class="h-lg mb-3">No uploads in progress</p>
@@ -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) {
@@ -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<UploadItem["status"], FileProgressUi> = {
queued: {
icon: faSpinner,
icon: faClock,
textClass: "text-muted",
spin: false,
},
@@ -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";
@@ -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));
@@ -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);
},
};
+60 -7
View File
@@ -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<void> {
const files = data.files || [];
const hasPerFileTracking = uploadIds && perFileProgress && uploadIds.length === files.length;
// Track per-file progress for aggregate calculation
const fileProgressMap = new Map<string, number>();
// Build API payload with TUS session info
const apiPayload: Record<string, unknown> = {
@@ -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<void> {
progress = () => {},
isComposite = false,
chunkSize = DEFAULT_CHUNK_SIZE,
uploadIds,
perFileProgress,
} = config;
// Initial validation
@@ -915,7 +954,7 @@ export async function submitUpload(config: UploadSubmitConfig): Promise<void> {
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<void> {
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<void> {
* ```
*/
export async function uploadDatasets(items: ApiUploadItem[], config: UploadDatasetsConfig = {}): Promise<void> {
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<void> {
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));