Refine queue page polling

This commit is contained in:
Dymas
2026-05-24 11:47:38 +02:00
parent 93446c5e92
commit 643408b19d
8 changed files with 170 additions and 60 deletions
+99 -55
View File
@@ -326,7 +326,7 @@ QUEUE_HTML = r"""<!doctype html>
<h2>What you can do here</h2>
<p class="muted">Search stays focused on finding shows and adding new jobs. Queue now handles status, logs, retries, and cleanup as a separate workflow.</p>
</div>
<span class="badge">Auto refresh every 2.5s</span>
<span class="badge">Active jobs update every 2.5s</span>
</section>
</aside>
@@ -351,7 +351,8 @@ QUEUE_HTML = r"""<!doctype html>
queueTotal: 0,
queueRequestId: 0,
queueTimer: null,
collapsedJobs: {}
collapsedJobs: {},
jobCards: {}
};
const $ = (id) => document.getElementById(id);
@@ -382,11 +383,87 @@ QUEUE_HTML = r"""<!doctype html>
state.collapsedJobs[jobId] = collapsed;
}
function isTerminalStatus(status) {
return ["done", "failed", "canceled"].includes(status);
}
function buildJobSummary(job) {
const libraryName = job.anime_name || job.title;
const season = String(job.season || "1").padStart(2, "0");
const seasonFolder = `Season ${season}`;
const target = job.target_dir || `${job.download_dir}/${libraryName}/${seasonFolder}`;
return `${libraryName} · S${season} · ${job.mode} · ${job.quality} · episodes ${job.episodes} · ${target}`;
}
function buildJobLog(job) {
return [...(job.log || [])].slice(-28).reverse().join("\n");
}
function renderJobActions(job, actions) {
actions.innerHTML = "";
if (job.status === "running" || job.status === "pending") {
actions.append(actionButton("Cancel", () => queueAction(job.id, "cancel"), "danger"));
return;
}
actions.append(actionButton("Retry", () => queueAction(job.id, "retry")));
actions.append(actionButton("Remove", () => queueAction(job.id, "remove")));
}
function updateJobCard(item, job) {
item.dataset.jobId = job.id;
item.dataset.jobStatus = job.status;
item.querySelector(".job-title").textContent = job.title;
item.querySelector(".job-head .muted").textContent = buildJobSummary(job);
const status = item.querySelector(".status");
status.textContent = job.status;
status.className = "status";
status.classList.add(job.status);
item.querySelector("pre").textContent = buildJobLog(job) || "Waiting...";
item.querySelector(".toolbar .muted").textContent = job.exit_code === null ? "" : `exit ${job.exit_code}`;
renderJobActions(job, item.querySelector(".row"));
state.jobCards[job.id] = item;
}
function createJobCard(job) {
const item = document.createElement("article");
item.className = "job";
const collapsed = isCollapsed(job);
if (collapsed) item.classList.add("collapsed");
item.innerHTML = `
<div class="job-head">
<button class="job-toggle ghost" type="button" aria-label="Toggle job details" aria-expanded="true"></button>
<div>
<div class="job-title"></div>
<p class="muted"></p>
</div>
<span class="status"></span>
</div>
<div class="job-body">
<pre></pre>
<div class="toolbar">
<span class="muted"></span>
<div class="row" style="flex:0 0 auto"></div>
</div>
</div>
`;
const toggle = item.querySelector(".job-toggle");
toggle.setAttribute("aria-expanded", collapsed ? "false" : "true");
toggle.addEventListener("click", () => {
const nextCollapsed = !item.classList.contains("collapsed");
item.classList.toggle("collapsed", nextCollapsed);
toggle.setAttribute("aria-expanded", nextCollapsed ? "false" : "true");
setCollapsed(job.id, nextCollapsed);
});
updateJobCard(item, job);
return item;
}
function renderQueue(data) {
const jobs = data.jobs || [];
state.queuePage = data.page || 1;
state.queuePages = data.pages || 1;
state.queueTotal = data.total || 0;
state.jobCards = {};
const el = $("queue");
el.innerHTML = "";
if (!jobs.length) {
@@ -395,58 +472,7 @@ QUEUE_HTML = r"""<!doctype html>
return;
}
for (const job of jobs) {
const item = document.createElement("article");
item.className = "job";
const collapsed = isCollapsed(job);
if (collapsed) item.classList.add("collapsed");
const log = [...(job.log || [])].slice(-28).reverse().join("\n");
item.innerHTML = `
<div class="job-head">
<button class="job-toggle ghost" type="button" aria-label="Toggle job details" aria-expanded="true"></button>
<div>
<div class="job-title"></div>
<p class="muted"></p>
</div>
<span class="status"></span>
</div>
<div class="job-body">
<pre></pre>
<div class="toolbar">
<span class="muted"></span>
<div class="row" style="flex:0 0 auto"></div>
</div>
</div>
`;
const toggle = item.querySelector(".job-toggle");
toggle.setAttribute("aria-expanded", collapsed ? "false" : "true");
toggle.addEventListener("click", () => {
const nextCollapsed = !item.classList.contains("collapsed");
item.classList.toggle("collapsed", nextCollapsed);
toggle.setAttribute("aria-expanded", nextCollapsed ? "false" : "true");
setCollapsed(job.id, nextCollapsed);
});
item.querySelector(".job-title").textContent = job.title;
const libraryName = job.anime_name || job.title;
const season = String(job.season || "1").padStart(2, "0");
const seasonFolder = `Season ${season}`;
const target = job.target_dir || `${job.download_dir}/${libraryName}/${seasonFolder}`;
item.querySelector(".job-head .muted").textContent =
`${libraryName} · S${season} · ${job.mode} · ${job.quality} · episodes ${job.episodes} · ${target}`;
const status = item.querySelector(".status");
status.textContent = job.status;
status.classList.add(job.status);
item.querySelector("pre").textContent = log || "Waiting...";
item.querySelector(".toolbar .muted").textContent = job.exit_code === null ? "" : `exit ${job.exit_code}`;
const actions = item.querySelector(".row");
if (job.status === "running") {
actions.append(actionButton("Cancel", () => queueAction(job.id, "cancel"), "danger"));
} else if (job.status === "pending") {
actions.append(actionButton("Cancel", () => queueAction(job.id, "cancel"), "danger"));
} else {
actions.append(actionButton("Retry", () => queueAction(job.id, "retry")));
actions.append(actionButton("Remove", () => queueAction(job.id, "remove")));
}
el.appendChild(item);
el.appendChild(createJobCard(job));
}
renderQueuePager();
}
@@ -501,13 +527,31 @@ QUEUE_HTML = r"""<!doctype html>
return data;
}
async function refreshVisibleJob(jobId) {
const item = state.jobCards[jobId];
if (!item) return null;
const job = await api(`/api/queue/${jobId}`);
if (!state.jobCards[jobId]) return job;
updateJobCard(state.jobCards[jobId], job);
return job;
}
async function pollVisibleJobs() {
const activeJobIds = Object.entries(state.jobCards)
.filter(([, item]) => item && !isTerminalStatus(item.dataset.jobStatus || ""))
.map(([jobId]) => jobId);
for (const jobId of activeJobIds) {
await refreshVisibleJob(jobId);
}
}
$("removeFinishedBtn").addEventListener("click", () => queueBulkAction("/api/queue/clear-finished", "Removed finished jobs"));
$("retryFailedBtn").addEventListener("click", () => queueBulkAction("/api/queue/retry-failed", "Retried failed jobs"));
(async function init() {
try {
await loadQueue();
state.queueTimer = startSerialPoll(() => loadQueue(), 2500);
state.queueTimer = startSerialPoll(() => pollVisibleJobs(), 2500);
} catch (error) {
setNotice(error.message);
}