diff --git a/src/rf_mapper/web/app.py b/src/rf_mapper/web/app.py index 7754604..4d5cf86 100644 --- a/src/rf_mapper/web/app.py +++ b/src/rf_mapper/web/app.py @@ -2,6 +2,7 @@ import json import os +import subprocess import threading import time from datetime import datetime @@ -1598,6 +1599,130 @@ def create_app(config: Config | None = None) -> Flask: else: return jsonify({"error": "Peer not found"}), 404 + # ==================== Node Control API ==================== + + def _ssh_node_command(scanner_id: str, command: str, timeout: int = 30) -> tuple[bool, str]: + """Execute a command on a peer node via SSH. + + Uses scanner_id as SSH hostname (relies on ~/.ssh/config for ports). + + Returns: + Tuple of (success, output/error message) + """ + try: + result = subprocess.run( + ["ssh", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes", + scanner_id, command], + capture_output=True, + text=True, + timeout=timeout + ) + output = result.stdout.strip() or result.stderr.strip() + return result.returncode == 0, output + except subprocess.TimeoutExpired: + return False, "SSH command timed out" + except Exception as e: + return False, str(e) + + @app.route("/api/nodes//start", methods=["POST"]) + def api_node_start(scanner_id: str): + """Start rf-mapper on a peer node via SSH""" + rf_config = app.config["RF_CONFIG"] + if not rf_config.scanner.is_master: + return jsonify({"error": "Only master node can control peers"}), 403 + + db = app.config.get("DATABASE") + if db: + peer = db.get_peer(scanner_id) + if not peer: + return jsonify({"error": f"Unknown peer: {scanner_id}"}), 404 + + print(f"[Node] Starting rf-mapper on {scanner_id}...") + cmd = "cd ~/git/rf-mapper && source venv/bin/activate && rf-mapper start" + success, output = _ssh_node_command(scanner_id, cmd, timeout=60) + + if success: + print(f"[Node] {scanner_id} started successfully") + return jsonify({"status": "started", "scanner_id": scanner_id, "output": output}) + else: + print(f"[Node] Failed to start {scanner_id}: {output}") + return jsonify({"error": "Failed to start", "details": output}), 500 + + @app.route("/api/nodes//stop", methods=["POST"]) + def api_node_stop(scanner_id: str): + """Stop rf-mapper on a peer node via SSH""" + rf_config = app.config["RF_CONFIG"] + if not rf_config.scanner.is_master: + return jsonify({"error": "Only master node can control peers"}), 403 + + db = app.config.get("DATABASE") + if db: + peer = db.get_peer(scanner_id) + if not peer: + return jsonify({"error": f"Unknown peer: {scanner_id}"}), 404 + + print(f"[Node] Stopping rf-mapper on {scanner_id}...") + cmd = "cd ~/git/rf-mapper && source venv/bin/activate && rf-mapper stop" + success, output = _ssh_node_command(scanner_id, cmd, timeout=30) + + if success: + print(f"[Node] {scanner_id} stopped successfully") + return jsonify({"status": "stopped", "scanner_id": scanner_id, "output": output}) + else: + print(f"[Node] Failed to stop {scanner_id}: {output}") + return jsonify({"error": "Failed to stop", "details": output}), 500 + + @app.route("/api/nodes//restart", methods=["POST"]) + def api_node_restart(scanner_id: str): + """Restart rf-mapper on a peer node via SSH""" + rf_config = app.config["RF_CONFIG"] + if not rf_config.scanner.is_master: + return jsonify({"error": "Only master node can control peers"}), 403 + + db = app.config.get("DATABASE") + if db: + peer = db.get_peer(scanner_id) + if not peer: + return jsonify({"error": f"Unknown peer: {scanner_id}"}), 404 + + print(f"[Node] Restarting rf-mapper on {scanner_id}...") + cmd = "cd ~/git/rf-mapper && source venv/bin/activate && rf-mapper restart" + success, output = _ssh_node_command(scanner_id, cmd, timeout=60) + + if success: + print(f"[Node] {scanner_id} restarted successfully") + return jsonify({"status": "restarted", "scanner_id": scanner_id, "output": output}) + else: + print(f"[Node] Failed to restart {scanner_id}: {output}") + return jsonify({"error": "Failed to restart", "details": output}), 500 + + @app.route("/api/nodes//status", methods=["GET"]) + def api_node_status(scanner_id: str): + """Get rf-mapper status on a peer node via SSH""" + rf_config = app.config["RF_CONFIG"] + if not rf_config.scanner.is_master: + return jsonify({"error": "Only master node can control peers"}), 403 + + db = app.config.get("DATABASE") + if db: + peer = db.get_peer(scanner_id) + if not peer: + return jsonify({"error": f"Unknown peer: {scanner_id}"}), 404 + + cmd = "cd ~/git/rf-mapper && source venv/bin/activate && rf-mapper status" + success, output = _ssh_node_command(scanner_id, cmd, timeout=15) + + # Parse status from output + reachable = success or "not running" in output.lower() + running = success and "running" in output.lower() and "not running" not in output.lower() + + return jsonify({ + "scanner_id": scanner_id, + "reachable": reachable, + "running": running, + "output": output + }) + @app.route("/api/sync/devices", methods=["GET"]) def api_sync_devices_get(): """Get devices for sync (called by peers)"""