feat(日志): 日志页支持关键字/级别/时间过滤 + 下载(含滚动历史)
「日志」页原来只能 tail 固定行数、且文件下拉写死了 4 项(新增的 notify.log
根本选不到)。本次:
- core/logger.py 新增读取接口:list_log_files()(模块文件 + .1/.2 滚动历史,
白名单)、query_log()(关键字/最低级别/时间过滤,返回结构化行)、
read_log_text()(导出)。
· 时间/级别过滤对**续行**用继承值(traceback 缩进行本身没有前缀),
按 ERROR 筛不会把堆栈拆散;
· 过滤按"最近 N 条命中"返回,保持文件原顺序;
- GET /api/logs 换新协议(rows/files/matched/scanned/truncated),
新增 GET /api/logs/download(附件下载,支持同样过滤;无一条件时整文件);
- 顺带修 **目录穿越**:原实现 os.path.join(_LOG_DIR, file) 可用 ../../ 逃逸,
现在 file 必须命中白名单;
- 顺带修 web/admin_api.py 里 _log 未定义(用户增删改的日志行会 NameError);
- 前端:文件下拉改为按接口渲染、加关键字(命中高亮)/级别/时间范围/下载/重置,
自动刷新时不再把正在上翻的用户拽回底部。
文档:doc/API.md §10.1(参数与返回)、README 日志节。
This commit is contained in:
+56
-14
@@ -1,17 +1,18 @@
|
||||
"""管理域 API:用户管理/日志查看。"""
|
||||
import os
|
||||
import time
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
from flask_login import current_user
|
||||
from flask import session
|
||||
|
||||
from core.logger import _LOG_DIR, _MODULE_FILES
|
||||
from core import logger
|
||||
from core.logger import get_logger
|
||||
from core.models import db, User
|
||||
from web import context
|
||||
from web.auth import (admin_required, perm_required, _validate_perms,
|
||||
PERM_LOGS)
|
||||
|
||||
_log = get_logger("web")
|
||||
bp = Blueprint("admin", __name__)
|
||||
|
||||
@bp.route("/api/users")
|
||||
@@ -79,22 +80,63 @@ def api_users_delete(uid):
|
||||
|
||||
|
||||
# ================== API:日志查看 ==================
|
||||
#
|
||||
# 过滤/白名单都在 core.logger 里做(读取侧与写入侧共用同一份文件清单,
|
||||
# 见 core/logger.py「日志读取」一节);这里只负责取参数 + 组装响应。
|
||||
|
||||
|
||||
def _log_filters():
|
||||
"""从 query string 取过滤条件(/api/logs 与 /api/logs/download 共用)。"""
|
||||
return dict(keyword=request.args.get("q", ""),
|
||||
min_level=request.args.get("level", ""),
|
||||
since=request.args.get("since", ""),
|
||||
until=request.args.get("until", ""))
|
||||
|
||||
|
||||
@bp.route("/api/logs")
|
||||
@perm_required(PERM_LOGS)
|
||||
def api_logs():
|
||||
files = list(_MODULE_FILES.values())
|
||||
current = request.args.get("file", "core.log")
|
||||
lines = int(request.args.get("lines", 300))
|
||||
content = ""
|
||||
path = os.path.join(_LOG_DIR, current)
|
||||
if os.path.exists(path):
|
||||
try:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
content = "".join(f.readlines()[-lines:])
|
||||
except Exception as e:
|
||||
content = f"读取失败: {e}"
|
||||
return jsonify({"ok": True, "content": content, "file": current, "files": files})
|
||||
"""读日志:可按关键字 / 最低级别 / 时间范围过滤,返回结构化行。
|
||||
|
||||
参数:file 文件名(白名单)、q 关键字、level 最低级别(ERROR 含以上…)、
|
||||
since/until 时间、lines 返回条数(取**最近** N 条命中)。
|
||||
"""
|
||||
files = logger.list_log_files()
|
||||
names = [f["name"] for f in files]
|
||||
# 默认仍落在 core.log(接口按 mtime 倒序返回,names[0] 会随当前哪个模块在
|
||||
# 写而漂移,作为"打开日志页时看哪个"不稳定)
|
||||
default = "core.log" if "core.log" in names else (names[0] if names else "")
|
||||
current = request.args.get("file") or default
|
||||
try:
|
||||
lines = int(request.args.get("lines", 300))
|
||||
except (TypeError, ValueError):
|
||||
lines = 300
|
||||
res = logger.query_log(current, limit=lines, **_log_filters())
|
||||
return jsonify({"ok": not res["error"], "error": res["error"], "file": current,
|
||||
"files": files, "rows": res["rows"], "matched": res["matched"],
|
||||
"scanned": res["scanned"], "truncated": res["truncated"]})
|
||||
|
||||
|
||||
@bp.route("/api/logs/download")
|
||||
@perm_required(PERM_LOGS)
|
||||
def api_logs_download():
|
||||
"""下载日志文件(带同样的过滤条件;无条件时就是整个文件)。
|
||||
|
||||
用 BytesIO 发送而不是 send_file(路径):Windows 上流式发送时文件句柄可能
|
||||
到 close 仍未释放,而日志文件正被日志线程持续写入,按路径发容易踩锁。
|
||||
"""
|
||||
from io import BytesIO
|
||||
from flask import send_file
|
||||
name = request.args.get("file", "")
|
||||
text, err = logger.read_log_text(name, **_log_filters())
|
||||
if err:
|
||||
return jsonify({"ok": False, "error": err}), 404
|
||||
data = text.encode("utf-8")
|
||||
fname = f"{name}_{time.strftime('%Y%m%d_%H%M%S')}.txt"
|
||||
_log.info("下载日志: %s(%d 字节)by %s", name, len(data),
|
||||
getattr(current_user, "username", ""))
|
||||
return send_file(BytesIO(data), as_attachment=True, download_name=fname,
|
||||
mimetype="text/plain; charset=utf-8")
|
||||
|
||||
|
||||
# ================== API:运行控制 ==================
|
||||
|
||||
Reference in New Issue
Block a user