From 17f849f7233e8ccef0e432b9f740978d5aca6f33 Mon Sep 17 00:00:00 2001 From: butubb <1422726308@qq.com> Date: Sat, 15 Aug 2026 15:22:55 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20STF=E8=AE=BE=E5=A4=87=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=BD=BB=E5=BA=95=E5=88=A0=E9=99=A4=EF=BC=88=E8=84=9A=E6=9C=AC?= =?UTF-8?q?+=E6=96=AD=E5=BC=80+STF=20API=E6=B8=85=E8=AE=B0=E5=BD=95?= =?UTF-8?q?=EF=BC=89+=20=E4=B8=80=E9=94=AE=E9=87=8D=E8=BF=9E=EF=BC=88?= =?UTF-8?q?=E5=90=8E=E5=8F=B0=E8=B7=91connect=E8=84=9A=E6=9C=AC=EF=BC=89+?= =?UTF-8?q?=20=E8=B7=B3=E8=BF=87=E5=8E=9F=E5=9B=A0=E6=97=A5=E5=BF=97?= =?UTF-8?q?=E5=8C=BA=E5=88=86=E7=A6=BB=E7=BA=BF/=E6=9C=AA=E5=B0=B1?= =?UTF-8?q?=E7=BB=AA=20+=20SSH=E8=B6=85=E6=97=B6=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E5=88=86=E7=B1=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/ssh_client.py | 3 +++ core/stf_client.py | 19 +++++++++++++++++++ core/stf_device_mgmt.py | 25 +++++++++++++++++++++++++ core/task_manager.py | 28 +++++++++++++++++++--------- static/admin/tools.js | 32 +++++++++++++++++++++++++++++++- templates/admin/monitor.html | 2 ++ web_server.py | 25 +++++++++++++++++++++++++ 7 files changed, 124 insertions(+), 10 deletions(-) diff --git a/core/ssh_client.py b/core/ssh_client.py index 935400f..f96df4c 100644 --- a/core/ssh_client.py +++ b/core/ssh_client.py @@ -10,6 +10,7 @@ 失败抛 SSHError(连接失败/超时),由调用方转成友好错误返回。 """ import re +import socket import subprocess import paramiko @@ -91,6 +92,8 @@ def _run_paramiko(cmd, timeout): client.close() except paramiko.AuthenticationException: raise SSHError(f"SSH 密码认证失败(请检查 .env 的 STF_SSH_PASSWORD)") + except socket.timeout: + raise SSHError(f"SSH 执行超时({timeout}s 内未完成,命令可能耗时较长)") except paramiko.SSHException as e: raise SSHError(f"SSH 连接失败: {e}") except Exception as e: diff --git a/core/stf_client.py b/core/stf_client.py index ba9b4f2..871c884 100644 --- a/core/stf_client.py +++ b/core/stf_client.py @@ -160,6 +160,25 @@ class STFClient: _log.error(f"释放 {serial} 失败: {msg}") return released, failed + def delete_device(self, serial): + """从 STF 池彻底删除设备记录(清理离线幽灵设备用)。 + + DELETE /api/v1/devices/{serial}(实测可用):断开后 STF 数据库里残留的 + present=False 陈旧记录会一直显示在设备池,用此端点彻底移除。 + """ + import urllib.parse + try: + r = requests.delete( + f"{self.base_url}/api/v1/devices/{urllib.parse.quote(serial, safe='')}", + headers=self.headers, timeout=_TIMEOUT) + if r.status_code in (200, 202, 204): + _log.info(f"已从 STF 池删除设备 {serial}") + return True, "已从 STF 池删除" + return False, f"HTTP {r.status_code}: {r.text[:150]}" + except requests.exceptions.RequestException as e: + _log.warning(f"删除 STF 设备 {serial} 请求异常: {e}") + return False, str(e) + # ================== 远程 ADB 隧道 ================== def remote_connect(self, serial): """建立远程 ADB 隧道,返回 remoteConnectUrl。 diff --git a/core/stf_device_mgmt.py b/core/stf_device_mgmt.py index 90defd6..20f9675 100644 --- a/core/stf_device_mgmt.py +++ b/core/stf_device_mgmt.py @@ -131,3 +131,28 @@ def remove_device(ip): msgs.append(out.strip() or (f"adb disconnect 执行完成(exit {code})" if code == 0 else "adb disconnect 无输出")) _log.info(f"STF 移除设备 {ip}: {msgs}") return msgs + + +def delete_device(ip, stf=None): + """彻底删除设备:脚本移除 + disconnect + 清 STF 池记录(幽灵设备)。""" + msgs = remove_device(ip) + if stf is not None: + ok, msg = stf.delete_device(f"{_norm_ip(ip)}:5555") + msgs.append(msg) + _log.info(f"STF 彻底删除设备 {ip}: {msgs}") + return msgs + + +def reconnect_all(): + """一键重连:后台运行 220 的 connect_devices.sh(cron 之外的立即补连)。 + + 脚本对未连接设备逐个 connect(不可达设备可能挂 40s+),全程可能 1-3 分钟, + 因此 nohup 后台执行立即返回,不阻塞请求;结果用"刷新"看设备连接状态。 + """ + cmd = (f"nohup bash {shlex.quote(STF_SCRIPT_PATH)} >/tmp/connect_devices.out 2>&1 &") + code, out, err = _ssh(cmd, timeout=15) + if code != 0: + raise StfDevError(f"启动重连失败: {err.strip() or out.strip()}") + _log.info("STF 一键重连已在 220 后台启动") + return True, ("重连已在 220 后台执行(对未连接设备逐个 connect,约 1-3 分钟)," + "稍后点\"刷新\"查看设备连接状态") diff --git a/core/task_manager.py b/core/task_manager.py index 78862de..86b9b01 100644 --- a/core/task_manager.py +++ b/core/task_manager.py @@ -156,19 +156,29 @@ class TaskJob: return [d["serial"] for d in manager.stf.list_free_devices()] except Exception: return [] - # 离线自动跳过(serial/group 模式):跳过 STF 池里不在线(present=False)的设备, - # 避免对离线设备反复尝试占用后报"设备离线"。默认开启,可在任务编辑器取消勾选。 + # 离线自动跳过(serial/group 模式):跳过 STF 池里不在线(present=False)或 + # 未就绪(ready=False,provider 刚接入还在初始化)的设备, + # 避免对离线/未就绪设备反复尝试占用后报"设备离线"。默认开启,可在任务编辑器取消勾选。 if serials and self.params.get("skip_offline", True): try: - online = {d["serial"] for d in manager.stf.list_all_devices() - if d.get("present") and d.get("ready")} + devs = manager.stf.list_all_devices() except Exception: - online = None # STF 查询失败时不过滤,维持原行为 - if online is not None: - skipped = [s for s in serials if s not in online] + devs = None # STF 查询失败时不过滤,维持原行为 + if devs is not None: + present = {d["serial"] for d in devs if d.get("present")} + ready = {d["serial"] for d in devs if d.get("present") and d.get("ready")} + kept, skipped = [], [] + for s in serials: + if s not in present: + skipped.append((s, "离线")) + elif s not in ready: + skipped.append((s, "未就绪(provider初始化中)")) + else: + kept.append(s) if skipped: - _log.info(f"任务 {self.name} 离线自动跳过 {len(skipped)} 台设备: {skipped}") - serials = [s for s in serials if s in online] + detail = ", ".join(f"{s}({why})" for s, why in skipped) + _log.info(f"任务 {self.name} 跳过 {len(skipped)} 台设备: {detail}") + serials = kept return serials diff --git a/static/admin/tools.js b/static/admin/tools.js index 65a3f18..679efe5 100644 --- a/static/admin/tools.js +++ b/static/admin/tools.js @@ -126,7 +126,37 @@ function renderStfDevRow(x){ return ''+esc(x.ip)+''+ ''+(x.inCfg?'脚本中':'未配置')+''+ ''+stHtml+''+ - ''; + ''+ + ' '+ + ''+ + ''; +} + +// 一键重连:手动运行 220 的 connect_devices.sh,补连所有配置设备 +async function reconnectStfDevices(){ + if(!confirm('立即运行 220 上的 connect_devices.sh?\n\n会补连脚本里所有未连接的设备(在线设备跳过)。'))return; + const r=await apiPost('/api/stf/devmgmt/reconnect',{}); + if(!r)return; + const box=document.getElementById('stfdev-reconnect-out'); + if(r.ok){ + box.style.display='block'; + box.textContent=r.output||'执行完成'; + showToast('重连完成','success'); + loadStfDevMgmt(); + }else{ + showToast('重连失败: '+(r.error||''),'error'); + } +} + +// 彻底删除:脚本移除 + 断开 + 清 STF 池记录(设备从池中彻底消失) +async function deleteStfDev(ip){ + if(!confirm('彻底删除 '+ip+'?\n\n1. 从 connect_devices.sh 移除\n2. 断开 220 的 adb 连接\n3. 调用 STF API 清除设备记录(设备从设备池彻底消失,需重新添加才会回来)'))return; + const r=await apiPost('/api/stf/devmgmt/delete',{ip:ip}); + if(!r)return; + if(r.ok){ + showToast((r.msgs||[]).join(';'),'success'); + loadStfDevMgmt(); + }else showToast('删除失败: '+(r.error||''),'error'); } async function addStfDev(){ diff --git a/templates/admin/monitor.html b/templates/admin/monitor.html index f04f263..e0dcf1b 100644 --- a/templates/admin/monitor.html +++ b/templates/admin/monitor.html @@ -642,10 +642,12 @@ select.form-control{cursor:pointer} + + diff --git a/web_server.py b/web_server.py index 938137c..1abedd6 100644 --- a/web_server.py +++ b/web_server.py @@ -1278,6 +1278,31 @@ def api_tools_appver(): "total": len(results)}) +@app.route("/api/stf/devmgmt/delete", methods=["POST"]) +@admin_required +def api_stf_devmgmt_delete(): + """STF 设备管理:彻底删除(脚本移除 + disconnect + 清 STF 池记录)。""" + ip = (request.json or {}).get("ip", "").strip() + if not ip: + return jsonify({"ok": False, "error": "请输入设备 IP"}), 400 + try: + msgs = stf_device_mgmt.delete_device(ip, stf=stf) + return jsonify({"ok": True, "msgs": msgs}) + except StfDevError as e: + return jsonify({"ok": False, "error": str(e)}), 502 + + +@app.route("/api/stf/devmgmt/reconnect", methods=["POST"]) +@admin_required +def api_stf_devmgmt_reconnect(): + """STF 设备管理:一键重连(手动运行 220 的 connect_devices.sh)。""" + try: + ok, summary = stf_device_mgmt.reconnect_all() + return jsonify({"ok": True, "output": summary}) + except StfDevError as e: + return jsonify({"ok": False, "error": str(e)}), 502 + + @app.route("/api/stf/restart", methods=["POST"]) @admin_required def api_stf_restart():
设备