fix: 设备异常状态清除(清除异常/一键清除,检查与更新原子化防竞态)
This commit is contained in:
@@ -115,6 +115,36 @@ def _remove_worker(serial):
|
|||||||
_WORKERS.pop(serial, None)
|
_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):
|
def get_worker_heartbeat(serial):
|
||||||
"""返回 worker 最近一次心跳时间,没有返回 0。"""
|
"""返回 worker 最近一次心跳时间,没有返回 0。"""
|
||||||
with _WORKERS_LOCK:
|
with _WORKERS_LOCK:
|
||||||
|
|||||||
+20
-2
@@ -130,6 +130,19 @@ async function stopSelectedDevices(){
|
|||||||
setTimeout(loadMonitor,500);
|
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 _shotTimer=null; // 自动刷新定时器
|
||||||
let _shotSerial=''; // 当前查看的 serial
|
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>':'';
|
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 改版选择器失效)
|
// 选择器健康警告(某步骤连续未找到元素,可能 App 改版选择器失效)
|
||||||
const warnInfo=dev.last_warning?'<div style="font-size:11px;color:#f59e0b" title="'+esc(dev.last_warning)+'">⚠ '+esc(dev.last_warning)+'</div>':'';
|
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 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 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 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="section-title">设备列表</div>
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<button class="btn btn-danger" onclick="apiAction('/api/stop_all')">停止全部</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="停止所有勾选的运行中设备">停止选中</button>
|
<button class="btn btn-danger" id="btn-stop-selected" onclick="stopSelectedDevices()" title="停止所有勾选的运行中设备" data-perm="devices">停止选中</button>
|
||||||
<button class="btn" onclick="apiAction('/api/release')">强制释放占用</button>
|
<button class="btn btn-danger" onclick="clearAllErrors()" title="清除所有异常/失败设备的错误状态(运行中/等待重试的跳过)" data-perm="devices">清除全部异常</button>
|
||||||
<button class="btn btn-primary" id="btn-scan-fg" onclick="scanForeground()">扫描前台App</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>
|
<button class="btn" onclick="loadMonitor()">刷新</button>
|
||||||
<input class="form-control list-search" id="search-devices" placeholder="搜索设备/型号/App/任务...">
|
<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>
|
<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.stf_client import STFClient
|
||||||
from core.task_manager import TaskManager
|
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.logger import get_logger, _LOG_DIR, _MODULE_FILES
|
||||||
from core.models import db, init_db, User, DeviceGroup, TaskJob, CustomAction
|
from core.models import db, init_db, User, DeviceGroup, TaskJob, CustomAction
|
||||||
from core.apk_manager import ApkManager
|
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 core import uiauto_helper
|
||||||
|
from config import ADB_PATH, STF_SSH_TARGET, STF_DOCKER_CONTAINER
|
||||||
from tasks import list_task_types, get_task_class
|
from tasks import list_task_types, get_task_class
|
||||||
|
|
||||||
_log = get_logger("web")
|
_log = get_logger("web")
|
||||||
@@ -439,19 +440,45 @@ def api_stop_device():
|
|||||||
|
|
||||||
|
|
||||||
@app.route("/api/stop_all", methods=["POST"])
|
@app.route("/api/stop_all", methods=["POST"])
|
||||||
@login_required
|
@perm_required(PERM_DEVICES)
|
||||||
def api_stop_all():
|
def api_stop_all():
|
||||||
stopped = mgr.stop_all()
|
stopped = mgr.stop_all()
|
||||||
return jsonify({"ok": True, "stopped": stopped})
|
return jsonify({"ok": True, "stopped": stopped})
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/release", methods=["POST"])
|
@app.route("/api/release", methods=["POST"])
|
||||||
@login_required
|
@perm_required(PERM_DEVICES)
|
||||||
def api_release():
|
def api_release():
|
||||||
released, failed = stf.release_all_mine()
|
released, failed = stf.release_all_mine()
|
||||||
return jsonify({"ok": True, "released": released, "failed": failed})
|
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")
|
@app.route("/api/device/screenshot")
|
||||||
@login_required
|
@login_required
|
||||||
def api_device_screenshot():
|
def api_device_screenshot():
|
||||||
|
|||||||
Reference in New Issue
Block a user