fix: 设备异常状态清除(清除异常/一键清除,检查与更新原子化防竞态)
This commit is contained in:
@@ -115,6 +115,36 @@ def _remove_worker(serial):
|
||||
_WORKERS.pop(serial, None)
|
||||
|
||||
|
||||
def clear_worker_error(serial):
|
||||
"""清除设备错误/失败状态,恢复为 idle。运行中/连接中返回 False 不清理。
|
||||
|
||||
检查与更新在同一把锁内完成,避免与任务启动写 connecting/running 的竞态。
|
||||
"""
|
||||
with _WORKERS_LOCK:
|
||||
w = _WORKERS.get(serial)
|
||||
if w is None:
|
||||
return True # 本就没有状态记录,视作已清理
|
||||
if w.get("status") in ("running", "connecting"):
|
||||
return False
|
||||
w.update(status="idle", last_error="", last_warning="", task_job="",
|
||||
current_action="", attempt=0, progress={}, end_time=0,
|
||||
last_heartbeat=time.time())
|
||||
return True
|
||||
|
||||
|
||||
def clear_all_worker_errors(exclude=()):
|
||||
"""批量清除所有 error/failed 状态(exclude 里的跳过)。返回清除数。"""
|
||||
exclude = set(exclude)
|
||||
with _WORKERS_LOCK:
|
||||
targets = [s for s, w in _WORKERS.items()
|
||||
if s not in exclude and w.get("status") in ("error", "failed")]
|
||||
cleared = 0
|
||||
for s in targets:
|
||||
if clear_worker_error(s):
|
||||
cleared += 1
|
||||
return cleared
|
||||
|
||||
|
||||
def get_worker_heartbeat(serial):
|
||||
"""返回 worker 最近一次心跳时间,没有返回 0。"""
|
||||
with _WORKERS_LOCK:
|
||||
|
||||
+20
-2
@@ -130,6 +130,19 @@ async function stopSelectedDevices(){
|
||||
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
|
||||
@@ -456,10 +469,15 @@ function renderDeviceRow(dev){
|
||||
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 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 btns='<div style="display:flex;gap:4px;flex-wrap:wrap">'+shotBtn+stopBtn+'</div>';
|
||||
// 异常/失败设备提供"清除异常"(把状态恢复为未启动)
|
||||
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>';
|
||||
// 复选框列
|
||||
|
||||
@@ -289,10 +289,11 @@ select.form-control{cursor:pointer}
|
||||
|
||||
<div class="section-title">设备列表</div>
|
||||
<div class="toolbar">
|
||||
<button class="btn btn-danger" onclick="apiAction('/api/stop_all')">停止全部</button>
|
||||
<button class="btn btn-danger" id="btn-stop-selected" onclick="stopSelectedDevices()" title="停止所有勾选的运行中设备">停止选中</button>
|
||||
<button class="btn" onclick="apiAction('/api/release')">强制释放占用</button>
|
||||
<button class="btn btn-primary" id="btn-scan-fg" onclick="scanForeground()">扫描前台App</button>
|
||||
<button class="btn btn-danger" onclick="apiAction('/api/stop_all')" data-perm="devices">停止全部</button>
|
||||
<button class="btn btn-danger" id="btn-stop-selected" onclick="stopSelectedDevices()" title="停止所有勾选的运行中设备" data-perm="devices">停止选中</button>
|
||||
<button class="btn btn-danger" onclick="clearAllErrors()" title="清除所有异常/失败设备的错误状态(运行中/等待重试的跳过)" data-perm="devices">清除全部异常</button>
|
||||
<button class="btn" onclick="apiAction('/api/release')" data-perm="devices">强制释放占用</button>
|
||||
<button class="btn btn-primary" id="btn-scan-fg" onclick="scanForeground()" data-perm="devices">扫描前台App</button>
|
||||
<button class="btn" onclick="loadMonitor()">刷新</button>
|
||||
<input class="form-control list-search" id="search-devices" placeholder="搜索设备/型号/App/任务...">
|
||||
<span class="hint"><span id="fg-status"></span> · 每 5 秒自动刷新 · <span id="srvTime"></span> · <span id="sel-count">未选中</span></span>
|
||||
|
||||
+31
-4
@@ -28,12 +28,13 @@ from flask_login import (LoginManager, login_user, logout_user,
|
||||
|
||||
from core.stf_client import STFClient
|
||||
from core.task_manager import TaskManager
|
||||
from core.device_worker import get_all_worker_status
|
||||
from core.device_worker import clear_worker_error, clear_all_worker_errors
|
||||
from core.logger import get_logger, _LOG_DIR, _MODULE_FILES
|
||||
from core.models import db, init_db, User, DeviceGroup, TaskJob, CustomAction
|
||||
from core.apk_manager import ApkManager
|
||||
from core.adb_helper import screenshot, list_installed_apps
|
||||
from core.adb_helper import screenshot, list_installed_apps, adb_connect_light, _ADB_LOCK, _adb
|
||||
from core import uiauto_helper
|
||||
from config import ADB_PATH, STF_SSH_TARGET, STF_DOCKER_CONTAINER
|
||||
from tasks import list_task_types, get_task_class
|
||||
|
||||
_log = get_logger("web")
|
||||
@@ -439,19 +440,45 @@ def api_stop_device():
|
||||
|
||||
|
||||
@app.route("/api/stop_all", methods=["POST"])
|
||||
@login_required
|
||||
@perm_required(PERM_DEVICES)
|
||||
def api_stop_all():
|
||||
stopped = mgr.stop_all()
|
||||
return jsonify({"ok": True, "stopped": stopped})
|
||||
|
||||
|
||||
@app.route("/api/release", methods=["POST"])
|
||||
@login_required
|
||||
@perm_required(PERM_DEVICES)
|
||||
def api_release():
|
||||
released, failed = stf.release_all_mine()
|
||||
return jsonify({"ok": True, "released": released, "failed": failed})
|
||||
|
||||
|
||||
@app.route("/api/device/clear_error", methods=["POST"])
|
||||
@perm_required(PERM_DEVICES)
|
||||
def api_device_clear_error():
|
||||
"""清除单台设备的异常状态(error/failed → idle),供设备列表"清除异常"按钮使用。"""
|
||||
serial = (request.json or {}).get("serial", "")
|
||||
if not serial:
|
||||
return jsonify({"ok": False, "error": "缺少 serial"}), 400
|
||||
if serial in mgr.get_running():
|
||||
return jsonify({"ok": False, "error": "设备正在运行或等待重试,无法清除"}), 400
|
||||
if not clear_worker_error(serial):
|
||||
return jsonify({"ok": False, "error": "设备正在运行任务,无法清除"}), 400
|
||||
_log.info(f"清除设备 {serial} 的异常状态")
|
||||
return jsonify({"ok": True, "msg": f"已清除 {serial} 的异常状态"})
|
||||
|
||||
|
||||
@app.route("/api/device/clear_all_errors", methods=["POST"])
|
||||
@perm_required(PERM_DEVICES)
|
||||
def api_device_clear_all_errors():
|
||||
"""一键清除所有异常/失败设备(跳过正在运行/等待重试的)。"""
|
||||
running = set(mgr.get_running())
|
||||
cleared = clear_all_worker_errors(exclude=running)
|
||||
_log.info(f"一键清除异常:共清除 {cleared} 台")
|
||||
return jsonify({"ok": True, "cleared": cleared,
|
||||
"msg": f"已清除 {cleared} 台设备的异常状态"})
|
||||
|
||||
|
||||
@app.route("/api/device/screenshot")
|
||||
@login_required
|
||||
def api_device_screenshot():
|
||||
|
||||
Reference in New Issue
Block a user