diff --git a/.gitignore b/.gitignore index df26f57..9150adb 100644 --- a/.gitignore +++ b/.gitignore @@ -41,4 +41,7 @@ desktop.ini .env.local -bin/adb/* \ No newline at end of file +bin/adb/* +# 数据备份与损坏库(运维临时文件,不入库) +data/db_backup_*/ +data/*.corrupt_* diff --git a/core/device_pool.py b/core/device_pool.py index cfdeec1..7dc4dbe 100644 --- a/core/device_pool.py +++ b/core/device_pool.py @@ -11,8 +11,10 @@ 后台线程(task_manager 等)调用时由本模块自行推 app context,调用方无需关心。 """ import time +import threading +import subprocess -from config import USB_ADB_HOST, USB_ADB_PORT +from config import ADB_PATH, USB_ADB_HOST, USB_ADB_PORT from core.adb_helper import _adb, _adb_remote from core.logger import get_logger from core.models import db, Device @@ -23,9 +25,24 @@ _app = None def init_app(app): - """web_server 启动时调用:绑定 Flask app(供后台线程推 db context)。""" + """web_server 启动时调用:绑定 Flask app(供后台线程推 db context), + 并后台刷新一次设备型号(首次启动/设备更换后型号可能变化)。""" global _app _app = app + try: + t = threading.Thread(target=_refresh_models_bg, daemon=True) + t.start() + except Exception: + pass + + +def _refresh_models_bg(): + """后台批量采集在线设备型号(启动时/手动触发)。失败静默,不影响启动。""" + time.sleep(3) # 等服务起来再查 + try: + refresh_all_models() + except Exception as e: + _log.warning(f"启动型号采集失败(可稍后手动采集): {e}") def _ctx(): @@ -133,3 +150,64 @@ def set_enabled(serial, enabled): d.enabled = enabled db.session.commit() return True + + +# ================== 型号采集 ================== +def refresh_model(serial, timeout=8): + """采集单台设备型号(getprop ro.product.model)并写回 DB。 + + IP:5555 设备走本机 adb;USB 设备先查本机 adb,查不到再查 220 远程 adb server。 + 采集失败返回空串(静默,不影响主流程)。返回采集到的型号。 + """ + if not serial: + return "" + model = "" + if ":" in serial: + try: + r = subprocess.run([ADB_PATH, "-s", serial, "shell", + "getprop", "ro.product.model"], + capture_output=True, timeout=timeout) + model = (r.stdout or b"").decode("utf-8", errors="replace").strip() + except Exception: + model = "" + else: + # USB:先本机,再远程 + try: + r = subprocess.run([ADB_PATH, "-s", serial, "shell", + "getprop", "ro.product.model"], + capture_output=True, timeout=timeout) + model = (r.stdout or b"").decode("utf-8", errors="replace").strip() + except Exception: + model = "" + if not model: + model = _adb_remote(USB_ADB_HOST, USB_ADB_PORT, "-s", serial, + "shell", "getprop", "ro.product.model") + model = model.strip() or "" + if not model: + return "" + with _ctx(): + d = Device.query.get(serial) + if d and d.model != model: + d.model = model + db.session.commit() + _log.info(f"设备池采集型号: {serial} -> {model}") + return model + + +def refresh_all_models(): + """批量采集池内在线设备的型号(并发 10,后台线程调用)。返回成功数。""" + serials = list_online() + if not serials: + return 0 + from concurrent.futures import ThreadPoolExecutor, as_completed + ok = 0 + with ThreadPoolExecutor(max_workers=min(10, len(serials))) as pool: + futures = {pool.submit(refresh_model, s): s for s in serials} + for fut in as_completed(futures): + try: + if fut.result(): + ok += 1 + except Exception: + pass + _log.info(f"设备池批量采集型号完成: {ok}/{len(serials)} 台") + return ok diff --git a/core/models.py b/core/models.py index 82eca54..d0d5cce 100644 --- a/core/models.py +++ b/core/models.py @@ -220,17 +220,19 @@ class Device(db.Model): """设备池(本地设备清单,替代 STF 池作为调度数据源)。 serial 即 adb 序列号(IP:5555 或 USB 序列号);enabled=False 不参与调度。 + model 为在线时自动采集的型号(如 Redmi 12C),供管理页/监控页区分设备。 """ serial = db.Column(db.String(120), primary_key=True) name = db.Column(db.String(80), default="") # 备注名(可选) + model = db.Column(db.String(120), default="") # 型号(自动采集) enabled = db.Column(db.Boolean, default=True) # 是否参与调度 note = db.Column(db.Text, default="") # 备注 created_at = db.Column(db.String(20), default="") # 添加时间 def to_dict(self): return {"serial": self.serial, "name": self.name or "", - "enabled": bool(self.enabled), "note": self.note or "", - "created_at": self.created_at or ""} + "model": self.model or "", "enabled": bool(self.enabled), + "note": self.note or "", "created_at": self.created_at or ""} def __repr__(self): return f"" @@ -248,6 +250,8 @@ SCHEMA_MIGRATIONS = [ "enabled BOOLEAN DEFAULT 1," "note TEXT DEFAULT ''," "created_at VARCHAR(20) DEFAULT '')"), + (3, "设备池:device 表新增 model 列(型号,在线时自动采集)", + "ALTER TABLE device ADD COLUMN model TEXT DEFAULT ''"), ] diff --git a/core/task_manager.py b/core/task_manager.py index 1bab318..c92ed65 100644 --- a/core/task_manager.py +++ b/core/task_manager.py @@ -824,9 +824,11 @@ class TaskManager: except Exception: online = set() try: - names = {d["serial"]: d["name"] for d in device_pool.list_devices()} + pool_rows = device_pool.list_devices() + names = {d["serial"]: d["name"] for d in pool_rows} + models = {d["serial"]: d["model"] for d in pool_rows} except Exception: - names = {} + names, models = {}, {} # 清理陈旧状态:serial 已不在设备池、且没有在跑 worker 的条目, # 避免设备被删除后其失败记录仍残留在"异常汇总"里 @@ -845,7 +847,7 @@ class TaskManager: r = running.get(serial, {}) result.append({ "serial": serial, - "model": w.get("model", ""), + "model": w.get("model") or models.get(serial, ""), "device_name": names.get(serial, ""), "present": is_online, "ready": is_online, # 阶段 1:ready 概念并入在线状态 diff --git a/static/admin/monitor.js b/static/admin/monitor.js index 8d4a832..4d82363 100644 --- a/static/admin/monitor.js +++ b/static/admin/monitor.js @@ -345,12 +345,12 @@ function renderDeviceRow(dev){ ?'':''; const btns='
'+shotBtn+locateBtn+clearBtn+stopBtn+'
'; const devName=dev.device_name||dev.model||''; - const devLabel=devName?''+esc(dev.serial)+'
'+esc(devName)+'':''+esc(dev.serial)+''; + const devLabel=(devName?''+esc(dev.serial)+'
'+esc(devName)+'':''+esc(dev.serial)+'')+offline; // 复选框列 const checked=_selectedSerials.has(dev.serial)?'checked':''; const rowCls=_selectedSerials.has(dev.serial)?'row-checked':''; const cb=''; - return ''+cb+''+devLabel+''+esc(dev.model)+''+offline+''+wb+''+fg+''+prog+''+tname+retry+endInfo+''+esc(dev.current_action||'-')+warnInfo+''+esc(dev.last_error||'-')+''+btns+''; + return ''+cb+''+devLabel+''+esc(dev.model)+''+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 fe28dd0..236cd04 100644 --- a/static/admin/tools.js +++ b/static/admin/tools.js @@ -143,11 +143,6 @@ function reconnectAdbDevice(){ if(!serial){showToast('请先选择目标设备','error');return;} _runAdbFull('adb connect '+serial); } -function checkStfAgent(){ - const serial=_selectedSerial(); - if(!serial){showToast('请先选择目标设备','error');return;} - _runAdbRepair('adb -s '+serial+' shell pm list packages | grep cyberagent'); -} // ================== Tab: 维护 - Tailscale 管理 ================== // 与其他表格共用 setListPager/applyListPager 统一逻辑(搜索 + 分页) let _tsDevices=[]; @@ -408,9 +403,9 @@ async function loadDevPool(){ const tb=document.getElementById('tb-devpool'); const status=document.getElementById('devpool-status'); if(!tb)return; - tb.innerHTML='加载中...'; + tb.innerHTML='加载中...'; const r=await apiGet('/api/devices/pool'); - if(!r||!r.ok){tb.innerHTML='加载失败';return;} + if(!r||!r.ok){tb.innerHTML='加载失败';return;} const rows=r.devices||[]; const onlineCnt=rows.filter(x=>x.online).length; status.textContent='共 '+rows.length+' 台,在线 '+onlineCnt+' 台'; @@ -424,6 +419,7 @@ async function loadDevPool(){ return ''+ ''+esc(d.serial)+''+ ''+esc(d.name||'')+''+ + ''+esc(d.model||'-')+''+ ''+online+''+ ''+st+''+ ''+esc(d.created_at||'')+''+ @@ -431,7 +427,7 @@ async function loadDevPool(){ ' '+ ''+ ''; - }).join('')||'设备池为空,添加第一台设备开始'; + }).join('')||'设备池为空,添加第一台设备开始'; } async function addPoolDev(){ @@ -470,3 +466,10 @@ async function reconnectPoolDevices(){ if(r.ok){showToast(r.msg,'success');setTimeout(loadDevPool,12000);} else showToast(r.error,'error'); } + +async function refreshPoolModels(){ + const r=await apiPost('/api/devices/pool/refresh_models',{}); + if(!r)return; + if(r.ok){showToast(r.msg,'success');setTimeout(loadDevPool,12000);} + else showToast(r.error,'error'); +} diff --git a/templates/admin/monitor.html b/templates/admin/monitor.html index 9fc7db2..8cee652 100644 --- a/templates/admin/monitor.html +++ b/templates/admin/monitor.html @@ -630,12 +630,13 @@ select.form-control{cursor:pointer} placeholder="备注名(可选)" onkeydown="if(event.key==='Enter')addPoolDev()"> + - +
serial名称在线状态添加时间操作serial名称型号在线状态添加时间操作
diff --git a/web_server.py b/web_server.py index 3393a5d..dc8635c 100644 --- a/web_server.py +++ b/web_server.py @@ -64,8 +64,7 @@ login_manager.login_view = "login" # 先初始化数据库(含旧 JSON 迁移),再创建 TaskManager(需要 app context 读写 DB) init_db(app) -# 设备池(本地清单 + adb 在线状态):阶段 0 起作为 STF 的替代数据源, -# 首次启动自动从 STF 导入现有设备;阶段 3 摘除 STF 后此模块独立运行 +# 设备池(本地清单 + adb 在线状态):任务调度的数据源 from core import device_pool device_pool.init_app(app) mgr = TaskManager(app=app) @@ -1294,6 +1293,104 @@ def _set_device_clipboard(serial, text): return False, f"{type(e).__name__}: {str(e)[:120]}" +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_light(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)+ 设备池,并发查询(最多 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 + # 只查在线设备:池内离线条目(如陈旧记录)不连,避免 connect 重试拖慢查询 + try: + online = set(device_pool.list_online()) + devices = [d for d in _merged_device_list() if d["serial"] in online] + except Exception: + devices = [] + 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(): @@ -1328,6 +1425,10 @@ def api_devices_pool_add(): adb_connect(serial) except Exception: pass + # 后台采集型号(不阻塞添加响应) + import threading + threading.Thread(target=device_pool.refresh_model, args=(serial,), + daemon=True).start() _log.info(f"设备池管理: {msg} {serial}") return jsonify({"ok": True, "msg": msg, "is_new": is_new}) @@ -1377,11 +1478,25 @@ def api_devices_pool_reconnect(): for _ in as_completed(futures): pass _log.info(f"设备池一键重连完成({len(serials)} 台)") + # 重连后顺手刷新型号 + try: + device_pool.refresh_all_models() + except Exception: + pass import threading threading.Thread(target=_run, daemon=True).start() return jsonify({"ok": True, "msg": "重连已启动(后台并发,约 10-20 秒)"}) +@app.route("/api/devices/pool/refresh_models", methods=["POST"]) +@perm_required(PERM_DEVICES) +def api_devices_pool_refresh_models(): + """批量采集池内在线设备的型号(后台执行,不阻塞)。""" + import threading + threading.Thread(target=device_pool.refresh_all_models, daemon=True).start() + return jsonify({"ok": True, "msg": "型号采集已启动(后台并发,约 10 秒)"}) + + # ================== API:Tailscale 管理(仅管理员) ================== # 通过 Tailscale 官方 API v2 管理 tailnet 设备(列表/改名/授权/密钥不过期/删除/生成 auth key)。 # 设备 IP 由 tailnet 自动分配,API 无法修改,列表只读展示。