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:
@@ -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` 参数走白名单,不接受任意路径。
|
||||
|
||||
---
|
||||
|
||||
|
||||
+192
@@ -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}"
|
||||
|
||||
+33
-2
@@ -139,7 +139,8 @@
|
||||
| POST | `/api/users` | Admin | 新建用户 |
|
||||
| PUT | `/api/users/<int:uid>` | Admin | 更新用户(改密/权限/管理员) |
|
||||
| DELETE | `/api/users/<int:uid>` | 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/<int:uid>` | Admin | `{"password"?,"is_admin"?,"perms"?}` | 取消最后一个管理员 → 400;不存在 404 |
|
||||
| `DELETE /api/users/<int:uid>` | 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 万行)。
|
||||
|
||||
---
|
||||
|
||||
|
||||
+77
-14
@@ -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=>
|
||||
'<option value="'+esc(f.name)+'">'+esc(f.label)+'</option>').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,'<span class="log-err">[ERROR]</span>')
|
||||
.replace(/\[WARNING\]/g,'<span class="log-warn">[WARNING]</span>')
|
||||
.replace(/\[INFO\]/g,'<span class="log-info">[INFO]</span>');
|
||||
document.getElementById('log-content').innerHTML=colored||'<span class="text-muted">(空)</span>';
|
||||
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='<span class="text-muted">'+esc(r.error||'读取失败')+'</span>';
|
||||
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('')
|
||||
: '<span class="text-muted">(无匹配行)</span>';
|
||||
let text=r.file+' · 命中 '+r.matched+' 行'
|
||||
+(rows.length<r.matched?'(显示最近 '+rows.length+' 行)':'')
|
||||
+' · 已扫描 '+r.scanned+' 行';
|
||||
if(r.truncated)text+=' · 更早的命中已省略:缩小时间范围或加关键字';
|
||||
hint.textContent=text;
|
||||
if(atBottom)el.scrollTop=el.scrollHeight;
|
||||
}
|
||||
|
||||
function _logRowHtml(r,kw){
|
||||
const lvl=(r.level||'').toUpperCase();
|
||||
const cls=lvl==='ERROR'||lvl==='CRITICAL'?'log-err'
|
||||
:lvl==='WARNING'?'log-warn':'log-info';
|
||||
// 续行(traceback 缩进行)没有前缀,只缩进显示消息体
|
||||
const head=r.cont?'':esc(r.ts)+' <span class="'+cls+'">['+esc(lvl)+']</span> ['
|
||||
+esc(r.module)+'] ';
|
||||
return '<div class="log-row'+(r.cont?' log-cont':'')+'">'+head+_logHl(r.msg,kw)+'</div>';
|
||||
}
|
||||
|
||||
// 关键字高亮:**先转义再匹配**——反过来的话消息里的 < > 会先被吃成实体
|
||||
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=>'<span class="log-hit">'+m+'</span>');
|
||||
}
|
||||
|
||||
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(){
|
||||
|
||||
@@ -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
|
||||
<div class="page-title">日志查看</div>
|
||||
<div class="page-sub">查看系统各模块运行日志</div>
|
||||
<div class="log-toolbar">
|
||||
<select id="log-file" class="form-control" style="width:auto" onchange="loadLogs()">
|
||||
<option value="core.log">核心日志 (core.log)</option>
|
||||
<option value="task.log">任务日志 (task.log)</option>
|
||||
<option value="web.log">Web日志 (web.log)</option>
|
||||
<option value="action.log">操作日志 (action.log)</option>
|
||||
<!-- 文件下拉由 /api/logs 返回的清单动态渲染(含 .1/.2 滚动历史与 notify.log) -->
|
||||
<select id="log-file" class="form-control" style="width:auto" onchange="loadLogs()"></select>
|
||||
<input id="log-q" class="form-control" style="width:190px" placeholder="关键字(设备号/包名/报错…)"
|
||||
onkeydown="if(event.key==='Enter')loadLogs()">
|
||||
<select id="log-level" class="form-control" style="width:auto" onchange="loadLogs()">
|
||||
<option value="">全部级别</option>
|
||||
<option value="ERROR">仅 ERROR</option>
|
||||
<option value="WARNING">WARNING 及以上</option>
|
||||
<option value="INFO">INFO 及以上</option>
|
||||
</select>
|
||||
<input id="log-since" type="datetime-local" class="form-control" style="width:auto"
|
||||
title="起始时间" onchange="loadLogs()">
|
||||
<span class="text-muted">~</span>
|
||||
<input id="log-until" type="datetime-local" class="form-control" style="width:auto"
|
||||
title="结束时间" onchange="loadLogs()">
|
||||
<select id="log-lines" class="form-control" style="width:auto" onchange="loadLogs()">
|
||||
<option value="100">最近 100 行</option>
|
||||
<option value="300" selected>最近 300 行</option>
|
||||
<option value="500">最近 500 行</option>
|
||||
<option value="1000">最近 1000 行</option>
|
||||
<option value="5000">最近 5000 行</option>
|
||||
</select>
|
||||
<label class="form-check"><input type="checkbox" id="log-auto" checked onchange="toggleLogAuto()"> 自动刷新</label>
|
||||
<button class="btn" onclick="loadLogs()">刷新</button>
|
||||
<button class="btn" onclick="downloadLogs()" title="下载当前筛选结果(无筛选即整个文件)">下载</button>
|
||||
<button class="btn" onclick="resetLogFilters()">重置</button>
|
||||
<span class="hint" id="log-hint"></span>
|
||||
</div>
|
||||
<div id="log-content" class="log-content"></div>
|
||||
|
||||
+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