feat: 新增脚手架/开发模式/权限白名单/system-monitor插件
Some checks failed
CI / test (3.10) (push) Has been cancelled
CI / test (3.11) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled
CI / test (3.13) (push) Has been cancelled

- nebula create mod/key/list-templates 模组脚手架
- nebula dev 开发模式热重载
- manifest permissions.imports 权限白名单机制
- system-monitor 系统监控仪表盘插件
- 默认端口统一为 10086
- 修复 _init_nbpf 误读 Ed25519 私钥为 RSA 的 bug
- 更新 README.md 文档
This commit is contained in:
starlight-apk
2026-05-16 20:20:43 +08:00
parent bce27db4ac
commit 5fbc5cc335
14 changed files with 1225 additions and 312 deletions

430
system-monitor/main.py Normal file
View File

@@ -0,0 +1,430 @@
"""
@NebulaShell/system-monitor
实时系统监控模组 - CPU/内存/磁盘/网络/进程TOP
提供 HTTP REST API默认端口 10087。
可在 manifest.json 的 config.args 中自定义端口。
"""
import json
import os
import threading
import time
from collections import deque
from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.parse import urlparse
try:
import psutil
HAS_PSUTIL = True
except ImportError:
HAS_PSUTIL = False
NAME = "system-monitor"
VERSION = "0.1.0"
# ── 历史数据存储 ──
MAX_HISTORY = 60 # 保留最近60条每分钟一条即1小时
_history: deque = deque(maxlen=MAX_HISTORY)
_collector_thread = None
_collector_running = False
def _collect_stats() -> dict:
"""采集一次系统状态快照"""
if not HAS_PSUTIL:
return {"error": "psutil not installed"}
now = time.time()
cpu_percent = psutil.cpu_percent(interval=0.1)
cpu_count = psutil.cpu_count()
mem = psutil.virtual_memory()
swap = psutil.swap_memory()
disk = psutil.disk_usage("/")
net = psutil.net_io_counters()
net_conns = len(psutil.net_connections())
# TOP 10 进程按CPU排序
top_processes = []
for proc in sorted(psutil.process_iter(["pid", "name", "cpu_percent", "memory_percent", "status"]),
key=lambda p: p.info.get("cpu_percent", 0) or 0, reverse=True)[:10]:
try:
top_processes.append({
"pid": proc.info["pid"],
"name": proc.info["name"] or "?",
"cpu": round(proc.info["cpu_percent"] or 0, 1),
"mem": round(proc.info["memory_percent"] or 0, 1),
"status": proc.info["status"] or "?",
})
except (psutil.NoSuchProcess, psutil.AccessDenied):
continue
# 开机时间
boot_time = psutil.boot_time()
return {
"timestamp": now,
"datetime": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(now)),
"uptime": round(now - boot_time),
"cpu": {
"percent": round(cpu_percent, 1),
"count": cpu_count,
"load_avg": [round(x, 2) for x in os.getloadavg()] if hasattr(os, "getloadavg") else None,
},
"memory": {
"total": mem.total,
"available": mem.available,
"used": mem.used,
"percent": round(mem.percent, 1),
"swap_total": swap.total,
"swap_used": swap.used,
"swap_percent": round(swap.percent, 1),
},
"disk": {
"total": disk.total,
"used": disk.used,
"free": disk.free,
"percent": round(disk.percent, 1),
},
"network": {
"bytes_sent": net.bytes_sent,
"bytes_recv": net.bytes_recv,
"packets_sent": net.packets_sent,
"packets_recv": net.packets_recv,
"connections": net_conns,
},
"processes": {
"total": len(psutil.pids()),
"running": sum(1 for p in psutil.process_iter(["status"])
if p.info.get("status") == "running"),
"top": top_processes,
},
}
def _collector_loop(interval: float = 5.0):
"""后台采集线程"""
global _collector_running
_collector_running = True
while _collector_running:
try:
stats = _collect_stats()
_history.append(stats)
except Exception:
pass
time.sleep(interval)
# ── HTTP 服务 ──
class MonitorHandler(BaseHTTPRequestHandler):
"""HTTP 请求处理器"""
def _json_response(self, data: dict, status: int = 200):
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8"))
def _html_response(self, html: str):
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.end_headers()
self.wfile.write(html.encode("utf-8"))
def do_GET(self):
parsed = urlparse(self.path)
path = parsed.path.rstrip("/")
if path == "/" or path == "":
self._html_response(_render_dashboard())
elif path == "/health":
self._json_response({"status": "ok", "version": VERSION, "uptime": _get_uptime()})
elif path == "/stats":
stats = _collect_stats() if not _history else _history[-1]
self._json_response(stats)
elif path == "/stats/current":
self._json_response(_collect_stats())
elif path == "/stats/history":
self._json_response({
"count": len(_history),
"max": MAX_HISTORY,
"data": list(_history),
})
elif path == "/stats/cpu":
s = _collect_stats()
self._json_response(s.get("cpu", {}))
elif path == "/stats/memory":
s = _collect_stats()
self._json_response(s.get("memory", {}))
elif path == "/stats/disk":
s = _collect_stats()
self._json_response(s.get("disk", {}))
elif path == "/stats/network":
s = _collect_stats()
self._json_response(s.get("network", {}))
elif path == "/stats/processes":
s = _collect_stats()
self._json_response(s.get("processes", {}))
else:
self._json_response({"error": "Not Found", "path": path}, 404)
def log_message(self, format, *args):
"""静默日志,避免刷屏"""
pass
def _get_uptime() -> float:
try:
return round(time.time() - psutil.boot_time())
except Exception:
return 0
def _render_dashboard() -> str:
"""渲染简易仪表盘 HTML"""
stats = _collect_stats() if not _history else _history[-1]
if not stats or "error" in stats:
return "<h1>System Monitor</h1><p>psutil not available</p>"
cpu = stats.get("cpu", {})
mem = stats.get("memory", {})
disk = stats.get("disk", {})
net = stats.get("network", {})
procs = stats.get("processes", {})
uptime = stats.get("uptime", 0)
# 格式化时间
days, rem = divmod(uptime, 86400)
hours, rem = divmod(rem, 3600)
mins = rem // 60
uptime_str = f"{int(days)}{int(hours)}{int(mins)}"
def bar(pct, color="primary"):
color_map = {
"primary": "#0d6efd", "success": "#198754",
"warning": "#ffc107", "danger": "#dc3545",
}
c = color_map.get(color, color_map["primary"])
return f'<div style="height:20px;background:#e9ecef;border-radius:10px;overflow:hidden">' \
f'<div style="height:100%;width:{pct}%;background:{c};transition:width 0.5s"></div></div>'
def mem_fmt(b):
for unit in ["B", "KB", "MB", "GB", "TB"]:
if b < 1024:
return f"{b:.1f} {unit}"
b /= 1024
return f"{b:.1f} PB"
# 进程TOP表格行
proc_rows = ""
for p in procs.get("top", []):
proc_rows += f"<tr><td>{p['pid']}</td><td>{p['name']}</td>" \
f"<td>{p['cpu']}%</td><td>{p['mem']}%</td><td>{p['status']}</td></tr>"
html = f"""<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>System Monitor</title>
<style>
* {{ margin:0; padding:0; box-sizing:border-box; }}
body {{ font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; background:#f8f9fa; color:#333; padding:20px; }}
.container {{ max-width:900px; margin:0 auto; }}
h1 {{ font-size:24px; margin-bottom:5px; }}
.sub {{ color:#666; font-size:14px; margin-bottom:20px; }}
.grid {{ display:grid; grid-template-columns:1fr 1fr; gap:15px; margin-bottom:20px; }}
.card {{ background:#fff; border-radius:12px; padding:16px; box-shadow:0 1px 3px rgba(0,0,0,0.1); }}
.card h3 {{ font-size:14px; color:#666; margin-bottom:8px; }}
.card .value {{ font-size:28px; font-weight:700; }}
.card .subtext {{ font-size:12px; color:#999; margin-top:4px; }}
.full {{ grid-column:1/-1; }}
table {{ width:100%; border-collapse:collapse; font-size:13px; }}
th, td {{ padding:6px 8px; text-align:left; border-bottom:1px solid #eee; }}
th {{ color:#666; font-weight:600; }}
a {{ color:#0d6efd; text-decoration:none; }}
a:hover {{ text-decoration:underline; }}
.links {{ margin-bottom:15px; font-size:13px; }}
.links a {{ margin-right:12px; }}
</style>
</head><body>
<div class="container">
<h1>📊 System Monitor</h1>
<p class="sub">v{VERSION} · 运行时间 {uptime_str} · 进程 {procs.get('total', '?')} 个</p>
<div class="links">
<a href="/stats">📄 JSON</a>
<a href="/stats/current">🔄 实时刷新</a>
<a href="/stats/history">📈 历史数据</a>
<a href="/health">💚 健康检查</a>
</div>
<div class="grid">
<div class="card">
<h3>🧠 CPU</h3>
<div class="value">{cpu.get('percent', '?')}%</div>
<div class="subtext">{cpu.get('count', '?')} 核心 · 负载 {cpu.get('load_avg', ['?','?','?'])}</div>
{bar(cpu.get('percent', 0), 'danger' if cpu.get('percent', 0) > 80 else 'warning' if cpu.get('percent', 0) > 60 else 'primary')}
</div>
<div class="card">
<h3>💾 内存</h3>
<div class="value">{mem.get('percent', '?')}%</div>
<div class="subtext">{mem_fmt(mem.get('used', 0))} / {mem_fmt(mem.get('total', 0))}</div>
{bar(mem.get('percent', 0), 'danger' if mem.get('percent', 0) > 80 else 'warning' if mem.get('percent', 0) > 60 else 'success')}
</div>
<div class="card">
<h3>💿 磁盘 /</h3>
<div class="value">{disk.get('percent', '?')}%</div>
<div class="subtext">{mem_fmt(disk.get('used', 0))} / {mem_fmt(disk.get('total', 0))}</div>
{bar(disk.get('percent', 0), 'danger' if disk.get('percent', 0) > 85 else 'warning')}
</div>
<div class="card">
<h3>🌐 网络</h3>
<div class="value">{net.get('connections', '?')}</div>
<div class="subtext">连接数 · ↓ {mem_fmt(net.get('bytes_recv', 0))}{mem_fmt(net.get('bytes_sent', 0))}</div>
</div>
</div>
<div class="card full">
<h3>⚡ TOP 10 进程 (CPU)</h3>
<table>
<thead><tr><th>PID</th><th>名称</th><th>CPU</th><th>内存</th><th>状态</th></tr></thead>
<tbody>{proc_rows}</tbody>
</table>
</div>
<p class="sub" style="text-align:center;margin-top:20px">
数据采集间隔 5秒 · 保留最近 {MAX_HISTORY}
</p>
</div>
</body></html>"""
return html
# ── HTTP 服务器 ──
_http_server = None
_server_thread = None
def _run_http_server(host: str, port: int):
global _http_server
server = HTTPServer((host, port), MonitorHandler)
_http_server = server
try:
server.serve_forever()
except Exception:
pass
# ── 生命周期 ──
def init(deps):
"""模组初始化"""
logger = deps.get("logger")
if logger:
logger.info(f"System Monitor v{VERSION} 初始化")
def start():
"""启动 HTTP 服务 + 数据采集"""
global _http_server, _server_thread, _collector_thread
if not HAS_PSUTIL:
print("[system-monitor] ⚠ psutil 未安装,系统监控不可用")
print("[system-monitor] 💡 请执行: pip install psutil")
return
# 启动数据采集后台线程5秒间隔
_collector_thread = threading.Thread(
target=_collector_loop, args=(5.0,), daemon=True
)
_collector_thread.start()
# 启动 HTTP 服务
port = 10087
host = "0.0.0.0"
_server_thread = threading.Thread(
target=_run_http_server, args=(host, port), daemon=True
)
_server_thread.start()
print(f"[system-monitor] ✅ 系统监控已启动")
print(f"[system-monitor] 🌐 仪表盘: http://localhost:{port}")
print(f"[system-monitor] 📄 API: http://localhost:{port}/stats")
print(f"[system-monitor] 💚 健康: http://localhost:{port}/health")
def stop():
"""停止服务,释放资源"""
global _http_server, _collector_running
_collector_running = False
if _http_server:
try:
_http_server.shutdown()
except Exception:
pass
_http_server = None
_history.clear()
print("[system-monitor] 已停止")
def health() -> dict:
"""健康检查"""
return {
"status": "ok" if HAS_PSUTIL else "degraded",
"version": VERSION,
"uptime": _get_uptime(),
"data_points": len(_history),
}
def stats() -> dict:
"""统计信息"""
return {
"version": VERSION,
"psutil_available": HAS_PSUTIL,
"history_count": len(_history),
"history_max": MAX_HISTORY,
"collector_running": _collector_running,
}
# ── 目录插件兼容(类 + New() 工厂函数) ──
class SystemMonitor:
"""系统监控插件类封装"""
name = NAME
version = VERSION
description = "实时系统监控CPU/内存/磁盘/网络/进程TOP"
def __init__(self):
self._http_server = None
self._server_thread = None
def init(self, deps=None):
return init(deps)
def start(self):
return start()
def stop(self):
return stop()
def health(self) -> dict:
return health()
def stats(self) -> dict:
return stats()
def New():
"""目录插件工厂函数"""
return SystemMonitor()