changes
This commit is contained in:
414
web/app.js
414
web/app.js
@@ -1,8 +1,8 @@
|
||||
"use strict";
|
||||
|
||||
/* ============================================================
|
||||
Transmission Manager — frontend
|
||||
Vanilla SPA. Auto-refreshes every 3s.
|
||||
Transmission Manager - frontend
|
||||
Vanilla SPA. Auto-refreshes every 3s with in-place updates.
|
||||
============================================================ */
|
||||
|
||||
const REFRESH_MS = 3000;
|
||||
@@ -24,18 +24,34 @@ const state = {
|
||||
lastError: null,
|
||||
};
|
||||
|
||||
const torrentCards = new Map();
|
||||
const actionStates = new Map();
|
||||
let syncInFlight = false;
|
||||
let syncQueued = false;
|
||||
|
||||
const $ = (sel) => document.querySelector(sel);
|
||||
|
||||
/* ---------- Fetching ---------- */
|
||||
async function fetchJSON(url) {
|
||||
const res = await fetch(url, { cache: "no-store" });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
|
||||
return res.json();
|
||||
async function fetchJSON(url, options = {}) {
|
||||
const res = await fetch(url, { cache: "no-store", ...options });
|
||||
const text = await res.text();
|
||||
let data = null;
|
||||
if (text) {
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch {
|
||||
data = text;
|
||||
}
|
||||
}
|
||||
if (!res.ok) {
|
||||
const message = data && data.error ? data.error : `HTTP ${res.status} for ${url}`;
|
||||
throw new Error(message);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
async function loadTorrents() {
|
||||
const data = await fetchJSON("/api/torrents");
|
||||
// Transmission returns { torrents: [...] }
|
||||
state.torrents = (data && data.torrents) || [];
|
||||
}
|
||||
|
||||
@@ -45,17 +61,29 @@ async function loadStats() {
|
||||
}
|
||||
|
||||
async function sync() {
|
||||
if (syncInFlight) {
|
||||
syncQueued = true;
|
||||
return;
|
||||
}
|
||||
|
||||
syncInFlight = true;
|
||||
setSyncing(true);
|
||||
try {
|
||||
await Promise.all([loadTorrents(), loadStats()]);
|
||||
state.lastError = null;
|
||||
renderAll();
|
||||
} catch (err) {
|
||||
state.lastError = err.message || String(err);
|
||||
renderError();
|
||||
} finally {
|
||||
renderAll();
|
||||
setSyncing(false);
|
||||
$("#last-updated").textContent = `updated ${formatTime(new Date())}`;
|
||||
const updated = $("#last-updated");
|
||||
if (updated) updated.textContent = `updated ${formatTime(new Date())}`;
|
||||
syncInFlight = false;
|
||||
}
|
||||
|
||||
if (syncQueued) {
|
||||
syncQueued = false;
|
||||
sync();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +95,25 @@ function setSyncing(on) {
|
||||
if (t) t.textContent = on ? "syncing" : "live";
|
||||
}
|
||||
|
||||
async function runTorrentAction(id, action) {
|
||||
const pendingText = action === "pause" ? "Pausing..." : "Resuming...";
|
||||
actionStates.set(id, { pending: action, error: null, pendingText });
|
||||
updateCardByID(id);
|
||||
|
||||
try {
|
||||
await fetchJSON(`/api/torrents/${id}/${action}`, { method: "POST" });
|
||||
actionStates.delete(id);
|
||||
await sync();
|
||||
} catch (err) {
|
||||
actionStates.set(id, {
|
||||
pending: null,
|
||||
error: err.message || String(err),
|
||||
pendingText: "",
|
||||
});
|
||||
updateCardByID(id);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Formatting helpers ---------- */
|
||||
function humanSize(bytes) {
|
||||
if (bytes == null || isNaN(bytes) || bytes < 0) return "—";
|
||||
@@ -155,24 +202,50 @@ function classify(t) {
|
||||
}
|
||||
}
|
||||
|
||||
function actionForStatus(cls) {
|
||||
const pausable = ["checking", "downloading", "queued", "seeding"].includes(cls.key);
|
||||
if (pausable) {
|
||||
return { action: "pause", label: "Pause", pendingText: "Pausing..." };
|
||||
}
|
||||
return { action: "resume", label: "Resume", pendingText: "Resuming..." };
|
||||
}
|
||||
|
||||
/* ---------- Sorting ---------- */
|
||||
const STATUS_RANK = { error: 0, downloading: 1, seeding: 2, queued: 3, checking: 4, finished: 5, paused: 6, idle: 7 };
|
||||
|
||||
function compareName(a, b) {
|
||||
return (a.name || "").localeCompare(b.name || "");
|
||||
}
|
||||
|
||||
function sortTorrents(list) {
|
||||
const key = state.sortKey;
|
||||
const sorted = list.slice();
|
||||
sorted.sort((a, b) => {
|
||||
switch (key) {
|
||||
case "name": return a.name.localeCompare(b.name);
|
||||
case "progress": return (b.percentDone || 0) - (a.percentDone || 0);
|
||||
case "ratio": return (b.uploadRatio || 0) - (a.uploadRatio || 0);
|
||||
case "size": return (b.totalSize || 0) - (a.totalSize || 0);
|
||||
case "name":
|
||||
return compareName(a, b);
|
||||
case "progress":
|
||||
return (b.percentDone || 0) - (a.percentDone || 0) || compareName(a, b);
|
||||
case "ratio":
|
||||
return (b.uploadRatio || 0) - (a.uploadRatio || 0) || compareName(a, b);
|
||||
case "size":
|
||||
return (b.totalSize || 0) - (a.totalSize || 0) || compareName(a, b);
|
||||
case "peer100": {
|
||||
const pa = peerStatus(a);
|
||||
const pb = peerStatus(b);
|
||||
return (pb.at100 - pa.at100)
|
||||
|| (pb.connected - pa.connected)
|
||||
|| ((b.percentDone || 0) - (a.percentDone || 0))
|
||||
|| compareName(a, b);
|
||||
}
|
||||
case "status": {
|
||||
const ra = STATUS_RANK[classify(a).key] ?? 99;
|
||||
const rb = STATUS_RANK[classify(b).key] ?? 99;
|
||||
if (ra !== rb) return ra - rb;
|
||||
return (b.percentDone || 0) - (a.percentDone || 0);
|
||||
return (b.percentDone || 0) - (a.percentDone || 0) || compareName(a, b);
|
||||
}
|
||||
default: return 0;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
return sorted;
|
||||
@@ -180,7 +253,6 @@ function sortTorrents(list) {
|
||||
|
||||
/* ---------- Rendering ---------- */
|
||||
function renderHeader() {
|
||||
const s = state.stats || {};
|
||||
const torrents = state.torrents || [];
|
||||
let dl = 0, ul = 0;
|
||||
for (const t of torrents) {
|
||||
@@ -192,138 +264,231 @@ function renderHeader() {
|
||||
$("#stat-ul").textContent = humanSpeed(ul);
|
||||
}
|
||||
|
||||
function badgeHTML(cls) {
|
||||
return `<span class="badge ${cls.key}"><span class="dotb"></span>${escapeHTML(cls.label)}</span>`;
|
||||
}
|
||||
|
||||
function escapeHTML(s) {
|
||||
if (s == null) return "";
|
||||
return String(s).replace(/[&<>"']/g, (c) => ({
|
||||
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
||||
})[c]);
|
||||
}
|
||||
|
||||
function initial(name) {
|
||||
const n = (name || "").trim();
|
||||
if (!n) return "?";
|
||||
// try to grab a meaningful token from the filename
|
||||
const base = n.replace(/\.[a-z0-9]+$/i, "");
|
||||
const m = base.match(/[A-Za-z0-9]/);
|
||||
return m ? m[0].toUpperCase() : "?";
|
||||
}
|
||||
|
||||
function renderCard(t) {
|
||||
function createCard() {
|
||||
const card = document.createElement("article");
|
||||
card.className = "card is-new";
|
||||
card.innerHTML = `
|
||||
<div class="card-top">
|
||||
<div class="favicon" data-field="initial"></div>
|
||||
<div class="card-body">
|
||||
<div class="card-name" data-field="name"></div>
|
||||
<div class="card-meta">
|
||||
<span class="meta-item"><span class="meta-label">Size</span> <span data-field="size"></span></span>
|
||||
<span class="meta-item"><span class="meta-label">Ratio</span> <span data-field="ratio"></span></span>
|
||||
<span class="meta-item"><span class="meta-label">Peers</span> <span data-field="peers"></span></span>
|
||||
<span class="meta-item done-meta hidden" data-field="done-meta-wrap"><span class="meta-label">Done</span> <span data-field="done-meta"></span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<div class="card-status-col">
|
||||
<span class="badge" data-field="badge"><span class="dotb"></span><span data-field="status-label"></span></span>
|
||||
<span class="pct" data-field="pct"></span>
|
||||
</div>
|
||||
<button type="button" class="action-btn" data-action-button></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="progress-wrap">
|
||||
<div class="progress-bar" data-field="progress"></div>
|
||||
</div>
|
||||
|
||||
<div class="card-bottom">
|
||||
<div class="stat-cell">
|
||||
<span class="k">Downloaded</span>
|
||||
<span class="v" data-field="downloaded"></span>
|
||||
</div>
|
||||
<div class="stat-cell">
|
||||
<span class="k">Uploaded</span>
|
||||
<span class="v" data-field="uploaded"></span>
|
||||
</div>
|
||||
<div class="stat-cell">
|
||||
<span class="k">↓ Speed</span>
|
||||
<span class="v" data-field="download-speed"></span>
|
||||
</div>
|
||||
<div class="stat-cell">
|
||||
<span class="k">↑ Speed</span>
|
||||
<span class="v" data-field="upload-speed"></span>
|
||||
</div>
|
||||
<div class="stat-cell">
|
||||
<span class="k">ETA</span>
|
||||
<span class="v" data-field="eta"></span>
|
||||
</div>
|
||||
<div class="stat-cell">
|
||||
<span class="k">Peers @ 100%</span>
|
||||
<span class="v mono" data-field="peers-100"></span>
|
||||
</div>
|
||||
<div class="stat-cell">
|
||||
<span class="k">Finished</span>
|
||||
<span class="v" data-field="finished"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="action-error hidden" data-field="action-error"></div>
|
||||
<div class="error-string hidden" data-field="error-string"></div>
|
||||
`;
|
||||
card.addEventListener("animationend", () => card.classList.remove("is-new"), { once: true });
|
||||
return card;
|
||||
}
|
||||
|
||||
function field(card, name) {
|
||||
return card.querySelector(`[data-field="${name}"]`);
|
||||
}
|
||||
|
||||
function setText(el, value) {
|
||||
const text = value == null ? "" : String(value);
|
||||
if (el.textContent !== text) el.textContent = text;
|
||||
}
|
||||
|
||||
function setValueClass(el, className) {
|
||||
el.classList.remove("accent", "success", "warn", "dim");
|
||||
if (className) el.classList.add(className);
|
||||
}
|
||||
|
||||
function updateCard(card, t) {
|
||||
const id = Number(t.id);
|
||||
const cls = classify(t);
|
||||
const pct = humanPct(t.percentDone);
|
||||
const pctText = (pct * 100).toFixed(pct === 1 ? 0 : 1) + "%";
|
||||
const isDone = pct >= 1.0;
|
||||
const ps = peerStatus(t);
|
||||
|
||||
let barClass = "";
|
||||
if (cls.key === "error") barClass = "error";
|
||||
else if (isDone) barClass = "done";
|
||||
else if (cls.key === "paused" || cls.key === "finished") barClass = "paused";
|
||||
|
||||
const dl = t.rateDownload || 0;
|
||||
const ul = t.rateUpload || 0;
|
||||
const eta = (dl > 0 && !isDone) ? formatETA(t.eta) : (isDone ? "—" : "∞");
|
||||
|
||||
const hasError = cls.key === "error";
|
||||
|
||||
return `
|
||||
<article class="card ${hasError ? "is-error" : ""}" data-id="${t.id}">
|
||||
<div class="card-top">
|
||||
<div class="favicon">${escapeHTML(initial(t.name))}</div>
|
||||
<div class="card-body">
|
||||
<div class="card-name" title="${escapeHTML(t.name)}">${escapeHTML(t.name)}</div>
|
||||
<div class="card-meta">
|
||||
<span class="meta-item"><span class="meta-label">Size</span> ${escapeHTML(humanSize(t.totalSize))}</span>
|
||||
<span class="meta-item"><span class="meta-label">Ratio</span> ${escapeHTML(humanRatio(t.uploadRatio))}</span>
|
||||
<span class="meta-item"><span class="meta-label">Peers</span> ${escapeHTML(ps.label)}</span>
|
||||
${t.doneDate ? `<span class="meta-item"><span class="meta-label">Done</span> ${escapeHTML(formatDate(t.doneDate))}</span>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-status-col">
|
||||
${badgeHTML(cls)}
|
||||
<span class="pct">${escapeHTML(pctText)}</span>
|
||||
</div>
|
||||
</div>
|
||||
let barClass = "";
|
||||
if (hasError) barClass = "error";
|
||||
else if (isDone) barClass = "done";
|
||||
else if (cls.key === "paused" || cls.key === "finished") barClass = "paused";
|
||||
|
||||
<div class="progress-wrap">
|
||||
<div class="progress-bar ${barClass}" style="width:${(pct * 100).toFixed(2)}%"></div>
|
||||
</div>
|
||||
card.dataset.id = String(id);
|
||||
card.classList.toggle("is-error", hasError);
|
||||
setText(field(card, "initial"), initial(t.name));
|
||||
|
||||
<div class="card-bottom">
|
||||
<div class="stat-cell">
|
||||
<span class="k">Downloaded</span>
|
||||
<span class="v">${escapeHTML(humanSize(t.downloadedEver))}${isDone ? ` <span class="dim">/ ${escapeHTML(humanSize(t.totalSize))}</span>` : ""}</span>
|
||||
</div>
|
||||
<div class="stat-cell">
|
||||
<span class="k">Uploaded</span>
|
||||
<span class="v">${escapeHTML(humanSize(t.uploadedEver))}</span>
|
||||
</div>
|
||||
<div class="stat-cell">
|
||||
<span class="k">↓ Speed</span>
|
||||
<span class="v ${dl > 0 ? "accent" : "dim"}">${escapeHTML(humanSpeed(dl))}</span>
|
||||
</div>
|
||||
<div class="stat-cell">
|
||||
<span class="k">↑ Speed</span>
|
||||
<span class="v ${ul > 0 ? "success" : "dim"}">${escapeHTML(humanSpeed(ul))}</span>
|
||||
</div>
|
||||
<div class="stat-cell">
|
||||
<span class="k">ETA</span>
|
||||
<span class="v">${escapeHTML(eta)}</span>
|
||||
</div>
|
||||
<div class="stat-cell">
|
||||
<span class="k">Peers @ 100%</span>
|
||||
<span class="v mono">${escapeHTML(`${ps.at100}/${ps.connected}`)}</span>
|
||||
</div>
|
||||
<div class="stat-cell">
|
||||
<span class="k">Finished</span>
|
||||
<span class="v">${escapeHTML(formatDate(t.doneDate))}</span>
|
||||
</div>
|
||||
</div>
|
||||
const nameEl = field(card, "name");
|
||||
setText(nameEl, t.name || "Unnamed torrent");
|
||||
nameEl.title = t.name || "";
|
||||
|
||||
${hasError && t.errorString ? `<div class="error-string">${escapeHTML(t.errorString)}</div>` : ""}
|
||||
</article>
|
||||
`;
|
||||
setText(field(card, "size"), humanSize(t.totalSize));
|
||||
setText(field(card, "ratio"), humanRatio(t.uploadRatio));
|
||||
setText(field(card, "peers"), ps.label);
|
||||
|
||||
const doneMetaWrap = field(card, "done-meta-wrap");
|
||||
doneMetaWrap.classList.toggle("hidden", !t.doneDate);
|
||||
setText(field(card, "done-meta"), formatDate(t.doneDate));
|
||||
|
||||
const badge = field(card, "badge");
|
||||
badge.className = `badge ${cls.key}`;
|
||||
setText(field(card, "status-label"), cls.label);
|
||||
setText(field(card, "pct"), pctText);
|
||||
|
||||
const progress = field(card, "progress");
|
||||
progress.className = `progress-bar ${barClass}`.trim();
|
||||
progress.style.width = `${(pct * 100).toFixed(2)}%`;
|
||||
|
||||
setText(field(card, "downloaded"), isDone ? `${humanSize(t.downloadedEver)} / ${humanSize(t.totalSize)}` : humanSize(t.downloadedEver));
|
||||
setText(field(card, "uploaded"), humanSize(t.uploadedEver));
|
||||
|
||||
const dlEl = field(card, "download-speed");
|
||||
setText(dlEl, humanSpeed(dl));
|
||||
setValueClass(dlEl, dl > 0 ? "accent" : "dim");
|
||||
|
||||
const ulEl = field(card, "upload-speed");
|
||||
setText(ulEl, humanSpeed(ul));
|
||||
setValueClass(ulEl, ul > 0 ? "success" : "dim");
|
||||
|
||||
setText(field(card, "eta"), eta);
|
||||
setText(field(card, "peers-100"), `${ps.at100}/${ps.connected}`);
|
||||
setText(field(card, "finished"), formatDate(t.doneDate));
|
||||
|
||||
const errorEl = field(card, "error-string");
|
||||
const errorText = hasError && t.errorString ? t.errorString : "";
|
||||
setText(errorEl, errorText);
|
||||
errorEl.classList.toggle("hidden", !errorText);
|
||||
|
||||
const action = actionForStatus(cls);
|
||||
const actionState = actionStates.get(id);
|
||||
const button = card.querySelector("[data-action-button]");
|
||||
button.dataset.action = action.action;
|
||||
button.classList.toggle("pause", action.action === "pause");
|
||||
button.classList.toggle("resume", action.action === "resume");
|
||||
button.disabled = Boolean(actionState && actionState.pending);
|
||||
button.setAttribute("aria-label", `${action.label} ${t.name || "torrent"}`);
|
||||
setText(button, actionState && actionState.pending ? actionState.pendingText : action.label);
|
||||
|
||||
const actionError = field(card, "action-error");
|
||||
const actionErrorText = actionState && actionState.error ? actionState.error : "";
|
||||
setText(actionError, actionErrorText);
|
||||
actionError.classList.toggle("hidden", !actionErrorText);
|
||||
}
|
||||
|
||||
function updateCardByID(id) {
|
||||
const card = torrentCards.get(String(id));
|
||||
const torrent = (state.torrents || []).find((t) => Number(t.id) === id);
|
||||
if (card && torrent) updateCard(card, torrent);
|
||||
}
|
||||
|
||||
function reconcileCards(torrents) {
|
||||
const list = $("#torrents");
|
||||
const visible = new Set();
|
||||
|
||||
torrents.forEach((t, index) => {
|
||||
const id = String(t.id);
|
||||
visible.add(id);
|
||||
|
||||
let card = torrentCards.get(id);
|
||||
if (!card) {
|
||||
card = createCard();
|
||||
torrentCards.set(id, card);
|
||||
}
|
||||
|
||||
updateCard(card, t);
|
||||
|
||||
const current = list.children[index];
|
||||
if (current !== card) {
|
||||
list.insertBefore(card, current || null);
|
||||
}
|
||||
});
|
||||
|
||||
for (const [id, card] of torrentCards) {
|
||||
if (!visible.has(id)) {
|
||||
card.remove();
|
||||
torrentCards.delete(id);
|
||||
actionStates.delete(Number(id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
const list = $("#torrents");
|
||||
const empty = $("#empty-state");
|
||||
const errEl = $("#error-state");
|
||||
const errMsg = $("#error-msg");
|
||||
|
||||
errEl.classList.toggle("hidden", !state.lastError);
|
||||
if (state.lastError) {
|
||||
errMsg.textContent = state.lastError;
|
||||
}
|
||||
|
||||
if (state.lastError && (!state.torrents || state.torrents.length === 0)) {
|
||||
reconcileCards([]);
|
||||
empty.classList.add("hidden");
|
||||
return;
|
||||
}
|
||||
|
||||
let torrents = sortTorrents(state.torrents || []);
|
||||
|
||||
const f = state.filter.trim().toLowerCase();
|
||||
if (f) torrents = torrents.filter((t) => (t.name || "").toLowerCase().includes(f));
|
||||
|
||||
errEl.classList.add("hidden");
|
||||
if (state.lastError) {
|
||||
list.innerHTML = "";
|
||||
empty.classList.add("hidden");
|
||||
errEl.classList.remove("hidden");
|
||||
$("#error-msg").textContent = state.lastError;
|
||||
return;
|
||||
}
|
||||
|
||||
if (torrents.length === 0) {
|
||||
list.innerHTML = "";
|
||||
empty.classList.remove("hidden");
|
||||
return;
|
||||
}
|
||||
empty.classList.add("hidden");
|
||||
list.innerHTML = torrents.map(renderCard).join("");
|
||||
}
|
||||
|
||||
function renderError() {
|
||||
// keep stats list emptied on error
|
||||
$("#torrents").innerHTML = "";
|
||||
$("#empty-state").classList.add("hidden");
|
||||
const errEl = $("#error-state");
|
||||
errEl.classList.remove("hidden");
|
||||
$("#error-msg").textContent = state.lastError || "Could not reach the Transmission daemon.";
|
||||
reconcileCards(torrents);
|
||||
empty.classList.toggle("hidden", torrents.length !== 0 || Boolean(state.lastError));
|
||||
}
|
||||
|
||||
function renderAll() {
|
||||
@@ -348,9 +513,20 @@ function wireEvents() {
|
||||
});
|
||||
});
|
||||
|
||||
$("#torrents").addEventListener("click", (e) => {
|
||||
const button = e.target.closest("[data-action-button]");
|
||||
if (!button || button.disabled) return;
|
||||
|
||||
const card = button.closest(".card");
|
||||
const id = Number(card && card.dataset.id);
|
||||
const action = button.dataset.action;
|
||||
if (!id || !["pause", "resume"].includes(action)) return;
|
||||
|
||||
runTorrentAction(id, action);
|
||||
});
|
||||
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (!document.hidden) {
|
||||
// immediate refresh when returning to the tab
|
||||
sync();
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user