feat(日志): 任务步骤明细表 + 「日志 → 步骤明细」面板(按设备/任务/时间过滤、按运行归组、导出 CSV)
文本日志只能 grep,"这台设备这次运行为什么失败"翻起来很费劲。新增一张 **结构化**的步骤明细表,把"哪一步、什么类型、哪个选择器、结果、耗时"落库。 - core/models.py:新增 task_step_log(run_id/job/设备/step_path/selector/ result/detail/duration_ms),索引 run_id、created_at、(serial,created_at)、 (job_id,created_at); - core/step_log.py(新):**专用写线程 + 有界队列**批量落库——步骤执行是热路径, 任务线程只 put_nowait(实测 9000 次入队 31ms),队列满丢弃并计数,绝不阻塞; 另有保留期清理(默认 14 天,每日 04:13 + 每次启动); - tasks/generic/task.py:_exec_one 记一条(异常=error / handler 返回 False=miss / 未知类型=unknown / 概率未触发=skip);_exec_steps 维护路径栈得到 "2.1.3" 这样的嵌套位置;**单次运行封顶 2000 条**——forever 循环任务否则会写爆表; - 运行上下文 ctx(run_id/job_id/job_name/device_name)由 TaskManager 生成, 经 create_worker(serial, params, ctx=None) 传入 worker(扩展点向后兼容); - 接口:/api/step_logs(过滤+分页)、/runs(按运行归组)、/filters(下拉选项)、 /download(CSV,带 BOM); - 前端:日志页拆成「文件日志 / 步骤明细」子分栏 + static/admin/steplog.js。 红线:新表自动进备份覆盖清单(SUMMARY_TABLES 由元数据派生),已补 TABLE_LABELS 中文标签,导出实测 14 张表、coverage_missing 为空。 文档:DATA_MODEL §2.8/§1、API §10.2、ARCHITECTURE §1.1/§2.2/§3.1、 DEVELOPMENT §5.2 与红线表、DEPLOY §5.2、TASK_DEV §8.3、README。
This commit is contained in:
@@ -106,7 +106,11 @@ function showTab(name){
|
||||
if(name==='monitor'){loadMonitor();_monitorTimer=setInterval(loadMonitor,5000);}
|
||||
if(name==='tasks'){showSubTab('tasks',_activeSubs.tasks);loadTasks();loadCustomActions();}
|
||||
if(name==='tools'){showSubTab('tools',_activeSubs.tools);loadToolsDevices();loadAdbDevices();loadTailscaleDevices();loadApks();}
|
||||
if(name==='logs'){loadLogs();if(document.getElementById('log-auto').checked)_logTimer=setInterval(loadLogs,3000);}
|
||||
if(name==='logs'){
|
||||
showSubTab('logs', _activeSubs.logs||'files'); // 文件日志 / 步骤明细
|
||||
if((_activeSubs.logs||'files')==='files'
|
||||
&& document.getElementById('log-auto').checked)_logTimer=setInterval(loadLogs,3000);
|
||||
}
|
||||
if(name==='agent'){
|
||||
showSubTab('agent', _activeSubs.agent||'chat'); // 聊天 / AI 建任务
|
||||
if(typeof initAgentChat==='function') initAgentChat();
|
||||
@@ -117,7 +121,8 @@ function showTab(name){
|
||||
|
||||
// ================== 页内子分栏(任务/工具 通用) ==================
|
||||
// 每个带子分栏的 Tab 记住上次选中的子分栏,切走再切回来保持原位
|
||||
let _activeSubs = {tasks: 'plan', tools: 'clipboard', system: 'backup', agent: 'chat'};
|
||||
let _activeSubs = {tasks: 'plan', tools: 'clipboard', system: 'backup', agent: 'chat',
|
||||
logs: 'files'};
|
||||
let _discoveryTimer = null; // 设备自动发现 10s 轮询(仅 devpool 子分栏激活时)
|
||||
|
||||
function showSubTab(tabId, name){
|
||||
@@ -127,6 +132,8 @@ function showSubTab(tabId, name){
|
||||
tab.querySelectorAll('.sub-panel').forEach(p=>p.classList.toggle('active', p.id===tabId+'-sub-'+name));
|
||||
if(tabId==='agent' && name==='taskgen' && typeof initTaskGen==='function') initTaskGen();
|
||||
if(tabId==='system' && name==='notify' && typeof loadNotifyPanel==='function') loadNotifyPanel();
|
||||
if(tabId==='logs' && name==='files' && typeof loadLogs==='function') loadLogs();
|
||||
if(tabId==='logs' && name==='steps' && typeof loadStepLogs==='function') loadStepLogs(true);
|
||||
if(name==='groups' && typeof loadGroups==='function') loadGroups();
|
||||
if(name==='apks' && typeof loadAgentStore==='function') loadAgentStore();
|
||||
if(name==='devpool' && typeof loadDevPool==='function'){
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
// ================== 「日志 → 步骤明细」子分栏 ==================
|
||||
// 数据源:task_step_log 表(结构化),接口 /api/step_logs*(见 web/admin_api.py)。
|
||||
// 与「文件日志」的分工:那边是原始文本,这边能按设备/任务/时间/结果过滤、能导出。
|
||||
|
||||
let _slOffset = 0; // 分页偏移
|
||||
let _slTotal = 0; // 命中总数
|
||||
let _slRunId = ''; // "只看某次运行"时的 run_id(空 = 不限)
|
||||
let _slInited = false; // 下拉选项只拉一次
|
||||
|
||||
function _slVal(id){const el=document.getElementById(id);return el?el.value:'';}
|
||||
|
||||
function _slParams(){
|
||||
const p=new URLSearchParams();
|
||||
const put=(k,v)=>{if(v)p.set(k,v);};
|
||||
put('serial', _slVal('sl-device'));
|
||||
put('job_id', _slVal('sl-job'));
|
||||
put('result', _slVal('sl-result'));
|
||||
put('q', _slVal('sl-q').trim());
|
||||
put('since', _slVal('sl-since'));
|
||||
put('until', _slVal('sl-until'));
|
||||
put('run_id', _slRunId);
|
||||
p.set('limit', _slVal('sl-limit')||200);
|
||||
p.set('offset', _slOffset);
|
||||
return p;
|
||||
}
|
||||
|
||||
async function loadStepLogs(reset){
|
||||
const body=document.getElementById('sl-body');
|
||||
if(!body)return;
|
||||
if(reset)_slOffset=0;
|
||||
const p=_slParams();
|
||||
const r=await apiGet('/api/step_logs?'+p.toString());
|
||||
if(!r||!r.ok){body.innerHTML='<tr><td colspan="9" class="text-muted">读取失败</td></tr>';return;}
|
||||
_slTotal=r.total||0;
|
||||
const rows=r.rows||[];
|
||||
body.innerHTML = rows.length ? rows.map(_slRowHtml).join('')
|
||||
: '<tr><td colspan="9" class="text-muted">(没有匹配的步骤记录——先跑一个任务,'
|
||||
+'或在上面放宽筛选条件)</td></tr>';
|
||||
// 提示栏:分页位置 + 保留期 + 运行期间丢弃计数(排障用)
|
||||
const from=_slTotal?(_slOffset+1):0, to=_slOffset+rows.length;
|
||||
let hint=`命中 ${_slTotal} 条 · 显示 ${from}-${to}`;
|
||||
if(_slRunId)hint=`只看运行 ${_slRunId} · `+hint;
|
||||
const st=r.stats||{};
|
||||
if(st.dropped)hint+=` · 队列溢出丢弃 ${st.dropped} 条`;
|
||||
if(st.failed)hint+=` · 落库失败 ${st.failed} 条`;
|
||||
document.getElementById('sl-hint').textContent=hint;
|
||||
document.getElementById('sl-page').textContent=
|
||||
`第 ${Math.floor(_slOffset/(parseInt(_slVal('sl-limit'),10)||200))+1} 页`;
|
||||
document.getElementById('sl-prev').disabled = _slOffset<=0;
|
||||
document.getElementById('sl-next').disabled = _slOffset+rows.length>=_slTotal;
|
||||
if(!_slInited){_slInited=true;loadStepFilters();}
|
||||
if(reset)loadStepRuns();
|
||||
}
|
||||
|
||||
// 结果 → 颜色。注意不能复用 .log-err/.log-warn/.log-info:那套的作用域是
|
||||
// .log-content(文件日志面板),表格里得用下面这套 res-*
|
||||
const _SL_RESULT={error:'res-error',unknown:'res-error',miss:'res-miss',
|
||||
cap:'res-miss',skip:'res-skip',ok:'res-ok'};
|
||||
|
||||
function _slRowHtml(r){
|
||||
const cls=_SL_RESULT[r.result]||'';
|
||||
const dev=esc(r.device_name||r.serial||'');
|
||||
const dur=r.duration_ms>=1000?(r.duration_ms/1000).toFixed(1)+'s':(r.duration_ms||0)+'ms';
|
||||
return '<tr>'
|
||||
+'<td class="text-muted" style="white-space:nowrap">'+esc(r.created_at||'')+'</td>'
|
||||
+'<td title="'+esc(r.serial||'')+'">'+dev+'</td>'
|
||||
+'<td>'+esc(r.job_name||'')+'</td>'
|
||||
+'<td>'+esc(r.step_path||'')+'</td>'
|
||||
+'<td>'+esc(r.step_label||'')+'</td>'
|
||||
+'<td class="text-muted">'+esc(r.step_type||'')+'</td>'
|
||||
+'<td class="'+cls+'">'+esc(r.result||'')+'</td>'
|
||||
+'<td class="text-muted">'+dur+'</td>'
|
||||
+'<td class="text-muted" title="'+esc(r.detail||'')+'">'+esc(_slTrim(r.detail,120))+'</td>'
|
||||
+'</tr>';
|
||||
}
|
||||
|
||||
function _slTrim(s,n){s=s||'';return s.length>n?s.slice(0,n)+'…':s;}
|
||||
|
||||
function stepLogPage(delta){
|
||||
const size=parseInt(_slVal('sl-limit'),10)||200;
|
||||
_slOffset=Math.max(0,_slOffset+delta*size);
|
||||
loadStepLogs(false);
|
||||
}
|
||||
|
||||
function resetStepFilters(){
|
||||
['sl-q','sl-since','sl-until'].forEach(id=>{const el=document.getElementById(id);if(el)el.value='';});
|
||||
['sl-device','sl-job','sl-result'].forEach(id=>{const el=document.getElementById(id);if(el)el.value='';});
|
||||
_slRunId='';
|
||||
loadStepLogs(true);
|
||||
}
|
||||
|
||||
function downloadStepLogs(){
|
||||
const p=_slParams();
|
||||
p.delete('limit');p.delete('offset');
|
||||
window.location='/api/step_logs/download?'+p.toString();
|
||||
}
|
||||
|
||||
// ---------- 筛选下拉(只列"明细里真的出现过"的设备/任务) ----------
|
||||
async function loadStepFilters(){
|
||||
const r=await apiGet('/api/step_logs/filters');
|
||||
if(!r||!r.ok)return;
|
||||
const dev=document.getElementById('sl-device');
|
||||
const job=document.getElementById('sl-job');
|
||||
const keep=(sel,val)=>{const cur=sel.value;sel.innerHTML=val;sel.value=cur;};
|
||||
keep(dev,'<option value="">全部设备</option>'+(r.devices||[]).map(d=>
|
||||
'<option value="'+esc(d.serial)+'">'+esc(d.device_name||d.serial)+'</option>').join(''));
|
||||
keep(job,'<option value="">全部任务</option>'+(r.jobs||[]).map(j=>
|
||||
'<option value="'+esc(j.job_id)+'">'+esc(j.job_name||j.job_id)+'</option>').join(''));
|
||||
document.getElementById('sl-note').textContent=
|
||||
`明细保留 ${r.keep_days} 天(超期自动清理);单次运行最多记录 `
|
||||
+`${r.max_rows_per_run} 条(无限循环任务不会写爆这张表)。`;
|
||||
}
|
||||
|
||||
// ---------- 最近运行概览(点一行 = 只看那次运行) ----------
|
||||
async function loadStepRuns(){
|
||||
const p=new URLSearchParams();
|
||||
if(_slVal('sl-device'))p.set('serial',_slVal('sl-device'));
|
||||
if(_slVal('sl-job'))p.set('job_id',_slVal('sl-job'));
|
||||
if(_slVal('sl-since'))p.set('since',_slVal('sl-since'));
|
||||
if(_slVal('sl-until'))p.set('until',_slVal('sl-until'));
|
||||
p.set('limit','20');
|
||||
const r=await apiGet('/api/step_logs/runs?'+p.toString());
|
||||
const body=document.getElementById('sl-runs-body');
|
||||
if(!r||!r.ok){body.innerHTML='';return;}
|
||||
const runs=r.runs||[];
|
||||
document.getElementById('sl-runs-summary').textContent=
|
||||
`最近运行概览(${runs.length} 次,点「查看」只看那次)`;
|
||||
body.innerHTML = runs.length ? runs.map(x=>'<tr>'
|
||||
+'<td class="text-muted" style="white-space:nowrap">'+esc(x.started_at||'')+'</td>'
|
||||
+'<td>'+esc(x.device_name||x.serial||'')+'</td>'
|
||||
+'<td>'+esc(x.job_name||'')+'</td>'
|
||||
+'<td>'+x.steps+'</td>'
|
||||
+'<td class="'+(x.failures?'res-error':'text-muted')+'">'+(x.failures||0)+'</td>'
|
||||
+'<td><button class="btn btn-sm" onclick="filterByRun(\''+esc(x.run_id)+'\')">查看</button></td>'
|
||||
+'</tr>').join('')
|
||||
: '<tr><td colspan="6" class="text-muted">(暂无运行记录)</td></tr>';
|
||||
}
|
||||
|
||||
function filterByRun(runId){
|
||||
_slRunId=runId||'';
|
||||
document.getElementById('sl-runs-box').open=false;
|
||||
loadStepLogs(true);
|
||||
}
|
||||
Reference in New Issue
Block a user