diff --git a/README.md b/README.md index 2c90cca..73ec219 100644 --- a/README.md +++ b/README.md @@ -246,7 +246,7 @@ self.set_progress(done=5, total=80, unit="视频", action_counts={"like": 3}, el |-----|------|--------| | **监控** | 统计卡片 + 设备表(在线/型号/任务状态/进度/前台 App/最近错误,支持排序、搜索、分页、多选批量操作)+ 异常汇总 + **任务运行概况**(每张任务卡:执行任务 / 停用任务 + 覆盖设备彩色 chip);导航栏「📺 大屏」打开 `/wall` | 所有登录用户(设备操作按钮需"设备控制") | | **任务** | 子分栏:**任务计划**(CRUD / 启停 / 执行 / 下次运行时间 / 离线跳过 / 步骤编辑器)、**自定义动作**(步骤打包复用) | 可看;写操作需"任务管理" | -| **日志** | 实时日志(按文件切换、可自动刷新) | 需"日志查看" | +| **日志** | 实时日志(文件切换含滚动历史、关键字/级别/时间过滤、命中高亮、下载当前筛选结果、可自动刷新) | 需"日志查看" | | **用户** | 用户 CRUD、改密、分配权限 | 仅管理员 | | **工具** | 8 个子分栏:剪贴板注入 / adb 远程终端 / Tailscale 管理 / 应用管理 / 应用版本管理 / 设备已装应用 / **设备池管理**(含自动发现)/ 设备分组 | 仅管理员 | | **AI 控制台** | 会话列表 + 对话区(Markdown 渲染、推理链折叠、token 统计)+ 实时画面(MJPEG)+ 目标设备选择;右上角:经验库 / 动作库 / 模型配置 | 仅管理员 | @@ -357,7 +357,10 @@ from core.logger import get_logger log = get_logger("task.generic") # → logs/task.log ``` -「日志」Tab 可在线查看。 +「日志」Tab 可在线查看:文件下拉包含 `.1/.2…` 滚动历史(可回溯更早的日志), +支持关键字(命中高亮)/最低级别/时间范围过滤,点「下载」导出当前筛选结果 +(无筛选就是整个文件)。读取逻辑在 `core/logger.py` 的 `list_log_files()/query_log()/ +read_log_text()`,`file` 参数走白名单,不接受任意路径。 --- diff --git a/core/logger.py b/core/logger.py index cc30904..b5e926f 100644 --- a/core/logger.py +++ b/core/logger.py @@ -16,7 +16,10 @@ 文件按 10MB 滚动,保留 5 个历史文件。 """ import os +import re import logging +import datetime +from collections import deque from logging.handlers import RotatingFileHandler _LOG_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "logs") @@ -84,3 +87,192 @@ def log_print(msg, level="info", module="core"): """print 的替代品,转发到 logging。""" log = get_logger(module) getattr(log, level, log.info)(msg) + + +# ================== 日志读取(「日志」页的筛选 / 下载) ================== +# +# 读日志的代码放这里而不是 web 层:文件命名与滚动规则归本模块管 +# (_MODULE_FILES + RotatingFileHandler 的 backupCount),**白名单校验必须和 +# 写入侧用同一份事实**,否则改了文件名就会漏掉一处,变成目录穿越。 + +# 行格式与 _FORMAT 对应:2026-09-16 00:27:11 [INFO] [web.notify] 消息 +_LINE_RE = re.compile(r"^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) " + r"\[([A-Z]+)\] \[([^\]]*)\] ?(.*)$") +_LEVEL_ORDER = {"DEBUG": 10, "INFO": 20, "WARNING": 30, "ERROR": 40, "CRITICAL": 50} + +# 单次读取的行数上限:文件本身被限在 10MB(约 10 万行),这里再兜一层, +# 万一将来有人调大 maxBytes,也不会把内存吃爆。 +_MAX_SCAN_LINES = 500000 + +_MODULE_LABELS = { + "core.log": "核心(adb/调度)", + "task.log": "任务执行", + "web.log": "Web/AI", + "action.log": "动作执行", + "notify.log": "通知发送", +} + + +def _rotation_files(base): + """基础名 + 磁盘上实际存在的滚动副本(core.log → core.log.1/.2…)。 + + RotatingFileHandler 的备份编号**越大越旧**,所以 base 最新,接着 .1/.2… + """ + names = [] + if os.path.exists(os.path.join(_LOG_DIR, base)): + names.append(base) + i = 1 + while i <= 99 and os.path.exists(os.path.join(_LOG_DIR, f"{base}.{i}")): + names.append(f"{base}.{i}") + i += 1 + return names + + +def list_log_files(): + """可查看的日志文件(模块文件 + 滚动历史),最近写入的排前面。 + + `file` 参数只能取这里的 name(白名单),杜绝 `../../` 之类的路径穿越。 + """ + out = [] + for base in sorted(set(_MODULE_FILES.values())): + label = _MODULE_LABELS.get(base, base) + for name in _rotation_files(base): + try: + st = os.stat(os.path.join(_LOG_DIR, name)) + except OSError: + continue + rotated = name != base + out.append({ + "name": name, + "label": f"{label} · {name}" + ("(历史)" if rotated else ""), + "module": base, + "size": st.st_size, + "mtime": int(st.st_mtime), + "rotated": rotated, + }) + out.sort(key=lambda f: (-f["mtime"], f["name"])) + return out + + +def _norm_ts(value, end=False): + """把前端的时间输入归一成 'YYYY-MM-DD HH:MM:SS'(可比字符串)。 + + 接受 datetime-local 的 'YYYY-MM-DDTHH:MM'、'YYYY-MM-DD HH:MM' 和纯日期; + 纯日期按整天算(end=True 取 23:59:59,否则 00:00:00)。 + """ + s = (value or "").strip().replace("T", " ") + if not s: + return "" + if len(s) == 10: # 只给了日期 → 按整天 + return s + (" 23:59:59" if end else " 00:00:00") + if len(s) == 16: # YYYY-MM-DD HH:MM + return s + (":59" if end else ":00") + return s[:19] + + +def _iter_filtered(fh, keyword, min_level, since, until, stats=None): + """逐行读文件,产出命中的 {ts, level, module, msg, cont}。 + + 过滤口径: + - 时间/级别用**继承值**——续行(traceback 的缩进行等)本身没有前缀, + 继承上一条带前缀的行,这样按 ERROR 筛选时整段堆栈不会被拆散; + - 关键字匹配本行原文(续行也参与),大小写不敏感。 + + stats(可选 dict)里回填 scanned:本次实际读了多少行(用于告诉用户 + "是读到文件开头了,还是被行数上限截断的")。 + """ + kw = (keyword or "").strip().lower() + floor = _LEVEL_ORDER.get((min_level or "").upper(), 0) + cur_ts = cur_level = cur_module = "" + for raw in fh: + if stats is not None: + stats["scanned"] += 1 + line = raw.rstrip("\r\n") + m = _LINE_RE.match(line) + if m: + cur_ts, cur_level, cur_module, msg = m.groups() + cont = False + else: + # 续行(traceback、被切成多行的长消息):沿用上一条的元信息 + msg, cont = line, True + if since and (not cur_ts or cur_ts < since): + continue + if until and (not cur_ts or cur_ts > until): + continue + if floor and _LEVEL_ORDER.get(cur_level, 0) < floor: + continue + if kw and kw not in line.lower(): + continue + yield {"ts": cur_ts, "level": cur_level, "module": cur_module, + "msg": msg, "cont": cont} + + +def query_log(name, keyword="", min_level="", since="", until="", + limit=300, max_scan=_MAX_SCAN_LINES): + """取日志文件里**最近** limit 条满足条件的行。 + + 返回 dict: + rows — [{ts, level, module, msg, cont}],保持文件原顺序(旧→新) + matched — 命中总数(可能大于 len(rows):更早的命中没返回) + scanned — 实际读了多少行(达到 max_scan 说明是被上限截断的) + truncated — matched > len(rows),即"还有更早的命中没显示" + error — 出错时的原因(如文件不存在) + + 正序读整文件 + deque 保留最后 N 条:文件被 RollingFileHandler 限在 10MB + (约 10 万行),整读一次百毫秒级;换来的是续行能正确继承时间戳——倒着 + 读的写法在遇到 traceback 时必须把续行攒着等前面那行,容易出错。 + """ + # 白名单:`name` 必须来自 list_log_files(),否则一律拒绝(防目录穿越) + if name not in {f["name"] for f in list_log_files()}: + return {"rows": [], "matched": 0, "scanned": 0, "truncated": False, + "error": "日志文件不存在或不可读取"} + since, until = _norm_ts(since), _norm_ts(until, end=True) + try: + keep = max(1, min(int(limit or 300), 5000)) + except (TypeError, ValueError): + keep = 300 + rows, matched, stats = deque(maxlen=keep), 0, {"scanned": 0} + try: + with open(os.path.join(_LOG_DIR, name), encoding="utf-8", + errors="replace") as fh: + for row in _iter_filtered(fh, keyword, min_level, since, until, stats): + matched += 1 + rows.append(row) + if stats["scanned"] >= max_scan: + break + except OSError as e: + return {"rows": [], "matched": 0, "scanned": 0, "truncated": False, + "error": f"读取失败: {e}"} + return {"rows": list(rows), "matched": matched, "scanned": stats["scanned"], + "truncated": matched > len(rows), "error": ""} + + +def read_log_text(name, keyword="", min_level="", since="", until="", + limit=200000): + """导出用的整段文本:命中行按原格式拼回去(下载按钮)。 + + 没给任何条件时直接读整个文件(含滚动历史由调用方选定的那一个)。 + """ + if name not in {f["name"] for f in list_log_files()}: + return None, "日志文件不存在或不可读取" + since, until = _norm_ts(since), _norm_ts(until, end=True) + any_filter = bool((keyword or "").strip() or min_level or since or until) + path = os.path.join(_LOG_DIR, name) + try: + with open(path, encoding="utf-8", errors="replace") as fh: + if not any_filter: + return fh.read(), "" + buf, n = [], 0 + for row in _iter_filtered(fh, keyword, min_level, since, until): + if row["cont"]: + buf.append(row["msg"]) # 续行原样输出 + else: + buf.append(f"{row['ts']} [{row['level']}] " + f"[{row['module']}] {row['msg']}") + n += 1 + if n >= limit: + buf.append(f"...(已达导出上限 {limit} 行,请缩小时间范围)") + break + return "\n".join(buf) + ("\n" if buf else ""), "" + except OSError as e: + return None, f"读取失败: {e}" diff --git a/doc/API.md b/doc/API.md index 3694fda..f1ca9d0 100644 --- a/doc/API.md +++ b/doc/API.md @@ -139,7 +139,8 @@ | POST | `/api/users` | Admin | 新建用户 | | PUT | `/api/users/` | Admin | 更新用户(改密/权限/管理员) | | DELETE | `/api/users/` | Admin | 删除用户 | -| GET | `/api/logs` | G | 读日志文件尾部 N 行 | +| GET | `/api/logs` | G | 读日志(关键字/级别/时间过滤,返回结构化行) | +| GET | `/api/logs/download` | G | 下载日志(同样支持过滤;无过滤即整个文件) | ### 2.5 devices(`web/devices_api.py`) @@ -615,7 +616,37 @@ | `POST /api/users` | Admin | `{"username","password","is_admin"?,"perms"?}` | 用户名/密码空、重名 → 400 | | `PUT /api/users/` | Admin | `{"password"?,"is_admin"?,"perms"?}` | 取消最后一个管理员 → 400;不存在 404 | | `DELETE /api/users/` | Admin | — | 删 `admin`/删自己/删最后一个管理员 → 400 | -| `GET /api/logs?file=core.log&lines=300` | G | — | 返回日志尾部 + 可选文件清单 | +| `GET /api/logs?file=core.log&lines=300` | G | `q` 关键字、`level` 最低级别、`since`/`until` 时间 | 见下 §10.1 | +| `GET /api/logs/download?file=core.log` | G | 同上(参数一致) | 以附件返回 `.txt`,文件名带时间戳 | + +#### 10.1 日志查询(`GET /api/logs`) + +参数: + +| 参数 | 说明 | +|------|------| +| `file` | 日志文件名,**白名单校验**(只能取接口返回的 `files[].name`,含 `.1/.2` 滚动历史);非法值返回 `ok:false` | +| `lines` | 返回**最近**多少条命中(默认 300,上限 5000) | +| `q` | 关键字,大小写不敏感,匹配整行原文 | +| `level` | 最低级别:`ERROR` / `WARNING` / `INFO` / `DEBUG`(含以上);留空为全部 | +| `since` / `until` | 时间范围,接受 `YYYY-MM-DDTHH:MM`(datetime-local)或 `YYYY-MM-DD`(按整天) | + +返回: + +```json +{"ok": true, "file": "core.log", + "files": [{"name": "core.log", "label": "核心(adb/调度) · core.log", + "size": 1175910, "mtime": 1789519242, "rotated": false}], + "rows": [{"ts": "2026-09-16 08:26:50", "level": "INFO", + "module": "core.disc", "msg": "发现: …", "cont": false}], + "matched": 11423, "scanned": 11423, "truncated": true} +``` + +- `rows` 保持文件原顺序(旧→新),即"最近 N 条命中"按时间正序排列; +- `cont=true` 是**续行**(traceback 的缩进行等):它本身没有时间/级别前缀, + 过滤时**继承上一条带前缀的行**,所以按 ERROR 筛选不会把堆栈拆散; +- `matched` 是命中总数,`truncated=true` 表示更早的命中没返回(应缩小时间范围或加关键字); +- `scanned` 是实际扫描行数(上限 50 万行)。 --- diff --git a/static/admin/admin.js b/static/admin/admin.js index 63a6fea..0af633e 100644 --- a/static/admin/admin.js +++ b/static/admin/admin.js @@ -91,22 +91,85 @@ async function deleteGroup(name){ } // ================== Tab 4: 日志 ================== +// 条件(文件/关键字/级别/时间)→ /api/logs(结构化行);下载走 /api/logs/download +function _logParams(){ + const v=id=>document.getElementById(id); + const p=new URLSearchParams(); + p.set('file', v('log-file').value||''); + p.set('lines', v('log-lines').value); + const q=v('log-q').value.trim(); if(q)p.set('q',q); + const lv=v('log-level').value; if(lv)p.set('level',lv); + const since=v('log-since').value; if(since)p.set('since',since); + const until=v('log-until').value; if(until)p.set('until',until); + return p; +} + +// 文件下拉由接口返回的清单渲染(写死的选项会漏掉新增模块,如 notify.log) +function _renderLogFiles(files,current){ + const sel=document.getElementById('log-file'); + const sig=(files||[]).map(f=>f.name).join(','); + if(sel.dataset.sig!==sig){ // 只在清单变化时重建,避免打断当前选择 + sel.dataset.sig=sig; + sel.innerHTML=(files||[]).map(f=> + '').join(''); + } + if(current)sel.value=current; +} + async function loadLogs(){ - const file=document.getElementById('log-file').value; - const lines=document.getElementById('log-lines').value; - const r=await apiGet('/api/logs?file='+encodeURIComponent(file)+'&lines='+lines); - if(!r||!r.ok)return; - const content=r.content||''; - // 简单着色 - const colored=esc(content) - .replace(/\[ERROR\]/g,'[ERROR]') - .replace(/\[WARNING\]/g,'[WARNING]') - .replace(/\[INFO\]/g,'[INFO]'); - document.getElementById('log-content').innerHTML=colored||'(空)'; - document.getElementById('log-hint').textContent=file+' · '+content.split('\n').length+' 行'; - // 自动滚动到底部 const el=document.getElementById('log-content'); - el.scrollTop=el.scrollHeight; + if(!el)return; + // 用户正往上翻看历史时不要被自动刷新拽回底部 + const atBottom=el.scrollHeight-el.scrollTop-el.clientHeight<40; + const r=await apiGet('/api/logs?'+_logParams().toString()); + if(!r)return; + _renderLogFiles(r.files,r.file); + const hint=document.getElementById('log-hint'); + if(!r.ok){ + el.innerHTML=''+esc(r.error||'读取失败')+''; + hint.textContent=''; + return; + } + const rows=r.rows||[], kw=document.getElementById('log-q').value.trim(); + el.innerHTML=rows.length + ? rows.map(row=>_logRowHtml(row,kw)).join('') + : '(无匹配行)'; + let text=r.file+' · 命中 '+r.matched+' 行' + +(rows.length['+esc(lvl)+'] [' + +esc(r.module)+'] '; + return '
'+head+_logHl(r.msg,kw)+'
'; +} + +// 关键字高亮:**先转义再匹配**——反过来的话消息里的 < > 会先被吃成实体 +function _logHl(text,kw){ + const safe=esc(text||''); + if(!kw)return safe; + const k=esc(kw); + if(!k)return safe; + const re=new RegExp(k.replace(/[.*+?^${}()|[\]\\]/g,'\\$&'),'gi'); + return safe.replace(re,m=>''+m+''); +} + +function downloadLogs(){ + window.location='/api/logs/download?'+_logParams().toString(); +} + +function resetLogFilters(){ + ['log-q','log-since','log-until'].forEach(id=>{document.getElementById(id).value='';}); + document.getElementById('log-level').value=''; + loadLogs(); } function toggleLogAuto(){ diff --git a/templates/admin/monitor.html b/templates/admin/monitor.html index 6cfb923..938098e 100644 --- a/templates/admin/monitor.html +++ b/templates/admin/monitor.html @@ -112,7 +112,13 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14 .log-content .log-err{color:#f87171} .log-content .log-warn{color:#fbbf24} .log-content .log-info{color:#67e8f9} -.log-toolbar{display:flex;gap:10px;margin-bottom:12px;align-items:center} +.log-row{white-space:pre-wrap} +.log-cont{color:#8a94a6;padding-left:2.2em} /* 续行(traceback 缩进行):淡色缩进 */ +.log-hit{background:#fbbf2433} /* 关键字命中高亮 */ +/* 工具栏:控件较多,允许换行;控件本身不参与收缩(否则会被挤成竖排文字) */ +.log-toolbar{display:flex;flex-wrap:wrap;gap:8px 10px;margin-bottom:12px;align-items:center} +.log-toolbar>*{flex:0 0 auto} +.log-toolbar .hint{flex:1 1 200px;min-width:160px} /* 区块标题 */ .section-title{font-family:var(--display);font-size:15px;font-weight:700;color:var(--text);margin:0 0 12px;padding-bottom:8px;border-bottom:2px solid var(--primary);display:inline-block;letter-spacing:.2px} @@ -780,20 +786,32 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
日志查看
查看系统各模块运行日志
- + + + + ~ + + +
diff --git a/web/admin_api.py b/web/admin_api.py index be9151c..4d55d43 100644 --- a/web/admin_api.py +++ b/web/admin_api.py @@ -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:运行控制 ==================