Files
HeXi/hexi/plugins/nonebot_plugin_web_hub/dashboard.py
T

311 lines
9.8 KiB
Python
Raw Normal View History

# -*- coding: utf-8 -*-
"""统一 Web 管理台 Dashboard 数据采集。
数据来源与 nonebot_plugin_picstatus 类似:
- bot 账号 / WS 连通性:从 OneBot V11 适配器的 bots / connections 读取
- host 性能:psutil(CPU/内存/磁盘/进程)+ platform + nonebot 版本
仅在 /hub/api/dashboard 被调用时执行,不做常驻采样。
同步采集(psutil 等可能阻塞的调用)整体放入线程池执行,避免阻塞事件循环;
结果做 1 秒短缓存,多个前端标签页(含 SSE 循环)不至于各自重复采集。
"""
from __future__ import annotations
import asyncio
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 每 2s 循环共用一份结果)
_DASH_CACHE: dict = {"time": 0.0, "data": None}
_DASH_CACHE_TTL = 1.0
def _cpu_sync() -> dict:
try:
per_core = psutil.cpu_percent(0.3, True)
except TypeError:
# 兼容部分平台不接受 percpu 参数
per_core = []
if per_core:
percent = sum(per_core) / len(per_core)
else:
percent = psutil.cpu_percent(0.3)
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 = psutil.Process()
cpu = proc.cpu_percent(0.2)
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 _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:
try:
login = await bot.get_login_info()
item["nick"] = login.get("nickname") or 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, 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),
_collect_bots(), # 需要走 bot API,必须留在事件循环
)
data = {
"bots": bots,
"system": system,
"cpu": cpu,
"memory": memory,
"process": process,
"disk": disk,
"network": network,
"backend": backend,
}
_DASH_CACHE["time"] = now
_DASH_CACHE["data"] = data
return data