adding scheduled view in torrents tab
Build and Deploy (internal) / Build Transmission Manager Image (push) Successful in 3m22s
Build and Deploy (internal) / Deploy Transmission Manager (internal) (push) Successful in 3s

This commit is contained in:
2026-09-25 20:57:09 -03:00
parent 0d686847f2
commit 19c3ee2e26
5 changed files with 141 additions and 24 deletions
+89 -19
View File
@@ -22,6 +22,7 @@ const state = {
stats: {},
session: null,
filter: "",
scheduledOnly: false,
sortKey: "status",
refreshMs: DEFAULT_REFRESH_MS,
lastError: null,
@@ -35,8 +36,9 @@ let refreshTimer = null;
let speedControlPending = false;
let speedControlError = null;
let schedulerData = null;
let schedulerItems = new Map();
let schedulerError = "";
let schedulerLoading = false;
let schedulerLoadPromise = null;
let schedulerSettingsPending = false;
let schedulerHistoryLoading = false;
let schedulerHistoryLoaded = false;
@@ -44,6 +46,7 @@ let schedulerHistoryEvents = [];
let schedulerHistoryHasMore = false;
let schedulerHistoryLatestID = -1;
const schedulerActionPending = new Set();
const schedulerActionErrors = new Map();
const schedulerRows = new Map();
const $ = (sel) => document.querySelector(sel);
@@ -153,7 +156,7 @@ async function runTorrentAction(id, action) {
await fetchJSON(`/api/torrents/${id}/${action}`, { method: "POST" });
actionStates.delete(id);
await sync();
if (location.hash === "#scheduler") loadScheduler();
loadScheduler();
} catch (err) {
actionStates.set(id, {
pending: null,
@@ -256,6 +259,24 @@ function schedulerStatus(item) {
return { label: "Available", key: "" };
}
function schedulerItemFor(torrent) {
const hash = (torrent.hashString || "").toLowerCase();
return schedulerItems.get(hash);
}
function renderScheduleControls() {
const filter = $("#scheduled-filter");
filter.disabled = !schedulerData;
filter.setAttribute("aria-pressed", String(state.scheduledOnly));
const count = schedulerData ? state.torrents.filter((torrent) => schedulerItemFor(torrent)?.optedIn).length : 0;
setText(filter, schedulerData ? `Scheduled (${count})` : "Scheduled");
const status = $("#schedule-status");
const message = schedulerError || (schedulerData && schedulerData.error) || "";
setText(status, message ? `Scheduler: ${message}` : "");
status.classList.toggle("hidden", !message);
}
function createSchedulerRow() {
const row = document.createElement("div");
row.className = "scheduler-row";
@@ -361,21 +382,25 @@ async function loadSchedulerHistory(reset = false) {
}
async function loadScheduler() {
if (schedulerLoading) return;
schedulerLoading = true;
try {
schedulerData = await fetchJSON("/api/scheduler");
schedulerError = "";
renderScheduler();
if (!schedulerHistoryLoaded || schedulerData.latestEventId !== schedulerHistoryLatestID) {
await loadSchedulerHistory(true);
if (schedulerLoadPromise) return schedulerLoadPromise;
schedulerLoadPromise = (async () => {
try {
schedulerData = await fetchJSON("/api/scheduler");
schedulerItems = new Map((schedulerData.torrents || []).map((item) => [item.hashString.toLowerCase(), item]));
schedulerError = "";
renderScheduler();
if (location.hash === "#scheduler" && (!schedulerHistoryLoaded || schedulerData.latestEventId !== schedulerHistoryLatestID)) {
await loadSchedulerHistory(true);
}
} catch (err) {
schedulerError = err.message || String(err);
renderScheduler();
} finally {
renderScheduleControls();
renderList();
}
} catch (err) {
schedulerError = err.message || String(err);
renderScheduler();
} finally {
schedulerLoading = false;
}
})();
try { await schedulerLoadPromise; } finally { schedulerLoadPromise = null; }
}
async function setSchedulerSlots(value) {
@@ -401,19 +426,25 @@ async function setSchedulerSlots(value) {
async function setSchedulerOptIn(hash, enabled) {
if (schedulerActionPending.has(hash)) return;
schedulerActionPending.add(hash);
schedulerActionErrors.delete(hash);
renderScheduler();
renderList();
try {
await fetchJSON(`/api/scheduler/torrents/${hash}/opt-in`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled }),
});
schedulerError = "";
if (schedulerLoadPromise) await schedulerLoadPromise;
await loadScheduler();
} catch (err) {
schedulerError = err.message || String(err);
schedulerActionErrors.set(hash, schedulerError);
} finally {
schedulerActionPending.delete(hash);
renderScheduler();
renderScheduleControls();
renderList();
}
}
@@ -647,6 +678,7 @@ function createCard() {
<div class="card-body">
<div class="card-name" data-field="name"></div>
<div class="card-meta">
<span class="schedule-badge" data-field="schedule-badge"></span>
<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>
@@ -658,6 +690,7 @@ function createCard() {
<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="card-schedule-toggle hidden" data-schedule-button></button>
<button type="button" class="action-btn" data-action-button></button>
</div>
</div>
@@ -761,6 +794,23 @@ function updateCard(card, t) {
setText(field(card, "status-label"), cls.label);
setText(field(card, "pct"), pctText);
const scheduleItem = schedulerItemFor(t);
const scheduled = Boolean(scheduleItem && scheduleItem.optedIn);
const scheduleBadge = field(card, "schedule-badge");
scheduleBadge.classList.toggle("is-scheduled", scheduled);
setText(scheduleBadge, !schedulerData ? (schedulerError ? "Schedule unavailable" : "Schedule loading") : scheduled
? `Scheduled · ${schedulerStatus(scheduleItem).label}` : "Not scheduled");
scheduleBadge.title = !schedulerData
? (schedulerError ? "Scheduler state is unavailable" : "Loading scheduler state")
: (!scheduleItem ? "Torrent must be complete and error-free before scheduling" : "");
const scheduleButton = card.querySelector("[data-schedule-button]");
const schedulable = Boolean(scheduleItem && (scheduleItem.available || scheduled));
scheduleButton.classList.toggle("hidden", !schedulable);
scheduleButton.disabled = schedulerActionPending.has((t.hashString || "").toLowerCase());
scheduleButton.setAttribute("aria-pressed", String(scheduled));
scheduleButton.setAttribute("aria-label", `${scheduled ? "Remove" : "Add"} ${t.name || "torrent"} ${scheduled ? "from" : "to"} scheduler`);
setText(scheduleButton, scheduleButton.disabled ? "Saving..." : (scheduled ? "Unschedule" : "Schedule"));
const progress = field(card, "progress");
progress.className = `progress-bar ${barClass}`.trim();
progress.style.width = `${(pct * 100).toFixed(2)}%`;
@@ -801,7 +851,7 @@ function updateCard(card, t) {
setText(button, actionState && actionState.pending ? actionState.pendingText : action.label);
const actionError = field(card, "action-error");
const actionErrorText = actionState && actionState.error ? actionState.error : "";
const actionErrorText = (actionState && actionState.error) || schedulerActionErrors.get((t.hashString || "").toLowerCase()) || "";
setText(actionError, actionErrorText);
actionError.classList.toggle("hidden", !actionErrorText);
}
@@ -860,15 +910,21 @@ function renderList() {
}
let torrents = sortTorrents(state.torrents || []);
if (state.scheduledOnly) torrents = torrents.filter((t) => schedulerItemFor(t)?.optedIn);
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));
setText($("#empty-title"), state.filter ? "No matching torrents" : (state.scheduledOnly ? "No scheduled torrents" : "No torrents"));
setText($("#empty-sub"), state.filter ? "Try a different filter." : (state.scheduledOnly
? "Schedule a completed torrent here to see it in this view."
: "Add torrents to your Transmission client to see them here."));
}
function renderAll() {
renderHeader();
renderScheduleControls();
renderList();
}
@@ -912,6 +968,11 @@ function wireEvents() {
state.filter = e.target.value;
renderList();
});
$("#scheduled-filter").addEventListener("click", () => {
state.scheduledOnly = !state.scheduledOnly;
renderScheduleControls();
renderList();
});
document.querySelectorAll(".sort-btn").forEach((btn) => {
btn.addEventListener("click", () => {
@@ -944,6 +1005,14 @@ function wireEvents() {
}
$("#torrents").addEventListener("click", (e) => {
const scheduleButton = e.target.closest("[data-schedule-button]");
if (scheduleButton && !scheduleButton.disabled) {
const card = scheduleButton.closest(".card");
const torrent = (state.torrents || []).find((t) => String(t.id) === card.dataset.id);
const item = torrent && schedulerItemFor(torrent);
if (item) setSchedulerOptIn(item.hashString, !item.optedIn);
return;
}
const button = e.target.closest("[data-action-button]");
if (!button || button.disabled) return;
@@ -958,7 +1027,7 @@ function wireEvents() {
document.addEventListener("visibilitychange", () => {
if (!document.hidden) {
sync();
if (location.hash === "#scheduler") loadScheduler();
loadScheduler();
}
});
}
@@ -969,9 +1038,10 @@ function boot() {
activateTab(location.hash.slice(1));
setRefreshInterval(state.refreshMs);
setInterval(() => {
if (!document.hidden && location.hash === "#scheduler") loadScheduler();
if (!document.hidden) loadScheduler();
}, 10000);
sync();
if (location.hash !== "#scheduler") loadScheduler();
}
document.addEventListener("DOMContentLoaded", boot);