Merge branch 'feat/log-query'——日志页:关键字/级别/时间过滤 + 下载(含滚动历史)

This commit is contained in:
2026-09-16 09:10:18 +08:00
6 changed files with 387 additions and 38 deletions
+5 -2
View File
@@ -246,7 +246,7 @@ self.set_progress(done=5, total=80, unit="视频", action_counts={"like": 3}, el
|-----|------|--------| |-----|------|--------|
| **监控** | 统计卡片 + 设备表(在线/型号/任务状态/进度/前台 App/最近错误,支持排序、搜索、分页、多选批量操作)+ 异常汇总 + **任务运行概况**(每张任务卡:执行任务 / 停用任务 + 覆盖设备彩色 chip);导航栏「📺 大屏」打开 `/wall` | 所有登录用户(设备操作按钮需"设备控制") | | **监控** | 统计卡片 + 设备表(在线/型号/任务状态/进度/前台 App/最近错误,支持排序、搜索、分页、多选批量操作)+ 异常汇总 + **任务运行概况**(每张任务卡:执行任务 / 停用任务 + 覆盖设备彩色 chip);导航栏「📺 大屏」打开 `/wall` | 所有登录用户(设备操作按钮需"设备控制") |
| **任务** | 子分栏:**任务计划**(CRUD / 启停 / 执行 / 下次运行时间 / 离线跳过 / 步骤编辑器)、**自定义动作**(步骤打包复用) | 可看;写操作需"任务管理" | | **任务** | 子分栏:**任务计划**(CRUD / 启停 / 执行 / 下次运行时间 / 离线跳过 / 步骤编辑器)、**自定义动作**(步骤打包复用) | 可看;写操作需"任务管理" |
| **日志** | 实时日志(按文件切换、可自动刷新) | 需"日志查看" | | **日志** | 实时日志(文件切换含滚动历史、关键字/级别/时间过滤、命中高亮、下载当前筛选结果、可自动刷新) | 需"日志查看" |
| **用户** | 用户 CRUD、改密、分配权限 | 仅管理员 | | **用户** | 用户 CRUD、改密、分配权限 | 仅管理员 |
| **工具** | 8 个子分栏:剪贴板注入 / adb 远程终端 / Tailscale 管理 / 应用管理 / 应用版本管理 / 设备已装应用 / **设备池管理**(含自动发现)/ 设备分组 | 仅管理员 | | **工具** | 8 个子分栏:剪贴板注入 / adb 远程终端 / Tailscale 管理 / 应用管理 / 应用版本管理 / 设备已装应用 / **设备池管理**(含自动发现)/ 设备分组 | 仅管理员 |
| **AI 控制台** | 会话列表 + 对话区(Markdown 渲染、推理链折叠、token 统计)+ 实时画面(MJPEG)+ 目标设备选择;右上角:经验库 / 动作库 / 模型配置 | 仅管理员 | | **AI 控制台** | 会话列表 + 对话区(Markdown 渲染、推理链折叠、token 统计)+ 实时画面(MJPEG)+ 目标设备选择;右上角:经验库 / 动作库 / 模型配置 | 仅管理员 |
@@ -357,7 +357,10 @@ from core.logger import get_logger
log = get_logger("task.generic") # → logs/task.log 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
View File
@@ -16,7 +16,10 @@
文件按 10MB 滚动,保留 5 个历史文件。 文件按 10MB 滚动,保留 5 个历史文件。
""" """
import os import os
import re
import logging import logging
import datetime
from collections import deque
from logging.handlers import RotatingFileHandler from logging.handlers import RotatingFileHandler
_LOG_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "logs") _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。""" """print 的替代品,转发到 logging。"""
log = get_logger(module) log = get_logger(module)
getattr(log, level, log.info)(msg) 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
View File
@@ -139,7 +139,8 @@
| POST | `/api/users` | Admin | 新建用户 | | POST | `/api/users` | Admin | 新建用户 |
| PUT | `/api/users/<int:uid>` | Admin | 更新用户(改密/权限/管理员) | | PUT | `/api/users/<int:uid>` | Admin | 更新用户(改密/权限/管理员) |
| DELETE | `/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`) ### 2.5 devices(`web/devices_api.py`)
@@ -615,7 +616,37 @@
| `POST /api/users` | Admin | `{"username","password","is_admin"?,"perms"?}` | 用户名/密码空、重名 → 400 | | `POST /api/users` | Admin | `{"username","password","is_admin"?,"perms"?}` | 用户名/密码空、重名 → 400 |
| `PUT /api/users/<int:uid>` | Admin | `{"password"?,"is_admin"?,"perms"?}` | 取消最后一个管理员 → 400;不存在 404 | | `PUT /api/users/<int:uid>` | Admin | `{"password"?,"is_admin"?,"perms"?}` | 取消最后一个管理员 → 400;不存在 404 |
| `DELETE /api/users/<int:uid>` | Admin | — | 删 `admin`/删自己/删最后一个管理员 → 400 | | `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
View File
@@ -91,22 +91,85 @@ async function deleteGroup(name){
} }
// ================== Tab 4: 日志 ================== // ================== 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(){ 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'); 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(){ function toggleLogAuto(){
+24 -6
View File
@@ -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-err{color:#f87171}
.log-content .log-warn{color:#fbbf24} .log-content .log-warn{color:#fbbf24}
.log-content .log-info{color:#67e8f9} .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} .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-title">日志查看</div>
<div class="page-sub">查看系统各模块运行日志</div> <div class="page-sub">查看系统各模块运行日志</div>
<div class="log-toolbar"> <div class="log-toolbar">
<select id="log-file" class="form-control" style="width:auto" onchange="loadLogs()"> <!-- 文件下拉由 /api/logs 返回的清单动态渲染(含 .1/.2 滚动历史与 notify.log) -->
<option value="core.log">核心日志 (core.log)</option> <select id="log-file" class="form-control" style="width:auto" onchange="loadLogs()"></select>
<option value="task.log">任务日志 (task.log)</option> <input id="log-q" class="form-control" style="width:190px" placeholder="关键字(设备号/包名/报错…)"
<option value="web.log">Web日志 (web.log)</option> onkeydown="if(event.key==='Enter')loadLogs()">
<option value="action.log">操作日志 (action.log)</option> <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> </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()"> <select id="log-lines" class="form-control" style="width:auto" onchange="loadLogs()">
<option value="100">最近 100 行</option> <option value="100">最近 100 行</option>
<option value="300" selected>最近 300 行</option> <option value="300" selected>最近 300 行</option>
<option value="500">最近 500 行</option> <option value="500">最近 500 行</option>
<option value="1000">最近 1000 行</option> <option value="1000">最近 1000 行</option>
<option value="5000">最近 5000 行</option>
</select> </select>
<label class="form-check"><input type="checkbox" id="log-auto" checked onchange="toggleLogAuto()"> 自动刷新</label> <label class="form-check"><input type="checkbox" id="log-auto" checked onchange="toggleLogAuto()"> 自动刷新</label>
<button class="btn" onclick="loadLogs()">刷新</button> <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> <span class="hint" id="log-hint"></span>
</div> </div>
<div id="log-content" class="log-content"></div> <div id="log-content" class="log-content"></div>
+56 -14
View File
@@ -1,17 +1,18 @@
"""管理域 API:用户管理/日志查看。""" """管理域 API:用户管理/日志查看。"""
import os
import time import time
from flask import Blueprint, jsonify, request from flask import Blueprint, jsonify, request
from flask_login import current_user from flask_login import current_user
from flask import session 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 core.models import db, User
from web import context from web import context
from web.auth import (admin_required, perm_required, _validate_perms, from web.auth import (admin_required, perm_required, _validate_perms,
PERM_LOGS) PERM_LOGS)
_log = get_logger("web")
bp = Blueprint("admin", __name__) bp = Blueprint("admin", __name__)
@bp.route("/api/users") @bp.route("/api/users")
@@ -79,22 +80,63 @@ def api_users_delete(uid):
# ================== API:日志查看 ================== # ================== 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") @bp.route("/api/logs")
@perm_required(PERM_LOGS) @perm_required(PERM_LOGS)
def api_logs(): def api_logs():
files = list(_MODULE_FILES.values()) """读日志:可按关键字 / 最低级别 / 时间范围过滤,返回结构化行。
current = request.args.get("file", "core.log")
lines = int(request.args.get("lines", 300)) 参数:file 文件名(白名单)、q 关键字、level 最低级别(ERROR 含以上…)、
content = "" since/until 时间、lines 返回条数(取**最近** N 条命中)。
path = os.path.join(_LOG_DIR, current) """
if os.path.exists(path): files = logger.list_log_files()
try: names = [f["name"] for f in files]
with open(path, encoding="utf-8") as f: # 默认仍落在 core.log(接口按 mtime 倒序返回,names[0] 会随当前哪个模块在
content = "".join(f.readlines()[-lines:]) # 写而漂移,作为"打开日志页时看哪个"不稳定)
except Exception as e: default = "core.log" if "core.log" in names else (names[0] if names else "")
content = f"读取失败: {e}" current = request.args.get("file") or default
return jsonify({"ok": True, "content": content, "file": current, "files": files}) 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:运行控制 ================== # ================== API:运行控制 ==================