From 006fbc1ddebba23f787f35fad59a5b3d0826dcce Mon Sep 17 00:00:00 2001 From: matei jordache Date: Wed, 25 Mar 2026 13:20:41 -0700 Subject: [PATCH] fix windows numpy import and add node timestamps --- README.md | 1 + backend/__init__.py | 18 ++++++ backend/execution.py | 9 ++- backend/frontend_build.py | 116 +++++++++++++++++++++++++++++++++++ backend/server.py | 38 ++++++++++-- frontend/src/App.jsx | 8 +++ frontend/src/CustomNode.jsx | 16 +++++ frontend/src/styles.css | 16 +++++ frontend/vite.config.js | 1 + tests/test_frontend_build.py | 40 ++++++++++++ tests/test_numpy_compat.py | 8 +++ 11 files changed, 263 insertions(+), 8 deletions(-) create mode 100644 backend/frontend_build.py create mode 100644 tests/test_frontend_build.py create mode 100644 tests/test_numpy_compat.py diff --git a/README.md b/README.md index 999d091..6a1d8bb 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,7 @@ http://127.0.0.1:5173 Notes: - The frontend dev server proxies API and WebSocket requests to the backend. +- If you open the backend directly in a browser instead of the Vite dev server, argonode now refreshes `frontend/dist` automatically when checked-out frontend sources are newer, such as after a `git pull`. - If you want the frontend accessible from other devices on your LAN, run: ```powershell diff --git a/backend/__init__.py b/backend/__init__.py index e69de29..f22815b 100644 --- a/backend/__init__.py +++ b/backend/__init__.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +import numpy as np + + +def _apply_numpy_compat_aliases() -> None: + """Restore removed NumPy scalar aliases still used by some dependencies.""" + aliases = { + "complex": complex, + "float": float, + "int": int, + } + for name, value in aliases.items(): + if not hasattr(np, name): + setattr(np, name, value) + + +_apply_numpy_compat_aliases() diff --git a/backend/execution.py b/backend/execution.py index 718fc40..e058664 100644 --- a/backend/execution.py +++ b/backend/execution.py @@ -23,6 +23,7 @@ The engine: from __future__ import annotations import uuid from collections import defaultdict, deque +from time import perf_counter from typing import Any, Callable from backend.node_registry import NODE_CLASS_MAPPINGS @@ -45,7 +46,7 @@ class ExecutionEngine: self, prompt: dict[str, dict], on_node_start: Callable[[str], None] | None = None, - on_node_done: Callable[[str], None] | None = None, + on_node_done: Callable[[str, float], None] | None = None, on_preview: Callable[[str, str], None] | None = None, on_table: Callable[[str, list], None] | None = None, on_mesh: Callable[[str, dict], None] | None = None, @@ -60,7 +61,7 @@ class ExecutionEngine: ---------- prompt : workflow dict (node_id → {class_type, inputs}) on_node_start : called with node_id just before a node executes - on_node_done : called with node_id just after a node executes + on_node_done : called with (node_id, elapsed_ms) just after a node executes on_preview : called with (node_id, data_uri) when a display node runs on_table : called with (node_id, table_list) when PrintTable runs on_overlay : called with (node_id, data_uri) for interactive overlays @@ -96,7 +97,9 @@ class ExecutionEngine: instance = cls() func = getattr(instance, cls.FUNCTION) + start_time = perf_counter() result = func(**inputs) + elapsed_ms = (perf_counter() - start_time) * 1000.0 # Nodes must return a tuple; coerce single values just in case if not isinstance(result, tuple): @@ -110,7 +113,7 @@ class ExecutionEngine: self._auto_preview(cls, node_id, result, on_preview, on_table) if on_node_done: - on_node_done(node_id) + on_node_done(node_id, elapsed_ms) return node_outputs diff --git a/backend/frontend_build.py b/backend/frontend_build.py new file mode 100644 index 0000000..1e190bd --- /dev/null +++ b/backend/frontend_build.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +import logging +import os +import subprocess +import threading +from pathlib import Path + +_BUILD_LOCK = threading.Lock() + + +class FrontendBuildError(RuntimeError): + """Raised when the source frontend could not be rebuilt.""" + + +def _latest_mtime(root: Path) -> float: + if not root.exists(): + return 0.0 + return max( + (path.stat().st_mtime for path in root.rglob("*") if path.is_file()), + default=0.0, + ) + + +def source_frontend_mtime(frontend_dir: Path) -> float: + tracked_files = ( + frontend_dir / "index.html", + frontend_dir / "package.json", + frontend_dir / "package-lock.json", + frontend_dir / "vite.config.js", + ) + mtimes = [path.stat().st_mtime for path in tracked_files if path.exists()] + mtimes.append(_latest_mtime(frontend_dir / "src")) + mtimes.append(_latest_mtime(frontend_dir / "public")) + return max(mtimes, default=0.0) + + +def dist_frontend_mtime(dist_dir: Path) -> float: + if not (dist_dir / "index.html").exists(): + return 0.0 + return _latest_mtime(dist_dir) + + +def frontend_dist_is_stale(frontend_dir: Path, dist_dir: Path) -> bool: + return dist_frontend_mtime(dist_dir) < source_frontend_mtime(frontend_dir) + + +def _npm_executable() -> str: + return "npm.cmd" if os.name == "nt" else "npm" + + +def _format_build_output(stdout: str, stderr: str) -> str: + combined = "\n".join(part.strip() for part in (stdout, stderr) if part.strip()) + if not combined: + return "" + lines = combined.splitlines() + if len(lines) > 40: + lines = lines[-40:] + return "\n".join(lines) + + +def _build_failure_hint(detail: str) -> str: + if "spawn EPERM" not in detail: + return "" + return ( + "Windows note: this build hit `spawn EPERM`, which usually means the current Node.js " + "install is not cooperating with esbuild. Node 20 LTS is the safest option here. " + "After switching, run `npm --prefix frontend install` and try again." + ) + + +def ensure_frontend_dist_ready( + project_root: Path, + frontend_dir: Path, + dist_dir: Path, + logger: logging.Logger | None = None, +) -> bool: + if not frontend_dist_is_stale(frontend_dir, dist_dir): + return False + + with _BUILD_LOCK: + if not frontend_dist_is_stale(frontend_dir, dist_dir): + return False + + if logger is not None: + logger.info("Frontend sources changed; rebuilding frontend/dist before serving the web app.") + + result = subprocess.run( + [_npm_executable(), "run", "build"], + cwd=str(project_root), + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + detail = _format_build_output(result.stdout, result.stderr) + message = ( + "Frontend build failed while refreshing frontend/dist. " + "Run `npm run build` from the repo root to inspect the full error." + ) + hint = _build_failure_hint(detail) + if hint: + message = f"{message}\n\n{hint}" + if detail: + message = f"{message}\n\n{detail}" + raise FrontendBuildError(message) + + if not (dist_dir / "index.html").exists(): + raise FrontendBuildError( + "Frontend rebuild finished, but frontend/dist/index.html is still missing." + ) + + if logger is not None: + logger.info("Frontend rebuild complete.") + + return True diff --git a/backend/server.py b/backend/server.py index 1c69c7f..dec4caf 100644 --- a/backend/server.py +++ b/backend/server.py @@ -17,6 +17,7 @@ WebSocket message types sent to clients {"type": "preview", "data": {"node_id": "...", "image": "data:..."}} {"type": "table", "data": {"node_id": "...", "rows": [...]}} {"type": "scalar", "data": {"node_id": "...", "value": 1.23, "unit": "nm"}} +{"type": "node_timing", "data": {"node_id": "...", "elapsed_ms": 12.34}} {"type": "execution_error", "data": {"node_id": "...", "message": "..."}} {"type": "execution_complete", "data": {"prompt_id": "..."}} """ @@ -25,15 +26,18 @@ from __future__ import annotations import asyncio import json import logging +import sys from pathlib import Path from aiohttp import web, WSMsgType +from backend.frontend_build import FrontendBuildError, ensure_frontend_dist_ready from backend.runtime_paths import ( ensure_runtime_dirs, frontend_dir, frontend_dist_dir, input_dir, output_dir, + project_root, ) log = logging.getLogger(__name__) @@ -136,13 +140,30 @@ def create_app(loop: asyncio.AbstractEventLoop) -> web.Application: # ------------------------------------------------------------------ async def index(request: web.Request) -> web.Response: - # Serve Vite build output if available, else raw frontend + if not getattr(sys, "frozen", False): + try: + await loop.run_in_executor( + None, + lambda: ensure_frontend_dist_ready( + project_root(), + FRONTEND_DIR, + DIST_DIR, + logger=log, + ), + ) + except FrontendBuildError as exc: + log.error("Unable to refresh frontend build: %s", exc) + return web.Response(status=500, text=str(exc), content_type="text/plain") + if (DIST_DIR / "index.html").exists(): return web.FileResponse(DIST_DIR / "index.html") - if (FRONTEND_DIR / "index.html").exists(): - return web.FileResponse(FRONTEND_DIR / "index.html") - raise web.HTTPInternalServerError( - reason="Frontend build not found. Run `npm run build` before launching the packaged app." + return web.Response( + status=500, + text=( + "Frontend build not found. Run `npm run build` from the repo root, " + "or use `npm run dev` for the Vite development server." + ), + content_type="text/plain", ) async def get_nodes(request: web.Request) -> web.Response: @@ -264,12 +285,19 @@ def create_app(loop: asyncio.AbstractEventLoop) -> web.Application: def on_start(node_id: str) -> None: broadcast({"type": "executing", "data": {"node": node_id, "prompt_id": prompt_id}}) + def on_done(node_id: str, elapsed_ms: float) -> None: + broadcast({ + "type": "node_timing", + "data": {"node_id": node_id, "elapsed_ms": elapsed_ms}, + }) + try: await loop.run_in_executor( None, lambda: engine.execute( prompt, on_node_start=on_start, + on_node_done=on_done, on_preview=on_preview, on_table=on_table, on_mesh=on_mesh, diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index dd7033f..280d8df 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -492,6 +492,10 @@ function Flow() { console.log('[argonode] WS:', msg.type, msg.data?.node_id || msg.data?.node || ''); switch (msg.type) { case 'execution_start': + setNodes((ns) => ns.map((n) => ({ + ...n, + data: { ...n.data, processingTimeMs: null }, + }))); setStatus({ text: 'Running workflow…', level: 'info' }); break; case 'executing': @@ -518,6 +522,9 @@ function Flow() { }, }); break; + case 'node_timing': + updateNodeData(msg.data.node_id, { processingTimeMs: msg.data.elapsed_ms }); + break; case 'mesh3d': updateNodeData(msg.data.node_id, { meshData: msg.data.mesh }); break; @@ -696,6 +703,7 @@ function Flow() { meshData: null, overlay: null, scalarValue: null, + processingTimeMs: null, }, }; diff --git a/frontend/src/CustomNode.jsx b/frontend/src/CustomNode.jsx index d4038bb..ed2f0d4 100644 --- a/frontend/src/CustomNode.jsx +++ b/frontend/src/CustomNode.jsx @@ -326,6 +326,16 @@ function formatScalarDisplay(scalarValue) { }; } +function formatProcessingTime(value) { + const ms = Number(value); + if (!Number.isFinite(ms) || ms < 0) return null; + if (ms < 1) return `${ms.toFixed(2)} ms`; + if (ms < 10) return `${ms.toFixed(1)} ms`; + if (ms < 1000) return `${Math.round(ms)} ms`; + if (ms < 10000) return `${(ms / 1000).toFixed(2)} s`; + return `${(ms / 1000).toFixed(1)} s`; +} + function getSourceTypeForInput(store, nodeId, inputName) { const targetHandle = `input::${inputName}::`; const edge = store.edges?.find((e) => e.target === nodeId && e.targetHandle?.startsWith(targetHandle)); @@ -428,6 +438,7 @@ function CustomNode({ id, data }) { const ctx = useContext(NodeContext); const def = data.definition; const scalarDisplay = formatScalarDisplay(data.scalarValue); + const processingTimeText = formatProcessingTime(data.processingTimeMs); // Parse inputs into data handles and widgets const required = def.input.required || {}; @@ -699,6 +710,11 @@ function CustomNode({ id, data }) { )} + {processingTimeText && ( +
+ {processingTimeText} +
+ )} ); diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 64c42aa..4e82a5e 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -150,6 +150,8 @@ html, body, #root { .node-body { padding: 4px 0; + display: flex; + flex-direction: column; } .node-warning { @@ -628,6 +630,20 @@ html, body, #root { text-align: right !important; } +.node-benchmark { + align-self: flex-end; + margin: 8px 10px 4px; + padding: 3px 7px; + border: 1px solid rgba(148, 163, 184, 0.28); + border-radius: 999px; + background: rgba(15, 23, 42, 0.92); + color: #94a3b8; + font-family: "SF Mono", "Fira Code", monospace; + font-size: 10px; + line-height: 1; + font-variant-numeric: tabular-nums lining-nums; +} + /* ── Node resize handles ───────────────────────────────────────────── */ .node-resize-line { border-color: #90caf9 !important; diff --git a/frontend/vite.config.js b/frontend/vite.config.js index eac01c1..3072098 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -4,6 +4,7 @@ import react from '@vitejs/plugin-react'; export default defineConfig({ plugins: [react()], server: { + host: true, port: 5173, proxy: { '/nodes': 'http://127.0.0.1:8188', diff --git a/tests/test_frontend_build.py b/tests/test_frontend_build.py new file mode 100644 index 0000000..f4d94ef --- /dev/null +++ b/tests/test_frontend_build.py @@ -0,0 +1,40 @@ +import os +from pathlib import Path + +from backend.frontend_build import frontend_dist_is_stale + + +def _write_with_mtime(path: Path, content: str, timestamp: float) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + os.utime(path, (timestamp, timestamp)) + + +def test_frontend_dist_is_stale_when_dist_is_missing(tmp_path: Path): + frontend_dir = tmp_path / "frontend" + dist_dir = frontend_dir / "dist" + + _write_with_mtime(frontend_dir / "src" / "main.jsx", "export default 1;", 200.0) + + assert frontend_dist_is_stale(frontend_dir, dist_dir) is True + + +def test_frontend_dist_is_stale_when_source_is_newer(tmp_path: Path): + frontend_dir = tmp_path / "frontend" + dist_dir = frontend_dir / "dist" + + _write_with_mtime(dist_dir / "index.html", "", 100.0) + _write_with_mtime(frontend_dir / "src" / "App.jsx", "export default 1;", 200.0) + + assert frontend_dist_is_stale(frontend_dir, dist_dir) is True + + +def test_frontend_dist_is_current_when_dist_is_newer(tmp_path: Path): + frontend_dir = tmp_path / "frontend" + dist_dir = frontend_dir / "dist" + + _write_with_mtime(frontend_dir / "src" / "App.jsx", "export default 1;", 100.0) + _write_with_mtime(dist_dir / "assets" / "app.js", "console.log('ok');", 200.0) + _write_with_mtime(dist_dir / "index.html", "", 200.0) + + assert frontend_dist_is_stale(frontend_dir, dist_dir) is False diff --git a/tests/test_numpy_compat.py b/tests/test_numpy_compat.py new file mode 100644 index 0000000..a13faa1 --- /dev/null +++ b/tests/test_numpy_compat.py @@ -0,0 +1,8 @@ +import backend # noqa: F401 +import numpy as np + + +def test_numpy_compat_aliases_are_available_after_backend_import(): + assert np.complex is complex + assert np.float is float + assert np.int is int