Files
transmission-manager/web/app.js
Jose Henrique ec3e1ee6a3
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
changes
2026-07-08 17:27:30 -03:00

543 lines
16 KiB
JavaScript

"use strict";
/* ============================================================
Transmission Manager - frontend
Vanilla SPA. Auto-refreshes every 3s with in-place updates.
============================================================ */
const REFRESH_MS = 3000;
const TRANSMISSION_STATUSES = {
0: "stopped",
1: "check-wait",
2: "checking",
3: "download-wait",
4: "downloading",
5: "seed-wait",
6: "seeding",
};
const state = {
torrents: [],
stats: {},
filter: "",
sortKey: "status",
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, 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");
state.torrents = (data && data.torrents) || [];
}
async function loadStats() {
const data = await fetchJSON("/api/stats");
state.stats = data || {};
}
async function sync() {
if (syncInFlight) {
syncQueued = true;
return;
}
syncInFlight = true;
setSyncing(true);
try {
await Promise.all([loadTorrents(), loadStats()]);
state.lastError = null;
} catch (err) {
state.lastError = err.message || String(err);
} finally {
renderAll();
setSyncing(false);
const updated = $("#last-updated");
if (updated) updated.textContent = `updated ${formatTime(new Date())}`;
syncInFlight = false;
}
if (syncQueued) {
syncQueued = false;
sync();
}
}
function setSyncing(on) {
const el = $("#refresh-indicator");
if (!el) return;
el.classList.toggle("syncing", on);
const t = el.querySelector(".refresh-text");
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 "—";
if (bytes === 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB", "PB"];
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
const v = bytes / Math.pow(1024, i);
const digits = i === 0 ? 0 : v < 10 ? 2 : v < 100 ? 1 : 0;
return `${v.toFixed(digits)} ${units[i]}`;
}
function humanSpeed(bytesPerSec) {
if (!bytesPerSec || bytesPerSec <= 0) return "0 kB/s";
return humanSize(bytesPerSec) + "/s";
}
function humanRatio(ratio) {
if (ratio == null || ratio < 0 || isNaN(ratio)) return "—";
if (ratio === -1) return "∞";
return ratio.toFixed(2);
}
function humanPct(p) {
if (p == null || isNaN(p)) return 0;
if (p < 0) return 0;
if (p >= 1) return 1;
return p;
}
function formatETA(sec) {
if (sec == null || sec < 0 || isNaN(sec)) return "—";
if (sec === 0) return "—";
if (sec < 60) return `${sec}s`;
const m = Math.floor(sec / 60);
if (m < 60) return `${m}m ${sec % 60}s`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ${m % 60}m`;
const d = Math.floor(h / 24);
return `${d}d ${h % 24}h`;
}
function formatDate(unix) {
if (!unix || unix <= 0) return "—";
const d = new Date(unix * 1000);
if (isNaN(d.getTime())) return "—";
return d.toLocaleDateString(undefined, {
year: "numeric", month: "short", day: "numeric",
});
}
function formatTime(d) {
return d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", second: "2-digit" });
}
function peerStatus(t) {
const peers = Array.isArray(t.peers) ? t.peers : [];
const connected = t.peersConnected || peers.length || 0;
const at100 = peers.filter((p) => p && p.progress === 1.0).length;
if (connected === 0) return { connected: 0, at100: 0, label: "no peers" };
return {
connected,
at100,
label: `${at100}/${connected} @ 100%`,
};
}
/* ---------- Torrent status classification ---------- */
function classify(t) {
if (t.error && t.error !== 0) {
return { key: "error", label: "Error", dot: true };
}
const tr = TRANSMISSION_STATUSES[t.status];
switch (tr) {
case "downloading": return { key: "downloading", label: "Downloading", dot: true };
case "seeding": return { key: "seeding", label: "Seeding", dot: true };
case "stopped":
if (t.isFinished || (t.percentDone >= 1.0)) return { key: "finished", label: "Finished", dot: false };
return { key: "paused", label: "Paused", dot: false };
case "checking": return { key: "checking", label: "Verifying", dot: true };
case "check-wait": return { key: "queued", label: "Queued", dot: true };
case "download-wait": return { key: "queued", label: "Queued ↓", dot: true };
case "seed-wait": return { key: "queued", label: "Queued ↑", dot: true };
default:
if (t.isFinished || (t.percentDone >= 1.0)) return { key: "finished", label: "Finished", dot: false };
return { key: "idle", label: "Idle", dot: false };
}
}
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 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) || compareName(a, b);
}
default:
return 0;
}
});
return sorted;
}
/* ---------- Rendering ---------- */
function renderHeader() {
const torrents = state.torrents || [];
let dl = 0, ul = 0;
for (const t of torrents) {
dl += t.rateDownload || 0;
ul += t.rateUpload || 0;
}
$("#stat-torrents").textContent = String(torrents.length);
$("#stat-dl").textContent = humanSpeed(dl);
$("#stat-ul").textContent = humanSpeed(ul);
}
function initial(name) {
const n = (name || "").trim();
if (!n) return "?";
const base = n.replace(/\.[a-z0-9]+$/i, "");
const m = base.match(/[A-Za-z0-9]/);
return m ? m[0].toUpperCase() : "?";
}
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);
const dl = t.rateDownload || 0;
const ul = t.rateUpload || 0;
const eta = (dl > 0 && !isDone) ? formatETA(t.eta) : (isDone ? "—" : "∞");
const hasError = cls.key === "error";
let barClass = "";
if (hasError) barClass = "error";
else if (isDone) barClass = "done";
else if (cls.key === "paused" || cls.key === "finished") barClass = "paused";
card.dataset.id = String(id);
card.classList.toggle("is-error", hasError);
setText(field(card, "initial"), initial(t.name));
const nameEl = field(card, "name");
setText(nameEl, t.name || "Unnamed torrent");
nameEl.title = t.name || "";
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 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));
reconcileCards(torrents);
empty.classList.toggle("hidden", torrents.length !== 0 || Boolean(state.lastError));
}
function renderAll() {
renderHeader();
renderList();
}
/* ---------- Events ---------- */
function wireEvents() {
const filterInput = $("#filter");
filterInput.addEventListener("input", (e) => {
state.filter = e.target.value;
renderList();
});
document.querySelectorAll(".sort-btn").forEach((btn) => {
btn.addEventListener("click", () => {
document.querySelectorAll(".sort-btn").forEach((b) => b.classList.remove("active"));
btn.classList.add("active");
state.sortKey = btn.dataset.sort;
renderList();
});
});
$("#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) {
sync();
}
});
}
/* ---------- Init ---------- */
function boot() {
wireEvents();
sync();
setInterval(sync, REFRESH_MS);
}
document.addEventListener("DOMContentLoaded", boot);