"""ASCII dashboard for live NexusOS service / tool / resource stats. No curses, no rich — pure box-drawing + optional ANSI color so it works in Termux, plain SSH, and Windows Terminal alike. The collector is separate from the renderer so tests can feed fixtures without a running stack. """ from __future__ import annotations import json import shutil import time import urllib.error import urllib.request from datetime import datetime, timezone from pathlib import Path from typing import Any from synapse.nexus_config import settings from . import ncp as services # Box drawing — ASCII fallbacks when the terminal can't do Unicode. _BOX = { "tl": "┌", "tr": "┐", "bl": "└", "br": "┘", "h": "─", "v": "│", "l": "├", "r": "┤", } _BOX_ASCII = { "tl": "+", "tr": "+", "bl": "+", "br": "+", "h": "-", "v": "|", "l": "+", "r": "+", } _FILL = "█" _EMPTY = "░" _FILL_ASCII = "#" _EMPTY_ASCII = "-" def _use_unicode() -> bool: enc = (getattr(__import__("sys").stdout, "encoding", None) or "").lower() return "utf" in enc or enc in ("cp65001",) def _http_ok(url: str, timeout: float = 1.0) -> bool: try: urllib.request.urlopen(url, timeout=timeout).read(1) return True except urllib.error.HTTPError: return True except Exception: return False def _provider_payload() -> dict: url = settings.ollama_host.rstrip("/") return { "provider": settings.provider, "url": url, "managed_by_nexus": settings.manage_ollama, "reachable": _http_ok(url + "/api/tags", timeout=2.0), } def _service_status() -> dict: payload = {} for key in ("backend", "frontend"): svc = services.SERVICES[key] pid = services.read_pid(svc) payload[key] = { "running": services.alive(pid) or _http_ok(svc.url), "pid": pid if services.alive(pid) else None, "url": svc.url, } payload["provider"] = _provider_payload() return payload def _get_json(url: str, timeout: float = 1.5) -> Any | None: try: with urllib.request.urlopen(url, timeout=timeout) as resp: return json.loads(resp.read().decode("utf-8", errors="replace")) except Exception: return None def _bar(ratio: float, width: int = 20, unicode: bool = True) -> str: ratio = max(0.0, min(1.0, float(ratio))) filled = int(round(ratio * width)) fill = _FILL if unicode else _FILL_ASCII empty = _EMPTY if unicode else _EMPTY_ASCII return fill * filled + empty * (width - filled) def _fmt_bytes(n: float | int | None) -> str: if n is None: return "—" n = float(n) for unit in ("B", "K", "M", "G", "T"): if abs(n) < 1024 or unit == "T": return f"{n:.0f}{unit}" if unit == "B" else f"{n:.1f}{unit}" n /= 1024 return f"{n:.1f}T" def _pid_stats(pids: list[int | None]) -> dict: """Aggregate CPU%/RSS for known service PIDs. Soft-depends on psutil.""" live = [int(p) for p in pids if p] if not live: return {"cpu_pct": None, "rss": None, "pids": []} try: import psutil # type: ignore except ImportError: return {"cpu_pct": None, "rss": None, "pids": live} cpu = 0.0 rss = 0 seen: list[int] = [] for pid in live: try: proc = psutil.Process(pid) cpu += proc.cpu_percent(interval=0.0) rss += proc.memory_info().rss seen.append(pid) except (psutil.Error, ProcessLookupError, ValueError): continue return {"cpu_pct": cpu, "rss": rss, "pids": seen} def _host_stats() -> dict: try: import psutil # type: ignore except ImportError: return {"cpu_pct": None, "mem_used": None, "mem_total": None, "mem_pct": None} vm = psutil.virtual_memory() return { "cpu_pct": psutil.cpu_percent(interval=0.05), "mem_used": vm.used, "mem_total": vm.total, "mem_pct": vm.percent, } def _api_counts(api_url: str) -> dict: """Pull cheap inventory counts from the backend when it is up.""" base = api_url.rstrip("/") out = { "online": False, "version": None, "ollama": None, "memories": None, "conversations": None, "playbooks": None, "models": None, "action_tool_policy": None, } status = _get_json(base + "/status") if not isinstance(status, dict): return out out["online"] = True out["version"] = status.get("version") out["ollama"] = status.get("ollama") mem = _get_json(base + "/memory") if isinstance(mem, list): out["memories"] = len(mem) elif isinstance(mem, dict) and isinstance(mem.get("memories"), list): out["memories"] = len(mem["memories"]) conv = _get_json(base + "/conversations") if isinstance(conv, list): out["conversations"] = len(conv) elif isinstance(conv, dict): items = conv.get("conversations") or conv.get("items") or [] if isinstance(items, list): out["conversations"] = len(items) pbs = _get_json(base + "/playbooks") if isinstance(pbs, list): out["playbooks"] = len(pbs) elif isinstance(pbs, dict) and isinstance(pbs.get("playbooks"), list): out["playbooks"] = len(pbs["playbooks"]) models = _get_json(base + "/models") if isinstance(models, list): out["models"] = len(models) elif isinstance(models, dict): items = models.get("models") or models.get("items") or [] if isinstance(items, list): out["models"] = len(items) settings_payload = _get_json(base + "/settings") if isinstance(settings_payload, dict): out["action_tool_policy"] = settings_payload.get("action_tool_policy") return out def _toolchain_stats() -> list[dict]: """Which run_snippet languages have a host toolchain right now.""" try: from synapse import code_run except Exception: return [] rows = [] for name, spec in code_run.RUN_LANGS.items(): tool = None try: tool = spec["tool"]() except Exception: tool = None rows.append({ "lang": name, "ready": bool(tool), "tool": tool or None, "summary": spec.get("summary") or name, }) return rows def _recent_tools(log_path: Path, limit: int = 8) -> list[str]: """Best-effort scrape of recent tool names from chat.log.""" if not log_path.is_file(): return [] try: # Read the tail without pulling a multi-MB log into memory. data = log_path.read_bytes() if len(data) > 64_000: data = data[-64_000:] text = data.decode("utf-8", errors="replace") except OSError: return [] found: list[str] = [] for line in reversed(text.splitlines()): # Chat tool loop yields "__status__"; logs may also name tools # in JSON payloads. Keep the match narrow. if "__status__" in line: name = line.split("__status__", 1)[-1].strip().split()[0].strip(",\"'") if name and name not in ("tools",) and name not in found: found.append(name) elif '"name":' in line and any( t in line for t in ("render_preview", "run_snippet", "web_search", "fetch_url", "remember", "get_time") ): for t in ("run_snippet", "render_preview", "web_search", "fetch_url", "remember", "get_time", "search_documents"): if t in line and t not in found: found.append(t) if len(found) >= limit: break return found def collect_snapshot() -> dict: """Gather one monitoring frame. Safe when services are down.""" services_payload = _service_status() pids = [ services_payload.get("backend", {}).get("pid"), services_payload.get("frontend", {}).get("pid"), ] api = _api_counts(settings.api_url) return { "ts": datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds"), "version": settings.version, "services": services_payload, "api": api, "host": _host_stats(), "procs": _pid_stats(pids), "toolchains": _toolchain_stats(), "recent_tools": _recent_tools(settings.logs_dir / "chat.log"), "paths": { "api_url": settings.api_url, "runtime_dir": str(settings.runtime_dir), }, } def _pad(text: str, width: int) -> str: # Visual width ≈ len for our ASCII/box content (no wide East-Asian chars). if len(text) > width: return text[: width - 1] + "…" if width > 1 else text[:width] return text + " " * (width - len(text)) def _row(box: dict, inner: str, width: int) -> str: return f"{box['v']} {_pad(inner, width - 4)} {box['v']}" def _rule(box: dict, width: int, kind: str = "mid") -> str: h = box["h"] * (width - 2) if kind == "top": return f"{box['tl']}{h}{box['tr']}" if kind == "bot": return f"{box['bl']}{h}{box['br']}" return f"{box['l']}{h}{box['r']}" def _svc_line(name: str, running: bool, detail: str, unicode: bool) -> str: mark = (_FILL if unicode else _FILL_ASCII) * 3 if running else (_EMPTY if unicode else _EMPTY_ASCII) * 3 state = "UP " if running else "DOWN" return f"{name:<10} {mark} {state} {detail}" def render_frame(snapshot: dict, *, width: int | None = None, unicode: bool | None = None) -> str: """Turn a snapshot into a single multi-line ASCII panel.""" if unicode is None: unicode = _use_unicode() box = _BOX if unicode else _BOX_ASCII cols = shutil.get_terminal_size((80, 24)).columns if width is None else width width = max(56, min(100, cols)) lines: list[str] = [] lines.append(_rule(box, width, "top")) title = f"NexusOS {snapshot.get('version', '')} monitor" raw_ts = snapshot.get("ts") or "" # Prefer local clock HH:MM:SS from an ISO stamp; fall back to wall clock. stamp = "" if "T" in raw_ts: try: stamp = raw_ts.split("T", 1)[1][:8] except Exception: stamp = "" if not stamp: stamp = datetime.now().strftime("%H:%M:%S") gap = max(1, width - 4 - len(title) - len(stamp)) header = f"{title}{' ' * gap}{stamp}" lines.append(_row(box, header, width)) lines.append(_rule(box, width, "mid")) lines.append(_row(box, "SERVICES", width)) svcs = snapshot.get("services") or {} for key, label in (("backend", "backend"), ("frontend", "frontend")): info = svcs.get(key) or {} running = bool(info.get("running")) pid = info.get("pid") url = info.get("url") or "" detail = url if pid: detail = f"pid {pid} {url}" lines.append(_row(box, _svc_line(label, running, detail, unicode), width)) provider = svcs.get("provider") or _provider_payload() pref = f"{provider.get('provider', '?')} @ {provider.get('url', '')}" lines.append(_row(box, _svc_line("provider", bool(provider.get("reachable")), pref, unicode), width)) lines.append(_rule(box, width, "mid")) lines.append(_row(box, "RESOURCES", width)) host = snapshot.get("host") or {} procs = snapshot.get("procs") or {} cpu = host.get("cpu_pct") if cpu is not None: lines.append(_row( box, f"host CPU [{_bar(cpu / 100.0, 22, unicode)}] {cpu:5.1f}%", width, )) else: lines.append(_row(box, "host CPU (install psutil for live bars)", width)) mem_pct = host.get("mem_pct") if mem_pct is not None: lines.append(_row( box, f"host MEM [{_bar(mem_pct / 100.0, 22, unicode)}] " f"{_fmt_bytes(host.get('mem_used'))} / {_fmt_bytes(host.get('mem_total'))}", width, )) proc_cpu = procs.get("cpu_pct") proc_rss = procs.get("rss") if proc_cpu is not None or proc_rss is not None: lines.append(_row( box, f"nexus cpu={proc_cpu if proc_cpu is not None else '—':>5} " f"rss={_fmt_bytes(proc_rss)} pids={','.join(str(p) for p in (procs.get('pids') or [])) or '—'}", width, )) lines.append(_rule(box, width, "mid")) lines.append(_row(box, "DATA / TOOLS", width)) api = snapshot.get("api") or {} if api.get("online"): policy = api.get("action_tool_policy") or "—" lines.append(_row( box, f"api UP v{api.get('version') or '?'} ollama={api.get('ollama') or '—'} " f"tools={policy}", width, )) lines.append(_row( box, f"memories={_n(api.get('memories'))} " f"chats={_n(api.get('conversations'))} " f"playbooks={_n(api.get('playbooks'))} " f"models={_n(api.get('models'))}", width, )) else: lines.append(_row(box, "api DOWN — start with: nexus start", width)) recent = snapshot.get("recent_tools") or [] lines.append(_row( box, "recent " + (", ".join(recent) if recent else "(none in chat.log)"), width, )) lines.append(_rule(box, width, "mid")) lines.append(_row(box, "RUN TOOLCHAINS (run_snippet)", width)) chains = snapshot.get("toolchains") or [] if not chains: lines.append(_row(box, "(code_run unavailable)", width)) else: # Pack ready/missing into one or two compact lines. ready = [c["lang"] for c in chains if c.get("ready")] missing = [c["lang"] for c in chains if not c.get("ready")] lines.append(_row( box, f"ready {', '.join(ready) if ready else '—'}", width, )) lines.append(_row( box, f"missing {', '.join(missing) if missing else '—'}", width, )) lines.append(_rule(box, width, "bot")) return "\n".join(lines) def _n(value) -> str: return "—" if value is None else str(value) def run_monitor(*, interval: float = 1.5, once: bool = False, json_output: bool = False) -> int: """Print one frame, or refresh in place until interrupted.""" clear = "\033[H\033[J" first = True while True: snap = collect_snapshot() if json_output: print(json.dumps(snap, indent=2, sort_keys=True)) else: frame = render_frame(snap) if once or not first: # Replacing the screen keeps the panel stable; first frame of a # live session also clears so leftover shell output doesn't mix. if not once: print(clear + frame, end="", flush=True) else: print(frame) else: print(clear + frame, end="", flush=True) first = False if once: return 0 try: time.sleep(max(0.3, float(interval))) except KeyboardInterrupt: print() return 130