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:
@@ -24,6 +24,16 @@ BLOCK_CODE = ["350235", "350234"]
|
||||
WHITE_LIST = ["1019165018"]
|
||||
|
||||
|
||||
def _jm_codes_getter() -> list[str]:
|
||||
from .services.store import sync_get_jm_codes
|
||||
return sync_get_jm_codes()
|
||||
|
||||
|
||||
def _jm_codes_setter(codes) -> None:
|
||||
from .services.store import sync_set_jm_codes
|
||||
sync_set_jm_codes(codes)
|
||||
|
||||
|
||||
def register_config() -> None:
|
||||
register_config_items(
|
||||
_PLUGIN_ID,
|
||||
@@ -31,24 +41,35 @@ def register_config() -> None:
|
||||
{
|
||||
"key": "TEXT_TEMPLATES",
|
||||
"label": "随机文案模板",
|
||||
"type": "text",
|
||||
"type": "array",
|
||||
"item_type": "str",
|
||||
"default": TEXT_TEMPLATES,
|
||||
},
|
||||
{
|
||||
"key": "BLOCK_CODE",
|
||||
"label": "屏蔽码",
|
||||
"type": "text",
|
||||
"type": "array",
|
||||
"item_type": "str",
|
||||
"default": BLOCK_CODE,
|
||||
},
|
||||
{
|
||||
"key": "WHITE_LIST",
|
||||
"label": "白名单群号",
|
||||
"type": "text",
|
||||
"type": "array",
|
||||
"item_type": "str",
|
||||
"default": WHITE_LIST,
|
||||
},
|
||||
{
|
||||
"key": "JM_CODES",
|
||||
"label": "JM 码列表",
|
||||
"type": "array",
|
||||
"item_type": "str",
|
||||
"default": [],
|
||||
"description": "一行一个(或逗号分隔)。保存会写回 hexi/data/random_jm_code/jm_code.json,机器人随改随用。",
|
||||
"getter": _jm_codes_getter,
|
||||
"setter": _jm_codes_setter,
|
||||
"nosave": True,
|
||||
},
|
||||
],
|
||||
store=_sys.modules[__name__],
|
||||
)
|
||||
|
||||
@@ -136,7 +136,7 @@ register_model_config(
|
||||
"proxy",
|
||||
],
|
||||
types={
|
||||
"steam_api_key": "text",
|
||||
"steam_api_key": "array",
|
||||
"steam_broadcast_type": "enum",
|
||||
"steam_playtime_theme": "enum",
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user