Skip to content

Commit 10e8134

Browse files
feat(uploads): resilient uploads — retry, background queue, space pre-check
Big uploads (ISOs, archives) are no longer lost to a hiccup: - Space pre-check: before uploading, a file that won't fit in the owner's remaining quota is PAUSED (never attempted) with a clear "needs space" message, instead of streaming gigabytes only to be rejected. - Auto-retry: an upload that fails online (network blip, 5xx) is handed to the persistent IndexedDB queue and retried in the background with exponential backoff capped at 5 min — so it keeps trying on its own. - Freeze, don't cancel: a 413/507 (owner quota or server disk full) pauses the item rather than dropping it; the user frees space and retries. - Manual control: a floating Transfers panel lists queued/paused uploads with per-item retry, "retry all", and remove. - The open Drive view refreshes when a background upload finally lands. Reuses the existing offline queue (extended with size/attempts/status/ backoff). Zero-Knowledge uploads stay inline (client-encrypted); this covers normal and public spaces. Web-only.
1 parent e63026c commit 10e8134

6 files changed

Lines changed: 264 additions & 25 deletions

File tree

‎apps/web/src/app/(app)/drive/page.tsx‎

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -119,8 +119,8 @@ function passKey(spaceId: string) {
119119
}
120120

121121
export default function EspacesPage() {
122-
const { refresh } = useAuth();
123-
const { online } = useOffline();
122+
const { user, refresh } = useAuth();
123+
const { online, enqueueForRetry } = useOffline();
124124
const { t } = useT();
125125
const [allFolders, setAllFolders] = useState<PublicFolder[]>([]);
126126
const [activeSpaceId, setActiveSpaceId] = useState<string | null>(null);
@@ -508,13 +508,45 @@ export default function EspacesPage() {
508508
await api.upload('/zk/files', form);
509509
}
510510
} else {
511+
// Server-side upload with a space pre-check and a resilient fallback: a file that won't
512+
// fit is PAUSED (never attempted) and one that fails mid-flight is handed to the
513+
// background retry queue — so a big upload survives a hiccup instead of being lost.
514+
const spaceName = activeSpace?.name ?? '';
515+
let remaining = user?.quotaBytes != null ? user.quotaBytes - user.usedBytes : Infinity;
516+
let uploaded = 0;
511517
for (let i = 0; i < arr.length; i += 1) {
518+
const f = arr[i]!;
519+
if (Number.isFinite(remaining) && f.size > remaining) {
520+
await enqueueForRetry(f, currentFolderId, spaceName, {
521+
blocked: true,
522+
error: t('offline.errNoSpacePre', { need: formatBytes(f.size), free: formatBytes(Math.max(0, remaining)) }),
523+
});
524+
toast(t('drive.uploadPausedSpace', { name: f.name }), 'info');
525+
continue;
526+
}
512527
setBusy(t('drive.uploading', { i: i + 1, n: arr.length }));
513528
setProgress(0);
514529
const form = new FormData();
515-
form.append('file', arr[i]!);
516-
await api.uploadWithProgress(`/files?folderId=${currentFolderId}`, form, setProgress);
530+
form.append('file', f);
531+
try {
532+
await api.uploadWithProgress(`/files?folderId=${currentFolderId}`, form, setProgress);
533+
remaining -= f.size;
534+
uploaded += 1;
535+
} catch (err) {
536+
const status = err instanceof ApiError ? err.status : 0;
537+
const blocked = status === 413 || status === 507;
538+
await enqueueForRetry(f, currentFolderId, spaceName, {
539+
blocked,
540+
error: err instanceof ApiError ? err.message : t('offline.errNetwork'),
541+
});
542+
toast(blocked ? t('drive.uploadPausedSpace', { name: f.name }) : t('drive.uploadQueuedRetry', { name: f.name }), 'info');
543+
}
517544
}
545+
await Promise.all([loadItems(currentFolderId, isZk, vaultKey), refresh()]);
546+
if (uploaded > 0) {
547+
toast(uploaded > 1 ? t('drive.filesImportedMany', { n: uploaded }) : t('drive.filesImportedOne', { n: uploaded }), 'success');
548+
}
549+
return;
518550
}
519551
await Promise.all([loadItems(currentFolderId, isZk, vaultKey), refresh()]);
520552
toast(arr.length > 1 ? t('drive.filesImportedMany', { n: arr.length }) : t('drive.filesImportedOne', { n: arr.length }), 'success');
@@ -526,7 +558,7 @@ export default function EspacesPage() {
526558
if (fileInput.current) fileInput.current.value = '';
527559
}
528560
},
529-
[currentFolderId, isZk, vaultKey, loadItems, refresh, t],
561+
[currentFolderId, isZk, vaultKey, loadItems, refresh, t, user, enqueueForRetry, activeSpace],
530562
);
531563

532564
// Full-window drag & drop: a drop anywhere over the page uploads into the open folder.
@@ -692,6 +724,15 @@ export default function EspacesPage() {
692724
if (currentFolderId) await Promise.all([loadItems(currentFolderId, isZk, vaultKey), refresh()]);
693725
}
694726

727+
// Refresh the open folder when a background (retry) upload lands, so the file appears.
728+
const reloadCurrentRef = useRef(reloadCurrent);
729+
reloadCurrentRef.current = reloadCurrent;
730+
useEffect(() => {
731+
const h = () => void reloadCurrentRef.current();
732+
window.addEventListener('ocl:queue-flushed', h);
733+
return () => window.removeEventListener('ocl:queue-flushed', h);
734+
}, []);
735+
695736
// ── Selection ───────────────────────────────────────────────────────────────
696737
function toggleKey(key: string) {
697738
setSelected((prev) => {

‎apps/web/src/app/(app)/layout.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { LanguageSwitcher } from '@/components/LanguageSwitcher';
1111
import { StatusBanner } from '@/components/StatusBanner';
1212
import { OfflineBanner } from '@/components/OfflineBanner';
1313
import { DownloadIndicator } from '@/components/DownloadIndicator';
14+
import { TransfersPanel } from '@/components/TransfersPanel';
1415
import { WhatsNew } from '@/components/WhatsNew';
1516
import { formatBytes } from '@opencoperlock/shared/client';
1617

@@ -174,6 +175,7 @@ export default function AppLayout({ children }: { children: React.ReactNode }) {
174175
<main className="mx-auto max-w-5xl px-4 py-6 sm:px-6 md:px-8 md:py-8">{children}</main>
175176
</div>
176177
<DownloadIndicator />
178+
<TransfersPanel />
177179
<WhatsNew />
178180
</div>
179181
);
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
'use client';
2+
3+
/**
4+
* Floating panel listing uploads that are being retried in the background or paused for lack of
5+
* space. Lets the user retry a paused item (after freeing space), retry them all, or drop one.
6+
* Hidden when the queue is empty.
7+
*/
8+
import { UploadCloud, RefreshCw, PauseCircle, X } from 'lucide-react';
9+
import { formatBytes } from '@opencoperlock/shared/client';
10+
import { useOffline } from '@/lib/offline';
11+
import { useT } from '@/lib/i18n';
12+
13+
export function TransfersPanel() {
14+
const { items, retryItem, retryAll, removeItem } = useOffline();
15+
const { t } = useT();
16+
if (items.length === 0) return null;
17+
const blocked = items.filter((i) => i.status === 'blocked').length;
18+
19+
return (
20+
<div className="fixed bottom-4 right-4 z-[110] w-80 max-w-[calc(100vw-2rem)] overflow-hidden rounded-xl border border-white/10 bg-[#15151d] shadow-2xl">
21+
<div className="flex items-center justify-between gap-2 border-b border-white/[0.07] px-3 py-2">
22+
<span className="flex items-center gap-2 text-sm font-medium text-zinc-200">
23+
<UploadCloud size={15} className="text-violet-300" /> {t('transfers.title', { n: items.length })}
24+
</span>
25+
{blocked > 0 && (
26+
<button className="text-xs text-violet-300 transition hover:text-violet-200" onClick={() => void retryAll()}>
27+
{t('transfers.retryAll')}
28+
</button>
29+
)}
30+
</div>
31+
<div className="max-h-64 space-y-0.5 overflow-y-auto p-1.5">
32+
{items.map((it) => (
33+
<div key={it.id} className="flex items-center gap-2 rounded-lg px-2 py-1.5 hover:bg-white/[0.03]">
34+
<span className={`shrink-0 ${it.status === 'blocked' ? 'text-amber-300' : 'text-zinc-400'}`}>
35+
{it.status === 'blocked' ? <PauseCircle size={15} /> : <RefreshCw size={15} className="animate-spin" />}
36+
</span>
37+
<div className="min-w-0 flex-1">
38+
<p className="truncate text-xs text-zinc-100">{it.name}</p>
39+
<p className="truncate text-[11px] text-zinc-500">
40+
{formatBytes(it.size)} ·{' '}
41+
{it.status === 'blocked' ? it.lastError ?? t('transfers.paused') : t('transfers.retrying')}
42+
</p>
43+
</div>
44+
{it.status === 'blocked' && it.id != null && (
45+
<button
46+
className="shrink-0 rounded p-1 text-zinc-400 transition hover:text-violet-300"
47+
title={t('transfers.retry')}
48+
onClick={() => void retryItem(it.id!)}
49+
>
50+
<RefreshCw size={14} />
51+
</button>
52+
)}
53+
{it.id != null && (
54+
<button
55+
className="shrink-0 rounded p-1 text-zinc-400 transition hover:text-red-300"
56+
title={t('transfers.remove')}
57+
onClick={() => void removeItem(it.id!)}
58+
>
59+
<X size={14} />
60+
</button>
61+
)}
62+
</div>
63+
))}
64+
</div>
65+
</div>
66+
);
67+
}

‎apps/web/src/lib/i18n.tsx‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,16 @@ const fr: Dict = {
5757
'offline.remove': 'Retirer',
5858
'offline.willSync': 'Ces fichiers seront envoyés et effacés de cet appareil dès le retour du réseau.',
5959
'offline.synced': '{n} fichier(s) synchronisé(s)',
60+
'offline.errNetwork': 'Échec réseau',
61+
'offline.errNoSpacePre': 'Espace insuffisant ({need} requis, {free} libre)',
62+
'transfers.title': 'Transferts ({n})',
63+
'transfers.retryAll': 'Tout réessayer',
64+
'transfers.retry': 'Réessayer',
65+
'transfers.remove': 'Retirer',
66+
'transfers.paused': 'En pause',
67+
'transfers.retrying': 'Nouvelle tentative…',
68+
'drive.uploadPausedSpace': '« {name} » en pause : espace insuffisant',
69+
'drive.uploadQueuedRetry': '« {name} » : échec, nouvelle tentative en arrière-plan',
6070
'download.started': 'Téléchargement…',
6171
// file viewer / editor
6272
'viewer.preview': 'Aperçu',
@@ -684,6 +694,16 @@ const en: Dict = {
684694
'offline.remove': 'Remove',
685695
'offline.willSync': 'These files will be uploaded and removed from this device as soon as the network returns.',
686696
'offline.synced': '{n} file(s) synced',
697+
'offline.errNetwork': 'Network failure',
698+
'offline.errNoSpacePre': 'Not enough space ({need} needed, {free} free)',
699+
'transfers.title': 'Transfers ({n})',
700+
'transfers.retryAll': 'Retry all',
701+
'transfers.retry': 'Retry',
702+
'transfers.remove': 'Remove',
703+
'transfers.paused': 'Paused',
704+
'transfers.retrying': 'Retrying…',
705+
'drive.uploadPausedSpace': '“{name}” paused: not enough space',
706+
'drive.uploadQueuedRetry': '“{name}”: failed, retrying in the background',
687707
'download.started': 'Downloading…',
688708
'viewer.preview': 'Preview',
689709
'viewer.source': 'Source',

0 commit comments

Comments
 (0)