feat: 设备型号采集展示(devices 表 model 列+getprop 采集+管理页型号列/采集按钮+监控页兜底)+ fix: 监控页设备列错位(离线标签残留 td)、appver 端点误删恢复、appver 只查在线设备用轻量连接防挂起

This commit is contained in:
2026-08-19 13:53:27 +08:00
parent eda71c8e5f
commit 6f6093842c
8 changed files with 227 additions and 21 deletions
+4 -1
View File
@@ -41,4 +41,7 @@ desktop.ini
.env.local
bin/adb/*
bin/adb/*
# 数据备份与损坏库(运维临时文件,不入库)
data/db_backup_*/
data/*.corrupt_*
+80 -2
View File
@@ -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
+6 -2
View File
@@ -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"<Device {self.serial}>"
@@ -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 ''"),
]
+5 -3
View File
@@ -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 概念并入在线状态
+2 -2
View File
@@ -345,12 +345,12 @@ function renderDeviceRow(dev){
?'<button class="btn btn-xs" onclick="clearDeviceError(\''+esc(dev.serial)+'\')" title="清除该设备的异常状态,恢复为未启动">清除异常</button>':'';
const btns='<div style="display:flex;gap:4px;flex-wrap:wrap">'+shotBtn+locateBtn+clearBtn+stopBtn+'</div>';
const devName=dev.device_name||dev.model||'';
const devLabel=devName?'<strong>'+esc(dev.serial)+'</strong><br><span style="font-size:11px;color:#94a3b8">'+esc(devName)+'</span>':'<strong>'+esc(dev.serial)+'</strong>';
const devLabel=(devName?'<strong>'+esc(dev.serial)+'</strong><br><span style="font-size:11px;color:#94a3b8">'+esc(devName)+'</span>':'<strong>'+esc(dev.serial)+'</strong>')+offline;
// 复选框列
const checked=_selectedSerials.has(dev.serial)?'checked':'';
const rowCls=_selectedSerials.has(dev.serial)?'row-checked':'';
const cb='<input type="checkbox" '+checked+' onclick="toggleDevice(\''+esc(dev.serial)+'\',this.checked)">';
return '<tr class="'+rowCls+'"><td class="col-cb">'+cb+'</td><td>'+devLabel+'</td><td>'+esc(dev.model)+'</td><td>'+offline+'</td><td>'+wb+'</td><td>'+fg+'</td><td>'+prog+'</td><td>'+tname+retry+endInfo+'</td><td class="text-muted">'+esc(dev.current_action||'-')+warnInfo+'</td><td class="err-text" title="'+esc(dev.last_error||'')+'">'+esc(dev.last_error||'-')+'</td><td>'+btns+'</td></tr>';
return '<tr class="'+rowCls+'"><td class="col-cb">'+cb+'</td><td>'+devLabel+'</td><td>'+esc(dev.model)+'</td><td>'+wb+'</td><td>'+fg+'</td><td>'+prog+'</td><td>'+tname+retry+endInfo+'</td><td class="text-muted">'+esc(dev.current_action||'-')+warnInfo+'</td><td class="err-text" title="'+esc(dev.last_error||'')+'">'+esc(dev.last_error||'-')+'</td><td>'+btns+'</td></tr>';
}
async function loadJobsForMonitor(){
+11 -8
View File
@@ -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='<tr><td colspan="6" class="empty">加载中...</td></tr>';
tb.innerHTML='<tr><td colspan="7" class="empty">加载中...</td></tr>';
const r=await apiGet('/api/devices/pool');
if(!r||!r.ok){tb.innerHTML='<tr><td colspan="6" class="empty">加载失败</td></tr>';return;}
if(!r||!r.ok){tb.innerHTML='<tr><td colspan="7" class="empty">加载失败</td></tr>';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 '<tr>'+
'<td style="font-family:monospace">'+esc(d.serial)+'</td>'+
'<td>'+esc(d.name||'')+'</td>'+
'<td>'+esc(d.model||'<span style="color:var(--text-light)">-</span>')+'</td>'+
'<td>'+online+'</td>'+
'<td>'+st+'</td>'+
'<td style="color:var(--text-light)">'+esc(d.created_at||'')+'</td>'+
@@ -431,7 +427,7 @@ async function loadDevPool(){
'<button class="btn btn-xs" onclick="togglePoolDev(\''+esc(d.serial)+'\','+(!d.enabled)+')">'+(d.enabled?'停用':'启用')+'</button> '+
'<button class="btn btn-xs btn-danger" onclick="removePoolDev(\''+esc(d.serial)+'\')">删除</button>'+
'</td></tr>';
}).join('')||'<tr><td colspan="6" class="empty">设备池为空,添加第一台设备开始</td></tr>';
}).join('')||'<tr><td colspan="7" class="empty">设备池为空,添加第一台设备开始</td></tr>';
}
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');
}
+2 -1
View File
@@ -630,12 +630,13 @@ select.form-control{cursor:pointer}
placeholder="备注名(可选)" onkeydown="if(event.key==='Enter')addPoolDev()">
<button class="btn btn-primary" onclick="addPoolDev()">添加设备</button>
<button class="btn" onclick="reconnectPoolDevices()" title="并发 adb connect 池内全部设备">一键重连</button>
<button class="btn" onclick="refreshPoolModels()" title="采集池内在线设备的型号(getprop)">采集型号</button>
<button class="btn" onclick="loadDevPool()">刷新</button>
<span id="devpool-status" class="text-muted"></span>
</div>
<table class="table table-hover table-sm" style="margin-top:8px">
<thead><tr>
<th>serial</th><th>名称</th><th>在线</th><th>状态</th><th>添加时间</th><th>操作</th>
<th>serial</th><th>名称</th><th>型号</th><th>在线</th><th>状态</th><th>添加时间</th><th>操作</th>
</tr></thead>
<tbody id="tb-devpool"></tbody>
</table>
+117 -2
View File
@@ -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 无法修改,列表只读展示。