mirror of
https://github.com/galaxyproject/galaxy.git
synced 2026-09-24 16:30:27 +08:00
Merge pull request #22919 from davelopez/26.1/fix_upload_progress_usability
[26.1] Fix upload progress usability
This commit is contained in:
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user