import os import json import gzip import hashlib import threading from typing import NamedTuple RESOURCE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "resources") # JS load order (dependencies first!). logic_standalone.js must stay LAST. JS_FILES = ( "logic_state.js", # Global variables "logic_utils.js", # API & Helper functions "logic_ratings.js", # Pairwise review: pure logic (normalisers, Bradley-Terry, pairing, scoreboard) "logic_wfparams.js", # Workflow Sweep: wf_params helpers (pure; must load before logic_ui/logic_pipeline) "logic_dragdrop.js", # Revise-modal image drag/send helpers (issue #22; pure, node-testable) "logic_ui.js", # DOM Creation, Modals, JSON Bars "logic_upscale_modal.js", # Upscale Modal (preset management, async upscale) "logic_virtual.js", # The Virtual Scroll Engine & Viewport "logic_pipeline.js", # Sorting & Filtering Logic "logic_compare.js", # Pairwise review: compare overlay, votes, scoreboard modal "logic_events.js", # Event Listeners (Message, Keyboard) "logic_init.js", # Initialization "logic_standalone.js" # Standalone-page mode (own websocket, run API); must stay LAST ) CSS_FILES = ("report.css",) # Fixed bundle routes (no path parameters; the ?v= query is only compared, never used for lookup). BUNDLE_ROUTES = {"js": "/config_tester/dashboard_bundle_js", "css": "/config_tester/dashboard_bundle_css"} BUNDLE_CONTENT_TYPES = {"js": "text/javascript", "css": "text/css"} class Bundle(NamedTuple): kind: str sig: tuple text: str body: bytes gzip: bytes hash: str _bundle_cache = {} _bundle_lock = threading.Lock() def _files_for(kind): if kind == "js": return JS_FILES if kind == "css": return CSS_FILES raise KeyError(kind) def _signature(names): """(name, mtime_ns, size) per resource file: cheap change detection for hot reload.""" sig = [("dir", RESOURCE_DIR, 0)] for n in names: st = os.stat(os.path.join(RESOURCE_DIR, n)) sig.append((n, st.st_mtime_ns, st.st_size)) return tuple(sig) def _build_text(kind): names = _files_for(kind) if kind == "css": with open(os.path.join(RESOURCE_DIR, names[0]), "r", encoding="utf-8") as f: return f.read() text = "" for n in names: with open(os.path.join(RESOURCE_DIR, n), "r", encoding="utf-8") as f: text += f"\n/* --- {n} --- */\n" text += f.read() + "\n" return text def get_dashboard_bundle(kind): """The page-independent JS or CSS bundle, rebuilt only when a resource file changed. Thread-safe (called from the aiohttp loop and from the execution thread). Raises KeyError for an unknown kind and OSError when a resource file is missing. """ names = _files_for(kind) with _bundle_lock: sig = _signature(names) cached = _bundle_cache.get(kind) if cached is not None and cached.sig == sig: return cached text = _build_text(kind) body = text.encode("utf-8") bundle = Bundle(kind, sig, text, body, gzip.compress(body, 9, mtime=0), hashlib.sha1(body).hexdigest()[:20]) _bundle_cache[kind] = bundle return bundle def bundle_url(kind): return f"{BUNDLE_ROUTES[kind]}?v={get_dashboard_bundle(kind).hash}" def get_html_template(title, manifest_data, node_id, standalone=False, inline_manifest=False, inline_assets=None): """Build the dashboard page. By default the page is a SHELL: no item data is inlined (an empty `fullManifest` flagged `__uscg_lazy__`), and the SPA fetches `/config_tester/manifest?session=
{e}
" if inline_assets: css_tag = f"" js_tag = f"" else: css_tag = f'' js_tag = f'' # 3. Inject Data wrapped_json = f"/*__JSON_START__*/\nlet fullManifest = {json_str};\n/*__JSON_END__*/" # 4. Final Replacements (asset tags LAST so resource text is never placeholder-substituted) 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_TAG__", css_tag) .replace("__JS_TAG__", js_tag) return final_html