adding scheduler
Build and Deploy (internal) / Build Transmission Manager Image (push) Successful in 3m13s
Build and Deploy (internal) / Deploy Transmission Manager (internal) (push) Successful in 3s

This commit is contained in:
2026-09-23 17:32:39 -03:00
parent 44889338c2
commit c98af34202
12 changed files with 1430 additions and 8 deletions
+240
View File
@@ -34,6 +34,17 @@ let syncQueued = false;
let refreshTimer = null;
let speedControlPending = false;
let speedControlError = null;
let schedulerData = null;
let schedulerError = "";
let schedulerLoading = false;
let schedulerSettingsPending = false;
let schedulerHistoryLoading = false;
let schedulerHistoryLoaded = false;
let schedulerHistoryEvents = [];
let schedulerHistoryHasMore = false;
let schedulerHistoryLatestID = -1;
const schedulerActionPending = new Set();
const schedulerRows = new Map();
const $ = (sel) => document.querySelector(sel);
@@ -142,6 +153,7 @@ async function runTorrentAction(id, action) {
await fetchJSON(`/api/torrents/${id}/${action}`, { method: "POST" });
actionStates.delete(id);
await sync();
if (location.hash === "#scheduler") loadScheduler();
} catch (err) {
actionStates.set(id, {
pending: null,
@@ -228,6 +240,196 @@ function formatTime(d) {
return d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", second: "2-digit" });
}
function formatDateTime(unix) {
if (!unix) return "Never";
const date = new Date(unix * 1000);
return Number.isNaN(date.getTime()) ? "Never" : date.toLocaleString();
}
/* ---------- Scheduler ---------- */
function schedulerStatus(item) {
if (!item.available) return { label: "Unavailable", key: "" };
if (item.active) return item.status === 6
? { label: "Active", key: "active" }
: { label: item.status === 5 ? "Queued" : "Starting", key: "waiting" };
if (item.optedIn) return { label: "Waiting", key: "waiting" };
return { label: "Available", key: "" };
}
function createSchedulerRow() {
const row = document.createElement("div");
row.className = "scheduler-row";
row.innerHTML = `<div class="scheduler-row-main"><span class="scheduler-row-name"></span><span class="scheduler-row-meta"></span></div>
<div class="scheduler-row-controls"><span class="scheduler-state"></span><button type="button" class="scheduler-toggle"></button></div>`;
return row;
}
function renderScheduler() {
const error = $("#scheduler-error");
const message = schedulerError || (schedulerData && schedulerData.error) || "";
setText(error, message);
error.classList.toggle("hidden", !message);
if (!schedulerData) return;
const slots = $("#scheduler-slots");
if (document.activeElement !== slots && !schedulerSettingsPending) slots.value = String(schedulerData.maxActive);
$("#scheduler-settings").querySelector("button").disabled = schedulerSettingsPending;
const list = $("#scheduler-torrents");
const items = schedulerData.torrents || [];
const visible = new Set();
items.forEach((item, index) => {
visible.add(item.hashString);
let row = schedulerRows.get(item.hashString);
if (!row) {
row = createSchedulerRow();
schedulerRows.set(item.hashString, row);
}
row.dataset.hash = item.hashString;
const name = row.querySelector(".scheduler-row-name");
setText(name, item.name || item.hashString);
name.title = item.name || item.hashString;
setText(row.querySelector(".scheduler-row-meta"), item.optedIn
? `Last seeded: ${formatDateTime(item.lastSeededAt)} · Idle clock: ${formatDateTime(item.lastUploadAt)}`
: "Not scheduled");
const status = schedulerStatus(item);
const badge = row.querySelector(".scheduler-state");
badge.className = `scheduler-state ${status.key}`.trim();
setText(badge, status.label);
const button = row.querySelector(".scheduler-toggle");
button.disabled = schedulerActionPending.has(item.hashString);
button.setAttribute("aria-pressed", String(Boolean(item.optedIn)));
button.setAttribute("aria-label", `${item.optedIn ? "Remove" : "Add"} ${item.name || "torrent"} ${item.optedIn ? "from" : "to"} scheduler`);
setText(button, schedulerActionPending.has(item.hashString) ? "Saving..." : (item.optedIn ? "Opt out" : "Opt in"));
const current = list.children[index];
if (current !== row) list.insertBefore(row, current || null);
});
for (const [hash, row] of schedulerRows) {
if (!visible.has(hash)) {
row.remove();
schedulerRows.delete(hash);
}
}
$("#scheduler-empty").classList.toggle("hidden", items.length > 0);
const active = items.filter((item) => item.active).length;
const optedIn = items.filter((item) => item.optedIn).length;
setText($("#scheduler-count"), `${active} active · ${optedIn} opted in`);
}
function renderSchedulerHistory() {
const list = $("#scheduler-history");
list.replaceChildren();
for (const event of schedulerHistoryEvents) {
const row = document.createElement("li");
const description = document.createElement("span");
const names = document.createElement("strong");
const movement = event.fromName && event.toName
? `${event.fromName} → ${event.toName}`
: (event.toName ? `${event.toName} activated` : `${event.fromName || "Torrent"} paused`);
setText(names, movement);
description.append(names, document.createTextNode(` · ${event.reason}`));
const time = document.createElement("time");
time.dateTime = new Date(event.happenedAt * 1000).toISOString();
setText(time, formatDateTime(event.happenedAt));
row.append(description, time);
list.append(row);
}
$("#scheduler-history-empty").classList.toggle("hidden", schedulerHistoryEvents.length > 0);
$("#scheduler-history-more").classList.toggle("hidden", !schedulerHistoryHasMore);
$("#scheduler-history-more").disabled = schedulerHistoryLoading;
}
async function loadSchedulerHistory(reset = false) {
if (schedulerHistoryLoading) return;
schedulerHistoryLoading = true;
try {
const before = !reset && schedulerHistoryEvents.length
? `?before=${schedulerHistoryEvents[schedulerHistoryEvents.length - 1].id}` : "";
const result = await fetchJSON(`/api/scheduler/history${before}`);
schedulerHistoryEvents = reset ? (result.events || []) : schedulerHistoryEvents.concat(result.events || []);
schedulerHistoryHasMore = Boolean(result.hasMore);
schedulerHistoryLoaded = true;
schedulerHistoryLatestID = schedulerData ? schedulerData.latestEventId : schedulerHistoryLatestID;
schedulerError = "";
} catch (err) {
schedulerError = err.message || String(err);
renderScheduler();
} finally {
schedulerHistoryLoading = false;
renderSchedulerHistory();
}
}
async function loadScheduler() {
if (schedulerLoading) return;
schedulerLoading = true;
try {
schedulerData = await fetchJSON("/api/scheduler");
schedulerError = "";
renderScheduler();
if (!schedulerHistoryLoaded || schedulerData.latestEventId !== schedulerHistoryLatestID) {
await loadSchedulerHistory(true);
}
} catch (err) {
schedulerError = err.message || String(err);
renderScheduler();
} finally {
schedulerLoading = false;
}
}
async function setSchedulerSlots(value) {
if (schedulerSettingsPending) return;
schedulerSettingsPending = true;
renderScheduler();
try {
await fetchJSON("/api/scheduler/settings", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ maxActive: value }),
});
setText($("#scheduler-feedback"), "Seed slots saved.");
schedulerError = "";
await loadScheduler();
} catch (err) {
schedulerError = err.message || String(err);
} finally {
schedulerSettingsPending = false;
renderScheduler();
}
}
async function setSchedulerOptIn(hash, enabled) {
if (schedulerActionPending.has(hash)) return;
schedulerActionPending.add(hash);
renderScheduler();
try {
await fetchJSON(`/api/scheduler/torrents/${hash}/opt-in`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled }),
});
schedulerError = "";
await loadScheduler();
} catch (err) {
schedulerError = err.message || String(err);
} finally {
schedulerActionPending.delete(hash);
renderScheduler();
}
}
function activateTab(name) {
const selected = name === "scheduler" ? "scheduler" : "torrents";
for (const tabName of ["torrents", "scheduler"]) {
const tab = $(`#tab-${tabName}`);
const active = tabName === selected;
tab.classList.toggle("active", active);
tab.setAttribute("aria-selected", String(active));
tab.tabIndex = active ? 0 : -1;
$(`#panel-${tabName}`).hidden = !active;
}
if (selected === "scheduler") loadScheduler();
}
function peerStatus(t) {
const peers = Array.isArray(t.peers) ? t.peers : [];
const connected = t.peersConnected || peers.length || 0;
@@ -672,6 +874,39 @@ function renderAll() {
/* ---------- Events ---------- */
function wireEvents() {
document.querySelectorAll(".app-tab").forEach((tab) => {
tab.addEventListener("click", () => { location.hash = tab.id === "tab-scheduler" ? "scheduler" : "torrents"; });
tab.addEventListener("keydown", (event) => {
if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return;
event.preventDefault();
const next = event.key === "Home" ? "torrents"
: event.key === "End" ? "scheduler"
: (tab.id === "tab-torrents" ? "scheduler" : "torrents");
location.hash = next;
$(`#tab-${next}`).focus();
});
});
window.addEventListener("hashchange", () => activateTab(location.hash.slice(1)));
$("#scheduler-settings").addEventListener("submit", (event) => {
event.preventDefault();
const value = Number($("#scheduler-slots").value);
if (!Number.isInteger(value) || value < 1 || value > 100) {
schedulerError = "Seed slots must be a whole number from 1 to 100.";
renderScheduler();
return;
}
setSchedulerSlots(value);
});
$("#scheduler-torrents").addEventListener("click", (event) => {
const button = event.target.closest(".scheduler-toggle");
if (!button || button.disabled) return;
const hash = button.closest(".scheduler-row").dataset.hash;
const item = schedulerData && schedulerData.torrents.find((t) => t.hashString === hash);
if (item) setSchedulerOptIn(hash, !item.optedIn);
});
$("#scheduler-history-more").addEventListener("click", () => loadSchedulerHistory(false));
const filterInput = $("#filter");
filterInput.addEventListener("input", (e) => {
state.filter = e.target.value;
@@ -723,6 +958,7 @@ function wireEvents() {
document.addEventListener("visibilitychange", () => {
if (!document.hidden) {
sync();
if (location.hash === "#scheduler") loadScheduler();
}
});
}
@@ -730,7 +966,11 @@ function wireEvents() {
/* ---------- Init ---------- */
function boot() {
wireEvents();
activateTab(location.hash.slice(1));
setRefreshInterval(state.refreshMs);
setInterval(() => {
if (!document.hidden && location.hash === "#scheduler") loadScheduler();
}, 10000);
sync();
}