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

1575 lines
81 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.
(function () {
"use strict";
// UI module extracted from original pqueue.js without logic changes
const state = (window.PQueue && window.PQueue.state) || {};
const API = (window.PQueue && window.PQueue.API) || window.API;
const Icons = (window.PQueue && window.PQueue.Icons) || window.Icons;
const Format = (window.PQueue && window.PQueue.Format) || window.Format;
const Events = (window.PQueue && window.PQueue.Events) || window.Events;
const setStatusMessage = (window.PQueue && window.PQueue.setStatusMessage) || window.setStatusMessage;
const copyText = (window.PQueue && window.PQueue.copyText) || window.copyText;
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-Persistent-Queue/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-Persistent-Queue/lib/tabler-icons.min.css";
link.dataset.pqueueIcons = "true";
document.head.appendChild(link);
}
},
// Compute a robust numeric key for history ordering
historyKey(row) {
try {
const ts = row && (row.completed_at || row.created_at);
if (typeof ts === 'number' && Number.isFinite(ts)) {
return ts < 1e12 ? Math.floor(ts * 1000) : Math.floor(ts);
}
if (typeof ts === 'string') {
const s = ts.trim();
if (/^\d+$/.test(s)) {
const n = parseInt(s, 10);
if (Number.isFinite(n)) return n < 1e12 ? n * 1000 : n;
}
let ms = Date.parse(s);
if (!Number.isFinite(ms)) {
ms = Date.parse(s.replace(' ', 'T'));
}
if (Number.isFinite(ms)) return ms;
}
const id = Number(row?.id);
return Number.isFinite(id) ? id : 0;
} catch (err) {
const id = Number(row?.id);
return Number.isFinite(id) ? id : 0;
}
},
// Composite key: [ms, id] for strict total ordering
historyKeyParts(row) {
const ms = UI.historyKey(row) || 0;
const id = Number(row?.id) || 0;
return [ms, id];
},
// 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;
}
},
// Choose the history card nearest the top of the viewport as the scroll anchor
getVisibleHistoryAnchor() {
try {
const scroller = UI.getScrollContainer();
const grid = state.dom.historyGrid;
if (!scroller || !grid) return null;
const scRect = scroller.getBoundingClientRect();
const cards = Array.from(grid.querySelectorAll('.pqueue-history-card'));
if (!cards.length) return null;
let best = null;
let bestDelta = Infinity;
for (const el of cards) {
const top = el.getBoundingClientRect().top;
const delta = top - scRect.top;
if (delta >= -1 && delta < bestDelta) { bestDelta = delta; best = el; }
}
if (best) return best;
for (let i = cards.length - 1; i >= 0; i--) {
const el = cards[i];
if (el.getBoundingClientRect().top < scRect.top) return el;
}
return cards[0];
} catch (err) { return null; }
},
// Keep an anchor element visually stationary while mutating DOM
withStableAnchor(mutator) {
try {
state.anchorLockDepth = (state.anchorLockDepth || 0) + 1;
if (state.anchorLockDepth === 1) {
const scroller = UI.getScrollContainer();
const anchor = UI.getVisibleHistoryAnchor() || state.dom.historyCard || state.dom.root;
if (scroller && anchor) {
const scRect = scroller.getBoundingClientRect();
const aRect = anchor.getBoundingClientRect();
state.anchorSnapshot = { scroller, anchor, before: aRect.top - scRect.top };
} else {
state.anchorSnapshot = null;
}
}
mutator && mutator();
window.requestAnimationFrame(() => {
try {
state.anchorLockDepth = Math.max(0, (state.anchorLockDepth || 1) - 1);
if (state.anchorLockDepth === 0 && state.anchorSnapshot && state.anchorSnapshot.scroller && document.body.contains(state.anchorSnapshot.anchor)) {
const { scroller, anchor, before } = state.anchorSnapshot;
const scRect2 = scroller.getBoundingClientRect();
const aRect2 = anchor.getBoundingClientRect();
const delta = (aRect2.top - scRect2.top) - before;
if (delta) scroller.scrollTop = Math.max(0, scroller.scrollTop + delta);
}
} catch (err) { /* noop */ }
});
} catch (err) {
try { mutator && mutator(); } catch (e) { /* noop */ }
state.anchorLockDepth = 0;
state.anchorSnapshot = null;
}
},
render() {
if (!state.container) return;
UI.ensureAssets();
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;
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);
// Place toolbar outside of scrollable sections at the bottom
const toolbarHost = UI.el("div", { class: "pqueue-toolbar-host" });
toolbarHost.appendChild(UI.renderToolbar());
state.container.appendChild(toolbarHost);
dom.sections = sections;
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);
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) {
const placeholder = UI.el("div", { class: "pqueue-item pqueue-item--running", style: "display:flex;align-items:center;justify-content:center;text-align:center;" }, [
UI.el("span", { class: "pqueue-muted", text: "no running job" })
]);
rows.push(placeholder);
} 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` : "",
content: rows,
classes: "pqueue-card--running",
});
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;
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');
try { UI.ensureThumbObserver(); } catch (err) { /* noop */ }
return card;
},
updateAfterRefresh(paged) {
try {
UI.updateToolbarControls();
UI.updateMetrics();
UI.updateRunningSection();
UI.updatePendingSection();
UI.reconcileHistoryFromState();
UI.updateHistorySubtitle();
} catch (err) {
try { UI.ensureHistoryObserver(); } catch (e) { /* noop */ }
}
},
reconcileHistoryFromState() {
try {
const grid = state.dom.historyGrid;
const sentinel = state.dom.historySentinel;
if (!grid || !sentinel) return;
const dir = (state.historyPaging?.params?.sort_dir === 'asc') ? 'asc' : 'desc';
UI.withStableAnchor(() => {
try { Array.from(grid.querySelectorAll('.pqueue-empty')).forEach((n) => n.remove()); } catch (err) { /* noop */ }
const existingIds = new Set(Array.from(grid.querySelectorAll('.pqueue-history-card')).map((n) => n.getAttribute('data-id')));
const ordered = Array.isArray(state.history) ? state.history.slice() : [];
try {
ordered.sort((a, b) => {
const [ams, aid] = UI.historyKeyParts(a);
const [bms, bid] = UI.historyKeyParts(b);
if (ams !== bms) return dir === 'desc' ? (bms - ams) : (ams - bms);
return dir === 'desc' ? (bid - aid) : (aid - bid);
});
} catch (err) { /* noop */ }
for (const row of ordered) {
const id = row?.id != null ? String(row.id) : null;
if (!id || existingIds.has(id)) continue;
const card = UI.historyCard(row);
const key = Number(card.getAttribute('data-key') || 0);
const key2 = Number(card.getAttribute('data-key2') || 0);
const existingCards = Array.from(grid.querySelectorAll('.pqueue-history-card'));
let inserted = false;
if (dir === 'desc') {
for (const existing of existingCards) {
const otherKey = Number(existing.getAttribute('data-key') || 0);
const otherKey2 = Number(existing.getAttribute('data-key2') || 0);
if (key > otherKey || (key === otherKey && key2 >= otherKey2)) { grid.insertBefore(card, existing); inserted = true; break; }
}
if (!inserted) grid.insertBefore(card, sentinel);
} else {
for (let i = existingCards.length - 1; i >= 0; i--) {
const existing = existingCards[i];
const otherKey = Number(existing.getAttribute('data-key') || 0);
const otherKey2 = Number(existing.getAttribute('data-key2') || 0);
if (key > otherKey || (key === otherKey && key2 >= otherKey2)) { grid.insertBefore(card, existing.nextSibling || sentinel); inserted = true; break; }
}
if (!inserted) {
const firstCard = existingCards[0] || grid.querySelector('.pqueue-history-card');
grid.insertBefore(card, firstCard || sentinel);
}
}
existingIds.add(id);
}
try {
state.historyIds = new Set(Array.from(grid.querySelectorAll('.pqueue-history-card')).map((n) => Number(n.getAttribute('data-id'))).filter((v) => v != null));
} catch (err) { /* noop */ }
});
try { UI.ensureThumbObserver(); } catch (err) { /* noop */ }
} catch (err) { /* noop */ }
},
updateToolbarControls() {
try {
const pauseBtn = document.getElementById('pqueue-toggle');
if (pauseBtn) {
const isPaused = !!state.paused;
pauseBtn.title = isPaused ? 'Resume queue' : 'Pause queue';
pauseBtn.setAttribute('aria-label', isPaused ? 'Resume queue' : 'Pause queue');
pauseBtn.classList.toggle('pqueue-button--success', isPaused);
pauseBtn.classList.toggle('pqueue-button--warning', !isPaused);
pauseBtn.innerHTML = '';
pauseBtn.appendChild(UI.icon(isPaused ? 'ti ti-player-play-filled' : 'ti ti-player-pause-filled'));
pauseBtn.removeEventListener('click', Events.togglePause);
pauseBtn.addEventListener('click', Events.togglePause);
}
} catch (err) { /* noop */ }
},
updateMetrics() {
try {
const newMetrics = UI.renderMetrics();
if (!newMetrics) return;
const old = state.dom.metrics;
if (old && old.parentNode) {
old.parentNode.replaceChild(newMetrics, old);
state.dom.metrics = newMetrics;
}
} catch (err) { /* noop */ }
},
updateRunningSection() {
try {
UI.withStableAnchor(() => {
const card = state.dom.runningCard;
if (!card) return;
const body = card.querySelector('.pqueue-card__body');
if (!body) return;
const rect = body.getBoundingClientRect();
const beforeHeight = Math.max(0, (rect && rect.height) || 0);
body.innerHTML = '';
if (!state.queue_running.length) {
if (beforeHeight) body.style.minHeight = `${beforeHeight}px`;
const placeholder = UI.el('div', { class: 'pqueue-item pqueue-item--running', style: 'display:flex;align-items:center;justify-content:center;text-align:center;' }, [
UI.el('span', { class: 'pqueue-muted', text: 'no running job' })
]);
body.appendChild(placeholder);
} else {
try { body.style.minHeight = ''; } catch (err) { /* noop */ }
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])]);
body.appendChild(UI.el('div', { class: 'pqueue-item pqueue-item--running', 'data-id': pid }, [meta, progress]));
});
}
const subtitle = card.querySelector('.pqueue-card__subtitle');
if (subtitle) subtitle.textContent = state.metrics.runningCount ? `${state.metrics.runningCount} active` : '';
});
} catch (err) { /* noop */ }
},
updatePendingSection() {
try {
UI.withStableAnchor(() => {
const card = state.dom.pendingCard;
if (!card) return;
const subtitle = card.querySelector('.pqueue-card__subtitle');
if (subtitle) subtitle.textContent = state.metrics.queueCount ? `${state.metrics.queueCount} pending` : '';
const table = state.dom.pendingTable;
if (!table) return;
Array.from(table.querySelectorAll('.pqueue-row[data-id]')).forEach((n) => n.remove());
const rows = state.queue_pending.map(UI.pendingRow).filter(Boolean);
const header = table.querySelector('.pqueue-list__header');
const frag = document.createDocumentFragment();
rows.forEach((r) => frag.appendChild(r));
if (header && header.nextSibling) {
table.insertBefore(frag, header.nextSibling);
} else {
table.appendChild(frag);
}
UI.refreshFilter();
UI.updateSelectionUI();
UI.updatePendingFooter(Array.from(table.querySelectorAll('.pqueue-row[data-id]')).filter((r) => r.style.display !== 'none').length);
});
} catch (err) { /* noop */ }
},
mergeHistoryFromRefresh(paged) {
try {
const grid = state.dom.historyGrid;
const sentinel = state.dom.historySentinel;
if (!grid || !sentinel) return;
let list = Array.isArray(paged?.history) ? paged.history : [];
try {
const dir = (state.historyPaging?.params?.sort_dir === 'asc') ? 'asc' : 'desc';
list = list.slice().sort((a, b) => {
const [ams, aid] = UI.historyKeyParts(a);
const [bms, bid] = UI.historyKeyParts(b);
if (ams !== bms) return dir === 'desc' ? (bms - ams) : (ams - bms);
return dir === 'desc' ? (bid - aid) : (aid - bid);
});
} catch (err) { /* noop */ }
if (!state.historyIds) state.historyIds = new Set();
UI.withStableAnchor(() => {
try { Array.from(grid.querySelectorAll('.pqueue-empty')).forEach((n) => n.remove()); } catch (err) { /* noop */ }
for (const row of list) {
const id = row?.id;
if (id == null || state.historyIds.has(id)) continue;
state.historyIds.add(id);
const card = UI.historyCard(row);
const dir = (state.historyPaging?.params?.sort_dir === 'asc') ? 'asc' : 'desc';
const key = Number(card.getAttribute('data-key') || 0);
const key2 = Number(card.getAttribute('data-key2') || 0);
const existingCards = Array.from(grid.querySelectorAll('.pqueue-history-card'));
let inserted = false;
if (dir === 'desc') {
for (const existing of existingCards) {
const otherKey = Number(existing.getAttribute('data-key') || 0);
const otherKey2 = Number(existing.getAttribute('data-key2') || 0);
if (key > otherKey || (key === otherKey && key2 >= otherKey2)) { grid.insertBefore(card, existing); inserted = true; break; }
}
if (!inserted) grid.insertBefore(card, sentinel);
} else {
for (let i = existingCards.length - 1; i >= 0; i--) {
const existing = existingCards[i];
const otherKey = Number(existing.getAttribute('data-key') || 0);
const otherKey2 = Number(existing.getAttribute('data-key2') || 0);
if (key > otherKey || (key === otherKey && key2 >= otherKey2)) { grid.insertBefore(card, existing.nextSibling || sentinel); inserted = true; break; }
}
if (!inserted) grid.insertBefore(card, sentinel);
}
if (Array.isArray(state.history)) {
if (dir === 'desc') state.history.unshift(row);
else state.history.push(row);
}
}
});
if (typeof paged?.total === 'number') state.historyTotal = paged.total;
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 || state.historyPaging.nextCursor;
state.historyPaging.hasMore = !!paged?.has_more || state.historyPaging.hasMore;
if (!state.historyPaging.hasMore && sentinel) sentinel.style.display = 'none';
try { UI.ensureThumbObserver(); } catch (err) { /* noop */ }
} catch (err) { /* noop */ }
},
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 attrs = { class: "pqueue-history-card", title: Format.tooltip(row) };
if (row && row.id != null) attrs["data-id"] = String(row.id);
const ts = row && (row.completed_at || row.created_at);
if (ts != null) attrs["data-ts"] = String(ts);
const [ms, idPart] = UI.historyKeyParts(row);
attrs["data-key"] = String(ms);
attrs["data-key2"] = String(idPart);
const card = UI.el("article", attrs);
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", decoding: "async", fetchpriority: "low" });
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", decoding: "async", fetchpriority: "low" });
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;
}
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 scroller = UI.getScrollContainer();
const rootIsAncestor = (el, ancestor) => {
try {
if (!ancestor || !(ancestor instanceof Element)) return false;
return ancestor.contains(el);
} catch (err) { return false; }
};
const root = rootIsAncestor(sentinel, scroller) ? scroller : null;
const obs = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
Events.loadMoreHistory();
});
}, { root: root, rootMargin: '360px 0px', threshold: 0 });
obs.observe(sentinel);
state.historyObserver = obs;
} catch (err) { /* noop */ }
},
ensureThumbObserver() {
try {
if (!('IntersectionObserver' in window)) return;
const grid = state.dom.historyGrid;
if (!grid) return;
if (state.thumbObserver) {
try { state.thumbObserver.disconnect(); } catch (e) {}
state.thumbObserver = null;
}
const scroller = UI.getScrollContainer();
const rootIsAncestor = (el, ancestor) => {
try { return ancestor instanceof Element ? ancestor.contains(el) : false; } catch (err) { return false; }
};
const root = rootIsAncestor(grid, scroller) ? scroller : null;
const ric = window.requestIdleCallback || ((cb) => setTimeout(cb, 0));
const obs = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
const img = entry.target;
if (!(img instanceof HTMLImageElement)) return;
if (entry.isIntersecting) {
try { img.setAttribute('fetchpriority', 'high'); } catch (err) {}
try { img.decoding = 'async'; } catch (err) {}
try { ric(() => { try { img.decode().catch(() => {}); } catch (e) {} }); } catch (err) {}
} else {
try { img.setAttribute('fetchpriority', 'low'); } catch (err) {}
}
});
}, { root, rootMargin: '300px 0px', threshold: 0 });
const imgs = Array.from(grid.querySelectorAll('.pqueue-thumb'));
imgs.forEach((img) => { try { obs.observe(img); } catch (err) {} });
state.thumbObserver = 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));
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');
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);
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) {}
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();
},
};
window.PQueue = window.PQueue || {};
window.PQueue.UI = UI;
window.UI = UI;
})();