diff --git a/public/upload.html b/public/upload.html index dc7769f..ca1afe6 100644 --- a/public/upload.html +++ b/public/upload.html @@ -47,9 +47,10 @@ color: var(--text); } main { - max-width: 640px; + width: 100%; margin: 0 auto; display: grid; + grid-template-columns: minmax(0, 1fr); gap: 1.25rem; } h1 { @@ -132,6 +133,7 @@ } #list { display: grid; + grid-template-columns: minmax(0, 1fr); gap: 0.6rem; max-height: 55vh; overflow-y: auto; @@ -154,16 +156,34 @@ gap: 1rem; font-size: 0.9rem; } + .item { + min-width: 0; + } .item .name { + flex: 1 1 0; + min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .item .detail { + flex: none; color: var(--muted); white-space: nowrap; font-variant-numeric: tabular-nums; } + .item .controls { + display: inline-flex; + gap: 0.35rem; + margin-left: 0.4rem; + } + .pause:hover, + .retry:hover { + color: var(--accent); + border-color: var(--accent); + } + .pause, + .retry, .cancel { flex: none; font: inherit; @@ -276,6 +296,7 @@ const CHUNK_THRESHOLD = 32 * 1024 * 1024; // au-delà : upload fragmenté et reprenable let CHUNK_POOL = 30; // budget global de morceaux en vol ; remplacé par GET /config const CHUNK_RETRIES = 3; + const INIT_RETRY_MS = 2000; // serveur saturé (429) : délai avant de réessayer l'init let currentTarget = null; let currentTargetAllowsDirs = false; @@ -364,19 +385,27 @@ item.innerHTML = '
' + '' + - ' ' + - '' + + '' + + '' + + '' + + '' + + '' + + '' + '
' + '
' + '
En attente…' + '
'; - item.querySelector('.name').textContent = label || file.name; + const nameEl = item.querySelector('.name'); + nameEl.textContent = label || file.name; + nameEl.title = label || file.name; // nom complet au survol quand il est tronqué item.querySelector('.total').textContent = formatSize(file.size); list.prepend(item); return { bar: item.querySelector('.bar > span'), status: item.querySelector('.status'), progress: item.querySelector('.progress'), + pauseBtn: item.querySelector('.pause'), + retryBtn: item.querySelector('.retry'), cancelBtn: item.querySelector('.cancel'), }; } @@ -386,19 +415,49 @@ ui.status.className = 'status ' + (kind || 'muted'); } + // États d'un job : queued | running | paused | done | failed | cancelled. + // Les boutons visibles en dépendent. + function setState(job, state) { + job.state = state; + if (state === 'done' || state === 'failed' || state === 'cancelled') jobs.delete(job); + else jobs.add(job); + updateQueueRow(); + const { pauseBtn, retryBtn, cancelBtn } = job.ui; + pauseBtn.hidden = state !== 'running' && state !== 'paused'; + pauseBtn.textContent = state === 'paused' ? 'Reprendre' : 'Pause'; + retryBtn.hidden = state !== 'failed'; + cancelBtn.hidden = !['queued', 'running', 'paused'].includes(state); + } + + function fail(job, text) { + setStatus(job.ui, text, 'err'); + setState(job, 'failed'); + } + const queue = []; const active = new Set(); + const jobs = new Set(); // jobs non terminés (en attente, en cours ou en pause) + function updateQueueRow() { - const running = active.size; - const waiting = queue.length; - if (running + waiting === 0) { + let running = 0; + let waiting = 0; + let paused = 0; + for (const job of jobs) { + if (job.state === 'running') running += 1; + else if (job.state === 'paused') paused += 1; + else waiting += 1; + } + if (jobs.size === 0) { queueRow.hidden = true; return; } queueRow.hidden = false; queueInfo.textContent = - running + ' en cours' + (waiting ? ' · ' + waiting + ' en attente' : ''); + running + + ' en cours' + + (paused ? ' · ' + paused + ' en pause' : '') + + (waiting ? ' · ' + waiting + ' en attente' : ''); } function pump() { @@ -423,20 +482,45 @@ updateQueueRow(); } + function cancelJob(job) { + if (job.state === 'queued') { + const i = queue.indexOf(job); + if (i >= 0) queue.splice(i, 1); + setStatus(job.ui, 'Annulé', 'err'); + setState(job, 'cancelled'); + } else if ((job.state === 'running' || job.state === 'paused') && job.cancel) { + job.cancel(); + } + } + + function retryJob(job) { + job.ui.bar.style.width = '0'; + job.ui.progress.textContent = ''; + setStatus(job.ui, 'En attente…', 'muted'); + setState(job, 'queued'); + queue.push(job); + pump(); + } + function enqueue(file, relativePath) { const ui = addItem(file, relativePath); - const job = { file, relativePath, ui, chunked: file.size > CHUNK_THRESHOLD }; - queue.push(job); - ui.cancelBtn.hidden = false; - ui.cancelBtn.onclick = () => { - const i = queue.indexOf(job); - if (i < 0) return; - queue.splice(i, 1); - ui.cancelBtn.hidden = true; - setStatus(ui, 'Annulé', 'err'); - updateQueueRow(); + // La cible est figée à l'ajout : changer le sélecteur ensuite ne détourne pas la file. + const job = { + file, + relativePath, + target: currentTarget, + ui, + chunked: file.size > CHUNK_THRESHOLD, + state: 'queued', }; - updateQueueRow(); + ui.cancelBtn.onclick = () => cancelJob(job); + ui.pauseBtn.onclick = () => { + if (job.state === 'paused') job.resume(); + else if (job.state === 'running') job.pause(); + }; + ui.retryBtn.onclick = () => retryJob(job); + queue.push(job); + setState(job, 'queued'); } // Pool global de morceaux en vol, réparti équitablement entre les fichiers @@ -460,13 +544,13 @@ tick() { while (this.free > 0) { const contenders = this.entries.filter( - (e) => !e.stopped && (e.pending.length > 0 || e.priming), + (e) => !e.stopped && !e.paused && (e.pending.length > 0 || e.priming), ); if (contenders.length === 0) return; const share = Math.max(1, Math.ceil(CHUNK_POOL / contenders.length)); let best = null; for (const e of this.entries) { - if (e.stopped || e.pending.length === 0 || e.inflight >= share) continue; + if (e.stopped || e.paused || e.pending.length === 0 || e.inflight >= share) continue; if (!best || e.inflight < best.inflight) best = e; } if (!best) return; @@ -530,17 +614,16 @@ }; } - function uploadQuery(relativePath) { + function uploadQuery(job) { const p = new URLSearchParams(); - if (currentTarget) p.set('target', currentTarget); - if (relativePath) p.set('path', relativePath); + if (job.target) p.set('target', job.target); + if (job.relativePath) p.set('path', job.relativePath); const s = p.toString(); return s ? '?' + s : ''; } function finish(job) { active.delete(job); - job.ui.cancelBtn.hidden = true; pump(); } @@ -550,30 +633,52 @@ ui.bar.style.width = '100%'; ui.progress.textContent = formatSize(job.file.size); setStatus(ui, 'Déposé : ' + (body.storedName || 'OK'), 'ok'); + setState(job, 'done'); } else if (status === 401) { + fail(job, 'Session expirée'); goToLogin(); } else { - setStatus(ui, 'Erreur ' + status + ' : ' + (body.message || 'échec'), 'err'); + fail(job, 'Erreur ' + status + ' : ' + (body.message || 'échec')); } } function startUpload(job) { active.add(job); - job.ui.cancelBtn.hidden = false; - job.ui.cancelBtn.onclick = () => job.cancel && job.cancel(); setStatus(job.ui, 'Envoi…', 'muted'); - if (job.file.size > CHUNK_THRESHOLD) runChunked(job); + setState(job, 'running'); + if (job.chunked) runChunked(job); else runSimple(job); } + // Dépôt direct (petits fichiers) : une seule requête. Une requête HTTP ne + // pouvant pas être suspendue, la pause l'interrompt et la reprise redémarre + // le fichier depuis le début (il est petit, < CHUNK_THRESHOLD). function runSimple(job) { - const { file, relativePath, ui } = job; + const { file, ui } = job; const report = makeReporter(ui, file.size); const form = new FormData(); form.append('file', file); const xhr = new XMLHttpRequest(); - job.cancel = () => xhr.abort(); - xhr.open('POST', '/upload' + uploadQuery(relativePath)); + let pausing = false; + job.cancel = () => { + if (job.state === 'paused') { + setStatus(ui, 'Annulé', 'err'); + setState(job, 'cancelled'); + } else { + xhr.abort(); + } + }; + job.pause = () => { + pausing = true; + xhr.abort(); + }; + job.resume = () => { + setStatus(ui, 'En attente…', 'muted'); + setState(job, 'queued'); + queue.push(job); + pump(); + }; + xhr.open('POST', '/upload' + uploadQuery(job)); xhr.upload.addEventListener('progress', (e) => { if (e.lengthComputable) report(e.loaded); }); @@ -589,22 +694,30 @@ }); xhr.addEventListener('error', () => { finish(job); - setStatus(ui, 'Erreur réseau', 'err'); + fail(job, 'Erreur réseau'); }); xhr.addEventListener('abort', () => { finish(job); ui.progress.textContent = ''; - setStatus(ui, 'Annulé', 'err'); + ui.bar.style.width = '0'; + if (pausing) { + setStatus(ui, 'En pause (reprise au début du fichier)', 'muted'); + setState(job, 'paused'); + } else { + setStatus(ui, 'Annulé', 'err'); + setState(job, 'cancelled'); + } }); xhr.send(form); } async function runChunked(job) { - const { file, relativePath, ui } = job; + const { file, ui } = job; const report = makeReporter(ui, file.size); - const inflight = new Set(); // XHR en vol (pour l'annulation) + const inflight = new Set(); // XHR en vol (pour l'annulation et la pause) let aborted = false; let sessionId = null; + let failureMessage = null; let resolveAll = null; const finished = new Promise((r) => { @@ -618,14 +731,22 @@ } }; - const dropSession = () => { - if (sessionId) fetch('/upload/' + sessionId, { method: 'DELETE' }).catch(() => {}); + // Nettoyage au mieux : une session orpheline est de toute façon purgée par le serveur. + const dropSession = (id) => { + if (id) fetch('/upload/' + id, { method: 'DELETE' }).catch(() => {}); }; // Inscrit dans le pool dès maintenant, avant l'init réseau : le // planificateur compte ce fichier dans la part équitable (`priming`) et // lui réserve un slot même si son init n'a pas encore répondu. - const entry = { inflight: 0, stopped: false, priming: true, pending: [], send: null }; + const entry = { + inflight: 0, + stopped: false, + paused: false, + priming: true, + pending: [], + send: null, + }; chunkPool.add(entry); job.cancel = () => { @@ -634,25 +755,61 @@ entry.pending.length = 0; for (const x of inflight) x.abort(); chunkPool.remove(entry); - dropSession(); + dropSession(sessionId); finish(job); ui.progress.textContent = ''; setStatus(ui, 'Annulé', 'err'); + setState(job, 'cancelled'); finalize('abort'); }; + // Pause : les morceaux en vol sont interrompus puis replanifiés (voir le + // listener `abort`) ; plus rien n'est envoyé tant que `paused` est vrai. + job.pause = () => { + entry.paused = true; + for (const x of Array.from(inflight)) x.abort(); + setStatus(ui, 'En pause', 'muted'); + setState(job, 'paused'); + }; + job.resume = () => { + entry.paused = false; + setStatus(ui, 'Envoi…', 'muted'); + setState(job, 'running'); + chunkPool.tick(); + }; let meta; - try { - const res = await fetch('/upload/init' + uploadQuery(relativePath), { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - name: file.name, - size: file.size, - type: file.type || 'application/octet-stream', - }), - }); - meta = await res.json().catch(() => ({})); + for (;;) { + let res; + try { + res = await fetch('/upload/init' + uploadQuery(job), { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + name: file.name, + size: file.size, + type: file.type || 'application/octet-stream', + }), + }); + meta = await res.json().catch(() => ({})); + } catch { + if (aborted) return; + entry.stopped = true; + chunkPool.remove(entry); + finish(job); + fail(job, 'Erreur réseau (init)'); + return; + } + if (aborted) { + if (res.ok) dropSession(meta.id); + return; + } + if (res.status === 429) { + // Serveur saturé (autres fenêtres/onglets) : on patiente au lieu d'échouer. + setStatus(ui, 'Serveur occupé, nouvelle tentative…', 'muted'); + await new Promise((resolve) => setTimeout(resolve, INIT_RETRY_MS)); + if (aborted) return; + continue; + } if (!res.ok) { entry.stopped = true; chunkPool.remove(entry); @@ -660,14 +817,9 @@ applyResult(job, res.status, meta); return; } - } catch { - entry.stopped = true; - chunkPool.remove(entry); - finish(job); - setStatus(ui, 'Erreur réseau (init)', 'err'); - return; + break; } - if (aborted) return; + if (job.state === 'running') setStatus(ui, 'Envoi…', 'muted'); sessionId = meta.id; const chunkSize = meta.chunkSize; const chunkCount = meta.chunkCount; @@ -685,7 +837,10 @@ if (attempts[index] > CHUNK_RETRIES) { entry.stopped = true; entry.pending.length = 0; - setStatus(ui, 'Erreur morceau ' + index + ' (' + (status || 'réseau') + ')', 'err'); + failureMessage = + status === 404 + ? 'Session expirée côté serveur, réessayez' + : 'Erreur morceau ' + index + ' (' + (status || 'réseau') + ')'; finalize('fail'); releasePermit(); return; @@ -739,6 +894,12 @@ }); xhr.addEventListener('abort', () => { inflight.delete(xhr); + if (!aborted && !entry.stopped) { + // Pause : le morceau interrompu sera renvoyé à la reprise. + loadedPer[index] = 0; + emit(); + entry.pending.unshift(index); + } releasePermit(); }); xhr.send(file.slice(start, end)); @@ -752,7 +913,8 @@ if (outcome === 'fail') { for (const x of inflight) x.abort(); finish(job); - dropSession(); + dropSession(sessionId); + if (failureMessage) fail(job, failureMessage); return; } @@ -763,18 +925,13 @@ applyResult(job, res.status, body); } catch { finish(job); - setStatus(ui, 'Erreur réseau (finalisation)', 'err'); + dropSession(sessionId); + fail(job, 'Erreur réseau (finalisation)'); } } function cancelAll() { - while (queue.length) { - const job = queue.shift(); - job.ui.cancelBtn.hidden = true; - setStatus(job.ui, 'Annulé', 'err'); - } - for (const job of Array.from(active)) if (job.cancel) job.cancel(); - updateQueueRow(); + for (const job of Array.from(jobs)) cancelJob(job); } queueCancel.addEventListener('click', cancelAll); diff --git a/tests/pages.test.ts b/tests/pages.test.ts index dd5e2ac..b13df50 100644 --- a/tests/pages.test.ts +++ b/tests/pages.test.ts @@ -73,6 +73,19 @@ describe('GET /', () => { expect(res.body).not.toContain('href="/auth/login"'); }); + it('page de dépôt : boutons pause/réessayer, titres tronquables, pleine largeur', async () => { + const cookie = await login(app, idp); + const res = await app.inject({ + method: 'GET', + url: '/', + cookies: { [SESSION_COOKIE]: cookie }, + }); + expect(res.body).toContain('class="pause"'); + expect(res.body).toContain('class="retry"'); + expect(res.body).toContain('text-overflow: ellipsis'); + expect(res.body).not.toContain('max-width: 640px'); + }); + it('échappe le nom d’utilisateur avant de l’insérer dans le HTML', async () => { const cookie = await login(app, idp, { name: '' }); const res = await app.inject({