Files
auto_control/static/admin/monitor.js
T

439 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 监控页:设备状态表(排序/多选/截图/异常汇总/任务运行概况)
// ================== 设备列表排序与多选 ==================
let _selectedSerials=new Set(); // 已勾选的 serial 集合
// 排序比较:从设备对象取排序值(统一排序用,见 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==='stf_occupied'){
v=dev.stf_occupied?1:0;
}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);
}
// 清除单台设备的异常状态(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 occupied=devices.filter(x=>x.stf_occupied).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:occupied,l:'STF占用',c:'#f59e0b'},
{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});
if(_sig===_devicesSig)return;
_devicesSig=_sig;
// 排序由 applyListPager 统一处理(sortValue + _lpSort)
setListPager('devices',{
data:()=>_statusData.devices||[],
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 stf=dev.stf_occupied?'<span class="label label-success">占用</span>':'<span class="label label-default">空闲</span>';
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 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+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>';
// 复选框列
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>'+stf+offline+'</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('');
}
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>'+
'</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>'+
'<button class="btn btn-xs" onclick="openTaskModal(\''+j.id+'\')">编辑</button>'+
'<button class="btn btn-danger btn-xs" onclick="deleteTask(\''+j.id+'\')">删除</button>'+
'</div>'+
'</div>';
}).join(''):'<div class="empty">暂无任务,到"任务"Tab 新建</div>';
}