Compare commits

..

2 commits

Author SHA1 Message Date
f8dd45a6f0 Merge pull request 'feat(ui): pause, réessayer, titres tronqués et pleine largeur' (#9) from feat/upload-ui-controls into main
All checks were successful
Publier l'image Docker / test (push) Successful in 21s
Publier l'image Docker / publish (push) Successful in 44s
2026-10-13 20:32:59 +02:00
5ef89e87a4 feat(ui): pause, réessayer, titres tronqués et pleine largeur
- bouton Pause/Reprendre par fichier (#6)
- bouton Réessayer sur les échecs (#7)
- noms de fichiers tronqués avec ellipse + titre au survol (#5)
- contenu pleine largeur (#4)
- cible figée à l'ajout, nouvelle tentative d'init sur 429 (#1, #3)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 20:32:41 +02:00
2 changed files with 238 additions and 68 deletions

View file

@ -47,9 +47,10 @@
color: var(--text); color: var(--text);
} }
main { main {
max-width: 640px; width: 100%;
margin: 0 auto; margin: 0 auto;
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 1.25rem; gap: 1.25rem;
} }
h1 { h1 {
@ -132,6 +133,7 @@
} }
#list { #list {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 0.6rem; gap: 0.6rem;
max-height: 55vh; max-height: 55vh;
overflow-y: auto; overflow-y: auto;
@ -154,16 +156,34 @@
gap: 1rem; gap: 1rem;
font-size: 0.9rem; font-size: 0.9rem;
} }
.item {
min-width: 0;
}
.item .name { .item .name {
flex: 1 1 0;
min-width: 0;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.item .detail { .item .detail {
flex: none;
color: var(--muted); color: var(--muted);
white-space: nowrap; white-space: nowrap;
font-variant-numeric: tabular-nums; 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 { .cancel {
flex: none; flex: none;
font: inherit; font: inherit;
@ -276,6 +296,7 @@
const CHUNK_THRESHOLD = 32 * 1024 * 1024; // au-delà : upload fragmenté et reprenable 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 let CHUNK_POOL = 30; // budget global de morceaux en vol ; remplacé par GET /config
const CHUNK_RETRIES = 3; const CHUNK_RETRIES = 3;
const INIT_RETRY_MS = 2000; // serveur saturé (429) : délai avant de réessayer l'init
let currentTarget = null; let currentTarget = null;
let currentTargetAllowsDirs = false; let currentTargetAllowsDirs = false;
@ -364,19 +385,27 @@
item.innerHTML = item.innerHTML =
'<div class="row">' + '<div class="row">' +
'<span class="name"></span>' + '<span class="name"></span>' +
'<span class="detail"><span class="total"></span> ' + '<span class="detail"><span class="total"></span>' +
'<button type="button" class="cancel" hidden>Annuler</button></span>' + '<span class="controls">' +
'<button type="button" class="pause" hidden>Pause</button>' +
'<button type="button" class="retry" hidden>Réessayer</button>' +
'<button type="button" class="cancel" hidden>Annuler</button>' +
'</span></span>' +
'</div>' + '</div>' +
'<div class="bar"><span></span></div>' + '<div class="bar"><span></span></div>' +
'<div class="row"><span class="status muted">En attente…</span>' + '<div class="row"><span class="status muted">En attente…</span>' +
'<span class="detail progress"></span></div>'; '<span class="detail progress"></span></div>';
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); item.querySelector('.total').textContent = formatSize(file.size);
list.prepend(item); list.prepend(item);
return { return {
bar: item.querySelector('.bar > span'), bar: item.querySelector('.bar > span'),
status: item.querySelector('.status'), status: item.querySelector('.status'),
progress: item.querySelector('.progress'), progress: item.querySelector('.progress'),
pauseBtn: item.querySelector('.pause'),
retryBtn: item.querySelector('.retry'),
cancelBtn: item.querySelector('.cancel'), cancelBtn: item.querySelector('.cancel'),
}; };
} }
@ -386,19 +415,49 @@
ui.status.className = 'status ' + (kind || 'muted'); 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 queue = [];
const active = new Set(); const active = new Set();
const jobs = new Set(); // jobs non terminés (en attente, en cours ou en pause)
function updateQueueRow() { function updateQueueRow() {
const running = active.size; let running = 0;
const waiting = queue.length; let waiting = 0;
if (running + 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; queueRow.hidden = true;
return; return;
} }
queueRow.hidden = false; queueRow.hidden = false;
queueInfo.textContent = queueInfo.textContent =
running + ' en cours' + (waiting ? ' · ' + waiting + ' en attente' : ''); running +
' en cours' +
(paused ? ' · ' + paused + ' en pause' : '') +
(waiting ? ' · ' + waiting + ' en attente' : '');
} }
function pump() { function pump() {
@ -423,20 +482,45 @@
updateQueueRow(); 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) { function enqueue(file, relativePath) {
const ui = addItem(file, relativePath); const ui = addItem(file, relativePath);
const job = { file, relativePath, ui, chunked: file.size > CHUNK_THRESHOLD }; // La cible est figée à l'ajout : changer le sélecteur ensuite ne détourne pas la file.
queue.push(job); const job = {
ui.cancelBtn.hidden = false; file,
ui.cancelBtn.onclick = () => { relativePath,
const i = queue.indexOf(job); target: currentTarget,
if (i < 0) return; ui,
queue.splice(i, 1); chunked: file.size > CHUNK_THRESHOLD,
ui.cancelBtn.hidden = true; state: 'queued',
setStatus(ui, 'Annulé', 'err');
updateQueueRow();
}; };
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 // Pool global de morceaux en vol, réparti équitablement entre les fichiers
@ -460,13 +544,13 @@
tick() { tick() {
while (this.free > 0) { while (this.free > 0) {
const contenders = this.entries.filter( 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; if (contenders.length === 0) return;
const share = Math.max(1, Math.ceil(CHUNK_POOL / contenders.length)); const share = Math.max(1, Math.ceil(CHUNK_POOL / contenders.length));
let best = null; let best = null;
for (const e of this.entries) { 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 || e.inflight < best.inflight) best = e;
} }
if (!best) return; if (!best) return;
@ -530,17 +614,16 @@
}; };
} }
function uploadQuery(relativePath) { function uploadQuery(job) {
const p = new URLSearchParams(); const p = new URLSearchParams();
if (currentTarget) p.set('target', currentTarget); if (job.target) p.set('target', job.target);
if (relativePath) p.set('path', relativePath); if (job.relativePath) p.set('path', job.relativePath);
const s = p.toString(); const s = p.toString();
return s ? '?' + s : ''; return s ? '?' + s : '';
} }
function finish(job) { function finish(job) {
active.delete(job); active.delete(job);
job.ui.cancelBtn.hidden = true;
pump(); pump();
} }
@ -550,30 +633,52 @@
ui.bar.style.width = '100%'; ui.bar.style.width = '100%';
ui.progress.textContent = formatSize(job.file.size); ui.progress.textContent = formatSize(job.file.size);
setStatus(ui, 'Déposé : ' + (body.storedName || 'OK'), 'ok'); setStatus(ui, 'Déposé : ' + (body.storedName || 'OK'), 'ok');
setState(job, 'done');
} else if (status === 401) { } else if (status === 401) {
fail(job, 'Session expirée');
goToLogin(); goToLogin();
} else { } else {
setStatus(ui, 'Erreur ' + status + ' : ' + (body.message || 'échec'), 'err'); fail(job, 'Erreur ' + status + ' : ' + (body.message || 'échec'));
} }
} }
function startUpload(job) { function startUpload(job) {
active.add(job); active.add(job);
job.ui.cancelBtn.hidden = false;
job.ui.cancelBtn.onclick = () => job.cancel && job.cancel();
setStatus(job.ui, 'Envoi…', 'muted'); setStatus(job.ui, 'Envoi…', 'muted');
if (job.file.size > CHUNK_THRESHOLD) runChunked(job); setState(job, 'running');
if (job.chunked) runChunked(job);
else runSimple(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) { function runSimple(job) {
const { file, relativePath, ui } = job; const { file, ui } = job;
const report = makeReporter(ui, file.size); const report = makeReporter(ui, file.size);
const form = new FormData(); const form = new FormData();
form.append('file', file); form.append('file', file);
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
job.cancel = () => xhr.abort(); let pausing = false;
xhr.open('POST', '/upload' + uploadQuery(relativePath)); 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) => { xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) report(e.loaded); if (e.lengthComputable) report(e.loaded);
}); });
@ -589,22 +694,30 @@
}); });
xhr.addEventListener('error', () => { xhr.addEventListener('error', () => {
finish(job); finish(job);
setStatus(ui, 'Erreur réseau', 'err'); fail(job, 'Erreur réseau');
}); });
xhr.addEventListener('abort', () => { xhr.addEventListener('abort', () => {
finish(job); finish(job);
ui.progress.textContent = ''; 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); xhr.send(form);
} }
async function runChunked(job) { async function runChunked(job) {
const { file, relativePath, ui } = job; const { file, ui } = job;
const report = makeReporter(ui, file.size); 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 aborted = false;
let sessionId = null; let sessionId = null;
let failureMessage = null;
let resolveAll = null; let resolveAll = null;
const finished = new Promise((r) => { const finished = new Promise((r) => {
@ -618,14 +731,22 @@
} }
}; };
const dropSession = () => { // Nettoyage au mieux : une session orpheline est de toute façon purgée par le serveur.
if (sessionId) fetch('/upload/' + sessionId, { method: 'DELETE' }).catch(() => {}); const dropSession = (id) => {
if (id) fetch('/upload/' + id, { method: 'DELETE' }).catch(() => {});
}; };
// Inscrit dans le pool dès maintenant, avant l'init réseau : le // Inscrit dans le pool dès maintenant, avant l'init réseau : le
// planificateur compte ce fichier dans la part équitable (`priming`) et // 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. // 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); chunkPool.add(entry);
job.cancel = () => { job.cancel = () => {
@ -634,25 +755,61 @@
entry.pending.length = 0; entry.pending.length = 0;
for (const x of inflight) x.abort(); for (const x of inflight) x.abort();
chunkPool.remove(entry); chunkPool.remove(entry);
dropSession(); dropSession(sessionId);
finish(job); finish(job);
ui.progress.textContent = ''; ui.progress.textContent = '';
setStatus(ui, 'Annulé', 'err'); setStatus(ui, 'Annulé', 'err');
setState(job, 'cancelled');
finalize('abort'); 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; let meta;
try { for (;;) {
const res = await fetch('/upload/init' + uploadQuery(relativePath), { let res;
method: 'POST', try {
headers: { 'content-type': 'application/json' }, res = await fetch('/upload/init' + uploadQuery(job), {
body: JSON.stringify({ method: 'POST',
name: file.name, headers: { 'content-type': 'application/json' },
size: file.size, body: JSON.stringify({
type: file.type || 'application/octet-stream', name: file.name,
}), size: file.size,
}); type: file.type || 'application/octet-stream',
meta = await res.json().catch(() => ({})); }),
});
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) { if (!res.ok) {
entry.stopped = true; entry.stopped = true;
chunkPool.remove(entry); chunkPool.remove(entry);
@ -660,14 +817,9 @@
applyResult(job, res.status, meta); applyResult(job, res.status, meta);
return; return;
} }
} catch { break;
entry.stopped = true;
chunkPool.remove(entry);
finish(job);
setStatus(ui, 'Erreur réseau (init)', 'err');
return;
} }
if (aborted) return; if (job.state === 'running') setStatus(ui, 'Envoi…', 'muted');
sessionId = meta.id; sessionId = meta.id;
const chunkSize = meta.chunkSize; const chunkSize = meta.chunkSize;
const chunkCount = meta.chunkCount; const chunkCount = meta.chunkCount;
@ -685,7 +837,10 @@
if (attempts[index] > CHUNK_RETRIES) { if (attempts[index] > CHUNK_RETRIES) {
entry.stopped = true; entry.stopped = true;
entry.pending.length = 0; 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'); finalize('fail');
releasePermit(); releasePermit();
return; return;
@ -739,6 +894,12 @@
}); });
xhr.addEventListener('abort', () => { xhr.addEventListener('abort', () => {
inflight.delete(xhr); inflight.delete(xhr);
if (!aborted && !entry.stopped) {
// Pause : le morceau interrompu sera renvoyé à la reprise.
loadedPer[index] = 0;
emit();
entry.pending.unshift(index);
}
releasePermit(); releasePermit();
}); });
xhr.send(file.slice(start, end)); xhr.send(file.slice(start, end));
@ -752,7 +913,8 @@
if (outcome === 'fail') { if (outcome === 'fail') {
for (const x of inflight) x.abort(); for (const x of inflight) x.abort();
finish(job); finish(job);
dropSession(); dropSession(sessionId);
if (failureMessage) fail(job, failureMessage);
return; return;
} }
@ -763,18 +925,13 @@
applyResult(job, res.status, body); applyResult(job, res.status, body);
} catch { } catch {
finish(job); finish(job);
setStatus(ui, 'Erreur réseau (finalisation)', 'err'); dropSession(sessionId);
fail(job, 'Erreur réseau (finalisation)');
} }
} }
function cancelAll() { function cancelAll() {
while (queue.length) { for (const job of Array.from(jobs)) cancelJob(job);
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();
} }
queueCancel.addEventListener('click', cancelAll); queueCancel.addEventListener('click', cancelAll);

View file

@ -73,6 +73,19 @@ describe('GET /', () => {
expect(res.body).not.toContain('href="/auth/login"'); 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 () => { it('échappe le nom d’utilisateur avant de l’insérer dans le HTML', async () => {
const cookie = await login(app, idp, { name: '<script>alert(1)</script>' }); const cookie = await login(app, idp, { name: '<script>alert(1)</script>' });
const res = await app.inject({ const res = await app.inject({