监控页的任务卡片原来带「编辑」,但 openTaskModal 依赖 _taskTypes/_groupsList/
_devicesList,这三个列表只在任务 Tab 的 loadTasks() 里加载——从监控页直接点编辑,
下拉框是空的(这就是"编辑有问题")。按需求把编辑/删除从监控页移除(编辑统一去任务 Tab),
每张卡片只留:执行任务 + 停用任务/启用任务。
并给卡片加「覆盖设备」一行:
- 后端 /api/jobs(含 POST/PUT 返回的 job)新增 coverage = {mode, serials, total}:
按 target 定义解析(all=设备池全量 / group=分组∩设备池 / serial=该设备),
不做离线过滤、不写调度日志(该接口每 5s 轮询),与调度用的 resolve_serials 口径差异已写入文档
- 前端用已在手的 /api/status 标注在线状态,渲染成彩色 chip:
在线=蓝、运行中=黄+⏳、离线或不在池=灰划线+✕,完整 serial/型号放 tooltip
- 文档:API.md §5 补 coverage 字段与三种模式口径、ARCHITECTURE §5.1 补卡片说明
自测:coverage 三种模式端到端(含分组过滤池外 serial)全通过;Edge headless 验卡片
(2 个按钮、无编辑/删除、chip 文案/样式/tooltip)全通过;离线分支与任务 Tab 编辑器回归通过
511 lines
23 KiB
JavaScript
511 lines
23 KiB
JavaScript
// 监控页:设备状态表(排序/多选/截图/异常汇总/任务运行概况)
|
||
// ================== 设备列表排序与多选 ==================
|
||
let _selectedSerials=new Set(); // 已勾选的 serial 集合
|
||
let _locatingSerials=new Set(); // 定位中的设备(按钮显示"结束定位")
|
||
|
||
// 排序比较:从设备对象取排序值(统一排序用,见 setListPager/sortValue)
|
||
function _sortValue(dev,key){
|
||
let v;
|
||
if(key==='progress'){
|
||
const p=dev.progress||{};
|
||
v=(p.done||0)+(((p.total||0)>0?(p.done||0)/(p.total):0)*10000);
|
||
}else if(key==='worker_status'){
|
||
// 按状态优先级排序:running>connecting>idle>done>error>released
|
||
const order={running:0,connecting:1,idle:2,done:3,released:4,error:5,failed:6};
|
||
v=order[dev.worker_status]||99;
|
||
}else{
|
||
v=dev[key]||'';
|
||
}
|
||
return v;
|
||
}
|
||
|
||
// 全选/取消全选
|
||
function toggleAllDevices(checked){
|
||
if(checked){
|
||
_statusData.devices.forEach(d=>_selectedSerials.add(d.serial));
|
||
}else{
|
||
_selectedSerials.clear();
|
||
}
|
||
renderDevices();
|
||
updateSelCount();
|
||
}
|
||
|
||
// 单个勾选
|
||
function toggleDevice(serial,checked){
|
||
if(checked){_selectedSerials.add(serial);}else{_selectedSerials.delete(serial);}
|
||
updateSelCount();
|
||
// 同步全选框状态
|
||
const all=document.getElementById('cb-all');
|
||
if(all){
|
||
const total=_statusData?(_statusData.devices||[]).length:0;
|
||
all.checked=total>0&&_selectedSerials.size===total;
|
||
}
|
||
}
|
||
|
||
function updateSelCount(){
|
||
const n=_selectedSerials.size;
|
||
const el=document.getElementById('sel-count');
|
||
if(el)el.textContent=n>0?('已选 '+n+' 台'):'未选中';
|
||
const btn=document.getElementById('btn-stop-selected');
|
||
if(btn)btn.disabled=n===0;
|
||
}
|
||
|
||
// 批量停止选中的运行中设备
|
||
async function stopSelectedDevices(){
|
||
const serials=Array.from(_selectedSerials);
|
||
if(serials.length===0){showToast('请先勾选设备','error');return;}
|
||
// 只停止运行中/连接中的
|
||
const toStop=serials.filter(s=>{
|
||
const d=(_statusData.devices||[]).find(x=>x.serial===s);
|
||
return d&&(d.worker_status==='running'||d.worker_status==='connecting');
|
||
});
|
||
if(toStop.length===0){showToast('选中的设备没有运行中的任务','error');return;}
|
||
if(!confirm('确定停止选中的 '+toStop.length+' 台设备的任务吗?'))return;
|
||
let ok=0,fail=0;
|
||
for(const s of toStop){
|
||
const r=await apiPost('/api/stop_device',{serial:s});
|
||
if(r&&r.ok){ok++;}else{fail++;}
|
||
}
|
||
showToast('已停止 '+ok+' 台'+(fail>0?(',失败 '+fail+' 台'):''),ok>0?'success':'error');
|
||
setTimeout(loadMonitor,500);
|
||
}
|
||
|
||
// 定位设备:点亮屏幕 + 打开大字定位页(显示设备 IP),便于在机架上找到目标
|
||
// 注意:打开定位页会切换设备前台,任务运行中慎用
|
||
async function locateDevice(serial){
|
||
if(!confirm('点亮 '+serial+' 屏幕并打开大字定位页?\n\n(会切换设备前台显示,任务运行中慎用)'))return;
|
||
const r=await apiPost('/api/device/locate',{serial:serial,show:true});
|
||
if(!r)return;
|
||
if(r.ok){
|
||
_locatingSerials.add(serial);
|
||
showToast(r.msg||'已定位','success');
|
||
renderDevices();
|
||
}else showToast(r.error||'定位失败','error');
|
||
}
|
||
|
||
// 结束定位:关闭设备上的定位页(浏览器),按钮恢复为"定位"
|
||
async function stopLocate(serial){
|
||
const r=await apiPost('/api/device/locate/stop',{serial:serial});
|
||
if(!r)return;
|
||
_locatingSerials.delete(serial);
|
||
renderDevices();
|
||
if(r.ok)showToast(r.msg||'已结束定位','success');
|
||
else showToast(r.error||'结束定位失败','error');
|
||
}
|
||
|
||
// 批量亮屏/息屏所有在线设备(息屏会中断运行中任务,前端已确认)
|
||
// 亮屏/息屏:优先作用于勾选设备;未勾选则作用于全部在线设备
|
||
async function screenAllDevices(mode){
|
||
const label=(mode==='on'?'亮屏':'息屏');
|
||
const selected=Array.from(_selectedSerials);
|
||
const target=selected.length?('选中设备 '+selected.length+' 台'):'全部在线设备';
|
||
if(!confirm(label+':将作用于'+target+'。\n\n'+(mode==='off'?'息屏会中断运行中的任务(u2 无法操作),':'')+'确认继续吗?'))return;
|
||
const r=await apiPost('/api/device/screen_all',{mode:mode,serials:selected.length?selected:undefined});
|
||
if(!r)return;
|
||
if(r.ok)showToast((mode==='off'?'已息屏':'已亮屏')+' '+r.success+'/'+r.total+' 台设备','success');
|
||
else showToast(r.error||'操作失败','error');
|
||
}
|
||
|
||
// 清除单台设备的异常状态(error/failed → 未启动)
|
||
async function clearDeviceError(serial){
|
||
if(!confirm('确认清除设备 '+serial+' 的异常状态?'))return;
|
||
const r=await apiPost('/api/device/clear_error',{serial:serial});
|
||
handleResult(r,()=>loadMonitor());
|
||
}
|
||
// 一键清除所有异常/失败设备的错误状态(运行中的任务不受影响)
|
||
async function clearAllErrors(){
|
||
if(!confirm('确认清除所有设备的异常状态?(运行中/等待重试的设备会跳过)'))return;
|
||
const r=await apiPost('/api/device/clear_all_errors');
|
||
handleResult(r,()=>loadMonitor());
|
||
}
|
||
|
||
// ================== 设备画面查看 ==================
|
||
let _shotTimer=null; // 自动刷新定时器
|
||
let _shotSerial=''; // 当前查看的 serial
|
||
let _shotLoading=false; // 防止并发请求
|
||
|
||
// 打开截图弹窗
|
||
function openScreenshot(serial){
|
||
_shotSerial=serial;
|
||
_shotLoading=false;
|
||
// 渲染弹窗
|
||
document.getElementById('modal-title').textContent='设备画面 - '+serial;
|
||
document.getElementById('modal-body').innerHTML=
|
||
'<div id="shot-content" class="shot-loading">正在获取画面...</div>'+
|
||
'<div class="shot-toolbar">'+
|
||
'<button class="btn btn-primary btn-sm" onclick="refreshShot()">刷新</button>'+
|
||
'<label class="auto-label"><input type="checkbox" id="shot-auto" checked onchange="toggleShotAuto()"> 自动刷新(3秒)</label>'+
|
||
'<span class="shot-info" id="shot-time"></span>'+
|
||
'</div>';
|
||
document.getElementById('modal-footer').innerHTML='<button class="btn" onclick="closeShotModal()">关闭</button>';
|
||
// 切换为截图弹窗样式(自适应大小)
|
||
document.getElementById('modal-box').classList.add('shot-modal');
|
||
showModal();
|
||
// 立即取一次
|
||
refreshShot();
|
||
// 开启自动刷新
|
||
startShotTimer();
|
||
}
|
||
|
||
// 关闭截图弹窗(停掉定时器 + 关闭 modal + 还原样式)
|
||
function closeShotModal(){
|
||
stopShotTimer();
|
||
_shotSerial='';
|
||
document.getElementById('modal-box').classList.remove('shot-modal');
|
||
closeModal();
|
||
}
|
||
|
||
function toggleShotAuto(){
|
||
const cb=document.getElementById('shot-auto');
|
||
if(cb&&cb.checked){startShotTimer();}else{stopShotTimer();}
|
||
}
|
||
|
||
function startShotTimer(){
|
||
stopShotTimer();
|
||
_shotTimer=setInterval(refreshShot,3000);
|
||
}
|
||
function stopShotTimer(){
|
||
// 用 typeof 容错:变量未声明时不报错(避免 closeModal 调用时抛异常导致弹窗关不掉)
|
||
if(typeof _shotTimer!=='undefined'&&_shotTimer){clearInterval(_shotTimer);_shotTimer=null;}
|
||
}
|
||
|
||
// 取一次截图
|
||
async function refreshShot(){
|
||
if(!_shotSerial)return;
|
||
if(_shotLoading)return; // 上一次还没回来,跳过这次
|
||
_shotLoading=true;
|
||
const el=document.getElementById('shot-content');
|
||
if(!el){_shotLoading=false;return;}
|
||
// 首次或出错后显示 loading
|
||
if(!el.querySelector('img')){
|
||
el.className='shot-loading';
|
||
el.textContent='正在获取画面...';
|
||
}
|
||
const url='/api/device/screenshot?serial='+encodeURIComponent(_shotSerial)+'&t='+Date.now();
|
||
try{
|
||
const r=await fetch(url);
|
||
if(r.status===401){window.location='/login';return;}
|
||
if(!r.ok){
|
||
const j=await r.json().catch(()=>({}));
|
||
throw new Error(j.error||('HTTP '+r.status));
|
||
}
|
||
const blob=await r.blob();
|
||
const objUrl=URL.createObjectURL(blob);
|
||
el.className='';
|
||
el.innerHTML='<img class="shot-img" src="'+objUrl+'" alt="设备画面">';
|
||
// 更新时间
|
||
const tEl=document.getElementById('shot-time');
|
||
if(tEl)tEl.textContent='更新于 '+new Date().toLocaleTimeString();
|
||
// 释放上一个 objUrl(避免内存泄漏)
|
||
if(el._lastObjUrl)URL.revokeObjectURL(el._lastObjUrl);
|
||
el._lastObjUrl=objUrl;
|
||
}catch(e){
|
||
el.className='shot-err';
|
||
el.textContent='截图失败:'+e.message;
|
||
}finally{
|
||
_shotLoading=false;
|
||
}
|
||
}
|
||
// ================== Tab 1: 监控 ==================
|
||
let _statusData=null;
|
||
let _jobsData=null;
|
||
|
||
async function loadMonitor(){
|
||
try{
|
||
const r=await apiGet('/api/status');if(!r||!r.ok)return;
|
||
_statusData=r;
|
||
document.getElementById('srvTime').textContent=new Date(r.server_time*1000).toLocaleTimeString();
|
||
// 前台 App 扫描状态
|
||
const fgStatus=document.getElementById('fg-status');
|
||
const scanBtn=document.getElementById('btn-scan-fg');
|
||
if(r.fg_scanning){
|
||
fgStatus.innerHTML='<span style="color:#f59e0b">扫描中...</span>';
|
||
scanBtn.disabled=true;scanBtn.textContent='扫描中...';
|
||
}else{
|
||
scanBtn.disabled=false;scanBtn.textContent='扫描前台App';
|
||
if(r.fg_last_scan){
|
||
fgStatus.innerHTML='上次扫描: '+new Date(r.fg_last_scan*1000).toLocaleTimeString();
|
||
}else{
|
||
fgStatus.innerHTML='<span style="color:#94a3b8">未扫描</span>';
|
||
}
|
||
}
|
||
const devices=r.devices||[];
|
||
const running=devices.filter(x=>x.worker_status==='running').length;
|
||
const idle=devices.filter(x=>x.worker_status==='idle').length;
|
||
const error=devices.filter(x=>['error','failed'].includes(x.worker_status)).length;
|
||
const total=devices.length;
|
||
const online=devices.filter(x=>x.present).length;
|
||
const cards=[
|
||
{n:total,l:'设备总数',c:'#3b82f6'},
|
||
{n:online,l:'在线',c:'#10b981'},
|
||
{n:running,l:'运行中',c:'#10b981'},
|
||
{n:idle,l:'空闲',c:'#94a3b8'},
|
||
{n:error,l:'异常',c:'#ef4444'},
|
||
];
|
||
document.getElementById('stats').innerHTML=cards.map(c=>
|
||
'<div class="stat-card" style="--accent:'+c.c+'"><div class="stat-num" style="color:'+c.c+'">'+c.n+'</div><div class="stat-lbl">'+c.l+'</div></div>'
|
||
).join('');
|
||
// 渲染设备表格(含排序+多选)
|
||
renderDevices();
|
||
// 同步全选框状态
|
||
const all=document.getElementById('cb-all');
|
||
if(all){
|
||
all.checked=devices.length>0&&_selectedSerials.size===devices.length;
|
||
}
|
||
updateSelCount();
|
||
// 加载任务卡片
|
||
loadJobsForMonitor();
|
||
// 加载异常汇总
|
||
loadSummary();
|
||
}catch(e){console.error(e);}
|
||
}
|
||
|
||
// ===== 异常设备汇总 =====
|
||
async function loadSummary(){
|
||
try{
|
||
const r=await apiGet('/api/summary');
|
||
if(!r||!r.ok)return;
|
||
const counts=r.counts||{};
|
||
const errors=r.errors||[];
|
||
const errCount=(counts.error||0)+(counts.failed||0);
|
||
const badge=document.getElementById('summary-badge');
|
||
const panel=document.getElementById('summary-panel');
|
||
const empty=document.getElementById('summary-empty');
|
||
if(badge)badge.textContent='· 异常 '+errCount+' · 运行 '+counts.running+' · 完成 '+counts.done;
|
||
if(!panel||!empty)return;
|
||
if(!errors.length){
|
||
panel.style.display='none';
|
||
empty.style.display='block';
|
||
return;
|
||
}
|
||
panel.style.display='block';
|
||
empty.style.display='none';
|
||
const tb=document.getElementById('tb-summary');
|
||
tb.innerHTML=errors.map(e=>{
|
||
const st=e.status==='failed'?'<span class="label label-danger">失败</span>':'<span class="label label-warning">异常</span>';
|
||
const up=e.updated?new Date(e.updated*1000).toLocaleTimeString():'';
|
||
return '<tr>'+
|
||
'<td><strong>'+esc(e.serial)+'</strong>'+(e.model?' <span style="font-size:11px;color:#94a3b8">'+esc(e.model)+'</span>':'')+'</td>'+
|
||
'<td>'+st+'</td>'+
|
||
'<td>'+esc(e.task||'-')+'</td>'+
|
||
'<td>'+(e.attempt>0?e.attempt+'次':'<span class="text-muted">-</span>')+'</td>'+
|
||
'<td class="err-text" title="'+esc(e.last_error||'')+'">'+esc(e.last_error||'-')+'</td>'+
|
||
'</tr>';
|
||
}).join('');
|
||
}catch(e){console.error(e);}
|
||
}
|
||
|
||
// 渲染设备表格(统一排序+搜索+分页,复选状态保持)
|
||
let _devicesSig='';
|
||
function renderDevices(){
|
||
if(!_statusData||!_statusData.devices){return;}
|
||
// 5秒自动刷新时若设备数据/分页/搜索/排序/定位状态都没变化,跳过整表重渲染(避免DOM抖动)
|
||
const _st=_lp('devices');
|
||
const _sig=JSON.stringify({d:_statusData.devices,p:_st.page,k:_st.kw,s:_st.sort,
|
||
l:[..._locatingSerials].sort().join(','),
|
||
sel:[..._selectedSerials].sort().join(',')});
|
||
if(_sig===_devicesSig)return;
|
||
_devicesSig=_sig;
|
||
// 排序由 applyListPager 统一处理(sortValue + _lpSort)
|
||
// 「只看在线」勾选时过滤离线设备(默认勾选)
|
||
const onlyOnline=document.getElementById('monitor-filter-online')
|
||
? document.getElementById('monitor-filter-online').checked : true;
|
||
setListPager('devices',{
|
||
data:()=>{
|
||
const all=_statusData.devices||[];
|
||
return onlyOnline? all.filter(d=>d.present) : all;
|
||
},
|
||
filter:(dev,kw)=>((dev.serial||'').toLowerCase().includes(kw)||(dev.model||'').toLowerCase().includes(kw)||(dev.foreground_app||'').toLowerCase().includes(kw)||(dev.task_job||'').toLowerCase().includes(kw)||(dev.current_action||'').toLowerCase().includes(kw)),
|
||
sortValue:_sortValue,
|
||
render:renderDeviceRow,
|
||
tbody:'tb-devices',pager:'pager-devices',pageSize:10,
|
||
empty:'<tr><td colspan="11" class="empty">暂无设备</td></tr>'
|
||
});
|
||
applyListPager('devices');
|
||
initListSearch('devices','search-devices');
|
||
}
|
||
function renderDeviceRow(dev){
|
||
const offline=dev.present?'':' <span class="label label-danger">离线</span>';
|
||
const st=STATUS_MAP[dev.worker_status]||{t:dev.worker_status,c:'default'};
|
||
const wb='<span class="label label-'+st.c+'">'+st.t+'</span>';
|
||
const fgApp=dev.foreground_app;
|
||
const fg=fgApp?(
|
||
fgApp==='(他人占用)'?'<span class="label label-warning">他人占用</span>':
|
||
fgApp==='(不可达)'?'<span class="label label-default">不可达</span>':
|
||
'<span class="label label-primary">'+esc(APP_NAMES[fgApp]||fgApp)+'</span>'
|
||
):'<span class="text-muted">-</span>';
|
||
const prog=fmtProgress(dev.progress, dev.worker_status);
|
||
const tname=dev.task_job?'<span class="label label-info">'+esc(dev.task_job)+'</span>':'<span class="text-muted">-</span>';
|
||
const retry=dev.attempt>0?' <span class="text-muted">'+dev.attempt+'次</span>':'';
|
||
// 预计结束时间(设置了运行时长上限时显示)
|
||
const endInfo=(dev.worker_status==='running'&&dev.end_time)?'<div style="font-size:11px;color:#94a3b8">预计结束 '+new Date(dev.end_time*1000).toLocaleTimeString()+'</div>':'';
|
||
// 选择器健康警告(某步骤连续未找到元素,可能 App 改版选择器失效)
|
||
const warnInfo=dev.last_warning?'<div style="font-size:11px;color:#f59e0b" title="'+esc(dev.last_warning)+'">⚠ '+esc(dev.last_warning)+'</div>':'';
|
||
// 设备操作按钮需要"设备控制"权限(无权限只保留截图查看)
|
||
const canDev=_can('devices');
|
||
const stopBtn=canDev&&(dev.worker_status==='running'||dev.worker_status==='connecting')?'<button class="btn btn-danger btn-xs" onclick="apiAction(\'/api/stop_device\',{serial:\''+esc(dev.serial)+'\'})">停止</button>':'';
|
||
// 截图按钮(任何状态都可看,离线时会报错)
|
||
const shotBtn='<button class="btn btn-xs" onclick="openScreenshot(\''+esc(dev.serial)+'\')" title="查看设备画面(不影响任务)">截图</button>';
|
||
// 定位按钮:定位中显示"结束定位"(关闭定位页),否则显示"定位"(点亮+大字页)
|
||
const locating=_locatingSerials.has(dev.serial);
|
||
const locateBtn=canDev?(locating
|
||
?'<button class="btn btn-xs btn-danger" onclick="stopLocate(\''+esc(dev.serial)+'\')" title="关闭定位页(退出浏览器)">结束定位</button>'
|
||
:'<button class="btn btn-xs" onclick="locateDevice(\''+esc(dev.serial)+'\')" title="点亮屏幕并显示设备IP大字页(会切换前台,任务运行中慎用)">定位</button>'):'';
|
||
// 异常/失败设备提供"清除异常"(把状态恢复为未启动)
|
||
const clearBtn=canDev&&(dev.worker_status==='error'||dev.worker_status==='failed')
|
||
?'<button class="btn btn-xs" onclick="clearDeviceError(\''+esc(dev.serial)+'\')" title="清除该设备的异常状态,恢复为未启动">清除异常</button>':'';
|
||
const btns='<div style="display:flex;gap:4px;flex-wrap:wrap">'+shotBtn+locateBtn+clearBtn+stopBtn+'</div>';
|
||
const devName=dev.device_name||dev.model||'';
|
||
const devLabel=(devName?'<strong>'+esc(dev.serial)+'</strong><br><span style="font-size:11px;color:#94a3b8">'+esc(devName)+'</span>':'<strong>'+esc(dev.serial)+'</strong>')+offline;
|
||
// 复选框列
|
||
const checked=_selectedSerials.has(dev.serial)?'checked':'';
|
||
const rowCls=_selectedSerials.has(dev.serial)?'row-checked':'';
|
||
const cb='<input type="checkbox" '+checked+' onclick="toggleDevice(\''+esc(dev.serial)+'\',this.checked)">';
|
||
return '<tr class="'+rowCls+'"><td class="col-cb">'+cb+'</td><td>'+devLabel+'</td><td>'+esc(dev.model)+'</td><td>'+wb+'</td><td>'+fg+'</td><td>'+prog+'</td><td>'+tname+retry+endInfo+'</td><td class="text-muted">'+esc(dev.current_action||'-')+warnInfo+'</td><td class="err-text" title="'+esc(dev.last_error||'')+'">'+esc(dev.last_error||'-')+'</td><td>'+btns+'</td></tr>';
|
||
}
|
||
|
||
async function loadJobsForMonitor(){
|
||
try{
|
||
const r=await apiGet('/api/jobs');if(!r||!r.ok)return;
|
||
_jobsData=r.jobs||[];
|
||
renderJobCards();
|
||
}catch(e){console.error(e);}
|
||
}
|
||
|
||
// 通用进度展示:解析 progress = {done, total, unit, action_counts, elapsed}
|
||
// 任意 app 都能用:显示进度条 + "done/total unit" + 运行时长 + 操作计数徽章
|
||
// 下次运行时间格式化:"2026-08-12 09:00:00" → "08-12 09:00"
|
||
function fmtNextRun(iso){
|
||
if(!iso)return '';
|
||
const s=String(iso).replace('T',' ');
|
||
return s.length>=16?s.slice(5,16):s;
|
||
}
|
||
|
||
function fmtProgress(progress, workerStatus){
|
||
if(!progress||!Object.keys(progress).length){
|
||
// 空闲/未运行 → 显示"-"
|
||
return '<span class="text-muted">-</span>';
|
||
}
|
||
const done=progress.done||0;
|
||
const total=progress.total||0;
|
||
const unit=progress.unit||'';
|
||
const elapsed=progress.elapsed||0;
|
||
const counts=progress.action_counts||{};
|
||
// 进度条(百分比)
|
||
let barHtml='';
|
||
if(total>0){
|
||
const pct=Math.min(100,Math.round(done*100/total));
|
||
barHtml='<div class="progress"><div class="progress-bar" style="width:'+pct+'%"></div></div> ';
|
||
}
|
||
// 计数文本:"5/80 视频" 或 "5 视频"(无 total 时)
|
||
let countHtml='';
|
||
if(total>0){
|
||
countHtml='<span style="font-size:12px;color:#64748b">'+done+'/'+total+(unit?' '+esc(unit):'')+'</span>';
|
||
}else if(done>0){
|
||
countHtml='<span style="font-size:12px;color:#64748b">'+done+(unit?' '+esc(unit):'')+'</span>';
|
||
}
|
||
// 运行时长(elapsed>0 才显示)
|
||
let timeHtml='';
|
||
if(elapsed>0){
|
||
const m=Math.floor(elapsed/60), s=elapsed%60;
|
||
timeHtml=' <span style="font-size:11px;color:#94a3b8">'+m+'m'+s+'s</span>';
|
||
}
|
||
// 操作计数徽章(按数量降序,最多显示6个,超出的折叠为 +N)
|
||
const countEntries=Object.entries(counts).filter(([_,v])=>v>0).sort((a,b)=>b[1]-a[1]);
|
||
const MAX_COUNTS=6;
|
||
const shown=countEntries.slice(0,MAX_COUNTS);
|
||
const more=countEntries.length-MAX_COUNTS;
|
||
let countsHtml='';
|
||
if(shown.length){
|
||
countsHtml='<div style="margin-top:2px">'+shown.map(([k,v])=>
|
||
'<span class="label label-info" style="margin-left:4px;font-size:11px">'+(ACTION_LABELS[k]||esc(k))+' '+v+'</span>'
|
||
).join('')+(more>0?' <span style="font-size:11px;color:#94a3b8">+'+more+'</span>':'')+'</div>';
|
||
}
|
||
// 空闲/完成但无进度数据 → 灰显
|
||
if(done===0&&total===0&&elapsed===0&&!countParts.length){
|
||
return '<span class="text-muted">-</span>';
|
||
}
|
||
return '<div>'+barHtml+countHtml+timeHtml+'</div>'+countsHtml;
|
||
}
|
||
|
||
function fmtParams(params){
|
||
if(!params||!Object.keys(params).length)return '';
|
||
const parts=[];
|
||
for(const [k,v] of Object.entries(params)){
|
||
if(k==='actions'){
|
||
if(v&&Object.keys(v).length){
|
||
const on=Object.entries(v).filter(([_,c])=>c.enabled).map(([a])=>ACTION_LABELS[a]||a);
|
||
if(on.length)parts.push('<span class="kv">操作:'+on.join('/')+'</span>');
|
||
}
|
||
continue;
|
||
}
|
||
const val=typeof v==='boolean'?(v?'开':'关'):(Array.isArray(v)?v.length+'条':v);
|
||
parts.push('<span class="kv">'+k+'='+val+'</span>');
|
||
}
|
||
return parts.join('');
|
||
}
|
||
|
||
// 任务覆盖的设备:后端给 serial 清单(coverage),这里用实时状态标注在线/运行中
|
||
function _jobCoverageHtml(j){
|
||
const cov=j.coverage||{}, serials=cov.serials||[];
|
||
const st={};
|
||
if(_statusData)for(const d of (_statusData.devices||[]))st[d.serial]=d;
|
||
if(!serials.length){
|
||
return '<div class="job-cov"><span class="kv">覆盖设备 0 台</span>'
|
||
+'<span class="cov-note">该目标下暂无可调度设备(检查设备池/分组)</span></div>';
|
||
}
|
||
let onlineN=0,busyN=0;
|
||
const chips=serials.map(s=>{
|
||
const d=st[s]||{};
|
||
const online=!!d.present;
|
||
const busy=(d.worker_status==='running'||d.worker_status==='connecting');
|
||
if(online)onlineN++;
|
||
if(busy)busyN++;
|
||
const cls=busy?'running':(online?'online':'offline');
|
||
const title=s+(d.model?' · '+d.model:'')
|
||
+(busy?' · 任务中:'+(d.task_job||''):'')
|
||
+(online?'':' · 离线或不在设备池');
|
||
return '<span class="cov-chip '+cls+'" title="'+esc(title)+'">'
|
||
+esc(s.replace(/:5555$/,''))+(busy?' ⏳':'')+(online?'':' ✕')+'</span>';
|
||
}).join('');
|
||
return '<div class="job-cov"><span class="kv">覆盖 '+serials.length+' 台 · 在线 '+onlineN
|
||
+(busyN?' · 运行中 '+busyN:'')+'</span>'
|
||
+'<div class="cov-chips">'+chips+'</div></div>';
|
||
}
|
||
|
||
function renderJobCards(){
|
||
const jobs=_jobsData||[];
|
||
const running={};
|
||
if(_statusData)for(const dev of _statusData.devices){
|
||
if(dev.task_job)running[dev.task_job]=(running[dev.task_job]||0)+1;
|
||
}
|
||
document.getElementById('jobs-grid').innerHTML=jobs.length?jobs.map(j=>{
|
||
const runCount=running[j.name]||0;
|
||
const isRunning=runCount>0;
|
||
const tgt=TARGET_MAP[j.target.mode]||j.target.mode;
|
||
const tgtDetail=j.target.mode==='group'?(j.target.group_name||''):(j.target.mode==='serial'?(j.target.serial||''):'');
|
||
const sched=j.schedule.mode==='cron_stop'?('启停 '+j.schedule.cron+'→'+j.schedule.stop_cron):
|
||
j.schedule.mode==='cron'?('定时 '+j.schedule.cron):'手动';
|
||
const retry=j.retry.max_attempts>1?(j.retry.max_attempts+'次/间隔'+j.retry.delay+'s'):'不重试';
|
||
const en=j.enabled?'<span class="label label-success">启用</span>':'<span class="label label-default">停用</span>';
|
||
const runBadge=isRunning?'<span class="label label-success pulse">运行中 '+runCount+'台</span>':'<span class="label label-default">空闲</span>';
|
||
return '<div class="job-card'+(isRunning?' running':'')+'">'+
|
||
'<div class="job-head"><div><div class="job-name">'+esc(j.name)+'</div>'+
|
||
'<div class="job-type">'+esc(j.task_type)+'</div></div>'+
|
||
'<div>'+en+' '+runBadge+'</div></div>'+
|
||
'<div class="job-body">'+
|
||
'<div><span class="kv">目标:'+esc(tgt)+(tgtDetail?'('+esc(tgtDetail)+')':'')+'</span>'+
|
||
'<span class="kv">调度:'+esc(sched)+'</span>'+
|
||
'<span class="kv">重试:'+esc(retry)+'</span></div>'+
|
||
'<div style="margin-top:4px">'+fmtParams(j.params)+'</div>'+
|
||
_jobCoverageHtml(j)+
|
||
'</div>'+
|
||
'<div class="job-actions">'+
|
||
'<button class="btn btn-primary btn-xs" onclick="apiAction(\'/api/jobs/'+j.id+'/run\')">执行任务</button>'+
|
||
'<button class="btn btn-xs" onclick="apiAction(\'/api/jobs/'+j.id+'/toggle\',{enabled:'+(!j.enabled)+'})">'+(j.enabled?'停用任务':'启用任务')+'</button>'+
|
||
'</div>'+
|
||
'</div>';
|
||
}).join(''):'<div class="empty">暂无任务,到"任务"Tab 新建</div>';
|
||
}
|
||
|