adding scheduler
This commit is contained in:
+240
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user