The CLI shipped from `management/`, which also holds desktop-only pieces (the Tk control panel, the XFCE panel wiring, the shell wrappers). Packaging that directory meant the wheel either dragged in tkinter or shipped a broken import. Split it: `nexusos_cli/` is what the wheel ships and what `nexus`/`ncp`/ `nexusos` dispatch to, `management/` keeps the desktop half. Alongside the move: * hatch_build.py decides the interface/web/dist include at build time. dist/ is gitignored, so a static force-include aborts `pip install -e .` on a fresh clone - before the reader reaches the `npm run build` step. Editable installs now skip a missing dist; wheels and sdists hard-error naming the command to run. * synapse/proc_util.py gives frontend_manager and ncp process inspection and termination without psutil, which became an optional extra when the wheel landed. It routes around Windows having no signals, where os.kill(pid, 15) is an unblockable TerminateProcess rather than a polite request. * nexusos_cli/monitor.py adds `ncp monitor`, an ASCII dashboard with no curses or rich dependency so it works in Termux, plain SSH and Windows Terminal. Collector and renderer are separate so tests feed fixtures, no stack needed. * tests/test_packaging_deps.py fails the gate when synapse or nexusos_cli import a distribution pyproject does not declare, and when an optional dependency is imported at module scope instead of lazily. * bin/check.sh now builds the wheel, twine-checks it, and asserts the compiled UI and seed playbooks are actually inside it. A wheel that builds but ships no dist/ serves a blank page, which only shows up after release. tests/test_nexus_api.py moves to tests/ with the module it covers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
459 lines
15 KiB
Python
459 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", "memory", "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("memory", {}).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,
|
|
"memory_url": settings.memory_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"), ("memory", "memory"), ("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
|