feat(api, ui): handle TUS lockups, automatic chunk token refresh, and add upload cancellation
Some checks failed
Automated Container Build / build-and-push (push) Failing after 9s
Some checks failed
Automated Container Build / build-and-push (push) Failing after 9s
This commit is contained in:
parent
36d97ac330
commit
91fc088389
2 changed files with 71 additions and 17 deletions
|
|
@ -105,8 +105,9 @@ interface UploadBatch {
|
||||||
uploadedBytes: number;
|
uploadedBytes: number;
|
||||||
totalFiles: number;
|
totalFiles: number;
|
||||||
filesUploaded: number;
|
filesUploaded: number;
|
||||||
status: 'uploading' | 'complete' | 'error';
|
status: 'uploading' | 'complete' | 'error' | 'canceled';
|
||||||
error?: string;
|
error?: string;
|
||||||
|
abortController?: AbortController;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Toast {
|
interface Toast {
|
||||||
|
|
@ -908,8 +909,11 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
||||||
uploadedBytes: 0,
|
uploadedBytes: 0,
|
||||||
totalFiles,
|
totalFiles,
|
||||||
filesUploaded: 0,
|
filesUploaded: 0,
|
||||||
status: 'uploading'
|
status: 'uploading' as const,
|
||||||
}]);
|
abortController: new AbortController()
|
||||||
|
};
|
||||||
|
|
||||||
|
setUploads(prev => [...prev, newBatch]);
|
||||||
|
|
||||||
const MAX_CONCURRENT = 4;
|
const MAX_CONCURRENT = 4;
|
||||||
let currentIndex = 0;
|
let currentIndex = 0;
|
||||||
|
|
@ -934,8 +938,10 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
||||||
const uploadPath = path ? (destPath === '.' ? path : `${destPath}/${path}`) : destPath;
|
const uploadPath = path ? (destPath === '.' ? path : `${destPath}/${path}`) : destPath;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (newBatch.abortController.signal.aborted) throw new Error('Upload canceled');
|
||||||
await api.upload(file, uploadPath, {
|
await api.upload(file, uploadPath, {
|
||||||
overwrite,
|
overwrite,
|
||||||
|
signal: newBatch.abortController.signal,
|
||||||
onProgress: (percent) => {
|
onProgress: (percent) => {
|
||||||
fileProgressMap.set(file.name + path, (percent / 100) * file.size);
|
fileProgressMap.set(file.name + path, (percent / 100) * file.size);
|
||||||
updateProgress();
|
updateProgress();
|
||||||
|
|
@ -945,7 +951,12 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
||||||
fileProgressMap.set(file.name + path, file.size);
|
fileProgressMap.set(file.name + path, file.size);
|
||||||
updateProgress();
|
updateProgress();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setUploads(prev => prev.map(b => b.id === batchId ? { ...b, status: 'error', error: err.message || 'Upload failed' } : b));
|
setUploads(prev => prev.map(b => b.id === batchId ? {
|
||||||
|
...b,
|
||||||
|
status: err.message === 'Upload canceled' ? 'canceled' : 'error',
|
||||||
|
error: err.message || 'Upload failed'
|
||||||
|
} : b));
|
||||||
|
if (err.message === 'Upload canceled') return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -2093,7 +2104,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setUploads(prev => prev.filter(u => u.status === 'uploading'))}
|
onClick={() => setUploads(prev => prev.filter(u => u.status !== 'uploading' && u.status !== 'error' && u.status !== 'canceled'))}
|
||||||
className="p-1 rounded hover:bg-white/10"
|
className="p-1 rounded hover:bg-white/10"
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4" style={{ color: 'var(--color-text-tertiary)' }} />
|
<X className="w-4 h-4" style={{ color: 'var(--color-text-tertiary)' }} />
|
||||||
|
|
@ -2104,17 +2115,28 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
||||||
const percentage = batch.totalBytes > 0 ? (batch.uploadedBytes / batch.totalBytes) * 100 : 0;
|
const percentage = batch.totalBytes > 0 ? (batch.uploadedBytes / batch.totalBytes) * 100 : 0;
|
||||||
return (
|
return (
|
||||||
<div key={batch.id} className="p-3 rounded-xl" style={{ backgroundColor: 'var(--color-bg-elevated)' }}>
|
<div key={batch.id} className="p-3 rounded-xl" style={{ backgroundColor: 'var(--color-bg-elevated)' }}>
|
||||||
<div className="flex items-center justify-between mb-1">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<span
|
<span
|
||||||
className="text-xs font-medium truncate max-w-[200px]"
|
className="text-xs font-medium truncate max-w-[180px]"
|
||||||
style={{ color: 'var(--color-text-primary)' }}
|
style={{ color: 'var(--color-text-primary)' }}
|
||||||
title={batch.name}
|
title={batch.name}
|
||||||
>
|
>
|
||||||
{batch.name}
|
{batch.name}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs" style={{ color: 'var(--color-text-tertiary)' }}>
|
<div className="flex items-center gap-3">
|
||||||
{batch.status === 'complete' ? '✓' : batch.status === 'error' ? '✗' : `${Math.round(percentage)}%`}
|
<span className="text-xs font-semibold" style={{ color: 'var(--color-text-secondary)' }}>
|
||||||
</span>
|
{batch.status === 'canceled' ? 'Canceled' : `${Math.round(percentage)}%`}
|
||||||
|
</span>
|
||||||
|
{batch.status === 'uploading' && (
|
||||||
|
<button
|
||||||
|
onClick={() => batch.abortController?.abort()}
|
||||||
|
className="p-1 rounded-full hover:bg-white/10 transition-colors"
|
||||||
|
title="Cancel upload"
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3 text-red-400 hover:text-red-300" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{batch.totalFiles > 1 && (
|
{batch.totalFiles > 1 && (
|
||||||
<div className="text-[10px] mb-2" style={{ color: 'var(--color-text-secondary)' }}>
|
<div className="text-[10px] mb-2" style={{ color: 'var(--color-text-secondary)' }}>
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,7 @@ class ApiClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async refreshAccessToken(): Promise<boolean> {
|
private async refreshAccessToken(): Promise<boolean> {
|
||||||
|
if (!this.refreshToken) return false;
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE}/api/auth/refresh`, {
|
const response = await fetch(`${API_BASE}/api/auth/refresh`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
@ -105,17 +106,35 @@ class ApiClient {
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
this.accessToken = data.access_token;
|
this.setTokens(data.access_token, data.refresh_token);
|
||||||
localStorage.setItem('access_token', data.access_token);
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
// Refresh failed
|
|
||||||
}
|
|
||||||
|
|
||||||
this.clearTokens();
|
// Refresh failed
|
||||||
if (typeof window !== 'undefined') window.dispatchEvent(new Event('auth_error'));
|
this.clearTokens();
|
||||||
return false;
|
return false;
|
||||||
|
} catch (e) {
|
||||||
|
this.clearTokens();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async ensureTokenFresh(): Promise<boolean> {
|
||||||
|
if (!this.accessToken) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parts = this.accessToken.split('.');
|
||||||
|
if (parts.length !== 3) return false;
|
||||||
|
const payload = JSON.parse(atob(parts[1]));
|
||||||
|
const exp = payload.exp * 1000;
|
||||||
|
// If expiring within 60 seconds, refresh it
|
||||||
|
if (Date.now() > exp - 60000) {
|
||||||
|
return await this.refreshAccessToken();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Setup ---
|
// --- Setup ---
|
||||||
|
|
@ -227,13 +246,17 @@ class ApiClient {
|
||||||
endpoint: `${API_BASE}/api/tus/`,
|
endpoint: `${API_BASE}/api/tus/`,
|
||||||
chunkSize: 5 * 1024 * 1024, // 5MB chunks bypass Next.js 10MB proxy limit
|
chunkSize: 5 * 1024 * 1024, // 5MB chunks bypass Next.js 10MB proxy limit
|
||||||
retryDelays: [0, 3000, 5000, 10000, 20000],
|
retryDelays: [0, 3000, 5000, 10000, 20000],
|
||||||
headers: this.getStreamHeaders(),
|
|
||||||
metadata: {
|
metadata: {
|
||||||
filename: file.name,
|
filename: file.name,
|
||||||
filetype: file.type,
|
filetype: file.type,
|
||||||
path: destPath,
|
path: destPath,
|
||||||
overwrite: options?.overwrite ? "true" : "false",
|
overwrite: options?.overwrite ? "true" : "false",
|
||||||
},
|
},
|
||||||
|
onBeforeRequest: async (req) => {
|
||||||
|
await this.ensureTokenFresh();
|
||||||
|
const xhr = req.getUnderlyingObject();
|
||||||
|
xhr.setRequestHeader('Authorization', `Bearer ${this.accessToken}`);
|
||||||
|
},
|
||||||
onError: function (error) {
|
onError: function (error) {
|
||||||
reject(error);
|
reject(error);
|
||||||
},
|
},
|
||||||
|
|
@ -245,6 +268,15 @@ class ApiClient {
|
||||||
resolve();
|
resolve();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (options?.signal) {
|
||||||
|
options.signal.addEventListener('abort', () => {
|
||||||
|
upload.abort(true).then(() => {
|
||||||
|
reject(new Error('Upload canceled'));
|
||||||
|
}).catch(reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
upload.start();
|
upload.start();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Reference in a new issue