diff --git a/.gitignore b/.gitignore index 3392e22..4494caa 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ _patches_applied/ *.swo *~ .DS_Store +docs/ diff --git a/config/plugins/commands.yaml b/config/plugins/commands.yaml index 7336276..33ba9e0 100644 --- a/config/plugins/commands.yaml +++ b/config/plugins/commands.yaml @@ -92,7 +92,7 @@ commands: permissions: - framework.command.test source: internal -last_updated: 279719.562686002 +last_updated: 283744.334290456 plugin_commands: example_plugin: echo: *id001 diff --git a/config/services/network_routes.yaml b/config/services/network_routes.yaml index 0a832ba..6a9f5a0 100644 --- a/config/services/network_routes.yaml +++ b/config/services/network_routes.yaml @@ -1,5 +1,5 @@ http_port: 4200 -last_updated: 279719.566351315 +last_updated: 283744.338585717 plugin_routes: example_plugin: - methods: diff --git a/main.py b/main.py index 66358e8..b90ed64 100644 --- a/main.py +++ b/main.py @@ -5,6 +5,7 @@ import logging import asyncio import sys import signal +import time import argparse from services.project_engine import ProjectEngine from services.pyenv_manager import PyEnvManager @@ -205,6 +206,7 @@ class SenSuFramework: logger.warning(f"引擎/代理初始化跳过: {e}") logger.info("🎉 SenSu 初始化完成!") + self.service_manager.start_time = time.time() self.is_running = True # 显示欢迎日志 diff --git a/services/log_service.py b/services/log_service.py index 08dee5f..09bf231 100644 --- a/services/log_service.py +++ b/services/log_service.py @@ -108,6 +108,10 @@ class LogService: debug_handler.addFilter(shared_filter) root_logger.addHandler(debug_handler) + # 压制 aiohttp 内部 WS 帧日志 (防止 WS 消息内容刷爆日志) + for aio_name in ['aiohttp', 'aiohttp.access', 'aiohttp.web', 'aiohttp.websocket']: + logging.getLogger(aio_name).setLevel(logging.WARNING) + self.is_initialized = True logger.info(f"✅ 日志系统初始化完成 - 会话ID: {session_id}") logger.info(f"📝 运行时日志: logs/runtime/{runtime_log_file}") diff --git a/services/web_panel/routes/status.py b/services/web_panel/routes/status.py index 0c26319..528b0a5 100644 --- a/services/web_panel/routes/status.py +++ b/services/web_panel/routes/status.py @@ -1,20 +1,24 @@ import time +import json +import asyncio +import logging from aiohttp import web from ..utils.system_info import SystemInfoCollector collector = SystemInfoCollector() +logger = logging.getLogger(__name__) + +# Track active system-status WS clients +_sys_ws_clients: set = set() def setup_routes(app, prefix=''): app.router.add_get(f'{prefix}/api/framework', get_framework) app.router.add_get(f'{prefix}/api/system', get_system) + app.router.add_get(f'{prefix}/api/system/ws', sys_ws_handler) + logger.info(f"📡 系统状态WS端点已注册: {prefix}/api/system/ws") -async def get_framework(req): - sm = req.app.get('service_manager') - if not sm: return web.json_response({"error": "Missing"}, 500) - - ps = sm.get_service("plugin") - uptime = time.time() - getattr(sm, 'start_time', time.time()) - # Read version from config file +def _read_version(): + """Read version from config file (shared helper)""" ver = "v0.6.0" try: import yaml, os @@ -22,13 +26,66 @@ async def get_framework(req): with open(config_path) as f: cfg = yaml.safe_load(f) ver = cfg.get("framework", {}).get("version", ver) - except: pass + except: + pass + return ver - return web.json_response({ +def _get_framework_data(sm): + """Collect framework status (reused by HTTP and WS handlers)""" + ver = _read_version() + ps = sm.get_service("plugin") if sm else None + uptime = int(time.time() - getattr(sm, 'start_time', time.time())) + return { "version": ver, - "uptime": int(uptime), + "uptime": uptime, "plugins": len(ps.plugins) if ps else 0 - }) + } + +async def get_framework(req): + sm = req.app.get('service_manager') + if not sm: + return web.json_response({"error": "Missing"}, status=500) + return web.json_response(_get_framework_data(sm)) async def get_system(req): return web.json_response(collector.get_all()) + +async def sys_ws_handler(req): + """WebSocket push endpoint: system+framwork stats every 2s (no logging per message)""" + ws = web.WebSocketResponse(heartbeat=30.0) + await ws.prepare(req) + _sys_ws_clients.add(ws) + + async def push(): + """Send one snapshot to this client (silent on error)""" + try: + sm = req.app.get('service_manager') + payload = json.dumps({ + "type": "sys", + "system": collector.get_all(), + "framework": _get_framework_data(sm) + }, ensure_ascii=False) + if not ws.closed: + await ws.send_str(payload) + except Exception: + pass + + # Background push loop — runs until client disconnects + async def push_loop(): + while not ws.closed: + await push() + await asyncio.sleep(2) + + task = asyncio.ensure_future(push_loop()) + try: + async for msg in ws: + if msg.type == web.WSMsgType.ERROR: + break + finally: + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + _sys_ws_clients.discard(ws) + return ws diff --git a/services/web_panel/utils/system_info.py b/services/web_panel/utils/system_info.py index 5770b34..eb1e675 100644 --- a/services/web_panel/utils/system_info.py +++ b/services/web_panel/utils/system_info.py @@ -4,18 +4,10 @@ import os import time import platform import logging -from typing import Dict, Any +from typing import Dict, Any, Tuple logger = logging.getLogger(__name__) -def _is_android() -> bool: - """检测是否为 Android 环境 (Termux 等)""" - return ( - 'ANDROID_ROOT' in os.environ or - os.path.exists('/system/bin/getprop') or - platform.release().lower().find('android') != -1 - ) - class SystemInfoCollector: def __init__(self): @@ -30,7 +22,39 @@ class SystemInfoCollector: else: logger.info("🤖 Android 平台识别,启用原生采集") - def get_all(self): + # State for speed calculation (rx_bytes, tx_bytes, timestamp) + self._prev_net: Tuple[int, int, float] | None = None + + # ── Raw counters (used by both total and speed) ── + + def _read_net_bytes(self) -> Tuple[int, int]: + """Read raw (rx_bytes, tx_bytes) since boot from system""" + try: + if self.psutil: + io = self.psutil.net_io_counters() + return (io.bytes_recv, io.bytes_sent) + except Exception: + pass + + # Android / Linux: parse /proc/net/dev + try: + rx = tx = 0 + with open('/proc/net/dev', 'r') as f: + for line in f: + if ':' not in line or 'lo' in line: + continue + parts = line.split(':')[1].split() + rx += int(parts[0]) + if len(parts) > 8: + tx += int(parts[8]) + return (rx, tx) + except Exception as e: + logger.warning(f"Network read failed: {e}") + return (0, 0) + + # ── Public collectors ── + + def get_all(self) -> Dict[str, Any]: return { "platform": { "system": platform.system(), @@ -39,55 +63,91 @@ class SystemInfoCollector: }, "cpu": self._get_cpu(), "memory": self._get_memory(), - "network": self._get_network() + "process": self._get_process(), + "network": self._get_network(), + "net_speed": self._get_network_speed() } - def _get_cpu(self): + def _get_cpu(self) -> Dict[str, Any]: if self.psutil: return { "percent": self.psutil.cpu_percent(interval=0.1), "cores": self.psutil.cpu_count(), - "load_avg": os.getloadavg() if hasattr(os, 'getloadavg') else [0,0,0] + "load_avg": os.getloadavg() if hasattr(os, 'getloadavg') else [0, 0, 0] } - # Android 估算:负载率 = (1分钟负载 / 核心数) * 100 try: load = os.getloadavg() if hasattr(os, "getloadavg") else [0.0, 0.0, 0.0] cores = os.cpu_count() or 1 percent = min(100.0, (load[0] / cores) * 100) return {"percent": round(percent, 1), "cores": cores, "load_avg": load} except Exception as e: - logger.error(f"System info collection error: {e}", exc_info=True) - return {"percent": 0, "cores": 0, "load_avg": [0,0,0]} + logger.error(f"CPU info error: {e}", exc_info=True) + return {"percent": 0, "cores": 0, "load_avg": [0, 0, 0]} - def _get_memory(self): + def _get_memory(self) -> Dict[str, Any]: if self.psutil: m = self.psutil.virtual_memory() - return {"total_gb": round(m.total/1073741824, 1), "used_gb": round(m.used/1073741824, 1), "percent": m.percent} + return {"total_gb": round(m.total / 1073741824, 1), + "used_gb": round(m.used / 1073741824, 1), + "percent": m.percent} try: mem = {} with open('/proc/meminfo') as f: for line in f: parts = line.split() - if len(parts) >= 2: mem[parts[0].rstrip(':')] = int(parts[1]) * 1024 - t, a = mem.get('MemTotal', 1), mem.get('MemAvailable', mem.get('MemFree', 0)) - return {"total_gb": round(t/1073741824, 1), "used_gb": round((t-a)/1073741824, 1), "percent": round(((t-a)/t)*100, 1)} + if len(parts) >= 2: + mem[parts[0].rstrip(':')] = int(parts[1]) * 1024 + t = mem.get('MemTotal', 1) + a = mem.get('MemAvailable', mem.get('MemFree', 0)) + return {"total_gb": round(t / 1073741824, 1), + "used_gb": round((t - a) / 1073741824, 1), + "percent": round(((t - a) / t) * 100, 1)} except Exception as e: logger.warning(f"Memory info failed: {e}") return {"total_gb": 0, "used_gb": 0, "percent": 0} - def _get_network(self): - if self.psutil: - io = self.psutil.net_io_counters() - return {"rx": round(io.bytes_recv/1048576, 1), "tx": round(io.bytes_sent/1048576, 1)} - # Android 解析 /proc/net/dev + def _get_process(self) -> Dict[str, Any]: + """Current process info (PID + RSS memory)""" + pid = os.getpid() + mem_mb = 0 try: - rx = 0 - with open('/proc/net/dev', 'r') as f: - for line in f: - if ':' in line and 'lo' not in line: # 排除 lo 回环 - parts = line.split(':')[1].split() - rx += int(parts[0]) # RX bytes - return {"rx": round(rx/1048576, 1), "tx": 0} + if self.psutil: + import psutil + mem_mb = round(psutil.Process(pid).memory_info().rss / 1048576, 1) + else: + # Parse /proc/self/status for VmRSS + with open('/proc/self/status', 'r') as f: + for line in f: + if line.startswith('VmRSS:'): + mem_mb = round(int(line.split()[1]) / 1024, 1) + break + except Exception as e: + logger.debug(f"Process info failed: {e}") + return {"pid": pid, "memory_mb": mem_mb} + + def _get_network(self) -> Dict[str, Any]: + """Cumulative RX/TX since boot (MB)""" + try: + rx, tx = self._read_net_bytes() + return {"rx_mb": round(rx / 1048576, 1), + "tx_mb": round(tx / 1048576, 1)} except Exception as e: logger.warning(f"Network info failed: {e}") - return {"rx": 0, "tx": 0} + return {"rx_mb": 0, "tx_mb": 0} + + def _get_network_speed(self) -> Dict[str, Any]: + """Real-time RX/TX speed (bytes/sec), computed from delta between calls""" + now = time.time() + rx, tx = self._read_net_bytes() + + if self._prev_net: + prx, ptx, pt = self._prev_net + dt = now - pt + speed_rx = (rx - prx) / dt if dt > 0 and rx >= prx else 0 + speed_tx = (tx - ptx) / dt if dt > 0 and tx >= ptx else 0 + else: + speed_rx = speed_tx = 0 + + self._prev_net = (rx, tx, now) + return {"rx_bytes_sec": round(speed_rx, 1), + "tx_bytes_sec": round(speed_tx, 1)} diff --git a/static/web_panel/css/style.css b/static/web_panel/css/style.css index 8a080fb..68cbc8d 100644 --- a/static/web_panel/css/style.css +++ b/static/web_panel/css/style.css @@ -118,8 +118,8 @@ h3{font-size:1rem;font-weight:500;letter-spacing:.15px} .btn:disabled{opacity:.38;pointer-events:none} /* ── MD3 Input ── */ -.input-group{margin-bottom:16px} -.input-group .label{display:block;margin-bottom:6px;color:var(--text-dim)} +.input-group{margin-bottom:16px;text-align:left} +.input-group label{display:block;margin-bottom:6px;color:var(--text-dim);font-size:.8rem;font-weight:500;letter-spacing:.3px} .input{ width:100%;padding:12px 16px;background:var(--md-sys-color-surface-container-lowest); border:1px solid var(--outline);border-radius:var(--shape-xs);color:var(--text); @@ -189,6 +189,7 @@ h3{font-size:1rem;font-weight:500;letter-spacing:.15px} .nav-item:hover{background:rgba(208,188,255,.08);color:var(--text)} .nav-item.active{background:var(--primary-container);color:var(--md-sys-color-on-primary-container)} .nav-item .nav-icon{width:24px;height:24px;flex-shrink:0;display:flex;align-items:center;justify-content:center} +.nav-item svg{width:20px;height:20px;flex-shrink:0} .nav-item .nav-label{overflow:hidden;text-overflow:ellipsis} .toggle-sidebar{ margin-top:auto;padding:16px;text-align:center;cursor:pointer; @@ -425,4 +426,3 @@ h3{font-size:1rem;font-weight:500;letter-spacing:.15px} /* ── Menu reveal animation for login error ── */ .err-msg{transition:opacity .3s} - diff --git a/static/web_panel/home.html b/static/web_panel/home.html index 50f6375..39e9244 100644 --- a/static/web_panel/home.html +++ b/static/web_panel/home.html @@ -19,27 +19,27 @@