Skip to content

Commit 327e6ba

Browse files
committed
feat(FR-1316): implement file upload manager with TUS protocol and cancellation support (#4050)
resolves #4046 ([FR-1316](https://lablup.atlassian.net/browse/FR-1316)) This PR implements a new file upload system with the following improvements: 1. Created a dedicated `FileUploadManager` component that handles file uploads centrally 2. Added a confirmation dialog when uploading files that would overwrite existing ones 3. Implemented a more robust upload process with progress tracking in notifications 4. Added ability to cancel ongoing uploads 5. Optimized chunk sizes based on file size for better performance 6. Added comprehensive upload status notifications (pending, success, failure) 7. Added internationalization support for all new upload-related messages The implementation moves the upload logic from the UI components to a centralized manager that handles all upload requests, making the code more maintainable and providing a consistent user experience. **Checklist:** - [x] Documentation - [ ] Minium required manager version - [ ] Specific setting for review (eg., KB link, endpoint or how to setup) - [ ] Minimum requirements to check during review - [ ] Test case(s) to demonstrate the difference of before/after [FR-1316]: https://lablup.atlassian.net/browse/FR-1316?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ
1 parent 3cfc87f commit 327e6ba

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

52 files changed

+1477
-971
lines changed

packages/backend.ai-ui/src/components/baiClient/FileExplorer/BAIFileExplorer.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
} from 'antd';
2424
import { createStyles } from 'antd-style';
2525
import { ItemType } from 'antd/es/breadcrumb/Breadcrumb';
26+
import { RcFile } from 'antd/es/upload';
2627
import dayjs from 'dayjs';
2728
import _ from 'lodash';
2829
import { createContext, Suspense, useEffect, useMemo, useState } from 'react';
@@ -52,11 +53,13 @@ export const FolderInfoContext = createContext<{
5253
export interface BAIFileExplorerProps {
5354
vfolderNodeFrgmt?: BAIFileExplorerFragment$key | null;
5455
targetVFolderId: string;
56+
onUpload: (files: Array<RcFile>, currentPath: string) => void;
5557
}
5658

5759
const BAIFileExplorer: React.FC<BAIFileExplorerProps> = ({
5860
vfolderNodeFrgmt,
5961
targetVFolderId,
62+
onUpload,
6063
}) => {
6164
const { t } = useTranslation();
6265
const { token } = theme.useToken();
@@ -248,6 +251,7 @@ const BAIFileExplorer: React.FC<BAIFileExplorerProps> = ({
248251
<Breadcrumb items={breadCrumbItems} />
249252
<ExplorerActionControls
250253
selectedFiles={selectedItems}
254+
onUpload={(files, currentPath) => onUpload(files, currentPath)}
251255
onRequestClose={(
252256
success: boolean,
253257
modifiedItems?: Array<VFolderFile>,

packages/backend.ai-ui/src/components/baiClient/FileExplorer/ExplorerActionControls.tsx

Lines changed: 34 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -15,30 +15,32 @@ import { useToggle } from 'ahooks';
1515
import { App, Button, Dropdown, Grid, theme, Tooltip, Upload } from 'antd';
1616
import { RcFile } from 'antd/es/upload';
1717
import _ from 'lodash';
18-
import { use } from 'react';
18+
import { use, useRef } from 'react';
1919
import { useTranslation } from 'react-i18next';
20-
import * as tus from 'tus-js-client';
2120

2221
interface ExplorerActionControlsProps {
2322
selectedFiles: Array<VFolderFile>;
2423
onRequestClose: (
2524
success: boolean,
2625
modifiedItems?: Array<VFolderFile>,
2726
) => void;
27+
onUpload: (files: Array<RcFile>, currentPath: string) => void;
2828
}
2929

3030
const ExplorerActionControls: React.FC<ExplorerActionControlsProps> = ({
3131
selectedFiles,
3232
onRequestClose,
33+
onUpload,
3334
}) => {
3435
const baiClient = useConnectedBAIClient();
3536
const { t } = useTranslation();
3637
const { lg } = Grid.useBreakpoint();
3738
const { token } = theme.useToken();
38-
const { message } = App.useApp();
39+
const { modal } = App.useApp();
3940
const { targetVFolderId, currentPath } = use(FolderInfoContext);
4041
const [openCreateModal, { toggle: toggleCreateModal }] = useToggle(false);
4142
const [openDeleteModal, { toggle: toggleDeleteModal }] = useToggle(false);
43+
const lastFileListRef = useRef<Array<RcFile>>([]);
4244

4345
const { data: vfolderInfo, isFetching } = useQuery({
4446
queryKey: ['vfolderInfo', targetVFolderId],
@@ -49,64 +51,32 @@ const ExplorerActionControls: React.FC<ExplorerActionControlsProps> = ({
4951
gcTime: 0,
5052
});
5153

52-
const handleUpload = (fileList: Array<RcFile>) => {
53-
const maxUploadSize = baiClient?._config?.maxFileUploadSize;
54-
if (
55-
maxUploadSize > 0 &&
56-
_.some(fileList, (file) => file.size > maxUploadSize)
57-
) {
58-
message.error(t('comp:FileExplorer.error.FileUploadSizeLimit'));
59-
return;
60-
}
61-
62-
// TODO: show confirmation modal before upload already existing files
63-
// TODO: show progress during upload via using bai-notification
64-
const uploadFiles: Array<Promise<tus.Upload>> = _.map(
65-
fileList,
66-
async (file) => {
67-
const fullPath = _.join(
68-
[currentPath, file.webkitRelativePath || file.name],
69-
'/',
54+
const handleUpload = async (fileList: RcFile[], path: string) => {
55+
// Currently, backend.ai only supports finding existing files by using list_files API.
56+
// This API throw an error if the file does not exist in the target vfolder.
57+
// So, we need to catch the error and return undefined.
58+
const duplicateCheckResult = await baiClient.vfolder
59+
.list_files(currentPath, targetVFolderId)
60+
.then((files) => {
61+
return _.some(
62+
files.items,
63+
(existFiles) =>
64+
existFiles.name ===
65+
(fileList[0].webkitRelativePath.split('/')[0] || fileList[0].name),
7066
);
67+
});
7168

72-
try {
73-
const url = await baiClient?.vfolder?.create_upload_session(
74-
fullPath,
75-
file,
76-
targetVFolderId,
77-
);
78-
const upload = new tus.Upload(file, {
79-
endpoint: url,
80-
uploadUrl: url,
81-
retryDelays: [0, 3000, 5000, 10000, 20000],
82-
chunkSize: 15 * 1024 * 1024, // 15MB
83-
metadata: {
84-
filename: fullPath,
85-
filetype: file.type,
86-
},
87-
// TODO: use baiNotification after notification migration to backend.ai-ui
88-
onError: (err) => {},
89-
onProgress: (bytesUploaded, bytesTotal) => {},
90-
onSuccess: () => {
91-
onRequestClose(true);
92-
},
93-
});
94-
return upload;
95-
} catch (err: any) {
96-
if (err && err.message) {
97-
message.error(err.message);
98-
} else if (err && err.title) {
99-
message.error(err.title);
100-
}
101-
return Promise.reject(err);
102-
}
103-
},
104-
);
105-
Promise.all(uploadFiles).then((uploads) => {
106-
uploads.forEach((upload) => {
107-
upload.start();
69+
if (duplicateCheckResult) {
70+
modal.confirm({
71+
title: t('comp:FileExplorer.DuplicatedFiles'),
72+
content: t('comp:FileExplorer.DuplicatedFilesDesc'),
73+
onOk: () => {
74+
onUpload(fileList, path);
75+
},
10876
});
109-
});
77+
} else {
78+
onUpload(fileList, path);
79+
}
11080
};
11181

11282
return (
@@ -146,7 +116,7 @@ const ExplorerActionControls: React.FC<ExplorerActionControlsProps> = ({
146116
label: (
147117
<Upload
148118
beforeUpload={(_, fileList) => {
149-
handleUpload(fileList);
119+
handleUpload(fileList, currentPath);
150120
return false; // Prevent default upload behavior
151121
}}
152122
showUploadList={false}
@@ -162,7 +132,10 @@ const ExplorerActionControls: React.FC<ExplorerActionControlsProps> = ({
162132
<Upload
163133
directory
164134
beforeUpload={(_, fileList) => {
165-
handleUpload(fileList);
135+
if (fileList !== lastFileListRef.current) {
136+
handleUpload(fileList, currentPath);
137+
}
138+
lastFileListRef.current = fileList;
166139
return false;
167140
}}
168141
showUploadList={false}
@@ -176,7 +149,7 @@ const ExplorerActionControls: React.FC<ExplorerActionControlsProps> = ({
176149
}}
177150
>
178151
<Tooltip title={!lg && t('general.button.Upload')}>
179-
<Button icon={<UploadOutlined />} onClick={() => {}}>
152+
<Button icon={<UploadOutlined />}>
180153
{lg && t('general.button.Upload')}
181154
</Button>
182155
</Tooltip>
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
export { default as useConnectedBAIClient } from './hooks/useConnectedBAIClient';
2-
31
export { default as BAIClientProvider } from './BAIClientProvider';
42
export type { BAIClientProviderProps } from './BAIClientProvider';
53
export { BAIClientContext, BAIAnonymousClientContext } from './context';
64
export type { BAIClient } from './types';
5+
export { default as useConnectedBAIClient } from './hooks/useConnectedBAIClient';
6+
export { default as useAnonymousBAIClient } from './hooks/useAnonymousBAIClient';
Lines changed: 37 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,60 +1,62 @@
11
{
22
"$schema": "../../i18n.schema.json",
3-
"comp:BAIPropertyFilter": {
4-
"PlaceHolder": "Suche",
5-
"ResetFilter": "Filter zurücksetzen"
6-
},
7-
"comp:BAISessionAgentIds": {
8-
"Agent": "Agent"
3+
"comp:BAITestButton": {
4+
"Test": "test"
95
},
10-
"comp:BAIStatistic": {
11-
"Unlimited": "Unbegrenzt"
6+
"comp:PaginationInfoText": {
7+
"Total": "{{start}} - {{end}} von {{total}} Elementen"
128
},
139
"comp:BAITable": {
14-
"SearchTableColumn": "Suchtabellenspalten",
10+
"SettingTable": "Tabelleneinstellungen",
1511
"SelectColumnToDisplay": "Wählen Sie Spalten aus, um angezeigt zu werden",
16-
"SettingTable": "Tabelleneinstellungen"
12+
"SearchTableColumn": "Suchtabellenspalten"
1713
},
18-
"comp:BAITestButton": {
19-
"Test": "test"
14+
"error": {
15+
"UnknownError": "Ein unbekannter Fehler ist aufgetreten. \nBitte versuchen Sie es erneut."
16+
},
17+
"general": {
18+
"NSelected": "{{count}} ausgewählt",
19+
"button": {
20+
"Delete": "Löschen",
21+
"Create": "Erstellen",
22+
"Upload": "Hochladen",
23+
"CopyAll": "Alle kopieren"
24+
}
2025
},
2126
"comp:FileExplorer": {
22-
"Controls": "Kontrollen",
23-
"CreateANewFolder": "Erstellen Sie einen neuen Ordner",
24-
"CreatedAt": "Erstellt at",
25-
"DeleteSelectedItemDesc": "Löschte Dateien und Ordner können nicht wiederhergestellt werden. \nMöchten Sie fortfahren?",
27+
"SelectedItemsDeletedSuccessfully": "Ausgewählte Dateien und Ordner wurden erfolgreich gelöscht.",
2628
"DeleteSelectedItemsDialog": "Bestätigung löschen",
27-
"DownloadStarted": "Die Datei \"{{fileName}}\" wurde gestartet.",
29+
"DeleteSelectedItemDesc": "Löschte Dateien und Ordner können nicht wiederhergestellt werden. \nMöchten Sie fortfahren?",
2830
"FolderCreatedSuccessfully": "Ordner erfolgreich erstellt.",
31+
"CreateANewFolder": "Erstellen Sie einen neuen Ordner",
2932
"FolderName": "Ordner Name",
33+
"PleaseEnterAFolderName": "Bitte geben Sie den Ordneramen ein.",
3034
"MaxFolderNameLength": "Der Ordnername muss 255 Zeichen oder weniger betragen.",
31-
"ModifiedAt": "Modifiziert bei",
3235
"Name": "Name",
33-
"PleaseEnterAFolderName": "Bitte geben Sie den Ordneramen ein.",
34-
"SelectedItemsDeletedSuccessfully": "Ausgewählte Dateien und Ordner wurden erfolgreich gelöscht.",
3536
"Size": "Größe",
37+
"CreatedAt": "Erstellt at",
38+
"ModifiedAt": "Modifiziert bei",
39+
"Controls": "Kontrollen",
3640
"UploadFiles": "Dateien hochladen",
3741
"UploadFolder": "Ordner hochladen",
42+
"DownloadStarted": "Die Datei \"{{fileName}}\" wurde gestartet.",
3843
"error": {
3944
"FileUploadSizeLimit": "Die Dateigröße überschreitet die Hochladungsgrenze."
40-
}
45+
},
46+
"DuplicatedFilesDesc": "Die Datei oder der Ordner mit demselben Namen existieren bereits. \nMöchten Sie überschreiben?",
47+
"DuplicatedFiles": "Überschreibung der Bestätigung"
4148
},
42-
"comp:PaginationInfoText": {
43-
"Total": "{{start}} - {{end}} von {{total}} Elementen"
49+
"comp:BAIPropertyFilter": {
50+
"PlaceHolder": "Suche",
51+
"ResetFilter": "Filter zurücksetzen"
4452
},
45-
"comp:ResourceStatistics": {
46-
"NoResourcesData": "Keine Ressourcendaten verfügbar"
53+
"comp:BAISessionAgentIds": {
54+
"Agent": "Agent"
4755
},
48-
"error": {
49-
"UnknownError": "Ein unbekannter Fehler ist aufgetreten. \nBitte versuchen Sie es erneut."
56+
"comp:BAIStatistic": {
57+
"Unlimited": "Unbegrenzt"
5058
},
51-
"general": {
52-
"NSelected": "{{count}} ausgewählt",
53-
"button": {
54-
"CopyAll": "Alle kopieren",
55-
"Create": "Erstellen",
56-
"Delete": "Löschen",
57-
"Upload": "Hochladen"
58-
}
59+
"comp:ResourceStatistics": {
60+
"NoResourcesData": "Keine Ressourcendaten verfügbar"
5961
}
6062
}
Lines changed: 36 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,60 +1,62 @@
11
{
22
"$schema": "../../i18n.schema.json",
3-
"comp:BAIPropertyFilter": {
4-
"PlaceHolder": "Αναζήτηση",
5-
"ResetFilter": "Επαναφορά φίλτρων"
6-
},
7-
"comp:BAISessionAgentIds": {
8-
"Agent": "Μέσο"
3+
"comp:BAITestButton": {
4+
"Test": "δοκιμή"
95
},
10-
"comp:BAIStatistic": {
11-
"Unlimited": "Απεριόριστος"
6+
"comp:PaginationInfoText": {
7+
"Total": "{{start}} - {{end}} των στοιχείων {{total}}"
128
},
139
"comp:BAITable": {
14-
"SearchTableColumn": "Στήλες πίνακα αναζήτησης",
1510
"SelectColumnToDisplay": "Επιλέξτε στήλες για εμφάνιση",
11+
"SearchTableColumn": "Στήλες πίνακα αναζήτησης",
1612
"SettingTable": "Ρυθμίσεις πίνακα"
1713
},
18-
"comp:BAITestButton": {
19-
"Test": "δοκιμή"
14+
"error": {
15+
"UnknownError": "Παρουσιάστηκε ένα άγνωστο σφάλμα. \nΔοκιμάστε ξανά."
16+
},
17+
"general": {
18+
"NSelected": "{{count}} Επιλεγμένη",
19+
"button": {
20+
"Delete": "Διαγράφω",
21+
"Create": "Δημιουργώ",
22+
"Upload": "Μεταφορτώσω",
23+
"CopyAll": "Αντιγράψτε όλα"
24+
}
2025
},
2126
"comp:FileExplorer": {
22-
"Controls": "Χειριστήρια",
23-
"CreateANewFolder": "Δημιουργήστε ένα νέο φάκελο",
24-
"CreatedAt": "Δημιουργήθηκε στο",
25-
"DeleteSelectedItemDesc": "Τα διαγραμμένα αρχεία και οι φάκελοι δεν μπορούν να αποκατασταθούν. \nΘέλετε να προχωρήσετε;",
27+
"SelectedItemsDeletedSuccessfully": "Επιλεγμένα αρχεία και φακέλους έχουν διαγραφεί με επιτυχία.",
2628
"DeleteSelectedItemsDialog": "Διαγραφή επιβεβαίωσης",
27-
"DownloadStarted": "Αρχείο \"{{fileName}}\" Η λήψη έχει ξεκινήσει.",
29+
"DeleteSelectedItemDesc": "Τα διαγραμμένα αρχεία και οι φάκελοι δεν μπορούν να αποκατασταθούν. \nΘέλετε να προχωρήσετε;",
2830
"FolderCreatedSuccessfully": "Ο φάκελος δημιούργησε με επιτυχία.",
31+
"CreateANewFolder": "Δημιουργήστε ένα νέο φάκελο",
2932
"FolderName": "Όνομα φακέλου",
33+
"PleaseEnterAFolderName": "Εισαγάγετε το όνομα του φακέλου.",
3034
"MaxFolderNameLength": "Το όνομα του φακέλου πρέπει να είναι 255 χαρακτήρες ή λιγότερο.",
31-
"ModifiedAt": "Τροποποιημένος",
3235
"Name": "Ονομα",
33-
"PleaseEnterAFolderName": "Εισαγάγετε το όνομα του φακέλου.",
34-
"SelectedItemsDeletedSuccessfully": "Επιλεγμένα αρχεία και φακέλους έχουν διαγραφεί με επιτυχία.",
3536
"Size": "Μέγεθος",
37+
"CreatedAt": "Δημιουργήθηκε στο",
38+
"ModifiedAt": "Τροποποιημένος",
39+
"Controls": "Χειριστήρια",
3640
"UploadFiles": "Μεταφόρτωση αρχείων",
3741
"UploadFolder": "Μεταφορτωμένος φάκελος",
42+
"DownloadStarted": "Αρχείο \"{{fileName}}\" Η λήψη έχει ξεκινήσει.",
3843
"error": {
3944
"FileUploadSizeLimit": "Το μέγεθος του αρχείου υπερβαίνει το όριο μεγέθους μεταφόρτωσης."
40-
}
45+
},
46+
"DuplicatedFilesDesc": "Το αρχείο ή ο φάκελος με το ίδιο όνομα υπάρχει ήδη. \nΘέλετε να αντικαταστήσετε;",
47+
"DuplicatedFiles": "Αντιπροσώπηση επιβεβαίωσης"
4148
},
42-
"comp:PaginationInfoText": {
43-
"Total": "{{start}} - {{end}} των στοιχείων {{total}}"
49+
"comp:BAIPropertyFilter": {
50+
"PlaceHolder": "Αναζήτηση",
51+
"ResetFilter": "Επαναφορά φίλτρων"
4452
},
45-
"comp:ResourceStatistics": {
46-
"NoResourcesData": "Δεν υπάρχουν διαθέσιμα δεδομένα πόρων"
53+
"comp:BAISessionAgentIds": {
54+
"Agent": "Μέσο"
4755
},
48-
"error": {
49-
"UnknownError": "Παρουσιάστηκε ένα άγνωστο σφάλμα. \nΔοκιμάστε ξανά."
56+
"comp:BAIStatistic": {
57+
"Unlimited": "Απεριόριστος"
5058
},
51-
"general": {
52-
"NSelected": "{{count}} Επιλεγμένη",
53-
"button": {
54-
"CopyAll": "Αντιγράψτε όλα",
55-
"Create": "Δημιουργώ",
56-
"Delete": "Διαγράφω",
57-
"Upload": "Μεταφορτώσω"
58-
}
59+
"comp:ResourceStatistics": {
60+
"NoResourcesData": "Δεν υπάρχουν διαθέσιμα δεδομένα πόρων"
5961
}
6062
}

0 commit comments

Comments
 (0)