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 }) {