🚧 Web UI
This commit is contained in:
+78
-2
@@ -38,7 +38,7 @@ from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from .app import build_app
|
||||
from .app import WebUIConfig, build_app
|
||||
from .modules import ChannelMismatch, ModuleUnknown
|
||||
from .scenario.dsl import load_node_modules, load_yaml, validate_scenario
|
||||
from .scenario.engine import ScenarioEngine, ScenarioError
|
||||
@@ -47,6 +47,49 @@ from .scenario.report import result_summary, write_csv, write_jsonl
|
||||
log = logging.getLogger("wago_sim.cli")
|
||||
|
||||
|
||||
async def start_webui(app, cfg: WebUIConfig):
|
||||
"""Start the web UI's Uvicorn server on the running event loop.
|
||||
|
||||
Runs on the same loop as the Modbus TCP server (spec §10.3) so UI
|
||||
writes and Modbus requests never race on the datastore. The socket
|
||||
is bound eagerly so 'port in use' surfaces as a clean CLI error.
|
||||
"""
|
||||
import uvicorn
|
||||
|
||||
from .webui.app import build_webui
|
||||
|
||||
fastapi_app = build_webui(
|
||||
app, user=cfg.user, password=cfg.password, readonly=cfg.readonly
|
||||
)
|
||||
# Bind eagerly: a taken port raises OSError here (clean CLI error)
|
||||
# instead of failing asynchronously inside the uvicorn task.
|
||||
try:
|
||||
prebound = await asyncio.get_running_loop().create_server(
|
||||
lambda: None, "0.0.0.0", cfg.port
|
||||
)
|
||||
except OSError as exc:
|
||||
_fail(f"cannot bind web UI port {cfg.port}: {exc.strerror or exc}")
|
||||
prebound.close()
|
||||
await prebound.wait_closed()
|
||||
config = uvicorn.Config(
|
||||
fastapi_app, host="0.0.0.0", port=cfg.port, log_level="warning"
|
||||
)
|
||||
server = uvicorn.Server(config)
|
||||
# serve() blocks; run it as a task so _serve keeps its stop-event wait
|
||||
task = asyncio.create_task(server.serve())
|
||||
# poll until the server reports started (or fails) so startup errors
|
||||
# surface here as a clean CLI error
|
||||
for _ in range(100): # up to ~2 s
|
||||
if server.started or task.done():
|
||||
break
|
||||
await asyncio.sleep(0.02)
|
||||
if not server.started:
|
||||
task.cancel()
|
||||
_fail(f"web UI failed to start on port {cfg.port}")
|
||||
server._serve_task = task # keep a ref for graceful shutdown
|
||||
return server
|
||||
|
||||
|
||||
def _build_logger(verbose: bool) -> None:
|
||||
# Stream logs unbuffered & single-line so they work in docker/systemd
|
||||
logging.basicConfig(
|
||||
@@ -137,8 +180,23 @@ def _prepare_report_path(path: str, option: str) -> Path:
|
||||
async def _serve(args: argparse.Namespace) -> int:
|
||||
node_config = _load_yaml_file(args.node, "node config")
|
||||
modules = load_node_modules(node_config)
|
||||
app = build_app(modules=modules, host=args.host, port=args.port)
|
||||
web_cfg = None
|
||||
if args.web:
|
||||
web_cfg = WebUIConfig(
|
||||
enabled=True,
|
||||
port=args.web_port,
|
||||
user=_env("WAGO_SIM_WEB_USER", "admin"),
|
||||
password=_env("WAGO_SIM_WEB_PASSWORD", "simu"),
|
||||
readonly=_env_flag("WAGO_SIM_WEB_READONLY"),
|
||||
)
|
||||
app = build_app(modules=modules, host=args.host, port=args.port, web=web_cfg,
|
||||
node_config_path=args.node)
|
||||
await app.server.start()
|
||||
web_server = None
|
||||
if web_cfg is not None:
|
||||
web_server = await start_webui(app, web_cfg)
|
||||
print(f"Web UI on http://{'127.0.0.1' if args.host == '0.0.0.0' else args.host}"
|
||||
f":{web_cfg.port} (user: {web_cfg.user})")
|
||||
print(f"Simulating WAGO 750-362 with {len(modules)} modules "
|
||||
f"on {args.host}:{args.port} (Ctrl+C to stop)")
|
||||
|
||||
@@ -157,6 +215,14 @@ async def _serve(args: argparse.Namespace) -> int:
|
||||
except asyncio.CancelledError:
|
||||
pass # SIGINT via asyncio.run's handler
|
||||
finally:
|
||||
if web_server is not None:
|
||||
task = getattr(web_server, "_serve_task", None)
|
||||
web_server.should_exit = True
|
||||
if task is not None:
|
||||
try:
|
||||
await asyncio.wait_for(task, timeout=5.0)
|
||||
except (asyncio.TimeoutError, asyncio.CancelledError):
|
||||
task.cancel()
|
||||
await app.server.stop()
|
||||
return 0
|
||||
|
||||
@@ -242,6 +308,16 @@ def main(argv: list[str] | None = None) -> int:
|
||||
"--port", type=_port_type, default=_env_port("WAGO_SIM_PORT", 502),
|
||||
help="listen port 1-65535 (or env: WAGO_SIM_PORT)",
|
||||
)
|
||||
serve.add_argument(
|
||||
"--web", action=argparse.BooleanOptionalAction,
|
||||
default=not _env_flag("WAGO_SIM_NO_WEB"),
|
||||
help="serve the web UI alongside Modbus (default: on; --no-web disables; "
|
||||
"env WAGO_SIM_NO_WEB=1)",
|
||||
)
|
||||
serve.add_argument(
|
||||
"--web-port", type=_port_type, default=_env_port("WAGO_SIM_WEB_PORT", 8080),
|
||||
help="web UI port (or env: WAGO_SIM_WEB_PORT; default 8080)",
|
||||
)
|
||||
serve.set_defaults(func=_serve)
|
||||
|
||||
runp = sub.add_parser("run", help="run a scenario headless (no server)")
|
||||
|
||||
Reference in New Issue
Block a user