Compare commits
No commits in common. "f8dd45a6f0e32201ecccb3fc4c1a2ac95cad9c20" and "f4bd82f9adff3c4ef3d678e113bf291e4ccf1da5" have entirely different histories.
f8dd45a6f0
...
f4bd82f9ad
2 changed files with 68 additions and 238 deletions
|
|
@ -47,10 +47,9 @@
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
main {
|
main {
|
||||||
width: 100%;
|
max-width: 640px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr);
|
|
||||||
gap: 1.25rem;
|
gap: 1.25rem;
|
||||||
}
|
}
|
||||||
h1 {
|
h1 {
|
||||||
|
|
@ -133,7 +132,6 @@
|
||||||
}
|
}
|
||||||
#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;
|
||||||
|
|
@ -156,34 +154,16 @@
|
||||||
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;
|
||||||
|
|
@ -296,7 +276,6 @@
|
||||||
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;
|
||||||
|
|
@ -385,27 +364,19 @@
|
||||||
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> ' +
|
||||||
'<span class="controls">' +
|
'<button type="button" class="cancel" hidden>Annuler</button></span>' +
|
||||||
'<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>';
|
||||||
const nameEl = item.querySelector('.name');
|
item.querySelector('.name').textContent = label || file.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'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -415,49 +386,19 @@
|
||||||
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() {
|
||||||
let running = 0;
|
const running = active.size;
|
||||||
let waiting = 0;
|
const waiting = queue.length;
|
||||||
let paused = 0;
|
if (running + waiting === 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 +
|
running + ' en cours' + (waiting ? ' · ' + waiting + ' en attente' : '');
|
||||||
' en cours' +
|
|
||||||
(paused ? ' · ' + paused + ' en pause' : '') +
|
|
||||||
(waiting ? ' · ' + waiting + ' en attente' : '');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function pump() {
|
function pump() {
|
||||||
|
|
@ -482,45 +423,20 @@
|
||||||
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);
|
||||||
// La cible est figée à l'ajout : changer le sélecteur ensuite ne détourne pas la file.
|
const job = { file, relativePath, ui, chunked: file.size > CHUNK_THRESHOLD };
|
||||||
const job = {
|
|
||||||
file,
|
|
||||||
relativePath,
|
|
||||||
target: currentTarget,
|
|
||||||
ui,
|
|
||||||
chunked: file.size > CHUNK_THRESHOLD,
|
|
||||||
state: 'queued',
|
|
||||||
};
|
|
||||||
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);
|
queue.push(job);
|
||||||
setState(job, 'queued');
|
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();
|
||||||
|
};
|
||||||
|
updateQueueRow();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pool global de morceaux en vol, réparti équitablement entre les fichiers
|
// Pool global de morceaux en vol, réparti équitablement entre les fichiers
|
||||||
|
|
@ -544,13 +460,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.paused && (e.pending.length > 0 || e.priming),
|
(e) => !e.stopped && (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.paused || e.pending.length === 0 || e.inflight >= share) continue;
|
if (e.stopped || 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;
|
||||||
|
|
@ -614,16 +530,17 @@
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function uploadQuery(job) {
|
function uploadQuery(relativePath) {
|
||||||
const p = new URLSearchParams();
|
const p = new URLSearchParams();
|
||||||
if (job.target) p.set('target', job.target);
|
if (currentTarget) p.set('target', currentTarget);
|
||||||
if (job.relativePath) p.set('path', job.relativePath);
|
if (relativePath) p.set('path', 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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -633,52 +550,30 @@
|
||||||
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 {
|
||||||
fail(job, 'Erreur ' + status + ' : ' + (body.message || 'échec'));
|
setStatus(ui, 'Erreur ' + status + ' : ' + (body.message || 'échec'), 'err');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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');
|
||||||
setState(job, 'running');
|
if (job.file.size > CHUNK_THRESHOLD) runChunked(job);
|
||||||
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, ui } = job;
|
const { file, relativePath, 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();
|
||||||
let pausing = false;
|
job.cancel = () => xhr.abort();
|
||||||
job.cancel = () => {
|
xhr.open('POST', '/upload' + uploadQuery(relativePath));
|
||||||
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);
|
||||||
});
|
});
|
||||||
|
|
@ -694,30 +589,22 @@
|
||||||
});
|
});
|
||||||
xhr.addEventListener('error', () => {
|
xhr.addEventListener('error', () => {
|
||||||
finish(job);
|
finish(job);
|
||||||
fail(job, 'Erreur réseau');
|
setStatus(ui, 'Erreur réseau', 'err');
|
||||||
});
|
});
|
||||||
xhr.addEventListener('abort', () => {
|
xhr.addEventListener('abort', () => {
|
||||||
finish(job);
|
finish(job);
|
||||||
ui.progress.textContent = '';
|
ui.progress.textContent = '';
|
||||||
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');
|
setStatus(ui, 'Annulé', 'err');
|
||||||
setState(job, 'cancelled');
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
xhr.send(form);
|
xhr.send(form);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runChunked(job) {
|
async function runChunked(job) {
|
||||||
const { file, ui } = job;
|
const { file, relativePath, ui } = job;
|
||||||
const report = makeReporter(ui, file.size);
|
const report = makeReporter(ui, file.size);
|
||||||
const inflight = new Set(); // XHR en vol (pour l'annulation et la pause)
|
const inflight = new Set(); // XHR en vol (pour l'annulation)
|
||||||
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) => {
|
||||||
|
|
@ -731,22 +618,14 @@
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Nettoyage au mieux : une session orpheline est de toute façon purgée par le serveur.
|
const dropSession = () => {
|
||||||
const dropSession = (id) => {
|
if (sessionId) fetch('/upload/' + sessionId, { method: 'DELETE' }).catch(() => {});
|
||||||
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 = {
|
const entry = { inflight: 0, stopped: false, priming: true, pending: [], send: null };
|
||||||
inflight: 0,
|
|
||||||
stopped: false,
|
|
||||||
paused: false,
|
|
||||||
priming: true,
|
|
||||||
pending: [],
|
|
||||||
send: null,
|
|
||||||
};
|
|
||||||
chunkPool.add(entry);
|
chunkPool.add(entry);
|
||||||
|
|
||||||
job.cancel = () => {
|
job.cancel = () => {
|
||||||
|
|
@ -755,33 +634,16 @@
|
||||||
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(sessionId);
|
dropSession();
|
||||||
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;
|
||||||
for (;;) {
|
|
||||||
let res;
|
|
||||||
try {
|
try {
|
||||||
res = await fetch('/upload/init' + uploadQuery(job), {
|
const res = await fetch('/upload/init' + uploadQuery(relativePath), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
|
@ -791,25 +653,6 @@
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
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);
|
||||||
|
|
@ -817,9 +660,14 @@
|
||||||
applyResult(job, res.status, meta);
|
applyResult(job, res.status, meta);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
break;
|
} catch {
|
||||||
|
entry.stopped = true;
|
||||||
|
chunkPool.remove(entry);
|
||||||
|
finish(job);
|
||||||
|
setStatus(ui, 'Erreur réseau (init)', 'err');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
if (job.state === 'running') setStatus(ui, 'Envoi…', 'muted');
|
if (aborted) return;
|
||||||
sessionId = meta.id;
|
sessionId = meta.id;
|
||||||
const chunkSize = meta.chunkSize;
|
const chunkSize = meta.chunkSize;
|
||||||
const chunkCount = meta.chunkCount;
|
const chunkCount = meta.chunkCount;
|
||||||
|
|
@ -837,10 +685,7 @@
|
||||||
if (attempts[index] > CHUNK_RETRIES) {
|
if (attempts[index] > CHUNK_RETRIES) {
|
||||||
entry.stopped = true;
|
entry.stopped = true;
|
||||||
entry.pending.length = 0;
|
entry.pending.length = 0;
|
||||||
failureMessage =
|
setStatus(ui, 'Erreur morceau ' + index + ' (' + (status || 'réseau') + ')', 'err');
|
||||||
status === 404
|
|
||||||
? 'Session expirée côté serveur, réessayez'
|
|
||||||
: 'Erreur morceau ' + index + ' (' + (status || 'réseau') + ')';
|
|
||||||
finalize('fail');
|
finalize('fail');
|
||||||
releasePermit();
|
releasePermit();
|
||||||
return;
|
return;
|
||||||
|
|
@ -894,12 +739,6 @@
|
||||||
});
|
});
|
||||||
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));
|
||||||
|
|
@ -913,8 +752,7 @@
|
||||||
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(sessionId);
|
dropSession();
|
||||||
if (failureMessage) fail(job, failureMessage);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -925,13 +763,18 @@
|
||||||
applyResult(job, res.status, body);
|
applyResult(job, res.status, body);
|
||||||
} catch {
|
} catch {
|
||||||
finish(job);
|
finish(job);
|
||||||
dropSession(sessionId);
|
setStatus(ui, 'Erreur réseau (finalisation)', 'err');
|
||||||
fail(job, 'Erreur réseau (finalisation)');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelAll() {
|
function cancelAll() {
|
||||||
for (const job of Array.from(jobs)) cancelJob(job);
|
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
queueCancel.addEventListener('click', cancelAll);
|
queueCancel.addEventListener('click', cancelAll);
|
||||||
|
|
|
||||||
|
|
@ -73,19 +73,6 @@ 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({
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue