refactor(management): replace curses TUIs with API-backed ncp subcommands; panel/autostart/install integration
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Regular → Executable
+178
-121
@@ -8,15 +8,42 @@ import os
|
||||
import signal
|
||||
import sys
|
||||
import webbrowser
|
||||
import requests
|
||||
import httpx
|
||||
from pathlib import Path
|
||||
|
||||
# --- CONFIGURATION ---
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
# Single source of truth: the VERSION file at the repo root
|
||||
try:
|
||||
VERSION = (PROJECT_ROOT / "VERSION").read_text(encoding="utf-8").strip() or "0.0.0"
|
||||
except Exception:
|
||||
VERSION = "0.0.0"
|
||||
|
||||
# Force 0.0.0.0 to ensure the Windows Host bridge works for Project Nexus
|
||||
FRONTEND_DIR = PROJECT_ROOT / "interface" / "web"
|
||||
FRONTEND_CMD = ["/home/jon/.nvm/versions/node/v20.20.2/bin/npm", "run", "dev", "--", "--host", "0.0.0.0"]
|
||||
def _find_npm():
|
||||
"""Prefer nvm's newest node. When the panel is launched from the desktop
|
||||
(not a shell), nvm isn't on PATH, so shutil.which('npm') returns the SYSTEM
|
||||
node — often too old for the installed Vite (e.g. Node 18 -> "CustomEvent is
|
||||
not defined"). nvm exists precisely to shadow the system node, and ncp
|
||||
already prefers it, so resolve it here too. _spawn_process prepends the
|
||||
returned npm's bin dir to PATH, so its node wins for the child process."""
|
||||
import shutil
|
||||
nvm_dir = Path.home() / ".nvm" / "versions" / "node"
|
||||
if nvm_dir.exists():
|
||||
def _ver(p):
|
||||
try:
|
||||
return tuple(int(x) for x in p.name.lstrip("v").split("."))
|
||||
except ValueError:
|
||||
return (0,)
|
||||
for v in sorted((d for d in nvm_dir.iterdir() if d.is_dir()), key=_ver, reverse=True):
|
||||
candidate = v / "bin" / "npm"
|
||||
if candidate.exists():
|
||||
return str(candidate)
|
||||
return shutil.which("npm") or "npm"
|
||||
|
||||
FRONTEND_CMD = [_find_npm(), "run", "dev", "--", "--host", "0.0.0.0"]
|
||||
FRONTEND_URL = "http://127.0.0.1:5173"
|
||||
|
||||
BACKEND_DIR = PROJECT_ROOT
|
||||
@@ -59,7 +86,7 @@ class NexusControlPanel:
|
||||
self.root.title("Nexus Control Panel")
|
||||
# Example icon path; replace with your actual icon file path
|
||||
try:
|
||||
self.root.iconphoto(True, tk.PhotoImage(file=r"/home/jon/nexus-core/assets/n-small.png"))
|
||||
self.root.iconphoto(True, tk.PhotoImage(file=str(PROJECT_ROOT / "assets" / "n-small.png")))
|
||||
except Exception as e:
|
||||
print(f"Icon load failed: {e}")
|
||||
|
||||
@@ -86,6 +113,7 @@ class NexusControlPanel:
|
||||
self._frontend_starting = False
|
||||
self._backend_starting = False
|
||||
self._memory_starting = False
|
||||
self._closing = False
|
||||
|
||||
self._setup_custom_titlebar()
|
||||
self._setup_ui()
|
||||
@@ -154,6 +182,7 @@ class NexusControlPanel:
|
||||
self.title_bar = tk.Frame(self.root, bg="#2d2d2d", bd=0)
|
||||
self.title_bar.pack(fill=tk.X, side=tk.TOP)
|
||||
tk.Label(self.title_bar, text="NexusOS // AI CONTROL PANEL", bg="#2d2d2d", fg="#00ff00", font=("Consolas", 10, "bold"), padx=8, pady=3).pack(side=tk.LEFT)
|
||||
tk.Label(self.title_bar, text=f"v{VERSION}", bg="#2d2d2d", fg="#666666", font=("Consolas", 9), padx=8, pady=3).pack(side=tk.RIGHT)
|
||||
self.title_bar.bind("<Button-1>", self.start_move)
|
||||
self.title_bar.bind("<B1-Motion>", self.do_move)
|
||||
|
||||
@@ -256,6 +285,21 @@ class NexusControlPanel:
|
||||
m_f, self.mind_console = self._create_terminal(bottom_f, " >> MINDTRACE", text_color="#cc88ff")
|
||||
m_f.grid(row=0, column=3, sticky="nsew", padx=(4, 0))
|
||||
|
||||
def _safe_after(self, delay, func):
|
||||
"""Schedule a Tk callback without ever raising.
|
||||
|
||||
tkinter is not thread-safe: the worker/monitor threads call in here,
|
||||
and during shutdown (root being destroyed) an .after() from a thread
|
||||
raises RuntimeError/TclError. Swallowing it keeps a thread's `finally`
|
||||
(which unlinks its PID file) from being aborted mid-way — the cause of
|
||||
the crash-on-close and the stale runtime/pids/*.pid files it left."""
|
||||
if self._closing:
|
||||
return
|
||||
try:
|
||||
self.root.after(delay, func)
|
||||
except (RuntimeError, tk.TclError):
|
||||
pass
|
||||
|
||||
def log(self, tag, message):
|
||||
def _log():
|
||||
con = self.sys_console
|
||||
@@ -265,119 +309,55 @@ class NexusControlPanel:
|
||||
elif tag == "MINDTRACE": con = self.mind_console
|
||||
con.insert(tk.END, f"[{tag}] {message}\n", tag)
|
||||
con.see(tk.END)
|
||||
self.root.after(0, _log)
|
||||
self._safe_after(0, _log)
|
||||
|
||||
def _detect_gpu(self):
|
||||
# Try NVIDIA (CUDA)
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["nvidia-smi", "--query-gpu=name", "--format=csv,noheader"],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
if r.returncode == 0:
|
||||
name = r.stdout.strip().splitlines()[0]
|
||||
self.log("GPU_NVIDIA", f"GPU: NVIDIA — {name} (CUDA)")
|
||||
return
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
|
||||
# Try AMD ROCm
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["rocm-smi", "--showproductname"],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
if r.returncode == 0:
|
||||
for line in r.stdout.splitlines():
|
||||
if line.strip() and not line.startswith("="):
|
||||
self.log("GPU_AMD", f"GPU: AMD — {line.strip()} (ROCm)")
|
||||
return
|
||||
self.log("GPU_AMD", "GPU: AMD (ROCm)")
|
||||
return
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
|
||||
# Try Vulkan (works on AMD, Intel, and NVIDIA without CUDA/ROCm stack)
|
||||
vulkan_ok = False
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["vulkaninfo", "--summary"], capture_output=True, text=True, timeout=5
|
||||
)
|
||||
vulkan_ok = r.returncode == 0
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
|
||||
if not vulkan_ok:
|
||||
icd_dirs = [Path("/usr/share/vulkan/icd.d"), Path("/etc/vulkan/icd.d")]
|
||||
"""Log a cosmetic colored GPU banner. Best-effort — label only."""
|
||||
def _run(cmd):
|
||||
try:
|
||||
vulkan_ok = any(p.is_dir() and any(p.iterdir()) for p in icd_dirs)
|
||||
except PermissionError:
|
||||
pass
|
||||
r = subprocess.run(cmd, capture_output=True, text=True, timeout=5)
|
||||
return r.stdout if r.returncode == 0 else None
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
return None
|
||||
|
||||
if vulkan_ok:
|
||||
# Use vulkaninfo to find the best device (discrete > integrated, AMD/NVIDIA > Intel)
|
||||
gpu_name = "GPU"
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["vulkaninfo", "--summary"], capture_output=True, text=True, timeout=5
|
||||
)
|
||||
if r.returncode == 0:
|
||||
devices = []
|
||||
current = {}
|
||||
for line in r.stdout.splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith("GPU") and line.endswith(":"):
|
||||
if current:
|
||||
devices.append(current)
|
||||
current = {"name": "GPU", "type": "", "vendor": ""}
|
||||
elif "=" in line:
|
||||
key, _, val = line.partition("=")
|
||||
key, val = key.strip(), val.strip()
|
||||
if key == "deviceName":
|
||||
current["name"] = val
|
||||
elif key == "deviceType":
|
||||
current["type"] = val.upper()
|
||||
elif key == "vendorID":
|
||||
current["vendor"] = val.lower()
|
||||
if current:
|
||||
devices.append(current)
|
||||
|
||||
def _score(d):
|
||||
type_score = 2 if "DISCRETE" in d["type"] else (0 if "INTEGRATED" in d["type"] else 1)
|
||||
vendor_score = 1 if any(v in d["vendor"] for v in ("0x1002", "0x10de")) else 0
|
||||
return (type_score, vendor_score)
|
||||
|
||||
if devices:
|
||||
best = max(devices, key=_score)
|
||||
gpu_name = best["name"]
|
||||
except Exception:
|
||||
pass
|
||||
self.log("GPU_VULKAN", f"GPU: {gpu_name} (Vulkan)")
|
||||
out = _run(["nvidia-smi", "--query-gpu=name", "--format=csv,noheader"])
|
||||
if out and out.strip():
|
||||
self.log("GPU_NVIDIA", f"GPU: NVIDIA — {out.strip().splitlines()[0]} (CUDA)")
|
||||
return
|
||||
|
||||
# Fallback: lspci — hardware present but no usable drivers
|
||||
try:
|
||||
r = subprocess.run(["lspci"], capture_output=True, text=True, timeout=5)
|
||||
if r.returncode == 0:
|
||||
for line in r.stdout.splitlines():
|
||||
ll = line.lower()
|
||||
if any(x in ll for x in ["vga", "3d controller", "display"]):
|
||||
if "nvidia" in ll:
|
||||
self.log("GPU_NVIDIA", "GPU: NVIDIA detected (no drivers)")
|
||||
return
|
||||
if "amd" in ll or "radeon" in ll or "advanced micro" in ll:
|
||||
self.log("GPU_AMD", "GPU: AMD detected (no drivers)")
|
||||
return
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
out = _run(["rocm-smi", "--showproductname"])
|
||||
if out is not None:
|
||||
name = next((l.strip() for l in out.splitlines() if l.strip() and not l.startswith("=")), "")
|
||||
self.log("GPU_AMD", f"GPU: AMD — {name} (ROCm)" if name else "GPU: AMD (ROCm)")
|
||||
return
|
||||
|
||||
out = _run(["vulkaninfo", "--summary"])
|
||||
if out is not None:
|
||||
name = next((l.split("=", 1)[1].strip() for l in out.splitlines()
|
||||
if l.strip().startswith("deviceName")), "GPU")
|
||||
self.log("GPU_VULKAN", f"GPU: {name} (Vulkan)")
|
||||
return
|
||||
|
||||
out = _run(["lspci"]) # hardware present but no usable drivers
|
||||
for line in (out or "").splitlines():
|
||||
ll = line.lower()
|
||||
if any(x in ll for x in ("vga", "3d controller", "display")):
|
||||
if "nvidia" in ll:
|
||||
self.log("GPU_NVIDIA", "GPU: NVIDIA detected (no drivers)")
|
||||
return
|
||||
if "amd" in ll or "radeon" in ll or "advanced micro" in ll:
|
||||
self.log("GPU_AMD", "GPU: AMD detected (no drivers)")
|
||||
return
|
||||
|
||||
self.log("SYSTEM", "GPU: not detected")
|
||||
|
||||
def _poll_status(self):
|
||||
self.update_master_ui()
|
||||
self.root.after(2000, self._poll_status)
|
||||
self._safe_after(2000, self._poll_status)
|
||||
|
||||
def update_master_ui(self):
|
||||
if self._closing: # window may be destroyed; stop_* still calls this
|
||||
return
|
||||
f, b, m = self._frontend_is_active(), self._backend_is_active(), self._memory_is_active()
|
||||
self.btn_master_start.config(state=tk.DISABLED if (f and b and m) else tk.NORMAL)
|
||||
self.btn_master_stop.config(state=tk.NORMAL if (f or b or m) else tk.DISABLED)
|
||||
@@ -454,7 +434,7 @@ class NexusControlPanel:
|
||||
self._backend_starting = False
|
||||
self.backend_process = p
|
||||
self._write_pid(BACKEND_PID, p.pid)
|
||||
self.root.after(0, self.update_master_ui)
|
||||
self._safe_after(0, self.update_master_ui)
|
||||
|
||||
if not p.stdout:
|
||||
raise RuntimeError("Process has no stdout stream")
|
||||
@@ -500,7 +480,7 @@ class NexusControlPanel:
|
||||
self._backend_starting = False
|
||||
self.backend_process = None
|
||||
BACKEND_PID.unlink(missing_ok=True)
|
||||
self.root.after(0, self.update_master_ui)
|
||||
self._safe_after(0, self.update_master_ui)
|
||||
|
||||
def start_frontend(self):
|
||||
if self._frontend_is_active():
|
||||
@@ -542,17 +522,17 @@ class NexusControlPanel:
|
||||
return
|
||||
|
||||
try:
|
||||
response = requests.get("http://localhost:8000/", timeout=2)
|
||||
response = httpx.get("http://localhost:8000/", timeout=2)
|
||||
if response.status_code == 200:
|
||||
time.sleep(1)
|
||||
self.log("SYSTEM", "Starting OLLAMA...")
|
||||
try:
|
||||
requests.post("http://localhost:8000/ollama/start", timeout=2)
|
||||
httpx.post("http://localhost:8000/ollama/start", timeout=2)
|
||||
self.log("SYSTEM", "OLLAMA startup command sent.")
|
||||
except Exception as e:
|
||||
self.log("SYSTEM", f"OLLAMA startup error: {e}")
|
||||
return
|
||||
except (requests.RequestException, ConnectionError):
|
||||
except (httpx.RequestError, ConnectionError):
|
||||
time.sleep(1)
|
||||
|
||||
self.log("SYSTEM", "Backend failed to become reachable — stopping OLLAMA.")
|
||||
@@ -561,7 +541,6 @@ class NexusControlPanel:
|
||||
def _stop_ollama_direct(self):
|
||||
"""Kill Ollama without the backend API (used when backend is not running)."""
|
||||
try:
|
||||
import httpx
|
||||
r = httpx.get("http://localhost:11434/api/tags", timeout=2.0)
|
||||
ollama_running = r.status_code == 200
|
||||
except Exception:
|
||||
@@ -586,9 +565,9 @@ class NexusControlPanel:
|
||||
def stop_backend(self):
|
||||
self.log("SYSTEM", "Stopping OLLAMA...")
|
||||
try:
|
||||
requests.post("http://localhost:8000/ollama/stop", timeout=5)
|
||||
except Exception as e:
|
||||
self.log("SYSTEM", f"OLLAMA stop error: {e}")
|
||||
httpx.post("http://localhost:8000/ollama/stop", timeout=5)
|
||||
except Exception:
|
||||
self._stop_ollama_direct()
|
||||
|
||||
if self.backend_process:
|
||||
self._terminate_process(self.backend_process)
|
||||
@@ -657,7 +636,7 @@ class NexusControlPanel:
|
||||
self._frontend_starting = False
|
||||
self._backend_starting = False
|
||||
self._memory_starting = False
|
||||
self.root.after(0, self.update_master_ui)
|
||||
self._safe_after(0, self.update_master_ui)
|
||||
self.log("SYSTEM", "Force kill complete.")
|
||||
|
||||
def stop_all(self):
|
||||
@@ -666,9 +645,47 @@ class NexusControlPanel:
|
||||
self.stop_memory()
|
||||
|
||||
def on_close(self):
|
||||
self.stop_all()
|
||||
self.root.destroy()
|
||||
if self._closing:
|
||||
return
|
||||
self._closing = True
|
||||
# Destroy the window FIRST so closing is instant. The teardown used to
|
||||
# run with the window still up, and stop_backend's blocking calls (a 5s
|
||||
# httpx.post to a backend stop_frontend had just killed, plus httpx +
|
||||
# pkill) froze it into "not responding" — the crash you saw with
|
||||
# services running. Destroy, then tear down fast and non-blocking.
|
||||
try:
|
||||
self.root.destroy()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self._force_teardown()
|
||||
except Exception:
|
||||
pass
|
||||
os._exit(0)
|
||||
|
||||
def _force_teardown(self):
|
||||
"""Fast, non-blocking shutdown for window-close. No HTTP calls: the
|
||||
window is already gone, and the graceful /ollama/stop endpoint (used by
|
||||
the STOP button) blocks for seconds against a backend we're killing."""
|
||||
# Ollama runs in its own session (start_new_session=True), so the group
|
||||
# kills below won't reach it — signal it directly, by binary path so we
|
||||
# don't match unrelated processes. Popen (no wait) keeps this instant.
|
||||
try:
|
||||
subprocess.Popen(["pkill", "-TERM", "-f", "ollama/bin/ollama"])
|
||||
except Exception:
|
||||
pass
|
||||
# Services the panel spawned itself (each in its own setsid group):
|
||||
for proc in (self.frontend_process, self.backend_process, self.memory_process):
|
||||
if proc:
|
||||
self._terminate_process(proc)
|
||||
# Services attached via pid file (ncp-started, one shared group);
|
||||
# _terminate_pid_file verifies ownership and unlinks. Guarantee the
|
||||
# unlink even for a pid that's already dead so no stale files remain.
|
||||
for pf in (FRONTEND_PID, BACKEND_PID, MEMORY_PID):
|
||||
if self._is_running(pf):
|
||||
self._terminate_pid_file(pf)
|
||||
else:
|
||||
pf.unlink(missing_ok=True)
|
||||
|
||||
def _frontend_is_active(self):
|
||||
return self._frontend_starting or self.frontend_process is not None or self._is_running(FRONTEND_PID)
|
||||
@@ -676,27 +693,48 @@ class NexusControlPanel:
|
||||
def _backend_is_active(self):
|
||||
return self._backend_starting or self.backend_process is not None or self._is_running(BACKEND_PID)
|
||||
|
||||
def _pid_is_ours(self, pid):
|
||||
"""True only if the live PID is actually one of our services. PID files
|
||||
outlive the process; the OS recycles the number onto something unrelated
|
||||
(a desktop app, a shell — or this panel), and killpg on a recycled PID's
|
||||
group takes down the wrong processes. Mirrors nexus-cli.sh's guard."""
|
||||
try:
|
||||
with open(f"/proc/{pid}/cmdline", "rb") as f:
|
||||
cmd = f.read().replace(b"\0", b" ").decode("utf-8", "replace")
|
||||
except OSError:
|
||||
return False
|
||||
return any(m in cmd for m in ("uvicorn synapse", "npm run dev", "vite"))
|
||||
|
||||
def _terminate_pid_file(self, pid_file):
|
||||
try:
|
||||
with open(pid_file, "r") as f:
|
||||
pid = int(f.read().strip())
|
||||
except Exception:
|
||||
pid_file.unlink(missing_ok=True)
|
||||
return
|
||||
try:
|
||||
if os.name == "nt":
|
||||
try:
|
||||
os.kill(pid, signal.CTRL_BREAK_EVENT)
|
||||
except Exception:
|
||||
pass
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
else:
|
||||
elif self._pid_is_ours(pid):
|
||||
try:
|
||||
os.killpg(os.getpgid(pid), signal.SIGKILL)
|
||||
pgid = os.getpgid(pid)
|
||||
# Never killpg our OWN group — if a service was launched into
|
||||
# the panel's process group, nuking the group SIGKILLs the
|
||||
# panel mid-close. Kill just the service pid in that case.
|
||||
if pgid == os.getpgrp():
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
else:
|
||||
os.killpg(pgid, signal.SIGKILL)
|
||||
except (ProcessLookupError, OSError):
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
# else: PID recycled onto a non-service — don't kill the wrong thing
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
pid_file.unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
pid_file.unlink(missing_ok=True)
|
||||
|
||||
def _is_running(self, pid_file):
|
||||
try:
|
||||
@@ -755,7 +793,26 @@ class NexusControlPanel:
|
||||
self.log("ERROR", f"MINDTRACE monitor fault: {e}")
|
||||
time.sleep(2)
|
||||
|
||||
PANEL_LOG = PROJECT_ROOT / "runtime" / "logs" / "panel.log"
|
||||
|
||||
def _log_crash(where, exc_type, exc_value, exc_tb):
|
||||
"""Persist tracebacks — the .desktop launcher runs Terminal=false, so
|
||||
stderr is discarded and crashes are otherwise invisible. Covers the three
|
||||
surfaces tk hides: main thread, worker threads, and Tk callbacks."""
|
||||
import traceback, datetime
|
||||
try:
|
||||
PANEL_LOG.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(PANEL_LOG, "a") as f:
|
||||
f.write(f"\n[{datetime.datetime.now().isoformat()}] {where}\n")
|
||||
traceback.print_exception(exc_type, exc_value, exc_tb, file=f)
|
||||
except Exception:
|
||||
pass
|
||||
traceback.print_exception(exc_type, exc_value, exc_tb)
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.excepthook = lambda et, ev, tb: _log_crash("main-thread uncaught", et, ev, tb)
|
||||
threading.excepthook = lambda a: _log_crash("thread uncaught", a.exc_type, a.exc_value, a.exc_traceback)
|
||||
root = tk.Tk()
|
||||
root.report_callback_exception = lambda et, ev, tb: _log_crash("tk callback", et, ev, tb)
|
||||
app = NexusControlPanel(root)
|
||||
root.mainloop()
|
||||
Reference in New Issue
Block a user