Files
HeXi/hexi/web_hub/dashboard.py
T
sansenhoshi 131b92b319 结构调整
视频解析多图/多媒体结构 消息体适配
2026-09-08 14:25:32 +08:00

311 lines
9.8 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 读取
- 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