需求:大屏和监控页都要能看到每台设备的电量,并且"低于多少电量就发通知"。
数据从哪来:`adb -s <serial> shell dumpsys battery`(**只读**,实测 0.2~0.35s/台,
11 台并发一轮 0.44s)。设备只要在 `adb devices` 里就说明 transport 现成,
**不需要 connect**——所以连空闲设备也能查。这是本模块敢每轮扫全量的前提,
也是这条只读路径与"空闲设备不主动 connect"红线(DEVELOPMENT.md §2 #3)的边界,
已写进文档免得后人误加 connect。
- core/device_battery.py(新):后台 daemon 线程 `device-battery`(照 device_discovery
的 init_app/shutdown 模式),默认 60s 一轮,采「设备池 ∩ 在线」(与 list_ready 同口径,
adb 可见但不归平台管的设备不上屏也不告警)。
· 结果**只放内存**(不落库 → 不建表、不动备份覆盖清单),离线设备保留最后读数;
· 读失败不清缓存:list_devices 失败时若照清会把整份缓存抹掉、大屏全体变「—」;
· 配置存 `app_meta.device_battery`(照 step_defaults:出厂值 + 白名单 + 范围钳制 +
只落与出厂值不同的字段),改阈值不用重启(线程每轮重读配置)。
- 告警:事件 `device.battery.low` / `device.battery.recovered`(默认关,去通知页勾订阅)。
**按档位做状态沿**(0 正常 / 1 低 / 2 严重):掉档才报、严重再报一次、恢复报一次;
迟滞 +5% 避免在阈值上下反复告警;同档位每 6h 重复提醒一次。
"充电中"看的是 `status:` 行(2/5),**不是"插着电"**——所以"插着却没充电"
(劣质线/温控/满电停充 status:4)仍会告警,那正是最该知道的情况(实测 fleet 里
就有一台 8% 插着 AC 但 `Max charging current: 0`)。
- GET /api/status 每台设备多一个 `battery`:`{level,charging,at,tier}`。
**tier 由后端算好**,前端只按它上色——阈值只在「工具 → 设备发现 → 电量监控」一处定义,
免得 JS 里再判一遍、两边不一致。
- 前端:大屏卡片型号行右侧加电量徽标(绿/黄/红 + ⚡ 充电中,离线置灰),
`cardHtml`/`renderGrid` 增量更新/脏检查白名单三处都改了(漏一处就不刷新);
监控页设备表新增可排序的「电量」列(空表 colspan 11 顺带对齐了)。
- 接口:GET/POST /api/devices/battery[/settings] + POST /scan(需设备权限,
与 /api/devices/discovery/* 同权限档)。
自测:单元 30 项(解析真机输出/档位/状态机/钳制,含 scale=255、无 status 行、
垃圾输出等边界)、集成 27 项(临时库自建设备池:缓存/通知/离线保留/删设备清理/
配置往返/阈值改动即时生效)、真实 11 台设备一轮 0.44s 全读到、API 端到端
(登录/权限/钳制/越界/REST 往返/页面 DOM)、两份前端各自语法检查 + 渲染用例。
537 lines
25 KiB
JavaScript
537 lines
25 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 if(key==='battery'){
|
||
// 没采到电量的排最后(用 -1 而不是 0,0% 是真实电量)
|
||
v=dev.battery?dev.battery.level:-1;
|
||
}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);
|
||
}
|
||
|
||
// 打开设备端 Agent:让设备上的 Agent 弹出「身份大字页」(名称/IP/平台地址),
|
||
// 人站在机架前一眼看出这台是平台上的哪一台。
|
||
// 替代原来的"推 HTML 让设备浏览器打开定位页"——那条路要设备装浏览器、页面还依赖
|
||
// 设备能访问平台,且在 MIUI 上本来就不稳。
|
||
async function showAgentInfo(serial){
|
||
const r=await apiPost('/api/agent/show-info',{serial:serial});
|
||
if(!r)return;
|
||
if(r.ok){
|
||
_locatingSerials.add(serial);
|
||
showToast(r.msg||'已让设备显示身份信息','success');
|
||
renderDevices();
|
||
}else showToast(r.error||'打开失败','error');
|
||
}
|
||
|
||
// 关闭设备端 Agent 的身份页
|
||
async function hideAgentInfo(serial){
|
||
const r=await apiPost('/api/agent/show-info',{serial:serial,close:true});
|
||
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');
|
||
}
|
||
// 电量单元格:告警档位(tier 0 正常/1 低/2 严重)由后端算好,前端只上色——
|
||
// 阈值只在「工具 → 设备发现 → 电量监控」一处定义,两边不重复判
|
||
function _battCell(dev){
|
||
const b=dev.battery;
|
||
if(!b)return '<span class="text-muted">-</span>';
|
||
const t=b.tier||0;
|
||
const cls=t===2?'label-danger':(t===1?'label-warning':'label-success');
|
||
const mins=b.at?Math.floor((Date.now()/1000-b.at)/60):-1;
|
||
const ageTxt=(mins<0?'':(mins<1?'刚刚':mins+' 分钟前'));
|
||
const chg=b.charging?' ⚡':'';
|
||
const tip=(t===2?'电量严重不足':(t===1?'电量低':'电量正常'))
|
||
+(b.charging?'(充电中)':'')+(ageTxt?'('+ageTxt+'采集)':'')
|
||
+(dev.present?'':'(设备已离线,显示最后一次读数)');
|
||
const stale=dev.present?'':'<div style="font-size:11px;color:#94a3b8">'+ageTxt+'</div>';
|
||
return '<span class="label '+cls+'" title="'+tip+'">'+b.level+'%'+chg+'</span>'+stale;
|
||
}
|
||
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>';
|
||
// 设备端 Agent:开着身份页时显示"关闭",否则"打开设备端 Agent"
|
||
const locating=_locatingSerials.has(dev.serial);
|
||
const locateBtn=canDev?(locating
|
||
?'<button class="btn btn-xs btn-danger" onclick="hideAgentInfo(\''+esc(dev.serial)+'\')" title="让设备上的 Agent 关掉身份大字页">关闭 Agent</button>'
|
||
:'<button class="btn btn-xs" onclick="showAgentInfo(\''+esc(dev.serial)+'\')" title="让设备上的 Agent 显示身份大字页(名称/IP/平台地址)——人在机架前对号用(会切换设备前台)">打开设备端 Agent</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 nm=(dev.device_name||'').trim();
|
||
const devLabel=(nm
|
||
? '<strong>'+esc(nm)+'</strong><br><span style="font-size:11px;color:#94a3b8;font-family:var(--mono)">'+esc(dev.serial)+'</span>'
|
||
: '<strong style="font-family:var(--mono)">'+esc(dev.serial)+'</strong>'
|
||
+'<br><span style="font-size:11px;color:#d97706">未命名</span>') + 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>'+_battCell(dev)+'</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');
|
||
// chip 优先显示设备名称(身份),没有名称才退回地址
|
||
const nm=(d.device_name||'').trim();
|
||
const title=s+(nm?' · '+nm:'')+(d.model?' · '+d.model:'')
|
||
+(busy?' · 任务中:'+(d.task_job||''):'')
|
||
+(online?'':' · 离线或不在设备池');
|
||
return '<span class="cov-chip '+cls+'" title="'+esc(title)+'">'
|
||
+esc(nm || 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>';
|
||
}
|
||
|