diff --git a/core/device_worker.py b/core/device_worker.py
index e984b1d..5990141 100644
--- a/core/device_worker.py
+++ b/core/device_worker.py
@@ -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:
diff --git a/static/admin/monitor.js b/static/admin/monitor.js
index ca483c1..54dc8a5 100644
--- a/static/admin/monitor.js
+++ b/static/admin/monitor.js
@@ -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)?'
预计结束 '+new Date(dev.end_time*1000).toLocaleTimeString()+'
':'';
// 选择器健康警告(某步骤连续未找到元素,可能 App 改版选择器失效)
const warnInfo=dev.last_warning?'⚠ '+esc(dev.last_warning)+'
':'';
- const stopBtn=(dev.worker_status==='running'||dev.worker_status==='connecting')?'':'';
+ // 设备操作按钮需要"设备控制"权限(无权限只保留截图查看)
+ const canDev=_can('devices');
+ const stopBtn=canDev&&(dev.worker_status==='running'||dev.worker_status==='connecting')?'':'';
// 截图按钮(任何状态都可看,离线时会报错)
const shotBtn='';
- const btns=''+shotBtn+stopBtn+'
';
+ // 异常/失败设备提供"清除异常"(把状态恢复为未启动)
+ const clearBtn=canDev&&(dev.worker_status==='error'||dev.worker_status==='failed')
+ ?'':'';
+ const btns=''+shotBtn+clearBtn+stopBtn+'
';
const devName=dev.device_name||dev.model||'';
const devLabel=devName?''+esc(dev.serial)+'
'+esc(devName)+'':''+esc(dev.serial)+'';
// 复选框列
diff --git a/templates/admin/monitor.html b/templates/admin/monitor.html
index 64883d3..c843f96 100644
--- a/templates/admin/monitor.html
+++ b/templates/admin/monitor.html
@@ -289,10 +289,11 @@ select.form-control{cursor:pointer}
设备列表
-
-
-
-
+
+
+
+
+
· 每 5 秒自动刷新 · · 未选中
diff --git a/web_server.py b/web_server.py
index a0ccfb7..93ad230 100644
--- a/web_server.py
+++ b/web_server.py
@@ -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():