Reworking useFileUpload to upload files synchronously, disabling upload form when upload is in progress.

This commit is contained in:
Roland 2025-05-14 18:25:00 -06:00
parent 5bd71f9664
commit ee434b3fb8
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
3 changed files with 170 additions and 77 deletions

View file

@ -114,6 +114,7 @@ function EvaluateForm() {
<pre>15 km to miles</pre>
<pre>65 degF to degC</pre>
<pre>7 * 750 Mb</pre>
<pre>8 * pi</pre>
</span>
const inputRef = React.useRef();
@ -122,12 +123,16 @@ function EvaluateForm() {
const [evaluatedValue, setEvaluatedValue] = React.useState(helpContents);
const doEvaluate = () => {
if (!inputValue) {
setEvaluatedValue(helpContents);
return;
}
setEvaluatedValue(''); // Clear input temporarily so the user can tell something happened.
try {
setEvaluatedValue(evaluate(inputValue).toString());
} catch (error) {
console.error(error);
setEvaluatedValue('Unable to evaluate.');
setEvaluatedValue(`Unable to evaluate: ${error.message}`);
}
}
@ -445,4 +450,4 @@ export function DashboardPage() {
{!searchStr && <FlagsMessages/>}
{body}
</PageContainer>
}
}

View file

@ -19,7 +19,9 @@ export function Upload({disabled}) {
doClear,
tagsSelector,
overwrite,
setOverwrite
setOverwrite,
overallProgress,
inProgress,
} = useUploadFile();
const onDrop = React.useCallback(async (acceptedFiles) => {
@ -28,7 +30,7 @@ export function Upload({disabled}) {
}
}, [destination]);
const {getRootProps, getInputProps} = useDropzone({onDrop});
const {getRootProps, getInputProps} = useDropzone({onDrop, disabled: inProgress});
React.useEffect(() => {
if (!destination) {
@ -77,13 +79,18 @@ export function Upload({disabled}) {
<label>Destination</label>
<DirectorySearch
onSelect={i => setDestination(i)}
disabled={disabled}
disabled={disabled || inProgress}
style={{marginBottom: '0.5em'}}
/>
{tagsSelector}
</SForm.Field>
<Toggle checked={overwrite} label='Overwrite' onChange={() => setOverwrite(!overwrite)}/>
<Toggle
checked={overwrite}
label='Overwrite'
onChange={() => setOverwrite(!overwrite)}
disabled={disabled || inProgress}
/>
</Form>
{destination ?
@ -91,8 +98,7 @@ export function Upload({disabled}) {
<Grid columns={1}>
<Grid.Row>
<Grid.Column style={{padding: '1em'}}>
<Form onSubmit={() => {
}}>
<Form>
<div {...getRootProps()}>
<input {...getInputProps()}/>
<Grid textAlign='center'>
@ -116,6 +122,17 @@ export function Upload({disabled}) {
<br/>
<Grid>
<Grid.Row columns={1}>
<Grid.Column>
{progresses && Object.keys(progresses).length > 1 && <Progress progress
percent={overallProgress}
color='blue'
label='Overall Progress'
/>}
</Grid.Column>
</Grid.Row>
</Grid>
<Grid columns={2}>
{progressBars}
</Grid>

View file

@ -480,7 +480,7 @@ export const useChannel = (channel_id) => {
c['url'] = c['url'] || '';
c['download_frequency'] = c['download_frequency'] || '';
c['match_regex'] = c['match_regex'] || '';
c['download_missing_data'] = c['download_missing_data'] ?? true;
c['download_missing_data'] = c['download_missing_data'] ?? true;
return c;
}
@ -1157,89 +1157,74 @@ export const useUploadFile = () => {
const [destination, setDestination] = useState('');
const [tagNames, setTagNames] = React.useState([]);
const [overwrite, setOverwrite] = React.useState(false);
const [totalSize, setTotalSize] = useState(0);
const [uploadedSize, setUploadedSize] = useState(0);
const [overallProgress, setOverallProgress] = useState(0);
const [inProgress, setInProgress] = useState(false);
const handleProgress = (name, chunk, totalChunks, status, type) => {
const percent = Math.round((100 * chunk) / totalChunks);
const newProgress = {[name]: {percent, status, type}};
setProgresses(prevState => ({...prevState, ...newProgress}));
setProgresses(prevState => {
const updatedProgresses = {...prevState, ...newProgress};
// Calculate total uploaded size based on all files' progress
let totalUploaded = 0;
files.forEach(file => {
const fileProgress = updatedProgresses[file.name];
if (fileProgress) {
const chunkSize = 10 * 1024 * 1024; // 10MB - same as in doUpload
const fileSize = file.size;
const totalFileChunks = Math.ceil(fileSize / chunkSize);
// For completed files, count the full size
if (fileProgress.status === 'complete' || fileProgress.status === 'conflicting') {
totalUploaded += fileSize;
}
// For files in progress, count the completed chunks
else if (fileProgress.percent > 0) {
const completedChunks = Math.floor((fileProgress.percent / 100) * totalFileChunks);
const completedSize = Math.min(completedChunks * chunkSize, fileSize);
totalUploaded += completedSize;
}
}
});
setUploadedSize(totalUploaded);
if (totalSize > 0) {
const newOverallProgress = Math.round((totalUploaded / totalSize) * 100);
setOverallProgress(newOverallProgress);
}
return updatedProgresses;
});
}
const handleFilesChange = (newFiles) => {
let newProgresses = {};
newFiles.map(i => {
newProgresses = {...newProgresses, [i['name']]: {percent: 0, status: 'pending'}};
let newTotalSize = 0;
newFiles.forEach(file => {
newProgresses = {...newProgresses, [file.name]: {percent: 0, status: 'pending'}};
newTotalSize += file.size;
});
setProgresses(newProgresses);
setFiles(newFiles);
setTotalSize(newTotalSize);
setUploadedSize(0);
setOverallProgress(0);
}
const uploadChunk = async (file, chunkNum, chunkSize, totalChunks, tries, maxTries) => {
if (tries > maxTries) {
console.error(`Exceeded max tries ${maxTries}`);
}
const start = chunkNum * chunkSize;
const end = Math.min(start + chunkSize, file.size);
// The bytes that we will send.
const chunk = file.slice(start, end);
const formData = new FormData();
formData.append('chunkNumber', chunkNum.toString());
formData.append('filename', file.path);
formData.append('totalChunks', totalChunks.toString());
formData.append('destination', destination);
formData.append('mkdir', 'true');
if (overwrite) {
formData.append('overwrite', 'true');
}
// Send the size that we're actually sending.
formData.append('chunkSize', chunk.size.toString());
formData.append('chunk', chunk);
for (let i = 0; i < tagNames.length; i++) {
formData.append('tagNames', tagNames[i]);
}
console.debug(`file upload: tries=${tries} chunkNum=${chunkNum} totalChunks=${totalChunks} chunkSize=${chunk.size} destination=${destination} tagNames=${tagNames}`);
const xhr = new XMLHttpRequest();
xhr.open('POST', '/api/files/upload', true);
xhr.onreadystatechange = async () => {
if (xhr.readyState === 4) {
if (xhr.status === 200 || xhr.status === 416) {
const data = JSON.parse(xhr.responseText);
handleProgress(file.name, chunkNum, totalChunks, 'pending', file.type);
const expectedChunk = data['expected_chunk'];
if (xhr.status === 416) {
console.warn(`Server requested a different chunk ${chunkNum}`);
await uploadChunk(file, expectedChunk, chunkSize, totalChunks, tries + 1, maxTries);
} else {
console.debug(`Uploading of chunk ${chunkNum} succeeded, got request for chunk ${chunkNum}`);
// Success, reset tries.
await uploadChunk(file, expectedChunk, chunkSize, totalChunks, 0, maxTries);
}
} else if (xhr.status === 201) {
handleProgress(file.name, totalChunks, totalChunks, 'complete', file.type);
console.info(`Uploading of ${file.path} completed.`);
} else if (xhr.status === 400) {
handleProgress(file.name, totalChunks, totalChunks, 'conflicting', file.type);
const data = JSON.parse(xhr.responseText);
if (data['code'] === 'FILE_UPLOAD_FAILED') {
console.error('File already exists. Giving up.');
}
} else {
handleProgress(file.name, totalChunks, totalChunks, 'failed', file.type);
console.error(`Failed to upload chunk ${chunkNum}. Giving up.`);
}
}
}
await xhr.send(formData);
};
const doUpload = async () => {
if (!files || files.length === 0 || !destination) {
return;
}
setInProgress(true);
for (let i = 0; i < files.length; i++) {
const file = files[i];
console.log(`Uploading file:`, file.path);
@ -1250,17 +1235,99 @@ export const useUploadFile = () => {
const tries = 0;
const maxTries = 20;
// Start recursive function to upload the file.
await uploadChunk(file, chunkNum, chunkSize, totalChunks, tries, maxTries);
// Start recursive function to upload the file and wait for it to complete
// before moving to the next file
try {
await new Promise((resolve, reject) => {
const uploadNextChunk = async (currentChunkNum, currentTries) => {
if (currentTries > maxTries) {
console.error(`Exceeded max tries ${maxTries}`);
reject(new Error(`Exceeded max tries ${maxTries}`));
return;
}
const start = currentChunkNum * chunkSize;
const end = Math.min(start + chunkSize, file.size);
const chunk = file.slice(start, end);
const formData = new FormData();
formData.append('chunkNumber', currentChunkNum.toString());
formData.append('filename', file.path);
formData.append('totalChunks', totalChunks.toString());
formData.append('destination', destination);
formData.append('mkdir', 'true');
if (overwrite) {
formData.append('overwrite', 'true');
}
formData.append('chunkSize', chunk.size.toString());
formData.append('chunk', chunk);
for (let j = 0; j < tagNames.length; j++) {
formData.append('tagNames', tagNames[j]);
}
console.debug(`file upload: tries=${currentTries} chunkNum=${currentChunkNum} totalChunks=${totalChunks} chunkSize=${chunk.size} destination=${destination} tagNames=${tagNames}`);
// Create a new XMLHttpRequest for each chunk to avoid InvalidStateError
const chunkXhr = new XMLHttpRequest();
chunkXhr.open('POST', '/api/files/upload', true);
chunkXhr.onreadystatechange = async () => {
if (chunkXhr.readyState === 4) {
if (chunkXhr.status === 200 || chunkXhr.status === 416) {
const data = JSON.parse(chunkXhr.responseText);
handleProgress(file.name, currentChunkNum, totalChunks, 'pending', file.type);
const expectedChunk = data['expected_chunk'];
if (chunkXhr.status === 416) {
console.warn(`Server requested a different chunk ${currentChunkNum}`);
uploadNextChunk(expectedChunk, currentTries + 1);
} else {
console.debug(`Uploading of chunk ${currentChunkNum} succeeded, got request for chunk ${expectedChunk}`);
// Success, reset tries.
uploadNextChunk(expectedChunk, 0);
}
} else if (chunkXhr.status === 201) {
handleProgress(file.name, totalChunks, totalChunks, 'complete', file.type);
console.info(`Uploading of ${file.path} completed.`);
resolve(); // File upload completed successfully
} else if (chunkXhr.status === 400) {
handleProgress(file.name, totalChunks, totalChunks, 'conflicting', file.type);
const data = JSON.parse(chunkXhr.responseText);
if (data['code'] === 'FILE_UPLOAD_FAILED') {
console.error('File already exists. Giving up.');
reject(new Error('File already exists'));
}
} else {
handleProgress(file.name, totalChunks, totalChunks, 'failed', file.type);
console.error(`Failed to upload chunk ${currentChunkNum}. Giving up.`);
reject(new Error(`Failed to upload chunk ${currentChunkNum}`));
}
}
};
chunkXhr.send(formData);
};
// Start uploading the first chunk
uploadNextChunk(chunkNum, tries);
});
} catch (error) {
console.error(`Error uploading file ${file.path}:`, error);
// Continue with the next file even if this one failed
}
}
// Clear form after upload.
setFiles([]);
setInProgress(false);
}
const doClear = () => {
setFiles([]);
setProgresses({});
setTotalSize(0);
setUploadedSize(0);
setOverallProgress(0);
setInProgress(false);
}
useEffect(() => {
@ -1279,6 +1346,10 @@ export const useUploadFile = () => {
tagNames,
tagsSelector,
overwrite, setOverwrite,
overallProgress,
totalSize,
uploadedSize,
inProgress,
}
}
@ -1395,4 +1466,4 @@ export const useSearchChannels = (defaultTagNames) => {
channels,
loading,
}
}
}