Files
NexusOS/nexusos_cli/monitor.py
T
Jon Wingender a0f033142f fix(packaging): remove the memory-service integration this CLI reintroduced
synapse/memory/service.py was deleted on 2026-08-25 when memory curation
moved in-process (curator.py) - there is no longer a second FastAPI app
to run on :8001. This CLI was evidently built against a pre-curator
baseline: `nexus serve` spawned `synapse.memory.service:app` (fails
with ModuleNotFoundError, logged only to memory.log where nobody would
see it), `nexus start memory`/`stop memory` had no handler at all
(silently fell through to show_help()), and doctor/status/monitor all
carried a "memory service" row that could never be anything but down.

Removed rather than repaired, since there's nothing to repair: the
service, its SERVICES entry, --memory-port/--no-memory, the -m/--memory
target everywhere it was offered (start/stop/logs/LEGACY_TARGETS), and
the memory_port/memory_url settings this PR had added. The `nexus
memory list|add|rm` data commands (nexus_api.py, hitting the backend's
own /memory REST endpoint) are untouched - unrelated, and still work.
2026-08-26 13:40:50 -05:00

457 lines
15 KiB
Python

"""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__<tool>"; 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