Skip to content

Server Package

Flask routes, session management, BlueSky server status, and Socket.IO event handlers.

Note

The HTTP route and Socket.IO event handlers are defined inside the register_* functions, so the reference below documents those registration entry points; each handler's method, path/event and JSON payloads are described in the corresponding register_* function's source (viewable via the source toggles).

WebATM.server.routes

WebATM.server.routes

Basic Flask routes for WebATM.

Contains routes for the main page, simulation commands, server configuration, health/status endpoints, and BlueSky file management (uploads, listings, directory browsing, downloads, and deletion).

get_webpack_assets

get_webpack_assets()

Read the webpack manifest and build script tags in load order.

Reads static/dist/manifest.json and returns one <script> tag per bundle — a single bundle in development builds, or the split runtime/vendor/app/main chunks in the correct order for production builds. Falls back to bundle.js when the manifest is missing or unreadable.

Returns:

Type Description
list[str]

HTML <script> tags for the webpack bundles.

Source code in WebATM/server/routes.py
def get_webpack_assets():
    """Read the webpack manifest and build script tags in load order.

    Reads ``static/dist/manifest.json`` and returns one ``<script>`` tag per
    bundle — a single bundle in development builds, or the split
    runtime/vendor/app/main chunks in the correct order for production
    builds. Falls back to ``bundle.js`` when the manifest is missing or
    unreadable.

    Returns:
        list[str]: HTML ``<script>`` tags for the webpack bundles.
    """
    try:
        # Go up one level from server/ to WebATM/ to find static/
        manifest_path = (
            Path(__file__).parent.parent / "static" / "dist" / "manifest.json"
        )

        if not manifest_path.exists():
            # Fallback to single bundle.js if manifest doesn't exist
            return ['<script src="/static/dist/bundle.js"></script>']

        with open(manifest_path) as f:
            manifest = json.load(f)

        # Split production bundles must load in this order; a development
        # manifest simply only contains main.js.
        chunk_order = ("runtime.js", "vendor.js", "app.js", "main.js")
        script_tags = [
            f'<script src="/static/dist/{manifest[chunk]}"></script>'
            for chunk in chunk_order
            if chunk in manifest
        ]

        return (
            script_tags
            if script_tags
            else ['<script src="/static/dist/bundle.js"></script>']
        )

    except Exception as e:
        logger.info(f"Error reading webpack manifest: {e}")
        # Fallback to single bundle.js
        return ['<script src="/static/dist/bundle.js"></script>']

register_basic_routes

register_basic_routes(app, session_manager)

Register the basic Flask routes with the application.

Parameters:

Name Type Description Default
app Flask

Flask application instance.

required
session_manager SessionManager

Session manager used by the status endpoint for session counts.

required
Source code in WebATM/server/routes.py
 178
 179
 180
 181
 182
 183
 184
 185
 186
 187
 188
 189
 190
 191
 192
 193
 194
 195
 196
 197
 198
 199
 200
 201
 202
 203
 204
 205
 206
 207
 208
 209
 210
 211
 212
 213
 214
 215
 216
 217
 218
 219
 220
 221
 222
 223
 224
 225
 226
 227
 228
 229
 230
 231
 232
 233
 234
 235
 236
 237
 238
 239
 240
 241
 242
 243
 244
 245
 246
 247
 248
 249
 250
 251
 252
 253
 254
 255
 256
 257
 258
 259
 260
 261
 262
 263
 264
 265
 266
 267
 268
 269
 270
 271
 272
 273
 274
 275
 276
 277
 278
 279
 280
 281
 282
 283
 284
 285
 286
 287
 288
 289
 290
 291
 292
 293
 294
 295
 296
 297
 298
 299
 300
 301
 302
 303
 304
 305
 306
 307
 308
 309
 310
 311
 312
 313
 314
 315
 316
 317
 318
 319
 320
 321
 322
 323
 324
 325
 326
 327
 328
 329
 330
 331
 332
 333
 334
 335
 336
 337
 338
 339
 340
 341
 342
 343
 344
 345
 346
 347
 348
 349
 350
 351
 352
 353
 354
 355
 356
 357
 358
 359
 360
 361
 362
 363
 364
 365
 366
 367
 368
 369
 370
 371
 372
 373
 374
 375
 376
 377
 378
 379
 380
 381
 382
 383
 384
 385
 386
 387
 388
 389
 390
 391
 392
 393
 394
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
def register_basic_routes(app, session_manager):
    """Register the basic Flask routes with the application.

    Args:
        app (Flask): Flask application instance.
        session_manager (SessionManager): Session manager used by the status
            endpoint for session counts.
    """

    @app.route("/")
    def index():
        """Serve the main web interface page (GET /).

        Returns:
            The rendered ``index.html`` template with webpack script tags and
            the WebATM version, or a 500 error message on failure.
        """
        try:
            from .. import __version__

            webpack_scripts = get_webpack_assets()
            return render_template(
                "index.html",
                webpack_scripts=webpack_scripts,
                webatm_version=__version__,
            )
        except Exception as e:
            return f"Error loading page: {str(e)}", 500

    @app.route("/api/simulation/command", methods=["POST"])
    def send_command():
        """Send a stack command to the simulation (POST /api/simulation/command).

        Expects a JSON body with a ``command`` string, which is forwarded to
        the BlueSky proxy.

        Returns:
            JSON with ``success`` and the echoed ``command``, or a 500 error
            payload on failure.
        """
        try:
            command = (request.get_json(silent=True) or {}).get("command", "")
            success = current_app.bluesky_proxy.send_command(command)
            return jsonify({"success": success, "command": command})
        except Exception:
            return jsonify({"error": "Failed to send command"}), 500

    @app.route("/api/server/config", methods=["GET"])
    def get_server_config():
        """Get the current server configuration (GET /api/server/config).

        Returns:
            JSON with the proxy's ``server_ip`` and ``is_connected`` state,
            or a 500 error payload on failure.
        """
        try:
            return jsonify(
                {
                    "server_ip": getattr(
                        current_app.bluesky_proxy, "server_ip", "localhost"
                    ),
                    "is_connected": getattr(
                        current_app.bluesky_proxy, "is_connected", False
                    ),
                }
            )
        except Exception:
            return jsonify({"error": "Failed to get server config"}), 500

    @app.route("/api/server/config", methods=["POST"])
    def update_server_config():
        """Update server config and reconnect (POST /api/server/config).

        Expects a JSON body with ``server_ip``. Tears down the existing
        BlueSky proxy, creates a fresh proxy instance preserving the
        Socket.IO wiring, connects it to the requested server, re-registers
        the data subscribers, then waits up to 10 seconds for BlueSky nodes
        to be detected before confirming.

        Returns:
            JSON with ``success: True`` and the ``server_ip`` once nodes are
            detected, or a 500 error payload if the connection fails or no
            nodes appear before the timeout.
        """
        try:
            data = request.get_json(silent=True) or {}
            server_ip = data.get("server_ip", "localhost").strip() or "localhost"
            logger.info(f"User requested connection to BlueSky server at {server_ip}")

            from ..proxy import BlueSkyProxy, register_subscribers, set_bluesky_proxy

            # Every (re)connect gets a completely fresh proxy: recreating the
            # ZMQ client is the reliable way to shed any half-dead connection
            # state. Only the Socket.IO wiring carries over. The old proxy is
            # replaced in place (never deleted) so concurrent requests always
            # find a usable current_app.bluesky_proxy.
            old_proxy = getattr(current_app, "bluesky_proxy", None)
            if old_proxy is not None:
                if old_proxy.running:
                    old_proxy.stop_client()
                    time.sleep(0.3)  # let ZMQ teardown settle before reconnecting
                old_proxy.close()

            proxy = BlueSkyProxy()
            proxy.socketio = old_proxy.socketio if old_proxy else None
            proxy.connected_clients = old_proxy.connected_clients if old_proxy else 0
            current_app.bluesky_proxy = proxy
            set_bluesky_proxy(proxy)  # update the global the subscribers use

            proxy.server_ip = server_ip
            proxy.start_client(hostname=server_ip)
            # Subscribers attach to the client start_client just created.
            register_subscribers()

            # Confirm the server is real: wait for node detection.
            timeout = 10.0
            start_time = time.time()
            while time.time() - start_time < timeout:
                if len(proxy.tracked_nodes) > 0:
                    logger.info("BlueSky nodes detected - connection confirmed")
                    return jsonify(
                        {
                            "success": True,
                            "server_ip": server_ip,
                            "message": "Connected to BlueSky remote server hosted by amvlab",
                        }
                    )
                time.sleep(0.1)

            logger.info(
                f"No BlueSky nodes detected after {timeout}s - server may be offline"
            )
            proxy.stop_client()
            return (
                jsonify(
                    {
                        "success": False,
                        "error": f"No BlueSky nodes detected on server {server_ip}. Server may be offline or not configured properly.",
                    }
                ),
                500,
            )
        except Exception as e:
            logger.info(f"Error updating server config: {e}")
            return (
                jsonify(
                    {
                        "success": False,
                        "error": f"Failed to connect to server: {str(e)}",
                    }
                ),
                500,
            )

    @app.route("/api/server/disconnect", methods=["POST"])
    def disconnect_server():
        """Disconnect from the BlueSky server (POST /api/server/disconnect).

        Stops the proxy's client with the ``"manual"`` context; the BlueSky
        server itself is left running.

        Returns:
            JSON with ``success`` and a message, or a 500 error payload on
            failure.
        """
        try:
            if current_app.bluesky_proxy.running:
                logger.info("User requested manual disconnection from BlueSky server")
                current_app.bluesky_proxy.stop_client("manual")
                # Wait a moment for cleanup to complete
                time.sleep(0.5)
                logger.info("BlueSky server disconnected successfully")
            else:
                logger.info(
                    "User requested disconnection, but client was already disconnected"
                )

            return jsonify({"success": True, "message": "Disconnected from server"})
        except Exception as e:
            logger.info(f"Error disconnecting from server: {e}")
            return (
                jsonify({"success": False, "error": f"Failed to disconnect: {str(e)}"}),
                500,
            )

    @app.route("/api/aircraft/models", methods=["GET"])
    def get_aircraft_models():
        """List available 3D aircraft models (GET /api/aircraft/models).

        Scans ``static/models/aircraft`` for ``.gltf``/``.glb`` files and
        maps known filenames to friendly display names.

        Returns:
            JSON with ``models`` (filename, displayName, description,
            fileSize, isDefault) sorted with the default model first, or a
            404/500 error payload.
        """
        try:
            models_dir = Path(__file__).parent.parent / "static" / "models" / "aircraft"

            if not models_dir.exists():
                logger.warning("Aircraft models directory not found")
                return jsonify(
                    {
                        "success": False,
                        "error": "3D aircraft models directory not found",
                        "models": [],
                    }
                ), 404

            # Scan for supported model files
            supported_extensions = {".gltf", ".glb"}

            # Friendly display names, keyed by the lowercased base stem
            # (the filename stem with any "_nologo" suffix stripped). Keep
            # this in sync with CATEGORY_TO_MODEL in aircraftCategories.ts.
            display_name_map = {
                "a320": "Airbus A320",
                "a350": "Airbus A350",
                "a380": "Airbus A380",
                "b737": "Boeing 737",
                "b787": "Boeing 787",
                "evtol": "eVTOL",
                "drone": "Drone",
            }

            # The model used when an aircraft's type is unknown and no model
            # is forced (mirrors DEFAULT_FALLBACK_MODEL in aircraftCategories.ts).
            default_model = "A320.glb"

            models = []

            for model_file in models_dir.iterdir():
                if not (
                    model_file.is_file()
                    and model_file.suffix.lower() in supported_extensions
                ):
                    continue

                # Split the stem into a base name and an optional "no logo"
                # variant so both spell out to a consistent display name.
                stem = model_file.stem
                is_nologo = stem.lower().endswith("_nologo")
                base = stem[: -len("_nologo")] if is_nologo else stem

                display_name = display_name_map.get(base.lower(), base)
                if is_nologo:
                    display_name = f"{display_name} (no logo)"

                models.append(
                    {
                        "filename": model_file.name,
                        "displayName": display_name,
                        "description": f"{display_name} 3D model",
                        "fileSize": model_file.stat().st_size,
                        "isDefault": model_file.name == default_model,
                    }
                )

            # Default model first, then grouped by friendly name with the
            # logo variant ahead of its "(no logo)" counterpart.
            models.sort(
                key=lambda m: (
                    not m["isDefault"],
                    m["displayName"].casefold(),
                )
            )

            logger.debug(
                f"Found {len(models)} aircraft models: {[m['filename'] for m in models]}"
            )

            return jsonify({"success": True, "models": models, "count": len(models)})

        except Exception as e:
            logger.error(f"Error fetching aircraft models: {e}")
            return jsonify(
                {
                    "success": False,
                    "error": f"Failed to fetch aircraft models: {str(e)}",
                    "models": [],
                }
            ), 500

    @app.route("/api/navdata/search", methods=["GET"])
    def search_navdata():
        """Search airports and waypoints by identifier (GET /api/navdata/search).

        Powers the map "go to" box. Backed by the SQLite FTS5 index built
        offline from OurAirports open data (see ``script/navdata/``). Query
        parameters:

        - ``q``: identifier/name prefix to match (required).
        - ``limit``: maximum results (default 10, capped at 50).
        - ``kind``: optional filter — ``airport``, ``heliport`` or
          ``waypoint``.

        Returns:
            JSON with ``results`` (kind, ident, name, lat, lon, rank, score,
            iata) ordered by exact match, kind, and importance; a 503 payload
            when the navdata index has not been built; or a 500 payload on
            search failure.
        """
        try:
            query = (request.args.get("q") or "").strip()
            if not query:
                return jsonify({"success": True, "results": []})

            limit = request.args.get("limit", type=int, default=10)
            limit = max(1, min(limit, 50))
            kind = request.args.get("kind")

            db_path = (
                Path(__file__).parent.parent / "static" / "navdata" / "navdata.sqlite"
            )
            if not db_path.exists():
                # Index hasn't been built yet - degrade gracefully so the UI
                # can show "navdata not available" rather than erroring.
                return jsonify(
                    {
                        "success": False,
                        "error": "navdata index not built",
                        "results": [],
                    }
                ), 503

            # Build a safe FTS5 prefix query: keep only alphanumeric tokens
            # (this also strips any FTS syntax the user might type) and turn
            # each into a prefix term so "heath" matches "Heathrow" and "kse"
            # matches "KSEA". Multiple tokens are implicitly AND-ed.
            import re

            tokens = re.findall(r"[A-Za-z0-9]+", query)
            if not tokens:
                return jsonify({"success": True, "results": []})
            match_expr = " ".join(f"{t}*" for t in tokens)

            import sqlite3

            # Open read-only so a concurrent rebuild can't be corrupted.
            conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
            try:
                conn.row_factory = sqlite3.Row
                sql = (
                    "SELECT n.kind, n.ident, n.name, n.lat, n.lon, n.score, n.rank, "
                    "n.iata, "
                    "(n.ident = ? COLLATE NOCASE) AS exact "
                    "FROM navaids_fts JOIN navaids n ON n.id = navaids_fts.rowid "
                    "WHERE navaids_fts MATCH ?"
                )
                params: list = [query, match_expr]
                if kind in ("airport", "heliport", "waypoint"):
                    sql += " AND n.kind = ?"
                    params.append(kind)
                # Exact ident match first, then a strict kind hierarchy
                # (airports, then heliports, then waypoints), then importance
                # (score), then FTS relevance and shorter idents.
                sql += (
                    " ORDER BY exact DESC, "
                    "CASE n.kind WHEN 'airport' THEN 0 "
                    "WHEN 'heliport' THEN 1 ELSE 2 END, "
                    "n.score DESC, navaids_fts.rank, length(n.ident) LIMIT ?"
                )
                params.append(limit)
                rows = conn.execute(sql, params).fetchall()
            finally:
                conn.close()

            results = [
                {
                    "kind": r["kind"],
                    "ident": r["ident"],
                    "name": r["name"],
                    "lat": r["lat"],
                    "lon": r["lon"],
                    "rank": r["rank"],
                    "score": r["score"],
                    "iata": r["iata"],
                }
                for r in rows
            ]
            return jsonify({"success": True, "results": results})

        except Exception as e:
            logger.error(f"Error searching navdata: {e}")
            return jsonify(
                {"success": False, "error": "navdata search failed", "results": []}
            ), 500

    @app.route("/health")
    def health_check():
        """Health check endpoint for Traefik (GET /health).

        Returns:
            A 200 JSON payload whenever Flask is running, or 503 with the
            error if the handler itself fails.
        """
        try:
            response_data = {
                "status": "healthy",
                "message": "Flask application is running",
                "timestamp": time.time(),
            }

            return jsonify(response_data), 200

        except Exception as e:
            return jsonify({"status": "unhealthy", "error": str(e)}), 503

    @app.route("/status")
    def status_check():
        """Report server, BlueSky and session status (GET /status).

        Probes the BlueSky command/data ports (11000/11001) with a short
        socket timeout, inspects the proxy's connection state and tracked
        nodes, and includes session information from the session manager
        (used externally, e.g. by demo-deploy, for capacity decisions).

        Returns:
            A 200 JSON payload with ``bluesky_server`` and ``session_info``
            sections, or 503 with the error on failure.
        """
        try:
            hostname = getattr(current_app.bluesky_proxy, "server_ip", None)
            listening, _ = probe_bluesky_ports(hostname)
            port_11000_listening = 11000 in listening
            port_11001_listening = 11001 in listening
            bluesky_running = bool(listening)

            # Additional check: if we have a proxy connection, see if it's receiving data
            proxy_running = False
            proxy_connected = False
            has_active_nodes = False
            if hasattr(current_app, "bluesky_proxy"):
                proxy_running = getattr(current_app.bluesky_proxy, "running", False)
                proxy_connected = getattr(
                    current_app.bluesky_proxy, "is_connected", False
                )
                tracked_nodes = getattr(current_app.bluesky_proxy, "tracked_nodes", [])
                has_active_nodes = len(tracked_nodes) > 0

            # Get session information from session manager
            session_info = session_manager.get_session_info()

            response_data = {
                "status": "healthy",
                "bluesky_server": {
                    "ports_accessible": bluesky_running,
                    "port_11000": port_11000_listening,
                    "port_11001": port_11001_listening,
                    "proxy_running": proxy_running,
                    "proxy_connected": proxy_connected,
                    "has_active_nodes": has_active_nodes,
                },
                "session_info": session_info,
                "timestamp": time.time(),
            }

            return jsonify(response_data), 200

        except Exception as e:
            return jsonify({"status": "unhealthy", "error": str(e)}), 503

    # BlueSky File Upload System Routes

    @app.route("/api/bluesky/configure-base-path", methods=["POST"])
    def configure_bluesky_base_path():
        """Configure the BlueSky base directory (POST /api/bluesky/configure-base-path).

        Expects a JSON body with ``base_path``. Validates that the path
        exists, is a directory and is writable, stores it on the app, and
        creates the ``scenario/``, ``plugins/`` and ``output/``
        subdirectories if needed (the same set the integrated build
        pre-creates), so browsing works before BlueSky's first start.

        Returns:
            JSON with the accepted ``base_path`` and ``derived_paths``
            (scenario, plugins, settings, output), or a 400/500 error
            payload.
        """
        try:
            data = request.get_json(silent=True) or {}
            base_path = data.get("base_path", "").strip()

            if not base_path:
                return jsonify(
                    {"success": False, "error": "Base path is required"}
                ), 400

            path_obj = Path(base_path).expanduser().resolve()

            if not path_obj.exists():
                return jsonify(
                    {"success": False, "error": f"Path does not exist: {path_obj}"}
                ), 400

            if not path_obj.is_dir():
                return jsonify(
                    {"success": False, "error": f"Path is not a directory: {path_obj}"}
                ), 400

            if not os.access(str(path_obj), os.W_OK):
                return jsonify(
                    {"success": False, "error": f"Path is not writable: {path_obj}"}
                ), 400

            current_app.bluesky_base_path = str(path_obj)

            try:
                for subdir in ("scenario", "plugins", "output"):
                    (path_obj / subdir).mkdir(exist_ok=True)
                logger.info(
                    f"BlueSky base path configured: {current_app.bluesky_base_path}"
                )

                return jsonify(
                    {
                        "success": True,
                        "base_path": current_app.bluesky_base_path,
                        "derived_paths": {
                            "scenario": str(path_obj / "scenario"),
                            "plugins": str(path_obj / "plugins"),
                            "settings": str(path_obj / "settings.cfg"),
                            "output": str(path_obj / "output"),
                        },
                    }
                )

            except Exception as e:
                return jsonify(
                    {
                        "success": False,
                        "error": f"Could not create subdirectories: {str(e)}",
                    }
                ), 500

        except Exception as e:
            logger.error(f"Error configuring BlueSky base path: {e}")
            return jsonify(
                {"success": False, "error": f"Failed to configure path: {str(e)}"}
            ), 500

    @app.route("/api/bluesky/upload/<file_type>", methods=["POST"])
    def upload_bluesky_file(file_type):
        """Upload a file to a BlueSky directory (POST /api/bluesky/upload/<file_type>).

        Accepts a multipart upload for one of the configured file types —
        ``scenario`` (``.scn``), ``plugins`` (``.py``) or ``settings``
        (``settings.cfg``). Validates the extension and size (50 MB for
        scenarios, 10 MB otherwise), sanitizes the filename, and
        auto-renames on conflicts for types that allow multiple files.

        Args:
            file_type (str): One of ``scenario``, ``plugins``, ``settings``.

        Returns:
            JSON with the stored ``filename`` and ``target_path``, or a
            400/500 error payload.
        """
        try:
            if not hasattr(current_app, "bluesky_base_path"):
                return jsonify(
                    {"success": False, "error": "BlueSky base path not configured"}
                ), 400

            base_path = Path(current_app.bluesky_base_path)

            if file_type not in WRITABLE_FILE_TYPES:
                return jsonify(
                    {"success": False, "error": f"Invalid file type: {file_type}"}
                ), 400

            if "file" not in request.files:
                return jsonify({"success": False, "error": "No file provided"}), 400

            file = request.files["file"]
            if file.filename == "":
                return jsonify({"success": False, "error": "No file selected"}), 400

            config = FILE_TYPES[file_type]

            if not file.filename.lower().endswith(config["extension"]):
                return jsonify(
                    {
                        "success": False,
                        "error": f"Invalid file extension. Expected {config['extension']}",
                    }
                ), 400

            max_size = 50 * 1024 * 1024 if file_type == "scenario" else 10 * 1024 * 1024
            file.seek(0, 2)
            file_size = file.tell()
            file.seek(0)

            if file_size > max_size:
                max_size_mb = max_size // (1024 * 1024)
                return jsonify(
                    {
                        "success": False,
                        "error": f"File too large. Maximum size: {max_size_mb}MB",
                    }
                ), 400

            filename = secure_filename(file.filename)
            if not filename:
                return jsonify({"success": False, "error": "Invalid filename"}), 400

            if file_type == "settings":
                # Single fixed file; a re-upload replaces it.
                target_path = base_path / config["filepath"]
            else:
                target_dir = base_path / config["directory"]
                target_dir.mkdir(exist_ok=True)

                # Auto-rename on conflicts rather than overwriting.
                counter = 1
                target_path = target_dir / filename
                while target_path.exists():
                    new_filename = (
                        f"{Path(filename).stem}_{counter}{Path(filename).suffix}"
                    )
                    target_path = target_dir / new_filename
                    counter += 1
                filename = target_path.name

            file.save(str(target_path))

            logger.info(f"File uploaded successfully: {target_path}")

            return jsonify(
                {
                    "success": True,
                    "filename": filename,
                    "file_type": file_type,
                    "target_path": str(target_path),
                    "message": f"{file_type.title()} file uploaded successfully",
                }
            )

        except Exception as e:
            logger.error(f"Error uploading {file_type} file: {e}")
            return jsonify(
                {"success": False, "error": f"Failed to upload file: {str(e)}"}
            ), 500

    @app.route("/api/bluesky/browse/<file_type>", methods=["GET"])
    @app.route("/api/bluesky/browse/<file_type>/<path:subpath>", methods=["GET"])
    def browse_bluesky_directory(file_type, subpath=""):
        """Browse a BlueSky directory tree (GET /api/bluesky/browse/<file_type>[/<subpath>]).

        Lists folders and files (extension matched case-insensitively) with
        subdirectory navigation and breadcrumbs. The subpath is sanitized
        (no ``..`` components) and resolved paths are verified to stay
        inside the allowed base directory to prevent traversal.

        Args:
            file_type (str): One of ``scenario``, ``plugins``, ``settings``,
                ``output``.
            subpath (str): Optional subdirectory path below the file type's
                base directory.

        Returns:
            JSON with ``files``, ``current_path`` and ``breadcrumbs``, or a
            400/403/500 error payload.
        """
        try:
            if not hasattr(current_app, "bluesky_base_path"):
                return jsonify(
                    {"success": False, "error": "BlueSky base path not configured"}
                ), 400

            base_path = Path(current_app.bluesky_base_path)

            if file_type not in FILE_TYPES:
                return jsonify(
                    {"success": False, "error": f"Invalid file type: {file_type}"}
                ), 400

            config = FILE_TYPES[file_type]

            # For settings, just return the single file (no directory browsing)
            if file_type == "settings":
                files = []
                settings_path = base_path / config["filepath"]
                if settings_path.exists():
                    stat_info = settings_path.stat()
                    files.append(
                        {
                            "filename": "settings.cfg",
                            "size": stat_info.st_size,
                            "modified": stat_info.st_mtime,
                            "type": "file",
                        }
                    )

                return jsonify(
                    {
                        "success": True,
                        "file_type": file_type,
                        "files": files,
                        "current_path": "",
                        "breadcrumbs": [],
                        "base_path": str(base_path),
                    }
                )

            # Sanitize the subpath and verify it stays inside the type's
            # directory (traversal/symlink escapes rejected).
            target_base = base_path / config["directory"]
            current_path_parts = _clean_parts(subpath)
            target_dir, error = _resolve_under(target_base, subpath)
            if error:
                return error

            files = _dir_entries(target_dir, config["extension"])

            breadcrumbs = [{"name": config["directory"], "path": ""}]
            for i, part in enumerate(current_path_parts):
                breadcrumbs.append(
                    {"name": part, "path": "/".join(current_path_parts[: i + 1])}
                )

            return jsonify(
                {
                    "success": True,
                    "file_type": file_type,
                    "files": files,
                    "current_path": "/".join(current_path_parts),
                    "breadcrumbs": breadcrumbs,
                    "base_path": str(base_path),
                }
            )

        except Exception as e:
            logger.error(f"Error browsing {file_type} directory: {e}")
            return jsonify(
                {"success": False, "error": f"Failed to browse directory: {str(e)}"}
            ), 500

    def _validate_output_path(filepath):
        """Validate and resolve a filepath within the output directory.

        Sanitizes the path (no ``..`` components) and verifies the resolved
        target stays inside the output directory and points to an existing
        file.

        Args:
            filepath (str): Requested path relative to the output directory.

        Returns:
            tuple: ``(resolved_path, error_response)``. On failure
                ``resolved_path`` is None and ``error_response`` holds the
                Flask (json, status) response to return.
        """
        if not hasattr(current_app, "bluesky_base_path"):
            return None, (
                jsonify(
                    {"success": False, "error": "BlueSky base path not configured"}
                ),
                400,
            )

        output_base = Path(current_app.bluesky_base_path) / "output"

        if not _clean_parts(filepath):
            return None, (
                jsonify({"success": False, "error": "No file specified"}),
                400,
            )

        resolved_target, error = _resolve_under(output_base, filepath)
        if error:
            return None, error

        if not resolved_target.is_file():
            return None, (
                jsonify({"success": False, "error": "File not found"}),
                404,
            )

        return resolved_target, None

    @app.route("/api/bluesky/output/download/<path:filepath>", methods=["GET"])
    def download_output_file(filepath):
        """Download an output file (GET /api/bluesky/output/download/<filepath>).

        Args:
            filepath (str): Path of the file relative to the output
                directory; validated against traversal.

        Returns:
            The file as an attachment, or a 400/403/404/500 error payload.
        """
        try:
            resolved_path, error = _validate_output_path(filepath)
            if error:
                return error

            return send_file(
                resolved_path,
                as_attachment=True,
                download_name=resolved_path.name,
            )

        except Exception as e:
            logger.error(f"Error downloading output file: {e}")
            return jsonify(
                {"success": False, "error": f"Failed to download file: {str(e)}"}
            ), 500

    @app.route("/api/bluesky/output/content/<path:filepath>", methods=["GET"])
    def get_output_file_content(filepath):
        """Read output-file content (GET /api/bluesky/output/content/<filepath>).

        Supports log streaming: with ``offset`` > 0 the file is read
        incrementally from that byte offset to the end; with offset 0 the
        last ``lines`` lines are tailed for the initial load. If the file
        shrank below the offset (truncated/rewritten between polls), the
        stream restarts with a tail load instead of silently skipping the
        new content. Query parameters:

        - ``offset``: byte offset to read from (0 = tail mode).
        - ``lines``: maximum lines for the initial tail load (default 200).

        Args:
            filepath (str): Path of the file relative to the output
                directory; validated against traversal.

        Returns:
            JSON with ``content``, the new ``offset``, ``total_size`` and
            ``filename``, or a 400/403/404/500 error payload.
        """
        try:
            resolved_path, error = _validate_output_path(filepath)
            if error:
                return error

            offset = request.args.get("offset", type=int, default=0)
            max_lines = request.args.get("lines", type=int, default=200)
            file_size = resolved_path.stat().st_size

            # A file smaller than the poller's offset was truncated or
            # rewritten (e.g. a re-run scenario logging to the same name).
            # The offset points into the old contents, so restart with a
            # tail load instead of pinning the stream at end-of-file, which
            # would silently skip everything the new file already holds.
            if offset > file_size:
                offset = 0

            with open(resolved_path, errors="replace") as f:
                if offset > 0:
                    # Incremental read from offset to end.
                    f.seek(offset)
                    content = f.read()
                else:
                    # Initial (or post-truncation) load: tail the last N lines.
                    content = "".join(f.readlines()[-max_lines:])
                new_offset = f.tell()

            return jsonify(
                {
                    "success": True,
                    "content": content,
                    "offset": new_offset,
                    "total_size": file_size,
                    "filename": resolved_path.name,
                }
            )

        except Exception as e:
            logger.error(f"Error reading output file content: {e}")
            return jsonify(
                {"success": False, "error": f"Failed to read file: {str(e)}"}
            ), 500

    @app.route("/api/bluesky/<file_type>/<path:filename>", methods=["DELETE"])
    def delete_bluesky_file(file_type, filename):
        """Delete a BlueSky file (DELETE /api/bluesky/<file_type>/<path:filename>).

        The filename is used as listed/browsed — it may include subdirectories
        below the type's directory (the browse UI navigates into them) and is
        validated against traversal with the same containment rule as browsing.

        Args:
            file_type (str): One of ``scenario``, ``plugins``, ``settings``.
                For ``settings`` only ``settings.cfg`` may be deleted.
            filename (str): Path of the file to delete, relative to the file
                type's directory.

        Returns:
            JSON confirming the deletion, or a 400/403/404/500 error payload.
        """
        try:
            if not hasattr(current_app, "bluesky_base_path"):
                return jsonify(
                    {"success": False, "error": "BlueSky base path not configured"}
                ), 400

            base_path = Path(current_app.bluesky_base_path)

            if file_type not in WRITABLE_FILE_TYPES:
                return jsonify(
                    {"success": False, "error": f"Invalid file type: {file_type}"}
                ), 400

            if file_type == "settings":
                if filename != "settings.cfg":
                    return jsonify(
                        {"success": False, "error": "Can only delete settings.cfg"}
                    ), 400
                target_path = base_path / FILE_TYPES["settings"]["filepath"]
            else:
                target_dir = base_path / FILE_TYPES[file_type]["directory"]
                target_path, error = _resolve_under(target_dir, filename)
                if error:
                    return error

            # Only real files are deletable — never directories.
            if not target_path.is_file():
                return jsonify(
                    {"success": False, "error": f"File not found: {filename}"}
                ), 404

            target_path.unlink()

            logger.info(f"File deleted successfully: {target_path}")

            return jsonify(
                {
                    "success": True,
                    "filename": filename,
                    "file_type": file_type,
                    "message": f"{file_type.title()} file deleted successfully",
                }
            )

        except Exception as e:
            logger.error(f"Error deleting {file_type} file: {e}")
            return jsonify(
                {"success": False, "error": f"Failed to delete file: {str(e)}"}
            ), 500

    @app.route("/api/bluesky/filestatus", methods=["GET"])
    def get_bluesky_file_status():
        """Get file-management configuration status (GET /api/bluesky/filestatus).

        Returns:
            JSON with ``configured``, the ``base_path`` and its
            ``derived_paths``, plus existence/writability flags, or a 500
            error payload.
        """
        try:
            if not hasattr(current_app, "bluesky_base_path"):
                return jsonify(
                    {"configured": False, "base_path": None, "derived_paths": {}}
                )

            base_path = Path(current_app.bluesky_base_path)

            return jsonify(
                {
                    "configured": True,
                    "base_path": str(base_path),
                    "derived_paths": {
                        "scenario": str(base_path / "scenario"),
                        "plugins": str(base_path / "plugins"),
                        "settings": str(base_path / "settings.cfg"),
                        "output": str(base_path / "output"),
                    },
                    "path_exists": base_path.exists(),
                    "path_writable": os.access(str(base_path), os.W_OK)
                    if base_path.exists()
                    else False,
                }
            )

        except Exception as e:
            logger.error(f"Error getting BlueSky file status: {e}")
            return jsonify(
                {"success": False, "error": f"Failed to get status: {str(e)}"}
            ), 500

WebATM.server.session_manager

WebATM.server.session_manager

Track active WebATM client sessions.

Connection liveness is handled by Socket.IO's built-in ping/pong (configured in app.py); this module only tracks which sessions are connected so the /status endpoint can report an accurate count.

SessionManager

SessionManager()

Track active client sessions by ID.

Attributes:

Name Type Description
active_sessions set[str]

IDs of the currently connected sessions.

Source code in WebATM/server/session_manager.py
def __init__(self):
    self.active_sessions: set[str] = set()

add_session

add_session(session_id: str) -> bool

Start tracking a session.

Parameters:

Name Type Description Default
session_id str

Unique session identifier.

required

Returns:

Type Description
bool

True if the session was added, False if it already exists.

Source code in WebATM/server/session_manager.py
def add_session(self, session_id: str) -> bool:
    """Start tracking a session.

    Args:
        session_id (str): Unique session identifier.

    Returns:
        bool: True if the session was added, False if it already exists.
    """
    if session_id in self.active_sessions:
        return False
    self.active_sessions.add(session_id)
    return True

remove_session

remove_session(session_id: str) -> bool

Stop tracking a session.

Parameters:

Name Type Description Default
session_id str

Session identifier to remove.

required

Returns:

Type Description
bool

True if the session was removed, False if it was not found.

Source code in WebATM/server/session_manager.py
def remove_session(self, session_id: str) -> bool:
    """Stop tracking a session.

    Args:
        session_id (str): Session identifier to remove.

    Returns:
        bool: True if the session was removed, False if it was not found.
    """
    if session_id not in self.active_sessions:
        return False
    self.active_sessions.remove(session_id)
    return True

get_session_count

get_session_count() -> int

Return the number of active sessions.

Source code in WebATM/server/session_manager.py
def get_session_count(self) -> int:
    """Return the number of active sessions."""
    return len(self.active_sessions)

get_session_info

get_session_info() -> dict[str, int]

Return session information for status reporting.

Returns:

Type Description
dict

{"active_sessions": <count>}, the shape read from /status by external capacity controllers (demo-deploy).

Source code in WebATM/server/session_manager.py
def get_session_info(self) -> dict[str, int]:
    """Return session information for status reporting.

    Returns:
        dict: ``{"active_sessions": <count>}``, the shape read from
            ``/status`` by external capacity controllers (demo-deploy).
    """
    return {"active_sessions": self.get_session_count()}

WebATM.server.bluesky_server_status

WebATM.server.bluesky_server_status

Provide BlueSky server status monitoring routes.

This module checks whether a BlueSky server is reachable by probing its command (11000) and data (11001) ports, and registers the Flask route that exposes this status to the web client.

is_port_listening

is_port_listening(
    port: int,
    timeout: float = 1.0,
    hostname: str | None = None,
) -> bool

Check if a TCP port is listening for connections.

Parameters:

Name Type Description Default
port int

Port number to check.

required
timeout float

Connection timeout in seconds.

1.0
hostname str | None

Hostname to check. Defaults to localhost when None.

None

Returns:

Type Description
bool

True if the port is listening, False otherwise.

Source code in WebATM/server/bluesky_server_status.py
def is_port_listening(
    port: int, timeout: float = 1.0, hostname: str | None = None
) -> bool:
    """Check if a TCP port is listening for connections.

    Args:
        port (int): Port number to check.
        timeout (float): Connection timeout in seconds.
        hostname (str | None): Hostname to check. Defaults to ``localhost``
            when ``None``.

    Returns:
        bool: True if the port is listening, False otherwise.
    """
    try:
        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
            sock.settimeout(timeout)
            return sock.connect_ex((hostname or "localhost", port)) == 0
    except OSError:
        # e.g. DNS failure (socket.gaierror) for an unresolvable hostname;
        # the context manager still closes the socket.
        return False

probe_bluesky_ports

probe_bluesky_ports(
    hostname: str | None = None, timeout: float = 0.5
) -> tuple[list[int], str]

Probe the BlueSky ports and summarize the result.

The server is considered running when at least one port is listening.

Parameters:

Name Type Description Default
hostname str | None

Host to probe. Defaults to localhost when None.

None
timeout float

Per-port connection timeout in seconds.

0.5

Returns:

Type Description
tuple[list[int], str]

The listening ports (empty when none) and a human-readable status message.

Source code in WebATM/server/bluesky_server_status.py
def probe_bluesky_ports(
    hostname: str | None = None, timeout: float = 0.5
) -> tuple[list[int], str]:
    """Probe the BlueSky ports and summarize the result.

    The server is considered running when at least one port is listening.

    Args:
        hostname (str | None): Host to probe. Defaults to ``localhost`` when
            ``None``.
        timeout (float): Per-port connection timeout in seconds.

    Returns:
        tuple[list[int], str]: The listening ports (empty when none) and a
            human-readable status message.
    """
    listening = [p for p in BLUESKY_PORTS if is_port_listening(p, timeout, hostname)]
    if listening:
        message = f"Server running (Ports: {', '.join(map(str, listening))})"
    else:
        message = "Server not accessible (Ports not listening)"
    return listening, message

register_server_status_routes

register_server_status_routes(app)

Register BlueSky server status routes with the Flask app.

Parameters:

Name Type Description Default
app Flask

Flask application instance.

required
Source code in WebATM/server/bluesky_server_status.py
def register_server_status_routes(app):
    """Register BlueSky server status routes with the Flask app.

    Args:
        app (Flask): Flask application instance.
    """

    @app.route("/api/server/status", methods=["GET", "POST"])
    def get_server_status():
        """Report whether the BlueSky server is reachable.

        Handles ``GET``/``POST /api/server/status``. Accepts an optional
        ``hostname`` via query string (GET) or JSON body (POST); when omitted,
        falls back to the proxy's currently configured server IP, then to
        ``localhost``. Probes the BlueSky command (11000) and data (11001)
        ports on that host.

        Returns:
            Response: JSON with ``status`` (``"success"``), ``running`` (bool),
                ``message`` (listening ports or failure reason), and
                ``hostname`` (the host that was probed). On unexpected errors,
                JSON with ``status`` (``"error"``) and ``message``, with HTTP
                500.
        """
        try:
            if request.method == "POST":
                hostname = (request.get_json(silent=True) or {}).get("hostname")
            else:
                hostname = request.args.get("hostname")

            if not hostname:
                hostname = getattr(current_app.bluesky_proxy, "server_ip", None)
            if not hostname:
                hostname = "localhost"

            listening, message = probe_bluesky_ports(hostname)
            return jsonify(
                {
                    "status": "success",
                    "running": bool(listening),
                    "message": message,
                    "hostname": hostname,
                }
            )
        except Exception as e:
            return jsonify({"status": "error", "message": str(e)}), 500

WebATM.server.socket_handlers

WebATM.server.socket_handlers

Socket.IO event handlers for WebATM.

Handles all WebSocket communication between the web client and the Flask server, including connection management, commands, node management, and BlueSky events.

register_socket_handlers

register_socket_handlers(socketio, session_manager)

Register all Socket.IO event handlers.

Parameters:

Name Type Description Default
socketio SocketIO

The Flask-SocketIO instance.

required
session_manager SessionManager

Session manager for tracking connected web clients.

required
Source code in WebATM/server/socket_handlers.py
def register_socket_handlers(socketio, session_manager):
    """Register all Socket.IO event handlers.

    Args:
        socketio (SocketIO): The Flask-SocketIO instance.
        session_manager (SessionManager): Session manager for tracking
            connected web clients.
    """

    @socketio.on("connect")
    def on_connect(auth):
        """Handle a new web client connection (``connect`` event).

        Creates and tracks a session, increments the connected-client
        counter, and sends the ``initial_data`` snapshot and the active
        node's shapes.

        Args:
            auth: Socket.IO auth payload (unused).

        Returns:
            False to reject the connection if the session cannot be
            tracked, otherwise None.
        """
        session_id = str(uuid.uuid4())
        session["session_id"] = session_id

        if not session_manager.add_session(session_id):
            logger.info(f"Rejected connection with duplicate session id {session_id}")
            return False

        current_app.bluesky_proxy.connected_clients += 1
        logger.info(
            f"Web client connected: {session_id} (total: {current_app.bluesky_proxy.connected_clients})"
        )

        try:
            emit("initial_data", current_app.bluesky_proxy.get_current_data())
            # Shapes created before this client connected. node_info is NOT
            # sent here: it would show "Connected (No Data)" before the user
            # connects; it flows naturally once data arrives.
            current_app.bluesky_proxy._emit_active_node_poly_data()
        except Exception as e:
            logger.info(f"Error sending initial data to {session_id}: {e}")

    @socketio.on("disconnect")
    def on_disconnect(reason):
        """Handle a web client disconnect (``disconnect`` event).

        Removes the session from the session manager and decrements the
        connected-client counter. The counter is only decremented for
        connections whose session was actually tracked, keeping it
        symmetric with ``on_connect`` (a connection rejected there never
        incremented it).

        Args:
            reason: Disconnect reason supplied by Flask-SocketIO.
        """
        session_id = session.get("session_id")
        if not (session_id and session_manager.remove_session(session_id)):
            logger.debug(f"Web client disconnected (untracked session): {session_id}")
            return

        current_app.bluesky_proxy.connected_clients = max(
            0, current_app.bluesky_proxy.connected_clients - 1
        )
        logger.info(
            f"Web client disconnected: {session_id} "
            f"(total: {current_app.bluesky_proxy.connected_clients}, reason: {reason})"
        )

    @socketio.on("command")
    def on_command(data):
        """Forward a stack command from the web client (``command`` event).

        Args:
            data (dict): Payload with a ``command`` string.

        Emits a ``command_result`` event with the success flag back to the
        sender.
        """
        command = (data or {}).get("command", "")
        success = current_app.bluesky_proxy.send_command(command)
        try:
            emit("command_result", {"success": success, "command": command})
        except Exception as e:
            logger.info(f"Error emitting command result: {e}")

    @socketio.on("set_active_node")
    def on_set_active_node(data):
        """Switch the active simulation node (``set_active_node`` event).

        The frontend sends hex-string node IDs; the handler looks up the
        original binary ID in the proxy's tracked nodes before delegating to
        ``actnode``.

        Args:
            data (dict): Payload with the hex-string ``node_id``.
        """
        node_id = (data or {}).get("node_id")
        if not node_id:
            return

        node_data = current_app.bluesky_proxy.tracked_nodes.get(node_id)
        if node_data is None:
            logger.debug(
                f"Could not find node ID for: {node_id} "
                f"(available: {list(current_app.bluesky_proxy.tracked_nodes.keys())})"
            )
            return

        binary_node_id = node_data.get("node_id")
        logger.info(f"Setting active node to: {node_id} (binary: {binary_node_id})")
        try:
            current_app.bluesky_proxy.actnode(binary_node_id)
        except Exception as e:
            logger.info(f"Error setting active node {node_id}: {e}")

    @socketio.on("get_nodes")
    def on_get_nodes():
        """Emit current node information (``get_nodes`` event).

        Triggers a ``node_info`` broadcast with the tracked nodes/servers.
        """
        try:
            current_app.bluesky_proxy._emit_node_info()
        except Exception as e:
            logger.info(f"Error getting nodes: {e}")

    @socketio.on("add_nodes")
    def on_add_nodes(data):
        """Add simulation nodes to a server (``add_nodes`` event).

        Args:
            data (dict): Payload with ``count`` (default 1) and an optional
                ``server_id`` string, encoded to bytes before delegation.
        """
        try:
            count = (data or {}).get("count", 1)
            server_id = (data or {}).get("server_id")
            if server_id and isinstance(server_id, str):
                server_id = server_id.encode()
            current_app.bluesky_proxy.addnodes(count, server_id=server_id)
            logger.info(f"Added {count} nodes to server {server_id}")
        except Exception as e:
            logger.info(f"Error adding nodes: {e}")

    @socketio.on("del_node")
    def on_del_node(data):
        """Terminate a single simulation node (``del_node`` event).

        The frontend sends hex-string node IDs; the handler looks up the
        original binary ID in the proxy's tracked nodes before delegating to
        ``delnode``, which sends a DELNODE message to the owning server. The
        node's removal flows back through the normal node-removed pipeline
        (tracked-nodes cleanup, active-node failover, ``node_info`` emission).

        Args:
            data (dict): Payload with the hex-string ``node_id``.
        """
        node_id = (data or {}).get("node_id")
        if not node_id:
            return

        node_data = current_app.bluesky_proxy.tracked_nodes.get(node_id)
        if node_data is None:
            logger.debug(
                f"Could not find node ID for: {node_id} "
                f"(available: {list(current_app.bluesky_proxy.tracked_nodes.keys())})"
            )
            return

        binary_node_id = node_data.get("node_id")
        logger.info(
            f"Requesting node termination: {node_id} (binary: {binary_node_id})"
        )
        try:
            current_app.bluesky_proxy.delnode(binary_node_id)
        except Exception as e:
            logger.info(f"Error deleting node {node_id}: {e}")