feat(dashboard): standalone page mode, own live websocket, open-standalone button

html_generator gains a standalone flag (USCG_STANDALONE) and escapes "</" in
the inlined manifest. New logic_standalone.js (loaded last) replaces every
parent-frame dependency: own ComfyUI websocket re-dispatching the bridge's
message shapes (session filter, id dedupe, backoff, resync on resume),
Fullscreen API, URL kept on the shown session, GENERATE NEW via
POST /config_tester/run, and an openStandalone() header button for the node
iframe. Lightbox gets modalNav/toggleModalFavorite/rejectModalItem helpers
and an action bar element (styled in the next commit).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jason Hoku
2026-11-03 23:58:33 -08:00
co-authored by Claude Fable 5.1
parent 6705245b13
commit 660bdc17cf
7 changed files with 354 additions and 25 deletions
+8 -2
View File
@@ -1,13 +1,17 @@
import os
import json
def get_html_template(title, manifest_data, node_id):
def get_html_template(title, manifest_data, node_id, standalone=False):
# 1. Normalize Data
if isinstance(manifest_data, list):
manifest_data = {"items": manifest_data, "meta": {"model": "", "positive": "", "negative": ""}}
# Indent JSON for readability
json_str = json.dumps(manifest_data, indent=2)
# The manifest is inlined inside a <script> element: a note/prompt containing "</script>" would
# end it early (page break / script injection). "<\/" is an equivalent JSON escape, so
# JSON.parse (web/dashboard.js's incremental refresh) still reads the original text.
json_str = json_str.replace("</", "<\\/")
# 2. Resolve File Paths
current_dir = os.path.dirname(__file__)
@@ -22,7 +26,8 @@ def get_html_template(title, manifest_data, node_id):
"logic_virtual.js", # The Virtual Scroll Engine & Viewport
"logic_pipeline.js", # Sorting & Filtering Logic
"logic_events.js", # Event Listeners (Message, Keyboard)
"logic_init.js" # Initialization
"logic_init.js", # Initialization
"logic_standalone.js" # Standalone-page mode (own websocket, run API); must stay LAST
]
try:
@@ -52,6 +57,7 @@ def get_html_template(title, manifest_data, node_id):
final_html = html_template \
.replace("__TITLE__", str(title)) \
.replace("__NODE_ID__", str(node_id)) \
.replace("__STANDALONE_FLAG__", "true" if standalone else "false") \
.replace("let fullManifest = __JSON_DATA__;", wrapped_json) \
.replace("__CSS_CONTENT__", css_content) \
.replace("__JS_CONTENT__", js_content)
+2 -1
View File
@@ -9,7 +9,8 @@ document.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
e.stopPropagation();
window.parent.postMessage({ type: 'forward_ctrl_s' }, '*');
// Standalone page: nothing to forward to (edits already auto-save), just keep the browser's Save dialog away.
if (!USCG_STANDALONE) window.parent.postMessage({ type: 'forward_ctrl_s' }, '*');
}
});
+170
View File
@@ -0,0 +1,170 @@
/**
* STANDALONE MODE (dashboard opened as a top-level page: /config_tester/dashboard)
* Everything that talks to the ComfyUI tab (postMessage bridge in web/dashboard.js) is replaced here:
* - live updates: own ComfyUI websocket, re-dispatched as the SAME message shapes the bridge sends,
* so logic_events.js renders them with no duplicated logic
* - "GENERATE NEW" in the lightbox queues through POST /config_tester/run
* - the node iframe's header button opens this page via openStandalone()
* Loaded LAST (after logic_init.js). All behaviour is inside `if (USCG_STANDALONE)`; the node iframe is untouched.
*/
// Header button (node iframe only): open this dashboard as its own page.
function openStandalone() {
var sess = (document.getElementById('session-input') || {}).value || '';
var base = document.baseURI;
if (!base || base.indexOf('http') !== 0) {
try { base = window.parent.location.href; } catch (e) { base = ''; }
}
var url;
try {
url = new URL('/config_tester/dashboard' + (sess ? '?session=' + encodeURIComponent(sess) : ''), base).href;
} catch (e) {
alert('Could not work out the dashboard URL: ' + e.message);
return;
}
// No `noopener` feature: it makes window.open() always return null, hiding blocked popups.
var w = window.open(url, '_blank');
if (w) {
try { w.opener = null; } catch (e) { /* cross-origin guard */ }
} else {
prompt('Open this URL in your browser', url);
}
}
// Lightbox "GENERATE NEW" without a ComfyUI graph: queue via the run API into the session being viewed.
async function uscgRunViaApi(btn, configOutput) {
var sess = window._uscgLoadedSession || (document.getElementById('session-input') || {}).value || '';
var original = btn ? btn.innerText : '';
var flash = function (text, ms) {
if (!btn) return;
btn.innerText = text;
setTimeout(function () { btn.innerText = original || 'GENERATE NEW'; }, ms);
};
try {
var body = { configs_json: configOutput, force: true };
if (sess) body.session_name = sess;
var r = await fetch('/config_tester/run', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
var j = null;
try { j = await r.json(); } catch (e) { /* non-JSON error body */ }
if (!r.ok) {
var msg = j && j.error ? (typeof j.error === 'string' ? j.error : (j.error.message || JSON.stringify(j.error))) : ('HTTP ' + r.status);
console.error('[Revise] /config_tester/run failed:', j);
flash('ERROR: ' + String(msg).slice(0, 60), 3000);
return;
}
console.log('[Revise] queued via /config_tester/run:', j.prompt_id);
flash('QUEUED!', 1000);
} catch (e) {
console.error('[Revise] run API error:', e);
flash('ERROR: ' + (e && e.message ? e.message : e), 3000);
}
}
if (USCG_STANDALONE) {
(function () {
document.body.classList.add('uscg-standalone');
var openBtn = document.getElementById('open-standalone-btn');
if (openBtn) openBtn.style.display = 'none';
var pack = document.getElementById('pack-workflow-checkbox');
if (pack) {
pack.checked = false;
pack.disabled = true;
pack.title = 'Needs the ComfyUI tab (open the dashboard from the node to pack the workflow)';
}
// The session this page currently shows ('' on the landing page).
var titleEl = document.getElementById('session-input');
window._uscgLoadedSession = (titleEl && titleEl.value) ? titleEl.value : '';
if (window._uscgLoadedSession) document.title = 'USCG - ' + window._uscgLoadedSession;
function knownIds() {
var s = new Set();
var items = (typeof fullManifest !== 'undefined' && fullManifest && fullManifest.items) || [];
for (var i = 0; i < items.length; i++) s.add(items[i].id);
return s;
}
function dispatch(type, data) {
window.postMessage({ type: type, data: data }, location.origin);
}
function onEvent(name, data) {
if (!data) return;
var isUpdate = (name === 'ultimate_grid.update' || name === 'ultimate_grid.update_data');
var isProgress = (name === 'ultimate_grid.progress');
if (!isUpdate && !isProgress) return;
// Landing page: follow a newly started session (mirrors the node bridge's "no session loaded" case).
if (!window._uscgLoadedSession) {
if (name === 'ultimate_grid.update' && data.session_name) {
loadSessionFromPicker(data.session_name);
}
return;
}
if (data.session_name !== window._uscgLoadedSession) return;
if (isProgress) { dispatch('progress_update', data); return; }
if (data.new_items && data.new_items.length) {
// processNewData does not dedupe: a resume reload plus a late event would double the cards.
var have = knownIds();
var fresh = data.new_items.filter(function (it) { return !have.has(it.id); });
if (!fresh.length) return;
var copy = {};
for (var k in data) copy[k] = data[k];
copy.new_items = fresh;
dispatch('update_data', copy);
} else if (data.manifest) {
dispatch('update_data', data);
}
}
// ---- websocket with backoff + resync on resume -------------------------------------
var ws = null, backoff = 1000, dropped = false, resyncTimer = null;
var clientId = 'uscg-dash-' + Math.random().toString(16).slice(2, 10) + Date.now().toString(16);
function resync() {
if (!window._uscgLoadedSession) return;
if (resyncTimer) return; // debounce: a reconnect and a visibility change must not both reload
resyncTimer = setTimeout(function () {
resyncTimer = null;
if (navigator.onLine === false) return;
loadSession();
}, 2000);
}
function connect() {
var url = (location.protocol === 'https:' ? 'wss' : 'ws') + '://' + location.host + '/ws?clientId=' + clientId;
try { ws = new WebSocket(url); } catch (e) { schedule(); return; }
ws.onopen = function () {
backoff = 1000;
if (dropped) { dropped = false; resync(); }
};
ws.onmessage = function (ev) {
if (typeof ev.data !== 'string') return; // binary preview frames
var msg;
try { msg = JSON.parse(ev.data); } catch (e) { return; }
if (msg && msg.type) onEvent(msg.type, msg.data);
};
ws.onclose = function () { dropped = true; schedule(); };
ws.onerror = function () { try { ws.close(); } catch (e) { /* already closed */ } };
}
function schedule() {
setTimeout(connect, backoff);
backoff = Math.min(backoff * 2, 30000);
}
document.addEventListener('visibilitychange', function () {
if (document.visibilityState === 'visible') resync();
});
connect();
})();
}
+56 -19
View File
@@ -1493,25 +1493,7 @@ function openM(id) {
e.code >= 'Digit1' && e.code <= 'Digit9') {
e.preventDefault();
e.stopPropagation();
const item = activeData ? activeData.find(x => x.id === currentId) : null;
if (item) {
item.favorited = !item.favorited;
if (typeof markItemChanged === 'function') markItemChanged(item);
if (typeof scheduleJSONUpdate === 'function') scheduleJSONUpdate();
// Visual feedback on the modal image
const mImg = document.getElementById('m-img');
if (mImg) {
const color = item.favorited ? '#00cc44' : '#cc4444';
mImg.style.transition = 'box-shadow 0.3s';
mImg.style.boxShadow = `0 0 30px ${color}`;
setTimeout(() => { mImg.style.boxShadow = ''; }, 400);
}
// Update the navigation counter tooltip if displayed
const counterEl = document.getElementById('modal-nav-counter');
if (counterEl) {
counterEl.title = item.favorited ? 'Favorited' : 'Not favorited';
}
}
toggleModalFavorite();
}
break;
}
@@ -1522,6 +1504,61 @@ function openM(id) {
_updateModalCounter();
}
// Favorite the item shown in the lightbox (1-9 keys and the phone action bar).
function toggleModalFavorite() {
const item = activeData ? activeData.find(x => x.id === window.currentModalId) : null;
if (!item) return;
item.favorited = !item.favorited;
if (typeof markItemChanged === 'function') markItemChanged(item);
if (typeof scheduleJSONUpdate === 'function') scheduleJSONUpdate();
// Keep the rendered card's star in sync when the card is in the DOM
const card = (typeof nodeMap !== 'undefined' && nodeMap) ? nodeMap.get(item.id) : null;
const favBtn = card ? card.querySelector('.favorite-btn') : null;
if (favBtn) {
favBtn.classList.toggle('favorited', item.favorited);
favBtn.innerText = item.favorited ? '★' : '☆';
}
// Visual feedback on the modal image
const mImg = document.getElementById('m-img');
if (mImg) {
const color = item.favorited ? '#00cc44' : '#cc4444';
mImg.style.transition = 'box-shadow 0.3s';
mImg.style.boxShadow = `0 0 30px ${color}`;
setTimeout(() => { mImg.style.boxShadow = ''; }, 400);
}
// Update the navigation counter tooltip if displayed
const counterEl = document.getElementById('modal-nav-counter');
if (counterEl) {
counterEl.title = item.favorited ? 'Favorited' : 'Not favorited';
}
const favActionBtn = document.getElementById('modal-fav-btn');
if (favActionBtn) favActionBtn.innerText = item.favorited ? '★ Favorited' : '☆ Favorite';
}
// Lightbox prev/next by delta through the filtered/sorted set (phone action bar).
function modalNav(delta) {
if (!processedData) return;
const idx = processedData.findIndex(x => x.id === window.currentModalId);
const target = processedData[idx + delta];
if (idx !== -1 && target) openM(target.id);
}
// Reject the lightbox item, then move on to the neighbour (or close when it was the last one).
function rejectModalItem() {
const item = activeData ? activeData.find(x => x.id === window.currentModalId) : null;
if (!item) return;
item.rejected = true;
if (typeof markItemChanged === 'function') markItemChanged(item);
const idx = processedData ? processedData.findIndex(x => x.id === item.id) : -1;
const next = idx !== -1 ? (processedData[idx + 1] || processedData[idx - 1]) : null;
// Re-run the pipeline after the neighbour is chosen: it drops the rejected item from the grid.
setTimeout(() => {
if (typeof updateDataPipeline === 'function') updateDataPipeline();
if (typeof scheduleJSONUpdate === 'function') scheduleJSONUpdate();
}, 100);
if (next && next.id !== item.id) openM(next.id); else closeM();
}
// Update the modal nav counter display
function _updateModalCounter() {
const counterEl = document.getElementById('modal-nav-counter');
+24
View File
@@ -5,6 +5,14 @@
*/
function toggleFullscreen() {
if (USCG_STANDALONE) {
// Top-level page: use the browser Fullscreen API (iOS Safari has no element fullscreen; harmless no-op there).
try {
if (document.fullscreenElement) document.exitFullscreen();
else if (document.documentElement.requestFullscreen) document.documentElement.requestFullscreen();
} catch (e) { /* unsupported */ }
return;
}
// Send current session name so fullscreen reload uses the right session
var currentSession = document.getElementById('session-input')?.value || '';
window.parent.postMessage({ type: 'toggle_fullscreen', node_id: TARGET_NODE_ID, session_name: currentSession }, '*');
@@ -152,6 +160,13 @@ async function loadSession() {
if (landing) landing.style.display = 'none';
if (viewport) viewport.style.display = 'block';
// Standalone page: keep the URL on the session being shown so F5 / reload-after-delete stay on it.
if (USCG_STANDALONE) {
window._uscgLoadedSession = sess;
try { history.replaceState(null, '', '?session=' + encodeURIComponent(sess)); } catch (e) { /* ignore */ }
document.title = 'USCG - ' + sess;
}
console.log('[Load] ✅ Session loaded successfully');
@@ -287,6 +302,7 @@ async function fetchAndShowSessions() {
cardsContainer.innerHTML = '';
sessions.forEach(s => {
const card = document.createElement('div');
card.className = 'session-card';
card.style.cssText = 'width: 180px; background: #2a2a2a; border: 1px solid #444; border-radius: 8px; overflow: hidden; cursor: pointer; transition: border-color 0.2s, transform 0.15s;';
card.onmouseenter = () => { card.style.borderColor = '#0af'; card.style.transform = 'translateY(-2px)'; };
card.onmouseleave = () => { card.style.borderColor = '#444'; card.style.transform = 'none'; };
@@ -783,6 +799,14 @@ async function triggerGen(btn) {
console.log("[Revise] Sending config to UltimateSamplerGrid:", config);
// No ComfyUI graph to drive (standalone page, or no parent app): queue through the run API instead.
let hasParentApp = false;
try { hasParentApp = !!(window.parent && window.parent.app); } catch (e) { /* cross-origin parent */ }
if (USCG_STANDALONE || !hasParentApp) {
await uscgRunViaApi(btn, configOutput);
return;
}
try {
const app = window.parent.app;
const graph = app.graph;
+15 -3
View File
@@ -2,6 +2,7 @@
<html>
<head>
<title>USCG - __TITLE__</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<style>
/* INJECTED CSS */
@@ -72,6 +73,7 @@
</button>
</div>
<button class="fs-btn" id="open-standalone-btn" onclick="openStandalone()" title="Open standalone dashboard (new tab, phone-friendly URL)">↗</button>
<button class="fs-btn" onclick="toggleFullscreen()" title="Toggle Fullscreen">⛶</button>
</div>
@@ -688,12 +690,12 @@
<button class="modal-close-x" onclick="closeM()" title="Close (Esc)">&times;</button>
<!-- Lightbox navigation bar -->
<div style="display: flex; align-items: center; gap: 8px; padding: 6px 12px; background: rgba(0,0,0,0.6); border-bottom: 1px solid #333; flex-shrink: 0;">
<button onclick="(function(){ var idx = processedData.findIndex(function(x){return x.id===window.currentModalId;}); if(idx>0) openM(processedData[idx-1].id); })()" title="Previous image (← or Shift+Space)"
<button class="modal-nav-btn" onclick="(function(){ var idx = processedData.findIndex(function(x){return x.id===window.currentModalId;}); if(idx>0) openM(processedData[idx-1].id); })()" title="Previous image (← or Shift+Space)"
style="background: #222; border: 1px solid #444; color: #ccc; padding: 4px 12px; border-radius: 4px; cursor: pointer; font-size: 14px;">◀</button>
<span id="modal-nav-counter" style="font-size: 11px; color: #777; min-width: 60px; text-align: center;"></span>
<button onclick="(function(){ var idx = processedData.findIndex(function(x){return x.id===window.currentModalId;}); if(idx < processedData.length-1) openM(processedData[idx+1].id); })()" title="Next image (→ or Space)"
<button class="modal-nav-btn" onclick="(function(){ var idx = processedData.findIndex(function(x){return x.id===window.currentModalId;}); if(idx < processedData.length-1) openM(processedData[idx+1].id); })()" title="Next image (→ or Space)"
style="background: #222; border: 1px solid #444; color: #ccc; padding: 4px 12px; border-radius: 4px; cursor: pointer; font-size: 14px;">▶</button>
<span style="font-size: 9px; color: #444; margin-left: 8px;">← → Space navigate &nbsp;|&nbsp; 1-9 favorite</span>
<span class="modal-kbd-hint" style="font-size: 9px; color: #444; margin-left: 8px;">← → Space navigate &nbsp;|&nbsp; 1-9 favorite</span>
</div>
<div class="modal-main">
<div class="preview"><img id="m-img" src=""></div>
@@ -733,11 +735,21 @@
<div style="font-size:10px; font-weight:bold; color:#666;">RELATED VARIANTS</div>
<div class="reel" id="reel"></div>
</div>
<!-- Phone action bar (hidden by CSS unless coarse pointer / standalone on a narrow screen) -->
<div id="modal-action-bar">
<button onclick="modalNav(-1)" title="Previous">◀ Prev</button>
<button id="modal-fav-btn" onclick="toggleModalFavorite()" title="Favorite">☆ Favorite</button>
<button onclick="rejectModalItem()" title="Reject">✕ Reject</button>
<button onclick="var c=document.querySelector('#modal .controls'); if(c) c.scrollIntoView({behavior:'smooth'});" title="Revise parameters">✎ Revise</button>
<button onclick="modalNav(1)" title="Next">Next ▶</button>
</div>
</div>
<script>
// INJECTED DATA
const TARGET_NODE_ID = "__NODE_ID__";
// true when served by /config_tester/dashboard, or whenever this page is not inside a frame
const USCG_STANDALONE = __STANDALONE_FLAG__ || (window.parent === window);
let fullManifest = __JSON_DATA__;
// INJECTED LOGIC
+79
View File
@@ -0,0 +1,79 @@
"""Standalone-dashboard flag + manifest inlining in html_generator (and the dashboard.js incremental parse)."""
import importlib.util
import os
import re
import shutil
import subprocess
import tempfile
import pytest
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_PKG = os.path.basename(_ROOT)
_spec = importlib.util.spec_from_file_location(f"{_PKG}.html_generator_real", os.path.join(_ROOT, "html_generator.py"))
html_generator = importlib.util.module_from_spec(_spec)
html_generator.__package__ = _PKG
_spec.loader.exec_module(html_generator)
EVIL = "evil </script><script>alert(1)</script> and </b>"
NODE = shutil.which("node")
def _page(**kw):
manifest = {"items": [{"id": 1, "note": EVIL}], "meta": {}}
return html_generator.get_html_template("t", manifest, "standalone", **kw)
def test_standalone_flag_and_no_placeholder_left():
on, off = _page(standalone=True), _page()
assert "const USCG_STANDALONE = true" in on
assert "const USCG_STANDALONE = false" in off
for page in (on, off):
assert "__STANDALONE_FLAG__" not in page
assert "logic_standalone.js" in page
assert "<title>USCG - t</title>" in page
def test_script_close_in_note_is_escaped():
page = _page(standalone=True)
assert "alert(1)</script>" not in page
assert "<\\/script>" in page
def _dashboard_js_pattern():
"""The exact incremental-refresh regex used by web/dashboard.js."""
src = open(os.path.join(_ROOT, "web", "dashboard.js"), encoding="utf-8").read()
m = re.search(r"const pattern = /(.*__JSON_END__.*?)/;", src)
assert m, "pattern line not found in web/dashboard.js"
return m.group(1)
@pytest.mark.skipif(NODE is None, reason="node not installed")
def test_escaped_manifest_still_parses_with_dashboard_js_extraction():
# Run dashboard.js's own regex + JSON.parse in node over the generated page.
page = _page(standalone=True)
script = (
"const html=require('fs').readFileSync(0,'utf8');"
"const m=html.match(new RegExp(process.argv[1]));"
"if(!m||!m[1]){console.error('no match');process.exit(2)}"
"const o=JSON.parse(m[1]);process.stdout.write(o.items[0].note);"
)
out = subprocess.run([NODE, "-e", script, _dashboard_js_pattern()], input=page.encode("utf-8"),
capture_output=True, timeout=30)
assert out.returncode == 0, out.stderr.decode()
assert out.stdout.decode() == EVIL
@pytest.mark.skipif(NODE is None, reason="node not installed")
@pytest.mark.parametrize("standalone", [True, False])
def test_concatenated_script_parses_as_one_script(standalone):
page = _page(standalone=standalone)
scripts = re.findall(r"<script>([\s\S]*?)</script>", page)
assert scripts, "no inline script found"
js = max(scripts, key=len)
with tempfile.TemporaryDirectory() as d:
path = os.path.join(d, "page.js")
with open(path, "w", encoding="utf-8") as f:
f.write(js)
out = subprocess.run([NODE, "--check", path], capture_output=True, timeout=60)
assert out.returncode == 0, out.stderr.decode()