阶段3: 摘除 STF——删除 stf_client/stf_device_mgmt 与全部引用(STF 容器重启/设备管理/强制释放占用/agent 按钮/占用列),调度与设备管理完全基于本地设备池+adb;错误类迁入 device_worker,create_worker 去 stf 参数
This commit is contained in:
@@ -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", "")
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
config — 全局配置常量(STF、adb 路径、养号参数)
|
||||
adb_helper — adb 命令封装(并发安全)
|
||||
stf_client — OpenSTF REST API 客户端
|
||||
device_worker — 设备生命周期 + 养号 worker + 全局状态注册表
|
||||
task_manager — 任务管理框架(分组/计划/调度/重试/持久化)
|
||||
"""
|
||||
|
||||
+3
-8
@@ -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
@@ -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
@@ -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。"""
|
||||
|
||||
@@ -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 # 断开失败不影响主流程
|
||||
@@ -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 分钟),"
|
||||
"稍后点\"刷新\"查看设备连接状态")
|
||||
@@ -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),
|
||||
|
||||
@@ -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">'+
|
||||
|
||||
@@ -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;
|
||||
// 抢占设备:触发时停止目标设备上的其他任务,接管执行
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 <设备></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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user