# -*- 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