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();
}
+35
View File
@@ -57,6 +57,12 @@
</header>
<main class="container">
<div class="app-tabs" role="tablist" aria-label="Views">
<button type="button" id="tab-torrents" class="app-tab active" role="tab" aria-selected="true" aria-controls="panel-torrents" tabindex="0">Torrents</button>
<button type="button" id="tab-scheduler" class="app-tab" role="tab" aria-selected="false" aria-controls="panel-scheduler" tabindex="-1">Scheduler</button>
</div>
<section id="panel-torrents" role="tabpanel" aria-labelledby="tab-torrents">
<div class="toolbar">
<input type="search" id="filter" class="filter-input" placeholder="Filter torrents…" autocomplete="off" />
<div class="sort-group" role="group" aria-label="Sort torrents">
@@ -103,6 +109,35 @@
<p class="error-sub" id="error-msg">Could not reach the Transmission daemon.</p>
<p class="error-hint" id="error-hint">Retrying in 3s…</p>
</div>
</section>
<section id="panel-scheduler" role="tabpanel" aria-labelledby="tab-scheduler" hidden>
<div class="scheduler-heading">
<div>
<h2>Seed scheduler</h2>
<p>Completed torrents take turns when a scheduled seed has not uploaded for one hour.</p>
</div>
<form id="scheduler-settings" class="scheduler-settings">
<label for="scheduler-slots">Seed slots</label>
<input id="scheduler-slots" type="number" min="1" max="100" value="1" required />
<button type="submit" class="scheduler-button">Save</button>
</form>
</div>
<p id="scheduler-error" class="scheduler-error hidden" role="alert"></p>
<p id="scheduler-feedback" class="scheduler-feedback" role="status"></p>
<div class="scheduler-section-heading">
<h3>Completed torrents</h3>
<span id="scheduler-count"></span>
</div>
<div id="scheduler-torrents" class="scheduler-torrents"></div>
<p id="scheduler-empty" class="scheduler-empty hidden">No completed torrents are available for scheduling.</p>
<div class="scheduler-section-heading scheduler-history-heading">
<h3>Cycle history</h3>
</div>
<ol id="scheduler-history" class="scheduler-history"></ol>
<p id="scheduler-history-empty" class="scheduler-empty hidden">No cycles recorded yet.</p>
<button type="button" id="scheduler-history-more" class="scheduler-button secondary hidden">Load more</button>
</section>
</main>
<footer class="app-footer">
+41
View File
@@ -204,6 +204,44 @@ button { font-family: inherit; }
flex: 1;
}
.app-tabs { display: flex; gap: 5px; margin-bottom: 22px; border-bottom: 1px solid var(--border-strong); }
.app-tab { padding: 10px 18px 12px; border: 0; border-bottom: 2px solid transparent; background: transparent; color: var(--text-2); cursor: pointer; font-size: 14px; font-weight: 700; }
.app-tab:hover { color: var(--text-0); }
.app-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
.app-tab:focus-visible, .scheduler-button:focus-visible, .scheduler-toggle:focus-visible, .scheduler-settings input:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.scheduler-heading { display: flex; justify-content: space-between; align-items: flex-start; gap: 20px; flex-wrap: wrap; margin-bottom: 20px; }
.scheduler-heading h2 { margin: 0 0 6px; color: var(--text-0); font-size: 23px; }
.scheduler-heading p { margin: 0; max-width: 620px; color: var(--text-2); font-size: 13px; line-height: 1.5; }
.scheduler-settings { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.scheduler-settings label { color: var(--text-1); font-size: 12px; font-weight: 700; }
.scheduler-settings input { width: 68px; padding: 7px 8px; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); background: var(--bg-2); color: var(--text-0); font-size: 13px; }
.scheduler-button { border: 1px solid rgba(0, 212, 255, 0.28); border-radius: var(--radius-sm); padding: 8px 12px; background: var(--accent-soft); color: var(--accent); cursor: pointer; font-size: 12px; font-weight: 700; }
.scheduler-button:hover:not(:disabled) { background: rgba(0, 212, 255, 0.2); }
.scheduler-button.secondary { margin-top: 12px; border-color: var(--border-strong); background: var(--bg-2); color: var(--text-1); }
.scheduler-button:disabled, .scheduler-toggle:disabled { opacity: .5; cursor: wait; }
.scheduler-error { padding: 10px 12px; border: 1px solid rgba(248, 113, 113, .35); border-radius: var(--radius-sm); background: rgba(248, 113, 113, .08); color: var(--err); font-size: 13px; }
.scheduler-feedback { min-height: 18px; margin: 0 0 10px; color: var(--text-2); font-size: 12px; }
.scheduler-section-heading { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; margin: 16px 0 10px; }
.scheduler-section-heading h3 { margin: 0; color: var(--text-0); font-size: 15px; }
.scheduler-section-heading span, .scheduler-empty { color: var(--text-2); font-size: 12px; }
.scheduler-history-heading { margin-top: 30px; }
.scheduler-torrents { display: flex; flex-direction: column; gap: 8px; }
.scheduler-row { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 12px 14px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg-2); }
.scheduler-row-main { min-width: 0; }
.scheduler-row-name { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-0); font-size: 13px; font-weight: 650; }
.scheduler-row-meta { display: block; margin-top: 4px; color: var(--text-2); font-size: 11px; }
.scheduler-row-controls { display: flex; align-items: center; gap: 10px; flex: none; }
.scheduler-state { padding: 4px 7px; border-radius: 999px; background: var(--bg-3); color: var(--text-2); font-size: 11px; white-space: nowrap; }
.scheduler-state.active { background: rgba(52, 211, 153, .12); color: var(--ok); }
.scheduler-state.waiting { background: rgba(251, 191, 36, .1); color: var(--warn); }
.scheduler-toggle { min-width: 78px; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 7px 9px; background: var(--bg-3); color: var(--text-1); cursor: pointer; font-size: 11px; font-weight: 700; }
.scheduler-toggle[aria-pressed="true"] { border-color: rgba(0, 212, 255, .3); background: var(--accent-soft); color: var(--accent); }
.scheduler-history { display: flex; flex-direction: column; gap: 7px; padding: 0; margin: 0; list-style: none; }
.scheduler-history li { display: flex; justify-content: space-between; gap: 12px; padding: 10px 12px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg-2); font-size: 12px; }
.scheduler-history strong { color: var(--text-0); font-weight: 650; }
.scheduler-history time { flex: none; color: var(--text-2); }
.toolbar {
display: flex;
align-items: center;
@@ -545,4 +583,7 @@ button { font-family: inherit; }
.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; }
.scheduler-row { align-items: flex-start; }
.scheduler-row-controls { flex-direction: column; align-items: flex-end; gap: 6px; }
.scheduler-history li { flex-direction: column; gap: 4px; }
}