From 6a8140fc1cee4d9f0bbf1427a48e89b627a0bdca Mon Sep 17 00:00:00 2001 From: Simon Cordt Date: Wed, 24 Sep 2025 14:53:18 +0200 Subject: [PATCH] Refactor and restyle Persistent Queue UI --- web/css/queue_style.css | 179 +++- web/pqueue.js | 1946 +++++++++++++++++++++++++++------------ 2 files changed, 1499 insertions(+), 626 deletions(-) diff --git a/web/css/queue_style.css b/web/css/queue_style.css index 5e00431..7d9797d 100644 --- a/web/css/queue_style.css +++ b/web/css/queue_style.css @@ -1,42 +1,137 @@ -.pqueue-root{display:flex;flex-direction:column;gap:.5rem;height:100%} -.pqueue-toolbar{display:flex;gap:.5rem;align-items:center} -.pqueue-sections{display:flex;flex-direction:column;gap:.5rem;overflow:auto} -.pqueue-divider{height:0;border-top:1px solid var(--border-color);opacity:.75;margin:.25rem 0} -.pqueue-card{border:none;background:transparent} -.pqueue-card>.p-card-body{padding:.25rem .5rem} -.pqueue-list{list-style:none;margin:0;padding:0} -.pqueue-item{display:flex;align-items:center;justify-content:space-between;border-bottom:1px dashed var(--border-color);padding:.35rem .25rem;border-radius:4px} -.pqueue-left{display:flex;flex-direction:column;gap:.25rem;flex:1} -.pqueue-item:hover{background:rgba(0,0,0,0.04)} -.dark-theme .pqueue-item:hover{background:rgba(255,255,255,0.06)} -.pqueue-item:last-child{border-bottom:none} -.pqueue-meta{display:flex;gap:.5rem;align-items:center} -.pqueue-actions{display:flex;gap:.5rem;align-items:center} -.pqueue-status-success{color:var(--green-500)} -.pqueue-status-error{color:var(--red-500)} -.pqueue-empty{opacity:.7;font-size:.9rem;padding:.25rem 0} -.pqueue-thumbs-grid{display:grid;grid-template-columns:repeat(1, 96px);gap:6px} -.pqueue-thumb{width:96px;height:96px;object-fit:cover;border-radius:4px;cursor:pointer} -.pqueue-thumb-wrap{position:relative;width:96px;height:96px} -.pqueue-thumb-wrap-placeholder{background:var(--surface-200,#212121);display:flex;align-items:center;justify-content:center;border-radius:4px;border:1px dashed var(--border-color,#4b4b4b);padding:6px;box-sizing:border-box;cursor:default} -.pqueue-thumb-wrap-placeholder--failed{border-color:var(--red-400,#f87171);background:rgba(248,113,113,0.08)} -.dark-theme .pqueue-thumb-wrap-placeholder--failed{background:rgba(248,113,113,0.12)} -.pqueue-thumb-wrap-placeholder--interrupted{border-color:var(--yellow-400,#facc15);background:rgba(250,204,21,0.08)} -.dark-theme .pqueue-thumb-wrap-placeholder--interrupted{background:rgba(250,204,21,0.12)} -.dark-theme .pqueue-thumb-wrap-placeholder{background:rgba(255,255,255,0.05)} -.pqueue-thumb-placeholder{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:.35rem;text-align:center;color:var(--text-color-secondary,#9ca3af);font-size:.76rem;line-height:1.2;padding:.25rem} -.pqueue-thumb-placeholder--failed{color:var(--red-400,#f87171)} -.pqueue-thumb-placeholder--interrupted{color:var(--yellow-500,#eab308)} -.pqueue-thumb-placeholder>i{font-size:1.25rem;opacity:.85} -.pqueue-thumb-placeholder-text{display:block;max-width:82px;word-break:break-word} -.pqueue-batch-badge{position:absolute;right:4px;bottom:4px;padding:2px 6px;border-radius:10px;background:rgba(0,0,0,0.7);color:#fff;font-size:.75rem;line-height:1} -.pqueue-gallery-overlay{position:fixed;inset:0;display:none;align-items:center;justify-content:center;background:rgba(0,0,0,0.65);z-index:100000} -.pqueue-gallery{position:relative;max-width:92vw;max-height:92vh} -.pqueue-gallery-img{max-width:92vw;max-height:92vh;border-radius:6px;box-shadow:0 8px 32px rgba(0,0,0,.6)} -.pqueue-gallery-counter{position:absolute;left:50%;transform:translateX(-50%);bottom:-28px;color:#fff;font-size:.85rem;opacity:.9} -.pqueue-gallery-close{position:absolute;top:-36px;right:0;background:transparent;border:none;color:#fff;font-size:24px;cursor:pointer} -.pqueue-gallery-nav{position:absolute;top:50%;transform:translateY(-50%);background:rgba(0,0,0,0.5);border:none;color:#fff;font-size:24px;cursor:pointer;width:36px;height:36px;border-radius:18px} -.pqueue-gallery-prev{left:-44px} -.pqueue-gallery-next{right:-44px} -.pqueue-progress-wrap{width:100%;height:8px;background:#1f2a1f;border-radius:4px;overflow:hidden;margin-top:6px} -.pqueue-progress-bar{height:100%;width:0;background:#22c55e;transition:none} \ No newline at end of file +.pqueue-root{display:flex;flex-direction:column;gap:1rem;height:100%;color:var(--common-text,#e5e7eb);font-size:13px;line-height:1.4} +.pqueue-root a{color:inherit} +.pqueue-toolbar{display:flex;flex-direction:column;gap:.75rem;padding:.75rem 1rem;border-radius:12px;background:var(--panels-primary,#1e1f25);box-shadow:0 1px 2px rgba(0,0,0,.24)} +.pqueue-toolbar__row{display:flex;gap:.75rem;align-items:center;justify-content:space-between;flex-wrap:wrap} +.pqueue-toolbar__group{display:flex;gap:.5rem;align-items:center} +.pqueue-toolbar__status{display:flex;gap:.4rem;align-items:center;color:var(--subtle-text,#9ca3af);min-height:1.5rem} +.pqueue-toolbar__status-text{font-size:.85rem} +.pqueue-toolbar__status-text--error{color:var(--danger-400,#fb7185)} +.pqueue-toolbar__row--secondary{gap:1rem} +.pqueue-toolbar__summary{display:flex;gap:1rem;align-items:center;color:var(--subtle-text,#9ca3af);font-size:.82rem} +.pqueue-summary__item{display:inline-flex;align-items:center;gap:.25rem} +.pqueue-input{appearance:none;-webkit-appearance:none;background:rgba(17,24,39,.6);border:1px solid rgba(148,163,184,.18);color:inherit;border-radius:10px;padding:.55rem .75rem;font:inherit;min-width:16rem;outline:none;transition:border-color .15s ease,background-color .15s ease;box-shadow:inset 0 1px 0 rgba(255,255,255,.04)} +.pqueue-input:focus{border-color:var(--focus-ring,#60a5fa);background:rgba(30,41,59,.8)} +.pqueue-input[type=number]{width:5rem;text-align:center} +.pqueue-button{display:inline-flex;align-items:center;gap:.35rem;border:none;border-radius:9px;padding:.45rem .75rem;font:inherit;font-size:.85rem;cursor:pointer;background:rgba(63,81,181,.16);color:inherit;transition:background-color .15s ease,transform .15s ease,color .15s ease} +.pqueue-button:disabled{opacity:.45;cursor:not-allowed} +.pqueue-button:hover:not(:disabled){background:rgba(96,125,255,.22)} +.pqueue-button--subtle{background:rgba(148,163,184,.12)} +.pqueue-button--subtle:hover:not(:disabled){background:rgba(148,163,184,.2)} +.pqueue-button--success{background:rgba(34,197,94,.18);color:var(--success-400,#4ade80)} +.pqueue-button--success:hover:not(:disabled){background:rgba(34,197,94,.26)} +.pqueue-button--warning{background:rgba(251,191,36,.22);color:var(--warning-300,#facc15)} +.pqueue-button--warning:hover:not(:disabled){background:rgba(251,191,36,.3)} +.pqueue-button--danger{background:rgba(239,68,68,.2);color:var(--danger-300,#f87171)} +.pqueue-button--danger:hover:not(:disabled){background:rgba(239,68,68,.28)} +.pqueue-button--secondary{background:rgba(14,165,233,.18);color:var(--info-300,#38bdf8)} +.pqueue-button--secondary:hover:not(:disabled){background:rgba(14,165,233,.26)} +.pqueue-button--ghost{background:transparent} +.pqueue-button--ghost:hover:not(:disabled){background:rgba(248,250,252,.08)} +.pqueue-button--subtle.pqueue-button--danger{background:rgba(239,68,68,.12)} +.pqueue-button__badge{display:inline-flex;align-items:center;justify-content:center;min-width:1.25rem;height:1.25rem;border-radius:.75rem;padding:0 .3rem;background:rgba(15,23,42,.6);font-size:.72rem;font-weight:500} +.pqueue-icon{display:inline-flex;align-items:center;justify-content:center;font-family:inherit} +.pqueue-icon--sm{font-size:.85rem} +.pqueue-icon--md{font-size:1.05rem} +.pqueue-icon--lg{font-size:1.35rem} +.pqueue-icon--spin{animation:pqueue-spin 1s linear infinite} +@keyframes pqueue-spin{from{transform:rotate(0)}to{transform:rotate(360deg)}} +.pqueue-sections{display:flex;flex-direction:column;gap:1rem;overflow:auto;padding-bottom:1rem} +.pqueue-card{background:rgba(17,24,39,.6);backdrop-filter:blur(22px);border-radius:14px;box-shadow:0 10px 30px rgba(15,23,42,.35);padding:1rem 1.15rem;display:flex;flex-direction:column;gap:.75rem;border:1px solid rgba(148,163,184,.12)} +.pqueue-card__header{display:flex;align-items:flex-start;justify-content:space-between;gap:1rem} +.pqueue-card__title-wrap{display:flex;align-items:center;gap:.6rem;color:var(--heading-text,#f9fafb)} +.pqueue-card__title{margin:0;font-size:1.05rem;font-weight:600} +.pqueue-card__subtitle{font-size:.78rem;color:var(--subtle-text,#94a3b8)} +.pqueue-card__actions{display:flex;gap:.4rem;align-items:center} +.pqueue-card__body{display:flex;flex-direction:column;gap:.75rem} +.pqueue-metrics{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:.75rem} +.pqueue-metric{padding:.75rem;border-radius:12px;background:linear-gradient(145deg,rgba(17,24,39,.65),rgba(30,41,59,.55));border:1px solid rgba(100,116,139,.16);display:flex;flex-direction:column;gap:.5rem} +.pqueue-metric__header{display:flex;align-items:center;gap:.5rem;color:var(--subtle-text,#94a3b8);font-size:.82rem;text-transform:uppercase;letter-spacing:.06em} +.pqueue-metric__value{font-size:1.5rem;font-weight:600;color:var(--heading-text,#f9fafb)} +.pqueue-metric__caption{font-size:.78rem;color:var(--muted-text,#94a3b8)} +.pqueue-metric--primary{border-color:rgba(129,140,248,.35);box-shadow:0 8px 24px rgba(79,70,229,.25)} +.pqueue-metric--success{border-color:rgba(74,222,128,.4)} +.pqueue-metric--warning{border-color:rgba(250,204,21,.42)} +.pqueue-metric--neutral{border-color:rgba(100,116,139,.2)} +.pqueue-item{display:flex;flex-direction:column;gap:.5rem;padding:.9rem;background:rgba(15,23,42,.65);border-radius:12px;border:1px solid rgba(148,163,184,.14)} +.pqueue-item.is-dragging{opacity:.4} +.pqueue-item__meta{display:flex;align-items:center;gap:.5rem;font-size:.85rem;color:var(--muted-text,#a1a9b8)} +.pqueue-progress{display:flex;flex-direction:column;gap:.2rem} +.pqueue-progress__track{position:relative;height:.55rem;border-radius:.5rem;background:rgba(71,85,105,.4);overflow:hidden} +.pqueue-progress-bar{position:absolute;inset:0;height:100%;background:linear-gradient(90deg,#22c55e,#0ea5e9);transition:width .18s ease} +.pqueue-progress__label{font-size:.75rem;color:var(--subtle-text,#94a3b8)} +.pqueue-table-wrapper{overflow:auto;max-width:100%} +.pqueue-table{min-width:760px;display:flex;flex-direction:column;gap:.5rem} +.pqueue-table__header{display:grid;grid-template-columns:240px 120px 130px 130px 110px 130px;gap:.5rem;font-size:.78rem;color:var(--muted-text,#94a3b8);text-transform:uppercase;letter-spacing:.08em;padding:.25rem .2rem} +.pqueue-col{display:flex;flex-direction:column;gap:.4rem;min-width:0} +.pqueue-col--prompt{display:flex;flex-direction:column;gap:.35rem} +.pqueue-col--sticky{position:sticky;left:0;z-index:2;background:inherit} +.pqueue-row{display:grid;grid-template-columns:240px 120px 130px 130px 110px 130px;gap:.5rem;padding:.75rem;background:rgba(15,23,42,.6);border-radius:12px;border:1px solid rgba(148,163,184,.1);transition:transform .15s ease,box-shadow .15s ease,background-color .15s ease} +.pqueue-row:hover{transform:translateY(-1px);box-shadow:0 8px 20px rgba(15,23,42,.28)} +.pqueue-row.is-selected{border-color:rgba(99,102,241,.55);background:rgba(79,70,229,.18)} +.pqueue-row--before{box-shadow:inset 0 2px 0 var(--focus-ring,#60a5fa)} +.pqueue-row--after{box-shadow:inset 0 -2px 0 var(--focus-ring,#60a5fa)} +.pqueue-row__primary{display:flex;align-items:center;gap:.45rem;font-size:.82rem;color:var(--muted-text,#a1a9b8)} +.pqueue-row__primary .pqueue-icon{color:var(--muted-text,#a1a9b8)} +.pqueue-row__primary .pqueue-icon:first-child{cursor:grab} +.pqueue-row__error{color:var(--danger-300,#fb7185)} +.pqueue-code{font-family:"JetBrains Mono",SFMono-Regular,Menlo,monospace;font-size:.78rem;background:rgba(15,23,42,.55);border:1px solid rgba(148,163,184,.14);padding:.18rem .4rem;border-radius:.5rem;color:var(--accent-text,#cbd5f5)} +.pqueue-chip{display:inline-flex;align-items:center;padding:.15rem .45rem;border-radius:.75rem;font-size:.72rem;font-weight:500;background:rgba(99,102,241,.18);color:var(--accent-text,#cbd5f5)} +.pqueue-chip--outline{background:transparent;border:1px dashed rgba(99,102,241,.35)} +.pqueue-status{display:inline-flex;align-items:center;gap:.3rem;font-size:.76rem;padding:.18rem .5rem;border-radius:.7rem;font-weight:500;text-transform:capitalize} +.pqueue-status--success{color:var(--success-300,#4ade80);background:rgba(34,197,94,.16)} +.pqueue-status--info{color:var(--info-300,#38bdf8);background:rgba(14,165,233,.2)} +.pqueue-status--warning{color:var(--warning-300,#facc15);background:rgba(234,179,8,.2)} +.pqueue-status--danger{color:var(--danger-300,#f87171);background:rgba(239,68,68,.2)} +.pqueue-status--neutral{color:var(--muted-text,#94a3b8);background:rgba(148,163,184,.18)} +.pqueue-status--subtle{background:rgba(255,255,255,.08)} +.pqueue-checkbox{width:1rem;height:1rem;border-radius:.25rem;border:1px solid rgba(148,163,184,.35);background:rgba(15,23,42,.6);cursor:pointer} +.pqueue-cell__primary{font-size:.82rem;color:var(--muted-text,#cbd5f5)} +.pqueue-cell__secondary{font-size:.7rem;color:var(--subtle-text,#94a3b8)} +.pqueue-table__footer{display:flex;align-items:center;justify-content:space-between;padding-top:.5rem;border-top:1px solid rgba(148,163,184,.12)} +.pqueue-table__footer-left{display:flex;gap:1rem;color:var(--subtle-text,#94a3b8);font-size:.76rem} +.pqueue-table__footer-right{display:flex;gap:.45rem} +.pqueue-history{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:.85rem} +.pqueue-history-card{display:flex;flex-direction:column;gap:.55rem;padding:.75rem;background:rgba(15,23,42,.55);border-radius:12px;border:1px solid rgba(148,163,184,.12);transition:box-shadow .15s ease,transform .15s ease} +.pqueue-history-card:hover{box-shadow:0 8px 18px rgba(15,23,42,.26);transform:translateY(-1px)} +.pqueue-history-card__header{display:flex;align-items:center;justify-content:space-between;font-size:.74rem;color:var(--subtle-text,#94a3b8)} +.pqueue-history-card__meta{display:flex;align-items:center;gap:.5rem} +.pqueue-history-card__duration{font-size:.75rem;color:var(--muted-text,#a1a9b8)} +.pqueue-thumb-row{display:flex;flex-wrap:wrap;gap:.5rem} +.pqueue-thumb-wrap{position:relative;width:100px;height:100px;border-radius:8px;overflow:hidden;background:rgba(15,23,42,.6);border:1px solid rgba(100,116,139,.18);cursor:pointer} +.pqueue-thumb{width:100%;height:100%;object-fit:cover;display:block} +.pqueue-thumb-badge{position:absolute;right:6px;bottom:6px;padding:.15rem .5rem;border-radius:.6rem;background:rgba(15,23,42,.75);color:#fff;font-size:.68rem} +.pqueue-thumb-placeholder{display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;color:var(--subtle-text,#94a3b8);height:100%;padding:.5rem;font-size:.75rem;gap:.35rem} +.pqueue-thumb-placeholder--failed{color:var(--danger-300,#fb7185)} +.pqueue-thumb-placeholder--interrupted{color:var(--warning-300,#facc15)} +.pqueue-thumb-wrap-placeholder{cursor:default;border-style:dashed} +.pqueue-gallery-overlay{position:fixed;inset:0;display:none;align-items:center;justify-content:center;background:rgba(15,23,42,.75);backdrop-filter:blur(6px);z-index:100000} +.pqueue-gallery{position:relative;max-width:92vw;max-height:92vh;display:flex;align-items:center;justify-content:center} +.pqueue-gallery__img{max-width:90vw;max-height:90vh;border-radius:12px;box-shadow:0 20px 45px rgba(0,0,0,.6)} +.pqueue-gallery__counter{position:absolute;left:50%;bottom:-32px;transform:translateX(-50%);color:#fff;font-size:.85rem} +.pqueue-gallery .pqueue-button{position:absolute;top:-48px;right:0} +.pqueue-gallery .pqueue-button:nth-of-type(2){right:48px} +.pqueue-gallery .pqueue-button:nth-of-type(3){left:-48px;top:50%;transform:translateY(-50%)} +.pqueue-gallery .pqueue-button:nth-of-type(4){right:-48px;top:50%;transform:translateY(-50%)} +.pqueue-modal{position:fixed;inset:0;display:none;align-items:center;justify-content:center;background:rgba(15,23,42,.7);backdrop-filter:blur(8px);z-index:100001} +.pqueue-modal__dialog{width:min(640px,92vw);max-height:85vh;background:rgba(15,23,42,.9);border-radius:16px;border:1px solid rgba(100,116,139,.2);box-shadow:0 24px 60px rgba(0,0,0,.55);display:flex;flex-direction:column} +.pqueue-modal__header{padding:1rem 1.2rem;border-bottom:1px solid rgba(100,116,139,.16);display:flex;align-items:center;justify-content:space-between;gap:.75rem} +.pqueue-modal__title{margin:0;font-size:1rem;font-weight:600;color:var(--heading-text,#f9fafb)} +.pqueue-modal__subtitle{font-size:.78rem;color:var(--subtle-text,#94a3b8)} +.pqueue-modal__actions{display:flex;gap:.35rem;align-items:center} +.pqueue-modal__body{padding:1rem 1.2rem;overflow:auto} +.pqueue-modal__code{margin:0;font-size:.78rem;line-height:1.6;background:rgba(15,23,42,.8);border-radius:12px;border:1px solid rgba(148,163,184,.18);padding:1rem;white-space:pre-wrap;word-break:break-word;max-height:60vh;overflow:auto} +.pqueue-fab{position:fixed;bottom:24px;right:24px;display:inline-flex;align-items:center;gap:.5rem;padding:.7rem 1rem;border-radius:999px;background:linear-gradient(135deg,#6366f1,#22d3ee);color:#fff;border:none;cursor:pointer;box-shadow:0 20px 40px rgba(79,70,229,.35);z-index:9999} +.pqueue-fab__label{font-weight:600} +.pqueue-fallback{position:fixed;right:24px;top:64px;width:420px;max-width:90vw;height:70vh;display:flex;flex-direction:column;background:rgba(15,23,42,.92);border-radius:16px;border:1px solid rgba(100,116,139,.22);box-shadow:0 32px 70px rgba(15,23,42,.55);backdrop-filter:blur(18px);transform:translateY(10px);opacity:0;pointer-events:none;transition:opacity .18s ease,transform .18s ease;z-index:9998} +.pqueue-fallback[data-open=true]{opacity:1;pointer-events:auto;transform:translateY(0)} +.pqueue-fallback__header{padding:.9rem 1rem;border-bottom:1px solid rgba(100,116,139,.18);display:flex;align-items:center;justify-content:space-between;color:var(--heading-text,#f9fafb)} +.pqueue-fallback__title{font-weight:600} +.pqueue-fallback__content{flex:1;overflow:auto;padding:1rem;background:rgba(15,23,42,.6)} +.pqueue-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:1.2rem;border:1px dashed rgba(148,163,184,.2);border-radius:12px;color:var(--subtle-text,#94a3b8);gap:.5rem} +.pqueue-empty__title{margin:0;font-size:.92rem;color:var(--heading-text,#f9fafb)} +.pqueue-empty__desc{margin:0;font-size:.78rem;color:inherit;max-width:26ch} +.dark-theme .pqueue-button{color:#dbe4ff} +.dark-theme .pqueue-card{background:rgba(15,23,42,.75)} +@media(max-width:900px){.pqueue-row,.pqueue-table__header{grid-template-columns:220px 120px 120px 120px 100px 120px}.pqueue-metrics{grid-template-columns:repeat(auto-fit,minmax(160px,1fr))}} +@media(max-width:720px){.pqueue-toolbar__row{flex-direction:column;align-items:flex-start}.pqueue-input{width:100%}.pqueue-table{min-width:100%}.pqueue-row,.pqueue-table__header{grid-template-columns:1fr}.pqueue-col--sticky{position:static}.pqueue-row{gap:.75rem}.pqueue-history{grid-template-columns:repeat(auto-fit,minmax(160px,1fr))}} + \ No newline at end of file diff --git a/web/pqueue.js b/web/pqueue.js index aa0d1b4..26b824d 100644 --- a/web/pqueue.js +++ b/web/pqueue.js @@ -1,15 +1,32 @@ -// Minimal frontend extension for ComfyUI Persistent Queue -// Loaded automatically from WEB_DIRECTORY by ComfyUI and served at /extensions//pqueue.js +// Production-grade frontend for ComfyUI Persistent Queue +// Served automatically at /extensions/ComfyUI-PersistentQueue/pqueue.js + +(function () { + "use strict"; -(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 }) }), + 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 = { @@ -19,13 +36,12 @@ db_pending: [], history: [], running_progress: {}, - container: null, + workflowCache: new Map(), + durationByWorkflow: new Map(), + dbIndex: new Map(), selectedPending: new Set(), - isRefreshing: false, - lastUpdated: null, - error: null, filters: { - pending: '', + pending: "", }, metrics: { runningCount: 0, @@ -38,720 +54,1482 @@ backlogDelta: 0, lastFailure: null, }, - workflowCache: new Map(), - durationByWorkflow: new Map(), - bulkPriorityValue: '', + isRefreshing: false, + lastUpdated: null, + error: null, + statusMessage: null, + statusTimer: null, + container: null, + dom: {}, }; - // Polling control (fallback only) - let _pqueuePollIntervalId = null; - let _pqueueFocusListener = null; + let pollIntervalId = null; + let focusListener = null; + let dragRow = null; + let dropHover = null; const Progress = { - computeAggregateProgress(nodes) { + computeAggregate(nodes) { let totalMax = 0; let totalVal = 0; Object.values(nodes ?? {}).forEach((st) => { - const mv = Math.max(1, Number(st?.max ?? 1)); - const vv = Math.max(0, Math.min(Number(st?.value ?? 0), mv)); - totalMax += mv; - totalVal += vv; + 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; }); - return totalMax > 0 ? Math.max(0, Math.min(1, totalVal / totalMax)) : 0; + if (!totalMax) return 0; + return Math.max(0, Math.min(1, totalVal / totalMax)); }, }; - const UI = { - el(tag, attrs = {}, children = []) { - const e = document.createElement(tag); - for (const [k, v] of Object.entries(attrs)) { - if (k === 'class') { - e.className = v; - } else if (k === 'text') { - e.textContent = v; - } else { - e.setAttribute(k, v); + 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; } } - for (const c of children) { - e.appendChild(c); + 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); } - return e; + const suffix = value === 0 ? "now" : value < 0 ? "ago" : "from now"; + return `${Math.abs(value)} ${unit}${Math.abs(value) === 1 ? "" : "s"} ${suffix}`; }, - createButton({ id, text, icon, classes, onClick }) { - const button = UI.el('button', { id, class: `p-button p-component ${classes}` }); - if (icon) { - button.appendChild(UI.el('span', { class: `p-button-icon pi ${icon}` })); - } - if (text) { - button.appendChild(UI.el('span', { class: 'p-button-label', text })); - } - if (onClick) { - button.onclick = onClick; - } - return button; + datetime(iso) { + if (!iso) return ""; + const d = new Date(iso); + if (!isFinite(d.getTime())) return ""; + return d.toLocaleString(); }, - createCard(title, content) { - return UI.el('div', { class: 'p-card pqueue-card' }, [ - UI.el('div', { class: 'p-card-body' }, [ - UI.el('div', { class: 'p-card-title', text: title }), - content, - ]), - ]); + 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`; }, - updateProgressBarsFromState() { - if (!state.container) return; - Object.entries(state.running_progress).forEach(([pid, f]) => { - const bar = state.container.querySelector(`.pqueue-item[data-id="${pid}"] .pqueue-progress-bar`); - if (bar) bar.style.width = `${(Number(f) * 100).toFixed(2)}%`; + 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; }, - 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'; + icon(name, { spin = false, size } = {}) { + const classes = ["pqueue-icon"]; + if (name) classes.push(name); + if (spin) classes.push("pqueue-icon--spin"); + if (size) classes.push(`pqueue-icon--${size}`); + return UI.el("span", { class: classes }); + }, + + button({ id, text, icon, variant, subtle, size, badge, title, disabled, onClick }) { + 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 btn = UI.el("button", { id, class: classes, type: "button", title: title || "" }); + 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 }) { + const card = UI.el("article", { class: ["pqueue-metric", variant ? `pqueue-metric--${variant}` : null] }); + const header = UI.el("div", { class: "pqueue-metric__header" }, [UI.icon(icon, { size: "md" }), UI.el("span", { class: "pqueue-metric__label", text: label })]); + 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 = "https://unpkg.com/@tabler/icons-webfont@latest/tabler-icons.min.css"; + link.crossOrigin = "anonymous"; + link.dataset.pqueueIcons = "true"; document.head.appendChild(link); } }, - renderToolbar() { - const pauseBtn = UI.createButton({ - id: 'pqueue-toggle', - text: state.paused ? 'Resume' : 'Pause', - icon: state.paused ? 'pi-play' : 'pi-pause', - classes: state.paused ? 'p-button-success' : 'p-button-warning', - }); - const clearBtn = UI.createButton({ - id: 'pqueue-clear', - text: 'Clear Pending', - icon: 'pi-stop', - classes: 'p-button-text p-button-danger', - }); - - return UI.el('div', { class: 'pqueue-toolbar' }, [pauseBtn, clearBtn]); - }, - - renderRunningSection() { - const runningList = UI.el('ul', { class: 'pqueue-list' }); - if (!state.queue_running.length) { - runningList.appendChild(UI.el('li', { class: 'pqueue-empty', text: 'No running job' })); - } else { - state.queue_running.forEach(item => { - const pid = item[1]; - const frac = Math.max(0, Math.min(1, Number(state.running_progress?.[pid]) || 0)); - const progressBar = UI.el('div', { class: 'pqueue-progress-bar' }); - progressBar.style.width = `${(frac * 100).toFixed(2)}%`; - - const li = UI.el('li', { class: 'pqueue-item', 'data-id': pid }, [ - UI.el('div', { class: 'pqueue-left' }, [ - UI.el('div', { class: 'pqueue-meta' }, [ - UI.el('i', { class: 'pi pi-spin pi-spinner' }), - UI.el('span', { text: pid }) - ]), - UI.el('div', { class: 'pqueue-progress-wrap' }, [progressBar]), - ]), - UI.el('div', { class: 'pqueue-actions' }, []) - ]); - runningList.appendChild(li); - }); - } - return UI.createCard('Running', runningList); - }, - - renderPendingSection() { - const pendingList = UI.el('ul', { id: 'pqueue-pending', class: 'pqueue-list' }); - if (!state.queue_pending.length) { - pendingList.appendChild(UI.el('li', { class: 'pqueue-empty', text: 'No pending items' })); - } else { - state.queue_pending.forEach(item => { - const checked = state.selectedPending.has(item[1]); - const checkbox = UI.el('input', { type: 'checkbox', class: 'p-checkbox-input pqueue-select' }); - if (checked) checkbox.checked = true; - - const priorityInput = UI.el('input', { type: 'number', value: '0', class: 'p-inputtext pqueue-priority', min: '0', style: 'width:5rem' }); - const savePriorityBtn = UI.createButton({ - icon: 'pi-sort-amount-up-alt', - classes: 'p-button-text pqueue-priority-save', - }); - const deleteBtn = UI.createButton({ - icon: 'pi-trash', - classes: 'p-button-text p-button-danger pqueue-delete', - }); - - const li = UI.el('li', { draggable: 'true', 'data-id': item[1], class: 'pqueue-item' }, [ - UI.el('div', { class: 'pqueue-meta' }, [ - UI.el('i', { class: 'pi pi-bars', style: 'cursor:grab' }), - checkbox, - UI.el('span', { text: item[1] }) - ]), - UI.el('div', { class: 'pqueue-actions' }, [ - priorityInput, - savePriorityBtn, - deleteBtn, - ]) - ]); - pendingList.appendChild(li); - }); - } - return UI.createCard(`Pending (${state.queue_pending.length})`, pendingList); - }, - - renderHistorySection() { - const historyList = UI.el('ul', { class: 'pqueue-list' }); - if (!state.history.length) { - historyList.appendChild(UI.el('li', { class: 'pqueue-empty', text: 'No history' })); - } else { - state.history.forEach(row => { - const ok = (row.status ?? 'success') === 'success'; - const thumbs = UI.buildResultThumbs(row); - const time = UI.buildDuration(row); - const li = UI.el('li', { class: 'pqueue-item' }, [ - UI.el('div', { class: 'pqueue-meta' }, [ - UI.el('i', { class: `pi ${ok ? 'pi-check pqueue-status-success' : 'pi-times pqueue-status-error'}` }), - (time ? UI.el('span', { class: 'task-time', text: time }) : UI.el('span', {})) - ]), - UI.el('div', { class: 'pqueue-thumbs-grid' }, thumbs.children.length ? Array.from(thumbs.children) : []) - ]); - historyList.appendChild(li); - }); - } - return UI.createCard('History', historyList); - }, - render() { if (!state.container) return; - UI.ensureThemeLink(); - state.container.innerHTML = ''; + UI.ensureAssets(); - const root = UI.el('div', { class: 'pqueue-root' }); - const toolbar = UI.renderToolbar(); - const sections = UI.el('div', { class: 'pqueue-sections' }); + const dom = (state.dom = {}); + state.container.innerHTML = ""; - sections.appendChild(UI.renderRunningSection()); - sections.appendChild(UI.el('div', { class: 'pqueue-divider' })); - sections.appendChild(UI.renderPendingSection()); - sections.appendChild(UI.el('div', { class: 'pqueue-divider' })); - sections.appendChild(UI.renderHistorySection()); + const root = UI.el("div", { class: "pqueue-root" }); + dom.root = root; - root.appendChild(toolbar); + 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); - Events.init(); - UI.updateProgressBarsFromState(); + Events.bind(); + UI.updateProgressBars(); + UI.refreshFilter(); + UI.updateSelectionUI(); + UI.updateToolbarStatus(); }, - getThumbPlaceholderMeta(row) { - const status = String(row?.status ?? '').toLowerCase(); - if (['interrupted', 'cancelled', 'canceled', 'stopped'].includes(status)) { - return { text: 'Interrupted', iconClass: 'pi-stop-circle', variant: 'interrupted' }; - } - if (['error', 'failed', 'failure'].includes(status)) { - return { text: 'Failed', iconClass: 'pi-times-circle', variant: 'failed' }; - } - return { text: 'Preview unavailable', iconClass: 'pi-image-off', variant: 'empty' }; - }, + renderToolbar() { + const pauseBtn = UI.button({ + id: "pqueue-toggle", + text: state.paused ? "Resume queue" : "Pause queue", + icon: state.paused ? "ti ti-player-play-filled" : "ti ti-player-pause-filled", + variant: state.paused ? "success" : "warning", + }); + const clearBtn = UI.button({ + id: "pqueue-clear", + text: "Clear pending", + icon: "ti ti-trash-x", + variant: "danger", + subtle: true, + title: "Remove every pending prompt", + }); + const refreshBtn = UI.button({ id: "pqueue-refresh", icon: "ti ti-refresh", variant: "ghost", subtle: true, title: "Refresh" }); - createThumbPlaceholder(options = {}) { - const { text, iconClass, variant } = options ?? {}; - const classes = ['pqueue-thumb-placeholder']; - if (variant) classes.push(`pqueue-thumb-placeholder--${variant}`); - const placeholder = UI.el('div', { class: classes.join(' ') }); - const icon = UI.el('i', { class: `pi ${iconClass || 'pi-image-off'}` }); - const label = UI.el('span', { class: 'pqueue-thumb-placeholder-text', text: text || 'Preview unavailable' }); - placeholder.appendChild(icon); - placeholder.appendChild(label); - return placeholder; - }, + 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", + }); + + const summary = UI.el("div", { class: "pqueue-toolbar__summary" }, [ + UI.el("span", { class: "pqueue-summary__item", text: `${state.metrics.queueCount} in queue` }), + UI.el("span", { class: "pqueue-summary__item", text: `${state.metrics.runningCount} running` }), + UI.el("span", { class: "pqueue-summary__item", text: `${state.metrics.historyCount} recent` }), + ]); + + const status = UI.el("div", { class: "pqueue-toolbar__status" }); + + state.dom.filterInput = filter; + state.dom.status = status; + + return UI.el("div", { class: "pqueue-toolbar" }, [ + UI.el("div", { class: "pqueue-toolbar__row" }, [ + UI.el("div", { class: "pqueue-toolbar__group" }, [pauseBtn, clearBtn, refreshBtn]), + status, + ]), + UI.el("div", { class: "pqueue-toolbar__row pqueue-toolbar__row--secondary" }, [filter, summary]), + ]); + }, + + 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-chart-bar", + label: "Backlog delta", + value: m.backlogDelta > 0 ? `+${m.backlogDelta}` : String(m.backlogDelta), + caption: m.lastFailure ? `Last failure ${Format.relative(m.lastFailure)}` : "", + variant: m.backlogDelta > 0 ? "warning" : "success", + }), + ]; + 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 jobs", + description: "Prompts 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 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-code", text: pid }), + 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])); + }); + } + return UI.card("Currently running", { + icon: "ti ti-activity-heartbeat", + subtitle: state.metrics.runningCount ? `${state.metrics.runningCount} active` : null, + content: rows, + }); + }, + + renderPending() { + const wrapper = UI.el("div", { class: "pqueue-table-wrapper" }); + const table = UI.el("div", { class: "pqueue-table", id: "pqueue-pending" }); + state.dom.pendingTable = table; + + const header = UI.el("div", { class: "pqueue-table__header" }, [ + UI.el("div", { class: "pqueue-col pqueue-col--prompt pqueue-col--sticky" }, [ + UI.el("div", { class: "pqueue-table__prompt" }, [ + UI.icon("ti ti-drag-drop", { size: "sm" }), + UI.el("input", { + type: "checkbox", + class: "pqueue-checkbox pqueue-select-all", + title: "Select all visible", + }), + UI.el("span", { class: "pqueue-table__heading", text: "Prompt" }), + ]), + ]), + UI.el("div", { class: "pqueue-col pqueue-col--submitted" }, [UI.el("span", { class: "pqueue-table__heading", text: "Submitted" })]), + UI.el("div", { class: "pqueue-col pqueue-col--updated" }, [UI.el("span", { class: "pqueue-table__heading", text: "Last attempt" })]), + UI.el("div", { class: "pqueue-col pqueue-col--priority" }, [UI.el("span", { class: "pqueue-table__heading", text: "Priority" })]), + UI.el("div", { class: "pqueue-col pqueue-col--estimate" }, [UI.el("span", { class: "pqueue-table__heading", text: "Estimate" })]), + UI.el("div", { class: "pqueue-col pqueue-col--actions" }, [UI.el("span", { class: "pqueue-table__heading", text: "Actions" })]), + ]); + table.appendChild(header); + + 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) => table.appendChild(row)); + wrapper.appendChild(table); + + 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 bulkPriority = UI.button({ + id: "pqueue-bulk-priority", + text: "Set priority", + icon: "ti ti-arrow-autofit-up", + variant: "secondary", + subtle: true, + disabled: state.selectedPending.size === 0, + badge: state.selectedPending.size || null, + }); + const deleteSelected = UI.button({ + id: "pqueue-delete-selected", + text: "Delete selected", + icon: "ti ti-trash", + variant: "danger", + subtle: true, + disabled: state.selectedPending.size === 0, + badge: state.selectedPending.size || null, + }); + state.dom.bulkPriorityBtn = bulkPriority; + 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" }, [bulkPriority, deleteSelected]), + ]); + + return UI.card("Queue", { + icon: "ti ti-stack-front", + subtitle: state.metrics.queueCount ? `${state.metrics.queueCount} pending` : null, + content: [wrapper, footer], + }); + }, + + 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("div", { 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 priority = Number(db.priority ?? 0); + const priorityChip = UI.el("span", { class: "pqueue-chip pqueue-chip--outline", text: `P${priority}` }); + const primary = UI.el("div", { class: "pqueue-row__primary" }, [UI.icon("ti ti-drag-drop", { size: "sm" }), checkbox, UI.el("span", { class: "pqueue-code", text: pid }), status, priorityChip]); + if (db.error) { + const err = UI.icon("ti ti-alert-triangle", { size: "sm" }); + err.classList.add("pqueue-row__error"); + err.title = db.error; + primary.appendChild(err); + } + + const submitted = db.created_at ? Format.relative(db.created_at) : "—"; + const submittedMeta = db.created_at ? Format.datetime(db.created_at) : ""; + const lastAttempt = db.completed_at || db.started_at; + const lastAttemptLabel = lastAttempt ? Format.relative(lastAttempt) : "—"; + const lastAttemptMeta = lastAttempt ? Format.datetime(lastAttempt) : ""; + + const priorityInput = UI.el("input", { + type: "number", + class: "pqueue-input pqueue-priority", + min: "-9999", + max: "9999", + step: "1", + value: String(priority), + "data-id": pid, + }); + + const saveBtn = UI.button({ icon: "ti ti-device-floppy", variant: "ghost", subtle: true, title: "Save priority" }); + saveBtn.dataset.action = "save-priority"; + saveBtn.dataset.id = pid; + + const deleteBtn = UI.button({ icon: "ti ti-trash", variant: "danger", subtle: true, title: "Delete" }); + deleteBtn.dataset.action = "delete"; + deleteBtn.dataset.id = pid; + + const viewBtn = UI.button({ icon: "ti ti-code", variant: "ghost", subtle: true, title: "View workflow" }); + viewBtn.dataset.action = "view"; + viewBtn.dataset.id = pid; + + const estimateSeconds = UI.estimateDuration(pid, item[2]); + const estimateLabel = estimateSeconds ? Format.duration(estimateSeconds) : "—"; + const estimateMeta = estimateSeconds ? "Typical runtime" : ""; + + const colPrompt = UI.el("div", { class: "pqueue-col pqueue-col--prompt pqueue-col--sticky" }, [primary]); + const colSubmitted = UI.el("div", { class: "pqueue-col pqueue-col--submitted" }, [UI.el("span", { class: "pqueue-cell__primary", text: submitted }), UI.el("span", { class: "pqueue-cell__secondary", text: submittedMeta })]); + const colUpdated = UI.el("div", { class: "pqueue-col pqueue-col--updated" }, [UI.el("span", { class: "pqueue-cell__primary", text: lastAttemptLabel }), UI.el("span", { class: "pqueue-cell__secondary", text: lastAttemptMeta })]); + const colPriority = UI.el("div", { class: "pqueue-col pqueue-col--priority" }, [priorityInput, saveBtn]); + const colEstimate = UI.el("div", { class: "pqueue-col pqueue-col--estimate" }, [UI.el("span", { class: "pqueue-cell__primary", text: estimateLabel }), UI.el("span", { class: "pqueue-cell__secondary", text: estimateMeta })]); + const colActions = UI.el("div", { class: "pqueue-col pqueue-col--actions" }, [viewBtn, deleteBtn]); + + row.appendChild(colPrompt); + row.appendChild(colSubmitted); + row.appendChild(colUpdated); + row.appendChild(colPriority); + row.appendChild(colEstimate); + row.appendChild(colActions); + + row.dataset.search = [pid, db.status, db.error, priority, db.created_at, db.completed_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))); + } + return UI.card("Recent history", { + icon: "ti ti-clock-bolt", + subtitle: state.metrics.historyCount ? `${state.metrics.historyCount} entries` : null, + content: grid, + }); + }, + + 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 meta = UI.el("div", { class: "pqueue-history-card__meta" }, [UI.el("span", { class: "pqueue-code", text: row.prompt_id || "—" }), 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); - buildResultThumbs(row) { - try { - const container = UI.el('div', { class: 'flex gap-1 flex-wrap' }); - const placeholderMeta = UI.getThumbPlaceholderMeta(row); const ensurePlaceholder = () => { - const wrapClasses = ['pqueue-thumb-wrap', 'pqueue-thumb-wrap-placeholder']; - if (placeholderMeta?.variant) wrapClasses.push(`pqueue-thumb-wrap-placeholder--${placeholderMeta.variant}`); - const wrap = UI.el('div', { class: wrapClasses.join(' ') }); - wrap.appendChild(UI.createThumbPlaceholder(placeholderMeta)); + 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 attachFallbackHandler = (thumbWrap, img, images = []) => { - img.addEventListener('error', () => { - thumbWrap.classList.add('pqueue-thumb-wrap-placeholder'); - if (placeholderMeta?.variant) thumbWrap.classList.add(`pqueue-thumb-wrap-placeholder--${placeholderMeta.variant}`); - thumbWrap.replaceChildren(UI.createThumbPlaceholder(placeholderMeta)); - if (!images.length) thumbWrap.onclick = null; - }, { once: true }); - }; - // Prefer stored DB thumbnail if available; bust cache with timestamp to avoid race + + 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 thumbWrap = UI.el('div', { class: 'pqueue-thumb-wrap' }); - const img = UI.el('img', { src: url.href, class: 'pqueue-thumb', title: `history-${row.id}` }); - attachFallbackHandler(thumbWrap, img, galleryImages); - thumbWrap.appendChild(img); - const imgCount = UI.countImages(row); - if (imgCount > 1) { - const badge = UI.el('div', { class: 'pqueue-batch-badge', text: `${imgCount}` }); - thumbWrap.appendChild(badge); - } - if (galleryImages.length) { - thumbWrap.onclick = () => UI.openGallery(galleryImages, 0, row.prompt_id); - } else { - thumbWrap.onclick = null; - } - container.appendChild(thumbWrap); - } - // Fallback to live preview route if DB thumb missing + 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}` }); + 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 thumbWrap = UI.el('div', { class: 'pqueue-thumb-wrap' }); - const img = UI.el('img', { src: url.href, class: 'pqueue-thumb', title: first.filename }); - attachFallbackHandler(thumbWrap, img, images); - thumbWrap.appendChild(img); - if (images.length > 1) { - const badge = UI.el('div', { class: 'pqueue-batch-badge', text: `${images.length}` }); - thumbWrap.appendChild(badge); - } - thumbWrap.onclick = () => UI.openGallery(images, 0, row.prompt_id); - container.appendChild(thumbWrap); - } - } + const wrap = UI.el("div", { class: "pqueue-thumb-wrap" }); + const img = UI.el("img", { class: "pqueue-thumb", src: url.href, title: first.filename }); + 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; - } catch(e) { return UI.el('span', {}); } }, - countImages(row) { - try { - return UI.extractImages(row).length; - } catch(e) { return 0; } - }, + 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" }; + }, - 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; - }, + 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 })]); + }, - // Collect normalized image descriptors for a row - extractImages(row) { - try { - let outputs = row.outputs ?? {}; - if (typeof outputs === 'string') { - try { outputs = JSON.parse(outputs); } catch(e) { outputs = {}; } - } - const images = []; - Object.values(outputs).forEach(v => { - if (v && typeof v === 'object') { - const imgs = v.images ?? v.ui?.images ?? []; - if (Array.isArray(imgs)) imgs.forEach(i => { - const filename = i.filename ?? i.name; - if (filename) images.push({ filename, type: i.type ?? 'output', subfolder: i.subfolder ?? '' }); - }); - } else if (Array.isArray(v)) { - v.forEach(i => { - const filename = i?.filename ?? i?.name; - if (filename) images.push({ filename, type: i.type ?? 'output', subfolder: i.subfolder ?? '' }); - }); - } - }); - return images; - } catch(e) { return []; } - }, - - buildDuration(row) { - try { - const dur = Number(row.duration_seconds); - if (!Number.isNaN(dur) && dur > 0) { - return `${dur.toFixed(2)}s`; + 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 */ } - const started = row.created_at ?? row.started_at; - const completed = row.completed_at; - if (!started || !completed) return ''; - const t1 = new Date(started).getTime(); - const t2 = new Date(completed).getTime(); - if (!isFinite(t1) || !isFinite(t2)) return ''; - const sec = Math.max(0, (t2 - t1) / 1000); - return `${sec.toFixed(2)}s`; - } catch(e) { return ''; } + } + 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)}%`; + }); + }, + + 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 }; }, - // Lightbox-style gallery for image batches openGallery(images, startIndex = 0, pid = null) { - try { - UI.ensureGalleryElements(); - 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.showGalleryImage(UI._galleryState.index); - UI._bindGalleryKeys(true); - } catch (e) { /* noop */ } + 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); - }, - - showGalleryImage(index) { - const imgs = UI._galleryState.images || []; - if (!imgs.length) return; - UI._galleryState.index = (index + imgs.length) % imgs.length; - const imgDesc = imgs[UI._galleryState.index]; - const url = UI.buildPreviewUrl(imgDesc, UI._galleryState.pid); - UI._galleryImg.src = url.href; - UI._galleryCounter.textContent = `${UI._galleryState.index + 1} / ${imgs.length}`; - }, - - nextImage() { UI.showGalleryImage(UI._galleryState.index + 1); }, - prevImage() { UI.showGalleryImage(UI._galleryState.index - 1); }, - - _bindGalleryKeys(enabled) { - const handler = (e) => { - if (UI._galleryOverlay?.style.display !== 'flex') return; - if (e.key === 'Escape') UI.closeGallery(); - else if (e.key === 'ArrowRight') UI.nextImage(); - else if (e.key === 'ArrowLeft') UI.prevImage(); - }; - if (enabled) { - UI._galleryKeyHandler = handler; - document.addEventListener('keydown', handler); - } else if (UI._galleryKeyHandler) { - document.removeEventListener('keydown', UI._galleryKeyHandler); - UI._galleryKeyHandler = null; - } - }, - - ensureGalleryElements() { - if (UI._galleryOverlay) return; - UI._galleryState = { images: [], index: 0 }; - const overlay = UI.el('div', { class: 'pqueue-gallery-overlay' }); - const box = UI.el('div', { class: 'pqueue-gallery' }); - const closeBtn = UI.el('button', { class: 'pqueue-gallery-close', title: 'Close' }); - closeBtn.textContent = '×'; - const img = UI.el('img', { class: 'pqueue-gallery-img' }); - const counter = UI.el('div', { class: 'pqueue-gallery-counter' }); - const prev = UI.el('button', { class: 'pqueue-gallery-nav pqueue-gallery-prev', title: 'Previous' }); - prev.textContent = '‹'; - const next = UI.el('button', { class: 'pqueue-gallery-nav pqueue-gallery-next', title: 'Next' }); - next.textContent = '›'; - - closeBtn.onclick = () => UI.closeGallery(); - prev.onclick = () => UI.prevImage(); - next.onclick = () => UI.nextImage(); - overlay.onclick = (e) => { if (e.target === overlay) UI.closeGallery(); }; - - box.appendChild(closeBtn); - box.appendChild(img); - box.appendChild(counter); - box.appendChild(prev); - box.appendChild(next); - overlay.appendChild(box); - document.body.appendChild(overlay); - UI._galleryOverlay = overlay; - UI._galleryImg = img; - UI._galleryCounter = counter; - }, - - 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); + if (UI.galleryOverlay) UI.galleryOverlay.style.display = "none"; + UI.bindGalleryKeys(false); }, - mount() { - if (state.container) return; - UI.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); + 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}`; + }, - const panel = UI.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'; }; + 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; + } + }, + + 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(close); + 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); - state.container = UI.el('div', { class: 'pqueue-content' }); - panel.appendChild(state.container); + 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 = { - async onPauseClick() { - if (state.paused) await API.resume(); else await API.pause(); - await refresh(); + 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.bulkPriorityBtn?.addEventListener("click", Events.bulkPriority); + 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); + } }, - async onClearClick() { - const ids = Array.from(document.querySelectorAll('#pqueue-pending li[data-id]')).map(li => li.dataset.id); - if (ids.length) { + 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(); } }, - onPendingListItemCheckboxChange(e) { - const li = e.target.closest('li'); - const id = li.dataset.id; - if (e.target.checked) state.selectedPending.add(id); else state.selectedPending.delete(id); + manualRefresh() { + if (state.isRefreshing) return; + refresh(); }, - async onPendingListItemPrioritySaveClick(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(); + handleFilter(event) { + state.filters.pending = event.target.value; + UI.refreshFilter(); + UI.updateSelectionUI(); }, - async onPendingListItemDeleteClick(e) { - const li = e.target.closest('li'); - const id = li.dataset.id; - await API.del([id]); - await refresh(); - }, - - init() { - document.getElementById('pqueue-toggle').onclick = Events.onPauseClick; - const clearBtn = document.getElementById('pqueue-clear'); - if (clearBtn) { - clearBtn.onclick = Events.onClearClick; - } - - const list = document.getElementById('pqueue-pending'); - if (!list) return; - - let dragged; - list.addEventListener('dragstart', (e) => { - const li = e.target.closest('li'); - dragged = li; - e.dataTransfer.setData('text/plain', li.dataset.id); + 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); }); - 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; + 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 === "save-priority") Events.savePriority(btn.closest(".pqueue-row")); + else if (action === "delete") Events.deleteSingle(id); + else if (action === "view") Events.viewWorkflow(id); + }, + + handleTableKey(event) { + if (!event.target.classList.contains("pqueue-priority")) return; + if (event.key === "Enter") { + event.preventDefault(); + Events.savePriority(event.target.closest(".pqueue-row")); + } else if (event.key === "Escape") { + event.preventDefault(); + event.target.value = String(state.dbIndex.get(event.target.dataset.id)?.priority ?? 0); + event.target.blur(); + } + }, + + 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 = (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); + 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); - await refresh(); - }); + 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(); + } + }, - list.addEventListener('change', (e) => { - if (e.target.classList.contains('pqueue-select')) { - Events.onPendingListItemCheckboxChange(e); - } - }); + handleDragEnd() { + if (dropHover) dropHover.classList.remove("pqueue-row--before", "pqueue-row--after"); + dropHover = null; + if (dragRow) { + dragRow.classList.remove("is-dragging"); + dragRow = null; + } + }, - list.addEventListener('click', (e) => { - if (e.target.closest('.pqueue-priority-save')) { - Events.onPendingListItemPrioritySaveClick(e); - } else if (e.target.closest('.pqueue-delete')) { - Events.onPendingListItemDeleteClick(e); - } - }); - } + async savePriority(row) { + if (!row) return; + const id = row.dataset.id; + const input = row.querySelector(".pqueue-priority"); + if (!input) return; + const value = parseInt(input.value || "0", 10); + if (Number.isNaN(value)) { + setStatusMessage("Priority must be a number", 3000); + return; + } + try { + await API.setPriority(id, value); + setStatusMessage("Priority updated"); + await refresh({ skipIfBusy: true }); + } catch (err) { + console.error("pqueue: set priority failed", err); + state.error = err?.message || "Failed to set priority"; + UI.updateToolbarStatus(); + } + }, + + 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() { + if (!state.selectedPending.size) return; + const value = window.prompt("Set priority for selected prompts", "0"); + if (value === null) return; + const parsed = parseInt(value, 10); + if (Number.isNaN(parsed)) { + window.alert("Priority must be a number"); + return; + } + try { + await Promise.all(Array.from(state.selectedPending).map((id) => API.setPriority(id, parsed))); + setStatusMessage(`Updated priority for ${state.selectedPending.size} prompt${state.selectedPending.size === 1 ? "" : "s"}`); + await refresh({ skipIfBusy: true }); + } catch (err) { + console.error("pqueue: bulk priority failed", err); + state.error = err?.message || "Failed to set priority"; + UI.updateToolbarStatus(); + } + }, + + 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() { - 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 || []; - state.running_progress = q.running_progress || {}; - const h = await API.getHistory(50); - state.history = h.history || []; - UI.render(); + async function refresh({ skipIfBusy } = {}) { + if (state.isRefreshing && skipIfBusy) return; + state.isRefreshing = true; + UI.updateToolbarStatus(); + try { + const [queue, history] = await Promise.all([API.getQueue(), API.getHistory(50)]); + 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 = history.history || []; + 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.queue_pending.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); + } + } 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(); + } catch (err) { + console.error("pqueue: refresh failed", err); + state.error = err?.message || "Failed to load persistent queue"; + } finally { + state.isRefreshing = false; + UI.render(); + } } - function setupSocketListeners() { + 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, + failureCount: 0, + backlogDelta: state.queue_pending.length - state.queue_running.length, + 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; + } + + 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 found, falling back to polling.'); + if (!api || typeof api.addEventListener !== "function") { + console.warn("pqueue: WebSocket API not available, falling back to polling"); return false; } - const onProgressState = (ev) => { + const onProgress = (event) => { try { - const payload = ev?.detail; + const payload = event?.detail; if (!payload?.prompt_id || !payload.nodes) return; - state.running_progress[payload.prompt_id] = Progress.computeAggregateProgress(payload.nodes); - UI.updateProgressBarsFromState(); - } catch (e) { /* ignore */ } + state.running_progress[payload.prompt_id] = Progress.computeAggregate(payload.nodes); + UI.updateProgressBars(); + } catch (err) { + /* noop */ + } }; - // Use immediate refresh on lifecycle events; progress updates are handled separately - const onQueueOrExecChange = () => refresh(); - const onExecDone = () => refresh(); + const onLifecycle = () => refresh({ skipIfBusy: true }); - api.addEventListener('progress_state', onProgressState); - api.addEventListener('status', onQueueOrExecChange); - // 'executing' fires often per-node; do not refresh on every tick - api.addEventListener('executing', onProgressState); - api.addEventListener('executed', onExecDone); - api.addEventListener('execution_start', onQueueOrExecChange); - api.addEventListener('execution_success', onExecDone); - api.addEventListener('execution_error', onExecDone); - api.addEventListener('execution_interrupted', onExecDone); + 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); - // If we reach here, socket listeners are active. Ensure fallback polling is disabled. - stopFallbackPolling(); + stopPolling(); return true; - } catch (e) { - console.error('pqueue: Error setting up WebSocket listeners.', e); + } catch (err) { + console.error("pqueue: socket setup failed", err); return false; } } - function stopFallbackPolling() { + function stopPolling() { try { - if (_pqueuePollIntervalId) { - clearInterval(_pqueuePollIntervalId); - _pqueuePollIntervalId = null; + if (pollIntervalId) { + clearInterval(pollIntervalId); + pollIntervalId = null; } - if (_pqueueFocusListener) { - window.removeEventListener('focus', _pqueueFocusListener); - _pqueueFocusListener = null; + if (focusListener) { + window.removeEventListener("focus", focusListener); + focusListener = null; } - } catch (e) { /* noop */ } + } catch (err) { + /* ignore */ + } } - function setupFallbackPolling() { - // Only used when WebSocket API is unavailable - stopFallbackPolling(); - _pqueueFocusListener = () => refresh(); - window.addEventListener('focus', _pqueueFocusListener); - _pqueuePollIntervalId = setInterval(refresh, 3000); + 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 finalSetup = (el) => { + const finalize = (el) => { state.container = el; UI.render(); - if (!setupSocketListeners()) { - setupFallbackPolling(); - // Retry establishing WebSocket listeners in the background + if (!setupSockets()) { + startPolling(); let retries = 50; - const retryId = setInterval(() => { - if (setupSocketListeners() || --retries <= 0) { - clearInterval(retryId); - } + const retryId = window.setInterval(() => { + if (setupSockets() || --retries <= 0) window.clearInterval(retryId); }, 200); } - // Initial fetch to populate queue + history on first load refresh(); }; - const sidebarTabDefinition = { - id: 'persistent_queue', - icon: 'pi pi-history', - title: 'Persistent Queue', - tooltip: 'Persistent Queue', - type: 'custom', + const tab = { + id: "persistent_queue", + icon: "ti ti-history", + title: "Persistent Queue", + tooltip: "Persistent Queue", + type: "custom", render: (el) => { - document.getElementById('pqueue-fab')?.remove(); - document.getElementById('pqueue-panel')?.remove(); - finalSetup(el); + UI.removeFallback(); + finalize(el); }, }; - const tryRegisterSidebar = () => { + const registerSidebar = () => { try { if (window.app?.extensionManager?.registerSidebarTab) { - window.app.extensionManager.registerSidebarTab(sidebarTabDefinition); + window.app.extensionManager.registerSidebarTab(tab); return true; } - } catch (error) { - console.error("pqueue: Error registering sidebar tab.", error); + } catch (err) { + console.error("pqueue: sidebar registration failed", err); } return false; }; - const initLogic = () => { - - if (tryRegisterSidebar()) { - return; - } - - if (!document.getElementById('pqueue-panel')) { - UI.mount(); - finalSetup(state.container); - } - - let retries = 50; // Try for 10 seconds - const intervalId = setInterval(() => { - if (tryRegisterSidebar() || --retries <= 0) { - clearInterval(intervalId); - } + const init = () => { + 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', - async setup(app) { - initLogic(); - } + name: "ComfyUI-PersistentQueue", + setup: () => init(), }; - // Use the modern extension system if available. if (window.app?.registerExtension) { window.app.registerExtension(extension); + } else if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); } else { - // Fallback for very old versions. - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', initLogic); - } else { - initLogic(); - } + init(); } }