Update 2026-07-14 10:41:57

This commit is contained in:
kassam 2026-07-14 10:41:58 +04:00
parent 2ab8a46d44
commit 7d9c4f803f
6 changed files with 349 additions and 2 deletions

View File

@ -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

View File

@ -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:

View File

@ -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

View File

@ -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:

View File

@ -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

View File

@ -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: