diff --git a/agents/g1/.env.example b/agents/g1/.env.example index e9d3b39..b1c4a9e 100644 --- a/agents/g1/.env.example +++ b/agents/g1/.env.example @@ -53,3 +53,13 @@ LOGS_INTERVAL=60 PROJECT_LOG_CONTAINER=auto PROJECT_LOG_PATH= PROJECT_LOG_LABEL= + +# ── remote dashboard (register the Sanad web UI for the fleet to embed) ─────── +# The agent probes these ports on the robot, finds the Sanad dashboard, and +# POSTs its URL to /{sn}/remote (kind=web) — no changes to the Sanad app. +REMOTE_ENABLE=1 +REMOTE_PORTS=8001,8014,8011,8012,8013,8000,8080 +REMOTE_KIND=web +# REMOTE_HOST= # default: the robot's primary LAN IP (fleet-reachable) +# REMOTE_URL= # pin an explicit URL (e.g. a public reverse-tunnel) — wins over discovery +REMOTE_INTERVAL=60 diff --git a/agents/g1/sanad_api_g1.py b/agents/g1/sanad_api_g1.py index b39db9e..7550677 100644 --- a/agents/g1/sanad_api_g1.py +++ b/agents/g1/sanad_api_g1.py @@ -140,6 +140,14 @@ class Config: alert_endpoint: str logs_endpoint: str logs_interval: float + # remote dashboard (register the Sanad UI URL for the fleet to embed) + remote_enable: bool + remote_endpoint: str + remote_kind: str + remote_host: str + remote_ports: str + remote_url: str + remote_interval: float # project logs (e.g. the robot's Sanad app) shipped alongside agent logs project_log_container: str project_log_path: str @@ -195,6 +203,13 @@ class Config: alert_endpoint=_env("ALERT_ENDPOINT", "/api/v1/fleet/ingest/{sn}/alert"), logs_endpoint=_env("LOGS_ENDPOINT", "/api/v1/fleet/ingest/{sn}/logs"), logs_interval=float(_env("LOGS_INTERVAL", "60")), + remote_enable=_env_bool("REMOTE_ENABLE", True), + remote_endpoint=_env("REMOTE_ENDPOINT", "/api/v1/fleet/ingest/{sn}/remote"), + remote_kind=_env("REMOTE_KIND", "web"), + remote_host=_env("REMOTE_HOST", ""), + remote_ports=_env("REMOTE_PORTS", "8001,8014,8011,8012,8013,8000,8080"), + remote_url=_env("REMOTE_URL", ""), # explicit URL (e.g. a public tunnel) wins + remote_interval=float(_env("REMOTE_INTERVAL", "60")), project_log_container=_env("PROJECT_LOG_CONTAINER", "auto"), project_log_path=_env("PROJECT_LOG_PATH", ""), project_log_label=_env("PROJECT_LOG_LABEL", ""), @@ -220,6 +235,9 @@ class Config: def logs_url(self) -> str: return self.server_url + self.logs_endpoint.format(sn=self.sn) + def remote_url_ep(self) -> str: + return self.server_url + self.remote_endpoint.format(sn=self.sn) + def auth_headers(self) -> Dict[str, str]: return {"Authorization": f"Bearer {self.device_token}"} @@ -1295,6 +1313,78 @@ def logs_loop(cfg: Config, session: requests.Session) -> None: pass +# --------------------------------------------------------------------------- # +# remote dashboard — discover the Sanad web UI and register its URL for the +# fleet dashboard to embed ("full dashboard through the fleet API"). No changes +# to the Sanad app: we only probe its port and POST the URL to /{sn}/remote. +# --------------------------------------------------------------------------- # +_REMOTE_STAT: Dict[str, Any] = {"url": None, "port": None, "kind": None, "ok": None} + + +def _primary_ip() -> str: + import socket + try: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.connect(("1.1.1.1", 80)) + ip = s.getsockname()[0] + s.close() + return ip + except Exception: + return "127.0.0.1" + + +def discover_dashboard(cfg: Config) -> Optional[Dict[str, Any]]: + """Find the Sanad dashboard: probe each candidate port on localhost; the one + that returns 200 with a 'Sanad'/dashboard page wins. Returns {url, port}.""" + if cfg.remote_url: # explicit URL (e.g. a tunnel) overrides + return {"url": cfg.remote_url, "port": None} + host = cfg.remote_host or _primary_ip() # the LAN IP the fleet can reach + for tok in cfg.remote_ports.split(","): + tok = tok.strip() + if not tok.isdigit(): + continue + port = int(tok) + try: + r = requests.get(f"http://127.0.0.1:{port}/", timeout=2) + except requests.RequestException: + continue + if r.status_code == 200 and ("sanad" in r.text.lower() or "dashboard" in r.text.lower()): + return {"url": f"http://{host}:{port}", "port": port} + return None + + +def register_remote(cfg: Config, session: requests.Session) -> None: + if not cfg.remote_enable: + return + d = discover_dashboard(cfg) + if not d: + _REMOTE_STAT.update(url=None, port=None, ok=None) + return + body = {"sn": cfg.sn, "name": cfg.name, "kind": cfg.remote_kind, + "url": d["url"], "label": f"{cfg.name} — Sanad Dashboard", + "port": d["port"], "ts": int(time.time())} + try: + r = session.post(cfg.remote_url_ep(), json=body, headers=cfg.auth_headers(), + timeout=cfg.http_timeout, verify=cfg.verify_tls) + _REMOTE_STAT.update(url=d["url"], port=d["port"], kind=cfg.remote_kind, ok=bool(r.ok)) + if r.ok: + log.info("remote dashboard registered: %s -> HTTP %s", d["url"], r.status_code) + else: + log.warning("remote register failed: HTTP %s %s", r.status_code, r.text[:150]) + except requests.RequestException as e: + _REMOTE_STAT.update(url=d["url"], port=d["port"], kind=cfg.remote_kind, ok=False) + log.debug("remote register failed: %s", e) + + +def remote_loop(cfg: Config, session: requests.Session) -> None: + while True: + try: + register_remote(cfg, session) + except Exception as e: + log.debug("remote loop: %s", e) + time.sleep(cfg.remote_interval) + + _ALERT_SEEN: set = set() @@ -1407,6 +1497,8 @@ def build_telemetry(cfg: Config, mac: str, reader: Optional[DDSReader], "project_logs": (_PROJECT_TAIL.label if _PROJECT_TAIL is not None and _PROJECT_TAIL.active else None), # e.g. "sanadr1-logs"; null = no project found + "remote": (dict(_REMOTE_STAT) if _REMOTE_STAT.get("url") else None), + # Sanad dashboard URL registered for the fleet UI "alerts": dict(_ALERTS_STAT), # alert status (sent, last, last_time, ok) "time": _now_str(), # full date+time of this post "started_at": _STARTED["now"], # when this agent run started @@ -1516,6 +1608,12 @@ def main(argv: Optional[List[str]] = None) -> int: if args.once or args.dry_run: # one map pass first so the telemetry "map" field reflects it map_sync_once(cfg, session, force=args.force, dry_run=args.dry_run) + if cfg.remote_enable and not args.dry_run: + register_remote(cfg, session) + elif cfg.remote_enable: + d = discover_dashboard(cfg) + if d: + _REMOTE_STAT.update(url=d["url"], port=d["port"], kind=cfg.remote_kind, ok=None) if args.once: one_telemetry() ship_logs(cfg, session) @@ -1525,9 +1623,11 @@ def main(argv: Optional[List[str]] = None) -> int: time.sleep(min(cfg.poll_interval, 1.0)) return 0 - # loop mode: map sync + log shipping in background threads + # loop mode: map sync + log shipping + remote registration in bg threads threading.Thread(target=map_loop, args=(cfg, session), daemon=True).start() threading.Thread(target=logs_loop, args=(cfg, session), daemon=True).start() + if cfg.remote_enable: + threading.Thread(target=remote_loop, args=(cfg, session), daemon=True).start() log.info("telemetry every %.1fs; map check every %.0fs; logs every %.0fs (Ctrl-C to stop)", cfg.poll_interval, cfg.map_poll_interval, cfg.logs_interval) while True: diff --git a/agents/go2/.env.example b/agents/go2/.env.example index bb2613c..41206e7 100644 --- a/agents/go2/.env.example +++ b/agents/go2/.env.example @@ -59,3 +59,13 @@ LOGS_INTERVAL=60 PROJECT_LOG_CONTAINER=auto PROJECT_LOG_PATH= PROJECT_LOG_LABEL= + +# ── remote dashboard (register the Sanad web UI for the fleet to embed) ─────── +# The agent probes these ports on the robot, finds the Sanad dashboard, and +# POSTs its URL to /{sn}/remote (kind=web) — no changes to the Sanad app. +REMOTE_ENABLE=1 +REMOTE_PORTS=8001,8014,8011,8012,8013,8000,8080 +REMOTE_KIND=web +# REMOTE_HOST= # default: the robot's primary LAN IP (fleet-reachable) +# REMOTE_URL= # pin an explicit URL (e.g. a public reverse-tunnel) — wins over discovery +REMOTE_INTERVAL=60 diff --git a/agents/go2/sanad_api_go2.py b/agents/go2/sanad_api_go2.py index 3d51d5d..08675bb 100644 --- a/agents/go2/sanad_api_go2.py +++ b/agents/go2/sanad_api_go2.py @@ -123,6 +123,13 @@ class Config: alert_endpoint: str logs_endpoint: str logs_interval: float + remote_enable: bool + remote_endpoint: str + remote_kind: str + remote_host: str + remote_ports: str + remote_url: str + remote_interval: float # project logs (e.g. the robot's Sanad app) shipped alongside agent logs project_log_container: str project_log_path: str @@ -174,6 +181,13 @@ class Config: alert_endpoint=_env("ALERT_ENDPOINT", "/api/v1/fleet/ingest/{sn}/alert"), logs_endpoint=_env("LOGS_ENDPOINT", "/api/v1/fleet/ingest/{sn}/logs"), logs_interval=float(_env("LOGS_INTERVAL", "60")), + remote_enable=_env_bool("REMOTE_ENABLE", True), + remote_endpoint=_env("REMOTE_ENDPOINT", "/api/v1/fleet/ingest/{sn}/remote"), + remote_kind=_env("REMOTE_KIND", "web"), + remote_host=_env("REMOTE_HOST", ""), + remote_ports=_env("REMOTE_PORTS", "8001,8014,8011,8012,8013,8000,8080"), + remote_url=_env("REMOTE_URL", ""), + remote_interval=float(_env("REMOTE_INTERVAL", "60")), project_log_container=_env("PROJECT_LOG_CONTAINER", "auto"), project_log_path=_env("PROJECT_LOG_PATH", ""), project_log_label=_env("PROJECT_LOG_LABEL", ""), @@ -197,6 +211,9 @@ class Config: def logs_url(self) -> str: return self.server_url + self.logs_endpoint.format(sn=self.sn) + def remote_url_ep(self) -> str: + return self.server_url + self.remote_endpoint.format(sn=self.sn) + def auth_headers(self) -> Dict[str, str]: return {"Authorization": f"Bearer {self.device_token}"} @@ -1214,6 +1231,103 @@ def logs_loop(cfg: Config, session: requests.Session) -> None: pass +# --------------------------------------------------------------------------- # +# remote dashboard (discover Sanad web UI, register its URL via /{sn}/remote) +# --------------------------------------------------------------------------- # +_REMOTE_STAT: Dict[str, Any] = {"url": None, "port": None, "kind": None, "ok": None} + + +def _primary_ip() -> str: + import socket + try: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.connect(("1.1.1.1", 80)) + ip = s.getsockname()[0] + s.close() + return ip + except Exception: + return "127.0.0.1" + + +def discover_dashboard(cfg: Config) -> Optional[Dict[str, Any]]: + """Find the Sanad dashboard: probe each candidate port on localhost; the one + that returns 200 with a 'Sanad'/dashboard page wins. Returns {url, port}.""" + if cfg.remote_url: # explicit URL (e.g. a tunnel) overrides + return {"url": cfg.remote_url, "port": None} + host = cfg.remote_host or _primary_ip() # the LAN IP the fleet can reach + for tok in cfg.remote_ports.split(","): + tok = tok.strip() + if not tok.isdigit(): + continue + port = int(tok) + try: + r = requests.get(f"http://127.0.0.1:{port}/", timeout=2) + except requests.RequestException: + continue + if r.status_code == 200 and ("sanad" in r.text.lower() or "dashboard" in r.text.lower()): + return {"url": f"http://{host}:{port}", "port": port} + return None + + +def register_remote(cfg: Config, session: requests.Session) -> None: + if not cfg.remote_enable: + return + d = discover_dashboard(cfg) + if not d: + _REMOTE_STAT.update(url=None, port=None, ok=None) + return + body = {"sn": cfg.sn, "name": cfg.name, "kind": cfg.remote_kind, + "url": d["url"], "label": f"{cfg.name} — Sanad Dashboard", + "port": d["port"], "ts": int(time.time())} + try: + r = session.post(cfg.remote_url_ep(), json=body, headers=cfg.auth_headers(), + timeout=cfg.http_timeout, verify=cfg.verify_tls) + _REMOTE_STAT.update(url=d["url"], port=d["port"], kind=cfg.remote_kind, ok=bool(r.ok)) + if r.ok: + log.info("remote dashboard registered: %s -> HTTP %s", d["url"], r.status_code) + else: + log.warning("remote register failed: HTTP %s %s", r.status_code, r.text[:150]) + except requests.RequestException as e: + _REMOTE_STAT.update(url=d["url"], port=d["port"], kind=cfg.remote_kind, ok=False) + log.debug("remote register failed: %s", e) + + +def remote_loop(cfg: Config, session: requests.Session) -> None: + while True: + try: + register_remote(cfg, session) + except Exception as e: + log.debug("remote loop: %s", e) + time.sleep(cfg.remote_interval) + + +_ALERT_SEEN: set = set() + + +def send_alerts(cfg: Config, session: requests.Session, faults: List[str]) -> None: + """POST each NEW fault (rising edge) to /{sn}/alert. Faults are strings.""" + global _ALERT_SEEN + current = set(faults) + new = current - _ALERT_SEEN + _ALERT_SEEN = current + for f in sorted(new): + try: + r = session.post(cfg.alert_url(), + json={"sn": cfg.sn, "name": cfg.name, + "alert": f, "message": f, "ts": int(time.time())}, + headers=cfg.auth_headers(), + timeout=cfg.http_timeout, verify=cfg.verify_tls) + _ALERTS_STAT.update(last=f, last_time=_now_str(), ok=bool(r.ok)) + if r.ok: + _ALERTS_STAT["sent"] += 1 + log.info("alert sent: %s -> HTTP %s", f, r.status_code) + except requests.RequestException as e: + _ALERTS_STAT.update(last=f, last_time=_now_str(), ok=False) + log.debug("alert send failed (%s): %s", f, e) + + + + _ALERT_SEEN: set = set() @@ -1320,6 +1434,7 @@ def build_telemetry(cfg: Config, mac: str, reader: Optional[DDSReader], "project_logs": (_PROJECT_TAIL.label if _PROJECT_TAIL is not None and _PROJECT_TAIL.active else None), # e.g. "sanadr1-logs"; null = no project found + "remote": (dict(_REMOTE_STAT) if _REMOTE_STAT.get("url") else None), "alerts": dict(_ALERTS_STAT), # alert status (sent, last, last_time, ok) "time": _now_str(), # full date+time of this post "started_at": _STARTED["now"], # when this agent run started @@ -1431,6 +1546,8 @@ def main(argv: Optional[List[str]] = None) -> int: threading.Thread(target=map_loop, args=(cfg, session), daemon=True).start() threading.Thread(target=logs_loop, args=(cfg, session), daemon=True).start() + if cfg.remote_enable: + threading.Thread(target=remote_loop, args=(cfg, session), daemon=True).start() log.info("loop every %.1fs; map every %.0fs; logs every %.0fs (Ctrl-C to stop)", cfg.poll_interval, cfg.map_poll_interval, cfg.logs_interval) while True: diff --git a/agents/r1/.env.example b/agents/r1/.env.example index 60c4bb2..5a84fb3 100644 --- a/agents/r1/.env.example +++ b/agents/r1/.env.example @@ -65,3 +65,13 @@ LOGS_INTERVAL=60 PROJECT_LOG_CONTAINER=auto PROJECT_LOG_PATH= PROJECT_LOG_LABEL= + +# ── remote dashboard (register the Sanad web UI for the fleet to embed) ─────── +# The agent probes these ports on the robot, finds the Sanad dashboard, and +# POSTs its URL to /{sn}/remote (kind=web) — no changes to the Sanad app. +REMOTE_ENABLE=1 +REMOTE_PORTS=8001,8014,8011,8012,8013,8000,8080 +REMOTE_KIND=web +# REMOTE_HOST= # default: the robot's primary LAN IP (fleet-reachable) +# REMOTE_URL= # pin an explicit URL (e.g. a public reverse-tunnel) — wins over discovery +REMOTE_INTERVAL=60 diff --git a/agents/r1/sanad_api_r1.py b/agents/r1/sanad_api_r1.py index 61f8df8..9a2e8cb 100644 --- a/agents/r1/sanad_api_r1.py +++ b/agents/r1/sanad_api_r1.py @@ -140,6 +140,14 @@ class Config: alert_endpoint: str logs_endpoint: str logs_interval: float + # remote dashboard (register the Sanad UI URL for the fleet to embed) + remote_enable: bool + remote_endpoint: str + remote_kind: str + remote_host: str + remote_ports: str + remote_url: str + remote_interval: float # project logs (e.g. the robot's Sanad app) shipped alongside agent logs project_log_container: str project_log_path: str @@ -195,6 +203,13 @@ class Config: alert_endpoint=_env("ALERT_ENDPOINT", "/api/v1/fleet/ingest/{sn}/alert"), logs_endpoint=_env("LOGS_ENDPOINT", "/api/v1/fleet/ingest/{sn}/logs"), logs_interval=float(_env("LOGS_INTERVAL", "60")), + remote_enable=_env_bool("REMOTE_ENABLE", True), + remote_endpoint=_env("REMOTE_ENDPOINT", "/api/v1/fleet/ingest/{sn}/remote"), + remote_kind=_env("REMOTE_KIND", "web"), + remote_host=_env("REMOTE_HOST", ""), + remote_ports=_env("REMOTE_PORTS", "8001,8014,8011,8012,8013,8000,8080"), + remote_url=_env("REMOTE_URL", ""), # explicit URL (e.g. a public tunnel) wins + remote_interval=float(_env("REMOTE_INTERVAL", "60")), project_log_container=_env("PROJECT_LOG_CONTAINER", "auto"), project_log_path=_env("PROJECT_LOG_PATH", ""), project_log_label=_env("PROJECT_LOG_LABEL", ""), @@ -220,6 +235,9 @@ class Config: def logs_url(self) -> str: return self.server_url + self.logs_endpoint.format(sn=self.sn) + def remote_url_ep(self) -> str: + return self.server_url + self.remote_endpoint.format(sn=self.sn) + def auth_headers(self) -> Dict[str, str]: return {"Authorization": f"Bearer {self.device_token}"} @@ -1295,6 +1313,78 @@ def logs_loop(cfg: Config, session: requests.Session) -> None: pass +# --------------------------------------------------------------------------- # +# remote dashboard — discover the Sanad web UI and register its URL for the +# fleet dashboard to embed ("full dashboard through the fleet API"). No changes +# to the Sanad app: we only probe its port and POST the URL to /{sn}/remote. +# --------------------------------------------------------------------------- # +_REMOTE_STAT: Dict[str, Any] = {"url": None, "port": None, "kind": None, "ok": None} + + +def _primary_ip() -> str: + import socket + try: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.connect(("1.1.1.1", 80)) + ip = s.getsockname()[0] + s.close() + return ip + except Exception: + return "127.0.0.1" + + +def discover_dashboard(cfg: Config) -> Optional[Dict[str, Any]]: + """Find the Sanad dashboard: probe each candidate port on localhost; the one + that returns 200 with a 'Sanad'/dashboard page wins. Returns {url, port}.""" + if cfg.remote_url: # explicit URL (e.g. a tunnel) overrides + return {"url": cfg.remote_url, "port": None} + host = cfg.remote_host or _primary_ip() # the LAN IP the fleet can reach + for tok in cfg.remote_ports.split(","): + tok = tok.strip() + if not tok.isdigit(): + continue + port = int(tok) + try: + r = requests.get(f"http://127.0.0.1:{port}/", timeout=2) + except requests.RequestException: + continue + if r.status_code == 200 and ("sanad" in r.text.lower() or "dashboard" in r.text.lower()): + return {"url": f"http://{host}:{port}", "port": port} + return None + + +def register_remote(cfg: Config, session: requests.Session) -> None: + if not cfg.remote_enable: + return + d = discover_dashboard(cfg) + if not d: + _REMOTE_STAT.update(url=None, port=None, ok=None) + return + body = {"sn": cfg.sn, "name": cfg.name, "kind": cfg.remote_kind, + "url": d["url"], "label": f"{cfg.name} — Sanad Dashboard", + "port": d["port"], "ts": int(time.time())} + try: + r = session.post(cfg.remote_url_ep(), json=body, headers=cfg.auth_headers(), + timeout=cfg.http_timeout, verify=cfg.verify_tls) + _REMOTE_STAT.update(url=d["url"], port=d["port"], kind=cfg.remote_kind, ok=bool(r.ok)) + if r.ok: + log.info("remote dashboard registered: %s -> HTTP %s", d["url"], r.status_code) + else: + log.warning("remote register failed: HTTP %s %s", r.status_code, r.text[:150]) + except requests.RequestException as e: + _REMOTE_STAT.update(url=d["url"], port=d["port"], kind=cfg.remote_kind, ok=False) + log.debug("remote register failed: %s", e) + + +def remote_loop(cfg: Config, session: requests.Session) -> None: + while True: + try: + register_remote(cfg, session) + except Exception as e: + log.debug("remote loop: %s", e) + time.sleep(cfg.remote_interval) + + _ALERT_SEEN: set = set() @@ -1407,6 +1497,8 @@ def build_telemetry(cfg: Config, mac: str, reader: Optional[DDSReader], "project_logs": (_PROJECT_TAIL.label if _PROJECT_TAIL is not None and _PROJECT_TAIL.active else None), # e.g. "sanadr1-logs"; null = no project found + "remote": (dict(_REMOTE_STAT) if _REMOTE_STAT.get("url") else None), + # Sanad dashboard URL registered for the fleet UI "alerts": dict(_ALERTS_STAT), # alert status (sent, last, last_time, ok) "time": _now_str(), # full date+time of this post "started_at": _STARTED["now"], # when this agent run started @@ -1516,6 +1608,12 @@ def main(argv: Optional[List[str]] = None) -> int: if args.once or args.dry_run: # one map pass first so the telemetry "map" field reflects it map_sync_once(cfg, session, force=args.force, dry_run=args.dry_run) + if cfg.remote_enable and not args.dry_run: + register_remote(cfg, session) + elif cfg.remote_enable: + d = discover_dashboard(cfg) + if d: + _REMOTE_STAT.update(url=d["url"], port=d["port"], kind=cfg.remote_kind, ok=None) if args.once: one_telemetry() ship_logs(cfg, session) @@ -1525,9 +1623,11 @@ def main(argv: Optional[List[str]] = None) -> int: time.sleep(min(cfg.poll_interval, 1.0)) return 0 - # loop mode: map sync + log shipping in background threads + # loop mode: map sync + log shipping + remote registration in bg threads threading.Thread(target=map_loop, args=(cfg, session), daemon=True).start() threading.Thread(target=logs_loop, args=(cfg, session), daemon=True).start() + if cfg.remote_enable: + threading.Thread(target=remote_loop, args=(cfg, session), daemon=True).start() log.info("telemetry every %.1fs; map check every %.0fs; logs every %.0fs (Ctrl-C to stop)", cfg.poll_interval, cfg.map_poll_interval, cfg.logs_interval) while True: