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:
@@ -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
|
||||
|
||||
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user