Files
co5dt-ComfyUI-Persistent-Queue/web/pqueue.js
T

2463 lines
116 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Production-grade frontend for ComfyUI Persistent Queue
// Served automatically at /extensions/ComfyUI-PersistentQueue/pqueue.js
(function () {
"use strict";
const API = {
getQueue: () => fetch("/api/pqueue").then((r) => r.json()),
getHistory: (limit = 50) => fetch(`/api/pqueue/history?limit=${limit}`).then((r) => r.json()),
getHistoryPaginated: (params = {}) => {
const url = new URL("/api/pqueue/history", window.location.origin);
Object.entries(params).forEach(([k, v]) => {
if (v === undefined || v === null || v === "") return;
url.searchParams.set(k, String(v));
});
return fetch(url.href).then((r) => r.json());
},
pause: () => fetch("/api/pqueue/pause", { method: "POST" }),
resume: () => fetch("/api/pqueue/resume", { method: "POST" }),
reorder: (order) =>
fetch("/api/pqueue/reorder", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ order }),
}),
setPriority: (prompt_id, priority) =>
fetch("/api/pqueue/priority", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt_id, priority }),
}),
del: (prompt_ids) =>
fetch("/api/pqueue/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt_ids }),
}),
rename: (prompt_id, name) =>
fetch("/api/pqueue/rename", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt_id, name }),
}),
};
const Icons = {
_ns: "http://www.w3.org/2000/svg",
_svg(attrs = {}) {
const svg = document.createElementNS(Icons._ns, "svg");
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("width", "1em");
svg.setAttribute("height", "1em");
svg.setAttribute("aria-hidden", "true");
Object.entries(attrs).forEach(([k, v]) => svg.setAttribute(k, v));
return svg;
},
_path(d, attrs = {}) {
const p = document.createElementNS(Icons._ns, "path");
p.setAttribute("d", d);
Object.entries(attrs).forEach(([k, v]) => p.setAttribute(k, v));
return p;
},
_strokeSvg(paths) {
const svg = Icons._svg({ fill: "none", stroke: "currentColor", "stroke-width": "1.6", "stroke-linecap": "round", "stroke-linejoin": "round" });
paths.forEach((d) => svg.appendChild(Icons._path(d)));
return svg;
},
_filledSvg(paths) {
const svg = Icons._svg({ fill: "currentColor" });
paths.forEach((d) => svg.appendChild(Icons._path(d)));
return svg;
},
resolve(name) {
const base = String(name || "").trim();
const key = base.split(/\s+/).pop().replace(/^ti-/, "");
switch (key) {
case "player-play":
return Icons._strokeSvg(["M8 5v14l11-7z"]);
case "player-play-filled":
return Icons._filledSvg(["M8 5v14l11-7z"]);
case "player-pause":
case "player-pause-filled":
return Icons._filledSvg(["M6 5h4v14H6z", "M14 5h4v14h-4z"]);
case "player-stop":
return Icons._filledSvg(["M6 6h12v12H6z"]);
case "refresh":
return Icons._strokeSvg(["M20 11a8 8 0 1 0-2.36 5.65M20 11v-5M20 11h-5"]);
case "trash":
return Icons._strokeSvg(["M4 7h16", "M7 7v12a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V7", "M9 7V5a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2"]);
case "drag-drop":
return Icons._strokeSvg(["M7 6h.01","M12 6h.01","M17 6h.01","M7 12h.01","M12 12h.01","M17 12h.01","M7 18h.01","M12 18h.01","M17 18h.01"]);
case "arrow-bar-to-up":
return Icons._strokeSvg(["M12 4v10","M8 8l4-4 4 4","M4 20h16"]);
case "arrow-bar-to-down":
return Icons._strokeSvg(["M12 20V10","M8 16l4 4 4-4","M4 4h16"]);
case "loader-2":
return Icons._strokeSvg(["M12 3a9 9 0 1 0 9 9"]);
case "circle-check":
return Icons._strokeSvg(["M20 12a8 8 0 1 1-16 0 8 8 0 0 1 16 0","M9 12l2 2 4-4"]);
case "alert-triangle":
return Icons._strokeSvg(["M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z","M12 9v4","M12 17h.01"]);
case "clock-hour-3":
return Icons._strokeSvg(["M12 7v5l4 2","M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18z"]);
case "circle-dashed":
return Icons._strokeSvg(["M12 3a9 9 0 0 1 9 9","M21 12a9 9 0 0 1-9 9","M12 21a9 9 0 0 1-9-9","M3 12a9 9 0 0 1 9-9"]);
case "x":
return Icons._strokeSvg(["M6 6l12 12","M6 18L18 6"]);
case "chevron-left":
return Icons._strokeSvg(["M15 6l-6 6 6 6"]);
case "chevron-right":
return Icons._strokeSvg(["M9 6l6 6-6 6"]);
case "copy":
return Icons._strokeSvg(["M8 8h10a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2z","M4 16V6a2 2 0 0 1 2-2h8"]);
case "history":
return Icons._strokeSvg(["M3 13a9 9 0 1 0 3-7","M3 13H7","M12 7v6l4 2"]);
case "activity-heartbeat":
return Icons._strokeSvg(["M3 12h4l2-3 3 6 2-3h5"]);
case "stack-front":
case "stack-2":
return Icons._strokeSvg(["M12 4l8 4-8 4-8-4 8-4z","M4 12l8 4 8-4","M4 16l8 4 8-4"]);
case "bolt":
return Icons._strokeSvg(["M13 3L4 14h7l-1 7 9-11h-7z"]);
case "info-circle":
return Icons._strokeSvg(["M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18z","M12 8h.01","M11 12h2v6h-2z"]);
default:
return null;
}
}
};
const state = {
paused: false,
queue_running: [],
queue_pending: [],
db_pending: [],
history: [],
running_progress: {},
workflowCache: new Map(),
durationByWorkflow: new Map(),
dbIndex: new Map(),
workflowNameCache: new Map(),
selectedPending: new Set(),
filters: {
pending: "",
historySince: "",
historyUntil: "",
historySinceTime: "",
historyUntilTime: "",
},
historyTotal: null,
historyIds: new Set(),
historyPaging: {
isLoading: false,
hasMore: true,
nextCursor: null,
params: { sort_by: "id", sort_dir: "desc", limit: 60 },
},
metrics: {
runningCount: 0,
queueCount: 0,
persistedCount: 0,
historyCount: 0,
successRate: null,
avgDuration: null,
estimatedTotalDuration: null,
estimatedPendingDuration: null,
estimatedRunningDuration: null,
failureCount: 0,
lastFailure: null,
},
isRefreshing: false,
renderLockUntil: 0,
lastUpdated: null,
error: null,
statusMessage: null,
statusTimer: null,
container: null,
dom: {},
};
let pollIntervalId = null;
let focusListener = null;
let dragRow = null;
let dropHover = null;
const Progress = {
computeAggregate(nodes) {
let totalMax = 0;
let totalVal = 0;
Object.values(nodes ?? {}).forEach((st) => {
const max = Math.max(1, Number(st?.max ?? 1));
const val = Math.max(0, Math.min(Number(st?.value ?? 0), max));
totalMax += max;
totalVal += val;
});
if (!totalMax) return 0;
return Math.max(0, Math.min(1, totalVal / totalMax));
},
};
const Format = {
relative(iso) {
if (!iso) return "";
const date = new Date(iso);
if (!isFinite(date.getTime())) return "";
const diff = date.getTime() - Date.now();
const abs = Math.abs(diff);
const units = [
{ limit: 45 * 1000, div: 1000, unit: "second" },
{ limit: 45 * 60 * 1000, div: 60 * 1000, unit: "minute" },
{ limit: 22 * 60 * 60 * 1000, div: 60 * 60 * 1000, unit: "hour" },
{ limit: 26 * 24 * 60 * 60 * 1000, div: 24 * 60 * 60 * 1000, unit: "day" },
{ limit: 11 * 30 * 24 * 60 * 60 * 1000, div: 30 * 24 * 60 * 60 * 1000, unit: "month" },
];
let value = diff;
let unit = "year";
for (const u of units) {
if (abs < u.limit) {
value = diff / u.div;
unit = u.unit;
break;
}
}
if (unit === "year") value = diff / (365 * 24 * 60 * 60 * 1000);
value = Math.round(value);
if (typeof Intl !== "undefined" && Intl.RelativeTimeFormat) {
const rtf = Format._rtf || (Format._rtf = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" }));
return rtf.format(value, unit);
}
const suffix = value === 0 ? "now" : value < 0 ? "ago" : "from now";
return `${Math.abs(value)} ${unit}${Math.abs(value) === 1 ? "" : "s"} ${suffix}`;
},
datetime(iso) {
if (!iso) return "";
const d = new Date(iso);
if (!isFinite(d.getTime())) return "";
return d.toLocaleString();
},
duration(seconds) {
if (!isFinite(seconds) || seconds <= 0) return "";
const sec = Math.round(seconds);
if (sec < 60) return `${sec}s`;
const minutes = Math.floor(sec / 60);
const remSec = sec % 60;
if (minutes < 60) return remSec ? `${minutes}m ${remSec}s` : `${minutes}m`;
const hours = Math.floor(minutes / 60);
const remMin = minutes % 60;
if (hours < 24) return remMin ? `${hours}h ${remMin}m` : `${hours}h`;
const days = Math.floor(hours / 24);
const remHours = hours % 24;
return remHours ? `${days}d ${remHours}h` : `${days}d`;
},
percent(frac) {
const n = Number(frac);
if (!isFinite(n) || n < 0) return "0%";
return `${Math.min(100, Math.max(0, n * 100)).toFixed(0)}%`;
},
statusLabel(status) {
const s = String(status || "").toLowerCase();
if (["success", "completed", "done"].includes(s)) return "Success";
if (["running", "executing", "in-progress"].includes(s)) return "Running";
if (["failed", "error", "failure"].includes(s)) return "Failed";
if (["cancelled", "canceled", "interrupted", "stopped"].includes(s)) return "Interrupted";
if (["pending", "queued", "waiting"].includes(s)) return "Pending";
return status || "Unknown";
},
tooltip(row) {
if (!row) return "";
const lines = [];
if (row.prompt_id) lines.push(`Prompt ID: ${row.prompt_id}`);
if (row.created_at) lines.push(`Created: ${Format.datetime(row.created_at)}`);
if (row.completed_at) lines.push(`Completed: ${Format.datetime(row.completed_at)}`);
if (row.status) lines.push(`Status: ${Format.statusLabel(row.status)}`);
if (row.duration_seconds) lines.push(`Duration: ${Format.duration(Number(row.duration_seconds))}`);
if (row.error) lines.push(`Error: ${row.error}`);
return lines.join("\n");
},
};
function setStatusMessage(message, duration = 2500) {
if (state.statusTimer) {
clearTimeout(state.statusTimer);
state.statusTimer = null;
}
state.statusMessage = message || null;
UI.updateToolbarStatus();
if (message && duration > 0) {
state.statusTimer = window.setTimeout(() => {
state.statusMessage = null;
state.statusTimer = null;
UI.updateToolbarStatus();
}, duration);
}
}
const UI = {
el(tag, attrs = {}, children = []) {
const el = document.createElement(tag);
Object.entries(attrs || {}).forEach(([key, value]) => {
if (value === null || value === undefined) return;
if (key === "class") {
el.className = Array.isArray(value) ? value.filter(Boolean).join(" ") : String(value);
} else if (key === "text") {
el.textContent = value;
} else {
el.setAttribute(key, value);
}
});
(Array.isArray(children) ? children : [children]).forEach((child) => {
if (child instanceof Node) el.appendChild(child);
else if (child !== null && child !== undefined) el.appendChild(document.createTextNode(String(child)));
});
return el;
},
icon(name, { spin = false, size } = {}) {
const classes = ["pqueue-icon"];
if (spin) classes.push("pqueue-icon--spin");
if (size) classes.push(`pqueue-icon--${size}`);
const el = UI.el("span", { class: classes });
const svg = Icons.resolve(name);
if (svg) el.appendChild(svg);
else if (name) el.classList.add(...String(name).split(/\s+/));
return el;
},
button({ id, text, icon, variant, subtle, size, badge, title, disabled, onClick, ariaLabel }) {
const classes = ["pqueue-button"];
if (variant) classes.push(`pqueue-button--${variant}`);
if (subtle) classes.push("pqueue-button--subtle");
if (size) classes.push(`pqueue-button--${size}`);
const attrs = { class: classes, type: "button" };
if (id) attrs.id = id;
if (title) attrs.title = title;
if (ariaLabel) attrs["aria-label"] = ariaLabel;
const btn = UI.el("button", attrs);
if (disabled) btn.disabled = true;
if (icon) btn.appendChild(UI.icon(icon));
if (text) btn.appendChild(UI.el("span", { class: "pqueue-button__label", text }));
if (badge != null) {
btn.appendChild(UI.el("span", { class: "pqueue-button__badge", text: String(badge) }));
}
if (typeof onClick === "function") btn.addEventListener("click", onClick);
return btn;
},
statusBadge(status, { subtle } = {}) {
const normalized = String(status || "").toLowerCase();
const meta = {
success: { icon: "ti ti-circle-check", label: "Success", variant: "success" },
completed: { icon: "ti ti-circle-check", label: "Completed", variant: "success" },
running: { icon: "ti ti-player-play", label: "Running", variant: "info" },
executing: { icon: "ti ti-player-play", label: "Executing", variant: "info" },
failed: { icon: "ti ti-alert-triangle", label: "Failed", variant: "danger" },
error: { icon: "ti ti-alert-triangle", label: "Error", variant: "danger" },
interrupted: { icon: "ti ti-player-stop", label: "Interrupted", variant: "warning" },
cancelled: { icon: "ti ti-player-stop", label: "Cancelled", variant: "warning" },
canceled: { icon: "ti ti-player-stop", label: "Cancelled", variant: "warning" },
pending: { icon: "ti ti-clock-hour-3", label: "Pending", variant: "neutral" },
queued: { icon: "ti ti-clock-hour-3", label: "Queued", variant: "neutral" },
}[normalized] || { icon: "ti ti-circle-dashed", label: Format.statusLabel(status || "Unknown"), variant: "neutral" };
const classes = ["pqueue-status", `pqueue-status--${meta.variant}`];
if (subtle) classes.push("pqueue-status--subtle");
const badge = UI.el("span", { class: classes });
badge.appendChild(UI.icon(meta.icon, { size: "sm" }));
badge.appendChild(UI.el("span", { class: "pqueue-status__label", text: meta.label }));
return badge;
},
emptyState({ icon, title, description, action } = {}) {
const box = UI.el("div", { class: "pqueue-empty" });
if (icon) box.appendChild(UI.icon(icon, { size: "lg" }));
if (title) box.appendChild(UI.el("h4", { class: "pqueue-empty__title", text: title }));
if (description) box.appendChild(UI.el("p", { class: "pqueue-empty__desc", text: description }));
if (action) box.appendChild(action);
return box;
},
metricTile({ icon, label, value, caption, variant, tooltip }) {
const card = UI.el("article", { class: ["pqueue-metric", variant ? `pqueue-metric--${variant}` : null] });
const labelEl = UI.el("span", { class: "pqueue-metric__label", text: label });
const headerChildren = [UI.icon(icon, { size: "md" }), labelEl];
if (tooltip) {
card.setAttribute("title", tooltip);
}
const header = UI.el("div", { class: "pqueue-metric__header" }, headerChildren);
card.appendChild(header);
card.appendChild(UI.el("div", { class: "pqueue-metric__value", text: value }));
if (caption) card.appendChild(UI.el("div", { class: "pqueue-metric__caption", text: caption }));
return card;
},
ensureAssets() {
if (!document.getElementById("pqueue-style")) {
const link = document.createElement("link");
link.id = "pqueue-style";
link.rel = "stylesheet";
link.href = "/extensions/ComfyUI-PersistentQueue/css/queue_style.css";
document.head.appendChild(link);
}
if (!document.querySelector("link[data-pqueue-icons]")) {
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = "/extensions/ComfyUI-PersistentQueue/lib/tabler-icons.min.css";
link.dataset.pqueueIcons = "true";
document.head.appendChild(link);
}
},
// Find and cache the real scroll container (ComfyUI sidebar)
getScrollContainer() {
try {
if (state?.dom?.scrollContainer && document.body.contains(state.dom.scrollContainer)) return state.dom.scrollContainer;
const explicit = document.querySelector('.sidebar-content-container');
if (explicit) { state.dom.scrollContainer = explicit; return explicit; }
let el = state.container;
while (el && el !== document.body && el !== document.documentElement) {
try {
const style = window.getComputedStyle(el);
const oy = style.overflowY;
if ((oy === 'auto' || oy === 'scroll') && el.scrollHeight > el.clientHeight) {
state.dom.scrollContainer = el;
return el;
}
el = el.parentElement;
} catch (err) { break; }
}
const fallback = document.scrollingElement || document.documentElement || document.body;
state.dom.scrollContainer = fallback;
return fallback;
} catch (err) {
return document.scrollingElement || document.documentElement || document.body;
}
},
// Keep an anchor element visually stationary while mutating DOM
withStableAnchor(mutator) {
try {
const scroller = UI.getScrollContainer();
const anchor = state.dom.historyCard || state.dom.root;
let before = null;
if (scroller && anchor) {
const scRect = scroller.getBoundingClientRect();
const aRect = anchor.getBoundingClientRect();
before = aRect.top - scRect.top;
}
mutator && mutator();
requestAnimationFrame(() => {
try {
if (before == null) return;
const scRect2 = scroller.getBoundingClientRect();
const aRect2 = anchor.getBoundingClientRect();
const after = aRect2.top - scRect2.top;
const delta = after - before;
if (delta !== 0) scroller.scrollTop = Math.max(0, scroller.scrollTop + delta);
} catch (err) { /* noop */ }
});
} catch (err) {
// Fallback: just run the mutator
try { mutator && mutator(); } catch (e) { /* noop */ }
}
},
render() {
if (!state.container) return;
UI.ensureAssets();
// Preserve scroll position relative to the history card to avoid jank on refresh
const prevDom = state.dom;
let _prevScrollTop = 0;
let _prevAnchorOffset = null;
let _prevScroller = null;
const findScrollContainer = (node) => {
try {
let el = node;
while (el && el !== document.body && el !== document.documentElement) {
const style = window.getComputedStyle(el);
const oy = style.overflowY;
if ((oy === 'auto' || oy === 'scroll') && el.scrollHeight > el.clientHeight) return el;
el = el.parentElement;
}
return document.scrollingElement || document.documentElement || document.body;
} catch (err) {
return document.scrollingElement || document.documentElement || document.body;
}
};
try {
_prevScroller = findScrollContainer(state.container);
if (_prevScroller) {
_prevScrollTop = _prevScroller.scrollTop || 0;
const prevCard = (prevDom && prevDom.historyCard) ? prevDom.historyCard : state.container.querySelector('.pqueue-card');
if (prevCard) {
const scRect = _prevScroller.getBoundingClientRect();
const cardRect = prevCard.getBoundingClientRect();
_prevAnchorOffset = cardRect.top - scRect.top;
}
}
} catch (err) { /* noop */ }
const dom = (state.dom = {});
state.container.innerHTML = "";
const root = UI.el("div", { class: "pqueue-root" });
dom.root = root;
root.appendChild(UI.renderToolbar());
const sections = UI.el("div", { class: "pqueue-sections" });
const metrics = UI.renderMetrics();
if (metrics) sections.appendChild(metrics);
sections.appendChild(UI.renderRunning());
sections.appendChild(UI.renderPending());
sections.appendChild(UI.renderHistory());
root.appendChild(sections);
state.container.appendChild(root);
// Expose scroller for other operations
dom.sections = sections;
// Restore scroll position relative to the history card to prevent layout shifts
requestAnimationFrame(() => {
try {
const scroller = _prevScroller || findScrollContainer(state.container);
if (!scroller) return;
const newCard = (state.dom && state.dom.historyCard) ? state.dom.historyCard : state.container.querySelector('.pqueue-card');
if (_prevAnchorOffset != null && newCard) {
const scRect2 = scroller.getBoundingClientRect();
const cardRect2 = newCard.getBoundingClientRect();
const newOffset = cardRect2.top - scRect2.top;
scroller.scrollTop = Math.max(0, _prevScrollTop + (newOffset - _prevAnchorOffset));
} else {
scroller.scrollTop = _prevScrollTop;
}
} catch (err) { /* noop */ }
});
Events.bind();
UI.updateProgressBars();
UI.refreshFilter();
UI.updateSelectionUI();
UI.updateToolbarStatus();
},
renderToolbar() {
const pauseBtn = UI.button({
id: "pqueue-toggle",
icon: state.paused ? "ti ti-player-play-filled" : "ti ti-player-pause-filled",
ariaLabel: state.paused ? "Resume queue" : "Pause queue",
title: state.paused ? "Resume queue" : "Pause queue",
variant: state.paused ? "success" : "warning",
subtle: true,
});
pauseBtn.addEventListener('click', Events.togglePause);
const clearBtn = UI.button({
id: "pqueue-clear",
icon: "ti ti-player-stop",
ariaLabel: "Clear pending",
title: "Clear pending jobs",
variant: "danger",
subtle: true,
});
clearBtn.addEventListener('click', Events.clearPending);
// Minimal toolbar: only pause/resume and clear
return UI.el("div", { class: "pqueue-toolbar" }, [
UI.el("div", { class: "pqueue-toolbar__row" }, [
UI.el("div", { class: "pqueue-toolbar__group" }, [pauseBtn, clearBtn]),
]),
]);
},
renderMetrics() {
const m = state.metrics;
if (!m) return null;
const tiles = [
UI.metricTile({
icon: "ti ti-stack-2",
label: "In queue",
value: String(m.queueCount),
caption: `${m.runningCount} running • ${m.persistedCount} persisted`,
variant: m.queueCount ? "primary" : "neutral",
}),
UI.metricTile({
icon: "ti ti-chart-pie",
label: "Success rate",
value: m.successRate != null ? `${Math.round(m.successRate * 100)}%` : "—",
caption: m.failureCount ? `${m.failureCount} recent failure${m.failureCount === 1 ? "" : "s"}` : "No recent failures",
variant: m.successRate != null && m.successRate >= 0.75 ? "success" : "neutral",
}),
UI.metricTile({
icon: "ti ti-clock-hour-3",
label: "Avg duration",
value: m.avgDuration ? Format.duration(m.avgDuration) : "—",
caption: "Calculated from history",
variant: "neutral",
}),
UI.metricTile({
icon: "ti ti-hourglass-high",
label: "Est. total duration",
value: m.estimatedTotalDuration ? Format.duration(m.estimatedTotalDuration) : "—",
caption: [
m.estimatedRunningDuration ? `~${Format.duration(m.estimatedRunningDuration)} running` : null,
m.estimatedPendingDuration ? `~${Format.duration(m.estimatedPendingDuration)} pending` : null,
].filter(Boolean).join(" • ") || (m.queueCount ? `${m.queueCount} pending item${m.queueCount === 1 ? "" : "s"}` : "No pending items"),
variant: m.estimatedTotalDuration ? "neutral" : "neutral",
tooltip: "Sum of per-workflow averages for running (remaining) and pending items. Per-workflow averages are computed from recent history; if unavailable, falls back to global average duration.",
}),
];
const wrap = UI.el("div", { class: "pqueue-metrics" }, tiles);
state.dom.metrics = wrap;
return wrap;
},
renderRunning() {
const rows = [];
if (!state.queue_running.length) {
rows.push(UI.emptyState({
icon: "ti ti-player-play",
title: "No running job",
description: "Prompt will start running after the queue resumes",
}));
} else {
state.queue_running.forEach((item, index) => {
const pid = item[1];
const fraction = Math.max(0, Math.min(1, Number(state.running_progress?.[pid]) || 0));
const workflowName = state.workflowNameCache.get(pid) || pid;
const meta = UI.el("div", { class: "pqueue-item__meta" }, [
UI.icon("ti ti-loader-2", { spin: true }),
UI.el("span", { class: "pqueue-chip pqueue-chip--primary", text: `#${index + 1}` }),
UI.el("span", { class: "pqueue-row__label", text: workflowName }),
UI.el("span", { class: "pqueue-progress__label", text: Format.percent(fraction) }),
]);
const bar = UI.el("div", { class: "pqueue-progress-bar" });
bar.style.width = `${(fraction * 100).toFixed(1)}%`;
const progress = UI.el("div", { class: "pqueue-progress" }, [UI.el("div", { class: "pqueue-progress__track" }, [bar])]);
rows.push(UI.el("div", { class: "pqueue-item pqueue-item--running", "data-id": pid }, [meta, progress]));
});
}
const card = UI.card("Currently running", {
icon: "ti ti-activity-heartbeat",
subtitle: state.metrics.runningCount ? `${state.metrics.runningCount} active` : null,
content: rows,
});
state.dom.runningCard = card;
return card;
},
renderPending() {
const wrapper = UI.el("div", { class: "pqueue-table-wrapper" });
const list = UI.el("div", { class: "pqueue-list", id: "pqueue-pending" });
state.dom.pendingTable = list;
// Search input moved to Queue card header actions
const filter = UI.el("input", {
id: "pqueue-filter",
class: "pqueue-input",
type: "search",
placeholder: "Filter pending by prompt, status, error…",
value: state.filters.pending,
spellcheck: "false",
});
state.dom.filterInput = filter;
const listHeader = UI.el("div", { class: "pqueue-list__header" }, [
UI.el("label", { class: "pqueue-list__selectall" }, [
UI.el("input", { type: "checkbox", class: "pqueue-checkbox pqueue-select-all", title: "Select all visible" }),
UI.el("span", { class: "pqueue-table__heading", text: "Select all" })
])
]);
list.appendChild(listHeader);
const rows = state.queue_pending.map(UI.pendingRow).filter(Boolean);
if (!rows.length) {
rows.push(UI.emptyState({
icon: "ti ti-stack-2",
title: "Queue empty",
description: "Enqueue prompts in ComfyUI to populate the persistent queue.",
}));
}
rows.forEach((row) => list.appendChild(row));
wrapper.appendChild(list);
const footerCount = UI.el("span", { class: "pqueue-muted" });
const footerUpdated = UI.el("span", { class: "pqueue-muted" });
state.dom.pendingCount = footerCount;
state.dom.pendingUpdated = footerUpdated;
const deleteSelected = UI.button({
id: "pqueue-delete-selected",
icon: "ti ti-trash",
variant: "danger",
subtle: true,
disabled: state.selectedPending.size === 0,
badge: state.selectedPending.size || null,
});
state.dom.deleteSelectedBtn = deleteSelected;
const footer = UI.el("div", { class: "pqueue-table__footer" }, [
UI.el("div", { class: "pqueue-table__footer-left" }, [footerCount, footerUpdated]),
UI.el("div", { class: "pqueue-table__footer-right" }, [deleteSelected]),
]);
const card = UI.card("Queue", {
icon: "ti ti-stack-front",
subtitle: state.metrics.queueCount ? `${state.metrics.queueCount} pending` : null,
actions: [filter],
content: [wrapper, footer],
});
state.dom.pendingCard = card;
return card;
},
pendingRow(item) {
if (!Array.isArray(item)) return null;
const pid = String(item[1] ?? "");
const db = state.dbIndex.get(pid) || {};
const selected = state.selectedPending.has(pid);
const row = UI.el("article", { class: ["pqueue-row", selected ? "is-selected" : null], "data-id": pid, draggable: "true" });
const checkbox = UI.el("input", { type: "checkbox", class: "pqueue-checkbox pqueue-select" });
checkbox.checked = selected;
const status = UI.statusBadge(db.status || "pending", { subtle: true });
const primaryLabel = UI.buildWorkflowLabel(pid, item, db);
primaryLabel.contentEditable = "true";
primaryLabel.spellcheck = false;
primaryLabel.dataset.id = pid;
primaryLabel.dataset.original = primaryLabel.textContent || "";
primaryLabel.classList.add("pqueue-editable");
const finishEdit = async (node, save) => {
try {
const id = node?.dataset?.id;
if (!id) return;
const original = node.dataset.original || "";
const current = (node.textContent || "").trim();
if (!save) {
node.textContent = original;
return;
}
if (current === original || !current) return;
await API.rename(id, current);
state.workflowNameCache.set(id, current);
setStatusMessage("Name updated");
} catch (err) {
console.error("pqueue: rename failed", err);
state.error = err?.message || "Failed to rename";
UI.updateToolbarStatus();
} finally {
node.dataset.original = node.textContent || "";
}
};
primaryLabel.addEventListener("keydown", async (e) => {
e.stopPropagation();
const target = e.currentTarget;
if (e.key === "Enter") {
e.preventDefault();
await finishEdit(target, true);
target.blur();
} else if (e.key === "Escape") {
e.preventDefault();
await finishEdit(target, false);
target.blur();
}
});
primaryLabel.addEventListener("blur", async (e) => {
await finishEdit(e.currentTarget, true);
});
const line1 = UI.el("div", { class: "pqueue-pcard__line1" }, [
UI.el("div", { class: "pqueue-pcard__l1-left" }, [checkbox, primaryLabel]),
UI.el("div", { class: "pqueue-pcard__l1-right" }, [UI.el("span", { class: "pqueue-muted", text: db.created_at ? Format.relative(db.created_at) : "—" })])
]);
const estimateSeconds = UI.estimateDuration(pid, item[2]);
const estimateLabel = estimateSeconds ? `Est. ${Format.duration(estimateSeconds)}` : "";
const dateLabel = db.created_at ? new Date(db.created_at).toLocaleDateString() : "";
const timeLabel = db.created_at ? new Date(db.created_at).toLocaleTimeString() : "";
const deleteBtn = UI.button({ icon: "ti ti-trash", variant: "danger", subtle: true, title: "Delete" });
deleteBtn.dataset.action = "delete";
deleteBtn.dataset.id = pid;
const moveTopBtn = UI.button({ icon: "ti ti-arrow-bar-to-up", variant: "ghost", subtle: true, title: "Move to top" });
moveTopBtn.dataset.action = "move-top";
moveTopBtn.dataset.id = pid;
const moveBottomBtn = UI.button({ icon: "ti ti-arrow-bar-to-down", variant: "ghost", subtle: true, title: "Move to bottom" });
moveBottomBtn.dataset.action = "move-bottom";
moveBottomBtn.dataset.id = pid;
const actionsWrap = UI.el("div", { class: "pqueue-actions-group" }, [deleteBtn, moveTopBtn, moveBottomBtn, UI.icon("ti ti-drag-drop", { size: "sm" })]);
const line2 = UI.el("div", { class: "pqueue-pcard__line2" }, [
UI.el("span", { class: "pqueue-muted", text: estimateLabel }),
UI.el("span", { class: "pqueue-muted", text: dateLabel }),
UI.el("span", { class: "pqueue-muted", text: timeLabel }),
actionsWrap,
]);
const statusLine = UI.el("div", { class: "pqueue-pcard__status" }, [status]);
if (db.error) {
const err = UI.icon("ti ti-alert-triangle", { size: "sm" });
err.classList.add("pqueue-row__error");
err.title = db.error;
statusLine.appendChild(err);
}
row.appendChild(line1);
row.appendChild(line2);
row.appendChild(statusLine);
row.dataset.search = [state.workflowNameCache.get(pid) || "", db.status, db.error, db.created_at].filter(Boolean).join(" ").toLowerCase();
return row;
},
renderHistory() {
const grid = UI.el("div", { class: "pqueue-history" });
state.dom.historyGrid = grid;
if (!state.history.length) {
grid.appendChild(UI.emptyState({
icon: "ti ti-photo-off",
title: "History empty",
description: "Completed prompts will show their thumbnails here.",
}));
} else {
state.history.forEach((row) => grid.appendChild(UI.historyCard(row)));
}
const sentinel = UI.historySentinel();
state.dom.historySentinel = sentinel;
grid.appendChild(sentinel);
const count = (state.historyTotal != null) ? state.historyTotal : state.metrics.historyCount;
const base = (count != null) ? `${count} entries` : null;
const range = UI.currentHistoryRangeLabel();
const subtitle = [base, range].filter(Boolean).join(' • ');
const card = UI.card("History", {
icon: "ti ti-clock-bolt",
subtitle,
actions: UI.historyFilters(),
content: grid,
});
state.dom.historyCard = card;
state.dom.historySubtitle = card.querySelector('.pqueue-card__subtitle');
return card;
},
currentHistoryRangeLabel() {
try {
const sDate = state.filters?.historySince || "";
const sTime = state.filters?.historySinceTime || "";
const uDate = state.filters?.historyUntil || "";
const uTime = state.filters?.historyUntilTime || "";
if (!sDate && !uDate) return "";
if (state.historyPreset === 'today') return 'Today';
if (state.historyPreset === '24h') return 'Last 24h';
if (state.historyPreset === '7d') return 'Last 7 days';
const compact = (d, t, end) => {
if (!d) return '';
const parts = d.split('-').map((x) => parseInt(x, 10));
let hh = end ? 23 : 0, mm = end ? 59 : 0;
if (t && /^\d{2}:\d{2}/.test(t)) {
const tt = t.split(':').map((x) => parseInt(x, 10));
hh = tt[0] ?? hh; mm = tt[1] ?? mm;
}
const pad = (n) => String(n).padStart(2, '0');
return `${parts[0]}-${pad(parts[1])}-${pad(parts[2])} ${pad(hh)}:${pad(mm)}`;
};
const left = compact(sDate, sTime, false);
const right = compact(uDate, uTime, true);
if (left && right) return `${left} – ${right}`;
if (left) return `Since ${left}`;
if (right) return `Until ${right}`;
return "";
} catch (err) {
return "";
}
},
updateHistorySubtitle() {
try {
const el = state.dom.historySubtitle;
if (!el) return;
const count = (state.historyTotal != null) ? state.historyTotal : state.metrics?.historyCount;
const base = (count != null) ? `${count} entries` : null;
const range = UI.currentHistoryRangeLabel();
const text = [base, range].filter(Boolean).join(' • ');
el.textContent = text;
} catch (err) {
/* noop */
}
},
historyFilters() {
const filtersBtn = UI.button({ icon: "ti ti-filter", variant: "ghost", subtle: true, title: "Filters" });
const sortToggle = UI.sortToggleButton();
filtersBtn.addEventListener('click', (e) => {
e.stopPropagation();
Events.toggleFiltersPopover(e.currentTarget);
});
return [filtersBtn, sortToggle, UI.clearFiltersIcon()];
},
clearFiltersIcon() {
const btn = UI.button({ icon: "ti ti-x", variant: "ghost", subtle: true, title: "Clear filters" });
btn.addEventListener('click', () => Events.applyHistoryPreset('clear'));
return btn;
},
sortToggleButton() {
const dir = (state.historyPaging?.params?.sort_dir === 'asc') ? 'asc' : 'desc';
const icon = dir === 'asc' ? 'ti ti-arrow-bar-to-up' : 'ti ti-arrow-bar-to-down';
const title = dir === 'asc' ? 'Sort ascending (click to toggle)' : 'Sort descending (click to toggle)';
const btn = UI.button({ icon, variant: 'ghost', subtle: true, title });
btn.addEventListener('click', () => {
const current = (state.historyPaging?.params?.sort_dir === 'asc') ? 'asc' : 'desc';
const next = current === 'asc' ? 'desc' : 'asc';
Events.setHistorySort(next);
UI.updateSortToggle();
});
state.dom.sortToggleBtn = btn;
return btn;
},
updateSortToggle() {
try {
const btn = state.dom.sortToggleBtn;
if (!btn) return;
const dir = (state.historyPaging?.params?.sort_dir === 'asc') ? 'asc' : 'desc';
const icon = dir === 'asc' ? 'ti ti-arrow-bar-to-up' : 'ti ti-arrow-bar-to-down';
btn.innerHTML = '';
btn.appendChild(UI.icon(icon));
btn.title = dir === 'asc' ? 'Sort ascending (click to toggle)' : 'Sort descending (click to toggle)';
} catch (err) {
/* noop */
}
},
historySentinel() {
const wrap = UI.el("div", { class: "pqueue-history-sentinel" });
const spinner = UI.icon("ti ti-loader-2", { size: "md", spin: true });
wrap.appendChild(spinner);
return wrap;
},
historyCard(row) {
const card = UI.el("article", { class: "pqueue-history-card", title: Format.tooltip(row) });
const header = UI.el("div", { class: "pqueue-history-card__header" }, [UI.statusBadge(row.status || "success"), UI.el("span", { class: "pqueue-history-card__time", text: row.completed_at ? Format.relative(row.completed_at) : Format.relative(row.created_at) })]);
const fileLabel = UI.historyPrimaryFilename(row);
const meta = UI.el("div", { class: "pqueue-history-card__meta" }, [UI.el("span", { class: "pqueue-code", text: fileLabel }), UI.el("span", { class: "pqueue-history-card__duration", text: Format.duration(Number(row.duration_seconds)) || "—" })]);
const thumbs = UI.renderThumbs(row);
card.appendChild(header);
card.appendChild(meta);
card.appendChild(thumbs);
return card;
},
renderThumbs(row) {
const container = UI.el("div", { class: "pqueue-thumb-row" });
const placeholderMeta = UI.thumbPlaceholder(row);
const ensurePlaceholder = () => {
const wrap = UI.el("div", { class: ["pqueue-thumb-wrap", "pqueue-thumb-wrap-placeholder", placeholderMeta.variant ? `pqueue-thumb-wrap-placeholder--${placeholderMeta.variant}` : null] });
wrap.appendChild(UI.thumbPlaceholderNode(placeholderMeta));
container.appendChild(wrap);
};
const attachFallback = (wrap, img, images) => {
img.addEventListener(
"error",
() => {
wrap.classList.add("pqueue-thumb-wrap-placeholder");
if (placeholderMeta.variant) wrap.classList.add(`pqueue-thumb-wrap-placeholder--${placeholderMeta.variant}`);
wrap.replaceChildren(UI.thumbPlaceholderNode(placeholderMeta));
if (!images.length) wrap.onclick = null;
},
{ once: true }
);
};
if (row.id) {
const galleryImages = UI.extractImages(row);
const url = new URL(`/api/pqueue/history/thumb/${row.id}`, window.location.origin);
url.searchParams.set("_", String(Date.now()));
const wrap = UI.el("div", { class: "pqueue-thumb-wrap" });
const img = UI.el("img", { class: "pqueue-thumb", src: url.href, title: `history-${row.id}`, loading: "lazy" });
attachFallback(wrap, img, galleryImages);
wrap.appendChild(img);
const count = UI.countImages(row);
if (count > 1) wrap.appendChild(UI.el("div", { class: "pqueue-thumb-badge", text: `${count}` }));
if (galleryImages.length) wrap.onclick = () => UI.openGallery(galleryImages, 0, row.prompt_id);
container.appendChild(wrap);
}
if (!container.children.length) {
const images = UI.extractImages(row);
if (images.length) {
const first = images[0];
const url = UI.buildPreviewUrl(first, row.prompt_id);
const wrap = UI.el("div", { class: "pqueue-thumb-wrap" });
const img = UI.el("img", { class: "pqueue-thumb", src: url.href, title: first.filename, loading: "lazy" });
attachFallback(wrap, img, images);
wrap.appendChild(img);
if (images.length > 1) wrap.appendChild(UI.el("div", { class: "pqueue-thumb-badge", text: `${images.length}` }));
wrap.onclick = () => UI.openGallery(images, 0, row.prompt_id);
container.appendChild(wrap);
}
}
if (!container.children.length) ensurePlaceholder();
return container;
},
thumbPlaceholder(row) {
const status = String(row?.status ?? "").toLowerCase();
if (["interrupted", "cancelled", "canceled", "stopped"].includes(status)) {
return { text: "Interrupted", icon: "ti ti-player-stop", variant: "interrupted" };
}
if (["error", "failed", "failure"].includes(status)) {
return { text: "Failed", icon: "ti ti-alert-triangle", variant: "failed" };
}
return { text: "Preview unavailable", icon: "ti ti-photo-off", variant: "empty" };
},
thumbPlaceholderNode(meta) {
return UI.el("div", { class: ["pqueue-thumb-placeholder", meta.variant ? `pqueue-thumb-placeholder--${meta.variant}` : null] }, [UI.icon(meta.icon), UI.el("span", { class: "pqueue-thumb-placeholder__text", text: meta.text })]);
},
estimateDuration(promptId, workflow) {
let key = state.workflowCache.get(promptId);
if (!key && workflow !== undefined) {
try {
key = typeof workflow === "string" ? workflow : JSON.stringify(workflow);
if (key) state.workflowCache.set(promptId, key);
} catch (err) {
/* ignore */
}
}
if (key && state.durationByWorkflow.has(key)) return state.durationByWorkflow.get(key);
return state.metrics.avgDuration;
},
updateProgressBars() {
if (!state.dom.root) return;
Object.entries(state.running_progress).forEach(([pid, frac]) => {
const bar = state.dom.root.querySelector(`.pqueue-item[data-id="${pid}"] .pqueue-progress-bar`);
if (bar) bar.style.width = `${(Number(frac) * 100).toFixed(1)}%`;
});
},
ensureHistoryObserver() {
try {
if (!('IntersectionObserver' in window)) return;
const grid = state.dom.historyGrid;
const sentinel = state.dom.historySentinel;
if (!grid || !sentinel) return;
if (state.historyObserver) {
state.historyObserver.disconnect();
state.historyObserver = null;
}
// Initialize cursor from currently loaded list if not set
if (!state.historyPaging?.nextCursor && state.history?.length) {
const last = state.history[state.history.length - 1];
state.historyPaging.nextCursor = { id: last?.id, value: last?.id };
state.historyPaging.hasMore = true;
}
const obs = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
Events.loadMoreHistory();
});
}, { root: null, rootMargin: '600px 0px', threshold: 0 });
obs.observe(sentinel);
state.historyObserver = obs;
} catch (err) {
/* noop */
}
},
buildFiltersPopover(anchor) {
const since = UI.el("input", { type: "date", class: "pqueue-input", value: state.filters?.historySince || "", id: "pqueue-since-date" });
const sinceTime = UI.el("input", { type: "time", class: "pqueue-input", value: state.filters?.historySinceTime || "", id: "pqueue-since-time", step: "1" });
const until = UI.el("input", { type: "date", class: "pqueue-input", value: state.filters?.historyUntil || "", id: "pqueue-until-date" });
const untilTime = UI.el("input", { type: "time", class: "pqueue-input", value: state.filters?.historyUntilTime || "", id: "pqueue-until-time", step: "1" });
const onChange = () => Events.updateHistoryDateFilters(since.value, until.value, sinceTime.value, untilTime.value);
[since, sinceTime, until, untilTime].forEach((el) => el.addEventListener("change", onChange));
// store DOM refs so presets can reflect UI immediately
state.dom.historySince = since;
state.dom.historySinceTime = sinceTime;
state.dom.historyUntil = until;
state.dom.historyUntilTime = untilTime;
const dates = UI.el("div", { class: "pqueue-popover__grid" }, [
UI.el("label", { class: "pqueue-field" }, [UI.el("span", { class: "pqueue-field__label", text: "Since date" }), since]),
UI.el("label", { class: "pqueue-field" }, [UI.el("span", { class: "pqueue-field__label", text: "Since time" }), sinceTime]),
UI.el("label", { class: "pqueue-field" }, [UI.el("span", { class: "pqueue-field__label", text: "Until date" }), until]),
UI.el("label", { class: "pqueue-field" }, [UI.el("span", { class: "pqueue-field__label", text: "Until time" }), untilTime]),
]);
const presets = UI.el("div", { class: "pqueue-popover__section pqueue-popover__presets" }, [
UI.button({ text: "Today", variant: "ghost", subtle: true, size: "sm", onClick: () => Events.applyHistoryPreset("today") }),
UI.button({ text: "Last 24h", variant: "ghost", subtle: true, size: "sm", onClick: () => Events.applyHistoryPreset("24h") }),
UI.button({ text: "Last 7d", variant: "ghost", subtle: true, size: "sm", onClick: () => Events.applyHistoryPreset("7d") }),
UI.button({ text: "Clear", variant: "ghost", subtle: true, size: "sm", onClick: () => Events.applyHistoryPreset("clear") }),
]);
const header = UI.el('div', { class: 'pqueue-popover__header pqueue-card__header' }, [
UI.el('div', { class: 'pqueue-popover__title', text: 'History filters' }),
UI.el('div', { class: 'pqueue-popover__hint', text: 'Local timezone applied' }),
UI.button({ icon: 'ti ti-x', variant: 'ghost', subtle: true, title: 'Close', onClick: () => { try { pop?.dispatchEvent(new CustomEvent('pqueue-close', { bubbles: true })); } catch (err) {} } })
]);
const footer = UI.el('div', { class: 'pqueue-popover__footer' }, [
UI.el('div', { class: 'pqueue-popover__footer-left' }, [UI.el('span', { class: 'pqueue-muted', text: UI.currentHistoryRangeLabel() })]),
UI.el('div', { class: 'pqueue-popover__footer-right' }, [
UI.button({ text: 'Clear', variant: 'ghost', subtle: true, size: 'sm', onClick: () => Events.applyHistoryPreset('clear') }),
UI.button({ text: 'Done', variant: 'secondary', size: 'sm', onClick: () => { try { pop?.dispatchEvent(new CustomEvent('pqueue-close', { bubbles: true })); } catch (err) {} } })
])
]);
const content = UI.el('div', { class: 'pqueue-card__body pqueue-popover__content' }, [
UI.el('div', { class: 'pqueue-popover__section' }, [UI.el('strong', { text: 'Date range' })]),
dates,
UI.el('div', { class: 'pqueue-popover__section' }, [UI.el('strong', { text: 'Presets' })]),
presets
]);
const pop = UI.el('div', { class: ['pqueue-popover', 'pqueue-card'], role: 'dialog', 'aria-label': 'History filters' }, [
UI.el('div', { class: 'pqueue-popover__arrow' }),
header,
content,
footer
]);
pop.setAttribute('data-open', 'false');
// keyboard handling: Esc closes; Tab traps within
const keyHandler = (e) => {
if (e.key === 'Escape') {
e.preventDefault();
try { pop.dispatchEvent(new CustomEvent('pqueue-close', { bubbles: true })); } catch (err) {}
} else if (e.key === 'Tab') {
const focusables = pop.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
const list = Array.from(focusables).filter((n) => !n.hasAttribute('disabled'));
if (!list.length) return;
const first = list[0];
const last = list[list.length - 1];
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
}
};
pop.addEventListener('keydown', keyHandler);
// Position near anchor and align arrow
try {
const rect = anchor.getBoundingClientRect();
const maxLeft = window.innerWidth - 360;
const left = Math.min(rect.left + window.scrollX, maxLeft);
const top = rect.bottom + 8 + window.scrollY;
pop.style.top = `${top}px`;
pop.style.left = `${left}px`;
const arrow = pop.querySelector('.pqueue-popover__arrow');
if (arrow) {
const anchorCenter = rect.left + rect.width / 2 + window.scrollX;
const popLeft = left;
const offset = Math.max(14, Math.min((anchorCenter - popLeft), 346));
arrow.style.left = `${offset}px`;
}
} catch (err) {}
// focus first input
window.setTimeout(() => since.focus(), 0);
return pop;
},
refreshFilter() {
const table = state.dom.pendingTable;
if (!table) return;
const filterVal = (state.filters.pending || "").trim().toLowerCase();
const rows = Array.from(table.querySelectorAll(".pqueue-row[data-id]"));
let visible = 0;
rows.forEach((row) => {
const match = !filterVal || (row.dataset.search || "").includes(filterVal);
row.style.display = match ? "" : "none";
if (match) visible += 1;
});
UI.updatePendingFooter(visible);
},
updatePendingFooter(visibleCount) {
if (state.dom.pendingCount) state.dom.pendingCount.textContent = `${visibleCount} row${visibleCount === 1 ? "" : "s"}`;
if (state.dom.pendingUpdated) state.dom.pendingUpdated.textContent = state.lastUpdated ? `Updated ${Format.relative(state.lastUpdated)}` : "Awaiting update";
},
updateSelectionUI() {
const table = state.dom.pendingTable;
if (!table) return;
const rows = Array.from(table.querySelectorAll(".pqueue-row[data-id]"));
const visibleRows = rows.filter((row) => row.style.display !== "none");
visibleRows.forEach((row) => {
const selected = state.selectedPending.has(row.dataset.id);
row.classList.toggle("is-selected", selected);
const checkbox = row.querySelector(".pqueue-select");
if (checkbox) checkbox.checked = selected;
});
const visibleSelected = visibleRows.filter((row) => state.selectedPending.has(row.dataset.id)).length;
const selectAll = table.querySelector(".pqueue-select-all");
if (selectAll) {
if (!visibleRows.length) {
selectAll.checked = false;
selectAll.indeterminate = false;
} else {
selectAll.checked = visibleSelected === visibleRows.length;
selectAll.indeterminate = visibleSelected > 0 && visibleSelected < visibleRows.length;
}
}
const count = state.selectedPending.size || null;
if (state.dom.bulkPriorityBtn) {
state.dom.bulkPriorityBtn.disabled = !count;
const badge = state.dom.bulkPriorityBtn.querySelector(".pqueue-button__badge");
if (badge) {
if (count) badge.textContent = String(count);
else badge.remove();
} else if (count) {
state.dom.bulkPriorityBtn.appendChild(UI.el("span", { class: "pqueue-button__badge", text: String(count) }));
}
}
if (state.dom.deleteSelectedBtn) {
state.dom.deleteSelectedBtn.disabled = !count;
const badge = state.dom.deleteSelectedBtn.querySelector(".pqueue-button__badge");
if (badge) {
if (count) badge.textContent = String(count);
else badge.remove();
} else if (count) {
state.dom.deleteSelectedBtn.appendChild(UI.el("span", { class: "pqueue-button__badge", text: String(count) }));
}
}
},
updateToolbarStatus() {
const status = state.dom.status;
if (!status) return;
status.innerHTML = "";
if (state.error) {
status.appendChild(UI.icon("ti ti-alert-circle", { size: "md" }));
status.appendChild(UI.el("span", { class: "pqueue-toolbar__status-text pqueue-toolbar__status-text--error", text: state.error }));
return;
}
if (state.statusMessage) {
status.appendChild(UI.icon("ti ti-info-circle", { size: "md" }));
status.appendChild(UI.el("span", { class: "pqueue-toolbar__status-text", text: state.statusMessage }));
return;
}
if (state.isRefreshing) {
status.appendChild(UI.icon("ti ti-loader-2", { size: "md", spin: true }));
status.appendChild(UI.el("span", { class: "pqueue-toolbar__status-text", text: "Syncing…" }));
return;
}
if (state.lastUpdated) {
status.appendChild(UI.icon("ti ti-history", { size: "md" }));
status.appendChild(UI.el("span", { class: "pqueue-toolbar__status-text", text: `Updated ${Format.relative(state.lastUpdated)}` }));
} else {
status.appendChild(UI.icon("ti ti-bolt", { size: "md" }));
status.appendChild(UI.el("span", { class: "pqueue-toolbar__status-text", text: "Ready" }));
}
},
ensureGallery() {
if (UI.galleryOverlay) return;
const overlay = UI.el("div", { class: "pqueue-gallery-overlay" });
const box = UI.el("div", { class: "pqueue-gallery" });
const closeBtn = UI.button({ icon: "ti ti-x", variant: "ghost", subtle: true, title: "Close" });
closeBtn.addEventListener("click", UI.closeGallery);
const img = UI.el("img", { class: "pqueue-gallery__img" });
const counter = UI.el("div", { class: "pqueue-gallery__counter" });
const prev = UI.button({ icon: "ti ti-chevron-left", variant: "ghost", subtle: true, title: "Previous" });
prev.addEventListener("click", () => UI.galleryShow(UI.galleryState.index - 1));
const next = UI.button({ icon: "ti ti-chevron-right", variant: "ghost", subtle: true, title: "Next" });
next.addEventListener("click", () => UI.galleryShow(UI.galleryState.index + 1));
box.appendChild(closeBtn);
box.appendChild(img);
box.appendChild(counter);
box.appendChild(prev);
box.appendChild(next);
overlay.appendChild(box);
overlay.addEventListener("click", (e) => {
if (e.target === overlay) UI.closeGallery();
});
document.body.appendChild(overlay);
UI.galleryOverlay = overlay;
UI.galleryImg = img;
UI.galleryCounter = counter;
UI.galleryState = { images: [], index: 0, pid: null };
},
openGallery(images, startIndex = 0, pid = null) {
UI.ensureGallery();
UI.galleryState.images = images || [];
UI.galleryState.pid = pid;
UI.galleryState.index = Math.max(0, Math.min(startIndex, UI.galleryState.images.length - 1));
UI.galleryOverlay.style.display = "flex";
UI.galleryShow(UI.galleryState.index);
UI.bindGalleryKeys(true);
},
closeGallery() {
if (UI.galleryOverlay) UI.galleryOverlay.style.display = "none";
UI.bindGalleryKeys(false);
},
galleryShow(index) {
const images = UI.galleryState.images;
if (!images.length) return;
UI.galleryState.index = (index + images.length) % images.length;
const desc = images[UI.galleryState.index];
UI.galleryImg.src = UI.buildPreviewUrl(desc, UI.galleryState.pid).href;
UI.galleryCounter.textContent = `${UI.galleryState.index + 1} / ${images.length}`;
},
bindGalleryKeys(enabled) {
const handler = (e) => {
if (!UI.galleryOverlay || UI.galleryOverlay.style.display !== "flex") return;
if (e.key === "Escape") UI.closeGallery();
else if (e.key === "ArrowRight") UI.galleryShow(UI.galleryState.index + 1);
else if (e.key === "ArrowLeft") UI.galleryShow(UI.galleryState.index - 1);
};
if (enabled) {
UI.galleryKeyHandler = handler;
document.addEventListener("keydown", handler);
} else if (UI.galleryKeyHandler) {
document.removeEventListener("keydown", UI.galleryKeyHandler);
UI.galleryKeyHandler = null;
}
},
buildPreviewUrl(imgDesc, pid) {
const url = new URL("/api/pqueue/preview", window.location.origin);
url.searchParams.set("filename", imgDesc.filename);
url.searchParams.set("type", imgDesc.type);
if (imgDesc.subfolder) url.searchParams.set("subfolder", imgDesc.subfolder);
url.searchParams.set("preview", "webp;50");
if (pid) url.searchParams.set("pid", pid);
return url;
},
extractImages(row) {
try {
let outputs = row.outputs ?? {};
if (typeof outputs === "string") {
try {
outputs = JSON.parse(outputs);
} catch (err) {
outputs = {};
}
}
const images = [];
Object.values(outputs).forEach((value) => {
if (value && typeof value === "object") {
const list = value.images ?? value.ui?.images ?? [];
if (Array.isArray(list)) list.forEach((img) => pushImg(img));
} else if (Array.isArray(value)) {
value.forEach((img) => pushImg(img));
}
});
return images;
function pushImg(img) {
if (!img || typeof img !== "object") return;
const filename = img.filename ?? img.name;
if (!filename) return;
images.push({ filename, type: img.type ?? "output", subfolder: img.subfolder ?? "" });
}
} catch (err) {
return [];
}
},
countImages(row) {
try {
return UI.extractImages(row).length;
} catch (err) {
return 0;
}
},
historyPrimaryFilename(row) {
try {
const images = UI.extractImages(row);
if (images.length && images[0]?.filename) {
return images[0].filename;
}
if (row.prompt_id) return String(row.prompt_id);
return "Unnamed output";
} catch (err) {
return row.prompt_id ? String(row.prompt_id) : "Unnamed output";
}
},
deriveWorkflowLabel(item, dbRow) {
try {
const prompt = item?.[2];
if (prompt && typeof prompt === "object") {
const name = prompt?.workflow?.name || prompt?.workflow?.title || prompt?.name;
if (typeof name === "string" && name.trim()) return name.trim();
}
if (dbRow?.workflow) {
try {
const parsed = JSON.parse(dbRow.workflow);
const name = parsed?.workflow?.name || parsed?.workflow?.title || parsed?.name;
if (typeof name === "string" && name.trim()) return name.trim();
} catch (err) {
/* ignore */
}
}
} catch (err) {
/* ignore */
}
return "Untitled workflow";
},
buildWorkflowLabel(pid, item, dbRow) {
let label = state.workflowNameCache.get(pid);
if (!label) {
label = UI.deriveWorkflowLabel(item, dbRow);
state.workflowNameCache.set(pid, label);
}
return UI.el("span", { class: "pqueue-row__label", text: label });
},
ensureWorkflowModal() {
if (UI.workflowOverlay) return;
const overlay = UI.el("div", { class: "pqueue-modal" });
const dialog = UI.el("div", { class: "pqueue-modal__dialog" });
const header = UI.el("header", { class: "pqueue-modal__header" });
const title = UI.el("h3", { class: "pqueue-modal__title" });
const subtitle = UI.el("div", { class: "pqueue-modal__subtitle" });
const copyBtn = UI.button({ icon: "ti ti-copy", text: "Copy JSON", variant: "ghost", subtle: true });
copyBtn.dataset.action = "workflow-copy";
const closeBtn = UI.button({ icon: "ti ti-x", variant: "ghost", subtle: true, title: "Close" });
closeBtn.addEventListener("click", UI.closeWorkflowModal);
header.appendChild(title);
header.appendChild(subtitle);
header.appendChild(UI.el("div", { class: "pqueue-modal__actions" }, [copyBtn, closeBtn]));
const body = UI.el("div", { class: "pqueue-modal__body" });
const code = UI.el("pre", { class: "pqueue-modal__code" });
body.appendChild(code);
dialog.appendChild(header);
dialog.appendChild(body);
overlay.appendChild(dialog);
overlay.addEventListener("click", (e) => {
if (e.target === overlay) UI.closeWorkflowModal();
});
document.body.appendChild(overlay);
UI.workflowOverlay = overlay;
UI.workflowDialog = dialog;
UI.workflowTitle = title;
UI.workflowSubtitle = subtitle;
UI.workflowCode = code;
UI.workflowCopyBtn = copyBtn;
UI.workflowCopyBtn.addEventListener("click", () => {
if (UI.workflowCode?.textContent) copyText(UI.workflowCode.textContent);
});
UI.workflowKeyHandler = (e) => {
if (e.key === "Escape") UI.closeWorkflowModal();
};
document.addEventListener("keydown", UI.workflowKeyHandler);
},
openWorkflowModal({ promptId, workflowText, sourceLabel }) {
UI.ensureWorkflowModal();
UI.workflowOverlay.style.display = "flex";
UI.workflowTitle.textContent = `Workflow for ${promptId}`;
UI.workflowSubtitle.textContent = sourceLabel ? `Source: ${sourceLabel}` : "";
UI.workflowCode.textContent = workflowText;
},
closeWorkflowModal() {
if (UI.workflowOverlay) UI.workflowOverlay.style.display = "none";
},
card(title, { icon, actions, subtitle, content, classes } = {}) {
const head = [];
const titleWrap = UI.el("div", { class: "pqueue-card__title-wrap" });
if (icon) titleWrap.appendChild(UI.icon(icon, { size: "md" }));
titleWrap.appendChild(UI.el("h3", { class: "pqueue-card__title", text: title }));
if (subtitle) titleWrap.appendChild(UI.el("span", { class: "pqueue-card__subtitle", text: subtitle }));
head.push(titleWrap);
if (actions && actions.length) head.push(UI.el("div", { class: "pqueue-card__actions" }, actions));
const body = Array.isArray(content) ? content : [content];
return UI.el("section", { class: ["pqueue-card", classes].filter(Boolean) }, [UI.el("header", { class: "pqueue-card__header" }, head), UI.el("div", { class: "pqueue-card__body" }, body)]);
},
mountFallback() {
if (document.getElementById("pqueue-fallback-panel")) {
state.container = document.querySelector("#pqueue-fallback-panel .pqueue-fallback__content");
return;
}
const fab = UI.el("button", { id: "pqueue-fab", class: "pqueue-fab", type: "button" }, [UI.icon("ti ti-history", { size: "md" }), UI.el("span", { class: "pqueue-fab__label", text: "Persistent Queue" })]);
const panel = UI.el("div", { id: "pqueue-fallback-panel", class: "pqueue-fallback", "data-open": "false" });
const header = UI.el("header", { class: "pqueue-fallback__header" }, [UI.el("div", { class: "pqueue-fallback__title", text: "Persistent Queue" }), UI.button({ icon: "ti ti-x", variant: "ghost", subtle: true, title: "Close", onClick: () => panel.setAttribute("data-open", "false") })]);
const content = UI.el("div", { class: "pqueue-fallback__content" });
panel.appendChild(header);
panel.appendChild(content);
document.body.appendChild(panel);
document.body.appendChild(fab);
fab.addEventListener("click", () => {
const open = panel.getAttribute("data-open") === "true";
panel.setAttribute("data-open", open ? "false" : "true");
if (!open) refresh({ skipIfBusy: true });
});
state.container = content;
},
removeFallback() {
document.getElementById("pqueue-fab")?.remove();
document.getElementById("pqueue-fallback-panel")?.remove();
},
};
const Events = {
bind() {
document.getElementById("pqueue-toggle")?.addEventListener("click", Events.togglePause);
document.getElementById("pqueue-clear")?.addEventListener("click", Events.clearPending);
document.getElementById("pqueue-refresh")?.addEventListener("click", Events.manualRefresh);
state.dom.filterInput?.addEventListener("input", Events.handleFilter);
state.dom.deleteSelectedBtn?.addEventListener("click", Events.deleteSelected);
state.dom.pendingTable?.querySelector(".pqueue-select-all")?.addEventListener("change", Events.toggleSelectAll);
const table = state.dom.pendingTable;
if (table) {
table.addEventListener("dragstart", Events.handleDragStart);
table.addEventListener("dragover", Events.handleDragOver);
table.addEventListener("dragleave", Events.handleDragLeave);
table.addEventListener("drop", Events.handleDrop);
table.addEventListener("dragend", Events.handleDragEnd);
table.addEventListener("change", Events.handleTableChange);
table.addEventListener("click", Events.handleTableClick);
table.addEventListener("keydown", Events.handleTableKey);
}
UI.ensureHistoryObserver();
// Short-lived render lock on pointer down/up inside our container to prevent re-renders during clicks
try {
const root = state.container;
if (root && !root._pqlockBound) {
const lock = () => { state.renderLockUntil = Date.now() + 300; };
const unlockSoon = () => { state.renderLockUntil = Date.now() + 100; };
root.addEventListener('pointerdown', lock, true);
root.addEventListener('pointerup', unlockSoon, true);
root.addEventListener('click', unlockSoon, true);
root._pqlockBound = true;
}
} catch (err) { /* noop */ }
},
async togglePause() {
try {
if (state.paused) await API.resume();
else await API.pause();
setStatusMessage(state.paused ? "Resuming queue…" : "Pausing queue…");
await refresh();
} catch (err) {
console.error("pqueue: toggle pause failed", err);
state.error = err?.message || "Failed to toggle queue";
UI.updateToolbarStatus();
}
},
async clearPending() {
if (!state.queue_pending.length) {
setStatusMessage("Queue already empty");
return;
}
if (!window.confirm(`Remove ${state.queue_pending.length} pending prompt${state.queue_pending.length === 1 ? "" : "s"}?`)) return;
try {
const ids = state.queue_pending.map((item) => item[1]).filter(Boolean);
await API.del(ids);
setStatusMessage(`Cleared ${ids.length} prompt${ids.length === 1 ? "" : "s"}`);
await refresh();
} catch (err) {
console.error("pqueue: clear failed", err);
state.error = err?.message || "Failed to clear queue";
UI.updateToolbarStatus();
}
},
manualRefresh() {
if (state.isRefreshing) return;
// Reset history paging on manual refresh
state.history = [];
state.historyIds = new Set();
// Preserve current date filters when resetting paging
const pad = (n) => String(n).padStart(2, '0');
const fmt = (dt) => `${dt.getFullYear()}-${pad(dt.getMonth()+1)}-${pad(dt.getDate())} ${pad(dt.getHours())}:${pad(dt.getMinutes())}:${pad(dt.getSeconds())}`;
const buildParams = () => {
const params = { sort_by: "id", sort_dir: "desc", limit: 60 };
const s = state.filters?.historySince;
const u = state.filters?.historyUntil;
if (s) {
const [y, m, d] = s.split('-').map((x) => parseInt(x, 10));
const start = new Date(y, (m || 1) - 1, d || 1, 0, 0, 0, 0);
params.since = fmt(start);
}
if (u) {
const [y, m, d] = u.split('-').map((x) => parseInt(x, 10));
const end = new Date(y, (m || 1) - 1, d || 1, 23, 59, 59, 999);
params.until = fmt(end);
}
return params;
};
state.historyPaging = { isLoading: false, hasMore: true, nextCursor: null, params: buildParams() };
refresh();
UI.updateHistorySubtitle();
},
handleFilter(event) {
state.filters.pending = event.target.value;
UI.refreshFilter();
UI.updateSelectionUI();
},
toggleSelectAll(event) {
const checked = event.target.checked;
const rows = Array.from(state.dom.pendingTable.querySelectorAll(".pqueue-row[data-id]"));
rows.forEach((row) => {
if (row.style.display === "none") return;
if (checked) state.selectedPending.add(row.dataset.id);
else state.selectedPending.delete(row.dataset.id);
});
UI.updateSelectionUI();
},
handleTableChange(event) {
if (!event.target.classList.contains("pqueue-select")) return;
const row = event.target.closest(".pqueue-row[data-id]");
if (!row) return;
const id = row.dataset.id;
if (event.target.checked) state.selectedPending.add(id);
else state.selectedPending.delete(id);
UI.updateSelectionUI();
},
handleTableClick(event) {
const btn = event.target.closest("button[data-action]");
if (!btn) return;
const action = btn.dataset.action;
const id = btn.dataset.id;
if (action === "delete") Events.deleteSingle(id);
else if (action === "move-top") Events.moveSingle(id, "top");
else if (action === "move-bottom") Events.moveSingle(id, "bottom");
},
handleTableKey() {},
handleDragStart(event) {
const row = event.target.closest(".pqueue-row[data-id]");
if (!row) return;
dragRow = row;
row.classList.add("is-dragging");
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", row.dataset.id);
},
handleDragOver(event) {
if (!dragRow) return;
const target = event.target.closest(".pqueue-row[data-id]");
if (!target || target === dragRow) return;
event.preventDefault();
if (dropHover && dropHover !== target) dropHover.classList.remove("pqueue-row--before", "pqueue-row--after");
const rect = target.getBoundingClientRect();
const before = event.clientY - rect.top < rect.height / 2;
target.classList.toggle("pqueue-row--before", before);
target.classList.toggle("pqueue-row--after", !before);
dropHover = target;
},
handleDragLeave(event) {
const target = event.target.closest(".pqueue-row[data-id]");
if (target && target !== dragRow) target.classList.remove("pqueue-row--before", "pqueue-row--after");
},
async handleDrop(event) {
if (!dragRow) return;
event.preventDefault();
const table = state.dom.pendingTable;
const target = event.target.closest(".pqueue-row[data-id]");
if (!table || !target || target === dragRow) return;
const rect = target.getBoundingClientRect();
const before = event.clientY - rect.top < rect.height / 2;
target.classList.remove("pqueue-row--before", "pqueue-row--after");
table.insertBefore(dragRow, before ? target : target.nextSibling);
const order = Array.from(table.querySelectorAll(".pqueue-row[data-id]"))
.filter((row) => row.style.display !== "none")
.map((row) => row.dataset.id);
try {
await API.reorder(order);
setStatusMessage("Queue reordered");
await refresh({ skipIfBusy: true });
} catch (err) {
console.error("pqueue: reorder failed", err);
state.error = err?.message || "Failed to reorder queue";
UI.updateToolbarStatus();
}
},
handleDragEnd() {
if (dropHover) dropHover.classList.remove("pqueue-row--before", "pqueue-row--after");
dropHover = null;
if (dragRow) {
dragRow.classList.remove("is-dragging");
dragRow = null;
}
},
async savePriority() {},
async deleteSingle(id) {
if (!id) return;
if (!window.confirm(`Delete prompt ${id}?`)) return;
try {
await API.del([id]);
state.selectedPending.delete(id);
setStatusMessage(`Deleted ${id}`);
await refresh({ skipIfBusy: true });
} catch (err) {
console.error("pqueue: delete failed", err);
state.error = err?.message || "Failed to delete prompt";
UI.updateToolbarStatus();
}
},
async bulkPriority() {},
async moveSingle(id, where) {
try {
const table = state.dom.pendingTable;
if (!table) return;
const ids = Array.from(table.querySelectorAll('.pqueue-row[data-id]')).map((r) => r.dataset.id);
const currentIndex = ids.indexOf(id);
if (currentIndex === -1) return;
ids.splice(currentIndex, 1);
if (where === 'top') ids.unshift(id);
else ids.push(id);
await API.reorder(ids);
setStatusMessage(where === 'top' ? 'Moved to top' : 'Moved to bottom');
await refresh({ skipIfBusy: true });
} catch (err) {
console.error('pqueue: move failed', err);
state.error = err?.message || 'Failed to move prompt';
UI.updateToolbarStatus();
}
},
async loadMoreHistory() {
try {
const paging = state.historyPaging;
if (!paging || paging.isLoading || !paging.hasMore) return;
paging.isLoading = true;
const params = { ...paging.params };
if (paging.nextCursor?.id != null) params.cursor_id = paging.nextCursor.id;
if (paging.nextCursor?.value != null) params.cursor_value = paging.nextCursor.value;
const result = await API.getHistoryPaginated(params);
const list = Array.isArray(result?.history) ? result.history : [];
const grid = state.dom.historyGrid;
const sentinel = state.dom.historySentinel;
if (grid && sentinel && list.length) {
const frag = document.createDocumentFragment();
for (const row of list) {
const id = row?.id;
if (id == null || state.historyIds.has(id)) continue;
state.historyIds.add(id);
frag.appendChild(UI.historyCard(row));
}
grid.insertBefore(frag, sentinel);
}
if (list.length) state.history.push(...list);
if (typeof result?.total === 'number') state.historyTotal = result.total;
paging.nextCursor = result?.next_cursor || null;
paging.hasMore = !!result?.has_more;
// Hide sentinel when no more
if (!paging.hasMore && sentinel) sentinel.style.display = "none";
// Recompute metrics and update the counter subtitle after page load
try { deriveMetrics(); } catch (err) { /* noop */ }
UI.updateHistorySubtitle();
} catch (err) {
/* ignore load errors to avoid UI jank */
} finally {
if (state.historyPaging) state.historyPaging.isLoading = false;
}
},
toggleFiltersPopover(anchor) {
try {
// If open, close and cleanup listeners
if (state.dom.filtersPopover) {
const existing = state.dom.filtersPopover;
if (typeof existing._closeAnimated === 'function') existing._closeAnimated();
else {
if (state.dom.filtersPopoverListeners) {
const { onDocPointer, onResize, onScroll } = state.dom.filtersPopoverListeners;
if (onDocPointer) document.removeEventListener('pointerdown', onDocPointer, true);
if (onResize) window.removeEventListener('resize', onResize);
if (onScroll) window.removeEventListener('scroll', onScroll, true);
state.dom.filtersPopoverListeners = null;
}
existing.remove();
state.dom.filtersPopover = null;
}
return;
}
const pop = UI.buildFiltersPopover(anchor);
document.body.appendChild(pop);
state.dom.filtersPopover = pop;
// Positioning function to keep popover within viewport
const position = () => {
try {
const margin = 10;
const rect = anchor.getBoundingClientRect();
const viewportW = window.innerWidth;
const viewportH = window.innerHeight;
const scrollX = window.scrollX;
const scrollY = window.scrollY;
// reset classes and styles affecting size
pop.classList.remove('pqueue-popover--above');
pop.style.maxHeight = '80vh';
const popW = pop.offsetWidth;
const popH = pop.offsetHeight;
const anchorCenter = rect.left + rect.width / 2 + scrollX;
let left = Math.round(anchorCenter - popW / 2);
left = Math.max(scrollX + margin, Math.min(left, scrollX + viewportW - popW - margin));
// Prefer below; flip above if overflowing bottom
let top = rect.bottom + margin + scrollY;
const bottomOverflow = (top + popH) - (scrollY + viewportH - margin);
if (bottomOverflow > 0) {
const aboveTop = rect.top + scrollY - popH - margin;
if (aboveTop >= scrollY + margin) {
top = aboveTop;
pop.classList.add('pqueue-popover--above');
} else {
// Clamp within viewport if neither fits perfectly
top = Math.max(scrollY + margin, Math.min(top, scrollY + viewportH - popH - margin));
}
}
pop.style.left = `${left}px`;
pop.style.top = `${top}px`;
// Position arrow
const arrow = pop.querySelector('.pqueue-popover__arrow');
if (arrow) {
const popLeft = left;
const offset = Math.max(14, Math.min(anchorCenter - popLeft, popW - 14));
arrow.style.left = `${offset}px`;
}
} catch (err) { /* noop */ }
};
// Outside click/tap closes popover
const closeAnimated = () => {
try {
if (!state.dom.filtersPopover) return;
const node = state.dom.filtersPopover;
// remove global listeners immediately
document.removeEventListener('pointerdown', onDocPointer, true);
window.removeEventListener('resize', onResize);
window.removeEventListener('scroll', onScroll, true);
state.dom.filtersPopoverListeners = null;
let removed = false;
const cleanup = () => {
if (removed) return;
removed = true;
try { node.remove(); } catch (err) {}
if (state.dom.filtersPopover === node) state.dom.filtersPopover = null;
};
node.setAttribute('data-open', 'false');
const timeoutId = window.setTimeout(cleanup, 180);
node.addEventListener('transitionend', () => { window.clearTimeout(timeoutId); cleanup(); }, { once: true });
} catch (err) { /* noop */ }
};
const onDocPointer = (ev) => {
try {
if (!state.dom.filtersPopover) return;
const target = ev.target;
if (state.dom.filtersPopover.contains(target)) return;
if (anchor && (target === anchor || (anchor.contains && anchor.contains(target)))) return;
closeAnimated();
} catch (err) { /* noop */ }
};
const onResize = () => position();
const onScroll = () => position();
document.addEventListener('pointerdown', onDocPointer, true);
window.addEventListener('resize', onResize);
window.addEventListener('scroll', onScroll, true);
state.dom.filtersPopoverListeners = { onDocPointer, onResize, onScroll };
pop.addEventListener('pqueue-close', closeAnimated);
pop._closeAnimated = closeAnimated;
// Initial position
position();
// Animate open
requestAnimationFrame(() => { try { pop.setAttribute('data-open', 'true'); } catch (err) {} });
} catch (err) {
/* noop */
}
},
async setHistorySort(dir) {
try {
if (!state.historyPaging) state.historyPaging = { isLoading: false, hasMore: true, nextCursor: null, params: { sort_by: 'id', sort_dir: 'desc', limit: 60 } };
const params = state.historyPaging.params || {};
params.sort_dir = (dir === 'asc') ? 'asc' : 'desc';
state.historyPaging.params = params;
// Reset and reload
const grid = state.dom.historyGrid;
const scroller = state.dom.sections || (state.container && state.container.querySelector('.pqueue-sections'));
let restore = null;
if (grid && scroller) {
const rect = grid.getBoundingClientRect();
const scRect = scroller.getBoundingClientRect();
const beforeOffset = rect.top - scRect.top;
const beforeHeight = rect.height;
// Freeze height to avoid layout jump while we clear and load
grid.style.minHeight = `${beforeHeight}px`;
restore = () => {
try { grid.style.minHeight = ''; } catch (err) {}
try { scroller.scrollTop = Math.max(0, scroller.scrollTop + ((grid.getBoundingClientRect().top - scroller.getBoundingClientRect().top) - beforeOffset)); } catch (err) {}
};
}
const sentinel = state.dom.historySentinel;
if (grid) Array.from(grid.querySelectorAll('.pqueue-history-card')).forEach((n) => n.remove());
state.history = [];
state.historyIds = new Set();
state.historyTotal = null;
state.historyPaging.nextCursor = null;
state.historyPaging.hasMore = true;
if (sentinel) sentinel.style.display = '';
await Events.loadMoreHistory();
UI.updateHistorySubtitle();
UI.updateSortToggle();
if (typeof restore === 'function') requestAnimationFrame(restore);
} catch (err) {
/* noop */
}
},
updateHistoryDateFilters(sinceVal, untilVal, sinceTimeVal = "", untilTimeVal = "") {
// Use LOCAL day bounds and format as 'YYYY-MM-DD HH:MM:SS' to match DB format
const pad = (n) => String(n).padStart(2, '0');
const fmt = (dt) => `${dt.getFullYear()}-${pad(dt.getMonth()+1)}-${pad(dt.getDate())} ${pad(dt.getHours())}:${pad(dt.getMinutes())}:${pad(dt.getSeconds())}`;
const toLocalStart = (d, t) => {
if (!d) return "";
const parts = d.split('-').map((x) => parseInt(x, 10));
let hh = 0, mm = 0, ss = 0;
if (t && /^\d{2}:\d{2}(:\d{2})?$/.test(t)) {
const tt = t.split(':').map((x) => parseInt(x, 10));
hh = tt[0] ?? 0; mm = tt[1] ?? 0; ss = tt[2] ?? 0;
}
const dt = new Date(parts[0], (parts[1] || 1) - 1, parts[2] || 1, hh, mm, ss, 0);
return fmt(dt);
};
const toLocalEnd = (d, t) => {
if (!d) return "";
const parts = d.split('-').map((x) => parseInt(x, 10));
let hh = 23, mm = 59, ss = 59;
if (t && /^\d{2}:\d{2}(:\d{2})?$/.test(t)) {
const tt = t.split(':').map((x) => parseInt(x, 10));
hh = tt[0] ?? 23; mm = tt[1] ?? 59; ss = tt[2] ?? 59;
}
const dt = new Date(parts[0], (parts[1] || 1) - 1, parts[2] || 1, hh, mm, ss, 999);
return fmt(dt);
};
state.filters.historySince = sinceVal || "";
state.filters.historyUntil = untilVal || "";
state.filters.historySinceTime = sinceTimeVal || "";
state.filters.historyUntilTime = untilTimeVal || "";
if (!state.historyPaging) state.historyPaging = { isLoading: false, hasMore: true, nextCursor: null, params: { sort_by: "id", sort_dir: "desc", limit: 60 } };
const params = state.historyPaging.params || {};
params.since = toLocalStart(state.filters.historySince, state.filters.historySinceTime) || undefined;
params.until = toLocalEnd(state.filters.historyUntil, state.filters.historyUntilTime) || undefined;
state.historyPaging.params = params;
// Reset list and load first page with filters
const grid = state.dom.historyGrid;
const sentinel = state.dom.historySentinel;
if (grid) {
// Remove all history cards except sentinel
Array.from(grid.querySelectorAll('.pqueue-history-card')).forEach((n) => n.remove());
}
state.history = [];
state.historyIds = new Set();
state.historyTotal = null;
state.historyPaging.nextCursor = null;
state.historyPaging.hasMore = true;
if (sentinel) sentinel.style.display = "";
// Prime by fetching one page now
Events.loadMoreHistory();
UI.updateHistorySubtitle();
},
applyHistoryPreset(kind) {
try {
const now = new Date();
const pad = (n) => String(n).padStart(2, '0');
const toDateStr = (dt) => `${dt.getFullYear()}-${pad(dt.getMonth()+1)}-${pad(dt.getDate())}`;
const toTimeStr = (dt) => `${pad(dt.getHours())}:${pad(dt.getMinutes())}:${pad(dt.getSeconds())}`;
if (kind === 'clear') {
state.historyPreset = undefined;
state.filters.historySince = "";
state.filters.historySinceTime = "";
state.filters.historyUntil = "";
state.filters.historyUntilTime = "";
} else if (kind === 'today') {
state.historyPreset = 'today';
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0);
const end = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999);
state.filters.historySince = toDateStr(start);
state.filters.historySinceTime = '00:00:00';
state.filters.historyUntil = toDateStr(end);
state.filters.historyUntilTime = '23:59:59';
} else if (kind === '24h') {
state.historyPreset = '24h';
const start = new Date(now.getTime() - 24 * 60 * 60 * 1000);
state.filters.historySince = toDateStr(start);
state.filters.historySinceTime = toTimeStr(start);
state.filters.historyUntil = toDateStr(now);
state.filters.historyUntilTime = toTimeStr(now);
} else if (kind === '7d') {
state.historyPreset = '7d';
const start = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
state.filters.historySince = toDateStr(start);
state.filters.historySinceTime = toTimeStr(start);
state.filters.historyUntil = toDateStr(now);
state.filters.historyUntilTime = toTimeStr(now);
}
if (state.dom.historySince) state.dom.historySince.value = state.filters.historySince;
if (state.dom.historySinceTime) state.dom.historySinceTime.value = state.filters.historySinceTime;
if (state.dom.historyUntil) state.dom.historyUntil.value = state.filters.historyUntil;
if (state.dom.historyUntilTime) state.dom.historyUntilTime.value = state.filters.historyUntilTime;
Events.updateHistoryDateFilters(
state.filters.historySince,
state.filters.historyUntil,
state.filters.historySinceTime,
state.filters.historyUntilTime
);
UI.updateHistorySubtitle();
} catch (err) {
/* noop */
}
},
async deleteSelected() {
if (!state.selectedPending.size) return;
if (!window.confirm(`Delete ${state.selectedPending.size} selected prompt${state.selectedPending.size === 1 ? "" : "s"}?`)) return;
try {
await API.del(Array.from(state.selectedPending));
state.selectedPending.clear();
setStatusMessage("Deleted selected prompts");
await refresh({ skipIfBusy: true });
} catch (err) {
console.error("pqueue: bulk delete failed", err);
state.error = err?.message || "Failed to delete prompts";
UI.updateToolbarStatus();
}
},
viewWorkflow(id) {
const info = lookupWorkflow(id);
if (!info) {
setStatusMessage("Workflow not available", 3000);
return;
}
const text = typeof info.workflow === "string" ? info.workflow : JSON.stringify(info.workflow, null, 2);
UI.openWorkflowModal({ promptId: id, workflowText: text, sourceLabel: info.source });
},
};
async function refresh({ skipIfBusy } = {}) {
// Respect render lock to avoid re-rendering while user is interacting
if (Date.now() < (state.renderLockUntil || 0)) return;
if (state.isRefreshing && skipIfBusy) return;
state.isRefreshing = true;
UI.updateToolbarStatus();
try {
// Use paginated history for initial load to respect filters
const [queue, paged] = await Promise.all([
API.getQueue(),
API.getHistoryPaginated(state.historyPaging?.params || { sort_by: "id", sort_dir: "desc", limit: 60 })
]);
state.paused = !!queue.paused;
state.queue_running = queue.queue_running || [];
state.queue_pending = queue.queue_pending || [];
state.db_pending = queue.db_pending || [];
state.running_progress = queue.running_progress || {};
state.history = (paged && Array.isArray(paged.history)) ? paged.history : [];
state.historyTotal = (paged && typeof paged.total === 'number') ? paged.total : null;
state.error = null;
const index = new Map();
state.db_pending.forEach((row) => {
if (row?.prompt_id) index.set(String(row.prompt_id), row);
});
state.dbIndex = index;
state.workflowCache = new Map();
state.workflowNameCache = new Map();
[...state.queue_pending, ...state.queue_running].forEach((item) => {
const pid = String(item[1] ?? "");
try {
const wf = item[2];
if (wf !== undefined) {
const key = typeof wf === "string" ? wf : JSON.stringify(wf);
if (key) state.workflowCache.set(pid, key);
}
const label = UI.deriveWorkflowLabel(item, index.get(pid));
if (label) state.workflowNameCache.set(pid, label);
} catch (err) {
/* noop */
}
});
deriveMetrics();
const visibleIds = new Set(state.queue_pending.map((item) => String(item[1] ?? "")));
state.selectedPending.forEach((id) => {
if (!visibleIds.has(id)) state.selectedPending.delete(id);
});
state.lastUpdated = new Date().toISOString();
// Seed history paging cursors and ids
try {
state.historyIds = new Set();
state.history.forEach((row) => {
if (row && row.id != null) state.historyIds.add(row.id);
});
const last = state.history[state.history.length - 1];
state.historyPaging = state.historyPaging || { isLoading: false, hasMore: true, nextCursor: null, params: { sort_by: "id", sort_dir: "desc", limit: 60 } };
state.historyPaging.nextCursor = paged?.next_cursor || (last ? { id: last.id, value: last.id } : null);
state.historyPaging.hasMore = !!paged?.has_more;
// If filters active but server returned fewer than requested and has_more==false, keep sentinel hidden
if (!state.historyPaging.hasMore && state.dom.historySentinel) state.dom.historySentinel.style.display = "none";
} catch (err) {
/* noop */
}
} catch (err) {
console.error("pqueue: refresh failed", err);
state.error = err?.message || "Failed to load persistent queue";
} finally {
state.isRefreshing = false;
UI.render();
}
}
function deriveMetrics() {
const metrics = {
runningCount: state.queue_running.length,
queueCount: state.queue_pending.length,
persistedCount: state.db_pending.length,
historyCount: state.history.length,
successRate: null,
avgDuration: null,
estimatedTotalDuration: null,
estimatedPendingDuration: null,
estimatedRunningDuration: null,
failureCount: 0,
lastFailure: null,
};
const durations = [];
const durationsByWorkflow = new Map();
let success = 0;
let failure = 0;
let total = 0;
state.history.forEach((row) => {
const status = String(row.status || "").toLowerCase();
const duration = Number(row.duration_seconds);
if (["success", "completed", "done"].includes(status)) success += 1;
if (["failed", "error", "failure"].includes(status)) {
failure += 1;
const ts = row.completed_at || row.created_at;
if (ts && (!metrics.lastFailure || ts > metrics.lastFailure)) metrics.lastFailure = ts;
}
if (["success", "completed", "done", "failed", "error", "failure", "cancelled", "canceled", "interrupted"].includes(status)) total += 1;
if (Number.isFinite(duration) && duration > 0) durations.push(duration);
const wf = row.workflow;
if (!wf) return;
let key = "";
if (typeof wf === "string") key = wf;
else {
try {
key = JSON.stringify(wf);
} catch (err) {
key = "";
}
}
if (!key) return;
const list = durationsByWorkflow.get(key) || [];
if (Number.isFinite(duration) && duration > 0) list.push(duration);
durationsByWorkflow.set(key, list);
});
metrics.successRate = total ? success / total : null;
metrics.failureCount = failure;
metrics.avgDuration = durations.length ? durations.reduce((acc, v) => acc + v, 0) / durations.length : null;
state.metrics = metrics;
const averages = new Map();
durationsByWorkflow.forEach((vals, key) => {
if (!vals.length) return;
const avg = vals.reduce((acc, v) => acc + v, 0) / vals.length;
averages.set(key, avg);
});
state.durationByWorkflow = averages;
// Compute estimated durations for running and pending items based on per-workflow averages.
// Fallback to global avgDuration when a workflow has no specific average yet.
try {
const fallback = metrics.avgDuration || 0;
const getEstimateForItem = (item) => {
if (!Array.isArray(item)) return 0;
const pid = String(item[1] ?? "");
let key = state.workflowCache.get(pid);
if (!key) {
const wf = item[2];
if (wf !== undefined) {
try {
key = typeof wf === "string" ? wf : JSON.stringify(wf);
if (key) state.workflowCache.set(pid, key);
} catch (err) {
key = null;
}
}
}
const perWf = (key && averages.has(key)) ? averages.get(key) : fallback;
return (Number.isFinite(perWf) && perWf > 0) ? perWf : 0;
};
// Pending: sum per-item averages
let pendingEstimate = 0;
for (const item of state.queue_pending) pendingEstimate += getEstimateForItem(item);
// Running: estimate remaining using (1 - progress) * per-item average
let runningEstimate = 0;
for (const item of state.queue_running) {
const perItem = getEstimateForItem(item);
const pid = String(item[1] ?? "");
const progress = Math.max(0, Math.min(1, Number(state.running_progress?.[pid]) || 0));
const remaining = perItem * (1 - progress);
if (Number.isFinite(remaining) && remaining > 0) runningEstimate += remaining;
}
const total = pendingEstimate + runningEstimate;
metrics.estimatedPendingDuration = pendingEstimate > 0 ? pendingEstimate : null;
metrics.estimatedRunningDuration = runningEstimate > 0 ? runningEstimate : null;
metrics.estimatedTotalDuration = total > 0 ? total : null;
state.metrics = metrics;
} catch (err) {
state.metrics = metrics;
}
}
function lookupWorkflow(promptId) {
if (!promptId) return null;
const queue = state.queue_pending.find((item) => String(item[1]) === String(promptId));
if (queue) return { workflow: queue[2], source: "Queue" };
const db = state.dbIndex.get(promptId);
if (db?.workflow) {
try {
return { workflow: JSON.parse(db.workflow), source: "Persistence" };
} catch (err) {
return { workflow: db.workflow, source: "Persistence" };
}
}
const history = state.history.find((row) => String(row.prompt_id) === String(promptId));
if (history?.workflow) {
try {
return { workflow: JSON.parse(history.workflow), source: "History" };
} catch (err) {
return { workflow: history.workflow, source: "History" };
}
}
return null;
}
function setupSockets() {
try {
const api = window.app?.api;
if (!api || typeof api.addEventListener !== "function") {
console.warn("pqueue: WebSocket API not available, falling back to polling");
return false;
}
const onProgress = (event) => {
try {
const payload = event?.detail;
if (!payload?.prompt_id || !payload.nodes) return;
state.running_progress[payload.prompt_id] = Progress.computeAggregate(payload.nodes);
UI.updateProgressBars();
} catch (err) {
/* noop */
}
};
const onLifecycle = () => refresh({ skipIfBusy: true });
api.addEventListener("progress_state", onProgress);
api.addEventListener("executing", onProgress);
api.addEventListener("status", onLifecycle);
api.addEventListener("execution_start", onLifecycle);
api.addEventListener("executed", onLifecycle);
api.addEventListener("execution_success", onLifecycle);
api.addEventListener("execution_error", onLifecycle);
api.addEventListener("execution_interrupted", onLifecycle);
stopPolling();
return true;
} catch (err) {
console.error("pqueue: socket setup failed", err);
return false;
}
}
function stopPolling() {
try {
if (pollIntervalId) {
clearInterval(pollIntervalId);
pollIntervalId = null;
}
if (focusListener) {
window.removeEventListener("focus", focusListener);
focusListener = null;
}
} catch (err) {
/* ignore */
}
}
function startPolling() {
stopPolling();
focusListener = () => refresh({ skipIfBusy: true });
window.addEventListener("focus", focusListener);
pollIntervalId = window.setInterval(() => refresh({ skipIfBusy: true }), 3000);
}
function copyText(text) {
navigator.clipboard?.writeText(text).then(
() => setStatusMessage("Workflow copied to clipboard"),
() => fallbackCopy(text)
);
function fallbackCopy(value) {
try {
const textarea = document.createElement("textarea");
textarea.value = value;
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
document.execCommand("copy");
document.body.removeChild(textarea);
setStatusMessage("Workflow copied to clipboard");
} catch (err) {
console.error("pqueue: fallback copy failed", err);
setStatusMessage("Copy failed", 4000);
}
}
}
function initialize() {
const finalize = (el) => {
state.container = el;
UI.render();
if (!setupSockets()) {
startPolling();
let retries = 50;
const retryId = window.setInterval(() => {
if (setupSockets() || --retries <= 0) window.clearInterval(retryId);
}, 200);
}
refresh();
};
const tab = {
id: "persistent_queue",
icon: "ti ti-archive",
title: "Persistent Queue",
tooltip: "Persistent Queue",
type: "custom",
render: (el) => {
UI.removeFallback();
finalize(el);
},
};
const registerSidebar = () => {
try {
if (window.app?.extensionManager?.registerSidebarTab) {
window.app.extensionManager.registerSidebarTab(tab);
return true;
}
} catch (err) {
console.error("pqueue: sidebar registration failed", err);
}
return false;
};
const init = () => {
UI.ensureAssets();
if (registerSidebar()) return;
UI.mountFallback();
finalize(state.container);
let retries = 50;
const intervalId = window.setInterval(() => {
if (registerSidebar() || --retries <= 0) window.clearInterval(intervalId);
}, 200);
};
const extension = {
name: "ComfyUI-PersistentQueue",
setup: () => init(),
};
if (window.app?.registerExtension) {
window.app.registerExtension(extension);
} else if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
}
initialize();
})();