feat: WS实时推送、网速双线图、按钮MD3修复、页面加载器修复 (v0.6.0)

后端:
- 新增 /api/system/ws WebSocket端点 替代HTTP轮询 (每2s推送系统+框架数据)
- 修复 uptime始终为0 (sm.start_time时间戳)
- 网络采集新增TX上行+实时网速(delta法)
- 进程内存采集 (/proc/self/status VmRSS)
- 抑制aiohttp内部WS帧日志防刷爆日志文件

前端:
- 仪表盘: HTTP轮询→WS连接+指数退避自动重连
- 实时日志: 修复onclose重连bug+批量渲染30fps+500行上限防卡死
- 网速卡片: DualLineChart双线图(下行实线/上行虚线)
- Y轴零点偏移-5% 防止零网速贴底
- 所有按钮修复MD3组合类(btn+btn-tonal+btn-sm)
- 页面加载器: 改动态script元素执行 修复onclick全局作用域问题
- emoji图标→Material Design SVG图标
- CSS去残留</style>+新增.nav-item svg约束
- 登录页输入框+标签左对齐+按钮MD3样式
- 多个版本号显示修复(去双重v前缀)
- chart.js/app.js/HTML页面统一加版本号防浏览器缓存
- .gitignore新增docs/目录

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
qinglong
2026-06-11 16:23:31 +08:00
parent fd0a97fec5
commit 4a989bf50f
24 changed files with 759 additions and 212 deletions
+4
View File
@@ -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}")
+68 -11
View File
@@ -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
+94 -34
View File
@@ -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)}