Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion react/src/components/BAIGeneralNotificationItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,15 @@ const BAIGeneralNotificationItem: React.FC<{
) : null}
</BAIFlex>
{notification.extraDescription && showExtraDescription ? (
<Card size="small">
<Card
size="small"
style={{
maxHeight: '300px',
overflow: 'auto',
overflowX: 'hidden',
marginTop: token.marginSM,
}}
>
<Typography.Text type="secondary" copyable>
{notification.extraDescription}
</Typography.Text>
Expand Down
10 changes: 9 additions & 1 deletion react/src/components/BAISessionNotificationItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,15 @@ const BAISessionNotificationItem: React.FC<{
) : null}
</BAIFlex>
{notification.extraDescription && showExtraDescription ? (
<Card size="small">
<Card
size="small"
style={{
maxHeight: '300px',
overflow: 'auto',
overflowX: 'hidden',
marginTop: token.marginSM,
}}
>
<Typography.Text type="secondary" copyable>
{notification.extraDescription}
</Typography.Text>
Expand Down
202 changes: 114 additions & 88 deletions react/src/components/FileUploadManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { useTranslation } from 'react-i18next';
import { graphql, useLazyLoadQuery } from 'react-relay';
import { FileUploadManagerQuery } from 'src/__generated__/FileUploadManagerQuery.graphql';
import { useSuspendedBackendaiClient } from 'src/hooks';
import { useBAISettingUserState } from 'src/hooks/useBAISetting';
import * as tus from 'tus-js-client';

type uploadStartFunction = (callbacks?: {
Expand Down Expand Up @@ -76,7 +77,10 @@ const FileUploadManager: React.FC = () => {
const { generateFolderPath } = useFolderExplorerOpener();
const [uploadRequests, setUploadRequests] = useAtom(uploadRequestAtom);
const [uploadStatus, setUploadStatus] = useAtom(uploadStatusAtom);
const queue = new PQueue({ concurrency: 4 });
const [maxConcurrentUploads] = useBAISettingUserState(
'max_concurrent_uploads',
);
const queue = new PQueue({ concurrency: maxConcurrentUploads || 2 });

const pendingDeltaBytesRef = useRef<Record<string, number>>({});
const throttledUploadRequests = _.throttle(
Expand Down Expand Up @@ -119,7 +123,7 @@ const FileUploadManager: React.FC = () => {
maxWidth: '300px',
}}
>
({t('explorer.Filename')}: {fileName})
{fileName}
</Typography.Text>
</BAIFlex>
),
Expand Down Expand Up @@ -205,56 +209,61 @@ const FileUploadManager: React.FC = () => {
};
});

uploadFileInfo.forEach(({ startFunction }) => {
uploadFileInfo.forEach(({ file, startFunction }) => {
queue.add(async () => {
// Capture fileName before any async operations
const fileName = file.webkitRelativePath || file.name;
let previousBytesUploaded = 0;
await startFunction({
onProgress: (bytesUploaded, _bytesTotal, fileName) => {
// Since bytesUploaded is cumulative, calculate delta from previous value
const deltaBytes = bytesUploaded - previousBytesUploaded;
previousBytesUploaded = bytesUploaded;
pendingDeltaBytesRef.current[vFolderId] =
(pendingDeltaBytesRef.current[vFolderId] || 0) + deltaBytes;

throttledUploadRequests(vFolderId, fileName);
},
})
// handle uploaded file name only, size is already handled in progress
.then(({ name: fileName }) => {
throttledUploadRequests.flush();
delete pendingDeltaBytesRef.current[vFolderId];

setUploadStatus((prev) => ({
...prev,
[vFolderId]: {
...prev[vFolderId],
pendingFiles: prev[vFolderId].pendingFiles.filter(
(f: string) => f !== fileName,
),
completedFiles: [
...(prev[vFolderId]?.completedFiles || []),
fileName,
],
},
}));
})
.catch(({ name: fileName }) => {
delete pendingDeltaBytesRef.current[vFolderId];

setUploadStatus((prev) => ({
...prev,
[vFolderId]: {
...prev[vFolderId],
pendingFiles: prev[vFolderId].pendingFiles.filter(
(f: string) => f !== fileName,
),
failedFiles: [
...(prev[vFolderId]?.failedFiles || []),
fileName,
],
},
}));

try {
await startFunction({
onProgress: (bytesUploaded, _bytesTotal, fileName) => {
// Since bytesUploaded is cumulative, calculate delta from previous value
const deltaBytes = bytesUploaded - previousBytesUploaded;
previousBytesUploaded = bytesUploaded;
pendingDeltaBytesRef.current[vFolderId] =
(pendingDeltaBytesRef.current[vFolderId] || 0) + deltaBytes;

throttledUploadRequests(vFolderId, fileName);
},
});

// Success case
throttledUploadRequests.flush();
delete pendingDeltaBytesRef.current[vFolderId];

setUploadStatus((prev) => ({
...prev,
[vFolderId]: {
...prev[vFolderId],
pendingFiles: prev[vFolderId].pendingFiles.filter(
(f: string) => f !== fileName,
),
completedFiles: [
...(prev[vFolderId]?.completedFiles || []),
fileName,
],
},
}));
} catch (error) {
// Error case - use the captured fileName regardless of error structure
throttledUploadRequests.flush();
delete pendingDeltaBytesRef.current[vFolderId];

setUploadStatus((prev) => ({
...prev,
[vFolderId]: {
...prev[vFolderId],
pendingFiles: prev[vFolderId].pendingFiles.filter(
(f: string) => f !== fileName,
),
failedFiles: [
...(prev[vFolderId]?.failedFiles || []),
fileName,
],
},
}));
}
});
});
});
Expand Down Expand Up @@ -403,46 +412,63 @@ export const useFileUploadManager = (vFolderId: string) => {
fileName: string,
) => void;
}) => {
const uploadPath = [currentPath, file.webkitRelativePath || file.name]
.filter(Boolean)
.join('/');
const uploadUrl: string = await baiClient.vfolder.create_upload_session(
uploadPath,
file,
vfolderId,
);

return new Promise<{ name: string; bytes: number }>(
(resolve, reject) => {
const upload = new tus.Upload(file, {
endpoint: uploadUrl,
uploadUrl: uploadUrl,
retryDelays: [0, 3000, 5000, 10000, 20000],
chunkSize: getOptimalChunkSize(file.size),
storeFingerprintForResuming: false, // Disable localStorage storage
metadata: {
filename: file.name,
filetype: file.type,
},
onProgress: (bytesUploaded, bytesTotal) => {
callbacks?.onProgress?.(bytesUploaded, bytesTotal, file.name);
},
onSuccess: () => {
resolve({
name: file.webkitRelativePath || file.name,
bytes: file.size,
});
},
onError: () => {
reject({
name: file.webkitRelativePath || file.name,
bytes: file.size,
const fileName = file.webkitRelativePath || file.name;

try {
const uploadPath = [currentPath, fileName].filter(Boolean).join('/');

const uploadUrl: string =
await baiClient.vfolder.create_upload_session(
uploadPath,
file,
vfolderId,
);

return await new Promise<{ name: string; bytes: number }>(
(resolve, reject) => {
try {
const upload = new tus.Upload(file, {
endpoint: uploadUrl,
uploadUrl: uploadUrl,
retryDelays: [0, 3000, 5000, 10000, 20000],
chunkSize: getOptimalChunkSize(file.size),
storeFingerprintForResuming: false, // Disable localStorage storage
metadata: {
filename: file.name,
filetype: file.type,
},
onProgress: (bytesUploaded, bytesTotal) => {
callbacks?.onProgress?.(
bytesUploaded,
bytesTotal,
fileName,
);
},
onSuccess: () => {
resolve({
name: fileName,
bytes: file.size,
});
},
onError: (_error) => {
// Always reject with consistent structure
reject(new Error(`Upload failed for ${fileName}`));
},
});
},
});
upload.start();
},
);
upload.start();
} catch (error) {
// Handle synchronous errors from tus.Upload constructor or start()
reject(
new Error(`Failed to initialize upload for ${fileName}`),
);
}
},
);
} catch (error) {
// Handle API errors or any other errors
// Always throw with a consistent error message
throw new Error(`Failed to prepare upload for ${fileName}`);
}
};
});

Expand Down
21 changes: 9 additions & 12 deletions react/src/components/FolderExplorerOpener.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { BAIUnmountAfterClose } from 'backend.ai-ui';
import React from 'react';
import { useLocation } from 'react-router-dom';
import { StringParam, useQueryParam } from 'use-query-params';
Expand All @@ -11,17 +10,15 @@ const FolderExplorerOpener = () => {
const normalizedFolderId = folderId?.replaceAll('-', '');

return (
<BAIUnmountAfterClose>
<FolderExplorerModal
vfolderID={normalizedFolderId || ''}
open={!!normalizedFolderId}
onRequestClose={() => {
setFolderId(null, 'replaceIn');
setCurrentPath(null, 'replaceIn');
}}
destroyOnHidden
/>
</BAIUnmountAfterClose>
<FolderExplorerModal
vfolderID={normalizedFolderId || ''}
open={!!normalizedFolderId}
onRequestClose={() => {
setFolderId(null, 'replaceIn');
setCurrentPath(null, 'replaceIn');
}}
destroyOnHidden
/>
);
};

Expand Down
1 change: 1 addition & 0 deletions react/src/hooks/useBAISetting.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ interface UserSettings {
[key: `table_column_overrides.${string}`]: BAITableColumnOverrideRecord;

classic_session_list?: boolean; // `experimental_neo_session_list` has been replaced with `classic_session_list`
max_concurrent_uploads?: number;
}

export type SessionHistory = {
Expand Down
35 changes: 35 additions & 0 deletions react/src/pages/UserSettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ const UserPreferencesPage = () => {
const [shellInfo, setShellInfo] = useState<ShellScriptType>('bootstrap');
const [isOpenShellScriptEditModal, { toggle: toggleShellScriptEditModal }] =
useToggle(false);
const [maxConcurrentUpload, setMaxConcurrentUpload] = useBAISettingUserState(
'max_concurrent_uploads',
);

const languageOptions = [
{ label: t('language.English'), value: 'en' },
Expand Down Expand Up @@ -249,6 +252,38 @@ const UserPreferencesPage = () => {
</Button>
),
},
{
'data-testid': 'items-max-concurrent-uploads',
type: 'select',
title: t('userSettings.MaxConcurrentUploads'),
description: t('userSettings.DescMaxConcurrentUploads'),
selectProps: {
options: _.map([2, 3, 4, 5], (num) =>
num === 2
? {
label: (
<>
{num}&nbsp;
<Typography.Text type="secondary">
({t('userSettings.Default')})
</Typography.Text>
</>
),
value: num,
}
: {
label: num.toString(),
value: num,
},
),
},
defaultValue: 2,
value: maxConcurrentUpload || 2,
setValue: setMaxConcurrentUpload,
onChange: (value: any) => {
setMaxConcurrentUpload(value);
},
},
]),
},
{
Expand Down
2 changes: 2 additions & 0 deletions resources/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -1943,6 +1943,7 @@
"DescKeepLoginSessionInformation": "Lassen Sie die Webui-App beim nächsten Mal die aktuellen Anmeldesitzungsinformationen beibehalten. <br /> Wenn die Option deaktiviert ist, werden die Anmeldeinformationen bei jeder Abmeldung gelöscht.",
"DescLanguage": "Setzen Sie die Schnittstelle. Andere Sprachen als Englisch und Koreanisch werden per maschineller Übersetzung bereitgestellt.",
"DescLetUserUpdateScriptWithNonEmptyValue": "Bitte aktualisieren Sie das Skript mit einem nicht leeren Wert.",
"DescMaxConcurrentUploads": "Begrenzt die Anzahl der Dateien, die gleichzeitig über den Datei-Explorer hochgeladen werden können.",
"DescMyKeypairInfo": "Siehe meine Schlüsselpaarinformationen",
"DescNewUserConfigFileCreated": "Eine neue Benutzerkonfigurationsdatei kann mit nicht leeren Daten erstellt werden.",
"DescNoBetaFeatures": "Derzeit keine Beta-Funktion verfügbar. :)",
Expand All @@ -1969,6 +1970,7 @@
"Language": "Sprache",
"LightMode": "Lichtmodus",
"Logs": "Protokolle",
"MaxConcurrentUploads": "Maximales Limit für gleichzeitiges Hochladen von Dateien",
"MyKeypairInfo": "Meine Schlüsselpaarinformationen",
"NEODataPage": "NEO -Datenseite",
"NEOSessionList": "NEO Sitzungsliste",
Expand Down
2 changes: 2 additions & 0 deletions resources/i18n/el.json
Original file line number Diff line number Diff line change
Expand Up @@ -1942,6 +1942,7 @@
"DescKeepLoginSessionInformation": "Αφήστε την εφαρμογή webui να διατηρήσει τις τρέχουσες πληροφορίες σύνδεσης κατά την επόμενη φορά. <br /> Εάν η επιλογή είναι απενεργοποιημένη, οι πληροφορίες σύνδεσης θα εκκαθαρίζονται σε κάθε αποσύνδεση.",
"DescLanguage": "Ρυθμίστε τη γλώσσα διεπαφής. Οι γλώσσες εκτός από τα αγγλικά και τα κορεατικά θα παρέχονται μέσω μηχανικής μετάφρασης.",
"DescLetUserUpdateScriptWithNonEmptyValue": "Ενημερώστε το σενάριο χωρίς κενή τιμή.",
"DescMaxConcurrentUploads": "Περιορίζει τον αριθμό των αρχείων που μπορούν να μεταφορτωθούν ταυτόχρονα μέσω της Εξερεύνησης αρχείων.",
"DescMyKeypairInfo": "Δείτε τις πληροφορίες του ζεύγους κλειδιών μου",
"DescNewUserConfigFileCreated": "Νέο αρχείο ρυθμίσεων χρήστη μπορεί να δημιουργηθεί με μη κενά δεδομένα.",
"DescNoBetaFeatures": "Δεν υπάρχει διαθέσιμη δυνατότητα beta τώρα. :)",
Expand All @@ -1968,6 +1969,7 @@
"Language": "Γλώσσα",
"LightMode": "Λειτουργία φωτός",
"Logs": "Κούτσουρα",
"MaxConcurrentUploads": "Μέγιστο όριο ταυτόχρονης μεταφόρτωσης αρχείων",
"MyKeypairInfo": "Πληροφορίες για το ζεύγος κλειδιών μου",
"NEOSessionList": "NEO Κατάλογος συνόδων",
"NoExistingSSHKeypair": "Δεν πρέπει να αντιγραφεί το πληκτρολόγιο SSH. Για να λάβετε SSH Keypair, πατήστε το κουμπί Δημιουργία.",
Expand Down
Loading