feat: revamp web hub — global connection status, array config editor, log streaming

- Global connection badge in app header (persistent dashboard SSE via ConnectionProvider)
- LogProvider keeps log data and SSE stream alive across page switches (no reload spinner)
- Config drawer: password reveal toggle, array-type row editor for list fields
- Plugin management: optimistic updates, race-free loading, reset all group overrides
- Logs: load-earlier pagination; backend tail-read optimization (before/end offsets)
- web_auth: Web password changes persist (sync_admin seeding only when .env has explicit key)
- Dashboard: collect in thread pool + 1s result cache
- ErrorBoundary + local Suspense: plugin chunk loading stays inside content area
- UI polish: table row hover, mobile header/breadcrumb fixes, wider config drawer
- config_standard: array type inference for list fields; array env skip
- random_jm_code / steam_info: array schema migration; README web frontend note

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-09-03 15:39:10 +08:00
co-authored by Claude Code
parent 9371a28e35
commit c53b210413
45 changed files with 1312 additions and 576 deletions
+86 -16
View File
@@ -54,8 +54,13 @@ basic_path = Path(__file__).resolve().parent
# hexi/web 是统一 Web 管理台前端(hexi/web/dist),不是插件目录下的 web
WEB_DIST = Path(__file__).resolve().parents[2] / "web" / "dist"
# 管理台自身版本号(展示在设置页「关于」处)
HUB_VERSION = "0.1.0"
def _hub_version() -> str:
"""管理台版本号:以 hexi/web/package.json 为唯一来源(前端构建也读它)。"""
try:
pkg = WEB_DIST.parent / "package.json"
return str(json.loads(pkg.read_text(encoding="utf-8")).get("version", "0.1.0"))
except Exception: # noqa: BLE001
return "0.1.0"
# 仓库根目录(bot.py 所在)与 bot 端口
_REPO_ROOT = Path(__file__).resolve().parents[3]
@@ -118,6 +123,65 @@ def _archive_log() -> None:
logger.warning(f"归档日志失败: {type(e).__name__}: {e}")
def _tail_log_lines(
path: Path, limit: int, before: int | None
) -> tuple[list[str], int, int]:
"""从日志文件读取最近 limit 行(或 before 字节之前最近的 limit 行)。
返回 (lines, window_start_offset, file_size)。window_start_offset 可作为
下一批更早日志的 before;为 0 表示已到文件头。仅按 8KB 步进向后寻找行边界,
避免大日志全量 read_bytes。逐行 UTF-8 优先、GBK 兜底:兼容旧 GBK 日志与
切换为 UTF-8 后的新日志混存(保留 ANSI 颜色码,交给前端渲染颜色)。
"""
size = path.stat().st_size
end = size if before is None or before <= 0 or before > size else int(before)
if end <= 0:
return [], 0, size
start = 0
with open(path, "rb") as f:
pos = end
newline_count = 0
while pos > 0 and newline_count <= limit:
step = min(pos, 8192)
f.seek(pos - step)
chunk = f.read(step)
idx = chunk.rfind(b"\n")
if idx == -1:
pos -= step
continue
nl_abs = pos - step + idx
newline_count += 1
if newline_count > limit:
start = nl_abs + 1
break
pos = nl_abs
f.seek(start)
raw = f.read(end - start)
raw_lines = raw.split(b"\n")
if raw_lines and raw_lines[-1] == b"":
raw_lines.pop()
lines = []
for raw_line in raw_lines:
try:
line = raw_line.decode("utf-8")
except UnicodeDecodeError:
line = raw_line.decode("gbk", errors="replace")
lines.append(line.rstrip("\r"))
return lines, start, size
def _count_log_lines(path: Path) -> int:
"""分块统计全文件行数(仅换行计数,不全量载入)。"""
count = 0
with open(path, "rb") as f:
while True:
chunk = f.read(1 << 20)
if not chunk:
break
count += chunk.count(b"\n")
return count
def _schedule_bot_action(action: str) -> None:
"""1 秒后执行:stop 直接退出;restart 先拉起新进程再退出旧进程。"""
@@ -205,7 +269,7 @@ def build_hub_app() -> FastAPI:
"created_at": user.get("created_at", ""),
},
"plugins": list_web_plugins(),
"version": HUB_VERSION,
"version": _hub_version(),
}
@app.get("/api/dashboard")
@@ -360,9 +424,15 @@ def build_hub_app() -> FastAPI:
@app.get("/api/logs")
async def logs(
limit: int = 500,
before: int | None = None,
_: dict = Depends(get_current_user),
):
"""实时读取 bot 运行日志(优先 _bot_run.log),返回最近 limit 行,供前端轮询。"""
"""读取 bot 运行日志(优先 _bot_run.log)。
- before 不传:返回最近 limit 行,offset/end 为文件尾(SSE 以 end 续读)
- before 传字节偏移:返回该偏移之前最近 limit 行,offset 为这些行的起点,
继续把 offset 传给 before 即可向前翻页;offset==0 表示已到文件头。
"""
root = Path(__file__).resolve().parents[3] # 仓库根目录
candidates = [
root / "_bot_run.log",
@@ -372,20 +442,20 @@ def build_hub_app() -> FastAPI:
if path is None:
return {"ok": False, "msg": "未找到日志文件", "lines": [], "size": 0, "total": 0}
try:
raw = path.read_bytes()
lines, start, size = _tail_log_lines(path, max(1, int(limit)), before)
total = _count_log_lines(path)
except OSError as e: # noqa: BLE001
return {"ok": False, "msg": f"读取日志失败: {e}", "lines": [], "size": 0, "total": 0}
# 逐行 UTF-8 优先、GBK 兜底:兼容旧 GBK 日志与切换为 UTF-8 后的新日志混存
# 保留 ANSI 颜色码,交给前端渲染颜色
lines = []
for raw_line in raw.split(b"\n"):
try:
line = raw_line.decode("utf-8")
except UnicodeDecodeError:
line = raw_line.decode("gbk", errors="replace")
lines.append(line.rstrip("\r"))
tail = lines[-max(1, int(limit)) :]
return {"ok": True, "file": path.name, "size": len(raw), "offset": len(raw), "lines": tail, "total": len(lines)}
return {
"ok": True,
"file": path.name,
"size": size,
"end": size,
"offset": start,
"has_more": start > 0,
"lines": lines,
"total": total,
}
@app.get("/api/dashboard/stream")
async def dashboard_stream(
@@ -5,6 +5,8 @@
- bot 账号 / WS 连通性:从 OneBot V11 适配器的 bots / connections 读取
- host 性能:psutil(CPU/内存/磁盘/进程)+ platform + nonebot 版本
仅在 /hub/api/dashboard 被调用时执行,不做常驻采样。
同步采集(psutil 等可能阻塞的调用)整体放入线程池执行,避免阻塞事件循环;
结果做 1 秒短缓存,多个前端标签页(含 SSE 循环)不至于各自重复采集。
"""
from __future__ import annotations
@@ -26,17 +28,21 @@ _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
async def _collect_cpu() -> dict:
def _cpu_sync() -> dict:
try:
per_core = await asyncio.to_thread(psutil.cpu_percent, 0.3, True)
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 = await asyncio.to_thread(psutil.cpu_percent, 0.3)
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 [])],
@@ -57,7 +63,7 @@ async def _collect_cpu() -> dict:
return info
async def _collect_memory() -> dict:
def _memory_sync() -> dict:
mem = psutil.virtual_memory()
try:
proc = psutil.Process()
@@ -76,10 +82,10 @@ async def _collect_memory() -> dict:
}
async def _collect_process() -> dict:
def _process_sync() -> dict:
try:
proc = psutil.Process()
cpu = await asyncio.to_thread(proc.cpu_percent, 0.2)
cpu = proc.cpu_percent(0.2)
info = {
"pid": proc.pid,
"name": proc.name(),
@@ -94,7 +100,7 @@ async def _collect_process() -> dict:
return info
async def _collect_network() -> dict:
def _network_sync() -> dict:
"""采集网络流量累计值 + 实时速率(bytes/s)。"""
global _NET_LAST
now = time.time()
@@ -157,7 +163,7 @@ async def _collect_network() -> dict:
}
async def _collect_backend() -> dict:
def _backend_sync() -> dict:
"""BOT 后端信息:NoneBot 版本 / 驱动方式 / 装载插件数。"""
from nonebot import get_loaded_plugins
@@ -183,7 +189,7 @@ async def _collect_backend() -> dict:
return info
async def _collect_system() -> dict:
def _system_sync() -> dict:
boot = psutil.boot_time()
now = time.time()
return {
@@ -206,7 +212,7 @@ def _nonebot_version() -> str:
return ""
async def _collect_disk() -> list:
def _disk_sync() -> list:
parts = []
for part in psutil.disk_partitions():
try:
@@ -271,18 +277,25 @@ async def _collect_bots() -> list:
async def collect_dashboard() -> dict:
"""聚合采集,失败字段降级,不阻塞整体返回。"""
cpu, memory, process, system, disk, bots, network, backend = await asyncio.gather(
_collect_cpu(),
_collect_memory(),
_collect_process(),
_collect_system(),
_collect_disk(),
_collect_bots(),
_collect_network(),
_collect_backend(),
"""聚合采集,失败字段降级,不阻塞整体返回。
同步采集整体放线程池(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,必须留在事件循环
)
return {
data = {
"bots": bots,
"system": system,
"cpu": cpu,
@@ -292,3 +305,6 @@ async def collect_dashboard() -> dict:
"network": network,
"backend": backend,
}
_DASH_CACHE["time"] = now
_DASH_CACHE["data"] = data
return data