diff --git a/src/patchdl_websrv.c b/src/patchdl_websrv.c index 7796170..52196a7 100644 --- a/src/patchdl_websrv.c +++ b/src/patchdl_websrv.c @@ -92,7 +92,6 @@ typedef struct dl_job { int pieces_done; int pieces_failed; int inflight; - int unpersisted; /* DONE pieces since last sidecar flush */ int fd; /* O_RDWR dest fd, -1 until admit */ int rc; /* 0 ok, -1 net/io, -2 verify */ struct dl_job *next; @@ -1225,7 +1224,7 @@ admit_next(void) { patchdl_manifest_free(&q->mf); free(q->ps); q->ps = NULL; free(q->bitmap); q->bitmap = NULL; - q->done_bytes = 0; q->pieces_failed = 0; q->unpersisted = 0; q->rc = 0; + q->done_bytes = 0; q->pieces_failed = 0; q->rc = 0; q->mf = mf; q->ps = ps; q->bitmap = bitmap; q->fd = fd; q->total = total; for (int i = 0; i < mf.count; i++) q->ps[i].slot = -1; @@ -1351,7 +1350,11 @@ dl_worker(void *arg) { job->pieces_done++; job->done_bytes += sz; job->bitmap[pidx / 8] |= (unsigned char)(1 << (pidx % 8)); - if (++job->unpersisted >= 8) { write_job_state(job); job->unpersisted = 0; } + /* Persist after every completed piece: the bytes were already + fdatasync'd, and a tiny atomic sidecar write means an unclean + kill re-downloads only the pieces still in flight, not a batch + of up-to-8 already-finished ones. */ + write_job_state(job); } else if (job->abort && (job->state == JOB_PAUSING || job->state == JOB_CANCELLING)) { job->ps[pidx].state = PC_PENDING; /* aborted by pause -> redo on resume */ diff --git a/web/app.js b/web/app.js index 2fc7fd3..30fc661 100644 --- a/web/app.js +++ b/web/app.js @@ -117,7 +117,9 @@ function bindElements() { deleteAfterInstall: document.getElementById("deleteAfterInstall"), verifyDownloads: document.getElementById("verifyDownloads"), homeShortcut: document.getElementById("homeShortcut"), - maxConnections: document.getElementById("maxConnections"), + connValue: document.getElementById("connValue"), + connMinus: document.getElementById("connMinus"), + connPlus: document.getElementById("connPlus"), refreshBtn: document.getElementById("refreshBtn"), saveBtn: document.getElementById("saveBtn"), clearLogBtn: document.getElementById("clearLogBtn"), @@ -150,6 +152,10 @@ function bindEvents() { els.refreshBtn.addEventListener("click", loadInitialData); els.saveBtn.addEventListener("click", saveConfig); els.clearLogBtn.addEventListener("click", () => { state.logs = []; renderLogs(); }); + if (els.connMinus) + els.connMinus.addEventListener("click", () => setConnections(clampConn(state.config.max_connections) - 1)); + if (els.connPlus) + els.connPlus.addEventListener("click", () => setConnections(clampConn(state.config.max_connections) + 1)); } function setView(view) { @@ -228,6 +234,40 @@ function renderStatus() { if (els.railSpace) els.railSpace.textContent = `${space} free`; } +const CONN_MIN = 1, CONN_MAX = 16; +function clampConn(n) { + n = parseInt(n, 10); + if (!Number.isFinite(n)) n = 4; + return Math.max(CONN_MIN, Math.min(CONN_MAX, n)); +} + +function renderConnStepper() { + const n = clampConn(state.config.max_connections); + if (els.connValue) els.connValue.textContent = String(n); + if (els.connMinus) els.connMinus.disabled = n <= CONN_MIN; + if (els.connPlus) els.connPlus.disabled = n >= CONN_MAX; +} + +let connSaveTimer = null; +// Stepper +/-: update + re-render immediately, then persist just this field +// (debounced) so rapid taps collapse into one POST and other unsaved form +// fields stay untouched. The server applies the new count live (no restart). +function setConnections(n) { + const v = clampConn(n); + if (v === clampConn(state.config.max_connections)) { renderConnStepper(); return; } + state.config.max_connections = v; + renderConnStepper(); + clearTimeout(connSaveTimer); + connSaveTimer = setTimeout(async () => { + try { + await postJson(API.config, { max_connections: v }); + showToast(`Parallel connections: ${v}`); + } catch (e) { + showToast("Could not save connections — API not reachable."); + } + }, 450); +} + function renderSettings() { els.defaultPolicy.value = state.config.default_policy || "deny"; els.downloadDir.value = state.config.download_dir || ""; @@ -235,7 +275,7 @@ function renderSettings() { els.deleteAfterInstall.checked = Boolean(state.config.delete_pkg_after_install); if (els.verifyDownloads) els.verifyDownloads.checked = Boolean(state.config.verify_downloads); if (els.homeShortcut) els.homeShortcut.checked = state.config.home_shortcut !== false; - if (els.maxConnections) els.maxConnections.value = state.config.max_connections || 4; + renderConnStepper(); els.allowlistHosts.replaceChildren(...(state.config.cdn_allowlist || []).map((host) => { const chip = document.createElement("span"); chip.className = "host-chip"; @@ -615,9 +655,7 @@ async function saveConfig() { delete_pkg_after_install: els.deleteAfterInstall.checked, verify_downloads: els.verifyDownloads ? els.verifyDownloads.checked : Boolean(state.config.verify_downloads), home_shortcut: els.homeShortcut ? els.homeShortcut.checked : state.config.home_shortcut !== false, - max_connections: els.maxConnections - ? Math.max(1, Math.min(16, parseInt(els.maxConnections.value, 10) || 4)) - : (state.config.max_connections || 4), + max_connections: clampConn(state.config.max_connections), }; try { await postJson(API.config, config); diff --git a/web/index.html b/web/index.html index 636dd19..3637841 100644 --- a/web/index.html +++ b/web/index.html @@ -186,11 +186,17 @@ - +