From 5dd605e3a047442b66b92bd0979d06d45c437986 Mon Sep 17 00:00:00 2001 From: butubb <1422726308@qq.com> Date: Tue, 11 Aug 2026 10:50:29 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=B7=A5=E5=85=B7Tab=E6=96=B0=E5=A2=9E?= =?UTF-8?q?STF=E8=AE=BE=E5=A4=87=E7=AE=A1=E7=90=86=EF=BC=88=E8=AF=BB?= =?UTF-8?q?=E5=86=99220=E8=84=9A=E6=9C=ACDEVICES=E5=88=97=E8=A1=A8+docker?= =?UTF-8?q?=20adb=20connect/disconnect=EF=BC=8C220=E4=BE=A7timeout?= =?UTF-8?q?=E5=85=9C=E5=BA=95=E9=98=B2=E5=8D=A1=E6=AD=BB=EF=BC=89+=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=AE=BE=E5=A4=87=E5=88=97=E8=A1=A8:5555?= =?UTF-8?q?=E9=87=8D=E5=A4=8D=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config.py | 9 ++- core/stf_device_mgmt.py | 141 +++++++++++++++++++++++++++++++++++ static/admin/monitor.js | 56 +++++++++++++- templates/admin/monitor.html | 23 ++++++ web_server.py | 42 ++++++++++- 5 files changed, 267 insertions(+), 4 deletions(-) create mode 100644 core/stf_device_mgmt.py diff --git a/config.py b/config.py index 12d853a..27bdceb 100644 --- a/config.py +++ b/config.py @@ -65,11 +65,16 @@ APK_DIR = os.path.join(DATA_DIR, "apks") # 仅单实例部署时设为 True:多实例共用同一 STF 账户时不要开,会误释放另一实例的运行中任务。 AUTO_RELEASE_STALE_OCCUPY = _env("AUTO_RELEASE_STALE_OCCUPY", "false").lower() in ("1", "true", "yes") -# ================== 维护(STF 容器一键重启) ================== -# 维护页"一键重启 STF 容器"通过 SSH 到部署机执行 docker restart。 +# ================== 维护(STF 容器一键重启 + STF 设备管理) ================== +# 通过 SSH 到部署机执行 docker 命令/改脚本。 # 前置条件:本机可免密 SSH 到目标机(stf@220 需已授权本机公钥)。 STF_SSH_TARGET = _env("STF_SSH_TARGET", "stf@192.168.20.220") STF_DOCKER_CONTAINER = _env("STF_DOCKER_CONTAINER", "stf") +# STF 设备管理:"工具 → STF 设备管理"维护设备池。 +# 220 上 adb 跑在独立容器(connect_devices.sh 里是 docker exec adb adb ...); +# 设备列表由脚本的 DEVICES 数组维护(cron 每 5 分钟补连)。 +STF_ADB_CONTAINER = _env("STF_ADB_CONTAINER", "adb") +STF_SCRIPT_PATH = _env("STF_SCRIPT_PATH", "/mnt/data/openstf/connect_devices.sh") # ================== 维护(Tailscale 管理) ================== # 维护页"Tailscale 管理"调用官方 API v2 管理 tailnet 设备。 diff --git a/core/stf_device_mgmt.py b/core/stf_device_mgmt.py new file mode 100644 index 0000000..7ebe134 --- /dev/null +++ b/core/stf_device_mgmt.py @@ -0,0 +1,141 @@ +"""OpenSTF 设备池管理(工具页"STF 设备管理"用)。 + +背景:220 上 STF 的 adb 跑在 Docker 容器里,设备池由 +/mnt/data/openstf/connect_devices.sh 的 DEVICES 数组维护(cron 每 5 分钟补连)。 +本模块把"改脚本 + 实时 adb connect/disconnect"封装成 API: + - status 已配置 IP 列表 + 220 adb 实际连接状态 + - add_device 写入脚本 DEVICES + 立即 docker exec adb adb connect + - remove_device 从脚本 DEVICES 移除 + adb disconnect + +脚本编辑用 220 上的 python3 做精确的行级增删(sed 处理多行数组容易误伤)。 +注意:这里的 adb 是 220 上的(STF provider 的),与本机 Mac 直连的 adb 相互独立; +移除设备会断开 STF 池连接(设备从 OpenSTF 下线),不影响本机已有的任务 adb 连接。 +""" +import re +import shlex +import subprocess + +from config import STF_SSH_TARGET, STF_ADB_CONTAINER, STF_SCRIPT_PATH +from core.logger import get_logger + +_log = get_logger("core.stfdev") + +_SSH_BASE = ["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5", + "-o", "StrictHostKeyChecking=no", STF_SSH_TARGET] + + +class StfDevError(Exception): + """STF 设备管理错误(SSH/脚本/命令失败)。""" + + +def _ssh(cmd, timeout=25): + """在 220 上执行一条 shell 命令。返回 (code, stdout, stderr)。""" + try: + r = subprocess.run(_SSH_BASE + [cmd], capture_output=True, timeout=timeout) + except subprocess.TimeoutExpired: + raise StfDevError(f"SSH 执行超时: {cmd[:80]}") + except Exception as e: + raise StfDevError(f"SSH 连接失败: {e}") + out = (r.stdout or b"").decode("utf-8", errors="replace") + err = (r.stderr or b"").decode("utf-8", errors="replace") + return r.returncode, out, err + + +def _adb(args, remote_timeout=15): + """在 220 的 adb 容器里执行 adb 命令。返回 (code, stdout, stderr)。 + + remote_timeout 是 220 侧的 timeout 秒数:adb connect 到不可达 IP 会长时间 + 挂起重试(实测 ≥40s),必须用 host 的 timeout 兜底,否则 API 请求会卡死。 + """ + return _ssh(f"timeout {remote_timeout} docker exec {shlex.quote(STF_ADB_CONTAINER)} adb " + + " ".join(shlex.quote(a) for a in args)) + + +def _norm_ip(ip): + """规范化 IP:去空白、去 :5555 后缀。""" + ip = str(ip or "").strip() + if ip.endswith(":5555"): + ip = ip[:-5] + return ip + + +# ================== 查询 ================== +def configured_ips(): + """解析脚本 DEVICES 数组里的 IP 列表。""" + code, out, err = _ssh(f"cat {shlex.quote(STF_SCRIPT_PATH)}") + if code != 0: + raise StfDevError(f"读取脚本失败: {err.strip()}") + m = re.search(r"DEVICES=\((.*?)\)", out, re.S) + if not m: + raise StfDevError("脚本中未找到 DEVICES 列表") + return re.findall(r'"([^"]+)"', m.group(1)) + + +def connected_devices(): + """220 adb 实际连接状态 [{serial, state}]。""" + code, out, err = _adb(["devices"]) + if code != 0: + raise StfDevError(f"adb devices 失败: {err.strip()}") + devs = [] + for line in out.splitlines()[1:]: + parts = line.split() + if len(parts) >= 2 and parts[0] and not parts[0].startswith("*"): + devs.append({"serial": parts[0], "state": parts[1]}) + return devs + + +def status(): + """综合状态:脚本配置的 IP + 实际连接。返回 {configured, connected}。""" + return {"configured": configured_ips(), "connected": connected_devices()} + + +# ================== 增删设备 ================== +def _edit_script(python_code): + """用 220 的 python3 执行脚本编辑代码(代码内请用双引号)。""" + cmd = f"python3 -c {shlex.quote(python_code)}" + code, out, err = _ssh(cmd) + if code != 0: + raise StfDevError(f"脚本编辑失败: {err.strip() or out.strip()}") + + +def add_device(ip): + """添加设备:写入脚本 DEVICES + 立即 connect。返回操作消息列表。""" + ip = _norm_ip(ip) + if not re.match(r"^\d{1,3}(\.\d{1,3}){3}$", ip): + raise StfDevError(f"IP 格式不正确: {ip}") + msgs = [] + if ip in configured_ips(): + msgs.append(f"{ip} 已在脚本配置中") + else: + # 在 DEVICES=( 行后插入一行 IP(python 精确行级插入) + _edit_script( + "p='" + STF_SCRIPT_PATH + "';s=open(p).read();" + "s=s.replace('DEVICES=(\\n','DEVICES=(\\n \\\"" + ip + "\\\"\\n',1);open(p,'w').write(s)") + msgs.append(f"已写入脚本 {STF_SCRIPT_PATH}") + code, out, err = _adb(["connect", f"{ip}:5555"]) + if code == 0: + msgs.append(out.strip() or "adb connect 成功") + elif code == 124: + msgs.append("连接超时(设备当前不可达),已保留在脚本中,cron 会每 5 分钟自动重试") + else: + msgs.append(f"连接失败: {out.strip() or err.strip() or '未知错误'}") + _log.info(f"STF 添加设备 {ip}: {msgs}") + return msgs + + +def remove_device(ip): + """移除设备:从脚本 DEVICES 删除 + adb disconnect。返回操作消息列表。""" + ip = _norm_ip(ip) + msgs = [] + if ip in configured_ips(): + # 删除 DEVICES 块内该 IP 的整行 + _edit_script( + "p='" + STF_SCRIPT_PATH + "';s=open(p).read();" + "s=s.replace(' \\\"" + ip + "\\\"\\n','');open(p,'w').write(s)") + msgs.append("已从脚本移除") + else: + msgs.append(f"{ip} 不在脚本配置中") + code, out, err = _adb(["disconnect", f"{ip}:5555"]) + msgs.append(out.strip() or (f"adb disconnect 执行完成(exit {code})" if code == 0 else "adb disconnect 无输出")) + _log.info(f"STF 移除设备 {ip}: {msgs}") + return msgs diff --git a/static/admin/monitor.js b/static/admin/monitor.js index a9f6f96..5768b2f 100644 --- a/static/admin/monitor.js +++ b/static/admin/monitor.js @@ -288,7 +288,7 @@ function showTab(name){ // 按需加载数据 if(name==='monitor'){loadMonitor();_monitorTimer=setInterval(loadMonitor,5000);} if(name==='tasks'){showSubTab('tasks',_activeSubs.tasks);loadTasks();loadCustomActions();} - if(name==='tools'){showSubTab('tools',_activeSubs.tools);loadToolsDevices();loadAdbDevices();loadTailscaleDevices();loadApks();} + if(name==='tools'){showSubTab('tools',_activeSubs.tools);loadToolsDevices();loadAdbDevices();loadTailscaleDevices();loadApks();loadStfDevMgmt();} if(name==='groups')loadGroups(); if(name==='logs'){loadLogs();if(document.getElementById('log-auto').checked)_logTimer=setInterval(loadLogs,3000);} if(name==='users')loadUsers(); @@ -350,6 +350,60 @@ function toggleClipDevice(cb){ else _clipSelected.delete(cb.value); } +// ================== 工具:STF 设备管理 ================== +async function loadStfDevMgmt(){ + const status = document.getElementById('stfdev-status'); + const r = await apiGet('/api/stf/devmgmt/status'); + if(!r || !r.ok){status.textContent='加载失败';showToast('加载失败: '+(r&&r.error||''),'error');return;} + const configured = r.configured || []; // 脚本 DEVICES 里的 IP(裸 IP) + const connected = {}; // 裸 IP -> state(220 adb devices 的 serial 带 :5555,归一化) + (r.connected || []).forEach(d=>{ + const s = d.serial; + connected[s.endsWith(':5555') ? s.slice(0,-5) : s] = d.state; + }); + const ips = Array.from(new Set(configured.concat(Object.keys(connected)))); + const tbody = document.getElementById('tb-stfdev'); + if(!ips.length){ + tbody.innerHTML='无设备(脚本未配置、220 adb 也无连接)'; + status.textContent=''; + return; + } + tbody.innerHTML = ips.map(ip=>{ + const inCfg = configured.includes(ip); + const st = connected[ip]; + const stHtml = st==='device' + ? '已连接' + : (st ? ''+esc(st)+'' : '未连接'); + return ''+esc(ip)+''+ + ''+(inCfg?'脚本中':'未配置')+''+ + ''+stHtml+''+ + ''; + }).join(''); + status.textContent = '脚本配置 '+configured.length+' 台,已连接 '+Object.keys(connected).length+' 台'; +} + +async function addStfDev(){ + const ip = document.getElementById('stfdev-ip').value.trim(); + if(!ip){showToast('请输入设备 IP','error');return;} + const r = await apiPost('/api/stf/devmgmt/add', {ip:ip}); + if(!r)return; + if(r.ok){ + showToast((r.msgs||[]).join(';'),'success'); + document.getElementById('stfdev-ip').value=''; + loadStfDevMgmt(); + }else showToast('添加失败: '+(r.error||''),'error'); +} + +async function removeStfDev(ip){ + if(!confirm('确定移除 '+ip+'?\n\n将从 connect_devices.sh 删除并断开 220 的 adb 连接,\n设备会从 OpenSTF 设备池下线(任务不再分配到它)。'))return; + const r = await apiPost('/api/stf/devmgmt/remove', {ip:ip}); + if(!r)return; + if(r.ok){ + showToast((r.msgs||[]).join(';'),'success'); + loadStfDevMgmt(); + }else showToast('移除失败: '+(r.error||''),'error'); +} + async function injectClipboard(){ const text = document.getElementById('clip-text').value.trim(); if(!text){showToast('请输入要注入的文字','error');return;} diff --git a/templates/admin/monitor.html b/templates/admin/monitor.html index c2170bc..5f795f2 100644 --- a/templates/admin/monitor.html +++ b/templates/admin/monitor.html @@ -478,6 +478,7 @@ select.form-control{cursor:pointer} + @@ -609,6 +610,28 @@ select.form-control{cursor:pointer}
+ + +
+
+ + + + +
+ + + + + +
设备脚本配置连接状态(220 adb)操作
点击"刷新"加载
+
+ 管理 220 上 OpenSTF 设备池:添加 = 写入 connect_devices.sh 的 DEVICES 列表并立即 adb connect; + 移除 = 从脚本删除并 adb disconnect(设备从 STF 池下线,任务将不再分配到它)。 + 脚本的 cron 每 5 分钟自动补连掉线设备。adb 操作发生在 220(STF provider 侧),不影响本机任务直连。 +
+
diff --git a/web_server.py b/web_server.py index a8f2679..dd14dbe 100644 --- a/web_server.py +++ b/web_server.py @@ -38,8 +38,9 @@ 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, adb_connect_light, adb_connect, _ADB_LOCK, _adb) -from core import uiauto_helper, tailscale_client +from core import uiauto_helper, tailscale_client, stf_device_mgmt from core.tailscale_client import TailscaleError +from core.stf_device_mgmt import StfDevError from config import ADB_PATH, STF_SSH_TARGET, STF_DOCKER_CONTAINER from tasks import list_task_types, get_task_class @@ -992,6 +993,45 @@ def _set_device_clipboard(serial, text): return False, f"{type(e).__name__}: {str(e)[:120]}" +@app.route("/api/stf/devmgmt/status") +@admin_required +def api_stf_devmgmt_status(): + """STF 设备管理:脚本配置的 IP + 220 adb 实际连接状态。""" + try: + st = stf_device_mgmt.status() + return jsonify({"ok": True, **st}) + except StfDevError as e: + return jsonify({"ok": False, "error": str(e)}), 502 + + +@app.route("/api/stf/devmgmt/add", methods=["POST"]) +@admin_required +def api_stf_devmgmt_add(): + """STF 设备管理:添加设备(写入脚本 + 立即 connect)。""" + ip = (request.json or {}).get("ip", "").strip() + if not ip: + return jsonify({"ok": False, "error": "请输入设备 IP"}), 400 + try: + msgs = stf_device_mgmt.add_device(ip) + return jsonify({"ok": True, "msgs": msgs}) + except StfDevError as e: + return jsonify({"ok": False, "error": str(e)}), 502 + + +@app.route("/api/stf/devmgmt/remove", methods=["POST"]) +@admin_required +def api_stf_devmgmt_remove(): + """STF 设备管理:移除设备(脚本删除 + disconnect)。""" + ip = (request.json or {}).get("ip", "").strip() + if not ip: + return jsonify({"ok": False, "error": "请输入设备 IP"}), 400 + try: + msgs = stf_device_mgmt.remove_device(ip) + return jsonify({"ok": True, "msgs": msgs}) + 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():