From 84653a7263d7d35305175e2152e3e4ee75278e5f Mon Sep 17 00:00:00 2001 From: Simon Cordt Date: Wed, 24 Sep 2025 01:26:52 +0200 Subject: [PATCH] Implement Persistent Queue frontend --- web/pqueue.js | 324 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 324 insertions(+) create mode 100644 web/pqueue.js diff --git a/web/pqueue.js b/web/pqueue.js new file mode 100644 index 0000000..5a8a4ea --- /dev/null +++ b/web/pqueue.js @@ -0,0 +1,324 @@ +// Minimal frontend extension for ComfyUI Persistent Queue +// Loaded automatically from WEB_DIRECTORY by ComfyUI and served at /extensions//pqueue.js + +(function() { + const API = { + getQueue: () => fetch('/api/pqueue').then(r => r.json()), + getHistory: (limit=50) => fetch(`/api/pqueue/history?limit=${limit}`).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 }) }), + }; + + const state = { + paused: false, + queue_running: [], + queue_pending: [], + db_pending: [], + history: [], + container: null, + listPending: null, + }; + + function el(tag, attrs={}, children=[]) { + const e = document.createElement(tag); + Object.entries(attrs).forEach(([k,v]) => { + if (k === 'class') e.className = v; else if (k === 'text') e.textContent = v; else e.setAttribute(k, v); + }); + children.forEach(c => e.appendChild(c)); + return e; + } + + function ensureThemeLink() { + if (!document.getElementById('pqueue-style-link')) { + const link = document.createElement('link'); + link.id = 'pqueue-style-link'; + link.rel = 'stylesheet'; + link.href = '/extensions/ComfyUI-PersistentQueue/css/queue_style.css'; + document.head.appendChild(link); + } + } + + function render() { + if (!state.container) return; + ensureThemeLink(); + state.container.innerHTML = ''; + + const root = el('div', { class: 'pqueue-root' }); + + // Toolbar + const toolbar = el('div', { class: 'pqueue-toolbar' }); + const pauseBtn = el('button', { id: 'pqueue-toggle', class: `p-button p-component ${state.paused ? 'p-button-success' : 'p-button-warning'}` }); + pauseBtn.innerHTML = `${state.paused ? 'Resume' : 'Pause'}`; + const refreshBtn = el('button', { id: 'pqueue-refresh', class: 'p-button p-component p-button-text' }); + refreshBtn.innerHTML = `Refresh`; + toolbar.appendChild(pauseBtn); + toolbar.appendChild(refreshBtn); + + const sections = el('div', { class: 'pqueue-sections' }); + + // Running + const runningCard = el('div', { class: 'p-card pqueue-card' }); + const runningBody = el('div', { class: 'p-card-body' }); + runningBody.appendChild(el('div', { class: 'p-card-title', text: 'Running' })); + const runningList = el('ul', { class: 'pqueue-list' }); + if (!state.queue_running.length) runningList.appendChild(el('li', { class: 'pqueue-empty', text: 'No running job' })); + state.queue_running.forEach(item => { + const li = el('li', { class: 'pqueue-item' }, [ + el('div', { class: 'pqueue-meta' }, [ + el('i', { class: 'pi pi-spin pi-spinner' }), + el('span', { text: item[1] }) + ]), + el('div', { class: 'pqueue-actions' }, []) + ]); + runningList.appendChild(li); + }); + runningBody.appendChild(runningList); + runningCard.appendChild(runningBody); + + // Pending + const pendingCard = el('div', { class: 'p-card pqueue-card' }); + const pendingBody = el('div', { class: 'p-card-body' }); + pendingBody.appendChild(el('div', { class: 'p-card-title', text: 'Pending' })); + const pendingList = el('ul', { id: 'pqueue-pending', class: 'pqueue-list' }); + if (!state.queue_pending.length) pendingList.appendChild(el('li', { class: 'pqueue-empty', text: 'No pending items' })); + state.queue_pending.forEach(item => { + const li = el('li', { draggable: 'true', 'data-id': item[1], class: 'pqueue-item' }, [ + el('div', { class: 'pqueue-meta' }, [ + el('i', { class: 'pi pi-clock' }), + el('span', { text: item[1] }) + ]), + el('div', { class: 'pqueue-actions' }, [ + el('input', { type: 'number', value: '0', class: 'p-inputtext pqueue-priority', min: '0', style: 'width:5rem' }), + (() => { const b=el('button', { class: 'p-button p-component p-button-text pqueue-priority-save' }); b.innerHTML=''; return b; })(), + (() => { const b=el('button', { class: 'p-button p-component p-button-text p-button-danger pqueue-delete' }); b.innerHTML=''; return b; })(), + ]) + ]); + pendingList.appendChild(li); + }); + pendingBody.appendChild(pendingList); + pendingCard.appendChild(pendingBody); + + // History + const historyCard = el('div', { class: 'p-card pqueue-card' }); + const historyBody = el('div', { class: 'p-card-body' }); + historyBody.appendChild(el('div', { class: 'p-card-title', text: 'History' })); + const historyList = el('ul', { class: 'pqueue-list' }); + if (!state.history.length) historyList.appendChild(el('li', { class: 'pqueue-empty', text: 'No history' })); + state.history.forEach(row => { + const ok = (row.status||'success') === 'success'; + const li = el('li', { class: 'pqueue-item' }, [ + el('div', { class: 'pqueue-meta' }, [ + el('i', { class: `pi ${ok ? 'pi-check-circle pqueue-status-success' : 'pi-times-circle pqueue-status-error'}` }), + el('span', { text: row.prompt_id }) + ]), + el('div', { class: 'pqueue-actions' }, [ + el('span', { class: 'p-tag', text: ok ? 'success' : 'error' }) + ]) + ]); + historyList.appendChild(li); + }); + historyBody.appendChild(historyList); + historyCard.appendChild(historyBody); + + sections.appendChild(runningCard); + sections.appendChild(pendingCard); + sections.appendChild(historyCard); + + root.appendChild(toolbar); + root.appendChild(sections); + state.container.appendChild(root); + + wireHandlers(); + } + + function wireHandlers() { + document.getElementById('pqueue-toggle').onclick = async () => { + if (state.paused) await API.resume(); else await API.pause(); + await refresh(); + }; + document.getElementById('pqueue-refresh').onclick = refresh; + + const list = document.getElementById('pqueue-pending'); + let dragged; + list.addEventListener('dragstart', (e) => { + const li = e.target.closest('li'); + dragged = li; + e.dataTransfer.setData('text/plain', li.dataset.id); + }); + list.addEventListener('dragover', (e) => e.preventDefault()); + list.addEventListener('drop', async (e) => { + e.preventDefault(); + const target = e.target.closest('li'); + if (!dragged || !target || dragged === target) return; + const rect = target.getBoundingClientRect(); + const before = (e.clientY - rect.top) < rect.height / 2; + if (before) list.insertBefore(dragged, target); else list.insertBefore(dragged, target.nextSibling); + const order = Array.from(list.querySelectorAll('li')).map(li => li.dataset.id); + await API.reorder(order); + await refresh(); + }); + + list.querySelectorAll('.pqueue-priority-save').forEach(btn => { + btn.onclick = async (e) => { + const li = e.target.closest('li'); + const id = li.dataset.id; + const val = parseInt(li.querySelector('.pqueue-priority').value || '0', 10); + await API.setPriority(id, val); + await refresh(); + }; + }); + + list.querySelectorAll('.pqueue-delete').forEach(btn => { + btn.onclick = async (e) => { + const li = e.target.closest('li'); + const id = li.dataset.id; + await API.del([id]); + await refresh(); + }; + }); + } + + async function refresh() { + const q = await API.getQueue(); + state.paused = !!q.paused; + state.queue_running = q.queue_running || []; + state.queue_pending = q.queue_pending || []; + state.db_pending = q.db_pending || []; + const h = await API.getHistory(50); + state.history = h.history || []; + render(); + } + + // Mount a simple panel into the existing UI sidebar if possible + function ensureStyles() { + if (document.getElementById('pqueue-styles')) return; + const style = document.createElement('style'); + style.id = 'pqueue-styles'; + style.textContent = ` + #pqueue-fab{position:fixed;right:16px;bottom:16px;z-index:99999;background:#2d2f34;color:#fff;border:none;border-radius:20px;padding:8px 12px;cursor:pointer;box-shadow:0 2px 8px rgba(0,0,0,0.35)} + #pqueue-panel{position:fixed;right:16px;top:64px;z-index:99998;width:420px;max-width:90vw;height:65vh;background:#1c1d20;color:#ddd;border:1px solid #333;border-radius:8px;box-shadow:0 6px 24px rgba(0,0,0,0.5);display:none;flex-direction:column} + #pqueue-panel header{display:flex;align-items:center;justify-content:space-between;padding:8px 12px;border-bottom:1px solid #333} + #pqueue-panel .pqueue-content{padding:8px 12px;overflow:auto;height:100%} + #pqueue-panel ul{list-style:none;margin:0;padding:0} + #pqueue-panel li{display:flex;gap:8px;align-items:center;padding:6px 4px;border-bottom:1px dashed #2c2c2c} + #pqueue-panel .pqueue-item{cursor:grab} + #pqueue-panel .pqueue-section{margin-bottom:10px} + `; + document.head.appendChild(style); + } + + function mount() { + if (state.container) return; + ensureStyles(); + // Floating button + const fab = document.createElement('button'); + fab.id = 'pqueue-fab'; + fab.textContent = 'Persistent Queue'; + fab.onclick = () => { + const panel = document.getElementById('pqueue-panel'); + if (!panel) return; + const visible = panel.style.display !== 'none'; + panel.style.display = visible ? 'none' : 'flex'; + if (!visible) refresh(); + }; + document.body.appendChild(fab); + + const panel = el('div', { id: 'pqueue-panel', class: 'pqueue-panel' }); + const header = document.createElement('header'); + const title = document.createElement('div'); + title.textContent = 'Persistent Queue'; + const close = document.createElement('button'); + close.textContent = '×'; + close.onclick = () => { panel.style.display = 'none'; }; + header.appendChild(title); + header.appendChild(close); + panel.appendChild(header); + state.container = el('div', { class: 'pqueue-content' }); + panel.appendChild(state.container); + document.body.appendChild(panel); + refresh(); + } + + // Listen to ComfyUI WS events to auto-refresh + function setupSocketRefresh() { + // The frontend sends events to the page via a global event dispatch. As a minimal approach, poll occasionally + // and also refresh when the page receives focus. + window.addEventListener('focus', () => refresh()); + setInterval(() => { refresh(); }, 3000); + } + + // Initialize extension + function init() { + try { + const tryRegisterSidebar = () => { + if (window.app && window.app.extensionManager && typeof window.app.extensionManager.registerSidebarTab === 'function') { + window.app.extensionManager.registerSidebarTab({ + id: 'persistent_queue', + icon: 'pi pi-history', + title: 'Persistent Queue', + tooltip: 'Persistent Queue', + type: 'custom', + render: (el) => { + state.container = el; + render(); + setupSocketRefresh(); + } + }); + // Remove floating fallback if present + const fab = document.getElementById('pqueue-fab'); + if (fab && fab.parentElement) fab.parentElement.removeChild(fab); + const panel = document.getElementById('pqueue-panel'); + if (panel && panel.parentElement) panel.parentElement.removeChild(panel); + return true; + } + return false; + }; + + if (!tryRegisterSidebar()) { + // Retry registration for a short window while the frontend boots + let retries = 50; + const iv = setInterval(() => { + if (tryRegisterSidebar() || --retries <= 0) clearInterval(iv); + }, 200); + // Fallback: floating panel immediately + mount(); + setupSocketRefresh(); + } + } catch(e) { console.warn('pqueue init failed', e); } + } + + // Prefer native extension lifecycle if available + if (window.app && typeof window.app.registerExtension === 'function') { + window.app.registerExtension({ + name: 'ComfyUI-PersistentQueue', + sidebarTabs: [ + { + id: 'persistent_queue', + icon: 'pi pi-history', + title: 'Persistent Queue', + tooltip: 'Persistent Queue', + type: 'custom', + render: (el) => { + state.container = el; + render(); + setupSocketRefresh(); + } + } + ], + async setup(app) { + // If for some reason sidebarTabs is not recognized by this frontend build, + // fall back to runtime registration or floating panel. + init(); + } + }); + } else if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +})(); + +