changes
Some checks failed
Build and Deploy (internal) / Build Transmission Manager Image (push) Failing after 2m56s
Build and Deploy (internal) / Deploy Transmission Manager (internal) (push) Has been skipped

This commit is contained in:
2026-07-08 17:27:30 -03:00
parent 9981d0bc19
commit ec3e1ee6a3
7 changed files with 933 additions and 219 deletions

View File

@@ -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) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
})[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();
}
});

View File

@@ -53,6 +53,7 @@
<button class="sort-btn active" data-sort="status">Status</button>
<button class="sort-btn" data-sort="ratio">Ratio</button>
<button class="sort-btn" data-sort="size">Size</button>
<button class="sort-btn" data-sort="peer100">100% Peers</button>
</div>
<div class="refresh-indicator" id="refresh-indicator" title="Auto-refreshes every 3s">
<span class="dot"></span>

View File

@@ -218,8 +218,8 @@ button { font-family: inherit; }
border-radius: var(--radius);
padding: 16px;
transition: border-color .2s ease, transform .2s ease, box-shadow .2s ease;
animation: cardIn .35s cubic-bezier(0.16, 1, 0.3, 1) both;
}
.card.is-new { animation: cardIn .35s cubic-bezier(0.16, 1, 0.3, 1) both; }
@keyframes cardIn { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
.card:hover { border-color: var(--border-hover); box-shadow: var(--shadow-2); transform: translateY(-1px); }
.card.is-error { border-color: rgba(248, 113, 113, 0.3); }
@@ -265,6 +265,38 @@ button { font-family: inherit; }
font-size: 13px; font-weight: 700; color: var(--text-0);
font-variant-numeric: tabular-nums; min-width: 44px; text-align: right;
}
.card-actions {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 10px;
}
.action-btn {
min-width: 78px;
border: 1px solid var(--border-strong);
border-radius: 999px;
background: var(--bg-3);
color: var(--text-1);
cursor: pointer;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
padding: 6px 11px;
text-transform: uppercase;
transition: background .15s, border-color .15s, color .15s, transform .15s, opacity .15s;
}
.action-btn:hover:not(:disabled) {
border-color: var(--border-hover);
color: var(--text-0);
transform: translateY(-1px);
}
.action-btn.pause { color: var(--warn); background: rgba(251, 191, 36, 0.08); border-color: rgba(251, 191, 36, 0.22); }
.action-btn.resume { color: var(--accent); background: var(--accent-soft); border-color: rgba(0, 212, 255, 0.24); }
.action-btn:disabled {
cursor: wait;
opacity: 0.62;
transform: none;
}
.badge {
display: inline-flex; align-items: center; gap: 6px;
@@ -346,6 +378,15 @@ button { font-family: inherit; }
color: #fca5a5;
font-size: 12px;
}
.action-error {
margin-top: 10px;
padding: 8px 12px;
background: rgba(248, 113, 113, 0.08);
border: 1px solid rgba(248, 113, 113, 0.25);
border-radius: 8px;
color: #fca5a5;
font-size: 12px;
}
/* ---------- Empty / error states ---------- */
.empty-state, .error-state {
@@ -384,11 +425,16 @@ button { font-family: inherit; }
.refresh-indicator { margin-left: 0; width: 100%; justify-content: flex-end; }
.sort-group { width: 100%; overflow-x: auto; }
.card { padding: 14px; }
.card-top { align-items: flex-start; }
.card-actions { flex-direction: column; align-items: flex-end; gap: 7px; }
.card-bottom { grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); }
.pct { min-width: 40px; }
}
@media (max-width: 440px) {
.brand-text h1 { font-size: 15px; }
.stat-pill .stat-label { display: none; }
.card-status-col { flex-direction: column-reverse; align-items: flex-end; gap: 6px; }
.card-top { flex-wrap: wrap; }
.card-body { flex-basis: calc(100% - 42px); }
.card-actions { width: 100%; flex-direction: row; justify-content: space-between; }
.card-status-col { flex-direction: column-reverse; align-items: flex-start; gap: 6px; }
}