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