"""TUI 客户端 - 前后端分离的 TUI 前端 通过 HTTP 连接后端 nebula serve,消费 JSON API, 直接使用 ANSI 转义码绘制专业终端界面。 支持鼠标点击导航。 """ import sys import json import time import tty import termios import signal import socket import urllib.request import urllib.error import shutil import re from typing import Optional # ── ANSI 工具 ──────────────────────────────────────────── def fg(r, g, b): return f"\x1b[38;2;{r};{g};{b}m" def bg(r, g, b): return f"\x1b[48;2;{r};{g};{b}m" def bold(s): return f"\x1b[1m{s}\x1b[22m" def dim(s): return f"\x1b[2m{s}\x1b[22m" def rst(): return "\x1b[0m" C = { "header_bg": (30, 30, 46), "status_bg": (30, 30, 46), "accent": (0, 255, 135), "green": (0, 255, 135), "yellow": (255, 220, 80), "red": (255, 80, 80), "cyan": (80, 200, 255), "dim": (100, 100, 120), "white": (220, 220, 240), "bar_bg": (50, 50, 70), } # ── 鼠标转义 ──────────────────────────────────────────── _MOUSE_ON = "\x1b[?1000h\x1b[?1002h\x1b[?1006h" _MOUSE_OFF = "\x1b[?1006l\x1b[?1002l\x1b[?1000l" # ── HTTP 请求 ──────────────────────────────────────────── def http_get(url: str, timeout=5) -> Optional[str]: try: req = urllib.request.Request(url, headers={"Accept": "application/json"}) with urllib.request.urlopen(req, timeout=timeout) as r: return r.read().decode("utf-8") except Exception: return None def backend_alive(host="127.0.0.1", port=8080) -> bool: try: s = socket.create_connection((host, port), timeout=2) s.close() return True except OSError: return False # ── 布局工具 ──────────────────────────────────────────── def term_size(): return shutil.get_terminal_size((80, 24)) def hbar(width: int, percent: float, color_fg=(0, 255, 135), color_bg=(50, 50, 70), char="█"): filled = max(0, min(width, int(width * percent / 100))) empty = width - filled bar = fg(*color_fg) + char * filled + rst() + fg(*color_bg) + "░" * empty + rst() return bar # ── TUI 客户端 ────────────────────────────────────────── Page = dict # {"id": str, "label": str, "desc": str} class TUIClient: _resize_flag = False @classmethod def _sigwinch(cls, sig, frame): cls._resize_flag = True PAGES: list[Page] = [ {"id": "welcome", "label": "首页", "desc": "系统概览"}, {"id": "dashboard", "label": "仪表盘", "desc": "CPU · 内存 · 磁盘 · 网络"}, {"id": "logs", "label": "日志", "desc": "实时日志输出"}, {"id": "terminal", "label": "终端", "desc": "Shell"}, {"id": "plugins", "label": "插件", "desc": "插件管理"}, ] def __init__(self, host="127.0.0.1", port=8080): self.host = host self.port = port self.base_url = f"http://{host}:{port}" self.running = False self.current_page = "welcome" self.width = 80 self.height = 24 self._stats_cache = {} self._stats_time = 0 # 鼠标点击区域: list of (y, page_id) self._click_zones: list[tuple[int, str]] = [] def _fetch_stats(self) -> dict: now = time.time() if now - self._stats_time < 1 and self._stats_cache: return self._stats_cache raw = http_get(f"{self.base_url}/api/dashboard/stats") if raw: try: self._stats_cache = json.loads(raw) self._stats_time = now except json.JSONDecodeError: pass return self._stats_cache # ── 鼠标事件 ────────────────────────────────────────── @staticmethod def _parse_sgr_mouse(data: str): """解析 SGR 鼠标事件 \x1b[ 1024**3: return f"{b/(1024**3):.1f}G" if b > 1024**2: return f"{b/(1024**2):.1f}M" if b > 1024: return f"{b/1024:.1f}K" return f"{b:.0f}B" # ── 主循环 ──────────────────────────────────────────── def _navigate(self, page_id: str): self._stats_cache = {} self._stats_time = 0 self.current_page = page_id self.width, self.height = term_size() self._render_all() def run(self): self.width, self.height = term_size() self.running = True self._render_all() signal.signal(signal.SIGWINCH, TUIClient._sigwinch) fd = sys.stdin.fileno() old = termios.tcgetattr(fd) try: tty.setraw(fd) # setraw 会关闭 ONLCR(\n→\r\n),重新开启避免阶梯乱码 attrs = termios.tcgetattr(fd) attrs[1] = attrs[1] | termios.ONLCR termios.tcsetattr(fd, termios.TCSANOW, attrs) sys.stdout.write(_MOUSE_ON) sys.stdout.flush() buf = "" while self.running: # 终端 resize 检测 if TUIClient._resize_flag: TUIClient._resize_flag = False self.width, self.height = term_size() self._render_all() ch = sys.stdin.read(1) buf += ch # 检测 SGR 鼠标事件结束符 M/m if buf.startswith("\x1b[<") and ch in ("M", "m"): ev = self._parse_sgr_mouse(buf) buf = "" if ev: button, mx, my = ev if button == 0 and ch == "M": # 左键按下 for zy, page_id in self._click_zones: if my == zy + 1: # 鼠标坐标 1-based self._navigate(page_id) break continue # 非鼠标序列 → 重置缓冲区 if not buf.startswith("\x1b"): pass elif buf.startswith("\x1b[<"): continue # 等待更多字符 elif len(buf) > 1: buf = "" # 其他转义序列,丢弃 # 处理单字符输入 if len(buf) == 1: c = buf buf = "" if c in ("q", "Q", "\x03", "\x04"): break elif c == "1": self._navigate("welcome") elif c == "2": self._navigate("dashboard") elif c == "3": self._navigate("logs") elif c == "4": self._navigate("terminal") elif c == "5": self._navigate("plugins") elif c in ("r", "R"): self._stats_cache = {} self._stats_time = 0 self._render_all() except Exception: pass finally: signal.signal(signal.SIGWINCH, signal.SIG_DFL) sys.stdout.write(_MOUSE_OFF) sys.stdout.flush() termios.tcsetattr(fd, termios.TCSADRAIN, old) print("\x1b[2J\x1b[H\x1b[0mTUI 已退出\n")