Files
HeXi/hexi/web_hub/dashboard.py
T
sansenhoshiandClaude Code 4b5e8b8b2e feat(web): 顶栏账号菜单接入 bot 启停 / 插件页分区 / 首页协议端配对信息
- 重启/关闭 bot 从设置页提到顶栏账号下拉(破坏性操作弹确认框),等待回连逻辑抽成
  lib/botAction.ts 供两处共用:先等旧进程让出端口,再以 dashboard SSE 真收到一帧为准,
  只看 HTTP 200 会在"端口起来但插件未挂接"时误判就绪
- 插件管理页按 带 Web 页面 / 带配置项 / 都没有 分区,卡片加类型与 Web/配置 徽章
- 首页「BOT 协议端信息」分两半:左半登录账号,右半 NoneBot 接入信息
  (IP / 运行端口 / WS 路径 / 访问令牌,均可一键复制,另给整条反向 WS 地址),
  监听地址非 0.0.0.0 时提示仅本机可连;「BOT 后端信息」移到自己一行
- dashboard.py 新增 endpoint 段:读 driver.config 的 host/port、驱动实际注册的
  反向 WS 路由(自动跳过根路径与尾斜杠别名)、适配器令牌与本机网卡 IP

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-22 14:23:52 +08:00

431 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""统一 Web 管理台 Dashboard 数据采集。
数据来源与 nonebot_plugin_picstatus 类似:
- bot 账号 / WS 连通性:从 OneBot V11 适配器的 bots / connections 读取
- 协议端接入信息:driver.config 的 host/port + 驱动实际注册的反向 WS 路由 + 本机网卡 IP
- host 性能:psutil(CPU/内存/磁盘/进程)+ platform + nonebot 版本
仅在 /hub/api/dashboard 被调用时执行,不做常驻采样。
同步采集(psutil 等可能阻塞的调用)整体放入线程池执行,避免阻塞事件循环;
结果做短缓存,多个前端标签页(含 SSE 循环)不至于各自重复采集。
CPU 采样走非阻塞模式(interval=None,与上一次调用求差):带 interval 的
psutil.cpu_percent/Process.cpu_percent 会真的 sleep 住调用线程,而 SSE 循环
每轮都调用一次,会凭空常驻烧掉约四分之一个核。
"""
from __future__ import annotations
import asyncio
import os
import platform
import time
import psutil
from nonebot import get_bots, logger
try:
from cpuinfo import get_cpu_info
except Exception: # pragma: no cover
get_cpu_info = None
_MODULE_START = time.time()
# 网络速率采样:记录上一次累计计数与时间,用于计算实时 bytes/s 用量
_NET_LAST: dict = {"time": None, "sent": None, "recv": None}
# 采集结果短缓存(多标签页 / SSE 循环共用一份结果)
_DASH_CACHE: dict = {"time": 0.0, "data": None}
_DASH_CACHE_TTL = 2.0
# 进程对象与 CPU 采样必须跨调用复用:psutil 的非阻塞模式(interval=None)
# 是「与上一次调用」求差,换对象或首次调用只会拿到 0.0。
_PROC = psutil.Process()
psutil.cpu_percent(None, True) # 预热 CPU 采样(首次调用恒为 0.0)
_PROC.cpu_percent(None) # 预热进程采样
# bot 昵称(get_login_info)基本不变,长缓存避免每轮都打一次 OneBot API
_LOGIN_CACHE: dict[str, tuple[float, str]] = {}
_LOGIN_TTL = 600.0
def _cpu_sync() -> dict:
try:
per_core = psutil.cpu_percent(None, True)
except TypeError:
# 兼容部分平台不接受 percpu 参数
per_core = []
if per_core:
percent = sum(per_core) / len(per_core)
else:
percent = psutil.cpu_percent(None)
info: dict = {
"percent": round(float(percent), 1),
"per_core": [round(float(x), 1) for x in (per_core or [])],
"logical": psutil.cpu_count(),
"physical": psutil.cpu_count(logical=False),
"brand": "",
}
if get_cpu_info:
try:
brand = get_cpu_info().get("brand_raw", "")
info["brand"] = brand.split("@", maxsplit=1)[0].strip()
except Exception:
info["brand"] = ""
try:
info["load_avg"] = [round(float(x), 2) for x in psutil.getloadavg()]
except Exception:
info["load_avg"] = None
return info
def _memory_sync() -> dict:
mem = psutil.virtual_memory()
try:
proc = psutil.Process()
rss = proc.memory_info().rss
proc_mem_percent = proc.memory_percent()
except Exception:
rss = 0
proc_mem_percent = 0.0
return {
"percent": round(float(mem.percent), 1),
"used": int(mem.used),
"total": int(mem.total),
"available": int(mem.available),
"process_rss": int(rss),
"process_percent": round(float(proc_mem_percent), 1),
}
def _process_sync() -> dict:
try:
proc = _PROC
cpu = proc.cpu_percent(None)
info = {
"pid": proc.pid,
"name": proc.name(),
"rss": int(proc.memory_info().rss),
"cpu_percent": round(float(cpu), 1),
"memory_percent": round(float(proc.memory_percent()), 1),
"create_time": int(proc.create_time()),
}
except Exception as e:
logger.warning(f"采集进程信息失败: {type(e).__name__}: {e}")
info = {}
return info
def _network_sync() -> dict:
"""采集网络流量累计值 + 实时速率(bytes/s)。"""
global _NET_LAST
now = time.time()
try:
io = psutil.net_io_counters(pernic=False)
except Exception:
return {
"sent": 0,
"recv": 0,
"sent_rate": 0,
"recv_rate": 0,
"packets_sent": 0,
"packets_recv": 0,
"interfaces": [],
}
sent = int(io.bytes_sent)
recv = int(io.bytes_recv)
sent_rate = recv_rate = 0.0
last = _NET_LAST
if last["time"] is not None and last["sent"] is not None:
dt = max(0.001, now - float(last["time"]))
sent_rate = max(0.0, (sent - int(last["sent"])) / dt)
recv_rate = max(0.0, (recv - int(last["recv"])) / dt)
_NET_LAST = {"time": now, "sent": sent, "recv": recv}
# 逐网卡明细(含 IP / 友好名称)
interfaces: list[dict] = []
try:
pernic = psutil.net_io_counters(pernic=True)
addrs = psutil.net_if_addrs()
stats = psutil.net_if_stats()
for name, cnt in pernic.items():
if not cnt.bytes_sent and not cnt.bytes_recv:
continue
ips = []
for addr in addrs.get(name, []):
ip = (addr.address or "").strip()
# 只保留 IPv4(带点分十进制),忽略 MAC / IPv6
if ip and ":" not in ip and ip.count(".") >= 3:
ips.append(ip)
up = bool(stats.get(name).isup) if name in stats else True
interfaces.append({
"name": name,
"up": up,
"sent": int(cnt.bytes_sent),
"recv": int(cnt.bytes_recv),
"ip": ", ".join(ips) if ips else "",
})
except Exception:
interfaces = []
return {
"sent": sent,
"recv": recv,
"sent_rate": round(sent_rate, 1),
"recv_rate": round(recv_rate, 1),
"packets_sent": int(io.packets_sent),
"packets_recv": int(io.packets_recv),
"interfaces": interfaces,
}
def _backend_sync() -> dict:
"""BOT 后端信息:NoneBot 版本 / 驱动方式 / 装载插件数。"""
from nonebot import get_loaded_plugins
try:
plugin_count = len(get_loaded_plugins())
except Exception:
plugin_count = 0
info: dict = {
"nonebot_version": _nonebot_version(),
"plugin_count": plugin_count,
"python": f"{platform.python_implementation()} {platform.python_version()}",
"driver_type": "",
"driver": "",
}
try:
from nonebot import get_driver
driver = get_driver()
info["driver_type"] = getattr(driver, "type", "") or ""
info["driver"] = getattr(driver.config, "driver", "") or ""
except Exception:
pass
return info
def _is_private_ip(ip: str) -> bool:
"""RFC1918 私有网段(192.168/10/172.16-31),用于把局域网地址排在前面。"""
parts = ip.split(".")
if len(parts) != 4:
return False
try:
a, b = int(parts[0]), int(parts[1])
except ValueError:
return False
return a == 10 or (a == 192 and b == 168) or (a == 172 and 16 <= b <= 31)
def _local_ips() -> list[str]:
"""本机可被协议端访问的 IPv4 列表(私有网段优先,排除环回/链路本地)。"""
ips: list[str] = []
try:
addrs = psutil.net_if_addrs()
stats = psutil.net_if_stats()
except Exception:
return ips
for name, addr_list in addrs.items():
st = stats.get(name)
if st is not None and not st.isup:
continue
for addr in addr_list:
ip = (addr.address or "").strip()
# 只保留 IPv4(忽略 MAC / IPv6),排除环回与自动分配地址
if not ip or ":" in ip or ip.count(".") < 3:
continue
if ip.startswith(("127.", "169.254.")):
continue
if ip not in ips:
ips.append(ip)
return sorted(ips, key=lambda ip: (not _is_private_ip(ip), ip))
def _ws_path() -> str:
"""反向 WS 路径:以驱动实际注册的 websocket 路由为准(跟随适配器版本)。"""
default = "/onebot/v11/ws"
try:
from nonebot import get_driver
routes = getattr(get_driver().server_app, "routes", None) or []
except Exception:
return default
paths = [
str(getattr(route, "path", "") or "")
for route in routes
if "WebSocket" in type(route).__name__
]
paths = [p for p in paths if "/onebot/" in p]
if not paths:
return default
# 同一条路由注册了根路径与尾斜杠别名,优先规范路径 /onebot/v11/ws
return sorted(paths, key=lambda p: (not p.rstrip("/").endswith("/ws"), len(p)))[0]
def _access_token() -> str:
"""反向 WS 授权令牌(OneBot V11 适配器配置;未设置时协议端无需带 Authorization)。"""
try:
for bot in get_bots().values():
adapter = getattr(bot, "adapter", None)
cfg = getattr(adapter, "onebot_config", None)
token = getattr(cfg, "onebot_access_token", None)
if token:
return str(token)
except Exception:
pass
return os.environ.get("ONEBOT_V11_ACCESS_TOKEN", "") or ""
def _endpoint_sync() -> dict:
"""协议端接入信息:监听地址 / 端口 / 反向 WS 路径 / 授权令牌 / 可用 IP。
供 Web 首页展示「协议端配对」信息,只读 driver.config 与驱动的路由表,不额外起服务。
"""
host = ""
port = None
try:
from nonebot import get_driver
config = get_driver().config
host = str(getattr(config, "host", "") or "")
raw_port = getattr(config, "port", None)
port = int(raw_port) if raw_port else None
except Exception:
pass
return {
"host": host,
"port": port,
"ws_path": _ws_path(),
"access_token": _access_token(),
"ips": _local_ips(),
}
def _system_sync() -> dict:
boot = psutil.boot_time()
now = time.time()
return {
"hostname": platform.node(),
"os": platform.platform(),
"python": f"{platform.python_implementation()} {platform.python_version()}",
"nonebot": _nonebot_version(),
"boot_time": int(boot),
"system_uptime_seconds": int(now - boot),
"bot_uptime_seconds": int(now - _MODULE_START),
}
def _nonebot_version() -> str:
try:
import nonebot
return nonebot.__version__
except Exception:
return ""
def _disk_sync() -> list:
parts = []
for part in psutil.disk_partitions():
try:
usage = psutil.disk_usage(part.mountpoint)
except Exception:
continue
parts.append(
{
"mount": part.mountpoint,
"device": part.device,
"percent": round(float(usage.percent), 1),
"used": int(usage.used),
"total": int(usage.total),
}
)
return parts
async def _collect_bots() -> list:
try:
bots = get_bots().values()
except Exception as e: # noqa: BLE001
logger.warning(f"获取 bot 列表失败: {type(e).__name__}: {e}")
return []
result = []
for bot in bots:
adapter = getattr(bot, "adapter", None)
connections = getattr(adapter, "connections", {}) or {}
ws_connected = bool(connections.get(bot.self_id))
item = {
"self_id": bot.self_id,
"adapter": adapter.get_name() if adapter else "",
"nick": bot.self_id,
"avatar": f"https://q1.qlogo.cn/g?b=qq&nk={bot.self_id}&s=640",
"ws_connected": ws_connected,
"online": None,
"msg_recv": None,
"msg_sent": None,
}
if ws_connected:
cached = _LOGIN_CACHE.get(bot.self_id)
if cached and time.time() - cached[0] < _LOGIN_TTL:
item["nick"] = cached[1]
else:
try:
login = await bot.get_login_info()
item["nick"] = login.get("nickname") or item["nick"]
_LOGIN_CACHE[bot.self_id] = (time.time(), item["nick"])
except Exception as e: # noqa: BLE001
logger.warning(
f"获取登录信息失败({bot.self_id}): {type(e).__name__}: {e}"
)
try:
status = await bot.get_status()
item["online"] = status.get("online")
stat = status.get("stat") or {}
item["msg_recv"] = stat.get("message_received") or stat.get(
"MessageReceived"
)
item["msg_sent"] = stat.get("message_sent") or stat.get("MessageSent")
except Exception as e: # noqa: BLE001
logger.warning(
f"获取连接状态失败({bot.self_id}): {type(e).__name__}: {e}"
)
result.append(item)
return result
async def collect_dashboard() -> dict:
"""聚合采集,失败字段降级,不阻塞整体返回。
同步采集整体放线程池(psutil 的 disk_usage 等可能秒级阻塞),
结果带 1 秒短缓存(SSE 每 2s 循环与多标签页共用)。
"""
now = time.time()
if _DASH_CACHE["data"] is not None and now - _DASH_CACHE["time"] < _DASH_CACHE_TTL:
return _DASH_CACHE["data"]
cpu, memory, process, system, disk, network, backend, endpoint, bots = (
await asyncio.gather(
asyncio.to_thread(_cpu_sync),
asyncio.to_thread(_memory_sync),
asyncio.to_thread(_process_sync),
asyncio.to_thread(_system_sync),
asyncio.to_thread(_disk_sync),
asyncio.to_thread(_network_sync),
asyncio.to_thread(_backend_sync),
asyncio.to_thread(_endpoint_sync),
_collect_bots(), # 需要走 bot API,必须留在事件循环
)
)
data = {
"bots": bots,
"system": system,
"cpu": cpu,
"memory": memory,
"process": process,
"disk": disk,
"network": network,
"backend": backend,
"endpoint": endpoint,
}
_DASH_CACHE["time"] = now
_DASH_CACHE["data"] = data
return data