Compare commits
5
Commits
2f2d8cc184
...
2e491b19e2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e491b19e2 | ||
|
|
136613bc12 | ||
|
|
3bd56b4f59 | ||
|
|
5dd605e3a0 | ||
|
|
8e23541380 |
+33
-4
@@ -1,12 +1,41 @@
|
|||||||
# 环境变量示例:复制为 .env 并按需修改(.env 不进 git)
|
# 环境变量示例:复制为 .env 并按需修改(.env 不进 git)
|
||||||
# 生产环境建议用环境变量/密钥管理注入,不要把 token 写进 git。
|
# 生产环境建议用环境变量/密钥管理注入,不要把真实 token 写进 git。
|
||||||
|
# 全部可配置项见 config.py(_env() 读取,未配置时用代码内默认值);
|
||||||
|
# 标"必须"的项务必设置,其余可留空用默认。
|
||||||
|
|
||||||
# STF 服务地址
|
# ==================== STF 平台 ====================
|
||||||
|
# STF 服务地址(默认 http://192.168.20.220:7100)
|
||||||
STF_URL=http://192.168.20.220:7100
|
STF_URL=http://192.168.20.220:7100
|
||||||
|
|
||||||
# STF API Token(安全敏感,生产必须设置)
|
# STF API Token(必须:STF 个人设置 → API Keys 生成)
|
||||||
STF_TOKEN=请填写你的STF_token
|
STF_TOKEN=请填写你的STF_token
|
||||||
|
|
||||||
# Web 会话密钥(安全敏感,生产必须设置为随机长字符串)
|
# 启动时自动释放残留 STF 占用(单实例无人值守开 true;多实例共用账户勿开,会误放另一实例的任务)
|
||||||
|
# AUTO_RELEASE_STALE_OCCUPY=true
|
||||||
|
|
||||||
|
# ==================== Web 会话 ====================
|
||||||
|
# Web 会话密钥(必须:生产设置为随机长字符串,重启不失效)
|
||||||
# 生成:python -c "import secrets; print(secrets.token_hex(32))"
|
# 生成:python -c "import secrets; print(secrets.token_hex(32))"
|
||||||
WEB_SECRET_KEY=请填写随机密钥
|
WEB_SECRET_KEY=请填写随机密钥
|
||||||
|
|
||||||
|
# ==================== SSH 到部署机(维护页重启 STF / 工具页 STF 设备管理) ====================
|
||||||
|
# SSH 目标(默认 [email protected])
|
||||||
|
# [email protected]
|
||||||
|
|
||||||
|
# SSH 认证密码(推荐):配置后走 paramiko **纯密码**登录(不碰本地密钥/SSH agent,
|
||||||
|
# 不会弹授权框,跨平台无需 sshpass);留空则退回系统 ssh 免密密钥(BatchMode=yes)。
|
||||||
|
STF_SSH_PASSWORD=请填写SSH密码
|
||||||
|
|
||||||
|
# 220 上 STF / adb 的 Docker 容器名(默认 stf / adb,一般不用改)
|
||||||
|
# STF_DOCKER_CONTAINER=stf
|
||||||
|
# STF_ADB_CONTAINER=adb
|
||||||
|
|
||||||
|
# STF 设备池脚本路径(工具页"STF 设备管理"增删设备时读写其 DEVICES 列表,默认值见 config.py)
|
||||||
|
# STF_SCRIPT_PATH=/mnt/data/openstf/connect_devices.sh
|
||||||
|
|
||||||
|
# ==================== Tailscale 管理(工具页) ====================
|
||||||
|
# API key(必须:Tailscale 后台 → Settings → API Access Tokens 生成)
|
||||||
|
TAILSCALE_API_KEY=请填写Tailscale_API_key
|
||||||
|
|
||||||
|
# tailnet 名/ID(默认按邮箱前缀,如 [email protected])
|
||||||
|
# [email protected]
|
||||||
|
|||||||
@@ -229,14 +229,20 @@ self.set_progress(done=5, total=80, unit="视频",
|
|||||||
| 配置项 | 默认值 | 说明 |
|
| 配置项 | 默认值 | 说明 |
|
||||||
|-------|--------|------|
|
|-------|--------|------|
|
||||||
| `STF_URL` | `http://192.168.20.220:7100` | OpenSTF 服务地址 |
|
| `STF_URL` | `http://192.168.20.220:7100` | OpenSTF 服务地址 |
|
||||||
| `STF_TOKEN` | (需替换) | STF API Token,在 STF 个人设置里生成 |
|
| `STF_TOKEN` | (.env 配置) | STF API Token,在 STF 个人设置里生成(密钥类配置统一放 `.env`) |
|
||||||
| `ADB_PATH` | 自动识别 | adb 二进制路径,自动区分 Windows/Linux |
|
| `ADB_PATH` | 自动识别 | adb 二进制路径,自动区分 Windows/Linux |
|
||||||
| `WEB_HOST` | `0.0.0.0` | Web 监听地址(0.0.0.0 支持局域网访问) |
|
| `WEB_HOST` | `0.0.0.0` | Web 监听地址(0.0.0.0 支持局域网访问) |
|
||||||
| `WEB_PORT` | `18050` | Web 端口(避开 Windows 动态端口范围) |
|
| `WEB_PORT` | `18050` | Web 端口(避开 Windows 动态端口范围) |
|
||||||
| `DATA_DIR` | `data/` | 持久化数据目录 |
|
| `DATA_DIR` | `data/` | 持久化数据目录 |
|
||||||
| `APK_DIR` | `data/apks/` | APK 文件存储目录 |
|
| `APK_DIR` | `data/apks/` | APK 文件存储目录 |
|
||||||
|
| `STF_SSH_TARGET` | `[email protected]` | 维护页重启 STF / STF 设备管理的 SSH 目标(需免密) |
|
||||||
|
| `STF_DOCKER_CONTAINER` | `stf` | 220 上 STF Docker 容器名 |
|
||||||
|
| `STF_ADB_CONTAINER` | `adb` | 220 上 adb Docker 容器名(STF 设备管理执行 adb 用) |
|
||||||
|
| `STF_SCRIPT_PATH` | `/mnt/data/openstf/connect_devices.sh` | STF 设备池脚本路径(DEVICES 列表由此维护) |
|
||||||
|
| `TAILSCALE_API_KEY` | (.env 配置) | Tailscale 管理 API key(Settings → API Access Tokens) |
|
||||||
|
| `TAILSCALE_TAILNET` | 按邮箱前缀 | tailnet 名/ID |
|
||||||
|
|
||||||
**必须修改的配置**:`STF_URL` 和 `STF_TOKEN`,改成你自己的 STF 服务地址和 Token。
|
**必须修改的配置**:`.env` 里的 `STF_TOKEN`(密钥类统一放 `.env`,不入 git);`STF_URL` 在 `config.py`。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -244,17 +250,19 @@ self.set_progress(done=5, total=80, unit="视频",
|
|||||||
|
|
||||||
### 页面结构
|
### 页面结构
|
||||||
|
|
||||||
单页应用(`templates/admin/monitor.html`),6 个 Tab:
|
单页应用(`templates/admin/monitor.html`),7 个 Tab;任务/工具 Tab 内部再有页内子分栏:
|
||||||
|
|
||||||
| Tab | 功能 | 可见性 |
|
| Tab | 功能 | 可见性 |
|
||||||
|-----|------|--------|
|
|-----|------|--------|
|
||||||
| 监控 | 设备状态大屏:在线/离线、运行任务、当前动作、进度条、前台 App、截图、停止 | 所有登录用户(设备操作按钮需"设备控制"权限) |
|
| 监控 | 设备状态大屏:在线/离线、运行任务、当前动作、进度条、前台 App、截图、停止 | 所有登录用户(设备操作按钮需"设备控制"权限) |
|
||||||
| 任务 | 任务计划 CRUD:新建/编辑/删除/启用停用/立即执行 | 所有登录用户可看,写操作需"任务管理"权限 |
|
| 任务 | 子分栏:任务计划(CRUD/启用停用/立即执行/下次运行时间)、自定义动作(打包复用) | 所有登录用户可看,写操作需"任务管理"权限 |
|
||||||
| 应用 | APK 上传/安装/删除 | 需"应用管理"权限 |
|
|
||||||
| 分组 | 设备分组管理:创建/编辑/删除分组 | 所有登录用户可看,写操作需"任务管理"权限 |
|
| 分组 | 设备分组管理:创建/编辑/删除分组 | 所有登录用户可看,写操作需"任务管理"权限 |
|
||||||
| 日志 | 实时日志查看:按模块切换(core/task/web/action) | 需"日志查看"权限 |
|
| 日志 | 实时日志查看:按模块切换(core/task/web/action) | 需"日志查看"权限 |
|
||||||
| 用户 | 用户管理:创建/删除/修改密码/分配权限 | 仅管理员 |
|
| 用户 | 用户管理:创建/删除/修改密码/分配权限 | 仅管理员 |
|
||||||
| 维护 | STF 容器一键重启、adb 远程终端(设备快捷选择/自动 `-s`、卸载 STF agent、重连设备)、Tailscale 管理(设备列表/改名/授权/密钥不过期/生成 auth key) | 仅管理员 |
|
| 维护 | STF 服务:一键重启容器 | 仅管理员 |
|
||||||
|
| 工具 | 子分栏:剪贴板注入、adb 远程终端(快捷命令/自动 `-s`/卸载 STF agent)、Tailscale 管理(改名/授权/密钥不过期/设置IP/auth key)、应用管理(APK 上传安装)、应用版本管理(按包名查所有设备版本)、设备已装应用、STF 设备管理(读写 220 脚本维护设备池) | 仅管理员 |
|
||||||
|
|
||||||
|
任务/工具 Tab 的子分栏会记住上次选中的位置;无权限的 tab 和按钮自动隐藏。
|
||||||
|
|
||||||
### 用户与权限
|
### 用户与权限
|
||||||
|
|
||||||
@@ -264,7 +272,7 @@ self.set_progress(done=5, total=80, unit="视频",
|
|||||||
|--------|------|---------|
|
|--------|------|---------|
|
||||||
| 任务管理 `tasks` | 任务/自定义动作/分组的增删改、启停、立即执行 | 任务 Tab、分组 Tab 的写操作 |
|
| 任务管理 `tasks` | 任务/自定义动作/分组的增删改、启停、立即执行 | 任务 Tab、分组 Tab 的写操作 |
|
||||||
| 设备控制 `devices` | 停止设备、释放占用、清除异常、前台扫描、元素抓取 | 监控 Tab 的设备操作按钮、步骤编辑器的"抓取元素" |
|
| 设备控制 `devices` | 停止设备、释放占用、清除异常、前台扫描、元素抓取 | 监控 Tab 的设备操作按钮、步骤编辑器的"抓取元素" |
|
||||||
| 应用管理 `apks` | APK 上传、安装、删除 | 应用 Tab |
|
| 应用管理 `apks` | APK 上传、安装、删除 | 应用管理/设备已装应用(当前并入工具 Tab,工具页整体仅管理员可见) |
|
||||||
| 日志查看 `logs` | 日志页 | 日志 Tab |
|
| 日志查看 `logs` | 日志页 | 日志 Tab |
|
||||||
|
|
||||||
规则:
|
规则:
|
||||||
@@ -332,10 +340,13 @@ self.set_progress(done=5, total=80, unit="视频",
|
|||||||
| `wait_el` | 等待元素出现(条件等待,替代固定时长) |
|
| `wait_el` | 等待元素出现(条件等待,替代固定时长) |
|
||||||
| `input_text` | 输入文字(随机候选/指定文字,可选输入前先清空) |
|
| `input_text` | 输入文字(随机候选/指定文字,可选输入前先清空) |
|
||||||
| `click` | 点击元素(支持 xpath/description/resourceId/text 选择器) |
|
| `click` | 点击元素(支持 xpath/description/resourceId/text 选择器) |
|
||||||
| `input_text` | 输入文字(随机候选/固定文字) |
|
|
||||||
| `wait` | 等待(可配置时长范围) |
|
| `wait` | 等待(可配置时长范围) |
|
||||||
| `loop` | 循环块(含子步骤,可配置循环次数) |
|
| `loop` | 循环块(含子步骤,可配置循环次数) |
|
||||||
| `group` | 动作组(含子步骤,按序执行一次) |
|
| `group` | 动作组(含子步骤,按序执行一次) |
|
||||||
|
| `if_el` | 条件判断:找元素(支持 xpath 等选择器或 **OCR识别** 截屏匹配图片文字),命中执行"找到时"分支,未命中执行"未找到时"分支;OCR 命中可自动点击。分支可嵌套循环/条件判断 |
|
||||||
|
|
||||||
|
步骤编辑器特性:操作库按分类分组、卡片可拖拽排序/跨层级嵌套(循环套循环)、☑ 多选打包自定义动作、
|
||||||
|
"测试此步骤"真机验证、"抓取元素"回填选择器、条件判断的 OCR 识别依赖 `rapidocr_onnxruntime`(跨平台)。
|
||||||
|
|
||||||
### 新增 App 任务
|
### 新增 App 任务
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ def _env(key, default):
|
|||||||
|
|
||||||
|
|
||||||
# ================== STF 配置 ==================
|
# ================== STF 配置 ==================
|
||||||
STF_URL = _env("STF_URL", "http://192.168.20.220:7100")
|
# 部署相关配置统一从 .env 读取(模板见 .env.example),config.py 不再内置具体值
|
||||||
|
STF_URL = _env("STF_URL", "")
|
||||||
STF_TOKEN = _env("STF_TOKEN", "") # 写入 .env,不要提交到 git
|
STF_TOKEN = _env("STF_TOKEN", "") # 写入 .env,不要提交到 git
|
||||||
|
|
||||||
# ================== adb 路径 ==================
|
# ================== adb 路径 ==================
|
||||||
@@ -65,16 +66,24 @@ APK_DIR = os.path.join(DATA_DIR, "apks")
|
|||||||
# 仅单实例部署时设为 True:多实例共用同一 STF 账户时不要开,会误释放另一实例的运行中任务。
|
# 仅单实例部署时设为 True:多实例共用同一 STF 账户时不要开,会误释放另一实例的运行中任务。
|
||||||
AUTO_RELEASE_STALE_OCCUPY = _env("AUTO_RELEASE_STALE_OCCUPY", "false").lower() in ("1", "true", "yes")
|
AUTO_RELEASE_STALE_OCCUPY = _env("AUTO_RELEASE_STALE_OCCUPY", "false").lower() in ("1", "true", "yes")
|
||||||
|
|
||||||
# ================== 维护(STF 容器一键重启) ==================
|
# ================== SSH 到部署机(维护页重启 STF + 工具页 STF 设备管理) ==================
|
||||||
# 维护页"一键重启 STF 容器"通过 SSH 到部署机执行 docker restart。
|
# 通过 SSH 到部署机执行 docker 命令/改脚本。配置从 .env 读取:
|
||||||
# 前置条件:本机可免密 SSH 到目标机(stf@220 需已授权本机公钥)。
|
# STF_SSH_TARGET SSH 目标(user@host)
|
||||||
STF_SSH_TARGET = _env("STF_SSH_TARGET", "[email protected]")
|
# STF_SSH_PASSWORD SSH 密码(配置后 paramiko 纯密码登录,跨平台;
|
||||||
STF_DOCKER_CONTAINER = _env("STF_DOCKER_CONTAINER", "stf")
|
# 留空则退回系统 ssh 免密密钥 BatchMode=yes)
|
||||||
|
# STF_DOCKER_CONTAINER STF Docker 容器名
|
||||||
|
# STF_ADB_CONTAINER 220 上 adb Docker 容器名(STF 设备管理执行 adb 用)
|
||||||
|
# STF_SCRIPT_PATH STF 设备池脚本路径(增删设备读写其 DEVICES 列表)
|
||||||
|
STF_SSH_TARGET = _env("STF_SSH_TARGET", "")
|
||||||
|
STF_SSH_PASSWORD = _env("STF_SSH_PASSWORD", "")
|
||||||
|
STF_DOCKER_CONTAINER = _env("STF_DOCKER_CONTAINER", "")
|
||||||
|
STF_ADB_CONTAINER = _env("STF_ADB_CONTAINER", "")
|
||||||
|
STF_SCRIPT_PATH = _env("STF_SCRIPT_PATH", "")
|
||||||
|
|
||||||
# ================== 维护(Tailscale 管理) ==================
|
# ================== Tailscale 管理(工具页) ==================
|
||||||
# 维护页"Tailscale 管理"调用官方 API v2 管理 tailnet 设备。
|
# 调用官方 API v2 管理 tailnet 设备。配置从 .env 读取:
|
||||||
# TAILSCALE_API_KEY:Tailscale 后台 → Settings → API Access Tokens 生成(或 OAuth client_id:secret)
|
# TAILSCALE_API_KEY:后台 → Settings → API Access Tokens 生成(或 OAuth client_id:secret)
|
||||||
# TAILSCALE_TAILNET:tailnet 名称/ID,个人账号一般是登录邮箱前缀
|
# TAILSCALE_TAILNET:tailnet 名称/ID,个人账号一般是登录邮箱前缀
|
||||||
# 安全:密钥用环境变量/.env 注入,不要写死在代码或提交到 git。
|
# 安全:密钥用环境变量/.env 注入,不要写死在代码或提交到 git。
|
||||||
TAILSCALE_API_KEY = _env("TAILSCALE_API_KEY", "") # 写入 .env,不要提交到 git
|
TAILSCALE_API_KEY = _env("TAILSCALE_API_KEY", "") # 写入 .env,不要提交到 git
|
||||||
TAILSCALE_TAILNET = _env("TAILSCALE_TAILNET", "[email protected]")
|
TAILSCALE_TAILNET = _env("TAILSCALE_TAILNET", "")
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"""SSH 远程命令执行统一封装(STF 重启 / STF 设备管理共用)。
|
||||||
|
|
||||||
|
认证方式二选一:
|
||||||
|
1. `.env` 配置 STF_SSH_PASSWORD → 密码认证(paramiko 实现,跨平台,
|
||||||
|
无需 sshpass/ssh 等外部工具,Windows/Linux/macOS 通吃)
|
||||||
|
2. 未配置 → 退回系统 `ssh` 命令 + BatchMode=yes(免密密钥,与旧行为一致)
|
||||||
|
|
||||||
|
用法:
|
||||||
|
code, out, err = ssh_client.run("docker ps") # 返回 (退出码, stdout, stderr)
|
||||||
|
失败抛 SSHError(连接失败/超时),由调用方转成友好错误返回。
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
import paramiko
|
||||||
|
|
||||||
|
from config import STF_SSH_TARGET, STF_SSH_PASSWORD
|
||||||
|
from core.logger import get_logger
|
||||||
|
|
||||||
|
_log = get_logger("core.ssh")
|
||||||
|
|
||||||
|
|
||||||
|
class SSHError(Exception):
|
||||||
|
"""SSH 执行错误(连接失败/超时等)。"""
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_target(target):
|
||||||
|
"""解析 STF_SSH_TARGET(user@host[:port] 或 host)→ (user, host, port)。"""
|
||||||
|
target = (target or "").strip()
|
||||||
|
user, host, port = "root", target, 22
|
||||||
|
m = re.match(r"^([^@]+)@(.+)$", target)
|
||||||
|
if m:
|
||||||
|
user, host = m.group(1), m.group(2)
|
||||||
|
if ":" in host:
|
||||||
|
host, p = host.rsplit(":", 1)
|
||||||
|
try:
|
||||||
|
port = int(p)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return user, host, port
|
||||||
|
|
||||||
|
|
||||||
|
def uses_password():
|
||||||
|
"""当前是否走密码认证。"""
|
||||||
|
return bool(STF_SSH_PASSWORD)
|
||||||
|
|
||||||
|
|
||||||
|
def run(cmd, timeout=25):
|
||||||
|
"""在部署机上执行一条命令。返回 (code, stdout, stderr)。"""
|
||||||
|
if uses_password():
|
||||||
|
return _run_paramiko(cmd, timeout)
|
||||||
|
return _run_system_ssh(cmd, timeout)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_system_ssh(cmd, timeout):
|
||||||
|
"""免密密钥路径:系统 ssh + BatchMode=yes(不弹密码提示)。"""
|
||||||
|
ssh = ["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5",
|
||||||
|
"-o", "StrictHostKeyChecking=no", STF_SSH_TARGET]
|
||||||
|
try:
|
||||||
|
r = subprocess.run(ssh + [cmd], capture_output=True, timeout=timeout)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
raise SSHError(f"SSH 执行超时: {cmd[:80]}")
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise SSHError("本机未安装 ssh,无法远程执行")
|
||||||
|
except Exception as e:
|
||||||
|
raise SSHError(f"SSH 连接失败: {e}")
|
||||||
|
return r.returncode, (r.stdout or b"").decode("utf-8", errors="replace"), \
|
||||||
|
(r.stderr or b"").decode("utf-8", errors="replace")
|
||||||
|
|
||||||
|
|
||||||
|
def _run_paramiko(cmd, timeout):
|
||||||
|
"""密码路径:paramiko SSHClient,**纯密码认证**。
|
||||||
|
|
||||||
|
look_for_keys=False + allow_agent=False:不尝试本地密钥、不询问 SSH agent
|
||||||
|
(如 Bitwarden),否则 agent 会弹授权框/用密钥绕过密码。
|
||||||
|
"""
|
||||||
|
user, host, port = _parse_target(STF_SSH_TARGET)
|
||||||
|
client = paramiko.SSHClient()
|
||||||
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
try:
|
||||||
|
client.connect(host, port=port, username=user, password=STF_SSH_PASSWORD,
|
||||||
|
timeout=8, banner_timeout=8, auth_timeout=8,
|
||||||
|
look_for_keys=False, allow_agent=False)
|
||||||
|
try:
|
||||||
|
stdin, stdout, stderr = client.exec_command(cmd, timeout=timeout)
|
||||||
|
out = stdout.read().decode("utf-8", errors="replace")
|
||||||
|
err = stderr.read().decode("utf-8", errors="replace")
|
||||||
|
code = stdout.channel.recv_exit_status()
|
||||||
|
return code, out, err
|
||||||
|
finally:
|
||||||
|
client.close()
|
||||||
|
except paramiko.AuthenticationException:
|
||||||
|
raise SSHError(f"SSH 密码认证失败(请检查 .env 的 STF_SSH_PASSWORD)")
|
||||||
|
except paramiko.SSHException as e:
|
||||||
|
raise SSHError(f"SSH 连接失败: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
raise SSHError(f"SSH 连接失败: {e}")
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
"""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
|
||||||
+75
-3
@@ -595,9 +595,11 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 13. 维护(仅管理员)
|
## 13. 维护 / 工具(仅管理员)
|
||||||
|
|
||||||
维护页两个接口都**仅管理员可用**(普通用户即使有业务权限也访问不了,返回 403)。
|
以下接口都**仅管理员可用**(普通用户即使有业务权限也访问不了,返回 403)。
|
||||||
|
维护页现仅含 STF 服务管理;adb 终端、Tailscale 管理、应用管理、STF 设备管理等
|
||||||
|
已集中到"工具"页(页内子分栏),API 不变。
|
||||||
|
|
||||||
### POST /api/stf/restart
|
### POST /api/stf/restart
|
||||||
|
|
||||||
@@ -672,7 +674,7 @@ adb 远程终端:用平台 adb 二进制执行任意 adb 命令(20s 超时
|
|||||||
|
|
||||||
## 15. Tailscale 管理(仅管理员)
|
## 15. Tailscale 管理(仅管理员)
|
||||||
|
|
||||||
维护页"Tailscale 管理"分区,调用 Tailscale 官方 API v2。所有接口仅管理员可用。
|
工具页"Tailscale 管理"子分栏,调用 Tailscale 官方 API v2。所有接口仅管理员可用。
|
||||||
前置:`.env` 配置 `TAILSCALE_API_KEY`(Settings → API Access Tokens)与
|
前置:`.env` 配置 `TAILSCALE_API_KEY`(Settings → API Access Tokens)与
|
||||||
`TAILSCALE_TAILNET`(tailnet 名,个人账号一般为邮箱前缀);未配置返回 502 并附提示。
|
`TAILSCALE_TAILNET`(tailnet 名,个人账号一般为邮箱前缀);未配置返回 502 并附提示。
|
||||||
设备 IP 由 tailnet 分配,API 不可修改,列表只读展示。
|
设备 IP 由 tailnet 分配,API 不可修改,列表只读展示。
|
||||||
@@ -739,3 +741,73 @@ adb 远程终端:用平台 adb 二进制执行任意 adb 命令(20s 超时
|
|||||||
```json
|
```json
|
||||||
{"ok": true, "key": "tskey-auth-...", "id": "k1", "expires": "2026-08-11T01:00:00Z"}
|
{"ok": true, "key": "tskey-auth-...", "id": "k1", "expires": "2026-08-11T01:00:00Z"}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. 工具(仅管理员)
|
||||||
|
|
||||||
|
工具页(剪贴板注入 / 应用版本管理 / STF 设备管理)接口,均仅管理员可用。
|
||||||
|
|
||||||
|
### POST /api/tools/clipboard/set
|
||||||
|
|
||||||
|
剪贴板注入:把指定文字写入一台或多台设备的剪贴板。
|
||||||
|
|
||||||
|
**请求**(JSON):
|
||||||
|
```json
|
||||||
|
{"serials": ["100.100.10.11:5555", "0123456789ABCDEF"], "text": "要注入的文字"}
|
||||||
|
```
|
||||||
|
设备来源与维护终端一致(本地 adb 含 USB + STF 池)。
|
||||||
|
实现:u2 `jsonrpc.setClipboard`(实测 `cmd clipboard` 在 MIUI 上不存在),支持中文/引号/换行;
|
||||||
|
IP 设备先 adb connect(已连接跳过,绝不 disconnect),USB 设备首次自动推送 atx-agent。
|
||||||
|
|
||||||
|
**响应**:
|
||||||
|
```json
|
||||||
|
{"ok": true, "results": {"100.100.10.11:5555": {"ok": true, "msg": "已注入"}},
|
||||||
|
"ok_count": 1, "fail_count": 0, "error": null}
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /api/tools/appver
|
||||||
|
|
||||||
|
应用版本管理:查询所有设备上指定包名的安装情况与版本号(并发 10 台)。
|
||||||
|
|
||||||
|
**请求**(JSON):
|
||||||
|
```json
|
||||||
|
{"pkg": "com.ss.android.ugc.aweme"}
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应**:
|
||||||
|
```json
|
||||||
|
{"ok": true, "total": 8, "fail": 0,
|
||||||
|
"results": {"100.100.10.11:5555": {"installed": true, "version_name": "28.5.0", "version_code": "280500"}}}
|
||||||
|
```
|
||||||
|
未安装返回 `installed: false`;查询失败的设备带 `error` 字段。
|
||||||
|
|
||||||
|
### GET /api/stf/devmgmt/status
|
||||||
|
|
||||||
|
STF 设备管理状态:connect_devices.sh 脚本配置的 IP 列表 + 220 上 adb 实际连接状态。
|
||||||
|
|
||||||
|
**响应**:
|
||||||
|
```json
|
||||||
|
{"ok": true,
|
||||||
|
"configured": ["100.100.10.10", "100.100.10.11"],
|
||||||
|
"connected": [{"serial": "100.100.10.10:5555", "state": "device"}]}
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /api/stf/devmgmt/add
|
||||||
|
|
||||||
|
添加设备:写入 220 上 `connect_devices.sh` 的 DEVICES 列表 + 立即 `docker exec adb adb connect`。
|
||||||
|
设备不可达时 connect 会 15 秒超时兜底(220 侧 `timeout`),但仍保留在脚本中,cron 每 5 分钟自动重试。
|
||||||
|
|
||||||
|
**请求**(JSON):`{"ip": "100.100.10.20"}`(可带 `:5555`,自动归一化)
|
||||||
|
|
||||||
|
**响应**:
|
||||||
|
```json
|
||||||
|
{"ok": true, "msgs": ["已写入脚本 /mnt/data/openstf/connect_devices.sh", "连接超时(设备当前不可达),已保留在脚本中,cron 会每 5 分钟自动重试"]}
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /api/stf/devmgmt/remove
|
||||||
|
|
||||||
|
移除设备:从脚本 DEVICES 删除 + `adb disconnect`(设备从 STF 池下线,任务不再分配)。
|
||||||
|
注意:断开的是 220(STF provider 侧)的 adb 连接,与本机任务直连的 adb 相互独立。
|
||||||
|
|
||||||
|
**请求**(JSON):`{"ip": "100.100.10.20"}`
|
||||||
|
|||||||
+39
-4
@@ -233,6 +233,32 @@ APK 上传/解析/批量安装。
|
|||||||
|
|
||||||
元素树解析:递归提取每个节点的 `resource-id/text/content-desc/class/bounds` 等属性,并推荐最佳选择器(优先 xpath)。
|
元素树解析:递归提取每个节点的 `resource-id/text/content-desc/class/bounds` 等属性,并推荐最佳选择器(优先 xpath)。
|
||||||
|
|
||||||
|
### 3.9 屏幕 OCR(`core/ocr.py`)
|
||||||
|
|
||||||
|
条件判断的 `ocr` 选择器实现:截屏 → RapidOCR(ONNX 推理,中英文模型随包内置)→ 关键词匹配 → 返回文字中心像素坐标(与 u2 `d.click` 一致)。
|
||||||
|
|
||||||
|
- **跨平台**(Windows/Linux/macOS),依赖 `rapidocr_onnxruntime`;服务器无显示器环境建议将 opencv-python 换成 opencv-python-headless
|
||||||
|
- 引擎懒加载单例 + 并发加锁(识别约 0.2-0.5s/次)
|
||||||
|
- 返回坐标约定:像素、原点左上
|
||||||
|
|
||||||
|
### 3.10 STF 设备管理(`core/stf_device_mgmt.py`)
|
||||||
|
|
||||||
|
工具页"STF 设备管理":维护 220 上 OpenSTF 设备池。
|
||||||
|
|
||||||
|
背景:220 的 adb 跑在 Docker 容器(`STF_ADB_CONTAINER`,默认 `adb`)里,设备池由
|
||||||
|
`/mnt/data/openstf/connect_devices.sh`(`STF_SCRIPT_PATH`)的 `DEVICES` 数组维护,cron 每 5 分钟补连。
|
||||||
|
|
||||||
|
| 函数 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `status()` | 脚本配置 IP + 220 adb 实际连接状态 |
|
||||||
|
| `add_device(ip)` | 用 220 的 python3 精确改脚本 DEVICES 块 + `docker exec adb adb connect` |
|
||||||
|
| `remove_device(ip)` | 脚本删条目 + `adb disconnect` |
|
||||||
|
|
||||||
|
关键点:
|
||||||
|
- 脚本编辑用 python3 行级增删(sed 处理多行数组易误伤)
|
||||||
|
- `adb connect` 到不可达 IP 会挂 40s+,220 侧用 `timeout 15` 兜底,超时保留在脚本由 cron 重试
|
||||||
|
- 断开的是 220(STF provider 侧)的 adb 连接,与本机任务直连的 adb 相互独立
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. 任务系统设计
|
## 4. 任务系统设计
|
||||||
@@ -289,13 +315,22 @@ tasks/douyin/actions/like.py — @register_action(ACTIONS) LikeAction
|
|||||||
|
|
||||||
### 5.2 步骤编辑器
|
### 5.2 步骤编辑器
|
||||||
|
|
||||||
`generic_steps` 任务的步骤编辑器:
|
`generic_steps` 任务的步骤编辑器(任务弹窗已加宽到 1000px):
|
||||||
- 左侧操作库(拖拽源)
|
- 左侧操作库(拖拽源,按 交互操作/屏幕与App/流程控制 分组)
|
||||||
- 中间画布(步骤卡片列表,HTML5 Drag API 排序)
|
- 中间画布(步骤卡片列表,HTML5 Drag API 排序 + 跨层级嵌套:循环套循环、动作组)
|
||||||
- 每个步骤卡片可展开参数表单
|
- 每个步骤卡片可展开参数表单
|
||||||
- 选择器字段旁有"抓取元素"按钮(独立模态框)
|
- 选择器字段旁有"抓取元素"按钮(独立模态框)
|
||||||
|
- **容器步骤**(loop/group/if_el):卡片内嵌子步骤容器接收拖入;if_el 有"✅找到时/❌未找到时"两个独立分支容器,分支可嵌套任意步骤
|
||||||
|
- **条件判断**(if_el):选择器支持 xpath 等 UI 树选择器或 OCR识别(`core/ocr.py`,截屏匹配图片/画布文字,命中可自动点击)
|
||||||
|
- 所有递归操作(选择打包、存自定义动作、校验、防循环自套)统一遍历 children/then/else 三个子数组
|
||||||
|
|
||||||
### 5.3 元素抓取模态框
|
### 5.3 页内子分栏
|
||||||
|
|
||||||
|
任务/工具 Tab 用通用 `showSubTab(tabId, name)` 实现页内子分栏:每个子分栏一个 `.sub-panel`,
|
||||||
|
`_activeSubs` 记住各 Tab 上次选中的子分栏。工具 Tab 集中了全部管理工具(剪贴板注入/adb 终端/
|
||||||
|
Tailscale/应用管理/应用版本/已装应用/STF 设备管理),维护 Tab 仅保留 STF 服务。
|
||||||
|
|
||||||
|
### 5.4 元素抓取模态框
|
||||||
|
|
||||||
独立的第二层模态框(`el-picker-overlay`,z-index 1100),不影响任务编辑窗口:
|
独立的第二层模态框(`el-picker-overlay`,z-index 1100),不影响任务编辑窗口:
|
||||||
1. 选择设备 → 2. 加载截图 + 元素树 → 3. 点击元素/边界框 → 4. 回填选择器
|
1. 选择设备 → 2. 加载截图 + 元素树 → 3. 点击元素/边界框 → 4. 回填选择器
|
||||||
|
|||||||
+12
-4
@@ -74,6 +74,9 @@ pip install -r requirements.txt
|
|||||||
| uiautomator2 | >=3.0 | Android UI 自动化 |
|
| uiautomator2 | >=3.0 | Android UI 自动化 |
|
||||||
| uiautodev | >=0.14 | UI 元素抓取 |
|
| uiautodev | >=0.14 | UI 元素抓取 |
|
||||||
| pyaxmlparser | >=0.3.27 | APK 元信息解析 |
|
| pyaxmlparser | >=0.3.27 | APK 元信息解析 |
|
||||||
|
| rapidocr_onnxruntime | >=1.4 | 屏幕 OCR(条件判断的 OCR识别 选择器,中英文模型随包内置,跨平台) |
|
||||||
|
|
||||||
|
> 服务器(无显示器/Linux)环境建议把 opencv-python 换成 `opencv-python-headless`(rapidocr 依赖 cv2,两者取一)。
|
||||||
|
|
||||||
> uiautomator2 首次连接设备时会自动推送 atx-agent 到设备,无需手动安装。
|
> uiautomator2 首次连接设备时会自动推送 atx-agent 到设备,无需手动安装。
|
||||||
|
|
||||||
@@ -102,16 +105,21 @@ STF_URL = "http://你的STF地址:端口"
|
|||||||
| `WEB_HOST` | `0.0.0.0` | 仅本机访问改为 `127.0.0.1` |
|
| `WEB_HOST` | `0.0.0.0` | 仅本机访问改为 `127.0.0.1` |
|
||||||
| `WEB_PORT` | `18050` | 端口冲突时修改 |
|
| `WEB_PORT` | `18050` | 端口冲突时修改 |
|
||||||
| `ADB_PATH` | 自动识别 | 用自定义 adb 时修改 |
|
| `ADB_PATH` | 自动识别 | 用自定义 adb 时修改 |
|
||||||
| `STF_SSH_TARGET` | `[email protected]` | 维护页"一键重启 STF 容器"的 SSH 目标(需免密登录) |
|
| `STF_SSH_TARGET` | `[email protected]` | 维护页"一键重启 STF 容器"与工具页"STF 设备管理"的 SSH 目标 |
|
||||||
|
| `STF_SSH_PASSWORD` | (空) | SSH 密码认证(.env 配置后走 paramiko 密码登录,跨平台);留空则退回系统 ssh 免密密钥(BatchMode) |
|
||||||
| `STF_DOCKER_CONTAINER` | `stf` | 220 上 STF Docker 容器名(`docker ps` 查看) |
|
| `STF_DOCKER_CONTAINER` | `stf` | 220 上 STF Docker 容器名(`docker ps` 查看) |
|
||||||
|
| `STF_ADB_CONTAINER` | `adb` | 220 上 adb Docker 容器名(STF 设备管理执行 `docker exec adb adb ...`) |
|
||||||
|
| `STF_SCRIPT_PATH` | `/mnt/data/openstf/connect_devices.sh` | STF 设备池脚本路径(工具页增删设备会读写其 DEVICES 列表) |
|
||||||
| `TAILSCALE_TAILNET` | 按邮箱前缀 | tailnet 名称/ID(个人账号一般为登录邮箱前缀) |
|
| `TAILSCALE_TAILNET` | 按邮箱前缀 | tailnet 名称/ID(个人账号一般为登录邮箱前缀) |
|
||||||
|
|
||||||
> **未配置 `WEB_SECRET_KEY`**:启动时随机生成(每次重启登录态失效,生产务必配置固定值)。
|
> **未配置 `WEB_SECRET_KEY`**:启动时随机生成(每次重启登录态失效,生产务必配置固定值)。
|
||||||
> **维护页 Tailscale 前置**:`.env` 写入 `TAILSCALE_API_KEY` 后重启服务;
|
> **工具页 Tailscale 管理前置**:`.env` 写入 `TAILSCALE_API_KEY` 后重启服务;
|
||||||
> 未配置时管理分区显示明确提示,不影响其他功能。
|
> 未配置时管理分区显示明确提示,不影响其他功能。
|
||||||
|
|
||||||
> **维护页 STF 重启前置**:本机需能免密 SSH 到部署机(把本机公钥加入目标机 `authorized_keys`),
|
> **维护页 STF 重启 / 工具页 STF 设备管理前置**:需能 SSH 到部署机——两种方式任选:
|
||||||
> 且 `STF_DOCKER_CONTAINER` 与真实容器名一致;未配置好时按钮会返回明确错误。
|
> 1. `.env` 配置 `STF_SSH_PASSWORD` 走密码认证(paramiko,无需额外工具,推荐)
|
||||||
|
> 2. 免密密钥:本机公钥加入目标机 `authorized_keys`
|
||||||
|
> 且 `STF_DOCKER_CONTAINER`/`STF_ADB_CONTAINER` 与真实容器名一致;未配置好时按钮会返回明确错误。
|
||||||
|
|
||||||
### 2.4 启动服务
|
### 2.4 启动服务
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ APScheduler>=3.10,<4.0
|
|||||||
# HTTP 客户端(STF API 调用)
|
# HTTP 客户端(STF API 调用)
|
||||||
requests>=2.28
|
requests>=2.28
|
||||||
|
|
||||||
|
# SSH 远程执行(STF 重启 / STF 设备管理;.env 配 STF_SSH_PASSWORD 时走密码认证,
|
||||||
|
# 跨平台无需 sshpass;未配置则退回系统 ssh 免密密钥)
|
||||||
|
paramiko>=3.0
|
||||||
|
|
||||||
# Android UI 自动化(uiautomator2)
|
# Android UI 自动化(uiautomator2)
|
||||||
uiautomator2>=3.0
|
uiautomator2>=3.0
|
||||||
|
|
||||||
|
|||||||
+176
-54
@@ -60,31 +60,9 @@ async function apiPut(url,body){
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ================== 设备列表排序与多选 ==================
|
// ================== 设备列表排序与多选 ==================
|
||||||
let _sortKey=''; // 当前排序字段
|
|
||||||
let _sortDir=''; // '' | 'asc' | 'desc'
|
|
||||||
let _selectedSerials=new Set(); // 已勾选的 serial 集合
|
let _selectedSerials=new Set(); // 已勾选的 serial 集合
|
||||||
|
|
||||||
// 点击表头排序
|
// 排序比较:从设备对象取排序值(统一排序用,见 setListPager/sortValue)
|
||||||
function sortBy(key){
|
|
||||||
if(_sortKey===key){
|
|
||||||
// 同一列:切换方向;已是降序则取消
|
|
||||||
if(_sortDir==='asc'){_sortDir='desc';}
|
|
||||||
else if(_sortDir==='desc'){_sortKey='';_sortDir='';}
|
|
||||||
else{_sortDir='asc';}
|
|
||||||
}else{
|
|
||||||
_sortKey=key;_sortDir='asc';
|
|
||||||
}
|
|
||||||
// 更新表头样式
|
|
||||||
document.querySelectorAll('.table thead th.sortable').forEach(th=>{
|
|
||||||
th.classList.remove('sort-asc','sort-desc');
|
|
||||||
if(th.dataset.key===_sortKey){
|
|
||||||
th.classList.add(_sortDir==='asc'?'sort-asc':(_sortDir==='desc'?'sort-desc':''));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
renderDevices();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 排序比较:从设备对象取排序值
|
|
||||||
function _sortValue(dev,key){
|
function _sortValue(dev,key){
|
||||||
let v;
|
let v;
|
||||||
if(key==='progress'){
|
if(key==='progress'){
|
||||||
@@ -281,23 +259,22 @@ function showTab(name){
|
|||||||
document.querySelectorAll('.tab-panel').forEach(p=>p.classList.remove('active'));
|
document.querySelectorAll('.tab-panel').forEach(p=>p.classList.remove('active'));
|
||||||
document.querySelectorAll('.navbar .tab').forEach(t=>t.classList.remove('active'));
|
document.querySelectorAll('.navbar .tab').forEach(t=>t.classList.remove('active'));
|
||||||
document.getElementById('tab-'+name).classList.add('active');
|
document.getElementById('tab-'+name).classList.add('active');
|
||||||
document.querySelectorAll('.navbar .tab')[['monitor','tasks','apps','groups','logs','users','maintenance','tools'].indexOf(name)].classList.add('active');
|
document.querySelectorAll('.navbar .tab')[['monitor','tasks','groups','logs','users','maintenance','tools'].indexOf(name)].classList.add('active');
|
||||||
// 停止所有定时器
|
// 停止所有定时器
|
||||||
if(_monitorTimer){clearInterval(_monitorTimer);_monitorTimer=null;}
|
if(_monitorTimer){clearInterval(_monitorTimer);_monitorTimer=null;}
|
||||||
if(_logTimer){clearInterval(_logTimer);_logTimer=null;}
|
if(_logTimer){clearInterval(_logTimer);_logTimer=null;}
|
||||||
// 按需加载数据
|
// 按需加载数据
|
||||||
if(name==='monitor'){loadMonitor();_monitorTimer=setInterval(loadMonitor,5000);}
|
if(name==='monitor'){loadMonitor();_monitorTimer=setInterval(loadMonitor,5000);}
|
||||||
if(name==='tasks'){showSubTab('tasks',_activeSubs.tasks);loadTasks();loadCustomActions();}
|
if(name==='tasks'){showSubTab('tasks',_activeSubs.tasks);loadTasks();loadCustomActions();}
|
||||||
if(name==='apps'){showSubTab('apps',_activeSubs.apps);loadApks();}
|
if(name==='tools'){showSubTab('tools',_activeSubs.tools);loadToolsDevices();loadAdbDevices();loadTailscaleDevices();loadApks();loadStfDevMgmt();}
|
||||||
if(name==='tools'){showSubTab('tools',_activeSubs.tools);loadToolsDevices();loadAdbDevices();loadTailscaleDevices();}
|
|
||||||
if(name==='groups')loadGroups();
|
if(name==='groups')loadGroups();
|
||||||
if(name==='logs'){loadLogs();if(document.getElementById('log-auto').checked)_logTimer=setInterval(loadLogs,3000);}
|
if(name==='logs'){loadLogs();if(document.getElementById('log-auto').checked)_logTimer=setInterval(loadLogs,3000);}
|
||||||
if(name==='users')loadUsers();
|
if(name==='users')loadUsers();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ================== 页内子分栏(任务/应用/工具 通用) ==================
|
// ================== 页内子分栏(任务/工具 通用) ==================
|
||||||
// 每个带子分栏的 Tab 记住上次选中的子分栏,切走再切回来保持原位
|
// 每个带子分栏的 Tab 记住上次选中的子分栏,切走再切回来保持原位
|
||||||
let _activeSubs = {tasks: 'plan', apps: 'apks', tools: 'clipboard'};
|
let _activeSubs = {tasks: 'plan', tools: 'clipboard'};
|
||||||
|
|
||||||
function showSubTab(tabId, name){
|
function showSubTab(tabId, name){
|
||||||
_activeSubs[tabId] = name;
|
_activeSubs[tabId] = name;
|
||||||
@@ -351,6 +328,113 @@ function toggleClipDevice(cb){
|
|||||||
else _clipSelected.delete(cb.value);
|
else _clipSelected.delete(cb.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ================== 工具:应用版本管理 ==================
|
||||||
|
let _appVerRows = []; // [{serial, installed, version_name, version_code, error}]
|
||||||
|
|
||||||
|
async function queryAppVers(){
|
||||||
|
const pkg = document.getElementById('appver-pkg').value.trim();
|
||||||
|
if(!pkg){showToast('请输入包名','error');return;}
|
||||||
|
const btn = document.querySelector('#tools-sub-appver .btn-primary');
|
||||||
|
btn.disabled = true; const old = btn.textContent; btn.textContent = '查询中...';
|
||||||
|
try{
|
||||||
|
const r = await apiPost('/api/tools/appver', {pkg});
|
||||||
|
if(!r || !r.ok){showToast('查询失败: '+(r&&r.error||''),'error');return;}
|
||||||
|
_appVerRows = Object.entries(r.results||{}).map(([serial,v])=>({serial, ...v}));
|
||||||
|
setListPager('appver',{
|
||||||
|
data:()=>_appVerRows,
|
||||||
|
filter:(x,kw)=>((x.serial||'').toLowerCase().includes(kw)||(x.version_name||'').toLowerCase().includes(kw)||(x.version_code||'').toLowerCase().includes(kw)),
|
||||||
|
sortValue:(x,k)=>{
|
||||||
|
if(k==='state'){ if(x.error)return 2; return x.installed?0:1; }
|
||||||
|
if(k==='code')return parseInt(x.version_code)||0;
|
||||||
|
if(k==='version')return x.version_name||'';
|
||||||
|
return x.serial||'';
|
||||||
|
},
|
||||||
|
render:renderAppVerRow,
|
||||||
|
tbody:'tb-appver',pager:'pager-appver',pageSize:10,
|
||||||
|
empty:'<tr><td colspan="4" class="empty">无可用设备</td></tr>'
|
||||||
|
});
|
||||||
|
applyListPager('appver');
|
||||||
|
initListSearch('appver','search-appver');
|
||||||
|
document.getElementById('appver-status').textContent = '共 '+(r.total||0)+' 台设备'+(r.fail?','+r.fail+' 台查询失败':'');
|
||||||
|
if(r.fail)showToast(r.fail+' 台设备查询失败,详见表格','error');
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false; btn.textContent = old;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function renderAppVerRow(x){
|
||||||
|
if(x.error)return '<tr><td style="font-family:monospace">'+esc(x.serial)+'</td>'+
|
||||||
|
'<td><span class="label label-warning">查询失败</span></td><td colspan="2">'+esc(x.error)+'</td></tr>';
|
||||||
|
if(x.installed)return '<tr><td style="font-family:monospace">'+esc(x.serial)+'</td>'+
|
||||||
|
'<td><span class="label label-success">已安装</span></td>'+
|
||||||
|
'<td>'+esc(x.version_name||'')+'</td><td style="font-family:monospace">'+esc(x.version_code||'')+'</td></tr>';
|
||||||
|
return '<tr><td style="font-family:monospace">'+esc(x.serial)+'</td>'+
|
||||||
|
'<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><button class="btn btn-xs btn-danger" onclick="removeStfDev(\''+esc(x.ip)+'\')">移除(断开+删脚本)</button></td></tr>';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addStfDev(){
|
||||||
|
const ip = document.getElementById('stfdev-ip').value.trim();
|
||||||
|
if(!ip){showToast('请输入设备 IP','error');return;}
|
||||||
|
const r = await apiPost('/api/stf/devmgmt/add', {ip:ip});
|
||||||
|
if(!r)return;
|
||||||
|
if(r.ok){
|
||||||
|
showToast((r.msgs||[]).join(';'),'success');
|
||||||
|
document.getElementById('stfdev-ip').value='';
|
||||||
|
loadStfDevMgmt();
|
||||||
|
}else showToast('添加失败: '+(r.error||''),'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeStfDev(ip){
|
||||||
|
if(!confirm('确定移除 '+ip+'?\n\n将从 connect_devices.sh 删除并断开 220 的 adb 连接,\n设备会从 OpenSTF 设备池下线(任务不再分配到它)。'))return;
|
||||||
|
const r = await apiPost('/api/stf/devmgmt/remove', {ip:ip});
|
||||||
|
if(!r)return;
|
||||||
|
if(r.ok){
|
||||||
|
showToast((r.msgs||[]).join(';'),'success');
|
||||||
|
loadStfDevMgmt();
|
||||||
|
}else showToast('移除失败: '+(r.error||''),'error');
|
||||||
|
}
|
||||||
|
|
||||||
async function injectClipboard(){
|
async function injectClipboard(){
|
||||||
const text = document.getElementById('clip-text').value.trim();
|
const text = document.getElementById('clip-text').value.trim();
|
||||||
if(!text){showToast('请输入要注入的文字','error');return;}
|
if(!text){showToast('请输入要注入的文字','error');return;}
|
||||||
@@ -370,18 +454,22 @@ async function injectClipboard(){
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ================== 通用列表:搜索 + 分页 ==================
|
// ================== 通用列表:搜索 + 分页 + 排序 ==================
|
||||||
// 用法:
|
// 用法:
|
||||||
// setListPager(key,{data:()=>array, filter:(item,kw)=>bool, render:(item)=>html,
|
// setListPager(key,{data:()=>array, filter:(item,kw)=>bool, render:(item)=>html,
|
||||||
// tbody:'tb-id', pager:'pager-id', pageSize:N});
|
// tbody:'tb-id', pager:'pager-id', pageSize:N,
|
||||||
// applyListPager(key); // 数据变化后重渲染(含搜索/翻页状态)
|
// sortValue:(item,colKey)=>值, // 可排序列的取值函数
|
||||||
// initListSearch(key,'search-id');// 搜索框绑定
|
// empty:'<tr>...'});
|
||||||
|
// applyListPager(key); // 数据变化后重渲染(含搜索/翻页/排序状态)
|
||||||
|
// initListSearch(key,'search-id'); // 搜索框绑定
|
||||||
|
// _lpSort(key,'colKey'); // 表头排序:th 加 data-sort="colKey" + onclick,
|
||||||
|
// // 再点切换升降序,第三次取消;排序指示器自动更新
|
||||||
var _LIST_PAGERS={};
|
var _LIST_PAGERS={};
|
||||||
|
|
||||||
function _lp(key){ return _LIST_PAGERS[key] || (_LIST_PAGERS[key]={page:1,kw:'',cfg:null}); }
|
function _lp(key){ return _LIST_PAGERS[key] || (_LIST_PAGERS[key]={page:1,kw:'',cfg:null,sort:null}); }
|
||||||
|
|
||||||
function setListPager(key,cfg){
|
function setListPager(key,cfg){
|
||||||
// 只更新配置,不重置 page/kw —— 监控列表 5 秒自动刷新时保持用户当前页
|
// 只更新配置,不重置 page/kw/sort —— 监控列表 5 秒自动刷新时保持用户当前状态
|
||||||
var st=_lp(key); st.cfg=cfg;
|
var st=_lp(key); st.cfg=cfg;
|
||||||
}
|
}
|
||||||
function initListSearch(key,inputId){
|
function initListSearch(key,inputId){
|
||||||
@@ -392,6 +480,17 @@ function initListSearch(key,inputId){
|
|||||||
var st=_lp(key); st.kw=this.value.trim(); st.page=1; applyListPager(key);
|
var st=_lp(key); st.kw=this.value.trim(); st.page=1; applyListPager(key);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
function _lpSort(key,colKey){
|
||||||
|
var st=_lp(key);
|
||||||
|
if(st.sort&&st.sort.key===colKey){
|
||||||
|
if(st.sort.dir===1)st.sort.dir=-1; // 升序 → 降序
|
||||||
|
else if(st.sort.dir===-1)st.sort=null; // 降序 → 取消排序
|
||||||
|
else st.sort={key:colKey,dir:1};
|
||||||
|
}else{
|
||||||
|
st.sort={key:colKey,dir:1};
|
||||||
|
}
|
||||||
|
applyListPager(key);
|
||||||
|
}
|
||||||
function applyListPager(key){
|
function applyListPager(key){
|
||||||
var st=_lp(key);
|
var st=_lp(key);
|
||||||
if(!st.cfg)return;
|
if(!st.cfg)return;
|
||||||
@@ -399,6 +498,18 @@ function applyListPager(key){
|
|||||||
var all=(cfg.data&&cfg.data())||[];
|
var all=(cfg.data&&cfg.data())||[];
|
||||||
var kw=st.kw.toLowerCase();
|
var kw=st.kw.toLowerCase();
|
||||||
var filtered=kw?all.filter(function(x){return cfg.filter(x,kw);}):all;
|
var filtered=kw?all.filter(function(x){return cfg.filter(x,kw);}):all;
|
||||||
|
// 排序:cfg.sortValue 取列值;空值/缺失恒排最后;字符串用中文数字感知比较(10 排在 9 后)
|
||||||
|
if(st.sort&&cfg.sortValue){
|
||||||
|
var sk=st.sort.key, dir=st.sort.dir;
|
||||||
|
filtered=filtered.slice().sort(function(a,b){
|
||||||
|
var va=cfg.sortValue(a,sk), vb=cfg.sortValue(b,sk);
|
||||||
|
if(va===vb)return 0;
|
||||||
|
if(va==null||va==='')return 1;
|
||||||
|
if(vb==null||vb==='')return -1;
|
||||||
|
if(typeof va==='number'&&typeof vb==='number')return (va-vb)*dir;
|
||||||
|
return String(va).localeCompare(String(vb),'zh-Hans-CN',{numeric:true})*dir;
|
||||||
|
});
|
||||||
|
}
|
||||||
var pageSize=cfg.pageSize||10;
|
var pageSize=cfg.pageSize||10;
|
||||||
var pages=Math.max(1,Math.ceil(filtered.length/pageSize));
|
var pages=Math.max(1,Math.ceil(filtered.length/pageSize));
|
||||||
if(st.page>pages)st.page=pages;
|
if(st.page>pages)st.page=pages;
|
||||||
@@ -406,7 +517,18 @@ function applyListPager(key){
|
|||||||
var start=(st.page-1)*pageSize;
|
var start=(st.page-1)*pageSize;
|
||||||
var slice=filtered.slice(start,start+pageSize);
|
var slice=filtered.slice(start,start+pageSize);
|
||||||
var tb=document.getElementById(cfg.tbody);
|
var tb=document.getElementById(cfg.tbody);
|
||||||
if(tb)tb.innerHTML=slice.map(cfg.render).join('')||(cfg.empty||'<tr><td colspan="20" class="empty">无数据</td></tr>');
|
if(tb){
|
||||||
|
tb.innerHTML=slice.map(cfg.render).join('')||(cfg.empty||'<tr><td colspan="20" class="empty">无数据</td></tr>');
|
||||||
|
// 表头排序指示器(th[data-sort],所有表格统一)
|
||||||
|
if(cfg.sortValue){
|
||||||
|
var table=tb.closest('table');
|
||||||
|
if(table)table.querySelectorAll('thead th[data-sort]').forEach(function(th){
|
||||||
|
th.classList.add('sortable');
|
||||||
|
th.classList.toggle('sort-asc',!!st.sort&&st.sort.key===th.dataset.sort&&st.sort.dir===1);
|
||||||
|
th.classList.toggle('sort-desc',!!st.sort&&st.sort.key===th.dataset.sort&&st.sort.dir===-1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
var pg=document.getElementById(cfg.pager);
|
var pg=document.getElementById(cfg.pager);
|
||||||
if(pg)pg.innerHTML=_lpPagerHtml(key,pages,st.page,filtered.length,pageSize);
|
if(pg)pg.innerHTML=_lpPagerHtml(key,pages,st.page,filtered.length,pageSize);
|
||||||
}
|
}
|
||||||
@@ -516,33 +638,20 @@ async function loadSummary(){
|
|||||||
}catch(e){console.error(e);}
|
}catch(e){console.error(e);}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 渲染设备表格(带排序+搜索+分页,复选状态保持)
|
// 渲染设备表格(统一排序+搜索+分页,复选状态保持)
|
||||||
let _sortedDevices=[];
|
|
||||||
let _devicesSig='';
|
let _devicesSig='';
|
||||||
function renderDevices(){
|
function renderDevices(){
|
||||||
if(!_statusData||!_statusData.devices){return;}
|
if(!_statusData||!_statusData.devices){return;}
|
||||||
// 5秒自动刷新时若设备数据/分页/排序都没变化,跳过整表重渲染(避免DOM抖动)
|
// 5秒自动刷新时若设备数据/分页/搜索/排序都没变化,跳过整表重渲染(避免DOM抖动)
|
||||||
const _st=_lp('devices');
|
const _st=_lp('devices');
|
||||||
const _sig=JSON.stringify({d:_statusData.devices,p:_st.page,k:_st.kw,s:_sortKey+(_sortDir||'')});
|
const _sig=JSON.stringify({d:_statusData.devices,p:_st.page,k:_st.kw,s:_st.sort});
|
||||||
if(_sig===_devicesSig)return;
|
if(_sig===_devicesSig)return;
|
||||||
_devicesSig=_sig;
|
_devicesSig=_sig;
|
||||||
let devices=_statusData.devices.slice();
|
// 排序由 applyListPager 统一处理(sortValue + _lpSort)
|
||||||
// 排序
|
|
||||||
if(_sortKey&&_sortDir){
|
|
||||||
const dir=_sortDir==='asc'?1:-1;
|
|
||||||
devices.sort((a,b)=>{
|
|
||||||
let va=_sortValue(a,_sortKey),vb=_sortValue(b,_sortKey);
|
|
||||||
if(typeof va==='string'&&typeof vb==='string'){
|
|
||||||
return va.localeCompare(vb)*dir;
|
|
||||||
}
|
|
||||||
va=Number(va)||0;vb=Number(vb)||0;
|
|
||||||
return (va-vb)*dir;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
_sortedDevices=devices;
|
|
||||||
setListPager('devices',{
|
setListPager('devices',{
|
||||||
data:()=>_sortedDevices,
|
data:()=>_statusData.devices||[],
|
||||||
filter:(dev,kw)=>((dev.serial||'').toLowerCase().includes(kw)||(dev.model||'').toLowerCase().includes(kw)||(dev.foreground_app||'').toLowerCase().includes(kw)||(dev.task_job||'').toLowerCase().includes(kw)||(dev.current_action||'').toLowerCase().includes(kw)),
|
filter:(dev,kw)=>((dev.serial||'').toLowerCase().includes(kw)||(dev.model||'').toLowerCase().includes(kw)||(dev.foreground_app||'').toLowerCase().includes(kw)||(dev.task_job||'').toLowerCase().includes(kw)||(dev.current_action||'').toLowerCase().includes(kw)),
|
||||||
|
sortValue:_sortValue,
|
||||||
render:renderDeviceRow,
|
render:renderDeviceRow,
|
||||||
tbody:'tb-devices',pager:'pager-devices',pageSize:10,
|
tbody:'tb-devices',pager:'pager-devices',pageSize:10,
|
||||||
empty:'<tr><td colspan="11" class="empty">暂无设备</td></tr>'
|
empty:'<tr><td colspan="11" class="empty">暂无设备</td></tr>'
|
||||||
@@ -725,6 +834,7 @@ function renderTaskTable(){
|
|||||||
setListPager('tasks',{
|
setListPager('tasks',{
|
||||||
data:()=>jobs,
|
data:()=>jobs,
|
||||||
filter:(j,kw)=>((j.name||'').toLowerCase().includes(kw)||(j.task_type||'').toLowerCase().includes(kw)||(j.id||'').toLowerCase().includes(kw)),
|
filter:(j,kw)=>((j.name||'').toLowerCase().includes(kw)||(j.task_type||'').toLowerCase().includes(kw)||(j.id||'').toLowerCase().includes(kw)),
|
||||||
|
sortValue:(j,k)=>{ if(k==='id')return j.id||''; if(k==='enabled')return j.enabled?1:0; if(k==='next_run')return j.next_run||''; return j[k]||''; },
|
||||||
render:renderTaskRow,
|
render:renderTaskRow,
|
||||||
tbody:'tb-tasks',pager:'pager-tasks',pageSize:10,
|
tbody:'tb-tasks',pager:'pager-tasks',pageSize:10,
|
||||||
empty:'<tr><td colspan="9" class="empty">暂无任务,点击"新建任务"创建</td></tr>'
|
empty:'<tr><td colspan="9" class="empty">暂无任务,点击"新建任务"创建</td></tr>'
|
||||||
@@ -2045,6 +2155,7 @@ async function loadCustomActions(){
|
|||||||
setListPager('actions',{
|
setListPager('actions',{
|
||||||
data:()=>_customActions,
|
data:()=>_customActions,
|
||||||
filter:(a,kw)=>((a.name||'').toLowerCase().includes(kw)),
|
filter:(a,kw)=>((a.name||'').toLowerCase().includes(kw)),
|
||||||
|
sortValue:(a,k)=>{ if(k==='steps')return (a.steps||[]).length; if(k==='created_at')return a.created_at||''; return a[k]||''; },
|
||||||
render:renderActionRow,
|
render:renderActionRow,
|
||||||
tbody:'tb-actions',pager:'pager-actions',pageSize:10,
|
tbody:'tb-actions',pager:'pager-actions',pageSize:10,
|
||||||
empty:'<tr><td colspan="5" class="empty">暂无自定义动作,可点击"新建动作"或用任务编辑器☑选中步骤打包</td></tr>'
|
empty:'<tr><td colspan="5" class="empty">暂无自定义动作,可点击"新建动作"或用任务编辑器☑选中步骤打包</td></tr>'
|
||||||
@@ -2132,6 +2243,7 @@ function renderGroupTable(){
|
|||||||
setListPager('groups',{
|
setListPager('groups',{
|
||||||
data:()=>groups,
|
data:()=>groups,
|
||||||
filter:(g,kw)=>((g.name||'').toLowerCase().includes(kw)||(g.description||'').toLowerCase().includes(kw)||(g.serials||[]).join(' ').toLowerCase().includes(kw)),
|
filter:(g,kw)=>((g.name||'').toLowerCase().includes(kw)||(g.description||'').toLowerCase().includes(kw)||(g.serials||[]).join(' ').toLowerCase().includes(kw)),
|
||||||
|
sortValue:(g,k)=>{ if(k==='count')return (g.serials||[]).length; return g[k]||''; },
|
||||||
render:renderGroupRow,
|
render:renderGroupRow,
|
||||||
tbody:'tb-groups',pager:'pager-groups',pageSize:10,
|
tbody:'tb-groups',pager:'pager-groups',pageSize:10,
|
||||||
empty:'<tr><td colspan="5" class="empty">暂无分组,点击"新建分组"创建</td></tr>'
|
empty:'<tr><td colspan="5" class="empty">暂无分组,点击"新建分组"创建</td></tr>'
|
||||||
@@ -2236,6 +2348,7 @@ async function loadUsers(){
|
|||||||
setListPager('users',{
|
setListPager('users',{
|
||||||
data:()=>users,
|
data:()=>users,
|
||||||
filter:(u,kw)=>(u.username||'').toLowerCase().includes(kw),
|
filter:(u,kw)=>(u.username||'').toLowerCase().includes(kw),
|
||||||
|
sortValue:(u,k)=>{ if(k==='is_admin')return u.is_admin?1:0; return u[k]||''; },
|
||||||
render:renderUserRow,
|
render:renderUserRow,
|
||||||
tbody:'tb-users',pager:'pager-users',pageSize:10,
|
tbody:'tb-users',pager:'pager-users',pageSize:10,
|
||||||
empty:'<tr><td colspan="5" class="empty">暂无用户</td></tr>'
|
empty:'<tr><td colspan="5" class="empty">暂无用户</td></tr>'
|
||||||
@@ -2436,6 +2549,13 @@ async function loadTailscaleDevices(){
|
|||||||
setListPager('tailscale',{
|
setListPager('tailscale',{
|
||||||
data:()=>_tsDevices,
|
data:()=>_tsDevices,
|
||||||
filter:(d,kw)=>((d.hostname||'').toLowerCase().includes(kw)||(d.name||'').toLowerCase().includes(kw)||(d.addresses||[]).join(',').includes(kw)),
|
filter:(d,kw)=>((d.hostname||'').toLowerCase().includes(kw)||(d.name||'').toLowerCase().includes(kw)||(d.addresses||[]).join(',').includes(kw)),
|
||||||
|
sortValue:(d,k)=>{
|
||||||
|
if(k==='ip')return (d.addresses||[])[0]||'';
|
||||||
|
if(k==='online'){ if(d.online===true)return 0; if(d.online===false)return 2; return 1; } // null=最近12h在线
|
||||||
|
if(k==='authorized')return d.authorized?1:0;
|
||||||
|
if(k==='key_expiry')return d.key_expiry_disabled?1:0;
|
||||||
|
return d[k]||'';
|
||||||
|
},
|
||||||
render:renderTsRow,
|
render:renderTsRow,
|
||||||
tbody:'tb-tailscale',pager:'pager-tailscale',pageSize:10,
|
tbody:'tb-tailscale',pager:'pager-tailscale',pageSize:10,
|
||||||
empty:'<tr><td colspan="8" class="empty">tailnet 暂无设备</td></tr>'
|
empty:'<tr><td colspan="8" class="empty">tailnet 暂无设备</td></tr>'
|
||||||
@@ -2558,6 +2678,7 @@ async function loadApks(){
|
|||||||
document.getElementById('apk-hint').textContent='共 '+apks.length+' 个应用';
|
document.getElementById('apk-hint').textContent='共 '+apks.length+' 个应用';
|
||||||
setListPager('apks',{
|
setListPager('apks',{
|
||||||
data:()=>apks,
|
data:()=>apks,
|
||||||
|
sortValue:(a,k)=>{ if(k==='size')return a.size||0; if(k==='upload_time')return a.upload_time||''; if(k==='package_name')return a.package_name||''; if(k==='version_name')return a.version_name||''; return a.display_name||''; },
|
||||||
filter:(a,kw)=>((a.display_name||'').toLowerCase().includes(kw)||(a.package_name||'').toLowerCase().includes(kw)),
|
filter:(a,kw)=>((a.display_name||'').toLowerCase().includes(kw)||(a.package_name||'').toLowerCase().includes(kw)),
|
||||||
render:renderApkRow,
|
render:renderApkRow,
|
||||||
tbody:'tb-apks',pager:'pager-apks',pageSize:10,
|
tbody:'tb-apks',pager:'pager-apks',pageSize:10,
|
||||||
@@ -2616,6 +2737,7 @@ async function loadDeviceApps(){
|
|||||||
setListPager('devapps',{
|
setListPager('devapps',{
|
||||||
data:()=>_devAppsData||[],
|
data:()=>_devAppsData||[],
|
||||||
filter:(a,kw)=>((a.label||a.package||'').toLowerCase().includes(kw)||(a.package||'').toLowerCase().includes(kw)||(a.path||'').toLowerCase().includes(kw)),
|
filter:(a,kw)=>((a.label||a.package||'').toLowerCase().includes(kw)||(a.package||'').toLowerCase().includes(kw)||(a.path||'').toLowerCase().includes(kw)),
|
||||||
|
sortValue:(a,k)=>{ if(k==='version_code')return parseInt(a.version_code)||0; if(k==='version_name')return a.version_name||''; if(k==='label')return (a.label||a.package||''); return a[k]||''; },
|
||||||
render:renderDevAppRow,
|
render:renderDevAppRow,
|
||||||
tbody:'tb-device-apps',pager:'pager-devapps',pageSize:20,
|
tbody:'tb-device-apps',pager:'pager-devapps',pageSize:20,
|
||||||
empty:'<tr><td colspan="4" class="empty">无应用</td></tr>'
|
empty:'<tr><td colspan="4" class="empty">无应用</td></tr>'
|
||||||
|
|||||||
+111
-70
@@ -286,7 +286,6 @@ select.form-control{cursor:pointer}
|
|||||||
<div class="tabs">
|
<div class="tabs">
|
||||||
<button class="tab active" onclick="showTab('monitor')">监控</button>
|
<button class="tab active" onclick="showTab('monitor')">监控</button>
|
||||||
<button class="tab" onclick="showTab('tasks')">任务</button>
|
<button class="tab" onclick="showTab('tasks')">任务</button>
|
||||||
<button class="tab" onclick="showTab('apps')" data-perm="apks">应用</button>
|
|
||||||
<button class="tab" onclick="showTab('groups')">分组</button>
|
<button class="tab" onclick="showTab('groups')">分组</button>
|
||||||
<button class="tab" onclick="showTab('logs')" data-perm="logs">日志</button>
|
<button class="tab" onclick="showTab('logs')" data-perm="logs">日志</button>
|
||||||
<button class="tab" onclick="showTab('users')" data-perm="admin">用户</button>
|
<button class="tab" onclick="showTab('users')" data-perm="admin">用户</button>
|
||||||
@@ -322,15 +321,15 @@ select.form-control{cursor:pointer}
|
|||||||
<table class="table">
|
<table class="table">
|
||||||
<thead><tr>
|
<thead><tr>
|
||||||
<th class="col-cb"><input type="checkbox" id="cb-all" onclick="toggleAllDevices(this.checked)" title="全选/取消全选"></th>
|
<th class="col-cb"><input type="checkbox" id="cb-all" onclick="toggleAllDevices(this.checked)" title="全选/取消全选"></th>
|
||||||
<th class="sortable" data-key="serial" onclick="sortBy('serial')">设备</th>
|
<th data-sort="serial" onclick="_lpSort('devices','serial')">设备</th>
|
||||||
<th class="sortable" data-key="model" onclick="sortBy('model')">型号</th>
|
<th data-sort="model" onclick="_lpSort('devices','model')">型号</th>
|
||||||
<th class="sortable" data-key="stf_occupied" onclick="sortBy('stf_occupied')">STF</th>
|
<th data-sort="stf_occupied" onclick="_lpSort('devices','stf_occupied')">STF</th>
|
||||||
<th class="sortable" data-key="worker_status" onclick="sortBy('worker_status')">任务状态</th>
|
<th data-sort="worker_status" onclick="_lpSort('devices','worker_status')">任务状态</th>
|
||||||
<th class="sortable" data-key="foreground_app" onclick="sortBy('foreground_app')">前台App</th>
|
<th data-sort="foreground_app" onclick="_lpSort('devices','foreground_app')">前台App</th>
|
||||||
<th class="sortable" data-key="progress" onclick="sortBy('progress')">进度</th>
|
<th data-sort="progress" onclick="_lpSort('devices','progress')">进度</th>
|
||||||
<th class="sortable" data-key="task_job" onclick="sortBy('task_job')">当前任务</th>
|
<th data-sort="task_job" onclick="_lpSort('devices','task_job')">当前任务</th>
|
||||||
<th class="sortable" data-key="current_action" onclick="sortBy('current_action')">当前动作</th>
|
<th data-sort="current_action" onclick="_lpSort('devices','current_action')">当前动作</th>
|
||||||
<th class="sortable" data-key="last_error" onclick="sortBy('last_error')">最近错误</th>
|
<th data-sort="last_error" onclick="_lpSort('devices','last_error')">最近错误</th>
|
||||||
<th>操作</th>
|
<th>操作</th>
|
||||||
</tr></thead>
|
</tr></thead>
|
||||||
<tbody id="tb-devices"></tbody>
|
<tbody id="tb-devices"></tbody>
|
||||||
@@ -370,7 +369,7 @@ select.form-control{cursor:pointer}
|
|||||||
</div>
|
</div>
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<thead><tr>
|
<thead><tr>
|
||||||
<th>ID</th><th>任务名</th><th>类型</th><th>启用</th><th>目标</th><th>调度</th><th>下次运行</th><th>重试</th><th>操作</th>
|
<th data-sort="id" onclick="_lpSort('tasks','id')">ID</th><th data-sort="name" onclick="_lpSort('tasks','name')">任务名</th><th data-sort="task_type" onclick="_lpSort('tasks','task_type')">类型</th><th data-sort="enabled" onclick="_lpSort('tasks','enabled')">启用</th><th>目标</th><th>调度</th><th data-sort="next_run" onclick="_lpSort('tasks','next_run')">下次运行</th><th>重试</th><th>操作</th>
|
||||||
</tr></thead>
|
</tr></thead>
|
||||||
<tbody id="tb-tasks"></tbody>
|
<tbody id="tb-tasks"></tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -386,67 +385,13 @@ select.form-control{cursor:pointer}
|
|||||||
</div>
|
</div>
|
||||||
<div class="help">把常用步骤打包成可复用的动作,拖入任务步骤编辑器使用;也可在任务编辑器里"☑选中步骤→打包"生成</div>
|
<div class="help">把常用步骤打包成可复用的动作,拖入任务步骤编辑器使用;也可在任务编辑器里"☑选中步骤→打包"生成</div>
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<thead><tr><th>图标</th><th>动作名</th><th>步骤数</th><th>创建时间</th><th>操作</th></tr></thead>
|
<thead><tr><th>图标</th><th data-sort="name" onclick="_lpSort('actions','name')">动作名</th><th data-sort="steps" onclick="_lpSort('actions','steps')">步骤数</th><th data-sort="created_at" onclick="_lpSort('actions','created_at')">创建时间</th><th>操作</th></tr></thead>
|
||||||
<tbody id="tb-actions"></tbody>
|
<tbody id="tb-actions"></tbody>
|
||||||
</table>
|
</table>
|
||||||
<div class="list-pager" id="pager-actions"></div>
|
<div class="list-pager" id="pager-actions"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ========== Tab: 应用 ========== -->
|
|
||||||
<div id="tab-apps" class="tab-panel">
|
|
||||||
<div class="page-title">应用</div>
|
|
||||||
<div class="page-sub">APK 上传安装与设备已装应用查看</div>
|
|
||||||
|
|
||||||
<div class="sub-tabs">
|
|
||||||
<button class="sub-tab active" data-sub="apks" onclick="showSubTab('apps','apks')">应用管理</button>
|
|
||||||
<button class="sub-tab" data-sub="devapps" onclick="showSubTab('apps','devapps')">设备已装应用</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 子分栏 1:应用管理 -->
|
|
||||||
<div class="sub-panel active" id="apps-sub-apks">
|
|
||||||
<div class="toolbar">
|
|
||||||
<label class="btn btn-primary" style="cursor:pointer;margin-bottom:0">
|
|
||||||
上传APK
|
|
||||||
<input type="file" accept=".apk" style="display:none" onchange="uploadApk(this)">
|
|
||||||
</label>
|
|
||||||
<button class="btn" onclick="loadApks()">刷新</button>
|
|
||||||
<input class="form-control list-search" id="search-apks" placeholder="搜索应用名/包名...">
|
|
||||||
<span class="hint" id="apk-hint"></span>
|
|
||||||
</div>
|
|
||||||
<table class="table">
|
|
||||||
<thead><tr>
|
|
||||||
<th>应用名</th><th>包名</th><th>版本</th><th>大小</th><th>上传时间</th><th>操作</th>
|
|
||||||
</tr></thead>
|
|
||||||
<tbody id="tb-apks"></tbody>
|
|
||||||
</table>
|
|
||||||
<div class="list-pager" id="pager-apks"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 子分栏 2:设备已装应用 -->
|
|
||||||
<div class="sub-panel" id="apps-sub-devapps">
|
|
||||||
<div class="toolbar">
|
|
||||||
<select id="apps-device" class="form-control" style="width:280px;display:inline-block">
|
|
||||||
<option value="">选择设备...</option>
|
|
||||||
</select>
|
|
||||||
<button class="btn btn-primary" onclick="loadDeviceApps()">查看应用</button>
|
|
||||||
<input class="form-control list-search" id="search-devapps" placeholder="搜索包名/路径...">
|
|
||||||
<span class="hint" id="apps-hint"></span>
|
|
||||||
</div>
|
|
||||||
<div style="max-height:420px;overflow:auto;border:1px solid var(--border);border-radius:8px">
|
|
||||||
<table class="table" style="margin:0">
|
|
||||||
<thead><tr>
|
|
||||||
<th>应用</th><th>版本号</th><th>versionCode</th><th>APK 路径</th>
|
|
||||||
</tr></thead>
|
|
||||||
<tbody id="tb-device-apps">
|
|
||||||
<tr><td colspan="4" class="empty">选择设备后点击"查看应用"</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div class="list-pager" id="pager-devapps"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ========== Tab 3: 分组 ========== -->
|
<!-- ========== Tab 3: 分组 ========== -->
|
||||||
<div id="tab-groups" class="tab-panel">
|
<div id="tab-groups" class="tab-panel">
|
||||||
<div class="page-title">设备分组管理</div>
|
<div class="page-title">设备分组管理</div>
|
||||||
@@ -458,7 +403,7 @@ select.form-control{cursor:pointer}
|
|||||||
</div>
|
</div>
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<thead><tr>
|
<thead><tr>
|
||||||
<th>分组名</th><th>描述</th><th>设备数</th><th>设备列表</th><th>操作</th>
|
<th data-sort="name" onclick="_lpSort('groups','name')">分组名</th><th>描述</th><th data-sort="count" onclick="_lpSort('groups','count')">设备数</th><th>设备列表</th><th>操作</th>
|
||||||
</tr></thead>
|
</tr></thead>
|
||||||
<tbody id="tb-groups"></tbody>
|
<tbody id="tb-groups"></tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -500,7 +445,7 @@ select.form-control{cursor:pointer}
|
|||||||
</div>
|
</div>
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<thead><tr>
|
<thead><tr>
|
||||||
<th>ID</th><th>用户名</th><th>管理员</th><th>权限</th><th>操作</th>
|
<th data-sort="id" onclick="_lpSort('users','id')">ID</th><th data-sort="username" onclick="_lpSort('users','username')">用户名</th><th data-sort="is_admin" onclick="_lpSort('users','is_admin')">管理员</th><th>权限</th><th>操作</th>
|
||||||
</tr></thead>
|
</tr></thead>
|
||||||
<tbody id="tb-users"></tbody>
|
<tbody id="tb-users"></tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -525,12 +470,16 @@ select.form-control{cursor:pointer}
|
|||||||
<!-- ========== Tab 8: 工具(仅管理员) ========== -->
|
<!-- ========== Tab 8: 工具(仅管理员) ========== -->
|
||||||
<div id="tab-tools" class="tab-panel">
|
<div id="tab-tools" class="tab-panel">
|
||||||
<div class="page-title">工具</div>
|
<div class="page-title">工具</div>
|
||||||
<div class="page-sub">管理员专用:常用设备工具集合</div>
|
<div class="page-sub">管理员专用:设备工具与应用管理集合</div>
|
||||||
|
|
||||||
<div class="sub-tabs">
|
<div class="sub-tabs">
|
||||||
<button class="sub-tab active" data-sub="clipboard" onclick="showSubTab('tools','clipboard')">剪贴板注入</button>
|
<button class="sub-tab active" data-sub="clipboard" onclick="showSubTab('tools','clipboard')">剪贴板注入</button>
|
||||||
<button class="sub-tab" data-sub="adb" onclick="showSubTab('tools','adb')">adb 远程终端</button>
|
<button class="sub-tab" data-sub="adb" onclick="showSubTab('tools','adb')">adb 远程终端</button>
|
||||||
<button class="sub-tab" data-sub="ts" onclick="showSubTab('tools','ts')">Tailscale 管理</button>
|
<button class="sub-tab" data-sub="ts" onclick="showSubTab('tools','ts')">Tailscale 管理</button>
|
||||||
|
<button class="sub-tab" data-sub="apks" onclick="showSubTab('tools','apks')">应用管理</button>
|
||||||
|
<button class="sub-tab" data-sub="appver" onclick="showSubTab('tools','appver')">应用版本管理</button>
|
||||||
|
<button class="sub-tab" data-sub="devapps" onclick="showSubTab('tools','devapps')">设备已装应用</button>
|
||||||
|
<button class="sub-tab" data-sub="stfdev" onclick="showSubTab('tools','stfdev')">STF 设备管理</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 子分栏 1:剪贴板注入 -->
|
<!-- 子分栏 1:剪贴板注入 -->
|
||||||
@@ -612,13 +561,105 @@ select.form-control{cursor:pointer}
|
|||||||
</div>
|
</div>
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<thead><tr>
|
<thead><tr>
|
||||||
<th>主机名</th><th>显示名</th><th>IP</th><th>系统</th><th>在线</th><th>授权</th><th>密钥过期</th><th>操作</th>
|
<th data-sort="hostname" onclick="_lpSort('tailscale','hostname')">主机名</th><th data-sort="name" onclick="_lpSort('tailscale','name')">显示名</th><th data-sort="ip" onclick="_lpSort('tailscale','ip')">IP</th><th data-sort="os" onclick="_lpSort('tailscale','os')">系统</th><th data-sort="online" onclick="_lpSort('tailscale','online')">在线</th><th data-sort="authorized" onclick="_lpSort('tailscale','authorized')">授权</th><th data-sort="key_expiry" onclick="_lpSort('tailscale','key_expiry')">密钥过期</th><th>操作</th>
|
||||||
</tr></thead>
|
</tr></thead>
|
||||||
<tbody id="tb-tailscale"><tr><td colspan="8" class="empty">点击"刷新设备"加载</td></tr></tbody>
|
<tbody id="tb-tailscale"><tr><td colspan="8" class="empty">点击"刷新设备"加载</td></tr></tbody>
|
||||||
</table>
|
</table>
|
||||||
<div class="list-pager" id="pager-tailscale"></div>
|
<div class="list-pager" id="pager-tailscale"></div>
|
||||||
<div class="help">"设置IP"可修改设备 IPv4(会断开当前连接,平台 serial 随之变化,需同步 STF 设备池);"密钥不过期"= 关闭设备密钥验证,设备不会被定期踢下线;auth key 生成后只显示一次,请立即复制。</div>
|
<div class="help">"设置IP"可修改设备 IPv4(会断开当前连接,平台 serial 随之变化,需同步 STF 设备池);"密钥不过期"= 关闭设备密钥验证,设备不会被定期踢下线;auth key 生成后只显示一次,请立即复制。</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 子分栏 4:应用管理 -->
|
||||||
|
<div class="sub-panel" id="tools-sub-apks">
|
||||||
|
<div class="toolbar">
|
||||||
|
<label class="btn btn-primary" style="cursor:pointer;margin-bottom:0">
|
||||||
|
上传APK
|
||||||
|
<input type="file" accept=".apk" style="display:none" onchange="uploadApk(this)">
|
||||||
|
</label>
|
||||||
|
<button class="btn" onclick="loadApks()">刷新</button>
|
||||||
|
<input class="form-control list-search" id="search-apks" placeholder="搜索应用名/包名...">
|
||||||
|
<span class="hint" id="apk-hint"></span>
|
||||||
|
</div>
|
||||||
|
<table class="table">
|
||||||
|
<thead><tr>
|
||||||
|
<th data-sort="name" onclick="_lpSort('apks','name')">应用名</th><th data-sort="package_name" onclick="_lpSort('apks','package_name')">包名</th><th data-sort="version_name" onclick="_lpSort('apks','version_name')">版本</th><th data-sort="size" onclick="_lpSort('apks','size')">大小</th><th data-sort="upload_time" onclick="_lpSort('apks','upload_time')">上传时间</th><th>操作</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody id="tb-apks"></tbody>
|
||||||
|
</table>
|
||||||
|
<div class="list-pager" id="pager-apks"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 子分栏 5:应用版本管理 -->
|
||||||
|
<div class="sub-panel" id="tools-sub-appver">
|
||||||
|
<div class="toolbar">
|
||||||
|
<input type="text" id="appver-pkg" class="form-control" style="width:340px;font-family:monospace"
|
||||||
|
placeholder="包名,如 com.ss.android.ugc.aweme" onkeydown="if(event.key==='Enter')queryAppVers()">
|
||||||
|
<button class="btn btn-primary" onclick="queryAppVers()">查询版本</button>
|
||||||
|
<input class="form-control list-search" id="search-appver" placeholder="搜索设备/版本...">
|
||||||
|
<span id="appver-status" class="text-muted"></span>
|
||||||
|
</div>
|
||||||
|
<table class="table">
|
||||||
|
<thead><tr>
|
||||||
|
<th data-sort="serial" onclick="_lpSort('appver','serial')">设备</th>
|
||||||
|
<th data-sort="state" onclick="_lpSort('appver','state')">状态</th>
|
||||||
|
<th data-sort="version" onclick="_lpSort('appver','version')">版本号</th>
|
||||||
|
<th data-sort="code" onclick="_lpSort('appver','code')">versionCode</th>
|
||||||
|
</tr></thead>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- 子分栏 6:设备已装应用 -->
|
||||||
|
<div class="sub-panel" id="tools-sub-devapps">
|
||||||
|
<div class="toolbar">
|
||||||
|
<select id="apps-device" class="form-control" style="width:280px;display:inline-block">
|
||||||
|
<option value="">选择设备...</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-primary" onclick="loadDeviceApps()">查看应用</button>
|
||||||
|
<input class="form-control list-search" id="search-devapps" placeholder="搜索包名/路径...">
|
||||||
|
<span class="hint" id="apps-hint"></span>
|
||||||
|
</div>
|
||||||
|
<div style="max-height:420px;overflow:auto;border:1px solid var(--border);border-radius:8px">
|
||||||
|
<table class="table" style="margin:0">
|
||||||
|
<thead><tr>
|
||||||
|
<th data-sort="label" onclick="_lpSort('devapps','label')">应用</th><th data-sort="version_name" onclick="_lpSort('devapps','version_name')">版本号</th><th data-sort="version_code" onclick="_lpSort('devapps','version_code')">versionCode</th><th>APK 路径</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody id="tb-device-apps">
|
||||||
|
<tr><td colspan="4" class="empty">选择设备后点击"查看应用"</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="list-pager" id="pager-devapps"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 子分栏 6:STF 设备管理 -->
|
||||||
|
<div class="sub-panel" id="tools-sub-stfdev">
|
||||||
|
<div class="toolbar">
|
||||||
|
<input type="text" id="stfdev-ip" class="form-control" style="width:220px;font-family:monospace"
|
||||||
|
placeholder="设备 IP,如 100.100.10.20" onkeydown="if(event.key==='Enter')addStfDev()">
|
||||||
|
<button class="btn btn-primary" onclick="addStfDev()">添加设备并连接</button>
|
||||||
|
<button class="btn" onclick="loadStfDevMgmt()">刷新</button>
|
||||||
|
<input class="form-control list-search" id="search-stfdev" placeholder="搜索 IP...">
|
||||||
|
<span id="stfdev-status" class="text-muted"></span>
|
||||||
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div><!-- /.content -->
|
</div><!-- /.content -->
|
||||||
|
|||||||
+125
-24
@@ -23,7 +23,7 @@ import shlex
|
|||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout
|
from concurrent.futures import ThreadPoolExecutor, as_completed, TimeoutError as FuturesTimeout
|
||||||
|
|
||||||
from flask import (Flask, jsonify, request, redirect, url_for, render_template,
|
from flask import (Flask, jsonify, request, redirect, url_for, render_template,
|
||||||
Response, make_response, session)
|
Response, make_response, session)
|
||||||
@@ -38,9 +38,11 @@ from core.models import db, init_db, User, DeviceGroup, TaskJob, CustomAction
|
|||||||
from core.apk_manager import ApkManager
|
from core.apk_manager import ApkManager
|
||||||
from core.adb_helper import (screenshot, list_installed_apps, adb_connect_light,
|
from core.adb_helper import (screenshot, list_installed_apps, adb_connect_light,
|
||||||
adb_connect, _ADB_LOCK, _adb)
|
adb_connect, _ADB_LOCK, _adb)
|
||||||
from core import uiauto_helper, tailscale_client
|
from core import uiauto_helper, tailscale_client, stf_device_mgmt, ssh_client
|
||||||
from core.tailscale_client import TailscaleError
|
from core.tailscale_client import TailscaleError
|
||||||
from config import ADB_PATH, STF_SSH_TARGET, STF_DOCKER_CONTAINER
|
from core.stf_device_mgmt import StfDevError
|
||||||
|
from core.ssh_client import SSHError
|
||||||
|
from config import ADB_PATH, STF_DOCKER_CONTAINER
|
||||||
from tasks import list_task_types, get_task_class
|
from tasks import list_task_types, get_task_class
|
||||||
|
|
||||||
_log = get_logger("web")
|
_log = get_logger("web")
|
||||||
@@ -879,13 +881,10 @@ def _blocked_adb_cmd(cmd):
|
|||||||
return any(p in low for p in _ADB_BLOCKED_PATTERNS)
|
return any(p in low for p in _ADB_BLOCKED_PATTERNS)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/adb/devices")
|
def _merged_device_list():
|
||||||
@admin_required
|
"""本地 adb(含 USB)+ STF 在线设备池的合并列表([{serial, state}])。
|
||||||
def api_adb_devices():
|
|
||||||
"""维护终端设备列表(仅管理员):本地 adb 已连接 + STF 在线设备池。
|
|
||||||
|
|
||||||
供终端设备选择器使用——选中后自动附加 `-s <serial>`,
|
供维护终端/剪贴板注入/应用版本管理等设备选择场景共用。
|
||||||
离线/未连接的设备也可选,配合"重连设备"按钮恢复。
|
|
||||||
"""
|
"""
|
||||||
states = {}
|
states = {}
|
||||||
out = _adb("devices")
|
out = _adb("devices")
|
||||||
@@ -900,8 +899,18 @@ def api_adb_devices():
|
|||||||
states[serial] = "stf"
|
states[serial] = "stf"
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
devices = [{"serial": s, "state": st} for s, st in sorted(states.items())]
|
return [{"serial": s, "state": st} for s, st in sorted(states.items())]
|
||||||
return jsonify({"ok": True, "devices": devices})
|
|
||||||
|
|
||||||
|
@app.route("/api/adb/devices")
|
||||||
|
@admin_required
|
||||||
|
def api_adb_devices():
|
||||||
|
"""维护终端设备列表(仅管理员):本地 adb 已连接 + STF 在线设备池。
|
||||||
|
|
||||||
|
供终端设备选择器使用——选中后自动附加 `-s <serial>`,
|
||||||
|
离线/未连接的设备也可选,配合"重连设备"按钮恢复。
|
||||||
|
"""
|
||||||
|
return jsonify({"ok": True, "devices": _merged_device_list()})
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/adb/cmd", methods=["POST"])
|
@app.route("/api/adb/cmd", methods=["POST"])
|
||||||
@@ -992,6 +1001,103 @@ def _set_device_clipboard(serial, text):
|
|||||||
return False, f"{type(e).__name__}: {str(e)[:120]}"
|
return False, f"{type(e).__name__}: {str(e)[:120]}"
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/stf/devmgmt/status")
|
||||||
|
@admin_required
|
||||||
|
def api_stf_devmgmt_status():
|
||||||
|
"""STF 设备管理:脚本配置的 IP + 220 adb 实际连接状态。"""
|
||||||
|
try:
|
||||||
|
st = stf_device_mgmt.status()
|
||||||
|
return jsonify({"ok": True, **st})
|
||||||
|
except StfDevError as e:
|
||||||
|
return jsonify({"ok": False, "error": str(e)}), 502
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/stf/devmgmt/add", methods=["POST"])
|
||||||
|
@admin_required
|
||||||
|
def api_stf_devmgmt_add():
|
||||||
|
"""STF 设备管理:添加设备(写入脚本 + 立即 connect)。"""
|
||||||
|
ip = (request.json or {}).get("ip", "").strip()
|
||||||
|
if not ip:
|
||||||
|
return jsonify({"ok": False, "error": "请输入设备 IP"}), 400
|
||||||
|
try:
|
||||||
|
msgs = stf_device_mgmt.add_device(ip)
|
||||||
|
return jsonify({"ok": True, "msgs": msgs})
|
||||||
|
except StfDevError as e:
|
||||||
|
return jsonify({"ok": False, "error": str(e)}), 502
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/stf/devmgmt/remove", methods=["POST"])
|
||||||
|
@admin_required
|
||||||
|
def api_stf_devmgmt_remove():
|
||||||
|
"""STF 设备管理:移除设备(脚本删除 + disconnect)。"""
|
||||||
|
ip = (request.json or {}).get("ip", "").strip()
|
||||||
|
if not ip:
|
||||||
|
return jsonify({"ok": False, "error": "请输入设备 IP"}), 400
|
||||||
|
try:
|
||||||
|
msgs = stf_device_mgmt.remove_device(ip)
|
||||||
|
return jsonify({"ok": True, "msgs": msgs})
|
||||||
|
except StfDevError as e:
|
||||||
|
return jsonify({"ok": False, "error": str(e)}), 502
|
||||||
|
|
||||||
|
|
||||||
|
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/restart", methods=["POST"])
|
@app.route("/api/stf/restart", methods=["POST"])
|
||||||
@admin_required
|
@admin_required
|
||||||
def api_stf_restart():
|
def api_stf_restart():
|
||||||
@@ -1006,22 +1112,17 @@ def api_stf_restart():
|
|||||||
if running:
|
if running:
|
||||||
return jsonify({"ok": False,
|
return jsonify({"ok": False,
|
||||||
"error": f"有 {len(running)} 台设备正在运行任务,请先停止再维护"}), 409
|
"error": f"有 {len(running)} 台设备正在运行任务,请先停止再维护"}), 409
|
||||||
ssh_cmd = ["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5",
|
|
||||||
"-o", "StrictHostKeyChecking=no", STF_SSH_TARGET,
|
|
||||||
f"docker restart {STF_DOCKER_CONTAINER} && "
|
|
||||||
f"docker ps --filter name={STF_DOCKER_CONTAINER} --format '{{{{.Names}}}}: {{{{.Status}}}}'"]
|
|
||||||
try:
|
try:
|
||||||
r = subprocess.run(ssh_cmd, capture_output=True, timeout=60)
|
code, out, err = ssh_client.run(
|
||||||
except subprocess.TimeoutExpired:
|
f"docker restart {STF_DOCKER_CONTAINER} && "
|
||||||
return jsonify({"ok": False, "error": "SSH 执行超时(60s)"}), 408
|
f"docker ps --filter name={STF_DOCKER_CONTAINER} --format '{{{{.Names}}}}: {{{{.Status}}}}'",
|
||||||
except FileNotFoundError:
|
timeout=60)
|
||||||
return jsonify({"ok": False, "error": "本机未安装 ssh,无法远程操作"}), 500
|
except SSHError as e:
|
||||||
out = (r.stdout or b"").decode("utf-8", errors="replace")
|
return jsonify({"ok": False, "error": str(e)}), 502
|
||||||
err = (r.stderr or b"").decode("utf-8", errors="replace")
|
if code != 0:
|
||||||
if r.returncode != 0:
|
|
||||||
_log.error(f"STF 容器重启失败: {err or out}")
|
_log.error(f"STF 容器重启失败: {err or out}")
|
||||||
return jsonify({"ok": False,
|
return jsonify({"ok": False,
|
||||||
"error": f"SSH 执行失败:{err or out}(请确认本机可免密 SSH 到 {STF_SSH_TARGET},且容器名 {STF_DOCKER_CONTAINER} 正确)"}), 502
|
"error": f"SSH 执行失败:{err or out}(请确认 SSH 配置(STF_SSH_TARGET / STF_SSH_PASSWORD)且容器名 {STF_DOCKER_CONTAINER} 正确)"}), 502
|
||||||
# 轮询 STF API 确认恢复(最长 30s)
|
# 轮询 STF API 确认恢复(最长 30s)
|
||||||
alive = False
|
alive = False
|
||||||
for _ in range(30):
|
for _ in range(30):
|
||||||
|
|||||||
Reference in New Issue
Block a user