feat: 选择器健康检测 - 连续未命中自动上报,暴露"静默空转"

通用任务 click 步骤连续 10 次找不到元素时判定选择器可能失效(App 改版),
记录 last_warning 并日志 ERROR,监控页设备行显示 ⚠ 警告。
解决"选择器失效但任务仍显示成功"的静默空转问题。
This commit is contained in:
2026-08-08 21:35:21 +08:00
parent 8a675923cd
commit 0c4f586ae5
3 changed files with 27 additions and 1 deletions
+1
View File
@@ -744,6 +744,7 @@ class TaskManager:
"progress": w.get("progress", {}),
"current_action": w.get("current_action", ""),
"last_error": w.get("last_error", ""),
"last_warning": w.get("last_warning", ""),
"running_job": r.get("job_id", ""),
"task_job": w.get("task_job", ""),
"attempt": r.get("attempt", 0),
+3 -1
View File
@@ -454,6 +454,8 @@ function renderDeviceRow(dev){
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 stopBtn=(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>';
@@ -464,7 +466,7 @@ function renderDeviceRow(dev){
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||'-')+'</td><td class="err-text" title="'+esc(dev.last_error||'')+'">'+esc(dev.last_error||'-')+'</td><td>'+btns+'</td></tr>';
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(){
+23
View File
@@ -83,6 +83,25 @@ class GenericStepsWorker(BaseWorker):
self.max_duration = int(p.get("max_duration", 0))
self.steps = p.get("steps", [])
self._action_counts = {} # 步骤执行计数 {step_label: count}
self._miss_counts = {} # 选择器健康:selector -> 连续未命中次数
# 某 click 选择器连续未找到元素的次数达到该值,判定可能失效(App 改版)
_MAX_CONSECUTIVE_MISS = 10
def _track_selector_health(self, sel_val, found):
"""记录某选择器的连续未命中次数,达到阈值上报 last_warning 并重置计数。
用于发现"选择器失效但任务仍显示成功"的静默空转问题(如 App 改版后元素找不到了)。
"""
if found:
self._miss_counts.pop(sel_val, None)
return
n = self._miss_counts.get(sel_val, 0) + 1
self._miss_counts[sel_val] = n
if n >= self._MAX_CONSECUTIVE_MISS:
_log.error(f"[{self.serial}] 选择器连续 {n} 次未找到元素,可能已失效(App 改版?):{sel_val}")
_update_status(self.serial, last_warning=f"选择器连续{n}次未命中")
self._miss_counts.pop(sel_val, None) # 已上报,重置避免刷屏
def run_task(self, d):
"""按 steps 顺序执行。loop 步骤递归执行其 children。"""
@@ -179,6 +198,7 @@ class GenericStepsWorker(BaseWorker):
# 兼容旧配置:selector_type 不是 xpath 但值是 XPath(以 // 开头)
if sel_type != "xpath" and sel_val.strip().startswith("//"):
sel_type = "xpath"
found = False
try:
if sel_type == "xpath":
# u2 的 d.xpath() 返回 XPathSelector,exists 是属性需用 wait()
@@ -186,6 +206,7 @@ class GenericStepsWorker(BaseWorker):
if el.wait(timeout=timeout):
el.click()
_log.info(f"[{self.serial}] click 命中: xpath={sel_val}")
found = True
else:
_log.warning(f"[{self.serial}] click 未找到元素: xpath={sel_val} (等待{timeout}s)")
else:
@@ -194,8 +215,10 @@ class GenericStepsWorker(BaseWorker):
if el.exists(timeout=timeout):
el.click()
_log.info(f"[{self.serial}] click 命中: {sel_type}={sel_val}")
found = True
else:
_log.warning(f"[{self.serial}] click 未找到元素: {sel_type}={sel_val} (等待{timeout}s)")
self._track_selector_health(sel_val, found)
except Exception as e:
_log.warning(f"[{self.serial}] click 异常: {e}")