Merge branch 'feat/dashboard-url-api' into integration
This commit is contained in:
+6
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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' }, '*');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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');
|
||||
|
||||
@@ -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
@@ -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
@@ -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)">×</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 | 1-9 favorite</span>
|
||||
<span class="modal-kbd-hint" style="font-size: 9px; color: #444; margin-left: 8px;">← → Space navigate | 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
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user