Merge branch 'feat/dashboard-url-api' into integration

This commit is contained in:
Jason Hoku
2026-11-04 00:06:41 -08:00
13 changed files with 1038 additions and 36 deletions
+6 -1
View File
@@ -53,6 +53,7 @@ ComfyUI-Ultimate-Sampler-Grid/
├── network_utils.py # ALL outbound network requests (CivitAI, HuggingFace VAE, Distribution LAN) (280 lines)
├── upscale_runner.py # Dashboard async upscaling background thread (371 lines)
├── html_generator.py # Reads /resources/ to build the Dashboard HTML (57 lines)
├── run_api.py # Pure helpers for POST /config_tester/run + run_status (no server imports)
│
├── distribution_manager.py # Distributed Job Queue Coordinator/Master (634 lines)
├── distribution_worker.py # Worker Thread for Remote Processing (813 lines)
@@ -72,6 +73,7 @@ ComfyUI-Ultimate-Sampler-Grid/
│ ├── template.html # HTML Skeleton for the generated report (662 lines)
│ ├── report.css # Styling (Infinite Canvas, Cards, Modals) (1642 lines)
│ ├── logic_init.js # Bootstrapper (Load JSON, Init State) (104 lines)
│ ├── logic_standalone.js # Standalone-page mode: own websocket, run-API GENERATE NEW, openStandalone() (loaded last)
│ ├── logic_state.js # State Store (Redux-style: filters, favorites) (104 lines)
│ ├── logic_pipeline.js # Data Processing (Filter -> Sort -> Layout) (510 lines)
│ ├── logic_virtual.js # Virtual DOM / Infinite Scroller Engine (925 lines)
@@ -204,6 +206,9 @@ All routes registered via `PromptServer.instance.routes`.
| POST | `/config_tester/save_changes` | 234 | Delta save — only changed items `{session_name, changed_items}` |
| POST | `/config_tester/save_manifest` | 308 | Full merge save `{session_name, manifest}` |
| POST | `/config_tester/get_session_html` | 390 | Generate dashboard HTML on-demand `{session_name, node_id}` |
| GET | `/config_tester/dashboard` | - | Dashboard SPA as a top-level page, optional `?session=` (standalone mode, `USCG_STANDALONE`, own websocket via `resources/logic_standalone.js`) |
| POST | `/config_tester/run` | - | Headless run: `{configs_json \| config_name, session_name?, overwrite_existing?, force? (default true), front?, client_id?}` -> builds Generator+Dashboard prompt, enqueues in-process like `/prompt`. Helpers in `run_api.py` |
| GET | `/config_tester/run_status` | - | `?prompt_id=` -> `pending/running/success/error` + session/dashboard_url/item_count |
| POST | `/config_tester/export_favorites` | 435 | Export favorited images with options (metadata, workflow, organize) |
| POST | `/config_tester/delete_non_favorites` | 662 | Delete all non-favorited images from session |
| POST | `/config_tester/scan_directory` | 746 | Scan external dir, create symlinks, generate manifest |
@@ -274,7 +279,7 @@ All routes registered via `PromptServer.instance.routes`.
* **`html_generator.py`** (57 lines)
* `get_html_template(title, manifest_data, node_id)` — reads all `/resources/` files, inlines into `template.html`
* **JS Load Order:** `logic_state.js` → `logic_utils.js` → `logic_ui.js` → `logic_virtual.js` → `logic_pipeline.js` → `logic_events.js` → `logic_init.js`
* **JS Load Order:** `logic_state.js` → `logic_utils.js` → `logic_ui.js` → `logic_virtual.js` → `logic_pipeline.js` → `logic_events.js` → `logic_init.js` → `logic_standalone.js`
* Replaces placeholders: `__CSS_CONTENT__`, `__JS_CONTENT__`, `__TITLE__`, `__NODE_ID__`
* Zero external HTTP requests — everything inlined
+43
View File
@@ -854,6 +854,21 @@ Clicking **REVISE** on a card opens the studio view:
4. **Bottom:** "Related Variants" reel showing other images with the same seed.
5. **GENERATE NEW:** Queues the specific config you just edited.
### Standalone Dashboard (phone / second monitor)
The dashboard is also served as a normal web page, with the same live updates as the node:
```
http://<comfyui-host>:8188/config_tester/dashboard?session=<session_name>
```
- Without `?session=` you get the session landing cards; picking a session updates the URL, so refresh keeps your place.
- Open it from the node with the **↗** button in the dashboard header (it opens the page for the session you are viewing).
- It opens its own ComfyUI websocket, so new images and the ETA bar update live while a run is going. After your phone sleeps, it reloads the session when you come back, without duplicating cards. Reverse proxies that do not upgrade websockets (some Tailscale Serve / nginx setups) lose live updates; the **R** key and the resume reload still work.
- Touch screens get 44px buttons, and the lightbox gets a bottom bar (Prev / Favorite / Reject / Revise / Next). The in-node view is unchanged apart from the extra header button.
- **GENERATE NEW** in the lightbox queues through the run API below, into the session you are viewing. "Pack workflow" in export needs the ComfyUI tab and is disabled here.
- Like ComfyUI itself, the page has no authentication. Only expose it on a network you trust.
---
## 🎯 Example Workflows
@@ -1344,6 +1359,34 @@ These endpoints are used internally by the distribution system:
| GET | `/uscg-distribution/download_model` | Download model file for model sync |
| POST | `/uscg-distribution/heartbeat` | Worker keepalive signal |
### Run API (headless runs: Discord bot, session hub, curl)
`POST /config_tester/run` builds the two-node prompt (Generator -> Dashboard) and queues it the same way ComfyUI's own `/prompt` does. No workflow JSON is needed.
| Field | Meaning |
|---|---|
| `configs_json` | Configs as an object `{"configs":[...]}`, a bare list, or a JSON string of either. |
| `config_name` | Alternative: a Config Builder file saved in `output/ultimate-configs` (with or without `.json`). Give exactly one of `configs_json` / `config_name`. |
| `session_name` | Output session (`output/benchmarks/<name>`). Letters, digits, `_`, `-` only. Defaults to the config's own session name, then `my_session`. |
| `overwrite_existing` | Optional bool, same as the Builder's Run Settings. Skip/resume still protects existing images when false. |
| `force` | Default **true**: a nonce is added so ComfyUI's node cache cannot short-circuit the run. `false` lets an identical request return the cached result with no new images. |
| `front` | Optional bool: queue ahead of waiting jobs. |
| `client_id` | Optional: events are still broadcast to every websocket; this tags the queue entry. |
```
curl -X POST http://HOST:8188/config_tester/run -H "Content-Type: application/json" \
-d '{"config_name":"my_flux_sweep","session_name":"flux_sweep_nov"}'
curl -X POST http://HOST:8188/config_tester/run -H "Content-Type: application/json" \
-d '{"configs_json":{"configs":[{"model":"x.safetensors","sampler":"euler","scheduler":"normal","steps":20,"cfg":7,"positive":"a cat","negative":"","seed":1}]},"session_name":"bot_run"}'
curl "http://HOST:8188/config_tester/run_status?prompt_id=<id>"
```
Response: `{"prompt_id", "number", "session_name", "dashboard_path", "dashboard_url", "node_errors"}`. Errors: `400` (bad JSON/configs, both or neither source, empty session, or ComfyUI validation failure with its `node_errors`), `403`, `404` (unknown `config_name`), `500`.
`GET /config_tester/run_status?prompt_id=<id>` returns `state` (`pending` | `running` | `success` | `error`), `queue_position`, `queue_remaining`, a small `status` summary (including the error message when a node failed), and, for runs started through `/run`, `session_name`, `dashboard_url` and `item_count`. Unknown ids return `404` with `state: "unknown"` (history is in memory and lost on restart).
Bot pattern: `POST /run`, post `dashboard_url` to the channel, poll `run_status` every 10-30 s (or pass `client_id` and listen on `/ws?clientId=...` for `ultimate_grid.progress`). `/run` is unauthenticated, the same exposure as ComfyUI's `/prompt`.
---
## File Locations
+213
View File
@@ -7,6 +7,9 @@ import os
import urllib.parse
import folder_paths
import shutil
import uuid
import time
import inspect
from .sampler_node import SamplerGridTester
from .dashboard_node import SamplerConfigDashboardViewer
from .html_generator import get_html_template
@@ -14,6 +17,10 @@ from .config_builder_node import UltimateConfigBuilder
from .json_text_node import SmartJSONTextNode
from .metadata_packer import pack_metadata_into_image
from .directory_scanner import scan_directory_for_images
from .run_api import (
RunTracker, sanitize_session_name_strict, normalize_configs_payload, apply_run_overrides,
build_run_prompt, resolve_saved_config, saved_config_to_configs_obj, summarize_history, find_in_queue,
)
import sys
# Register a sys.modules alias for cross-companion imports.
@@ -539,6 +546,212 @@ async def get_session_html(request):
except Exception as e:
return web.Response(status=500, text=str(e))
# =============================================================================
# API: STANDALONE DASHBOARD PAGE + HEADLESS RUN API
# =============================================================================
_run_tracker = RunTracker()
@server.PromptServer.instance.routes.get("/config_tester/dashboard")
async def standalone_dashboard(request):
"""
Serve the dashboard SPA as a top-level page (phone / second-monitor friendly).
Optional ?session=<name>; without it the SPA shows its session landing cards.
"""
try:
session_name = sanitize_session_name_strict(request.query.get("session", ""))
headers = {"Cache-Control": "no-store"}
empty_manifest = {"items": [], "meta": {"model": "", "positive": "", "negative": ""}, "session_name": ""}
if not session_name:
html = get_html_template("", empty_manifest, "standalone", standalone=True)
return web.Response(status=200, text=html, content_type="text/html", charset="utf-8", headers=headers)
base_dir = os.path.join(folder_paths.get_output_directory(), "benchmarks", session_name)
if not _is_path_within(base_dir, _get_benchmarks_base()):
return web.Response(status=403, text="Forbidden: path outside benchmarks directory")
manifest_path = os.path.join(base_dir, "manifest.json")
if not os.path.exists(manifest_path):
# Unknown session: landing page (picker); the title stays empty so the SPA does not
# pretend a session is loaded.
html = get_html_template("", empty_manifest, "standalone", standalone=True)
return web.Response(status=200, text=html, content_type="text/html", charset="utf-8", headers=headers)
with open(manifest_path, "r", encoding="utf-8") as f:
manifest = json.load(f)
html = get_html_template(session_name, manifest, "standalone", standalone=True)
return web.Response(status=200, text=html, content_type="text/html", charset="utf-8", headers=headers)
except Exception as e:
print(f"[GridTester] ❌ /dashboard failed: {e}")
return web.Response(status=500, text=str(e))
@server.PromptServer.instance.routes.post("/config_tester/run")
async def run_headless(request):
"""
Queue a USCG run without the ComfyUI graph editor (Discord bot / session hub / curl).
Builds the 2-node prompt (Generator -> Dashboard) and enqueues it the way
PromptServer's own POST /prompt handler does (in-process, no HTTP self-call).
Body: {configs_json?: object|string|list, config_name?: str, session_name?: str,
overwrite_existing?: bool, force?: bool (default true), front?: bool, client_id?: str}
force=false lets ComfyUI's node cache short-circuit an identical request.
"""
try:
try:
body = await request.json()
except Exception:
return web.json_response({"error": "Request body must be JSON"}, status=400)
if not isinstance(body, dict):
return web.json_response({"error": "Request body must be a JSON object"}, status=400)
has_inline = body.get("configs_json") not in (None, "")
has_named = bool(body.get("config_name"))
if has_inline == has_named:
return web.json_response({"error": "Provide exactly one of 'configs_json' or 'config_name'"}, status=400)
try:
if has_named:
try:
cfg_path = resolve_saved_config(CONFIGS_DIR, body["config_name"])
except FileNotFoundError:
return web.json_response({"error": f"Config '{body['config_name']}' not found"}, status=404)
except PermissionError:
return web.json_response({"error": "Forbidden"}, status=403)
with open(cfg_path, "r", encoding="utf-8") as f:
saved = json.load(f)
obj = normalize_configs_payload(
saved_config_to_configs_obj(saved, UltimateConfigBuilder.state_to_configs_json))
else:
obj = normalize_configs_payload(body["configs_json"])
except ValueError as e:
return web.json_response({"error": str(e)}, status=400)
force = body.get("force", True)
force = True if force is None else bool(force)
overwrite = body.get("overwrite_existing")
obj, session_name = apply_run_overrides(
obj,
session_name=body.get("session_name") or None,
overwrite_existing=overwrite if overwrite is None else bool(overwrite),
nonce=uuid.uuid4().hex if force else None,
)
if not session_name:
return web.json_response({"error": "session_name is empty after sanitising (allowed: letters, digits, _ and -)"}, status=400)
configs_str = json.dumps(obj, indent=2, ensure_ascii=False)
prompt = build_run_prompt(configs_str, session_name)
client_id = body.get("client_id")
client_id = str(client_id) if client_id else None
front = bool(body.get("front", False))
import execution # ComfyUI core; imported lazily so this package still loads without it
# --- enqueue: mirrors PromptServer.post_prompt (ComfyUI 0.42.5, server.py) ---
ps = server.PromptServer.instance
json_data = {"prompt": prompt}
if client_id:
json_data["client_id"] = client_id
if front:
json_data["front"] = True
json_data = ps.trigger_on_prompt(json_data) # same hook third-party on_prompt handlers use
if not isinstance(json_data, dict) or "prompt" not in json_data:
return web.json_response({"error": "An on_prompt handler rejected the run"}, status=400)
prompt = json_data["prompt"]
if "number" in json_data:
number = float(json_data["number"])
else:
number = ps.number
if json_data.get("front"):
number = -number
ps.number += 1
prompt_id = str(uuid.uuid4())
if hasattr(ps, "node_replace_manager"):
ps.node_replace_manager.apply_replacements(prompt)
# 0.42.5: async validate_prompt(prompt_id, prompt, partial_execution_list). Older builds
# take (prompt) or (prompt_id, prompt) and may be sync.
n_params = len(inspect.signature(execution.validate_prompt).parameters)
if n_params >= 3:
valid = execution.validate_prompt(prompt_id, prompt, None)
elif n_params == 2:
valid = execution.validate_prompt(prompt_id, prompt)
else:
valid = execution.validate_prompt(prompt)
if inspect.isawaitable(valid):
valid = await valid
if not valid[0]:
print(f"[GridTester] ⚠️ /run rejected by ComfyUI validation: {valid[1]}")
return web.json_response({"error": valid[1], "node_errors": valid[3]}, status=400)
extra_data = {}
if client_id:
extra_data["client_id"] = client_id
extra_data["create_time"] = int(time.time() * 1000) # milliseconds, like post_prompt
item = (number, prompt_id, prompt, extra_data, valid[2])
if hasattr(execution, "SENSITIVE_EXTRA_DATA_KEYS"):
item = item + ({},) # trailing `sensitive` dict (empty: nothing sensitive to strip)
ps.prompt_queue.put(item)
_run_tracker.add(prompt_id, session_name)
dashboard_path = "/config_tester/dashboard?session=" + urllib.parse.quote(session_name)
print(f"[GridTester] 🚀 /run queued {prompt_id} session={session_name}")
return web.json_response({
"prompt_id": prompt_id,
"number": number,
"session_name": session_name,
"dashboard_path": dashboard_path,
"dashboard_url": f"{request.scheme}://{request.host}{dashboard_path}",
"node_errors": valid[3],
})
except Exception as e:
print(f"[GridTester] ❌ /run failed: {e}")
return web.json_response({"error": str(e)}, status=500)
@server.PromptServer.instance.routes.get("/config_tester/run_status")
async def run_status(request):
"""State of a run queued through /config_tester/run: pending | running | success | error | unknown."""
try:
prompt_id = request.query.get("prompt_id", "")
if not prompt_id:
return web.json_response({"error": "Missing prompt_id"}, status=400)
pq = server.PromptServer.instance.prompt_queue
getter = getattr(pq, "get_current_queue_volatile", None) or pq.get_current_queue
running, pending = getter()
result = {"prompt_id": prompt_id, "queue_remaining": len(running) + len(pending)}
hist = pq.get_history(prompt_id=prompt_id).get(prompt_id)
found = find_in_queue(running, pending, prompt_id)
if found:
result["state"] = found[0]
if found[0] == "pending":
result["queue_position"] = found[1]
elif hist is not None:
summary = summarize_history(hist)
result["status"] = summary
result["state"] = "error" if summary["status"] == "error" else "success"
else:
result["state"] = "unknown"
return web.json_response(result, status=404)
session_name = _run_tracker.get(prompt_id)
if session_name:
dashboard_path = "/config_tester/dashboard?session=" + urllib.parse.quote(session_name)
result["session_name"] = session_name
result["dashboard_url"] = f"{request.scheme}://{request.host}{dashboard_path}"
manifest_path = os.path.join(folder_paths.get_output_directory(), "benchmarks", session_name, "manifest.json")
if _is_path_within(manifest_path, _get_benchmarks_base()) and os.path.exists(manifest_path):
try:
with open(manifest_path, "r", encoding="utf-8") as f:
result["item_count"] = len(json.load(f).get("items", []))
except Exception:
pass
return web.json_response(result)
except Exception as e:
print(f"[GridTester] ❌ /run_status failed: {e}")
return web.json_response({"error": str(e)}, status=500)
# =============================================================================
# API: EXPORT FAVORITES
# =============================================================================
+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;
+96 -10
View File
@@ -760,20 +760,29 @@ body {
}
.reject-btn {
width: 32px;
height: 32px;
font-size: 16px;
width: 44px;
height: 44px;
font-size: 18px;
}
.favorite-btn {
width: 36px;
height: 36px;
font-size: 20px;
width: 44px;
height: 44px;
font-size: 22px;
}
.upscale-btn {
width: 44px;
height: 44px;
font-size: 18px;
top: 110px;
}
.revise-btn {
padding: 8px 12px;
min-height: 44px;
padding: 0 12px;
font-size: 11px;
top: 58px;
}
}
@@ -1734,4 +1743,81 @@ body {
.analytics-count {
font-size: 11px;
}
}
}
/* ==========================================================================
TOUCH / PHONE LAYOUT
Scoped to a coarse pointer, or to the standalone page (body.uscg-standalone)
at phone width. Never width alone: node iframes are narrow too and must look
exactly as before.
========================================================================== */
#modal-action-bar { display: none; }
@media (hover: none) and (pointer: coarse) {
/* header */
#header { height: 44px; }
#header .fs-btn,
#header .action-btn,
.filter-popup-btn { min-width: 40px; min-height: 40px; }
.cog-menu-panel { top: 48px; }
/* lightbox */
.modal-close-x { width: 44px; height: 44px; }
.modal-nav-btn { min-width: 44px; min-height: 44px; }
.modal-kbd-hint { display: none; }
.controls .action-btn,
.controls .modal-action-btn { min-height: 44px; }
#modal-action-bar {
display: flex;
position: sticky;
bottom: 0;
gap: 6px;
padding: 8px 8px calc(8px + env(safe-area-inset-bottom));
background: rgba(0, 0, 0, 0.9);
border-top: 1px solid #333;
z-index: 1002;
flex-shrink: 0;
}
#modal-action-bar button {
flex: 1;
min-height: 48px;
font-size: 18px;
background: #222;
color: #eee;
border: 1px solid #444;
border-radius: 6px;
cursor: pointer;
padding: 0 4px;
}
#modal { overflow-y: auto; padding-bottom: 72px; }
}
@media (max-width: 600px) {
body.uscg-standalone #modal-action-bar {
display: flex;
position: sticky;
bottom: 0;
gap: 6px;
padding: 8px 8px calc(8px + env(safe-area-inset-bottom));
background: rgba(0, 0, 0, 0.9);
border-top: 1px solid #333;
z-index: 1002;
flex-shrink: 0;
}
body.uscg-standalone #modal-action-bar button {
flex: 1;
min-height: 48px;
font-size: 18px;
background: #222;
color: #eee;
border: 1px solid #444;
border-radius: 6px;
cursor: pointer;
padding: 0 4px;
}
body.uscg-standalone #modal { overflow-y: auto; padding-bottom: 72px; }
body.uscg-standalone .session-card { width: calc(50% - 6px) !important; }
body.uscg-standalone #session-landing { padding: 16px 12px !important; }
}
+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
+144
View File
@@ -0,0 +1,144 @@
"""Pure helpers for the headless run API (POST /config_tester/run, GET /config_tester/run_status).
No aiohttp / ComfyUI imports at module level so the helpers are unit-testable on their own.
The routes themselves live in __init__.py.
"""
import json
import os
import re
from collections import OrderedDict
DEFAULT_SESSION = "my_session" # the Generator node's own default (sampler_node.py)
_MAX_TRACKED_RUNS = 200
def sanitize_session_name_strict(name):
"""Same regex as delete_session / save_manifest / get_session_html. '' when nothing is left."""
return re.sub(r'[^\w\-]', '', str(name or ''))
def normalize_configs_payload(configs_json):
"""Accept a dict with a `configs` list, a bare list, or a JSON string of either.
Returns a dict that always has a non-empty `configs` list; other top-level keys
(`_distribution`, `_session_settings`, ...) are preserved. Raises ValueError otherwise.
"""
obj = configs_json
if isinstance(obj, (str, bytes)):
try:
obj = json.loads(obj)
except (TypeError, ValueError) as e:
raise ValueError(f"configs_json is not valid JSON: {e}")
if isinstance(obj, list):
obj = {"configs": obj}
if not isinstance(obj, dict):
raise ValueError("configs_json must be an object with a 'configs' list, a list of configs, or a JSON string of either")
configs = obj.get("configs")
if not isinstance(configs, list) or not configs:
raise ValueError("configs_json must contain a non-empty 'configs' list")
return obj
def apply_run_overrides(obj, session_name=None, overwrite_existing=None, nonce=None):
"""Apply run-level overrides. Returns (obj, sanitised_session_name).
Session precedence: explicit session_name, then _session_settings.session_name, then the
Generator default. The sanitised name is written back to _session_settings.session_name
because that is what the Generator actually reads. `nonce` goes into an inert top-level
key; it changes IS_CHANGED's hash so ComfyUI's node cache cannot short-circuit the run.
"""
settings = obj.get("_session_settings")
if not isinstance(settings, dict):
settings = {}
obj["_session_settings"] = settings
chosen = session_name or settings.get("session_name") or DEFAULT_SESSION
session = sanitize_session_name_strict(chosen)
settings["session_name"] = session
if overwrite_existing is not None:
settings["overwrite_existing"] = bool(overwrite_existing)
if nonce is not None:
obj["_run_nonce"] = str(nonce)
return obj, session
def build_run_prompt(configs_str, session_name):
"""The 2-node API-format prompt: Generator -> Dashboard (which owns the output)."""
return {
"1": {"class_type": "UltimateSamplerGrid", "inputs": {"configs_json": configs_str}},
"2": {"class_type": "UltimateGridDashboard",
"inputs": {"session_name": session_name, "dashboard_html": ["1", 0]}},
}
def resolve_saved_config(configs_dir, name):
"""Mirror of the /configbuilder/load_config path handling. Returns the file path.
Raises FileNotFoundError (missing) or PermissionError (escapes configs_dir).
"""
filename = str(name or "")
if not filename.endswith(".json"):
filename += ".json"
filename = os.path.basename(filename)
filepath = os.path.join(configs_dir, filename)
real_path = os.path.realpath(filepath)
real_base = os.path.realpath(configs_dir)
if not (real_path == real_base or real_path.startswith(real_base + os.sep)):
raise PermissionError("config path escapes the configs directory")
if not os.path.isfile(filepath):
raise FileNotFoundError(f"config '{filename}' not found")
return filepath
def saved_config_to_configs_obj(data, state_to_configs_json):
"""Saved Builder files hold the Builder STATE; convert via the single source of truth."""
if isinstance(data, dict):
if "config_arrays" in data:
return json.loads(state_to_configs_json(data))
if "configs" in data:
return data
raise ValueError("saved config is neither a Builder state (config_arrays) nor a configs JSON (configs)")
def summarize_history(entry):
"""Small status summary of a history entry. Never returns `outputs` or `prompt`."""
status = (entry or {}).get("status") or {}
out = {"status": status.get("status_str", "unknown"), "completed": bool(status.get("completed", False))}
for msg in status.get("messages") or []:
try:
if msg[0] == "execution_error":
d = msg[1] or {}
out["error"] = {
"node_id": d.get("node_id"),
"node_type": d.get("node_type"),
"exception_message": d.get("exception_message"),
}
break
except (IndexError, TypeError, KeyError):
continue
return out
def find_in_queue(running, pending, prompt_id):
"""('running', 0), ('pending', position) or None. Items are tuples (number, prompt_id, ...)."""
for item in running or []:
if len(item) > 1 and item[1] == prompt_id:
return ("running", 0)
for pos, item in enumerate(sorted(pending or [], key=lambda i: i[0])):
if len(item) > 1 and item[1] == prompt_id:
return ("pending", pos)
return None
class RunTracker:
"""Bounded prompt_id -> session_name map (oldest evicted first)."""
def __init__(self, cap=_MAX_TRACKED_RUNS):
self._cap = cap
self._d = OrderedDict()
def add(self, prompt_id, session):
self._d[prompt_id] = session
while len(self._d) > self._cap:
self._d.popitem(last=False)
def get(self, prompt_id):
return self._d.get(prompt_id)
+182
View File
@@ -0,0 +1,182 @@
"""Pure helpers behind POST /config_tester/run and GET /config_tester/run_status."""
import importlib.util
import json
import os
import pytest
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_PKG = os.path.basename(_ROOT)
def _load(name, filename):
spec = importlib.util.spec_from_file_location(f"{_PKG}.{name}_real", os.path.join(_ROOT, filename))
mod = importlib.util.module_from_spec(spec)
mod.__package__ = _PKG
spec.loader.exec_module(mod)
return mod
run_api = _load("run_api", "run_api.py")
# ---- sanitize ---------------------------------------------------------------
@pytest.mark.parametrize("raw,expected", [
("../../etc", "etc"),
("a b/c", "abc"),
("ünï", "ünï"),
("", ""),
(None, ""),
("my-session_1", "my-session_1"),
("..", ""),
])
def test_sanitize(raw, expected):
assert run_api.sanitize_session_name_strict(raw) == expected
# ---- normalize --------------------------------------------------------------
def test_normalize_accepts_all_shapes():
cfg = [{"model": "x"}]
assert run_api.normalize_configs_payload({"configs": cfg}) == {"configs": cfg}
assert run_api.normalize_configs_payload(cfg) == {"configs": cfg}
assert run_api.normalize_configs_payload(json.dumps({"configs": cfg})) == {"configs": cfg}
assert run_api.normalize_configs_payload(json.dumps(cfg)) == {"configs": cfg}
@pytest.mark.parametrize("bad", ["not json", {}, {"configs": []}, 42, [], None, {"configs": "x"}])
def test_normalize_rejects(bad):
with pytest.raises(ValueError):
run_api.normalize_configs_payload(bad)
def test_normalize_preserves_other_keys():
out = run_api.normalize_configs_payload({"configs": [{"a": 1}], "_distribution": {"enabled": True}})
assert out["_distribution"] == {"enabled": True}
# ---- overrides --------------------------------------------------------------
def test_session_precedence():
obj, s = run_api.apply_run_overrides({"configs": [1], "_session_settings": {"session_name": "from_settings"}},
session_name="from_body")
assert s == "from_body" and obj["_session_settings"]["session_name"] == "from_body"
obj, s = run_api.apply_run_overrides({"configs": [1], "_session_settings": {"session_name": "from_settings"}})
assert s == "from_settings"
obj, s = run_api.apply_run_overrides({"configs": [1]})
assert s == "my_session"
assert obj["_session_settings"]["session_name"] == "my_session" # dict created when absent
def test_session_is_sanitised_and_can_end_empty():
_, s = run_api.apply_run_overrides({"configs": [1]}, session_name="../../x y")
assert s == "xy"
_, s = run_api.apply_run_overrides({"configs": [1]}, session_name="///")
assert s == ""
def test_overwrite_only_when_given():
obj, _ = run_api.apply_run_overrides({"configs": [1]})
assert "overwrite_existing" not in obj["_session_settings"]
obj, _ = run_api.apply_run_overrides({"configs": [1]}, overwrite_existing=False)
assert obj["_session_settings"]["overwrite_existing"] is False
obj, _ = run_api.apply_run_overrides({"configs": [1]}, overwrite_existing=True)
assert obj["_session_settings"]["overwrite_existing"] is True
def test_nonce_changes_serialised_string():
a, _ = run_api.apply_run_overrides({"configs": [1]}, session_name="s")
b, _ = run_api.apply_run_overrides({"configs": [1]}, session_name="s", nonce="abc")
assert "_run_nonce" not in a and b["_run_nonce"] == "abc"
assert json.dumps(a) != json.dumps(b)
def test_non_dict_settings_replaced():
obj, s = run_api.apply_run_overrides({"configs": [1], "_session_settings": "junk"}, session_name="s")
assert obj["_session_settings"] == {"session_name": "s"}
# ---- prompt -----------------------------------------------------------------
def test_build_run_prompt_exact():
cs = json.dumps({"configs": [{"a": 1}]}, indent=2)
assert run_api.build_run_prompt(cs, "sess") == {
"1": {"class_type": "UltimateSamplerGrid", "inputs": {"configs_json": cs}},
"2": {"class_type": "UltimateGridDashboard", "inputs": {"session_name": "sess", "dashboard_html": ["1", 0]}},
}
assert json.loads(run_api.build_run_prompt(cs, "s")["1"]["inputs"]["configs_json"]) == {"configs": [{"a": 1}]}
# ---- saved configs ----------------------------------------------------------
def test_saved_state_uses_injected_converter():
calls = []
def fake(state):
calls.append(state)
return json.dumps({"configs": [{"model": "m"}]})
out = run_api.saved_config_to_configs_obj({"config_arrays": [], "x": 1}, fake)
assert out == {"configs": [{"model": "m"}]} and calls[0]["x"] == 1
def test_saved_configs_shape_passes_through():
data = {"configs": [{"a": 1}]}
assert run_api.saved_config_to_configs_obj(data, lambda s: pytest.fail("must not call")) is data
@pytest.mark.parametrize("junk", [{}, {"foo": 1}, [], "x", None])
def test_saved_junk_raises(junk):
with pytest.raises(ValueError):
run_api.saved_config_to_configs_obj(junk, lambda s: "{}")
def test_resolve_saved_config(tmp_path):
(tmp_path / "ok.json").write_text("{}")
assert run_api.resolve_saved_config(str(tmp_path), "ok") == str(tmp_path / "ok.json")
assert run_api.resolve_saved_config(str(tmp_path), "ok.json") == str(tmp_path / "ok.json")
# traversal reduces to the basename inside the dir
assert run_api.resolve_saved_config(str(tmp_path), "../../ok") == str(tmp_path / "ok.json")
with pytest.raises(FileNotFoundError):
run_api.resolve_saved_config(str(tmp_path), "../missing")
with pytest.raises(FileNotFoundError):
run_api.resolve_saved_config(str(tmp_path), "")
# ---- status helpers ---------------------------------------------------------
def test_summarize_history_never_leaks():
entry = {
"prompt": [1, "pid", {"1": {"inputs": {"configs_json": "SECRET"}}}, {}, ["2"]],
"outputs": {"2": {"dashboard_html": ["<html>HUGE</html>"]}},
"status": {"status_str": "error", "completed": False, "messages": [
["execution_start", {"prompt_id": "pid"}],
["execution_error", {"node_id": "1", "node_type": "UltimateSamplerGrid", "exception_message": "boom",
"traceback": ["x"]}],
]},
}
s = run_api.summarize_history(entry)
assert s["status"] == "error" and s["completed"] is False
assert s["error"] == {"node_id": "1", "node_type": "UltimateSamplerGrid", "exception_message": "boom"}
blob = json.dumps(s)
assert "SECRET" not in blob and "HUGE" not in blob and "traceback" not in blob
def test_summarize_history_success_and_malformed():
ok = run_api.summarize_history({"status": {"status_str": "success", "completed": True, "messages": []}})
assert ok == {"status": "success", "completed": True}
assert run_api.summarize_history({})["status"] == "unknown"
run_api.summarize_history({"status": {"messages": [None, [], "x"]}}) # must not raise
def test_find_in_queue_five_and_six_tuples():
running = [(0, "run", {}, {}, ["2"], {})]
pending = [(5, "late", {}, {}, ["2"]), (2, "early", {}, {}, ["2"], {}), (3, "mid", {}, {}, ["2"])]
assert run_api.find_in_queue(running, pending, "run") == ("running", 0)
assert run_api.find_in_queue(running, pending, "early") == ("pending", 0)
assert run_api.find_in_queue(running, pending, "mid") == ("pending", 1)
assert run_api.find_in_queue(running, pending, "late") == ("pending", 2)
assert run_api.find_in_queue(running, pending, "nope") is None
assert run_api.find_in_queue(None, None, "x") is None
def test_run_tracker_is_bounded():
t = run_api.RunTracker(cap=3)
for i in range(5):
t.add(f"p{i}", f"s{i}")
assert t.get("p0") is None and t.get("p1") is None and t.get("p4") == "s4"
+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()