「日志」页原来只能 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 日志节。
289 lines
12 KiB
JavaScript
289 lines
12 KiB
JavaScript
// 管理 Tab:分组/日志/用户
|
||
// ================== Tab 3: 分组 ==================
|
||
async function loadGroups(){
|
||
const r=await apiGet('/api/groups');if(!r)return;
|
||
_groupsList=r.groups||[];
|
||
const dr=await apiGet('/api/devices');
|
||
// items 带名称(设备身份);老接口只有 serial 列表时兜底成对象
|
||
_devicesList=(dr&&dr.items)?dr.items
|
||
:((dr&&dr.devices)||[]).map(s=>({serial:s,name:'',model:''}));
|
||
renderGroupTable();
|
||
}
|
||
|
||
function renderGroupTable(){
|
||
const groups=_groupsList||[];
|
||
setListPager('groups',{
|
||
data:()=>groups,
|
||
filter:(g,kw)=>((g.name||'').toLowerCase().includes(kw)||(g.description||'').toLowerCase().includes(kw)||(g.serials||[]).join(' ').toLowerCase().includes(kw)),
|
||
sortValue:(g,k)=>{ if(k==='count')return (g.serials||[]).length; return g[k]||''; },
|
||
render:renderGroupRow,
|
||
tbody:'tb-groups',pager:'pager-groups',pageSize:10,
|
||
empty:'<tr><td colspan="5" class="empty">暂无分组,点击"新建分组"创建</td></tr>'
|
||
});
|
||
applyListPager('groups');
|
||
initListSearch('groups','search-groups');
|
||
}
|
||
function renderGroupRow(g){
|
||
const serials=g.serials||[];
|
||
return '<tr>'+
|
||
'<td><strong>'+esc(g.name)+'</strong></td>'+
|
||
'<td>'+esc(g.description||'-')+'</td>'+
|
||
'<td>'+serials.length+'</td>'+
|
||
'<td class="text-muted" style="max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+(serials.length?serials.map(esc).join(', '):'-')+'</td>'+
|
||
'<td>'+
|
||
(_can('tasks')?(
|
||
'<button class="btn btn-xs" onclick="openGroupModal(\''+esc(g.name)+'\')">编辑</button>'+
|
||
'<button class="btn btn-danger btn-xs" onclick="deleteGroup(\''+esc(g.name)+'\')">删除</button>'
|
||
):'<span class="text-muted">只读</span>')+
|
||
'</td>'+
|
||
'</tr>';
|
||
}
|
||
|
||
function openGroupModal(name){
|
||
const group=name?(_groupsList||[]).find(g=>g.name===name):null;
|
||
const isEdit=!!group;
|
||
document.getElementById('modal-title').textContent=isEdit?'编辑分组':'新建分组';
|
||
const g=group||{name:'',description:'',serials:[]};
|
||
const current= new Set(g.serials||[]);
|
||
const nameOf={};
|
||
(_devicesList||[]).forEach(x=>{ if(x&&x.serial) nameOf[x.serial]=x.name||''; });
|
||
const allSerials=[...new Set([...(_devicesList||[]).map(x=>x.serial||x), ...(g.serials||[])])].sort();
|
||
const deviceCheckboxes=allSerials.map(s=>
|
||
'<label><input type="checkbox" value="'+esc(s)+'" '+(current.has(s)?'checked':'')+'>'
|
||
+esc(nameOf[s]?devText(nameOf[s], s):s)+'</label>'
|
||
).join('');
|
||
|
||
document.getElementById('modal-body').innerHTML=
|
||
'<div class="form-group"><label>分组名</label><input type="text" class="form-control" id="g-name" value="'+esc(g.name)+'" placeholder="如:测试组" '+(isEdit?'readonly':'')+'></div>'+
|
||
'<div class="form-group"><label>描述</label><input type="text" class="form-control" id="g-description" value="'+esc(g.description||'')+'" placeholder="可选"></div>'+
|
||
'<div class="form-group"><label>设备列表(勾选属于此分组的设备)</label>'+
|
||
'<div class="device-list">'+(deviceCheckboxes||'<div class="text-muted">暂无在线设备</div>')+'</div>'+
|
||
'<div class="help">仅显示当前在线设备,已选设备即使离线也会保留</div>'+
|
||
'</div>';
|
||
|
||
document.getElementById('modal-footer').innerHTML=
|
||
'<button class="btn" onclick="closeModal()">取消</button>'+
|
||
'<button class="btn btn-primary" onclick="saveGroup('+(isEdit?'\''+esc(g.name)+'\'':'null')+')">'+(isEdit?'保存':'创建')+'</button>';
|
||
|
||
showModal();
|
||
}
|
||
|
||
async function saveGroup(name){
|
||
const newName=document.getElementById('g-name').value.trim();
|
||
if(!newName){showToast('分组名不能为空','error');return;}
|
||
const desc=document.getElementById('g-description').value;
|
||
const serials=Array.from(document.querySelectorAll('.device-list input:checked')).map(c=>c.value);
|
||
const data={name:newName,description:desc,serials:serials};
|
||
|
||
if(name){
|
||
const r=await apiPut('/api/groups/'+encodeURIComponent(name),data);
|
||
handleResult(r,()=>{closeModal();loadGroups();});
|
||
}else{
|
||
const r=await apiPost('/api/groups',data);
|
||
handleResult(r,()=>{closeModal();loadGroups();});
|
||
}
|
||
}
|
||
|
||
async function deleteGroup(name){
|
||
if(!confirm('确认删除分组"'+name+'"吗?'))return;
|
||
const r=await apiDelete('/api/groups/'+encodeURIComponent(name));
|
||
handleResult(r,()=>loadGroups());
|
||
}
|
||
|
||
// ================== 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 el=document.getElementById('log-content');
|
||
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(){
|
||
if(_logTimer){clearInterval(_logTimer);_logTimer=null;}
|
||
if(document.getElementById('log-auto').checked && _activeTab==='logs'){
|
||
_logTimer=setInterval(loadLogs,3000);
|
||
}
|
||
}
|
||
|
||
// ================== Tab 5: 用户 ==================
|
||
async function loadUsers(){
|
||
const r=await apiGet('/api/users');if(!r)return;
|
||
const users=r.users||[];
|
||
setListPager('users',{
|
||
data:()=>users,
|
||
filter:(u,kw)=>(u.username||'').toLowerCase().includes(kw),
|
||
sortValue:(u,k)=>{ if(k==='is_admin')return u.is_admin?1:0; return u[k]||''; },
|
||
render:renderUserRow,
|
||
tbody:'tb-users',pager:'pager-users',pageSize:10,
|
||
empty:'<tr><td colspan="5" class="empty">暂无用户</td></tr>'
|
||
});
|
||
applyListPager('users');
|
||
initListSearch('users','search-users');
|
||
}
|
||
function renderUserRow(u){
|
||
const admin=u.is_admin?'<span class="label label-primary">管理员</span>':'<span class="label label-default">普通</span>';
|
||
const perms=(u.perms||[]).map(p=>'<span class="label label-info">'+(PERM_LABELS[p]||p)+'</span>').join(' ')
|
||
||'<span class="text-muted">-</span>';
|
||
return '<tr>'+
|
||
'<td>'+u.id+'</td>'+
|
||
'<td><strong>'+esc(u.username)+'</strong></td>'+
|
||
'<td>'+admin+'</td>'+
|
||
'<td>'+perms+'</td>'+
|
||
'<td>'+
|
||
'<button class="btn btn-xs" onclick="openUserModal('+u.id+')">编辑</button>'+
|
||
(u.username==='admin'?'':'<button class="btn btn-danger btn-xs" onclick="deleteUser('+u.id+')">删除</button>')+
|
||
'</td>'+
|
||
'</tr>';
|
||
}
|
||
|
||
function togglePermBoxes(){
|
||
// 勾选"管理员权限"时:权限位全选并禁用(管理员拥有全部权限)
|
||
const isAdmin=document.getElementById('u-is_admin').checked;
|
||
document.querySelectorAll('.u-perm').forEach(cb=>{
|
||
cb.disabled=isAdmin;
|
||
if(isAdmin)cb.checked=true;
|
||
});
|
||
}
|
||
|
||
function openUserModal(uid){
|
||
const isEdit=!!uid;
|
||
document.getElementById('modal-title').textContent=isEdit?'编辑用户':'新建用户';
|
||
const permBoxes=Object.keys(PERM_LABELS).map(p=>
|
||
'<label style="margin-right:12px"><input type="checkbox" class="u-perm" value="'+p+'">'+PERM_LABELS[p]+'</label>'
|
||
).join('');
|
||
document.getElementById('modal-body').innerHTML=
|
||
'<div class="form-group"><label>用户名</label><input type="text" class="form-control" id="u-username" '+(isEdit?'disabled':'')+' placeholder="用户名"></div>'+
|
||
'<div class="form-group"><label>密码'+(isEdit?'(留空不修改)':'')+'</label><input type="password" class="form-control" id="u-password" placeholder="密码"></div>'+
|
||
'<div class="form-group form-check"><input type="checkbox" id="u-is_admin" onchange="togglePermBoxes()" checked><label for="u-is_admin">管理员权限(拥有全部权限)</label></div>'+
|
||
'<div class="form-group"><label>业务权限(管理员自动拥有全部,无需勾选)</label>'+
|
||
'<div>'+permBoxes+'</div>'+
|
||
'<div class="help">任务管理=任务/自定义动作/分组;设备控制=停止/释放/清异常/扫描/抓元素;应用管理=APK上传安装;日志查看=日志页</div>'+
|
||
'</div>';
|
||
|
||
document.getElementById('modal-footer').innerHTML=
|
||
'<button class="btn" onclick="closeModal()">取消</button>'+
|
||
'<button class="btn btn-primary" onclick="saveUser('+(uid||'null')+')">'+(isEdit?'保存':'创建')+'</button>';
|
||
|
||
if(isEdit){
|
||
// 预填充(从表格数据获取)
|
||
apiGet('/api/users').then(r=>{
|
||
const u=(r&&r.users||[]).find(x=>x.id===uid);
|
||
if(u){
|
||
document.getElementById('u-username').value=u.username;
|
||
document.getElementById('u-is_admin').checked=u.is_admin;
|
||
document.querySelectorAll('.u-perm').forEach(cb=>{
|
||
cb.checked=(u.perms||[]).includes(cb.value);
|
||
});
|
||
togglePermBoxes();
|
||
}
|
||
});
|
||
}else{
|
||
togglePermBoxes();
|
||
}
|
||
showModal();
|
||
}
|
||
|
||
async function saveUser(uid){
|
||
const username=document.getElementById('u-username').value.trim();
|
||
const password=document.getElementById('u-password').value;
|
||
const is_admin=document.getElementById('u-is_admin').checked;
|
||
const perms=[...document.querySelectorAll('.u-perm:checked')].map(cb=>cb.value);
|
||
|
||
if(uid){
|
||
const data={is_admin:is_admin,perms:perms};
|
||
if(password)data.password=password;
|
||
const r=await apiPut('/api/users/'+uid,data);
|
||
handleResult(r,()=>{closeModal();loadUsers();});
|
||
}else{
|
||
if(!username||!password){showToast('用户名和密码不能为空','error');return;}
|
||
const r=await apiPost('/api/users',{username:username,password:password,is_admin:is_admin,perms:perms});
|
||
handleResult(r,()=>{closeModal();loadUsers();});
|
||
}
|
||
}
|
||
|
||
async function deleteUser(uid){
|
||
if(!confirm('确认删除此用户吗?'))return;
|
||
const r=await apiDelete('/api/users/'+uid);
|
||
handleResult(r,()=>loadUsers());
|
||
}
|
||
|
||
|
||
// ================== 初始化 ==================
|
||
initCsrf(); // 获取 CSRF token(非 GET 请求需携带)
|
||
loadMe(); // 获取当前用户权限,隐藏无权限的功能入口
|
||
showTab('monitor');
|