diff --git a/config.py b/config.py index 5cba4be..cfd21ff 100644 --- a/config.py +++ b/config.py @@ -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", "") diff --git a/core/__init__.py b/core/__init__.py index 4fd3c32..986affd 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -2,7 +2,6 @@ config — 全局配置常量(STF、adb 路径、养号参数) adb_helper — adb 命令封装(并发安全) - stf_client — OpenSTF REST API 客户端 device_worker — 设备生命周期 + 养号 worker + 全局状态注册表 task_manager — 任务管理框架(分组/计划/调度/重试/持久化) """ diff --git a/core/apk_manager.py b/core/apk_manager.py index 0766ac6..f4ebff9 100644 --- a/core/apk_manager.py +++ b/core/apk_manager.py @@ -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()} diff --git a/core/device_pool.py b/core/device_pool.py index f13c210..cfdeec1 100644 --- a/core/device_pool.py +++ b/core/device_pool.py @@ -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)") diff --git a/core/device_worker.py b/core/device_worker.py index 8e4e4f5..0c2a060 100644 --- a/core/device_worker.py +++ b/core/device_worker.py @@ -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。""" diff --git a/core/stf_client.py b/core/stf_client.py deleted file mode 100644 index 871c884..0000000 --- a/core/stf_client.py +++ /dev/null @@ -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 # 断开失败不影响主流程 diff --git a/core/stf_device_mgmt.py b/core/stf_device_mgmt.py deleted file mode 100644 index 20f9675..0000000 --- a/core/stf_device_mgmt.py +++ /dev/null @@ -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 分钟)," - "稍后点\"刷新\"查看设备连接状态") diff --git a/core/task_manager.py b/core/task_manager.py index 3398bc4..1bab318 100644 --- a/core/task_manager.py +++ b/core/task_manager.py @@ -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 → 查询 → 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), diff --git a/static/admin/apps.js b/static/admin/apps.js index 49e4067..5d496da 100644 --- a/static/admin/apps.js +++ b/static/admin/apps.js @@ -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= '
'+ diff --git a/static/admin/editor.js b/static/admin/editor.js index eb46d33..2561fc0 100644 --- a/static/admin/editor.js +++ b/static/admin/editor.js @@ -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; // 抢占设备:触发时停止目标设备上的其他任务,接管执行 diff --git a/static/admin/monitor.js b/static/admin/monitor.js index d0c37a4..8d4a832 100644 --- a/static/admin/monitor.js +++ b/static/admin/monitor.js @@ -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?'占用':'空闲'; const offline=dev.present?'':' 离线'; const st=STATUS_MAP[dev.worker_status]||{t:dev.worker_status,c:'default'}; const wb=''+st.t+''; @@ -355,7 +350,7 @@ function renderDeviceRow(dev){ const checked=_selectedSerials.has(dev.serial)?'checked':''; const rowCls=_selectedSerials.has(dev.serial)?'row-checked':''; const cb=''; - return ''+cb+''+devLabel+''+esc(dev.model)+''+stf+offline+''+wb+''+fg+''+prog+''+tname+retry+endInfo+''+esc(dev.current_action||'-')+warnInfo+''+esc(dev.last_error||'-')+''+btns+''; + return ''+cb+''+devLabel+''+esc(dev.model)+''+offline+''+wb+''+fg+''+prog+''+tname+retry+endInfo+''+esc(dev.current_action||'-')+warnInfo+''+esc(dev.last_error||'-')+''+btns+''; } async function loadJobsForMonitor(){ diff --git a/static/admin/tools.js b/static/admin/tools.js index 2dc147f..fe28dd0 100644 --- a/static/admin/tools.js +++ b/static/admin/tools.js @@ -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='
无可用设备(本地 adb 或 STF 池均无)
'; + wrap.innerHTML='
无可用设备(本地 adb 或设备池均无)
'; 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 '
-
-
维护
-
管理员专用:STF 服务管理(adb 远程终端与 Tailscale 管理已移至"工具")
-
- - -
-
- 通过 SSH 到部署机执行 docker restart(目标与容器名可用环境变量 - STF_SSH_TARGET / STF_DOCKER_CONTAINER 配置)。 - 影响:STF 约 30 秒不可用,期间设备占用状态重置;有任务在运行时会被拒绝执行。 -
-
@@ -482,7 +467,6 @@ select.form-control{cursor:pointer} -
@@ -507,7 +491,7 @@ select.form-control{cursor:pointer}
- +
点击"刷新设备"加载
@@ -527,7 +511,7 @@ select.form-control{cursor:pointer}
选中目标设备后,命令里的 -s <设备> 自动附加,直接写 shell ls /sdcard 即可。离线设备也可选,用"重连设备"恢复。 - 禁止 kill-server / disconnect(会中断 STF provider 的设备监控)。
+ 禁止 kill-server / disconnect(会中断共享的 adb transport,导致设备连接全部重建)。
@@ -548,9 +532,7 @@ select.form-control{cursor:pointer}
- -
(执行命令后输出显示在这里)
@@ -571,7 +553,7 @@ select.form-control{cursor:pointer} 点击"刷新设备"加载
-
"设置IP"可修改设备 IPv4(会断开当前连接,平台 serial 随之变化,需同步 STF 设备池);"密钥不过期"= 关闭设备密钥验证,设备不会被定期踢下线;auth key 生成后只显示一次,请立即复制。
+
"设置IP"可修改设备 IPv4(会断开当前连接,平台 serial 随之变化,需同步设备池);"密钥不过期"= 关闭设备密钥验证,设备不会被定期踢下线;auth key 生成后只显示一次,请立即复制。
@@ -613,7 +595,7 @@ select.form-control{cursor:pointer} 输入包名后点击"查询版本"
-
查询所有设备(本地 adb 含 USB + STF 池)上该包名的安装情况与版本号,用于对比各设备 App 版本是否一致;未连接的 STF 设备会自动连接(绝不断开,不影响任务)。
+
查询所有设备(本地 adb 含 USB + 设备池)上该包名的安装情况与版本号,用于对比各设备 App 版本是否一致;未连接的设备会自动连接(绝不断开,不影响任务)。
@@ -661,36 +643,6 @@ select.form-control{cursor:pointer} 新增设备流程:手机开 USB 调试 → 连上后「adb 终端」执行 tcpip 5555 转网络调试 → Tailscale 上线(同一账号)→ 在此添加 IP:5555(自动尝试连接)→ 任务运行时 u2 自动推送 atx-agent。停用 = 不参与任务调度,删除 = 从池中移除(不影响其他系统)。 - 迁移期如需 STF 侧也接入,再到「STF 设备管理」添加同一 IP。 - - - - -
-
- - - - - - -
- - - - - - - - - -
设备脚本配置连接状态(220 adb)操作
点击"刷新"加载
-
-
- 管理 220 上 OpenSTF 设备池:添加 = 写入 connect_devices.sh 的 DEVICES 列表并立即 adb connect; - 移除 = 从脚本删除并 adb disconnect(设备从 STF 池下线,任务将不再分配到它)。 - 脚本的 cron 每 5 分钟自动补连掉线设备。adb 操作发生在 220(STF provider 侧),不影响本机任务直连。
diff --git a/web_server.py b/web_server.py index d884fae..3393a5d 100644 --- a/web_server.py +++ b/web_server.py @@ -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 `, 离线/未连接的设备也可选,配合"重连设备"按钮恢复。 """ @@ -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,默认 stf@192.168.20.220)执行 - `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)