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
|
*.swo
|
||||||
*~
|
*~
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
docs/
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ commands:
|
|||||||
permissions:
|
permissions:
|
||||||
- framework.command.test
|
- framework.command.test
|
||||||
source: internal
|
source: internal
|
||||||
last_updated: 279719.562686002
|
last_updated: 283744.334290456
|
||||||
plugin_commands:
|
plugin_commands:
|
||||||
example_plugin:
|
example_plugin:
|
||||||
echo: *id001
|
echo: *id001
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
http_port: 4200
|
http_port: 4200
|
||||||
last_updated: 279719.566351315
|
last_updated: 283744.338585717
|
||||||
plugin_routes:
|
plugin_routes:
|
||||||
example_plugin:
|
example_plugin:
|
||||||
- methods:
|
- methods:
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import logging
|
|||||||
import asyncio
|
import asyncio
|
||||||
import sys
|
import sys
|
||||||
import signal
|
import signal
|
||||||
|
import time
|
||||||
import argparse
|
import argparse
|
||||||
from services.project_engine import ProjectEngine
|
from services.project_engine import ProjectEngine
|
||||||
from services.pyenv_manager import PyEnvManager
|
from services.pyenv_manager import PyEnvManager
|
||||||
@@ -205,6 +206,7 @@ class SenSuFramework:
|
|||||||
logger.warning(f"引擎/代理初始化跳过: {e}")
|
logger.warning(f"引擎/代理初始化跳过: {e}")
|
||||||
|
|
||||||
logger.info("🎉 SenSu 初始化完成!")
|
logger.info("🎉 SenSu 初始化完成!")
|
||||||
|
self.service_manager.start_time = time.time()
|
||||||
self.is_running = True
|
self.is_running = True
|
||||||
|
|
||||||
# 显示欢迎日志
|
# 显示欢迎日志
|
||||||
|
|||||||
@@ -108,6 +108,10 @@ class LogService:
|
|||||||
debug_handler.addFilter(shared_filter)
|
debug_handler.addFilter(shared_filter)
|
||||||
root_logger.addHandler(debug_handler)
|
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
|
self.is_initialized = True
|
||||||
logger.info(f"✅ 日志系统初始化完成 - 会话ID: {session_id}")
|
logger.info(f"✅ 日志系统初始化完成 - 会话ID: {session_id}")
|
||||||
logger.info(f"📝 运行时日志: logs/runtime/{runtime_log_file}")
|
logger.info(f"📝 运行时日志: logs/runtime/{runtime_log_file}")
|
||||||
|
|||||||
@@ -1,20 +1,24 @@
|
|||||||
import time
|
import time
|
||||||
|
import json
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
from ..utils.system_info import SystemInfoCollector
|
from ..utils.system_info import SystemInfoCollector
|
||||||
|
|
||||||
collector = SystemInfoCollector()
|
collector = SystemInfoCollector()
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Track active system-status WS clients
|
||||||
|
_sys_ws_clients: set = set()
|
||||||
|
|
||||||
def setup_routes(app, prefix=''):
|
def setup_routes(app, prefix=''):
|
||||||
app.router.add_get(f'{prefix}/api/framework', get_framework)
|
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', 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):
|
def _read_version():
|
||||||
sm = req.app.get('service_manager')
|
"""Read version from config file (shared helper)"""
|
||||||
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
|
|
||||||
ver = "v0.6.0"
|
ver = "v0.6.0"
|
||||||
try:
|
try:
|
||||||
import yaml, os
|
import yaml, os
|
||||||
@@ -22,13 +26,66 @@ async def get_framework(req):
|
|||||||
with open(config_path) as f:
|
with open(config_path) as f:
|
||||||
cfg = yaml.safe_load(f)
|
cfg = yaml.safe_load(f)
|
||||||
ver = cfg.get("framework", {}).get("version", ver)
|
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,
|
"version": ver,
|
||||||
"uptime": int(uptime),
|
"uptime": uptime,
|
||||||
"plugins": len(ps.plugins) if ps else 0
|
"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):
|
async def get_system(req):
|
||||||
return web.json_response(collector.get_all())
|
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 time
|
||||||
import platform
|
import platform
|
||||||
import logging
|
import logging
|
||||||
from typing import Dict, Any
|
from typing import Dict, Any, Tuple
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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:
|
class SystemInfoCollector:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
@@ -30,7 +22,39 @@ class SystemInfoCollector:
|
|||||||
else:
|
else:
|
||||||
logger.info("🤖 Android 平台识别,启用原生采集")
|
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 {
|
return {
|
||||||
"platform": {
|
"platform": {
|
||||||
"system": platform.system(),
|
"system": platform.system(),
|
||||||
@@ -39,55 +63,91 @@ class SystemInfoCollector:
|
|||||||
},
|
},
|
||||||
"cpu": self._get_cpu(),
|
"cpu": self._get_cpu(),
|
||||||
"memory": self._get_memory(),
|
"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:
|
if self.psutil:
|
||||||
return {
|
return {
|
||||||
"percent": self.psutil.cpu_percent(interval=0.1),
|
"percent": self.psutil.cpu_percent(interval=0.1),
|
||||||
"cores": self.psutil.cpu_count(),
|
"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:
|
try:
|
||||||
load = os.getloadavg() if hasattr(os, "getloadavg") else [0.0, 0.0, 0.0]
|
load = os.getloadavg() if hasattr(os, "getloadavg") else [0.0, 0.0, 0.0]
|
||||||
cores = os.cpu_count() or 1
|
cores = os.cpu_count() or 1
|
||||||
percent = min(100.0, (load[0] / cores) * 100)
|
percent = min(100.0, (load[0] / cores) * 100)
|
||||||
return {"percent": round(percent, 1), "cores": cores, "load_avg": load}
|
return {"percent": round(percent, 1), "cores": cores, "load_avg": load}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"System info collection error: {e}", exc_info=True)
|
logger.error(f"CPU info error: {e}", exc_info=True)
|
||||||
return {"percent": 0, "cores": 0, "load_avg": [0,0,0]}
|
return {"percent": 0, "cores": 0, "load_avg": [0, 0, 0]}
|
||||||
|
|
||||||
def _get_memory(self):
|
def _get_memory(self) -> Dict[str, Any]:
|
||||||
if self.psutil:
|
if self.psutil:
|
||||||
m = self.psutil.virtual_memory()
|
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:
|
try:
|
||||||
mem = {}
|
mem = {}
|
||||||
with open('/proc/meminfo') as f:
|
with open('/proc/meminfo') as f:
|
||||||
for line in f:
|
for line in f:
|
||||||
parts = line.split()
|
parts = line.split()
|
||||||
if len(parts) >= 2: mem[parts[0].rstrip(':')] = int(parts[1]) * 1024
|
if len(parts) >= 2:
|
||||||
t, a = mem.get('MemTotal', 1), mem.get('MemAvailable', mem.get('MemFree', 0))
|
mem[parts[0].rstrip(':')] = int(parts[1]) * 1024
|
||||||
return {"total_gb": round(t/1073741824, 1), "used_gb": round((t-a)/1073741824, 1), "percent": round(((t-a)/t)*100, 1)}
|
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:
|
except Exception as e:
|
||||||
logger.warning(f"Memory info failed: {e}")
|
logger.warning(f"Memory info failed: {e}")
|
||||||
return {"total_gb": 0, "used_gb": 0, "percent": 0}
|
return {"total_gb": 0, "used_gb": 0, "percent": 0}
|
||||||
|
|
||||||
def _get_network(self):
|
def _get_process(self) -> Dict[str, Any]:
|
||||||
if self.psutil:
|
"""Current process info (PID + RSS memory)"""
|
||||||
io = self.psutil.net_io_counters()
|
pid = os.getpid()
|
||||||
return {"rx": round(io.bytes_recv/1048576, 1), "tx": round(io.bytes_sent/1048576, 1)}
|
mem_mb = 0
|
||||||
# Android 解析 /proc/net/dev
|
|
||||||
try:
|
try:
|
||||||
rx = 0
|
if self.psutil:
|
||||||
with open('/proc/net/dev', 'r') as f:
|
import psutil
|
||||||
for line in f:
|
mem_mb = round(psutil.Process(pid).memory_info().rss / 1048576, 1)
|
||||||
if ':' in line and 'lo' not in line: # 排除 lo 回环
|
else:
|
||||||
parts = line.split(':')[1].split()
|
# Parse /proc/self/status for VmRSS
|
||||||
rx += int(parts[0]) # RX bytes
|
with open('/proc/self/status', 'r') as f:
|
||||||
return {"rx": round(rx/1048576, 1), "tx": 0}
|
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:
|
except Exception as e:
|
||||||
logger.warning(f"Network info failed: {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}
|
.btn:disabled{opacity:.38;pointer-events:none}
|
||||||
|
|
||||||
/* ── MD3 Input ── */
|
/* ── MD3 Input ── */
|
||||||
.input-group{margin-bottom:16px}
|
.input-group{margin-bottom:16px;text-align:left}
|
||||||
.input-group .label{display:block;margin-bottom:6px;color:var(--text-dim)}
|
.input-group label{display:block;margin-bottom:6px;color:var(--text-dim);font-size:.8rem;font-weight:500;letter-spacing:.3px}
|
||||||
.input{
|
.input{
|
||||||
width:100%;padding:12px 16px;background:var(--md-sys-color-surface-container-lowest);
|
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);
|
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: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.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 .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}
|
.nav-item .nav-label{overflow:hidden;text-overflow:ellipsis}
|
||||||
.toggle-sidebar{
|
.toggle-sidebar{
|
||||||
margin-top:auto;padding:16px;text-align:center;cursor:pointer;
|
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 ── */
|
/* ── Menu reveal animation for login error ── */
|
||||||
.err-msg{transition:opacity .3s}
|
.err-msg{transition:opacity .3s}
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -19,27 +19,27 @@
|
|||||||
|
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<a class="nav-item active" data-page="dashboard">
|
<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>
|
<span>仪表盘</span>
|
||||||
</a>
|
</a>
|
||||||
<a class="nav-item" data-page="logs">
|
<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>
|
<span>实时日志</span>
|
||||||
</a>
|
</a>
|
||||||
<a class="nav-item" data-page="console">
|
<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>
|
<span>控制台</span>
|
||||||
</a>
|
</a>
|
||||||
<a class="nav-item" data-page="plugins">
|
<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>
|
<span>插件管理</span>
|
||||||
</a>
|
</a>
|
||||||
<a class="nav-item" data-page="projects">
|
<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>
|
<span>项目管理</span>
|
||||||
</a>
|
</a>
|
||||||
<a class="nav-item" data-page="proxy">
|
<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>
|
<span>反向代理</span>
|
||||||
</a>
|
</a>
|
||||||
<div class="toggle-sidebar" onclick="toggleSidebar()">☰</div>
|
<div class="toggle-sidebar" onclick="toggleSidebar()">☰</div>
|
||||||
@@ -52,9 +52,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 🟢 1. 先加载图表库 -->
|
<!-- 🟢 1. 先加载图表库 -->
|
||||||
<script src="./static/js/chart.js"></script>
|
<script src="./static/js/chart.js?v=0603"></script>
|
||||||
|
|
||||||
<!-- 🟢 2. 再加载主逻辑 -->
|
<!-- 🟢 2. 再加载主逻辑 -->
|
||||||
<script src="./static/js/app.js"></script>
|
<script src="./static/js/app.js?v=0601"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -9,9 +9,9 @@
|
|||||||
<div class="login-wrapper">
|
<div class="login-wrapper">
|
||||||
<div class="login-card">
|
<div class="login-card">
|
||||||
<h2>🐱 SenSu Login</h2>
|
<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 class="input" type="text" id="u" value="admin"></div>
|
||||||
<div class="input-group"><label>密码</label><input type="password" id="p" value="admin"></div>
|
<div class="input-group"><label>密码</label><input class="input" type="password" id="p" value="admin"></div>
|
||||||
<button class="btn-primary" onclick="doLogin()">进入系统</button>
|
<button class="btn btn-filled" style="width:100%;margin-top:8px" onclick="doLogin()">进入系统</button>
|
||||||
<div id="err" class="err-msg"></div>
|
<div id="err" class="err-msg"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -55,21 +55,30 @@ async function loadPage(pageName) {
|
|||||||
|
|
||||||
content.innerHTML = cleanHtml;
|
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) {
|
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 {
|
try {
|
||||||
const jsResp = await fetch(`./static/pages/${pageName}.js?t=${Date.now()}`);
|
const jsResp = await fetch(`./static/pages/${pageName}.js?t=${Date.now()}`);
|
||||||
if(jsResp.ok) {
|
if(jsResp.ok) {
|
||||||
const jsCode = await jsResp.text();
|
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';
|
const moduleName = pageName.charAt(0).toUpperCase() + pageName.slice(1) + 'Module';
|
||||||
if(window[moduleName]?.init) window[moduleName].init();
|
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%';
|
bar.style.width = '100%';
|
||||||
setTimeout(() => bar.classList.remove('active'), 200);
|
setTimeout(() => bar.classList.remove('active'), 200);
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ class MiniChart {
|
|||||||
}
|
}
|
||||||
|
|
||||||
resize() {
|
resize() {
|
||||||
const rect = this.canvas.parentElement.getBoundingClientRect();
|
var rect = this.canvas.parentElement.getBoundingClientRect();
|
||||||
this.canvas.width = rect.width - 24;
|
this.canvas.width = rect.width - 24;
|
||||||
this.canvas.height = 60;
|
this.canvas.height = 60;
|
||||||
this.draw();
|
this.draw();
|
||||||
@@ -19,33 +19,112 @@ class MiniChart {
|
|||||||
|
|
||||||
update(val) {
|
update(val) {
|
||||||
this.data.push(val);
|
this.data.push(val);
|
||||||
if(this.data.length > 60) this.data.shift();
|
if (this.data.length > 60) this.data.shift();
|
||||||
this.maxVal = Math.max(...this.data, 100);
|
this.maxVal = Math.max.apply(null, this.data.concat([100]));
|
||||||
this.draw();
|
this.draw();
|
||||||
}
|
}
|
||||||
|
|
||||||
draw() {
|
draw() {
|
||||||
if(!this.ctx) return;
|
if (!this.ctx) return;
|
||||||
const { width, height } = this.canvas;
|
var ctx = this.ctx;
|
||||||
this.ctx.clearRect(0, 0, width, height);
|
var w = this.canvas.width;
|
||||||
|
var h = this.canvas.height;
|
||||||
|
|
||||||
this.ctx.strokeStyle = this.color;
|
ctx.clearRect(0, 0, w, h);
|
||||||
this.ctx.lineWidth = 2;
|
|
||||||
this.ctx.beginPath();
|
|
||||||
|
|
||||||
this.data.forEach((v, i) => {
|
ctx.strokeStyle = this.color;
|
||||||
const x = (i / 59) * width;
|
ctx.lineWidth = 2;
|
||||||
const y = height - (v / this.maxVal) * (height - 10);
|
ctx.beginPath();
|
||||||
if(i === 0) this.ctx.moveTo(x, y);
|
|
||||||
else this.ctx.lineTo(x, y);
|
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);
|
ctx.lineTo(w, h);
|
||||||
this.ctx.lineTo(0, height);
|
ctx.lineTo(0, h);
|
||||||
this.ctx.fillStyle = this.color + '20';
|
ctx.fillStyle = this.color + '20';
|
||||||
this.ctx.fill();
|
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.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,11 +23,22 @@
|
|||||||
<canvas id="chart-cpu" class="mini-chart"></canvas>
|
<canvas id="chart-cpu" class="mini-chart"></canvas>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<h3>🌐 网络接收</h3>
|
<h3>🌐 网络流量</h3>
|
||||||
<div class="stat-value" id="d-net">--</div>
|
<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>
|
<canvas id="chart-net" class="mini-chart"></canvas>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<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>
|
<h3>💾 进程内存</h3>
|
||||||
<div class="stat-value" id="d-proc-mem">--</div>
|
<div class="stat-value" id="d-proc-mem">--</div>
|
||||||
<canvas id="chart-proc-mem" class="mini-chart"></canvas>
|
<canvas id="chart-proc-mem" class="mini-chart"></canvas>
|
||||||
@@ -60,11 +71,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 3. 快捷操作 (占位) -->
|
<!-- 3. 快捷操作 -->
|
||||||
<div class="side-card">
|
<div class="side-card">
|
||||||
<h3>🛠️ 快捷操作</h3>
|
<h3>
|
||||||
<button class="btn-sm" style="width:100%; margin-bottom:6px" onclick="window.PluginsModule?.refresh()">🔄 刷新插件列表</button>
|
<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>
|
||||||
<button class="btn-sm" style="width:100%; color:var(--error); border-color:var(--error)" onclick="doLogout()">🚪 退出登录</button>
|
快捷操作
|
||||||
|
</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>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,105 +1,182 @@
|
|||||||
window.DashboardModule = {
|
window.DashboardModule = {
|
||||||
|
ws: null,
|
||||||
|
reconnectTimer: null,
|
||||||
|
reconnectDelay: 1000,
|
||||||
charts: {},
|
charts: {},
|
||||||
init: () => {
|
|
||||||
// 1. 初始化图表实例
|
init: function() {
|
||||||
window.DashboardModule.charts = {
|
var self = this;
|
||||||
mem: new MiniChart('chart-mem', '#9ece6a'),
|
|
||||||
cpu: new MiniChart('chart-cpu', '#7aa2f7'),
|
// 初始化图表
|
||||||
net: new MiniChart('chart-net', '#e0af68'),
|
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')
|
proc_mem: new MiniChart('chart-proc-mem', '#f7768e')
|
||||||
};
|
};
|
||||||
|
|
||||||
// 2. 获取并填充右侧固定信息 (只获取一次即可,除非重启)
|
// 静态信息 (HTTP 一次)
|
||||||
fetchSystemStaticInfo();
|
fetchSystemStaticInfo();
|
||||||
|
|
||||||
// 3. 启动实时数据轮询
|
// WebSocket 实时推送
|
||||||
fetchDash();
|
self._connect();
|
||||||
window._dashInterval = setInterval(fetchDash, 2000); // 2秒刷新
|
|
||||||
},
|
},
|
||||||
destroy: () => {
|
|
||||||
clearInterval(window._dashInterval);
|
_connect: function() {
|
||||||
window.DashboardModule.charts = {};
|
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() {
|
async function fetchSystemStaticInfo() {
|
||||||
try {
|
try {
|
||||||
const sys = await fetch('./api/system', {credentials:'include'}).then(r => r.json());
|
var sys = await fetch('./api/system', {credentials:'include'}).then(function(r){return r.json();});
|
||||||
|
var el;
|
||||||
// 硬件信息
|
if (sys.platform) {
|
||||||
if(sys.platform) {
|
el = document.getElementById('info-os'); if (el) el.textContent = sys.platform.system || '--';
|
||||||
document.getElementById('info-os').textContent = sys.platform.system || '--';
|
el = document.getElementById('info-arch'); if (el) el.textContent = sys.platform.machine || '--';
|
||||||
document.getElementById('info-arch').textContent = sys.platform.machine || '--';
|
el = document.getElementById('info-env'); if (el) el.textContent = sys.platform.env || 'Standard';
|
||||||
document.getElementById('info-env').textContent = sys.platform.env || 'Standard';
|
|
||||||
}
|
}
|
||||||
if(sys.cpu) {
|
if (sys.cpu) {
|
||||||
const c = sys.cpu.cores || 0;
|
var c = sys.cpu.cores || 0;
|
||||||
document.getElementById('info-cores').textContent = `${c} / ${c}`; // Android下通常逻辑核=物理核
|
el = document.getElementById('info-cores'); if (el) el.textContent = c + ' / ' + c;
|
||||||
}
|
}
|
||||||
if(sys.memory) {
|
if (sys.memory) {
|
||||||
document.getElementById('info-mem-total').textContent = sys.memory.total_gb + ' GB';
|
el = document.getElementById('info-mem-total'); if (el) el.textContent = sys.memory.total_gb + ' GB';
|
||||||
}
|
}
|
||||||
|
// 框架信息 (部分来自 API)
|
||||||
// 框架信息 (部分需结合 API)
|
try {
|
||||||
const host = window.location.hostname + (window.location.port ? ':'+window.location.port : '');
|
var fw = await fetch('./api/framework', {credentials:'include'}).then(function(r){return r.json();});
|
||||||
document.getElementById('info-addr').textContent = host;
|
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) {}
|
} 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) {
|
function formatUptime(seconds) {
|
||||||
const h = Math.floor(seconds / 3600);
|
var h = Math.floor(seconds / 3600);
|
||||||
const m = Math.floor((seconds % 3600) / 60);
|
var m = Math.floor((seconds % 3600) / 60);
|
||||||
const s = Math.floor(seconds % 60);
|
var s = Math.floor(seconds % 60);
|
||||||
return `${h}h ${m}m ${s}s`;
|
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 = {
|
window.LogsModule = {
|
||||||
ws: null,
|
ws: null,
|
||||||
init: () => {
|
reconnectTimer: null,
|
||||||
const box = document.getElementById("log-box");
|
reconnectDelay: 1000,
|
||||||
const base = window.location.pathname.split("/").slice(0,2).join("/");
|
maxLines: 500,
|
||||||
const connect = () => {
|
lineCount: 0,
|
||||||
window.LogsModule.ws = new WebSocket("ws://"+location.host+base+"/api/logs/ws");
|
batchBuffer: [],
|
||||||
window.LogsModule.ws.onopen = () => box.innerHTML += "<div style=\"color:var(--success)\">Connected</div>";
|
batchTimer: null,
|
||||||
window.LogsModule.ws.onmessage = e => {
|
|
||||||
try {
|
init: function() {
|
||||||
const d = JSON.parse(e.data);
|
var self = this;
|
||||||
if(d.type === "log") {
|
self.box = document.getElementById("log-box");
|
||||||
const cls = d.level === "ERROR" ? "log-ERROR" : d.level === "WARNING" ? "log-WARNING" : "log-INFO";
|
if (!self.box) return;
|
||||||
const t = d.timestamp ? new Date(d.timestamp*1000).toLocaleTimeString() : "--";
|
self.box.innerHTML = "";
|
||||||
box.innerHTML += "<div class=\"log-entry\"><span style=\"color:#555;margin-right:5px\">"+t+"</span><span class=\""+cls+"\">["+d.level+"]</span> "+d.message+"</div>";
|
self.lineCount = 0;
|
||||||
box.scrollTop = box.scrollHeight;
|
self._connect();
|
||||||
}
|
|
||||||
}catch(e){}
|
|
||||||
};
|
|
||||||
window.LogsModule.ws.onclose = setTimeout(connect, 3000);
|
|
||||||
};
|
|
||||||
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;">
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:1rem;">
|
||||||
<h2 style="color:var(--accent);">📦 插件管理</h2>
|
<h2 style="color:var(--primary); display:flex; align-items:center; gap:8px">
|
||||||
<button class="btn-sm" onclick="window.PluginsModule.refresh()">🔄 刷新</button>
|
<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>
|
||||||
<div id="plugin-list" class="plugin-list">加载中...</div>
|
<div id="plugin-list" class="plugin-list">加载中...</div>
|
||||||
|
|||||||
@@ -17,10 +17,10 @@ window.PluginsModule = {
|
|||||||
</div>
|
</div>
|
||||||
<div class="plugin-act">
|
<div class="plugin-act">
|
||||||
${p.running
|
${p.running
|
||||||
? `<button class="btn-sm" onclick="window.PluginsModule.act('${p.name}','disable')">停用</button>`
|
? `<button class="btn btn-sm btn-tonal" 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}','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>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
|
|||||||
@@ -38,23 +38,23 @@
|
|||||||
var projects=[];
|
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")}
|
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(){
|
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(){
|
async function addProject(){
|
||||||
var n=document.getElementById("proj-name").value,cmd=document.getElementById("proj-cmd").value;
|
var n=document.getElementById("proj-name").value,cmd=document.getElementById("proj-cmd").value;
|
||||||
if(!n||!cmd){alert("请填写名称和命令");return}
|
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()
|
refresh()
|
||||||
}
|
}
|
||||||
async function deployGit(){
|
async function deployGit(){
|
||||||
var u=document.getElementById("git-url").value,b=document.getElementById("git-branch").value;
|
var u=document.getElementById("git-url").value,b=document.getElementById("git-branch").value;
|
||||||
if(!u){alert("请填写 Git URL");return}
|
if(!u){alert("请填写 Git URL");return}
|
||||||
var n=u.split("/").pop().replace(".git","");
|
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()
|
refresh()
|
||||||
}
|
}
|
||||||
async function stopP(el,n){await fetch("/SenSu/api/projects/"+n+"/stop",{method:"POST"});refresh()}
|
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("/SenSu/api/projects/"+n+"/logs?tail=30");var d=await r.json();b.innerHTML=d.logs.join("<br>")||"(无日志)"}}
|
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("/SenSu/api/projects/"+n+"/stdin",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({text:t})})}
|
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();
|
setInterval(refresh,4000);refresh();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user