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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user