diff --git a/hexi/web/src/layout/AppLayout.tsx b/hexi/web/src/layout/AppLayout.tsx index fe3d7dd..5e0fe39 100644 --- a/hexi/web/src/layout/AppLayout.tsx +++ b/hexi/web/src/layout/AppLayout.tsx @@ -1,11 +1,17 @@ import { useLayoutEffect, useEffect, useRef, useState, Suspense } from 'react' import { Navigate, NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom' -import { Avatar, Button, Separator, Spinner } from '@heroui/react' -import { House, Rocket, Gear, FileText, Bars } from '@gravity-ui/icons' +import { + Avatar, Button, Dropdown, Modal, Spinner, toast, +} from '@heroui/react' +import { + ArrowRightFromSquare, Bars, ChevronDown, FileText, Gear, House, Power, Rocket, + ArrowRotateLeft, +} from '@gravity-ui/icons' import { useAuth } from '../auth/auth' -import { hubPlugins } from '../api/client' +import { hubBotAction, hubPlugins } from '../api/client' import { ErrorBoundary } from '../components/ErrorBoundary' import { useConnection } from '../connection' +import { BOT_ACTION_TEXT, watchBotReconnect } from '../lib/botAction' interface NavItem { id: string; label: string; description: string; icon: any } @@ -198,10 +204,10 @@ export function AppLayout() { {status} {lastUpdate && {new Date(lastUpdate).toLocaleTimeString('zh-CN', { hour12: false })}} - {(username || 'U').charAt(0).toUpperCase()} - {username || '未登录'} - - + { logout(); navigate('/hub/login') }} + />
@@ -218,3 +224,85 @@ export function AppLayout() { ) } + + +/** 顶栏账号菜单:头像 + 用户名点开下拉,重启/关闭 bot 与退出登录都收进去。 + * + * 重启/关闭都是不可逆的破坏性操作(关掉之后得去服务器手动拉起), + * 所以先弹确认框再发请求;重启还会复用 `watchBotReconnect` 等回连。 + */ +function AccountMenu({ username, onLogout }: { username: string; onLogout: () => void }) { + const [pending, setPending] = useState<'stop' | 'restart' | null>(null) + const [busy, setBusy] = useState(false) + const reconnectRef = useRef<(() => void) | null>(null) + + // 布局卸载(登出/跳转)时别把等待回连的 SSE 与定时器留在外面 + useEffect(() => () => { reconnectRef.current?.() }, []) + + const confirm = async () => { + const action = pending + if (!action) return + setBusy(true) + try { + await hubBotAction(action) + setPending(null) + if (action === 'stop') { + toast.warning('已请求关闭 bot;需要时在服务器上重新启动') + return + } + toast.success('已请求重启 bot,等待回连…') + reconnectRef.current?.() + reconnectRef.current = watchBotReconnect({ + onReady: () => { reconnectRef.current = null; toast.success('bot 已重启并回连') }, + onTimeout: () => { reconnectRef.current = null; toast.danger('等待回连超时,请检查 bot 是否已启动') }, + }) + } catch (e: any) { + toast.danger(e.message || '操作失败') + } finally { setBusy(false) } + } + + return ( + <> + + + {(username || 'U').charAt(0).toUpperCase()} + {username || '未登录'} + + + + { + if (key === 'logout') onLogout() + else setPending(key === 'stop' ? 'stop' : 'restart') + }} + > + + 重启 bot + + + 关闭 bot + + + 退出登录 + + + + + + { if (!open) setPending(null) }}> + + {pending ? BOT_ACTION_TEXT[pending].title : ''} + +

{pending ? BOT_ACTION_TEXT[pending].confirm : ''}

+
+ + + + +
+
+ + ) +} diff --git a/hexi/web/src/lib/botAction.ts b/hexi/web/src/lib/botAction.ts new file mode 100644 index 0000000..21e5f40 --- /dev/null +++ b/hexi/web/src/lib/botAction.ts @@ -0,0 +1,52 @@ +import { hubDashboardStream } from '../api/client' + +/** 请求停止/重启 bot 后,等它回连的那段等待逻辑(设置页与顶栏账号菜单共用)。 + * + * 重启分两步回来:旧进程先退出(让出端口),新进程才起来,所以先干等一会儿 + * 再开始探活;探活以 dashboard SSE **真正收到一帧**为准 —— 只看 HTTP 200 会在 + * 「端口起来了但插件还没挂接」时就放行,紧接着的请求全 404。 + * + * 返回 dispose:重复触发或组件卸载时调用,内部会撤掉 SSE 与超时定时器。 + */ +export function watchBotReconnect(opts: { + onReady: () => void + onTimeout: () => void + /** 等旧进程退出的时间 */ + startupDelayMs?: number + /** 探活总超时 */ + timeoutMs?: number +}): () => void { + const { onReady, onTimeout, startupDelayMs = 4000, timeoutMs = 60000 } = opts + let stopped = false + let timer: number | null = null + let disposeStream: (() => void) | null = null + + const finish = () => { + stopped = true + if (timer != null) { window.clearTimeout(timer); timer = null } + if (disposeStream) { disposeStream(); disposeStream = null } + } + + timer = window.setTimeout(() => { + if (stopped) return + disposeStream = hubDashboardStream({ + onData: (d) => { + if (stopped) return + if (d && d.ok !== false) { finish(); onReady() } + }, + }) + timer = window.setTimeout(() => { + if (stopped) return + finish() + onTimeout() + }, timeoutMs) + }, startupDelayMs) + + return finish +} + +/** 菜单/按钮上共用的文案,避免两处写法漂移 */ +export const BOT_ACTION_TEXT = { + restart: { title: '重启 bot', confirm: '确定重启 bot 进程吗?将先拉起新进程再退出旧进程,期间 Web 与 QQ 连接会短暂断开。' }, + stop: { title: '关闭 bot', confirm: '确定关闭当前 bot 进程吗?Web 管理台与 QQ 连接都会断开,需要在服务器上手动重新启动。' }, +} as const diff --git a/hexi/web/src/pages/home/index.tsx b/hexi/web/src/pages/home/index.tsx index ad11284..dc38047 100644 --- a/hexi/web/src/pages/home/index.tsx +++ b/hexi/web/src/pages/home/index.tsx @@ -1,4 +1,6 @@ -import { Avatar, Card, Spinner } from '@heroui/react' +import { useState } from 'react' +import { Avatar, Button, Card, Spinner } from '@heroui/react' +import { Copy, CopyCheck, Eye, EyeSlash } from '@gravity-ui/icons' import { useConnection } from '../../connection' import { fmtBytes, fmtRate, fmtUptime } from '../../lib/format' @@ -75,6 +77,13 @@ interface BackendInfo { driver: string driver_type: string } +interface EndpointInfo { + host: string + port: number | null + ws_path: string + access_token: string + ips: string[] +} interface DashboardData { ok: boolean bots: BotInfo[] @@ -85,6 +94,68 @@ interface DashboardData { disk: DiskInfo[] network: NetworkInfo backend: BackendInfo + endpoint: EndpointInfo +} + +/** 复制的兜底:非 HTTPS/localhost 下 navigator.clipboard 不可用 */ +async function copyText(text: string) { + try { await navigator.clipboard.writeText(text); return true } + catch { + const ta = document.createElement('textarea'); ta.value = text; document.body.appendChild(ta); ta.select() + const ok = document.execCommand('copy'); ta.remove(); return ok + } +} + +function CopyBtn({ text, label }: { text: string; label?: string }) { + const [done, setDone] = useState(false) + const onCopy = async () => { + if (!text || !(await copyText(text))) return + setDone(true); window.setTimeout(() => setDone(false), 1500) + } + return ( + + ) +} + +/** 协议端配对信息行:左标签右值 + 复制按钮 */ +function EndpointRow({ label, value, copy }: { label: string; value: string; copy: string }) { + return ( +
+ {label} +
+ {value || '—'} + +
+
+ ) +} + +/** 访问令牌:默认打码,可显示后复制(协议端配对需要填) */ +function TokenRow({ token }: { token: string }) { + const [show, setShow] = useState(false) + if (!token) return ( +
+ 访问令牌 + 未设置(协议端无需 Authorization) +
+ ) + return ( +
+ 访问令牌 +
+ {show ? token : '•'.repeat(8)} + + +
+
+ ) } function Bar({ value, color }: { value: number; color?: string }) { @@ -134,84 +205,129 @@ export default function HomePage() { const net = data?.network const backend = data?.backend + // 协议端配对:优先取本机局域网 IP(0.0.0.0 是监听地址,不是可连接地址) + const ep = data?.endpoint + const bindHost = ep?.host || '' + const lanIps = ep?.ips || [] + const ip = lanIps[0] || (bindHost && bindHost !== '0.0.0.0' && bindHost !== '::' ? bindHost : '') + const port = ep?.port ?? null + const wsPath = ep?.ws_path || '' + const token = ep?.access_token || '' + const otherIps = lanIps.slice(1) + const wsUrl = ip && port && wsPath ? `ws://${ip}:${port}${wsPath}` : '' + const bindHint = data && bindHost && bindHost !== '0.0.0.0' && bindHost !== '::' + ? `监听地址为 ${bindHost},仅本机可连;把 .env 的 HOST 改成 0.0.0.0 并重启后可对局域网开放` + : '' + return (

总览

-

主机与 BOT 综合状态(实时刷新)

+

主机与 BOT 综合状态

{error && !data &&

{error}

} - {/* 顶部:BOT 协议端信息 + BOT 后端信息 一行两列 */} -
- {/* BOT 协议端信息:登录账号昵称 / 账号 / 适配器类型 */} - - - - - - {!data ? ( -
- ) : !data.bots.length ? ( -

暂无在线 QQ 账号

- ) : ( -
- {data.bots.map(bot => ( -
- - - {(bot.nick || bot.self_id).charAt(0).toUpperCase()} - -
-
{bot.nick}
-
{bot.self_id} · {bot.adapter}
+ {/* 顶部:BOT 协议端信息(左半登录账号 / 右半 NoneBot 接入信息,供协议端配对) */} + + + + + +
+ {/* 左半:登录账号昵称 / 账号 / 适配器类型 */} +
+
登录账号
+ {!data ? ( +
+ ) : !data.bots.length ? ( +

暂无在线 QQ 账号

+ ) : ( +
+ {data.bots.map(bot => ( +
+ + + {(bot.nick || bot.self_id).charAt(0).toUpperCase()} + +
+
{bot.nick}
+
{bot.self_id} · {bot.adapter}
+
+
+ {bot.msg_recv != null && 收 {bot.msg_recv}} + {bot.msg_sent != null && 发 {bot.msg_sent}} + {bot.online != null && {bot.online ? '在线' : '离线'}} + + + {bot.ws_connected ? '已连接' : '未连接'} + +
-
- {bot.msg_recv != null && 收 {bot.msg_recv}} - {bot.msg_sent != null && 发 {bot.msg_sent}} - {bot.online != null && {bot.online ? '在线' : '离线'}} - - - {bot.ws_connected ? '已连接' : '未连接'} - -
-
- ))} -
- )} - - - {/* BOT 后端信息:NoneBot 版本 / 驱动方式 / 装载插件数 */} - - - - - -
-
-
NoneBot 版本
-
{backend?.nonebot_version || '—'}
-
-
-
驱动方式
-
{backend?.driver || '—'}
-
{backend?.driver_type || ''}
-
-
-
装载插件数
-
{backend?.plugin_count ?? '—'} 个
-
-
-
Python
-
{backend?.python || '—'}
-
bot 运行 {sys ? fmtUptime(sys.bot_uptime_seconds) : '—'}
-
+ ))} +
+ )}
-
-
-
+ {/* 右半:NoneBot 接入信息(IP / 端口 / WS 路径 / 令牌,均可一键复制) */} +
+
+ 协议端接入信息 + +
+
+ + + + +
+ {otherIps.length > 0 && ( +

+ 其它网卡: + {otherIps.map(x => ( + + ))} +

+ )} +
+
反向 WebSocket 地址(协议端侧填写)
+
{wsUrl || '—'}
+
+ {bindHint &&

{bindHint}

} +
+
+ + + + {/* BOT 后端信息:NoneBot 版本 / 驱动方式 / 装载插件数 */} + + + + + +
+
+
NoneBot 版本
+
{backend?.nonebot_version || '—'}
+
+
+
驱动方式
+
{backend?.driver || '—'}
+
{backend?.driver_type || ''}
+
+
+
装载插件数
+
{backend?.plugin_count ?? '—'} 个
+
+
+
Python
+
{backend?.python || '—'}
+
bot 运行 {sys ? fmtUptime(sys.bot_uptime_seconds) : '—'}
+
+
+
+
{/* 1. 主机规格信息:系统 / 处理器 / 内存 / 硬盘 */} diff --git a/hexi/web/src/pages/plugins/index.tsx b/hexi/web/src/pages/plugins/index.tsx index 41121d8..e351df1 100644 --- a/hexi/web/src/pages/plugins/index.tsx +++ b/hexi/web/src/pages/plugins/index.tsx @@ -281,6 +281,81 @@ export default function PluginsPage() { return ov ? ov.enabled : p.enabled } + // 分类:带 Web 页面的在最上,仅带配置的居中,两者都无的在最下 + // (同时有 Web 与配置的归入第一类,配置入口在卡片里照常显示) + const sections: { key: string; title: string; hint: string; items: CatalogPlugin[] }[] = [ + { key: 'web', title: '带 Web 页面', hint: '有独立管理页面', items: plugins.filter(p => p.has_web) }, + { key: 'config', title: '带配置项', hint: '无独立页面,可打开「配置」编辑', items: plugins.filter(p => !p.has_web && p.has_config) }, + { key: 'plain', title: '无 Web 页面与配置项', hint: '仅支持全局 / 分群开关', items: plugins.filter(p => !p.has_web && !p.has_config) }, + ] + + const renderPlugin = (p: CatalogPlugin) => { + const on = p.enabled + return ( + + +
+
+
+ {p.name} + {p.type && ( + + {p.type === 'library' ? 'lib' : 'app'} + + )} + {p.has_web && Web} + {p.has_config && 配置} + {on ? '启用' : '停用'} +
+
{p.id}
+
{p.description || '—'}
+
+ +
+ {p.has_config && } + {p.has_web && p.web_path && } + + + + + 分群控制 + + +
+
+ 分群控制(覆盖全局) + +
+ {!groups.length &&
未获取到群列表
} + {groups.map(g => { + const eff = effectiveGroupEnabled(p, g.group_id) + return ( +
+ toggleGroup(p, g.group_id, v)}> + {g.group_name || g.group_id} + +
+ ) + })} +
+
+
+ + toggleGlobal(p, v)}> + 全局启用 + +
+
+
+
+ ) + } + return (
@@ -295,73 +370,17 @@ export default function PluginsPage() { {loading && !plugins.length &&
} {!loading && !plugins.length && !error &&

未发现可管理的插件

} -
- {plugins.map(p => { - const on = p.enabled - return ( - - -
-
-
- {p.name} - {p.type && ( - - {p.type === 'library' ? 'lib' : 'app'} - - )} - {p.has_web && Web} - {p.has_config && 配置} - {on ? '启用' : '停用'} -
-
{p.id}
-
{p.description || '—'}
-
- -
- {p.has_config && } - {p.has_web && p.web_path && } - - - - - 分群控制 - - -
-
- 分群控制(覆盖全局) - -
- {!groups.length &&
未获取到群列表
} - {groups.map(g => { - const eff = effectiveGroupEnabled(p, g.group_id) - return ( -
- toggleGroup(p, g.group_id, v)}> - {g.group_name || g.group_id} - -
- ) - })} -
-
-
- - toggleGlobal(p, v)}> - 全局启用 - -
-
-
-
- ) - })} +
+ {sections.map(s => s.items.length > 0 && ( +
+
+

{s.title}

+ {s.items.length} + {s.hint} +
+ {s.items.map(renderPlugin)} +
+ ))}
diff --git a/hexi/web/src/pages/settings/index.tsx b/hexi/web/src/pages/settings/index.tsx index db8ba33..546bb98 100644 --- a/hexi/web/src/pages/settings/index.tsx +++ b/hexi/web/src/pages/settings/index.tsx @@ -3,8 +3,9 @@ import { Button, Card, Form, Input, Label, ListBox, Modal, Select, Spinner, Switch, TextField, toast, useOverlayState, } from '@heroui/react' import { - hubSettings, hubChangePassword, hubBotAction, hubBotConfig, hubBotConfigSave, hubDashboardStream, + hubSettings, hubChangePassword, hubBotAction, hubBotConfig, hubBotConfigSave, } from '../../api/client' +import { watchBotReconnect } from '../../lib/botAction' interface SettingsData { ok: boolean @@ -39,8 +40,6 @@ export default function SettingsPage() { const [pendingAction, setPendingAction] = useState<'stop' | 'restart' | null>(null) const [restarting, setRestarting] = useState(null) const botState = useOverlayState() - const pollRef = useRef(0) - const pollTimerRef = useRef(null) const restartDisposeRef = useRef<(() => void) | null>(null) const [botCfg, setBotCfg] = useState(null) @@ -77,10 +76,8 @@ export default function SettingsPage() { useEffect(() => { load() }, [load]) - useEffect(() => () => { - if (restartDisposeRef.current) restartDisposeRef.current() - if (pollTimerRef.current != null) window.clearTimeout(pollTimerRef.current) - }, []) + // dispose 内部会一并撤掉 SSE 与超时定时器 + useEffect(() => () => { restartDisposeRef.current?.() }, []) const changePassword = async () => { if (!oldPassword) { toast.warning('请输入原密码'); return } @@ -130,44 +127,22 @@ export default function SettingsPage() { botState.open() } - const clearPollTimer = () => { - if (pollTimerRef.current != null) { - window.clearTimeout(pollTimerRef.current) - pollTimerRef.current = null - } - } - - const pollForReconnect = async () => { - const id = ++pollRef.current - // 先等旧进程完全退出(旧的 dashboard SSE 可能仍被旧进程服务,提前收到数据会误判就绪) - await new Promise(res => setTimeout(res, 4000)) - if (pollRef.current !== id) return - if (restartDisposeRef.current) restartDisposeRef.current() - // 以 dashboard SSE 真正收到数据作为「后端已就绪」信号,避免仅 HTTP 起来但插件未挂接就放行 - const dispose = hubDashboardStream({ - onData: (d) => { - if (pollRef.current !== id) return - if (d && d.ok !== false) { - restartDisposeRef.current = null - dispose() - clearPollTimer() - pollRef.current += 1 // 让旧定时器即使未被清除也不会误判超时 - setRestarting(null) - toast.success('bot 已重启并回连') - load() - } - }, - }) - restartDisposeRef.current = dispose - pollTimerRef.current = window.setTimeout(() => { - pollTimerRef.current = null - if (pollRef.current === id) { + const pollForReconnect = () => { + // 等待逻辑与顶栏账号菜单共用一份(lib/botAction.ts),别在两边各写一套 + restartDisposeRef.current?.() + restartDisposeRef.current = watchBotReconnect({ + onReady: () => { + restartDisposeRef.current = null + setRestarting(null) + toast.success('bot 已重启并回连') + load() + }, + onTimeout: () => { restartDisposeRef.current = null - dispose() setRestarting(null) toast.danger('等待回连超时,请检查 bot 是否已启动') - } - }, 60000) + }, + }) } const confirmBotAction = async () => { diff --git a/hexi/web/tsconfig.tsbuildinfo b/hexi/web/tsconfig.tsbuildinfo index 61dd18c..d9b48ab 100644 --- a/hexi/web/tsconfig.tsbuildinfo +++ b/hexi/web/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/connection.tsx","./src/logs.tsx","./src/main.tsx","./src/router.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/auth/loginpage.tsx","./src/auth/auth.tsx","./src/components/errorboundary.tsx","./src/components/ui.tsx","./src/layout/applayout.tsx","./src/lib/format.ts","./src/pages/home/index.tsx","./src/pages/logs/index.tsx","./src/pages/plugins/index.tsx","./src/pages/settings/index.tsx","./src/plugins/index.ts","./src/plugins/galgame_card/index.tsx","./src/plugins/helldivers/index.tsx","./src/plugins/learning_chat/index.tsx"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/app.tsx","./src/connection.tsx","./src/logs.tsx","./src/main.tsx","./src/router.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/auth/loginpage.tsx","./src/auth/auth.tsx","./src/components/errorboundary.tsx","./src/components/ui.tsx","./src/layout/applayout.tsx","./src/lib/botaction.ts","./src/lib/format.ts","./src/pages/home/index.tsx","./src/pages/logs/index.tsx","./src/pages/plugins/index.tsx","./src/pages/settings/index.tsx","./src/plugins/index.ts","./src/plugins/galgame_card/index.tsx","./src/plugins/helldivers/index.tsx","./src/plugins/learning_chat/index.tsx","./src/plugins/mc_server_status/index.tsx","./src/plugins/video_analysis/index.tsx"],"version":"5.9.3"} \ No newline at end of file diff --git a/hexi/web_hub/dashboard.py b/hexi/web_hub/dashboard.py index 77d2e95..4ce87d1 100644 --- a/hexi/web_hub/dashboard.py +++ b/hexi/web_hub/dashboard.py @@ -3,6 +3,7 @@ 数据来源与 nonebot_plugin_picstatus 类似: - bot 账号 / WS 连通性:从 OneBot V11 适配器的 bots / connections 读取 +- 协议端接入信息:driver.config 的 host/port + 驱动实际注册的反向 WS 路由 + 本机网卡 IP - host 性能:psutil(CPU/内存/磁盘/进程)+ platform + nonebot 版本 仅在 /hub/api/dashboard 被调用时执行,不做常驻采样。 同步采集(psutil 等可能阻塞的调用)整体放入线程池执行,避免阻塞事件循环; @@ -15,6 +16,7 @@ psutil.cpu_percent/Process.cpu_percent 会真的 sleep 住调用线程,而 SSE from __future__ import annotations import asyncio +import os import platform import time @@ -202,6 +204,102 @@ def _backend_sync() -> dict: 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() @@ -303,15 +401,18 @@ async def collect_dashboard() -> dict: 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,必须留在事件循环 + 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, @@ -322,6 +423,7 @@ async def collect_dashboard() -> dict: "disk": disk, "network": network, "backend": backend, + "endpoint": endpoint, } _DASH_CACHE["time"] = now _DASH_CACHE["data"] = data