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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user