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:
@@ -10,3 +10,4 @@ _patches_applied/
|
||||
*.swo
|
||||
*~
|
||||
.DS_Store
|
||||
docs/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
http_port: 4200
|
||||
last_updated: 279719.566351315
|
||||
last_updated: 283744.338585717
|
||||
plugin_routes:
|
||||
example_plugin:
|
||||
- methods:
|
||||
|
||||
@@ -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
|
||||
|
||||
# 显示欢迎日志
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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:
|
||||
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 ':' 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 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)}
|
||||
|
||||
@@ -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}
|
||||
</style>
|
||||
|
||||
@@ -19,27 +19,27 @@
|
||||
|
||||
<aside class="sidebar">
|
||||
<a class="nav-item active" data-page="dashboard">
|
||||
<svg viewBox="0 0 24 24"><path d="M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z"/></svg>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24"><path d="M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z"/></svg>
|
||||
<span>仪表盘</span>
|
||||
</a>
|
||||
<a class="nav-item" data-page="logs">
|
||||
<svg viewBox="0 0 24 24"><path d="M14 2H6c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm2 16H8v-2h8v2zm0-4H8v-2h8v2zm-3-5V3.5L18.5 9H13z"/></svg>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24"><path d="M14 2H6c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm2 16H8v-2h8v2zm0-4H8v-2h8v2zm-3-5V3.5L18.5 9H13z"/></svg>
|
||||
<span>实时日志</span>
|
||||
</a>
|
||||
<a class="nav-item" data-page="console">
|
||||
<svg viewBox="0 0 24 24"><path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm-8 14H4v-4h8v4zm0-6H4V8h8v4zm8 6h-8v-4h8v4zm0-6h-8V8h8v4z"/></svg>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24"><path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm-8 14H4v-4h8v4zm0-6H4V8h8v4zm8 6h-8v-4h8v4zm0-6h-8V8h8v4z"/></svg>
|
||||
<span>控制台</span>
|
||||
</a>
|
||||
<a class="nav-item" data-page="plugins">
|
||||
<svg viewBox="0 0 24 24"><path d="M20.5 11H19V7c0-1.1-.9-2-2-2h-4V3.5C13 2.12 11.88 1 10.5 1S8 2.12 8 3.5V5H4c-1.1 0-1.99.9-1.99 2v3.8H3.5c1.49 0 2.7 1.21 2.7 2.7s-1.21 2.7-2.7 2.7H2V20c0 1.1.9 2 2 2h3.8v-1.5c0-1.49 1.21-2.7 2.7-2.7 1.49 0 2.7 1.21 2.7 2.7V22H17c1.1 0 2-.9 2-2v-4h1.5c1.38 0 2.5-1.12 2.5-2.5S21.88 11 20.5 11z"/></svg>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24"><path d="M20.5 11H19V7c0-1.1-.9-2-2-2h-4V3.5C13 2.12 11.88 1 10.5 1S8 2.12 8 3.5V5H4c-1.1 0-1.99.9-1.99 2v3.8H3.5c1.49 0 2.7 1.21 2.7 2.7s-1.21 2.7-2.7 2.7H2V20c0 1.1.9 2 2 2h3.8v-1.5c0-1.49 1.21-2.7 2.7-2.7 1.49 0 2.7 1.21 2.7 2.7V22H17c1.1 0 2-.9 2-2v-4h1.5c1.38 0 2.5-1.12 2.5-2.5S21.88 11 20.5 11z"/></svg>
|
||||
<span>插件管理</span>
|
||||
</a>
|
||||
<a class="nav-item" data-page="projects">
|
||||
<svg viewBox="0 0 24 24"><path d="M20 6h-8l-2-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2zm0 12H4V6h5.17l2 2H20v10z"/></svg>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24"><path d="M20 6h-8l-2-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2zm0 12H4V6h5.17l2 2H20v10z"/></svg>
|
||||
<span>项目管理</span>
|
||||
</a>
|
||||
<a class="nav-item" data-page="proxy">
|
||||
<svg viewBox="0 0 24 24"><path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/></svg>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24"><path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/></svg>
|
||||
<span>反向代理</span>
|
||||
</a>
|
||||
<div class="toggle-sidebar" onclick="toggleSidebar()">☰</div>
|
||||
@@ -52,9 +52,9 @@
|
||||
</div>
|
||||
|
||||
<!-- 🟢 1. 先加载图表库 -->
|
||||
<script src="./static/js/chart.js"></script>
|
||||
<script src="./static/js/chart.js?v=0603"></script>
|
||||
|
||||
<!-- 🟢 2. 再加载主逻辑 -->
|
||||
<script src="./static/js/app.js"></script>
|
||||
<script src="./static/js/app.js?v=0601"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
<div class="login-wrapper">
|
||||
<div class="login-card">
|
||||
<h2>🐱 SenSu Login</h2>
|
||||
<div class="input-group"><label>用户名</label><input type="text" id="u" value="admin"></div>
|
||||
<div class="input-group"><label>密码</label><input type="password" id="p" value="admin"></div>
|
||||
<button class="btn-primary" onclick="doLogin()">进入系统</button>
|
||||
<div class="input-group"><label>用户名</label><input class="input" type="text" id="u" value="admin"></div>
|
||||
<div class="input-group"><label>密码</label><input class="input" type="password" id="p" value="admin"></div>
|
||||
<button class="btn btn-filled" style="width:100%;margin-top:8px" onclick="doLogin()">进入系统</button>
|
||||
<div id="err" class="err-msg"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -55,21 +55,30 @@ async function loadPage(pageName) {
|
||||
|
||||
content.innerHTML = cleanHtml;
|
||||
|
||||
// Execute inline scripts
|
||||
// Execute inline scripts via dynamic <script> tag so function
|
||||
// declarations become globally accessible (onclick handlers need them)
|
||||
for(const code of scripts) {
|
||||
try { (new Function(code)).call(window); } catch(e) { console.error('Inline script error:', e); }
|
||||
try {
|
||||
const s = document.createElement('script');
|
||||
s.textContent = code;
|
||||
document.head.appendChild(s);
|
||||
document.head.removeChild(s);
|
||||
} catch(e) { console.error('Inline script error:', e); }
|
||||
}
|
||||
|
||||
// Load external JS module (optional — skip if 404)
|
||||
// Load external JS module the same way (optional — skip if 404)
|
||||
try {
|
||||
const jsResp = await fetch(`./static/pages/${pageName}.js?t=${Date.now()}`);
|
||||
if(jsResp.ok) {
|
||||
const jsCode = await jsResp.text();
|
||||
(new Function(jsCode)).call(window);
|
||||
const s = document.createElement('script');
|
||||
s.textContent = jsCode;
|
||||
document.head.appendChild(s);
|
||||
document.head.removeChild(s);
|
||||
const moduleName = pageName.charAt(0).toUpperCase() + pageName.slice(1) + 'Module';
|
||||
if(window[moduleName]?.init) window[moduleName].init();
|
||||
}
|
||||
} catch(e) { /* JS module optional */ }
|
||||
} catch(e) { console.error('Page JS error:', pageName, e); }
|
||||
|
||||
bar.style.width = '100%';
|
||||
setTimeout(() => bar.classList.remove('active'), 200);
|
||||
|
||||
@@ -11,7 +11,7 @@ class MiniChart {
|
||||
}
|
||||
|
||||
resize() {
|
||||
const rect = this.canvas.parentElement.getBoundingClientRect();
|
||||
var rect = this.canvas.parentElement.getBoundingClientRect();
|
||||
this.canvas.width = rect.width - 24;
|
||||
this.canvas.height = 60;
|
||||
this.draw();
|
||||
@@ -19,33 +19,112 @@ class MiniChart {
|
||||
|
||||
update(val) {
|
||||
this.data.push(val);
|
||||
if(this.data.length > 60) this.data.shift();
|
||||
this.maxVal = Math.max(...this.data, 100);
|
||||
if (this.data.length > 60) this.data.shift();
|
||||
this.maxVal = Math.max.apply(null, this.data.concat([100]));
|
||||
this.draw();
|
||||
}
|
||||
|
||||
draw() {
|
||||
if(!this.ctx) return;
|
||||
const { width, height } = this.canvas;
|
||||
this.ctx.clearRect(0, 0, width, height);
|
||||
if (!this.ctx) return;
|
||||
var ctx = this.ctx;
|
||||
var w = this.canvas.width;
|
||||
var h = this.canvas.height;
|
||||
|
||||
this.ctx.strokeStyle = this.color;
|
||||
this.ctx.lineWidth = 2;
|
||||
this.ctx.beginPath();
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
this.data.forEach((v, i) => {
|
||||
const x = (i / 59) * width;
|
||||
const y = height - (v / this.maxVal) * (height - 10);
|
||||
if(i === 0) this.ctx.moveTo(x, y);
|
||||
else this.ctx.lineTo(x, y);
|
||||
ctx.strokeStyle = this.color;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
|
||||
var self = this;
|
||||
this.data.forEach(function(v, i) {
|
||||
var x = (i / 59) * w;
|
||||
var y = h - (v / self.maxVal) * (h - 10);
|
||||
if (i === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
});
|
||||
this.ctx.stroke();
|
||||
ctx.stroke();
|
||||
|
||||
// 填充渐变
|
||||
this.ctx.lineTo(width, height);
|
||||
this.ctx.lineTo(0, height);
|
||||
this.ctx.fillStyle = this.color + '20';
|
||||
this.ctx.fill();
|
||||
ctx.lineTo(w, h);
|
||||
ctx.lineTo(0, h);
|
||||
ctx.fillStyle = this.color + '20';
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Dual-line chart (e.g. download + upload speed) ── */
|
||||
class DualLineChart {
|
||||
constructor(canvasId, colorA, colorB) {
|
||||
this.canvas = document.getElementById(canvasId);
|
||||
this.ctx = this.canvas.getContext('2d');
|
||||
this.colorA = colorA || '#bb9af7'; // solid line (download)
|
||||
this.colorB = colorB || '#c0a8f0'; // dashed line (upload)
|
||||
this.dataA = new Array(60).fill(0);
|
||||
this.dataB = new Array(60).fill(0);
|
||||
this.maxVal = 100;
|
||||
this.applyResize();
|
||||
window.addEventListener('resize', () => this.applyResize());
|
||||
}
|
||||
|
||||
applyResize() {
|
||||
var rect = this.canvas.parentElement.getBoundingClientRect();
|
||||
this.canvas.width = rect.width - 24;
|
||||
this.canvas.height = 60;
|
||||
this.draw();
|
||||
}
|
||||
|
||||
update(valA, valB) {
|
||||
this.dataA.push(valA);
|
||||
this.dataB.push(valB);
|
||||
if (this.dataA.length > 60) { this.dataA.shift(); this.dataB.shift(); }
|
||||
var all = this.dataA.concat(this.dataB).concat([100]);
|
||||
this.maxVal = Math.max.apply(null, all);
|
||||
this.draw();
|
||||
}
|
||||
|
||||
draw() {
|
||||
if (!this.ctx) return;
|
||||
var ctx = this.ctx;
|
||||
var w = this.canvas.width;
|
||||
var h = this.canvas.height;
|
||||
|
||||
var self = this;
|
||||
|
||||
// Y-axis: range from -5% to 100%, so zero never hugs the bottom
|
||||
var pad = Math.max(10, self.maxVal * 0.05);
|
||||
var range = self.maxVal + pad; // e.g. 0..100 becomes -5..100
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
// ── Line A: solid (download) ──
|
||||
ctx.strokeStyle = this.colorA;
|
||||
ctx.lineWidth = 1.8;
|
||||
ctx.setLineDash([]);
|
||||
ctx.beginPath();
|
||||
this.dataA.forEach(function(v, i) {
|
||||
var x = (i / 59) * w;
|
||||
var y = h - ((v + pad) / range) * (h - 8);
|
||||
if (i === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
});
|
||||
ctx.stroke();
|
||||
|
||||
// ── Line B: dashed (upload) ──
|
||||
ctx.strokeStyle = this.colorB;
|
||||
ctx.lineWidth = 1.4;
|
||||
ctx.setLineDash([4, 4]);
|
||||
ctx.beginPath();
|
||||
this.dataB.forEach(function(v, i) {
|
||||
var x = (i / 59) * w;
|
||||
var y = h - ((v + pad) / range) * (h - 8);
|
||||
if (i === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
});
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
}
|
||||
|
||||
window.MiniChart = MiniChart;
|
||||
window.DualLineChart = DualLineChart;
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
framework:
|
||||
debug: true
|
||||
name: SenSu
|
||||
version: Alpha_0.2.0
|
||||
logging:
|
||||
debug_level_file: true
|
||||
level: INFO
|
||||
max_file_size: 10MB
|
||||
max_log_files: 20
|
||||
plugins:
|
||||
auto_load: true
|
||||
hot_reload: true
|
||||
max_retry_count: 3
|
||||
services:
|
||||
internet:
|
||||
api_port: 8000
|
||||
enable_reverse_proxy: false
|
||||
ws_port: 8765
|
||||
tui:
|
||||
layout:
|
||||
grid-rows: 4fr 5fr 1fr
|
||||
@@ -0,0 +1,12 @@
|
||||
admin_permissions:
|
||||
- framework.*
|
||||
- plugin.*
|
||||
- service.*
|
||||
default_permissions:
|
||||
- framework.status.read
|
||||
- plugin.self.info.read
|
||||
permission_levels:
|
||||
- read
|
||||
- write
|
||||
- execute
|
||||
- admin
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1,89 @@
|
||||
commands:
|
||||
autoscroll:
|
||||
description: '滚动控制: 切换自动滚动'
|
||||
permissions:
|
||||
- framework.tui.control
|
||||
source: internal
|
||||
create-plugin:
|
||||
description: 创建新插件脚手架
|
||||
permissions:
|
||||
- framework.scaffold.plugin
|
||||
source: internal
|
||||
help:
|
||||
description: 显示帮助信息
|
||||
permissions:
|
||||
- framework.command.help.read
|
||||
source: internal
|
||||
history:
|
||||
description: 显示命令历史
|
||||
permissions:
|
||||
- framework.command.history.read
|
||||
source: internal
|
||||
netdiag:
|
||||
description: 网络服务诊断
|
||||
permissions:
|
||||
- framework.network.diagnose
|
||||
source: internal
|
||||
permissions:
|
||||
description: '权限管理: 显示权限状态'
|
||||
permissions:
|
||||
- framework.permission.read
|
||||
source: internal
|
||||
pm_plugin_status:
|
||||
description: '权限管理: 查看插件权限状态'
|
||||
permissions:
|
||||
- framework.permission.read
|
||||
source: internal
|
||||
pmallow:
|
||||
description: '权限管理: 同意权限请求'
|
||||
permissions:
|
||||
- framework.permission.read
|
||||
source: internal
|
||||
pmdeny:
|
||||
description: '权限管理: 拒绝权限请求'
|
||||
permissions:
|
||||
- framework.permission.read
|
||||
source: internal
|
||||
pmhelp:
|
||||
description: '权限管理: 显示权限命令帮助'
|
||||
permissions:
|
||||
- framework.permission.read
|
||||
source: internal
|
||||
pmignore:
|
||||
description: '权限管理: 暂时忽略权限请求'
|
||||
permissions:
|
||||
- framework.permission.read
|
||||
source: internal
|
||||
pmpending:
|
||||
description: '权限管理: 查看待授权请求列表'
|
||||
permissions:
|
||||
- framework.permission.read
|
||||
source: internal
|
||||
pmrequests:
|
||||
description: '权限管理: 查看待授权请求列表(别名)'
|
||||
permissions:
|
||||
- framework.permission.read
|
||||
source: internal
|
||||
pmtest:
|
||||
description: '权限管理: 测试权限配置文件'
|
||||
permissions:
|
||||
- framework.permission.read
|
||||
source: internal
|
||||
scroll:
|
||||
description: '滚动控制: 手动滚动到底部'
|
||||
permissions:
|
||||
- framework.tui.control
|
||||
source: internal
|
||||
status:
|
||||
description: 显示框架状态
|
||||
permissions:
|
||||
- framework.status.read
|
||||
source: internal
|
||||
testlog:
|
||||
description: 生成测试日志
|
||||
permissions:
|
||||
- framework.command.test
|
||||
source: internal
|
||||
last_updated: 283025.170846772
|
||||
plugin_commands: {}
|
||||
total_commands: 17
|
||||
@@ -23,10 +23,21 @@
|
||||
<canvas id="chart-cpu" class="mini-chart"></canvas>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>🌐 网络接收</h3>
|
||||
<div class="stat-value" id="d-net">--</div>
|
||||
<h3>🌐 网络流量</h3>
|
||||
<div style="display:flex; gap:16px">
|
||||
<div><div class="stat-sub" style="font-size:.65rem">下行</div><div class="stat-value" id="d-net-rx" style="font-size:1.4rem">--</div></div>
|
||||
<div><div class="stat-sub" style="font-size:.65rem">上行</div><div class="stat-value" id="d-net-tx" style="font-size:1.4rem">--</div></div>
|
||||
</div>
|
||||
<canvas id="chart-net" class="mini-chart"></canvas>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>⚡ 实时网速</h3>
|
||||
<div style="display:flex; gap:16px">
|
||||
<div><div class="stat-sub" style="font-size:.65rem">↓ 下载</div><div class="stat-value" id="d-speed-rx" style="font-size:1.4rem">--</div></div>
|
||||
<div><div class="stat-sub" style="font-size:.65rem">↑ 上传</div><div class="stat-value" id="d-speed-tx" style="font-size:1.4rem">--</div></div>
|
||||
</div>
|
||||
<canvas id="chart-speed" class="mini-chart"></canvas>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>💾 进程内存</h3>
|
||||
<div class="stat-value" id="d-proc-mem">--</div>
|
||||
@@ -60,11 +71,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3. 快捷操作 (占位) -->
|
||||
<!-- 3. 快捷操作 -->
|
||||
<div class="side-card">
|
||||
<h3>🛠️ 快捷操作</h3>
|
||||
<button class="btn-sm" style="width:100%; margin-bottom:6px" onclick="window.PluginsModule?.refresh()">🔄 刷新插件列表</button>
|
||||
<button class="btn-sm" style="width:100%; color:var(--error); border-color:var(--error)" onclick="doLogout()">🚪 退出登录</button>
|
||||
<h3>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" style="vertical-align:-3px;margin-right:6px"><path fill="var(--primary)" d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94L14.4 2.81c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41L9.25 5.35c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/></svg>
|
||||
快捷操作
|
||||
</h3>
|
||||
<button class="btn btn-sm btn-tonal" style="width:100%;margin-bottom:6px" onclick="window.PluginsModule?.refresh()">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24"><path fill="currentColor" d="M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>
|
||||
刷新插件列表
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outlined" style="width:100%;color:var(--error);border-color:var(--error)" onclick="doLogout()">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24"><path fill="currentColor" d="M17 7l-1.41 1.41L18.17 11H8v2h10.17l-2.58 2.58L17 17l5-5zM4 5h8V3H4c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h8v-2H4V5z"/></svg>
|
||||
退出登录
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
@@ -1,105 +1,182 @@
|
||||
window.DashboardModule = {
|
||||
ws: null,
|
||||
reconnectTimer: null,
|
||||
reconnectDelay: 1000,
|
||||
charts: {},
|
||||
init: () => {
|
||||
// 1. 初始化图表实例
|
||||
window.DashboardModule.charts = {
|
||||
|
||||
init: function() {
|
||||
var self = this;
|
||||
|
||||
// 初始化图表
|
||||
self.charts = {
|
||||
mem: new MiniChart('chart-mem', '#9ece6a'),
|
||||
cpu: new MiniChart('chart-cpu', '#7aa2f7'),
|
||||
net: new MiniChart('chart-net', '#e0af68'),
|
||||
speed: new DualLineChart('chart-speed', '#bb9af7', '#c0a8f0'),
|
||||
proc_mem: new MiniChart('chart-proc-mem', '#f7768e')
|
||||
};
|
||||
|
||||
// 2. 获取并填充右侧固定信息 (只获取一次即可,除非重启)
|
||||
// 静态信息 (HTTP 一次)
|
||||
fetchSystemStaticInfo();
|
||||
|
||||
// 3. 启动实时数据轮询
|
||||
fetchDash();
|
||||
window._dashInterval = setInterval(fetchDash, 2000); // 2秒刷新
|
||||
// WebSocket 实时推送
|
||||
self._connect();
|
||||
},
|
||||
destroy: () => {
|
||||
clearInterval(window._dashInterval);
|
||||
window.DashboardModule.charts = {};
|
||||
|
||||
_connect: function() {
|
||||
var self = this;
|
||||
if (self.ws) {
|
||||
self.ws.onclose = null;
|
||||
self.ws.close();
|
||||
self.ws = null;
|
||||
}
|
||||
|
||||
var base = window.location.pathname.split("/").slice(0, 2).join("/");
|
||||
var ws = new WebSocket("ws://" + location.host + base + "/api/system/ws");
|
||||
self.ws = ws;
|
||||
|
||||
ws.onopen = function() {
|
||||
self.reconnectDelay = 1000;
|
||||
};
|
||||
|
||||
ws.onmessage = function(e) {
|
||||
try {
|
||||
var d = JSON.parse(e.data);
|
||||
if (d.type === "sys") {
|
||||
self._updateUI(d.system, d.framework);
|
||||
}
|
||||
} catch(ex) {}
|
||||
};
|
||||
|
||||
ws.onclose = function() {
|
||||
self.ws = null;
|
||||
self.reconnectTimer = setTimeout(function() {
|
||||
self.reconnectDelay = Math.min(self.reconnectDelay * 1.5, 15000);
|
||||
self._connect();
|
||||
}, self.reconnectDelay);
|
||||
};
|
||||
|
||||
ws.onerror = function() { ws.close(); };
|
||||
},
|
||||
|
||||
_updateUI: function(sys, fw) {
|
||||
// ── 框架卡片 ──
|
||||
if (fw) {
|
||||
var el;
|
||||
el = document.getElementById('d-uptime'); if (el) el.textContent = formatUptime(fw.uptime || 0);
|
||||
el = document.getElementById('d-plugins'); if (el) el.textContent = fw.plugins || 0;
|
||||
el = document.getElementById('d-ver-badge'); if (el) el.textContent = fw.version || '?';
|
||||
}
|
||||
if (!sys) return;
|
||||
var self = this;
|
||||
|
||||
// ── 内存 ──
|
||||
if (sys.memory) {
|
||||
var m = sys.memory.percent || 0;
|
||||
var el = document.getElementById('d-mem'); if (el) el.textContent = m + '%';
|
||||
self.charts.mem.update(m);
|
||||
}
|
||||
|
||||
// ── CPU ──
|
||||
if (sys.cpu) {
|
||||
var cpuVal = sys.cpu.percent;
|
||||
if (cpuVal === null || cpuVal === undefined) {
|
||||
var load = (sys.cpu.load_avg && sys.cpu.load_avg[0]) ? sys.cpu.load_avg[0] : 0;
|
||||
cpuVal = Math.min(100, (load / (sys.cpu.cores || 1)) * 100);
|
||||
}
|
||||
var el = document.getElementById('d-cpu'); if (el) el.textContent = Math.round(cpuVal) + '%';
|
||||
self.charts.cpu.update(cpuVal);
|
||||
|
||||
if (sys.cpu.load_avg) {
|
||||
el = document.getElementById('info-load'); if (el) el.textContent = sys.cpu.load_avg[2].toFixed(2);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 网络累计流量 (RX + TX) ──
|
||||
if (sys.network) {
|
||||
var el;
|
||||
el = document.getElementById('d-net-rx'); if (el) el.textContent = formatTraffic(sys.network.rx_mb);
|
||||
el = document.getElementById('d-net-tx'); if (el) el.textContent = formatTraffic(sys.network.tx_mb);
|
||||
// Chart shows RX trend
|
||||
self.charts.net.update(sys.network.rx_mb || 0);
|
||||
}
|
||||
|
||||
// ── 实时网速 ──
|
||||
if (sys.net_speed) {
|
||||
var el;
|
||||
el = document.getElementById('d-speed-rx');
|
||||
if (el) el.textContent = formatSpeed(sys.net_speed.rx_bytes_sec);
|
||||
el = document.getElementById('d-speed-tx');
|
||||
if (el) el.textContent = formatSpeed(sys.net_speed.tx_bytes_sec);
|
||||
// Chart: RX solid line, TX dashed line
|
||||
self.charts.speed.update(sys.net_speed.rx_bytes_sec || 0, sys.net_speed.tx_bytes_sec || 0);
|
||||
}
|
||||
|
||||
// ── 进程内存 ──
|
||||
if (sys.process && sys.process.memory_mb !== undefined) {
|
||||
var pm = sys.process.memory_mb || 0;
|
||||
var el = document.getElementById('d-proc-mem'); if (el) el.textContent = pm + ' MB';
|
||||
self.charts.proc_mem.update(pm);
|
||||
}
|
||||
if (sys.process && sys.process.pid !== undefined) {
|
||||
var el = document.getElementById('info-pid'); if (el) el.textContent = sys.process.pid;
|
||||
}
|
||||
},
|
||||
|
||||
destroy: function() {
|
||||
if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; }
|
||||
if (this.ws) { this.ws.onclose = null; this.ws.close(); this.ws = null; }
|
||||
this.charts = {};
|
||||
}
|
||||
};
|
||||
|
||||
/* ── 静态信息 ── */
|
||||
async function fetchSystemStaticInfo() {
|
||||
try {
|
||||
const sys = await fetch('./api/system', {credentials:'include'}).then(r => r.json());
|
||||
|
||||
// 硬件信息
|
||||
if(sys.platform) {
|
||||
document.getElementById('info-os').textContent = sys.platform.system || '--';
|
||||
document.getElementById('info-arch').textContent = sys.platform.machine || '--';
|
||||
document.getElementById('info-env').textContent = sys.platform.env || 'Standard';
|
||||
var sys = await fetch('./api/system', {credentials:'include'}).then(function(r){return r.json();});
|
||||
var el;
|
||||
if (sys.platform) {
|
||||
el = document.getElementById('info-os'); if (el) el.textContent = sys.platform.system || '--';
|
||||
el = document.getElementById('info-arch'); if (el) el.textContent = sys.platform.machine || '--';
|
||||
el = document.getElementById('info-env'); if (el) el.textContent = sys.platform.env || 'Standard';
|
||||
}
|
||||
if(sys.cpu) {
|
||||
const c = sys.cpu.cores || 0;
|
||||
document.getElementById('info-cores').textContent = `${c} / ${c}`; // Android下通常逻辑核=物理核
|
||||
if (sys.cpu) {
|
||||
var c = sys.cpu.cores || 0;
|
||||
el = document.getElementById('info-cores'); if (el) el.textContent = c + ' / ' + c;
|
||||
}
|
||||
if(sys.memory) {
|
||||
document.getElementById('info-mem-total').textContent = sys.memory.total_gb + ' GB';
|
||||
if (sys.memory) {
|
||||
el = document.getElementById('info-mem-total'); if (el) el.textContent = sys.memory.total_gb + ' GB';
|
||||
}
|
||||
|
||||
// 框架信息 (部分需结合 API)
|
||||
const host = window.location.hostname + (window.location.port ? ':'+window.location.port : '');
|
||||
document.getElementById('info-addr').textContent = host;
|
||||
|
||||
// 框架信息 (部分来自 API)
|
||||
try {
|
||||
var fw = await fetch('./api/framework', {credentials:'include'}).then(function(r){return r.json();});
|
||||
el = document.getElementById('info-fw-ver'); if (el) el.textContent = fw.version || '?';
|
||||
} catch(e) {}
|
||||
var host = window.location.hostname + (window.location.port ? ':' + window.location.port : '');
|
||||
el = document.getElementById('info-addr'); if (el) el.textContent = host;
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
async function fetchDash() {
|
||||
try {
|
||||
const fw = await fetch('./api/framework', {credentials:'include'}).then(r => r.json());
|
||||
const sys = await fetch('./api/system', {credentials:'include'}).then(r => r.json());
|
||||
|
||||
// --- 左侧动态数据更新 ---
|
||||
if(fw) {
|
||||
document.getElementById('d-uptime').textContent = formatUptime(fw.uptime || 0);
|
||||
document.getElementById('d-plugins').textContent = fw.plugins || 0;
|
||||
if(document.getElementById('d-ver-badge')) document.getElementById('d-ver-badge').textContent = 'v' + (fw.version||'?');
|
||||
}
|
||||
|
||||
if(sys) {
|
||||
// 内存
|
||||
const m = sys.memory?.percent || 0;
|
||||
document.getElementById('d-mem').textContent = m + '%';
|
||||
window.DashboardModule.charts.mem.update(m);
|
||||
|
||||
// CPU (兼容 Android null 情况)
|
||||
let cpuVal = sys.cpu?.percent;
|
||||
if (cpuVal === null || cpuVal === undefined) {
|
||||
const load = sys.cpu?.load_avg?.[0] || 0;
|
||||
const cores = sys.cpu?.cores || 1;
|
||||
cpuVal = Math.min(100, (load / cores) * 100);
|
||||
}
|
||||
document.getElementById('d-cpu').textContent = Math.round(cpuVal) + '%';
|
||||
window.DashboardModule.charts.cpu.update(cpuVal);
|
||||
|
||||
// 网络 (RX 总量)
|
||||
const netRx = sys.network?.rx || 0;
|
||||
document.getElementById('d-net').textContent = netRx + ' MB';
|
||||
window.DashboardModule.charts.net.update(netRx); // 图表显示总流量趋势
|
||||
|
||||
// 进程内存
|
||||
const pm = sys.process?.memory_mb || 0;
|
||||
document.getElementById('d-proc-mem').textContent = pm + ' MB';
|
||||
window.DashboardModule.charts.proc_mem.update(pm);
|
||||
|
||||
// --- 右侧动态数据更新 ---
|
||||
if(sys.process) {
|
||||
document.getElementById('info-pid').textContent = sys.process.pid || '--';
|
||||
}
|
||||
if(sys.cpu?.load_avg) {
|
||||
document.getElementById('info-load').textContent = sys.cpu.load_avg[2].toFixed(2);
|
||||
}
|
||||
}
|
||||
} catch(e) { console.warn("Dashboard fetch error", e); }
|
||||
}
|
||||
|
||||
// 辅助:秒数转时间格式
|
||||
/* ── 格式化 ── */
|
||||
function formatUptime(seconds) {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
return `${h}h ${m}m ${s}s`;
|
||||
var h = Math.floor(seconds / 3600);
|
||||
var m = Math.floor((seconds % 3600) / 60);
|
||||
var s = Math.floor(seconds % 60);
|
||||
if (h > 0) return h + 'h ' + m + 'm';
|
||||
if (m > 0) return m + 'm ' + s + 's';
|
||||
return s + 's';
|
||||
}
|
||||
|
||||
function formatTraffic(mb) {
|
||||
if (mb === undefined || mb === null) return '--';
|
||||
if (mb >= 1024) return (mb / 1024).toFixed(1) + ' GB';
|
||||
if (mb >= 1) return mb.toFixed(1) + ' MB';
|
||||
return (mb * 1024).toFixed(0) + ' KB';
|
||||
}
|
||||
|
||||
function formatSpeed(bytesPerSec) {
|
||||
if (bytesPerSec === undefined || bytesPerSec === null || bytesPerSec < 0) return '0 B/s';
|
||||
if (bytesPerSec >= 1048576) return (bytesPerSec / 1048576).toFixed(1) + ' MB/s';
|
||||
if (bytesPerSec >= 1024) return (bytesPerSec / 1024).toFixed(0) + ' KB/s';
|
||||
return bytesPerSec.toFixed(0) + ' B/s';
|
||||
}
|
||||
|
||||
+128
-21
@@ -1,25 +1,132 @@
|
||||
window.LogsModule = {
|
||||
ws: null,
|
||||
init: () => {
|
||||
const box = document.getElementById("log-box");
|
||||
const base = window.location.pathname.split("/").slice(0,2).join("/");
|
||||
const connect = () => {
|
||||
window.LogsModule.ws = new WebSocket("ws://"+location.host+base+"/api/logs/ws");
|
||||
window.LogsModule.ws.onopen = () => box.innerHTML += "<div style=\"color:var(--success)\">Connected</div>";
|
||||
window.LogsModule.ws.onmessage = e => {
|
||||
try {
|
||||
const d = JSON.parse(e.data);
|
||||
if(d.type === "log") {
|
||||
const cls = d.level === "ERROR" ? "log-ERROR" : d.level === "WARNING" ? "log-WARNING" : "log-INFO";
|
||||
const t = d.timestamp ? new Date(d.timestamp*1000).toLocaleTimeString() : "--";
|
||||
box.innerHTML += "<div class=\"log-entry\"><span style=\"color:#555;margin-right:5px\">"+t+"</span><span class=\""+cls+"\">["+d.level+"]</span> "+d.message+"</div>";
|
||||
box.scrollTop = box.scrollHeight;
|
||||
}
|
||||
}catch(e){}
|
||||
};
|
||||
window.LogsModule.ws.onclose = setTimeout(connect, 3000);
|
||||
};
|
||||
connect();
|
||||
reconnectTimer: null,
|
||||
reconnectDelay: 1000,
|
||||
maxLines: 500,
|
||||
lineCount: 0,
|
||||
batchBuffer: [],
|
||||
batchTimer: null,
|
||||
|
||||
init: function() {
|
||||
var self = this;
|
||||
self.box = document.getElementById("log-box");
|
||||
if (!self.box) return;
|
||||
self.box.innerHTML = "";
|
||||
self.lineCount = 0;
|
||||
self._connect();
|
||||
},
|
||||
destroy: () => window.LogsModule.ws?.close()
|
||||
|
||||
_connect: function() {
|
||||
var self = this;
|
||||
if (self.ws) {
|
||||
self.ws.onclose = null;
|
||||
self.ws.close();
|
||||
self.ws = null;
|
||||
}
|
||||
|
||||
var base = window.location.pathname.split("/").slice(0, 2).join("/");
|
||||
var ws = new WebSocket("ws://" + location.host + base + "/api/logs/ws");
|
||||
self.ws = ws;
|
||||
|
||||
ws.onopen = function() {
|
||||
self.reconnectDelay = 1000;
|
||||
self._appendHTML('<div style="color:var(--md-sys-color-primary)">🟢 Connected</div>');
|
||||
};
|
||||
|
||||
ws.onmessage = function(e) {
|
||||
try {
|
||||
var d = JSON.parse(e.data);
|
||||
if (d.type === "log") {
|
||||
self._bufferLog(d);
|
||||
}
|
||||
} catch(ex) {}
|
||||
};
|
||||
|
||||
ws.onclose = function() {
|
||||
self.ws = null;
|
||||
self._appendHTML('<div style="color:var(--error)">🔴 Disconnected — reconnecting...</div>');
|
||||
self.reconnectTimer = setTimeout(function() {
|
||||
self.reconnectDelay = Math.min(self.reconnectDelay * 1.5, 15000);
|
||||
self._connect();
|
||||
}, self.reconnectDelay);
|
||||
};
|
||||
|
||||
ws.onerror = function() {
|
||||
ws.close();
|
||||
};
|
||||
},
|
||||
|
||||
/* Buffer log entries then flush at ~30fps to avoid DOM thrashing */
|
||||
_bufferLog: function(d) {
|
||||
var self = this;
|
||||
self.batchBuffer.push(d);
|
||||
if (!self.batchTimer) {
|
||||
self.batchTimer = setTimeout(function() {
|
||||
self._flush();
|
||||
self.batchTimer = null;
|
||||
}, 33); // ~30fps flush
|
||||
}
|
||||
},
|
||||
|
||||
_flush: function() {
|
||||
var self = this;
|
||||
var batch = self.batchBuffer;
|
||||
self.batchBuffer = [];
|
||||
if (!batch.length || !self.box) return;
|
||||
|
||||
var html = '';
|
||||
for (var i = 0; i < batch.length; i++) {
|
||||
var d = batch[i];
|
||||
var cls = d.level === "ERROR" ? "log-ERROR" : d.level === "WARNING" ? "log-WARNING" : "log-INFO";
|
||||
var t = d.timestamp ? new Date(d.timestamp * 1000).toLocaleTimeString() : "--";
|
||||
html += '<div class="log-entry">' +
|
||||
'<span style="color:#555;margin-right:5px">' + t + '</span>' +
|
||||
'<span class="' + cls + '">[' + d.level + ']</span> ' +
|
||||
self._escapeHtml(d.message || '') + '</div>';
|
||||
}
|
||||
|
||||
self._appendHTML(html);
|
||||
},
|
||||
|
||||
_appendHTML: function(html) {
|
||||
var self = this;
|
||||
if (!self.box) return;
|
||||
|
||||
// Estimate line count from <div> tags
|
||||
var newLines = (html.match(/<div/g) || []).length;
|
||||
self.lineCount += newLines;
|
||||
|
||||
// Trim old lines if over cap
|
||||
while (self.lineCount > self.maxLines && self.box.firstChild) {
|
||||
// Count lines in the first child
|
||||
var removed = 1;
|
||||
if (self.box.firstChild.nodeType === 1) {
|
||||
var inner = self.box.firstChild.innerHTML || '';
|
||||
removed = Math.max(1, (inner.match(/<div/g) || []).length);
|
||||
}
|
||||
self.box.removeChild(self.box.firstChild);
|
||||
self.lineCount = Math.max(0, self.lineCount - removed);
|
||||
}
|
||||
|
||||
// Efficient append: insertAdjacentHTML instead of innerHTML +=
|
||||
self.box.insertAdjacentHTML("beforeend", html);
|
||||
self.box.scrollTop = self.box.scrollHeight;
|
||||
},
|
||||
|
||||
_escapeHtml: function(text) {
|
||||
return String(text)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
},
|
||||
|
||||
destroy: function() {
|
||||
if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; }
|
||||
if (this.batchTimer) { clearTimeout(this.batchTimer); this.batchTimer = null; }
|
||||
if (this.ws) { this.ws.onclose = null; this.ws.close(); this.ws = null; }
|
||||
this.batchBuffer = [];
|
||||
this.box = null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:1rem;">
|
||||
<h2 style="color:var(--accent);">📦 插件管理</h2>
|
||||
<button class="btn-sm" onclick="window.PluginsModule.refresh()">🔄 刷新</button>
|
||||
<h2 style="color:var(--primary); display:flex; align-items:center; gap:8px">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24"><path fill="var(--primary)" d="M20.5 11H19V7c0-1.1-.9-2-2-2h-4V3.5C13 2.12 11.88 1 10.5 1S8 2.12 8 3.5V5H4c-1.1 0-1.99.9-1.99 2v3.8H3.5c1.49 0 2.7 1.21 2.7 2.7s-1.21 2.7-2.7 2.7H2V20c0 1.1.9 2 2 2h3.8v-1.5c0-1.49 1.21-2.7 2.7-2.7 1.49 0 2.7 1.21 2.7 2.7V22H17c1.1 0 2-.9 2-2v-4h1.5c1.38 0 2.5-1.12 2.5-2.5S21.88 11 20.5 11z"/></svg>
|
||||
插件管理
|
||||
</h2>
|
||||
<button class="btn btn-sm btn-tonal" onclick="window.PluginsModule.refresh()">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" style="vertical-align:-2px"><path fill="currentColor" d="M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
<div id="plugin-list" class="plugin-list">加载中...</div>
|
||||
|
||||
@@ -17,10 +17,10 @@ window.PluginsModule = {
|
||||
</div>
|
||||
<div class="plugin-act">
|
||||
${p.running
|
||||
? `<button class="btn-sm" onclick="window.PluginsModule.act('${p.name}','disable')">停用</button>`
|
||||
: `<button class="btn-sm" onclick="window.PluginsModule.act('${p.name}','enable')">启用</button>`
|
||||
? `<button class="btn btn-sm btn-tonal" onclick="window.PluginsModule.act('${p.name}','disable')">停用</button>`
|
||||
: `<button class="btn btn-sm btn-tonal" onclick="window.PluginsModule.act('${p.name}','enable')">启用</button>`
|
||||
}
|
||||
<button class="btn-sm" onclick="window.PluginsModule.act('${p.name}','reload')">重载</button>
|
||||
<button class="btn btn-sm btn-outlined" onclick="window.PluginsModule.act('${p.name}','reload')">重载</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
@@ -38,23 +38,23 @@
|
||||
var projects=[];
|
||||
function showTab(e,t){document.querySelectorAll("#tab-running,#tab-add,#tab-git").forEach(el=>el.style.display="none");document.getElementById("tab-"+t).style.display="block";document.querySelectorAll(".tab").forEach(el=>el.classList.remove("active"));e.target.classList.add("active")}
|
||||
async function refresh(){
|
||||
try{var r=await fetch("/SenSu/api/projects");var d=await r.json();projects=d.projects;var h="";for(var p of projects){var cls=p.status==="running"?"status-running":p.status==="error"?"status-error":"status-stopped";h+='<div class="mgmt-card"><div class="status-dot '+cls+'"></div><div class="info"><b>'+p.name+'</b><br><small>'+p.status+" PID:"+(p.pid||"-")+" :"+(p.port||"-")+" "+(p.uptime?p.uptime+"s":"")+(p.proxy?" → "+p.proxy:"")+'</small></div><div class="actions"><button class="btn btn-sm btn-danger" onclick="stopP(this,'+JSON.stringify(p.name)+')">停止</button><button class="btn btn-sm btn-outlined" onclick="logsP(this,'+JSON.stringify(p.name)+')">日志</button><button class="btn btn-sm btn-text" onclick="cmdP(this,'+JSON.stringify(p.name)+')">命令</button></div></div><div class="log-box" id="logs-'+p.name+'"></div>'}document.getElementById("project-list").innerHTML=h||"<div style=\"color:var(--text-dim);text-align:center;padding:40px\">暂无项目</div>"}catch(e){document.getElementById("project-list").innerHTML="<div style=\"color:var(--error);text-align:center;padding:40px\">引擎未就绪 (API 错误)</div>"}
|
||||
try{var r=await fetch("./api/projects");var d=await r.json();projects=d.projects;var h="";for(var p of projects){var cls=p.status==="running"?"status-running":p.status==="error"?"status-error":"status-stopped";h+='<div class="mgmt-card"><div class="status-dot '+cls+'"></div><div class="info"><b>'+p.name+'</b><br><small>'+p.status+" PID:"+(p.pid||"-")+" :"+(p.port||"-")+" "+(p.uptime?p.uptime+"s":"")+(p.proxy?" → "+p.proxy:"")+'</small></div><div class="actions"><button class="btn btn-sm btn-danger" onclick="stopP(this,'+JSON.stringify(p.name)+')">停止</button><button class="btn btn-sm btn-outlined" onclick="logsP(this,'+JSON.stringify(p.name)+')">日志</button><button class="btn btn-sm btn-text" onclick="cmdP(this,'+JSON.stringify(p.name)+')">命令</button></div></div><div class="log-box" id="logs-'+p.name+'"></div>'}document.getElementById("project-list").innerHTML=h||"<div style=\"color:var(--text-dim);text-align:center;padding:40px\">暂无项目</div>"}catch(e){document.getElementById("project-list").innerHTML="<div style=\"color:var(--error);text-align:center;padding:40px\">引擎未就绪 (API 错误)</div>"}
|
||||
}
|
||||
async function addProject(){
|
||||
var n=document.getElementById("proj-name").value,cmd=document.getElementById("proj-cmd").value;
|
||||
if(!n||!cmd){alert("请填写名称和命令");return}
|
||||
await fetch("/SenSu/api/projects/run",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:n,cmd:cmd.split(" "),cwd:document.getElementById("proj-cwd").value,port:parseInt(document.getElementById("proj-port").value)||0,proxy_path:document.getElementById("proj-proxy").value})});
|
||||
await fetch("./api/projects/run",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:n,cmd:cmd.split(" "),cwd:document.getElementById("proj-cwd").value,port:parseInt(document.getElementById("proj-port").value)||0,proxy_path:document.getElementById("proj-proxy").value})});
|
||||
refresh()
|
||||
}
|
||||
async function deployGit(){
|
||||
var u=document.getElementById("git-url").value,b=document.getElementById("git-branch").value;
|
||||
if(!u){alert("请填写 Git URL");return}
|
||||
var n=u.split("/").pop().replace(".git","");
|
||||
await fetch("/SenSu/api/projects/run",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:n,cmd:["git","clone","-b",b,u,n],cwd:"data/projects"})});
|
||||
await fetch("./api/projects/run",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:n,cmd:["git","clone","-b",b,u,n],cwd:"data/projects"})});
|
||||
refresh()
|
||||
}
|
||||
async function stopP(el,n){await fetch("/SenSu/api/projects/"+n+"/stop",{method:"POST"});refresh()}
|
||||
async function logsP(el,n){var b=document.getElementById("logs-"+n);b.style.display=b.style.display==="none"?"block":"none";if(b.style.display==="block"){var r=await fetch("/SenSu/api/projects/"+n+"/logs?tail=30");var d=await r.json();b.innerHTML=d.logs.join("<br>")||"(无日志)"}}
|
||||
async function cmdP(el,n){var t=prompt("命令: "+n);if(t)await fetch("/SenSu/api/projects/"+n+"/stdin",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({text:t})})}
|
||||
async function stopP(el,n){await fetch("./api/projects/"+n+"/stop",{method:"POST"});refresh()}
|
||||
async function logsP(el,n){var b=document.getElementById("logs-"+n);b.style.display=b.style.display==="none"?"block":"none";if(b.style.display==="block"){var r=await fetch("./api/projects/"+n+"/logs?tail=30");var d=await r.json();b.innerHTML=d.logs.join("<br>")||"(无日志)"}}
|
||||
async function cmdP(el,n){var t=prompt("命令: "+n);if(t)await fetch("./api/projects/"+n+"/stdin",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({text:t})})}
|
||||
setInterval(refresh,4000);refresh();
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user