feat: 工具Tab新增STF设备管理(读写220脚本DEVICES列表+docker adb connect/disconnect,220侧timeout兜底防卡死)+ 修复设备列表:5555重复显示
This commit is contained in:
@@ -65,11 +65,16 @@ APK_DIR = os.path.join(DATA_DIR, "apks")
|
|||||||
# 仅单实例部署时设为 True:多实例共用同一 STF 账户时不要开,会误释放另一实例的运行中任务。
|
# 仅单实例部署时设为 True:多实例共用同一 STF 账户时不要开,会误释放另一实例的运行中任务。
|
||||||
AUTO_RELEASE_STALE_OCCUPY = _env("AUTO_RELEASE_STALE_OCCUPY", "false").lower() in ("1", "true", "yes")
|
AUTO_RELEASE_STALE_OCCUPY = _env("AUTO_RELEASE_STALE_OCCUPY", "false").lower() in ("1", "true", "yes")
|
||||||
|
|
||||||
# ================== 维护(STF 容器一键重启) ==================
|
# ================== 维护(STF 容器一键重启 + STF 设备管理) ==================
|
||||||
# 维护页"一键重启 STF 容器"通过 SSH 到部署机执行 docker restart。
|
# 通过 SSH 到部署机执行 docker 命令/改脚本。
|
||||||
# 前置条件:本机可免密 SSH 到目标机(stf@220 需已授权本机公钥)。
|
# 前置条件:本机可免密 SSH 到目标机(stf@220 需已授权本机公钥)。
|
||||||
STF_SSH_TARGET = _env("STF_SSH_TARGET", "[email protected]")
|
STF_SSH_TARGET = _env("STF_SSH_TARGET", "[email protected]")
|
||||||
STF_DOCKER_CONTAINER = _env("STF_DOCKER_CONTAINER", "stf")
|
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 管理) ==================
|
||||||
# 维护页"Tailscale 管理"调用官方 API v2 管理 tailnet 设备。
|
# 维护页"Tailscale 管理"调用官方 API v2 管理 tailnet 设备。
|
||||||
|
|||||||
@@ -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
|
||||||
+55
-1
@@ -288,7 +288,7 @@ function showTab(name){
|
|||||||
// 按需加载数据
|
// 按需加载数据
|
||||||
if(name==='monitor'){loadMonitor();_monitorTimer=setInterval(loadMonitor,5000);}
|
if(name==='monitor'){loadMonitor();_monitorTimer=setInterval(loadMonitor,5000);}
|
||||||
if(name==='tasks'){showSubTab('tasks',_activeSubs.tasks);loadTasks();loadCustomActions();}
|
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==='groups')loadGroups();
|
||||||
if(name==='logs'){loadLogs();if(document.getElementById('log-auto').checked)_logTimer=setInterval(loadLogs,3000);}
|
if(name==='logs'){loadLogs();if(document.getElementById('log-auto').checked)_logTimer=setInterval(loadLogs,3000);}
|
||||||
if(name==='users')loadUsers();
|
if(name==='users')loadUsers();
|
||||||
@@ -350,6 +350,60 @@ function toggleClipDevice(cb){
|
|||||||
else _clipSelected.delete(cb.value);
|
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='<tr><td colspan="4" class="empty">无设备(脚本未配置、220 adb 也无连接)</td></tr>';
|
||||||
|
status.textContent='';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tbody.innerHTML = ips.map(ip=>{
|
||||||
|
const inCfg = configured.includes(ip);
|
||||||
|
const st = connected[ip];
|
||||||
|
const stHtml = st==='device'
|
||||||
|
? '<span class="label label-success">已连接</span>'
|
||||||
|
: (st ? '<span class="label label-warning">'+esc(st)+'</span>' : '<span class="label label-default">未连接</span>');
|
||||||
|
return '<tr><td style="font-family:monospace">'+esc(ip)+'</td>'+
|
||||||
|
'<td>'+(inCfg?'<span class="label label-info">脚本中</span>':'<span class="label label-default">未配置</span>')+'</td>'+
|
||||||
|
'<td>'+stHtml+'</td>'+
|
||||||
|
'<td><button class="btn btn-xs btn-danger" onclick="removeStfDev(\''+esc(ip)+'\')">移除(断开+删脚本)</button></td></tr>';
|
||||||
|
}).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(){
|
async function injectClipboard(){
|
||||||
const text = document.getElementById('clip-text').value.trim();
|
const text = document.getElementById('clip-text').value.trim();
|
||||||
if(!text){showToast('请输入要注入的文字','error');return;}
|
if(!text){showToast('请输入要注入的文字','error');return;}
|
||||||
|
|||||||
@@ -478,6 +478,7 @@ select.form-control{cursor:pointer}
|
|||||||
<button class="sub-tab" data-sub="ts" onclick="showSubTab('tools','ts')">Tailscale 管理</button>
|
<button class="sub-tab" data-sub="ts" onclick="showSubTab('tools','ts')">Tailscale 管理</button>
|
||||||
<button class="sub-tab" data-sub="apks" onclick="showSubTab('tools','apks')">应用管理</button>
|
<button class="sub-tab" data-sub="apks" onclick="showSubTab('tools','apks')">应用管理</button>
|
||||||
<button class="sub-tab" data-sub="devapps" onclick="showSubTab('tools','devapps')">设备已装应用</button>
|
<button class="sub-tab" data-sub="devapps" onclick="showSubTab('tools','devapps')">设备已装应用</button>
|
||||||
|
<button class="sub-tab" data-sub="stfdev" onclick="showSubTab('tools','stfdev')">STF 设备管理</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 子分栏 1:剪贴板注入 -->
|
<!-- 子分栏 1:剪贴板注入 -->
|
||||||
@@ -609,6 +610,28 @@ select.form-control{cursor:pointer}
|
|||||||
</div>
|
</div>
|
||||||
<div class="list-pager" id="pager-devapps"></div>
|
<div class="list-pager" id="pager-devapps"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 子分栏 6:STF 设备管理 -->
|
||||||
|
<div class="sub-panel" id="tools-sub-stfdev">
|
||||||
|
<div class="toolbar">
|
||||||
|
<input type="text" id="stfdev-ip" class="form-control" style="width:220px;font-family:monospace"
|
||||||
|
placeholder="设备 IP,如 100.100.10.20" onkeydown="if(event.key==='Enter')addStfDev()">
|
||||||
|
<button class="btn btn-primary" onclick="addStfDev()">添加设备并连接</button>
|
||||||
|
<button class="btn" onclick="loadStfDevMgmt()">刷新</button>
|
||||||
|
<span id="stfdev-status" class="text-muted"></span>
|
||||||
|
</div>
|
||||||
|
<table class="table">
|
||||||
|
<thead><tr>
|
||||||
|
<th>设备</th><th>脚本配置</th><th>连接状态(220 adb)</th><th>操作</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody id="tb-stfdev"><tr><td colspan="4" class="empty">点击"刷新"加载</td></tr></tbody>
|
||||||
|
</table>
|
||||||
|
<div class="help">
|
||||||
|
管理 220 上 OpenSTF 设备池:添加 = 写入 connect_devices.sh 的 DEVICES 列表并立即 adb connect;
|
||||||
|
移除 = 从脚本删除并 adb disconnect(设备从 STF 池下线,任务将不再分配到它)。
|
||||||
|
脚本的 cron 每 5 分钟自动补连掉线设备。adb 操作发生在 220(STF provider 侧),不影响本机任务直连。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div><!-- /.content -->
|
</div><!-- /.content -->
|
||||||
|
|||||||
+41
-1
@@ -38,8 +38,9 @@ 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, adb_connect_light,
|
from core.adb_helper import (screenshot, list_installed_apps, adb_connect_light,
|
||||||
adb_connect, _ADB_LOCK, _adb)
|
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.tailscale_client import TailscaleError
|
||||||
|
from core.stf_device_mgmt import StfDevError
|
||||||
from config import ADB_PATH, STF_SSH_TARGET, STF_DOCKER_CONTAINER
|
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
|
||||||
|
|
||||||
@@ -992,6 +993,45 @@ def _set_device_clipboard(serial, text):
|
|||||||
return False, f"{type(e).__name__}: {str(e)[:120]}"
|
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"])
|
@app.route("/api/stf/restart", methods=["POST"])
|
||||||
@admin_required
|
@admin_required
|
||||||
def api_stf_restart():
|
def api_stf_restart():
|
||||||
|
|||||||
Reference in New Issue
Block a user