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({