阶段3: 摘除 STF——删除 stf_client/stf_device_mgmt 与全部引用(STF 容器重启/设备管理/强制释放占用/agent 按钮/占用列),调度与设备管理完全基于本地设备池+adb;错误类迁入 device_worker,create_worker 去 stf 参数

This commit is contained in:
2026-08-18 08:08:32 +08:00
parent 98244d8161
commit a5ce57b6fd
17 changed files with 71 additions and 956 deletions
+3 -15
View File
@@ -29,8 +29,7 @@ def _env(key, default):
return os.environ.get(key, default)
# ================== STF 配置 ==================
# 部署相关配置统一从 .env 读取(模板见 .env.example),config.py 不再内置具体值
# ================== STF 配置(已废弃,阶段 3 摘除 STF 后仅保留供历史参考) ==================
STF_URL = _env("STF_URL", "")
STF_TOKEN = _env("STF_TOKEN", "") # 写入 .env,不要提交到 git
@@ -61,19 +60,8 @@ DATA_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data")
# APK 文件存储目录(应用管理功能)
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")
# ================== SSH 到部署机(维护页重启 STF + 工具页 STF 设备管理) ==================
# 通过 SSH 到部署机执行 docker 命令/改脚本。配置从 .env 读取:
# STF_SSH_TARGET SSH 目标(user@host)
# STF_SSH_PASSWORD SSH 密码(配置后 paramiko 纯密码登录,跨平台;
# 留空则退回系统 ssh 免密密钥 BatchMode=yes)
# STF_DOCKER_CONTAINER STF Docker 容器名
# STF_ADB_CONTAINER 220 上 adb Docker 容器名(STF 设备管理执行 adb 用)
# STF_SCRIPT_PATH STF 设备池脚本路径(增删设备读写其 DEVICES 列表)
# ================== SSH 到部署机(已废弃,STF 相关操作退役) ==================
# 阶段 3 后代码不再使用 SSH;保留配置仅供手动运维 220(如 docker stop stf)。
STF_SSH_TARGET = _env("STF_SSH_TARGET", "")
STF_SSH_PASSWORD = _env("STF_SSH_PASSWORD", "")
STF_DOCKER_CONTAINER = _env("STF_DOCKER_CONTAINER", "")
-1
View File
@@ -2,7 +2,6 @@
config — 全局配置常量(STF、adb 路径、养号参数)
adb_helper — adb 命令封装(并发安全)
stf_client — OpenSTF REST API 客户端
device_worker — 设备生命周期 + 养号 worker + 全局状态注册表
task_manager — 任务管理框架(分组/计划/调度/重试/持久化)
"""
+3 -8
View File
@@ -29,7 +29,6 @@ from concurrent.futures import ThreadPoolExecutor, as_completed
from config import APK_DIR, ADB_PATH
from core.logger import get_logger
from core.models import db, ApkFile as ApkRow
from .stf_client import STFClient
from .device_worker import get_all_worker_status
_log = get_logger("core.apk")
@@ -43,8 +42,7 @@ _INSTALL_CONCURRENCY = 5
class ApkManager:
"""APK 文件管理 + 批量安装。"""
def __init__(self, stf_client=None, app=None):
self.stf = stf_client or STFClient()
def __init__(self, app=None):
self.app = app # Flask app,用于 db context
os.makedirs(APK_DIR, exist_ok=True)
self._lock = threading.Lock()
@@ -194,11 +192,8 @@ class ApkManager:
if not serials:
return False, "未选择设备"
# 获取设备信息(型号等)
try:
all_devices = {d["serial"]: d for d in self.stf.list_all_devices()}
except Exception:
all_devices = {}
# 设备信息(型号从 worker 状态取,池内无型号信息)
all_devices = {}
# 获取 worker 状态(判断设备是否运行中)
worker_status = {w["serial"]: w for w in get_all_worker_status()}
+5 -51
View File
@@ -1,22 +1,17 @@
"""设备池(STF 替代数据源):SQLite devices 表 = 设备清单,本地 adb = 在线状态。
阶段 0:纯新增模块,不改现有行为。task_manager / web_server 的 STF 调用
在阶段 1 逐个切换到本模块,阶段 3 摘除 STF 后删除 seed 相关代码。
"""设备池:SQLite devices 表 = 设备清单,本地 adb = 在线状态(STF 替代数据源)。
设计:
- list_configured() — devices 表里 enabled 的设备(清单,管理页维护)
- list_online() — 本机 `adb devices` 里 state=device 的设备(实时)
- list_ready() — configured ∩ online(调度用,取代 STF list_free_devices)
- list_online() — 本机 `adb devices` 里 state=device 的设备(实时;
池内有 USB 设备时合并 220 远程 adb server 状态)
- list_ready() — configured ∩ online(任务调度用)
- CRUD — add/update/remove/set_enabled(设备池管理页用)
- 首次启动自动从 STF 导入现有设备(一次性,app_meta 标记防重复)
红线:全模块不 connect / 不 kill-server / 不 disconnect,遵守既有技术约束。
后台线程(task_manager 等)调用时由本模块自行推 app context,调用方无需关心。
"""
import time
from sqlalchemy import text
from config import USB_ADB_HOST, USB_ADB_PORT
from core.adb_helper import _adb, _adb_remote
from core.logger import get_logger
@@ -28,13 +23,9 @@ _app = None
def init_app(app):
"""web_server 启动时调用:绑定 Flask app,并做首次设备导入。"""
"""web_server 启动时调用:绑定 Flask app(供后台线程推 db context)。"""
global _app
_app = app
try:
_seed_from_stf_if_empty()
except Exception as e:
_log.warning(f"设备池初始化异常(不影响启动): {e}")
def _ctx():
@@ -142,40 +133,3 @@ def set_enabled(serial, enabled):
d.enabled = enabled
db.session.commit()
return True
# ================== 首次导入(阶段 3 摘除 STF 后删除) ==================
def _seed_from_stf_if_empty():
"""首次启动把 STF 现有设备导入本地清单。
条件:device 表为空 且 未导入过(app_meta 标记)。STF 不可达时跳过,
不写标记,下次启动重试。摘除 STF 后此函数随 stf_client 一起删除。
"""
try:
from core.stf_client import STFClient
except Exception:
return
with _ctx():
if Device.query.count() > 0:
return
seeded = db.session.execute(text(
"SELECT value FROM app_meta WHERE key='devices_seeded_from_stf'")).scalar()
if seeded:
return
try:
# 导入全部 serial(不限于 present):STF 池 = 配置的舰队,
# present=false 的是掉线/陈旧记录,导入后显示离线,管理页可删
serials = [d["serial"] for d in STFClient().list_all_devices()]
except Exception as e:
_log.warning(f"从 STF 导入设备清单失败(可稍后手动添加): {e}")
return
if not serials:
_log.warning("STF 无在线设备,跳过设备池首次导入")
return
now = time.strftime("%Y-%m-%d %H:%M")
for s in serials:
db.session.add(Device(serial=s, created_at=now))
db.session.execute(text(
"INSERT OR REPLACE INTO app_meta(key,value) VALUES('devices_seeded_from_stf','1')"))
db.session.commit()
_log.info(f"设备池首次导入 {len(serials)} 台设备(来源 STF)")
+19 -9
View File
@@ -18,12 +18,23 @@ import uiautomator2 as u2
from config import USB_ADB_HOST, USB_ADB_PORT
from core.logger import get_logger
from core.stf_client import DeviceOfflineError, STFError
from core import device_pool
from .adb_helper import adb_connect, _adb_remote
_log = get_logger("core.worker")
class STFError(Exception):
"""设备操作错误(历史名称保留兼容)。code: offline/conflict/..."""
def __init__(self, message, code=""):
super().__init__(message)
self.code = code
class DeviceOfflineError(STFError):
"""设备离线/不可用:任务不重试(换设备也没用)。"""
# 心跳超时阈值(秒)。worker 超过这个时间没更新心跳,判定为卡死
_HEARTBEAT_TIMEOUT = 120
# 看门狗检查间隔
@@ -201,11 +212,11 @@ def stop_watchdog():
class BaseWorker(threading.Thread):
"""通用 worker 基类。
自动处理:STF 占用/释放、u2 连接、状态上报、异常捕获、stop 信号、心跳。
自动处理:设备获取、u2 连接、状态上报、异常捕获、stop 信号、心跳。
子类只需实现 run_task(d) 方法,专注业务逻辑。
生命周期(基类 run() 已封装,不要重写):
1. acquire 设备(STF 占用 + adb 连接)
1. acquire 设备(直连 IP:5555 / USB 走远程 adb server)
2. u2.connect 拿到 d
3. 调用 setup(d) ← 子类可选钩子,做初始化
4. 调用子类 run_task(d) ← 业务逻辑
@@ -246,9 +257,8 @@ class BaseWorker(threading.Thread):
其他异常 — 可重试
"""
def __init__(self, stf_client, serial, params=None, daemon=True):
def __init__(self, serial, params=None, daemon=True):
super().__init__(daemon=daemon)
self.stf = stf_client
self.serial = serial
self.params = params or {}
self._stop_flag = threading.Event()
@@ -347,7 +357,7 @@ class BaseWorker(threading.Thread):
def run(self):
"""基类主循环:不要重写。子类实现 run_task + 可选钩子。"""
device = STFDevice(serial=self.serial)
_update_status(self.serial, status="connecting", stf_occupied=False,
_update_status(self.serial, status="connecting",
last_error="", remote_adb_url="", usb_server="", model="",
current_action="")
try:
@@ -370,7 +380,7 @@ class BaseWorker(threading.Thread):
model = info.get("productName") or ""
except Exception:
pass
_update_status(self.serial, status="running", stf_occupied=True,
_update_status(self.serial, status="running",
remote_adb_url=remote or "", usb_server=usb_server or "",
model=model)
@@ -425,9 +435,9 @@ class BaseWorker(threading.Thread):
with _WORKERS_LOCK:
cur = _WORKERS.get(self.serial, {}).get("status")
if cur in ("running", "connecting"):
_update_status(self.serial, stf_occupied=False, status="released")
_update_status(self.serial, status="released")
else:
_update_status(self.serial, stf_occupied=False)
pass
def _u2_connect_with_timeout(self, remote):
"""u2.connect 带超时保护,避免 atx-agent 无响应时永久 hang。"""
-247
View File
@@ -1,247 +0,0 @@
"""OpenSTF REST API 客户端封装。
负责:设备列表查询、占用、远程 ADB 隧道建立、释放。
不负责实际 UI 操作(那是 DeviceWorker 的事)。
健壮性设计:
- 所有请求带 timeout,避免 STF 网关 504 时长时间卡死
- remoteConnect 带重试,STF provider 偶尔慢启动
- 友好错误分类:设备离线 / 网络不通 / STF 内部错误,便于上层决策
- 占用冲突自动重试(STF 偶发 400 "device already in use")
"""
import time
import requests
from config import STF_URL, STF_TOKEN
from core.logger import get_logger
_log = get_logger("core.stf")
# 请求超时(秒)。connect 超时 + read 超时
_TIMEOUT = (5, 15)
# remoteConnect 重试次数(STF provider 慢启动时需要重试)
_RC_RETRIES = 3
# remoteConnect 重试间隔
_RC_DELAY = 2
class STFError(Exception):
"""STF 相关错误基类。"""
def __init__(self, message, code=""):
super().__init__(message)
self.code = code # 错误码:offline / network / conflict / server / unknown
class DeviceOfflineError(STFError):
"""设备离线/不可用。"""
class STFNetworkError(STFError):
"""STF 服务不可达。"""
class DeviceConflictError(STFError):
"""设备已被占用。"""
def _headers():
return {"Authorization": f"Bearer {STF_TOKEN}"}
class STFClient:
"""OpenSTF REST API 封装。"""
def __init__(self, base_url=STF_URL, token=STF_TOKEN):
self.base_url = base_url
self.headers = _headers()
# ================== 设备查询 ==================
def list_all_devices(self):
"""返回 STF 上所有设备(含状态)。"""
try:
resp = requests.get(f"{self.base_url}/api/v1/devices",
headers=self.headers, timeout=_TIMEOUT)
resp.raise_for_status()
return resp.json().get("devices", [])
except requests.exceptions.ConnectionError as e:
raise STFNetworkError(f"STF 服务不可达: {e}", "network") from e
except requests.exceptions.Timeout as e:
raise STFNetworkError(f"STF 请求超时: {e}", "network") from e
except requests.exceptions.RequestException as e:
_log.error(f"获取设备列表失败: {e}")
raise STFError(f"获取设备列表失败: {e}", "server") from e
def list_free_devices(self):
"""返回可占用的空闲设备。"""
return [d for d in self.list_all_devices()
if d.get("present") and d.get("ready")
and not d.get("using") and d.get("owner") is None]
def list_my_devices(self):
"""返回当前账户已占用的设备。"""
try:
resp = requests.get(f"{self.base_url}/api/v1/user/devices",
headers=self.headers, timeout=_TIMEOUT)
resp.raise_for_status()
return resp.json().get("devices", [])
except requests.exceptions.RequestException as e:
_log.error(f"获取已占用设备失败: {e}")
return []
# ================== 占用/释放 ==================
def occupy(self, serial, retries=2):
"""占用设备。返回 (ok, msg)。
冲突(已被占用)时自动重试,偶发 400 "already in use" 可能是脏状态。
"""
for attempt in range(1, retries + 1):
try:
r = requests.post(f"{self.base_url}/api/v1/user/devices/{serial}",
headers=self.headers, timeout=_TIMEOUT)
if r.status_code in (200, 201):
return True, r.text[:200]
if r.status_code == 400 and "already" in r.text.lower():
# 已被占用(可能是自己之前占用没释放干净),尝试先释放再占
_log.warning(f"{serial} 占用冲突,尝试清理后重试 ({attempt}/{retries})")
try:
requests.delete(f"{self.base_url}/api/v1/user/devices/{serial}",
headers=self.headers, timeout=_TIMEOUT)
except Exception:
pass
time.sleep(1)
continue
return False, f"HTTP {r.status_code}: {r.text[:200]}"
except requests.exceptions.RequestException as e:
_log.warning(f"{serial} 占用请求异常 ({attempt}/{retries}): {e}")
if attempt < retries:
time.sleep(1)
return False, str(e)
return False, "占用冲突重试耗尽"
def release(self, serial, retries=3):
"""释放设备占用。返回 (ok, msg)。
STF 释放可能因设备响应超时返回 504(如设备过载/卡死),此时设备实际未释放。
必须检查响应状态并重试,不能静默吞掉——否则占用会一直悬着。
"""
self.remote_disconnect(serial)
last_status = None
last_body = ""
for attempt in range(1, retries + 1):
try:
r = requests.delete(f"{self.base_url}/api/v1/user/devices/{serial}",
headers=self.headers, timeout=_TIMEOUT)
if r.status_code in (200, 201, 202, 204):
return True, "released"
last_status = r.status_code
last_body = r.text[:150]
_log.warning(f"释放 {serial} 失败 HTTP {r.status_code}: {last_body} ({attempt}/{retries})")
except requests.exceptions.RequestException as e:
_log.warning(f"释放 {serial} 请求异常 ({attempt}/{retries}): {e}")
last_status = -1
last_body = str(e)
if attempt < retries:
time.sleep(2)
return False, f"STF 释放失败(HTTP {last_status}): {last_body}"
def release_all_mine(self):
"""释放当前账户占用的所有设备(清理用)。返回 (released, failed)。"""
released, failed = [], []
for d in self.list_my_devices():
serial = d["serial"]
ok, msg = self.release(serial)
if ok:
released.append(serial)
_log.info(f"已释放 {serial}")
else:
failed.append(serial)
_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。
STF provider 慢启动或设备掉线时会 504,这里带重试。
设备真离线时快速失败,不长时间卡住 worker。
"""
last_err = None
for attempt in range(1, _RC_RETRIES + 1):
try:
conn = requests.post(
f"{self.base_url}/api/v1/user/devices/{serial}/remoteConnect",
headers=self.headers, timeout=_TIMEOUT,
)
# 504 = STF 网关等 provider 响应超时,通常是设备掉线或 provider 卡死
if conn.status_code == 504:
last_err = f"STF 网关超时(504),设备可能掉线"
_log.warning(f"{serial} remoteConnect 504 ({attempt}/{_RC_RETRIES})")
if attempt < _RC_RETRIES:
time.sleep(_RC_DELAY)
continue
raise DeviceOfflineError(
f"{serial} remoteConnect 超时,设备可能掉线或 provider 卡死",
"offline",
)
conn.raise_for_status()
data = conn.json()
if not data.get("success"):
raise STFError(f"远程连接失败: {data.get('description')}", "server")
url = (data.get("remoteConnectUrl") or data.get("remoteAdbUrl")
or data.get("remote_adb_url") or data.get("adbUrl")
or data.get("url"))
if not url:
raise STFError("STF 返回成功但无 remoteConnectUrl", "server")
return url
except DeviceOfflineError:
raise
except requests.exceptions.ConnectionError as e:
last_err = str(e)
_log.warning(f"{serial} remoteConnect 网络异常 ({attempt}/{_RC_RETRIES}): {e}")
if attempt < _RC_RETRIES:
time.sleep(_RC_DELAY)
except requests.exceptions.Timeout as e:
last_err = str(e)
_log.warning(f"{serial} remoteConnect 超时 ({attempt}/{_RC_RETRIES}): {e}")
if attempt < _RC_RETRIES:
time.sleep(_RC_DELAY)
except requests.exceptions.RequestException as e:
last_err = str(e)
_log.error(f"{serial} remoteConnect 请求异常: {e}")
raise STFError(f"远程连接请求失败: {e}", "server") from e
# 重试耗尽
raise DeviceOfflineError(
f"{serial} remoteConnect 重试 {_RC_RETRIES} 次失败: {last_err}",
"offline",
)
def remote_disconnect(self, serial):
"""断开远程 ADB 隧道。"""
try:
requests.post(
f"{self.base_url}/api/v1/user/devices/{serial}/remoteDisconnect",
headers=self.headers, timeout=_TIMEOUT,
)
except requests.exceptions.RequestException:
pass # 断开失败不影响主流程
-158
View File
@@ -1,158 +0,0 @@
"""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
from config import STF_ADB_CONTAINER, STF_SCRIPT_PATH
from core.logger import get_logger
from core.ssh_client import run as _ssh_run, SSHError
_log = get_logger("core.stfdev")
class StfDevError(Exception):
"""STF 设备管理错误(SSH/脚本/命令失败)。"""
def _ssh(cmd, timeout=25):
"""在 220 上执行一条 shell 命令(认证见 core.ssh_client)。返回 (code, stdout, stderr)。"""
try:
return _ssh_run(cmd, timeout=timeout)
except SSHError as e:
raise StfDevError(str(e))
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
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 分钟),"
"稍后点\"刷新\"查看设备连接状态")
+6 -8
View File
@@ -31,9 +31,9 @@ from config import DATA_DIR
from core.logger import get_logger
from core.models import db, DeviceGroup as GroupRow, TaskJob as JobRow
from core import device_pool
from .stf_client import STFClient, DeviceOfflineError
from .adb_helper import get_foreground_app, get_foreground_app_remote
from .device_worker import (
DeviceOfflineError,
get_all_worker_status, _update_status, _remove_worker,
_WORKERS, _WORKERS_LOCK,
start_watchdog, stop_watchdog,
@@ -195,9 +195,9 @@ class _ForegroundScanner:
策略(按设备状态区分获取逻辑):
- worker 运行中设备:用已有 remote_adb_url 直接查询(已有 adb 连接,无额外开销)
- 空闲设备:直接 adb connect <serial> → 查询 → adb disconnect(绕过 STF,不打扰设备)
- 被别人占用的设备:标记 "他人占用"
- STF occupy/release 会打扰设备(可能退回桌面),绝不使用
- USB 运行中设备:经远程 adb server(220)查询
- 空闲设备:不主动连接(返回"空闲")——IP:5555 的 adb transport 与 220
共享,外部 connect/disconnect 会扰动共享连接
serial 格式为 IP:5555(设备本身的 adb 网络地址),可直接 adb connect。
adb connect/disconnect 只建立/断开调试连接,不影响设备 UI。
@@ -334,8 +334,7 @@ class _ForegroundScanner:
# ================== 任务管理器 ==================
class TaskManager:
def __init__(self, stf_client=None, app=None):
self.stf = stf_client or STFClient()
def __init__(self, app=None):
self.app = app # Flask app,用于 db context
self.scheduler = BackgroundScheduler(timezone="Asia/Shanghai")
self.scheduler.start()
@@ -683,7 +682,7 @@ class TaskManager:
worker = None
try:
worker = task.create_worker(self.stf, serial, job.params)
worker = task.create_worker(serial, job.params)
with self._lock:
self._running[serial]["worker"] = worker
_log.info(f"{serial} 开始任务 {job.name} (第{attempt}/{max_attempts}次)")
@@ -850,7 +849,6 @@ class TaskManager:
"device_name": names.get(serial, ""),
"present": is_online,
"ready": is_online, # 阶段 1:ready 概念并入在线状态
"stf_occupied": False, # 阶段 1:已无 STF 占用(阶段 3 删字段)
"owner": "",
"worker_status": w.get("status", "idle"),
"foreground_app": self._fg_scanner.get(serial),
+1 -1
View File
@@ -152,7 +152,7 @@ async function openInstallModal(apkId){
const r=await apiGet('/api/apks/install/devices');
const online=(r&&r.ok)?(r.devices||[]):[];
if(!online.length){
showToast('没有可用设备(STF 池或本机 adb 均无)','error');return;
showToast('没有可用设备(设备池或本机 adb 均无)','error');return;
}
const body=
'<div style="margin-bottom:12px">'+
+1 -1
View File
@@ -1055,7 +1055,7 @@ async function saveTask(jobId){
catch(e){showToast('参数 JSON 格式错误','error');return;}
}
// 离线自动跳过(serial/group 模式):任务触发时跳过 STF 池中不在线的设备
// 离线自动跳过(serial/group 模式):任务触发时跳过设备池中不在线的设备
const skipEl=document.getElementById('f-skip_offline');
params.skip_offline = skipEl?skipEl.checked:true;
// 抢占设备:触发时停止目标设备上的其他任务,接管执行
+1 -6
View File
@@ -9,8 +9,6 @@ function _sortValue(dev,key){
if(key==='progress'){
const p=dev.progress||{};
v=(p.done||0)+(((p.total||0)>0?(p.done||0)/(p.total):0)*10000);
}else if(key==='stf_occupied'){
v=dev.stf_occupied?1:0;
}else if(key==='worker_status'){
// 按状态优先级排序:running>connecting>idle>done>error>released
const order={running:0,connecting:1,idle:2,done:3,released:4,error:5,failed:6};
@@ -231,14 +229,12 @@ async function loadMonitor(){
const running=devices.filter(x=>x.worker_status==='running').length;
const idle=devices.filter(x=>x.worker_status==='idle').length;
const error=devices.filter(x=>['error','failed'].includes(x.worker_status)).length;
const occupied=devices.filter(x=>x.stf_occupied).length;
const total=devices.length;
const online=devices.filter(x=>x.present).length;
const cards=[
{n:total,l:'设备总数',c:'#3b82f6'},
{n:online,l:'在线',c:'#10b981'},
{n:running,l:'运行中',c:'#10b981'},
{n:occupied,l:'STF占用',c:'#f59e0b'},
{n:idle,l:'空闲',c:'#94a3b8'},
{n:error,l:'异常',c:'#ef4444'},
];
@@ -318,7 +314,6 @@ function renderDevices(){
initListSearch('devices','search-devices');
}
function renderDeviceRow(dev){
const stf=dev.stf_occupied?'<span class="label label-success">占用</span>':'<span class="label label-default">空闲</span>';
const offline=dev.present?'':' <span class="label label-danger">离线</span>';
const st=STATUS_MAP[dev.worker_status]||{t:dev.worker_status,c:'default'};
const wb='<span class="label label-'+st.c+'">'+st.t+'</span>';
@@ -355,7 +350,7 @@ function renderDeviceRow(dev){
const checked=_selectedSerials.has(dev.serial)?'checked':'';
const rowCls=_selectedSerials.has(dev.serial)?'row-checked':'';
const cb='<input type="checkbox" '+checked+' onclick="toggleDevice(\''+esc(dev.serial)+'\',this.checked)">';
return '<tr class="'+rowCls+'"><td class="col-cb">'+cb+'</td><td>'+devLabel+'</td><td>'+esc(dev.model)+'</td><td>'+stf+offline+'</td><td>'+wb+'</td><td>'+fg+'</td><td>'+prog+'</td><td>'+tname+retry+endInfo+'</td><td class="text-muted">'+esc(dev.current_action||'-')+warnInfo+'</td><td class="err-text" title="'+esc(dev.last_error||'')+'">'+esc(dev.last_error||'-')+'</td><td>'+btns+'</td></tr>';
return '<tr class="'+rowCls+'"><td class="col-cb">'+cb+'</td><td>'+devLabel+'</td><td>'+esc(dev.model)+'</td><td>'+offline+'</td><td>'+wb+'</td><td>'+fg+'</td><td>'+prog+'</td><td>'+tname+retry+endInfo+'</td><td class="text-muted">'+esc(dev.current_action||'-')+warnInfo+'</td><td class="err-text" title="'+esc(dev.last_error||'')+'">'+esc(dev.last_error||'-')+'</td><td>'+btns+'</td></tr>';
}
async function loadJobsForMonitor(){
+8 -152
View File
@@ -1,6 +1,6 @@
// 工具 Tab:剪贴板注入/应用版本/STF设备管理 + 维护(重启STF) + adb终端 + Tailscale管理
// 工具 Tab:剪贴板注入/应用版本/设备池管理 + adb终端 + Tailscale管理 + 远程看屏
// ================== 工具:剪贴板注入 ==================
let _clipDevices = []; // [{serial, state}],来源 /api/adb/devices(本地 adb + STF 池)
let _clipDevices = []; // [{serial, state}],来源 /api/adb/devices(本地 adb + 设备池)
let _clipSelected = new Set();
// 本地 adb 设备列表共享缓存(剪贴板/终端两个面板同源,10 秒内复用避免重复请求;force 强制刷新)
@@ -24,13 +24,13 @@ async function loadToolsDevices(force){
_clipDevices = list;
const wrap = document.getElementById('clip-devices');
if(!_clipDevices.length){
wrap.innerHTML='<div class="empty" style="padding:20px">无可用设备(本地 adb 或 STF 池均无)</div>';
wrap.innerHTML='<div class="empty" style="padding:20px">无可用设备(本地 adb 或设备池均无)</div>';
status.textContent='';
return;
}
wrap.innerHTML = _clipDevices.map(d=>{
const st = d.state==='stf' ? 'STF池' : (d.state==='device' ? '已连接' : (d.state==='stf_not_ready' ? 'STF未就绪' : d.state));
const stCls = (d.state==='device'||d.state==='stf') ? 'label-success' : 'label-warning';
const st = d.state==='device' ? '已连接' : (d.state==='pool' ? '设备池' : d.state);
const stCls = d.state==='device' ? 'label-success' : 'label-warning';
return '<label data-serial="'+esc(d.serial)+'">'
+'<input type="checkbox" value="'+esc(d.serial)+'"'+( _clipSelected.has(d.serial)?' checked':'')+' onchange="toggleClipDevice(this)">'
+'<span style="font-family:monospace;flex:1;overflow:hidden;text-overflow:ellipsis">'+esc(d.serial)+'</span>'
@@ -87,119 +87,6 @@ function renderAppVerRow(x){
'<td><span class="label label-default">未安装</span></td><td>-</td><td>-</td></tr>';
}
// ================== 工具:STF 设备管理 ==================
let _stfDevRows = []; // [{ip, inCfg, state}]
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;
});
_stfDevRows = Array.from(new Set(configured.concat(Object.keys(connected)))).map(ip=>({
ip, inCfg: configured.includes(ip), state: connected[ip]||''
}));
setListPager('stfdev',{
data:()=>_stfDevRows,
filter:(x,kw)=>x.ip.toLowerCase().includes(kw),
sortValue:(x,k)=>{
if(k==='cfg')return x.inCfg?1:0;
if(k==='state'){ const o={device:0,offline:1,unauthorized:2,'':9}; return (o[x.state]!==undefined)?o[x.state]:9; }
return x.ip||'';
},
render:renderStfDevRow,
tbody:'tb-stfdev',pager:'pager-stfdev',pageSize:10,
empty:'<tr><td colspan="4" class="empty">无设备(脚本未配置、220 adb 也无连接)</td></tr>'
});
applyListPager('stfdev');
initListSearch('stfdev','search-stfdev');
status.textContent = '脚本配置 '+configured.length+' 台,已连接 '+Object.keys(connected).length+' 台';
}
function renderStfDevRow(x){
const stHtml = x.state==='device'
? '<span class="label label-success">已连接</span>'
: (x.state ? '<span class="label label-warning">'+esc(x.state)+'</span>' : '<span class="label label-default">未连接</span>');
return '<tr><td style="font-family:monospace">'+esc(x.ip)+'</td>'+
'<td>'+(x.inCfg?'<span class="label label-info">脚本中</span>':'<span class="label label-default">未配置</span>')+'</td>'+
'<td>'+stHtml+'</td>'+
'<td style="white-space:nowrap">'+
'<button class="btn btn-xs btn-danger" onclick="removeStfDev(\''+esc(x.ip)+'\')" title="断开+删脚本">移除</button> '+
'<button class="btn btn-xs btn-danger" onclick="deleteStfDev(\''+esc(x.ip)+'\')" title="断开+删脚本+清 STF 池记录(设备从池中彻底消失)">彻底删除</button>'+
'</td></tr>';
}
// 一键重连:手动运行 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(){
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;}
if(!_clipSelected.size){showToast('请先勾选目标设备','error');return;}
const btn = document.querySelector('#tools-sub-clipboard .btn-primary');
btn.disabled = true; const old = btn.textContent; btn.textContent = '注入中...';
try{
const r = await apiPost('/api/tools/clipboard/set', {serials:[..._clipSelected], text});
if(!r||!r.ok){showToast('注入失败: '+(r&&r.error||''),'error');return;}
const rows = Object.entries(r.results).map(([s,v])=>
'<tr><td style="font-family:monospace">'+esc(s)+'</td><td>'+(v.ok?'<span class="label label-success">成功</span>':'<span class="label label-danger">失败</span> '+esc(v.msg))+'</td></tr>').join('');
document.getElementById('clip-result').innerHTML = rows;
document.getElementById('clip-result-wrap').style.display = 'block';
showToast('注入完成:成功 '+r.ok_count+' 台,失败 '+r.fail_count+' 台', r.fail_count?'error':'success');
} finally {
btn.disabled = false; btn.textContent = old;
}
}
// ================== Tab: 维护(仅管理员) ==================
// 服务器类命令(不需要 -s 目标设备)
const _NO_DEVICE_CMDS=['devices','version','help','start-server','connect','kill-server','disconnect'];
@@ -222,7 +109,7 @@ async function loadAdbDevices(force){
const list=await _fetchAdbDevices(force);
if(list===null)return;
const cur=sel.value;
const stateTxt={device:'在线',offline:'离线',stf:'STF池(未连接)',stf_not_ready:'STF未就绪'};
const stateTxt={device:'在线',offline:'离线',pool:'设备池(未连接)'};
const opts=['<option value="">(不指定设备)</option>'].concat(
list.map(d=>
'<option value="'+esc(d.serial)+'">'+esc(d.serial)+' ('+(stateTxt[d.state]||d.state)+')</option>'
@@ -251,21 +138,6 @@ async function runAdbCmd(){
cmd=_attachDevice(cmd);
await _runAdbFull('adb '+cmd);
}
// 修复类命令:目标为 IP:port 时先 adb connect(已连接自动跳过),
// 否则设备不在本机 adb server 里会报 "device not found"(STF 未就绪的设备尤其如此)。
async function _runAdbRepair(full){
const serial=_selectedSerial();
if(serial && serial.indexOf(':')>=0){
await _runAdbFull('adb connect '+serial);
}
await _runAdbFull(full);
}
function uninstallStfAgent(){
const serial=_selectedSerial();
if(!serial){showToast('请先选择目标设备','error');return;}
if(!confirm('确定卸载 '+serial+' 上的 STF agent(jp.co.cyberagent.stf)?\n\n卸载后 STF 会在设备重连时自动重新推送安装。'))return;
_runAdbRepair('adb -s '+serial+' uninstall jp.co.cyberagent.stf');
}
function reconnectAdbDevice(){
const serial=_selectedSerial();
if(!serial){showToast('请先选择目标设备','error');return;}
@@ -276,22 +148,6 @@ function checkStfAgent(){
if(!serial){showToast('请先选择目标设备','error');return;}
_runAdbRepair('adb -s '+serial+' shell pm list packages | grep cyberagent');
}
async function restartStf(){
if(!confirm('确定重启 STF 容器?\n\n影响:STF 约 30 秒不可用,期间所有设备占用状态重置,正在执行的任务会受影响。\n\n确认继续吗?'))return;
const st=document.getElementById('stf-restart-status');
st.textContent='正在重启 STF 容器(SSH 执行中,最长 60s)...';
const r=await apiPost('/api/stf/restart',{});
if(!r)return;
if(r.ok){st.textContent='✅ '+r.msg+(r.detail?' ['+r.detail+']':'');showToast(r.msg,'success');}
else{st.textContent='❌ '+r.error;showToast(r.error,'error');}
}
// 测试此步骤弹窗关闭
function closeTestModal(){
const ov=document.getElementById('test-overlay');
if(ov)ov.classList.remove('show');
}
// ================== Tab: 维护 - Tailscale 管理 ==================
// 与其他表格共用 setListPager/applyListPager 统一逻辑(搜索 + 分页)
let _tsDevices=[];
@@ -358,7 +214,7 @@ function renameTailscaleDevice(id, current){
});
}
function setTsIp(id, current){
const ip=prompt('设置新的 Tailscale IPv4 地址(如 100.100.10.16):\n\n⚠ 改 IP 会断开设备当前 tailscale 连接,且平台设备池(STF serial)随之变化,改完记得同步 STF 设备池/分组/任务目标。', (current||'').split(':')[0]||'');
const ip=prompt('设置新的 Tailscale IPv4 地址(如 100.100.10.16):\n\n⚠ 改 IP 会断开设备当前 tailscale 连接,且平台设备池(serial)随之变化,改完记得同步设备池/分组/任务目标。', (current||'').split(':')[0]||'');
if(ip===null)return;
const v=ip.trim();
if(!v){showToast('IP 不能为空','error');return;}
@@ -435,7 +291,7 @@ async function loadScreenDevices(force){
const list=await _fetchAdbDevices(force);
if(list===null)return;
const cur=sel.value;
const stateTxt={device:'在线',offline:'离线',pool:'设备池(未连接)',stf_not_ready:'STF未就绪'};
const stateTxt={device:'在线',offline:'离线',pool:'设备池(未连接)'};
sel.innerHTML=list.map(d=>
'<option value="'+esc(d.serial)+'">'+esc(d.serial)+' ('+(stateTxt[d.state]||d.state)+')</option>'
).join('');
+3 -3
View File
@@ -49,8 +49,8 @@
from .actions import get_action_class
return get_action_class(action_type)
def create_worker(self, stf, serial, params):
return MyWorker(stf, serial, params=params)
def create_worker(self, serial, params):
return MyWorker(serial, params=params)
5. 在 my_task/__init__.py 加:from . import task (触发注册)
6. 在 tasks/__init__.py 加:from .my_task import task (触发注册)
@@ -85,7 +85,7 @@ class BaseTask:
description = ""
default_params = {}
def create_worker(self, stf, serial, params):
def create_worker(self, serial, params):
"""返回一个 threading.Thread(已启动或待启动),执行实际任务。"""
raise NotImplementedError
+4 -4
View File
@@ -52,8 +52,8 @@ class DouyinNurtureWorker(BaseWorker):
操作参数优先级:任务 params.actions > 本文件 DEFAULT_PARAMS.actions。
"""
def __init__(self, stf_client, serial, params=None):
super().__init__(stf_client, serial, params)
def __init__(self, serial, params=None):
super().__init__(serial, params)
p = {**DEFAULT_PARAMS, **(self.params or {})}
self.watch_count = int(p["watch_count"])
self.watch_min = float(p["watch_min"])
@@ -244,7 +244,7 @@ class DouyinNurtureTask(BaseTask):
"""按 action_type 取抖音操作类。"""
return get_action_class(action_type)
def create_worker(self, stf, serial, params):
def create_worker(self, serial, params):
merged = {**DEFAULT_PARAMS, **(params or {})}
# actions 字段深合并(参数级,保留前端没传的操作默认值)
# 确保即使前端只传 actions.like.enabled,like.params.rate 也能拿到默认 0.3
@@ -266,4 +266,4 @@ class DouyinNurtureTask(BaseTask):
merged_cfg["params"] = merged_params
merged_actions[atype] = merged_cfg
merged["actions"] = merged_actions
return DouyinNurtureWorker(stf, serial, params=merged)
return DouyinNurtureWorker(serial, params=merged)
+4 -4
View File
@@ -110,8 +110,8 @@ class GenericStepsWorker(BaseWorker):
只实现 run_task,按步骤类型分发到 _exec_<type> 方法。
"""
def __init__(self, stf_client, serial, params=None):
super().__init__(stf_client, serial, params)
def __init__(self, serial, params=None):
super().__init__(serial, params)
p = {**DEFAULT_PARAMS, **(self.params or {})}
self.max_duration = int(p.get("max_duration", 0))
self.steps = p.get("steps", [])
@@ -614,9 +614,9 @@ class GenericStepsTask(BaseTask):
def get_action_class(cls, action_type):
return None
def create_worker(self, stf, serial, params):
def create_worker(self, serial, params):
merged = {**DEFAULT_PARAMS, **(params or {})}
return GenericStepsWorker(stf, serial, params=merged)
return GenericStepsWorker(serial, params=merged)
def test_step(serial, step):
+4 -52
View File
@@ -312,7 +312,6 @@ select.form-control{cursor:pointer}
<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="停止所有勾选的运行中设备" data-perm="devices">停止选中</button>
<button class="btn btn-danger" onclick="clearAllErrors()" title="清除所有异常/失败设备的错误状态(运行中/等待重试的跳过)" data-perm="devices">清除全部异常</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="screenAllDevices('on')" data-perm="devices" title="所有在线设备亮屏并解锁">一键亮屏</button>
<button class="btn" onclick="screenAllDevices('off')" data-perm="devices" title="所有在线设备息屏(运行中任务会中断)">一键息屏</button>
@@ -325,7 +324,6 @@ select.form-control{cursor:pointer}
<th class="col-cb"><input type="checkbox" id="cb-all" onclick="toggleAllDevices(this.checked)" title="全选/取消全选"></th>
<th data-sort="serial" onclick="_lpSort('devices','serial')">设备</th>
<th data-sort="model" onclick="_lpSort('devices','model')">型号</th>
<th data-sort="stf_occupied" onclick="_lpSort('devices','stf_occupied')">STF</th>
<th data-sort="worker_status" onclick="_lpSort('devices','worker_status')">任务状态</th>
<th data-sort="foreground_app" onclick="_lpSort('devices','foreground_app')">前台App</th>
<th data-sort="progress" onclick="_lpSort('devices','progress')">进度</th>
@@ -455,19 +453,6 @@ select.form-control{cursor:pointer}
</div>
<!-- ========== Tab 7: 维护(仅管理员) ========== -->
<div id="tab-maintenance" class="tab-panel">
<div class="page-title">维护</div>
<div class="page-sub">管理员专用:STF 服务管理(adb 远程终端与 Tailscale 管理已移至"工具")</div>
<div class="toolbar">
<button class="btn btn-danger" onclick="restartStf()">一键重启 STF 容器</button>
<span id="stf-restart-status" class="text-muted"></span>
</div>
<div class="help" style="margin-top:8px">
通过 SSH 到部署机执行 <code>docker restart</code>(目标与容器名可用环境变量
<code>STF_SSH_TARGET</code> / <code>STF_DOCKER_CONTAINER</code> 配置)。
影响:STF 约 30 秒不可用,期间设备占用状态重置;<b>有任务在运行时会被拒绝执行</b>。
</div>
</div>
<!-- ========== Tab 8: 工具(仅管理员) ========== -->
<div id="tab-tools" class="tab-panel">
@@ -482,7 +467,6 @@ select.form-control{cursor:pointer}
<button class="sub-tab" data-sub="appver" onclick="showSubTab('tools','appver')">应用版本管理</button>
<button class="sub-tab" data-sub="devapps" onclick="showSubTab('tools','devapps')">设备已装应用</button>
<button class="sub-tab" data-sub="devpool" onclick="showSubTab('tools','devpool')">设备池管理</button>
<button class="sub-tab" data-sub="stfdev" onclick="showSubTab('tools','stfdev')">STF 设备管理</button>
<button class="sub-tab" data-sub="screen" onclick="showSubTab('tools','screen')">远程看屏</button>
</div>
@@ -507,7 +491,7 @@ select.form-control{cursor:pointer}
</div>
<div style="flex:1;min-width:280px">
<div class="form-group">
<label>目标设备(本地 adb 含 USB + STF 池,可多选)</label>
<label>目标设备(本地 adb 含 USB + 设备池,可多选)</label>
<div class="device-list" id="clip-devices" style="max-height:300px">
<div class="empty" style="padding:20px">点击"刷新设备"加载</div>
</div>
@@ -527,7 +511,7 @@ select.form-control{cursor:pointer}
<div class="sub-panel" id="tools-sub-adb">
<div class="help">选中目标设备后,命令里的 <code>-s &lt;设备&gt;</code> 自动附加,直接写
<code>shell ls /sdcard</code> 即可。离线设备也可选,用"重连设备"恢复。
<b>禁止 kill-server / disconnect</b>(会中断 STF provider 的设备监控)。</div>
<b>禁止 kill-server / disconnect</b>(会中断共享的 adb transport,导致设备连接全部重建)。</div>
<div style="display:flex;gap:8px;margin:8px 0;align-items:center">
<label style="font-size:12px;color:var(--text-light)">目标设备</label>
<select id="adb-device" class="form-control" style="flex:1;max-width:340px;font-family:monospace"></select>
@@ -548,9 +532,7 @@ select.form-control{cursor:pointer}
<button class="btn btn-xs" onclick="fillAdbCmd('tcpip 5555')" title="USB 设备开启网络调试(重启设备后失效,需重新执行)">tcpip 5555 转网络调试</button>
</div>
<div style="display:flex;gap:6px;margin-bottom:8px;flex-wrap:wrap">
<button class="btn btn-xs btn-danger" onclick="uninstallStfAgent()" title="自动先 adb connect 再卸载 jp.co.cyberagent.stf,STF 会在设备重连时重新推送">卸载 STF agent(卡死时用)</button>
<button class="btn btn-xs" onclick="reconnectAdbDevice()" title="重新 adb connect 该设备">重连设备</button>
<button class="btn btn-xs" onclick="checkStfAgent()" title="自动先 adb connect 再查看 jp.co.cyberagent.stf 是否已安装">检查 agent</button>
</div>
<pre id="adb-output" class="adb-console">(执行命令后输出显示在这里)</pre>
</div>
@@ -571,7 +553,7 @@ select.form-control{cursor:pointer}
<tbody id="tb-tailscale"><tr><td colspan="8" class="empty">点击"刷新设备"加载</td></tr></tbody>
</table>
<div class="list-pager" id="pager-tailscale"></div>
<div class="help">"设置IP"可修改设备 IPv4(会断开当前连接,平台 serial 随之变化,需同步 STF 设备池);"密钥不过期"= 关闭设备密钥验证,设备不会被定期踢下线;auth key 生成后只显示一次,请立即复制。</div>
<div class="help">"设置IP"可修改设备 IPv4(会断开当前连接,平台 serial 随之变化,需同步设备池);"密钥不过期"= 关闭设备密钥验证,设备不会被定期踢下线;auth key 生成后只显示一次,请立即复制。</div>
</div>
<!-- 子分栏 4:应用管理 -->
@@ -613,7 +595,7 @@ select.form-control{cursor:pointer}
<tbody id="tb-appver"><tr><td colspan="4" class="empty">输入包名后点击"查询版本"</td></tr></tbody>
</table>
<div class="list-pager" id="pager-appver"></div>
<div class="help">查询所有设备(本地 adb 含 USB + STF 池)上该包名的安装情况与版本号,用于对比各设备 App 版本是否一致;未连接的 STF 设备会自动连接(绝不断开,不影响任务)。</div>
<div class="help">查询所有设备(本地 adb 含 USB + 设备池)上该包名的安装情况与版本号,用于对比各设备 App 版本是否一致;未连接的设备会自动连接(绝不断开,不影响任务)。</div>
</div>
<!-- 子分栏 6:设备已装应用 -->
@@ -661,36 +643,6 @@ select.form-control{cursor:pointer}
新增设备流程:手机开 USB 调试 → 连上后「adb 终端」执行 <code>tcpip 5555</code> 转网络调试 →
Tailscale 上线(同一账号)→ 在此添加 <code>IP:5555</code>(自动尝试连接)→
任务运行时 u2 自动推送 atx-agent。<b>停用 = 不参与任务调度</b>,删除 = 从池中移除(不影响其他系统)。
迁移期如需 STF 侧也接入,再到「STF 设备管理」添加同一 IP。
</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="reconnectStfDevices()" title="立即运行 220 的 connect_devices.sh,补连所有配置设备">一键重连</button>
<button class="btn" onclick="loadStfDevMgmt()">刷新</button>
<input class="form-control list-search" id="search-stfdev" placeholder="搜索 IP...">
<span id="stfdev-status" class="text-muted"></span>
</div>
<div class="help" id="stfdev-reconnect-out" style="display:none;background:#0f172a;color:#e2e8f0;font-family:monospace;font-size:12px;padding:10px;border-radius:8px;margin-bottom:10px;white-space:pre-wrap"></div>
<table class="table">
<thead><tr>
<th data-sort="ip" onclick="_lpSort('stfdev','ip')">设备</th>
<th data-sort="cfg" onclick="_lpSort('stfdev','cfg')">脚本配置</th>
<th data-sort="state" onclick="_lpSort('stfdev','state')">连接状态(220 adb)</th>
<th>操作</th>
</tr></thead>
<tbody id="tb-stfdev"><tr><td colspan="4" class="empty">点击"刷新"加载</td></tr></tbody>
</table>
<div class="list-pager" id="pager-stfdev"></div>
<div class="help">
管理 220 上 OpenSTF 设备池:添加 = 写入 connect_devices.sh 的 DEVICES 列表并立即 adb connect;
移除 = 从脚本删除并 adb disconnect(设备从 STF 池下线,任务将不再分配到它)。
脚本的 cron 每 5 分钟自动补连掉线设备。adb 操作发生在 220(STF provider 侧),不影响本机任务直连。
</div>
</div>
+9 -236
View File
@@ -33,7 +33,6 @@ from markupsafe import escape as _esc
from flask_login import (LoginManager, login_user, logout_user,
login_required, current_user)
from core.stf_client import STFClient
from core.task_manager import TaskManager
from core.device_worker import clear_worker_error, clear_all_worker_errors, get_all_worker_status
from core.logger import get_logger, _LOG_DIR, _MODULE_FILES
@@ -41,11 +40,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, stf_device_mgmt, ssh_client
from core import uiauto_helper, tailscale_client
from core.tailscale_client import TailscaleError
from core.stf_device_mgmt import StfDevError
from core.ssh_client import SSHError
from config import ADB_PATH, STF_DOCKER_CONTAINER
from config import ADB_PATH
from tasks import list_task_types, get_task_class
_log = get_logger("web")
@@ -67,13 +64,12 @@ login_manager.login_view = "login"
# 先初始化数据库(含旧 JSON 迁移),再创建 TaskManager(需要 app context 读写 DB)
init_db(app)
stf = STFClient()
# 设备池(本地清单 + adb 在线状态):阶段 0 起作为 STF 的替代数据源,
# 首次启动自动从 STF 导入现有设备;阶段 3 摘除 STF 后此模块独立运行
from core import device_pool
device_pool.init_app(app)
mgr = TaskManager(stf, app=app)
apk_mgr = ApkManager(stf, app=app)
mgr = TaskManager(app=app)
apk_mgr = ApkManager(app=app)
@login_manager.user_loader
@@ -232,7 +228,6 @@ def api_health():
if d.get("worker_status") in ("running", "connecting")),
"device_error": sum(1 for d in devs
if d.get("worker_status") in ("error", "failed")),
"occupied": sum(1 for d in devs if d.get("stf_occupied")),
"jobs": len(mgr.jobs),
})
except Exception as e:
@@ -738,18 +733,6 @@ def api_stop_all():
return jsonify({"ok": True, "stopped": stopped})
@app.route("/api/release", methods=["POST"])
@perm_required(PERM_DEVICES)
def api_release():
# 阶段 1:调度已不占 STF,此处仅清理迁移期遗留的 STF 占用记录
# (阶段 3 摘除 STF 后此接口改为清理本实例 _running/worker 状态)
try:
released, failed = stf.release_all_mine()
except Exception:
released, 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():
@@ -899,7 +882,7 @@ def api_steps_test():
"""测试单个步骤:在指定设备上试执行,验证选择器是否命中(步骤编辑器"测试此步骤")。
请求: {"serial": "100.100.10.11:5555", "step": {"type": "click", "params": {...}}}
只读连接(adb connect + u2),不占用/释放 STF,与运行中任务互不干扰。
只读连接(adb connect + u2),与运行中任务互不干扰。
返回 result: "命中" / "未找到" / "已执行"。
"""
data = request.json or {}
@@ -1208,23 +1191,15 @@ def _merged_device_list():
states[serial] = "pool"
except Exception:
pass
# 迁移期:STF 里 adb 可达但 agent 未就绪的设备仍列出,供维护终端修复 agent
try:
for d in stf.list_all_devices():
serial = d.get("serial", "")
if serial and d.get("present") and not d.get("ready") and serial not in states:
states[serial] = "stf_not_ready"
except Exception:
pass
return [{"serial": s, "state": st} for s, st in sorted(states.items())]
@app.route("/api/adb/devices")
@admin_required
def api_adb_devices():
"""维护终端设备列表(仅管理员):本地 adb 已连接 + STF 在线设备池
(含 agent 未就绪的待修复设备,state=stf_not_ready)。
"""维护终端设备列表(仅管理员):本地 adb 已连接 + 设备池(SQLite)。
状态:device/offline=本机 adb 实际状态;pool=设备池已配置但本机未连接。
供终端设备选择器使用——选中后自动附加 `-s <serial>`,
离线/未连接的设备也可选,配合"重连设备"按钮恢复。
"""
@@ -1272,7 +1247,7 @@ def api_tools_clipboard_set():
"""工具-剪贴板注入:把指定文字写入一台或多台设备的剪贴板。
请求: {"serials": ["100.100.10.11:5555", ...], "text": "要注入的文字"}
设备来源与维护终端一致:本地 adb 已连接设备(含 USB)+ STF 在线池。
设备来源与维护终端一致:本地 adb 已连接设备(含 USB)+ 设备池。
实现:u2 jsonrpc.setClipboard(实测 cmd clipboard 在 MIUI 上不存在)。
"""
data = request.json or {}
@@ -1319,152 +1294,6 @@ 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)。
迁移期桥接:同步加入本地设备池(SQLite),保证新设备可被调度。
阶段 3 摘除 STF 后此接口随设备池管理面板退役。
"""
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)
except StfDevError as e:
return jsonify({"ok": False, "error": str(e)}), 502
# 桥接:同步本地设备池(serial 统一 IP:5555)
try:
device_pool.add_device(f"{ip}:5555" if ":" not in ip else ip)
except Exception as e:
_log.warning(f"STF 添加设备桥接设备池失败: {e}")
return jsonify({"ok": True, "msgs": msgs})
@app.route("/api/stf/devmgmt/remove", methods=["POST"])
@admin_required
def api_stf_devmgmt_remove():
"""STF 设备管理:移除设备(脚本删除 + disconnect)。
迁移期桥接:同步从本地设备池删除。阶段 3 此接口退役。
"""
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)
except StfDevError as e:
return jsonify({"ok": False, "error": str(e)}), 502
try:
device_pool.remove_device(f"{ip}:5555" if ":" not in ip else ip)
except Exception:
pass
return jsonify({"ok": True, "msgs": msgs})
def _app_ver_on_device(serial, pkg):
"""单设备包版本查询:pm path 检查安装 → dumpsys 取 versionName/versionCode。
IP:port 设备先 adb connect(已连接自动跳过;绝不 disconnect,红线)。
查询是只读 shell 命令,无需 adb server 锁(锁只保护 connect/kill-server 类操作)。
"""
if ":" in serial:
try:
adb_connect(serial)
except Exception as e:
return {"installed": False, "error": f"adb 连接失败: {e}"}
try:
r = subprocess.run([ADB_PATH, "-s", serial, "shell", "pm", "path", pkg],
capture_output=True, timeout=15)
if b"package:" not in (r.stdout or b""):
return {"installed": False, "version_name": "", "version_code": ""}
r2 = subprocess.run([ADB_PATH, "-s", serial, "shell", "dumpsys", "package", pkg],
capture_output=True, timeout=20)
out2 = (r2.stdout or b"").decode("utf-8", errors="replace")
vm = re.search(r"versionName=(\S+)", out2)
vc = re.search(r"versionCode=(\d+)", out2)
return {"installed": True,
"version_name": vm.group(1) if vm else "",
"version_code": vc.group(1) if vc else ""}
except subprocess.TimeoutExpired:
return {"installed": False, "error": "查询超时"}
except Exception as e:
return {"installed": False, "error": str(e)}
@app.route("/api/tools/appver", methods=["POST"])
@admin_required
def api_tools_appver():
"""工具-应用版本管理:查询所有设备上指定包名的安装情况与版本号。
请求: {"pkg": "com.ss.android.ugc.aweme"}
设备来源:本地 adb(含 USB)+ STF 在线池,并发查询(最多 10 台同时)。
"""
pkg = (request.json or {}).get("pkg", "").strip()
if not re.match(r"^[A-Za-z0-9_.]+$", pkg or ""):
return jsonify({"ok": False, "error": "包名格式不正确(仅字母/数字/._)"}), 400
devices = _merged_device_list()
if not devices:
return jsonify({"ok": False, "error": "无可用设备"}), 404
results = {}
with ThreadPoolExecutor(max_workers=min(10, len(devices))) as pool:
futures = {pool.submit(_app_ver_on_device, d["serial"], pkg): d for d in devices}
for fut in as_completed(futures, timeout=90):
d = futures[fut]
try:
results[d["serial"]] = fut.result()
except Exception as e:
results[d["serial"]] = {"installed": False, "error": str(e)}
fail = sum(1 for v in results.values() if v.get("error"))
return jsonify({"ok": True, "results": results, "fail": fail,
"total": len(results)})
@app.route("/api/stf/devmgmt/delete", methods=["POST"])
@admin_required
def api_stf_devmgmt_delete():
"""STF 设备管理:彻底删除(脚本移除 + disconnect + 清 STF 池记录)。
迁移期桥接:同步从本地设备池删除。阶段 3 此接口退役。
"""
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)
except StfDevError as e:
return jsonify({"ok": False, "error": str(e)}), 502
try:
device_pool.remove_device(f"{ip}:5555" if ":" not in ip else ip)
except Exception:
pass
return jsonify({"ok": True, "msgs": msgs})
@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
# ================== API:设备池管理(本地 SQLite 清单,新增设备的规范化入口) ==================
@app.route("/api/devices/pool", methods=["GET"])
@perm_required(PERM_DEVICES)
def api_devices_pool_list():
@@ -1553,46 +1382,6 @@ def api_devices_pool_reconnect():
return jsonify({"ok": True, "msg": "重连已启动(后台并发,约 10-20 秒)"})
@app.route("/api/stf/restart", methods=["POST"])
@admin_required
def api_stf_restart():
"""一键重启 STF Docker 容器(仅管理员)。
通过 SSH(config.STF_SSH_TARGET,默认 [email protected])执行
`docker restart <容器>`(config.STF_DOCKER_CONTAINER,默认 stf),
随后轮询 STF API 确认恢复。
影响:STF 约 30s 不可用,期间占用状态重置、任务中断——有任务在跑时拒绝执行。
"""
running = mgr.get_running()
if running:
return jsonify({"ok": False,
"error": f"有 {len(running)} 台设备正在运行任务,请先停止再维护"}), 409
try:
code, out, err = ssh_client.run(
f"docker restart {STF_DOCKER_CONTAINER} && "
f"docker ps --filter name={STF_DOCKER_CONTAINER} --format '{{{{.Names}}}}: {{{{.Status}}}}'",
timeout=60)
except SSHError as e:
return jsonify({"ok": False, "error": str(e)}), 502
if code != 0:
_log.error(f"STF 容器重启失败: {err or out}")
return jsonify({"ok": False,
"error": f"SSH 执行失败:{err or out}(请确认 SSH 配置(STF_SSH_TARGET / STF_SSH_PASSWORD)且容器名 {STF_DOCKER_CONTAINER} 正确)"}), 502
# 轮询 STF API 确认恢复(最长 30s)
alive = False
for _ in range(30):
try:
stf.list_all_devices()
alive = True
break
except Exception:
time.sleep(1)
_log.info(f"STF 容器 {STF_DOCKER_CONTAINER} 已重启,API 恢复={alive}")
return jsonify({"ok": True,
"msg": "STF 容器已重启" + ("" if alive else "(STF API 尚未恢复,请稍后刷新)"),
"detail": out.strip(), "api_alive": alive})
# ================== API:Tailscale 管理(仅管理员) ==================
# 通过 Tailscale 官方 API v2 管理 tailnet 设备(列表/改名/授权/密钥不过期/删除/生成 auth key)。
# 设备 IP 由 tailnet 自动分配,API 无法修改,列表只读展示。
@@ -1812,25 +1601,9 @@ def _run_server(host, port):
if __name__ == "__main__":
from config import WEB_HOST, WEB_PORT, AUTO_RELEASE_STALE_OCCUPY
from config import WEB_HOST, WEB_PORT
# 自动启动 uiautodev 服务(元素抓取功能依赖,端口 20242)
_ensure_uiauto_running()
# 启动时处理可能残留的 STF 占用(上次异常退出/强杀遗留)。
# AUTO_RELEASE_STALE_OCCUPY=True(单实例)时自动释放;否则仅提示。
# 多实例共用一个 STF 账户时不要开启,误放会中断另一实例的任务。
try:
_mine = stf.list_my_devices()
if _mine:
_serials = ", ".join(d["serial"] for d in _mine)
if AUTO_RELEASE_STALE_OCCUPY:
_log.warning(f"启动时发现 {len(_mine)} 台设备残留占用,自动释放(AUTO_RELEASE_STALE_OCCUPY=True):{_serials}")
_released, _failed = stf.release_all_mine()
_log.info(f"自动释放完成:成功 {len(_released)} 台,失败 {len(_failed)} 台")
else:
_log.warning(f"启动时发现 {len(_mine)} 台设备仍被本账户占用(可能异常退出残留):{_serials}。"
f"单实例无人值守可设 AUTO_RELEASE_STALE_OCCUPY=True 自动清理")
except Exception:
pass
_log.info(f"管理后台: http://localhost:{WEB_PORT}/ (admin/admin123)")
try:
_run_server(WEB_HOST, WEB_PORT)