feat: 设备型号采集展示(devices 表 model 列+getprop 采集+管理页型号列/采集按钮+监控页兜底)+ fix: 监控页设备列错位(离线标签残留 td)、appver 端点误删恢复、appver 只查在线设备用轻量连接防挂起
This commit is contained in:
@@ -42,3 +42,6 @@ desktop.ini
|
|||||||
|
|
||||||
|
|
||||||
bin/adb/*
|
bin/adb/*
|
||||||
|
# 数据备份与损坏库(运维临时文件,不入库)
|
||||||
|
data/db_backup_*/
|
||||||
|
data/*.corrupt_*
|
||||||
|
|||||||
+80
-2
@@ -11,8 +11,10 @@
|
|||||||
后台线程(task_manager 等)调用时由本模块自行推 app context,调用方无需关心。
|
后台线程(task_manager 等)调用时由本模块自行推 app context,调用方无需关心。
|
||||||
"""
|
"""
|
||||||
import time
|
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.adb_helper import _adb, _adb_remote
|
||||||
from core.logger import get_logger
|
from core.logger import get_logger
|
||||||
from core.models import db, Device
|
from core.models import db, Device
|
||||||
@@ -23,9 +25,24 @@ _app = None
|
|||||||
|
|
||||||
|
|
||||||
def init_app(app):
|
def init_app(app):
|
||||||
"""web_server 启动时调用:绑定 Flask app(供后台线程推 db context)。"""
|
"""web_server 启动时调用:绑定 Flask app(供后台线程推 db context),
|
||||||
|
并后台刷新一次设备型号(首次启动/设备更换后型号可能变化)。"""
|
||||||
global _app
|
global _app
|
||||||
_app = 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():
|
def _ctx():
|
||||||
@@ -133,3 +150,64 @@ def set_enabled(serial, enabled):
|
|||||||
d.enabled = enabled
|
d.enabled = enabled
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return True
|
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
@@ -220,17 +220,19 @@ class Device(db.Model):
|
|||||||
"""设备池(本地设备清单,替代 STF 池作为调度数据源)。
|
"""设备池(本地设备清单,替代 STF 池作为调度数据源)。
|
||||||
|
|
||||||
serial 即 adb 序列号(IP:5555 或 USB 序列号);enabled=False 不参与调度。
|
serial 即 adb 序列号(IP:5555 或 USB 序列号);enabled=False 不参与调度。
|
||||||
|
model 为在线时自动采集的型号(如 Redmi 12C),供管理页/监控页区分设备。
|
||||||
"""
|
"""
|
||||||
serial = db.Column(db.String(120), primary_key=True)
|
serial = db.Column(db.String(120), primary_key=True)
|
||||||
name = db.Column(db.String(80), default="") # 备注名(可选)
|
name = db.Column(db.String(80), default="") # 备注名(可选)
|
||||||
|
model = db.Column(db.String(120), default="") # 型号(自动采集)
|
||||||
enabled = db.Column(db.Boolean, default=True) # 是否参与调度
|
enabled = db.Column(db.Boolean, default=True) # 是否参与调度
|
||||||
note = db.Column(db.Text, default="") # 备注
|
note = db.Column(db.Text, default="") # 备注
|
||||||
created_at = db.Column(db.String(20), default="") # 添加时间
|
created_at = db.Column(db.String(20), default="") # 添加时间
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
return {"serial": self.serial, "name": self.name or "",
|
return {"serial": self.serial, "name": self.name or "",
|
||||||
"enabled": bool(self.enabled), "note": self.note or "",
|
"model": self.model or "", "enabled": bool(self.enabled),
|
||||||
"created_at": self.created_at or ""}
|
"note": self.note or "", "created_at": self.created_at or ""}
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"<Device {self.serial}>"
|
return f"<Device {self.serial}>"
|
||||||
@@ -248,6 +250,8 @@ SCHEMA_MIGRATIONS = [
|
|||||||
"enabled BOOLEAN DEFAULT 1,"
|
"enabled BOOLEAN DEFAULT 1,"
|
||||||
"note TEXT DEFAULT '',"
|
"note TEXT DEFAULT '',"
|
||||||
"created_at VARCHAR(20) DEFAULT '')"),
|
"created_at VARCHAR(20) DEFAULT '')"),
|
||||||
|
(3, "设备池:device 表新增 model 列(型号,在线时自动采集)",
|
||||||
|
"ALTER TABLE device ADD COLUMN model TEXT DEFAULT ''"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -824,9 +824,11 @@ class TaskManager:
|
|||||||
except Exception:
|
except Exception:
|
||||||
online = set()
|
online = set()
|
||||||
try:
|
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:
|
except Exception:
|
||||||
names = {}
|
names, models = {}, {}
|
||||||
|
|
||||||
# 清理陈旧状态:serial 已不在设备池、且没有在跑 worker 的条目,
|
# 清理陈旧状态:serial 已不在设备池、且没有在跑 worker 的条目,
|
||||||
# 避免设备被删除后其失败记录仍残留在"异常汇总"里
|
# 避免设备被删除后其失败记录仍残留在"异常汇总"里
|
||||||
@@ -845,7 +847,7 @@ class TaskManager:
|
|||||||
r = running.get(serial, {})
|
r = running.get(serial, {})
|
||||||
result.append({
|
result.append({
|
||||||
"serial": serial,
|
"serial": serial,
|
||||||
"model": w.get("model", ""),
|
"model": w.get("model") or models.get(serial, ""),
|
||||||
"device_name": names.get(serial, ""),
|
"device_name": names.get(serial, ""),
|
||||||
"present": is_online,
|
"present": is_online,
|
||||||
"ready": is_online, # 阶段 1:ready 概念并入在线状态
|
"ready": is_online, # 阶段 1:ready 概念并入在线状态
|
||||||
|
|||||||
@@ -345,12 +345,12 @@ function renderDeviceRow(dev){
|
|||||||
?'<button class="btn btn-xs" onclick="clearDeviceError(\''+esc(dev.serial)+'\')" title="清除该设备的异常状态,恢复为未启动">清除异常</button>':'';
|
?'<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 btns='<div style="display:flex;gap:4px;flex-wrap:wrap">'+shotBtn+locateBtn+clearBtn+stopBtn+'</div>';
|
||||||
const devName=dev.device_name||dev.model||'';
|
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 checked=_selectedSerials.has(dev.serial)?'checked':'';
|
||||||
const rowCls=_selectedSerials.has(dev.serial)?'row-checked':'';
|
const rowCls=_selectedSerials.has(dev.serial)?'row-checked':'';
|
||||||
const cb='<input type="checkbox" '+checked+' onclick="toggleDevice(\''+esc(dev.serial)+'\',this.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(){
|
async function loadJobsForMonitor(){
|
||||||
|
|||||||
+11
-8
@@ -143,11 +143,6 @@ function reconnectAdbDevice(){
|
|||||||
if(!serial){showToast('请先选择目标设备','error');return;}
|
if(!serial){showToast('请先选择目标设备','error');return;}
|
||||||
_runAdbFull('adb connect '+serial);
|
_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 管理 ==================
|
// ================== Tab: 维护 - Tailscale 管理 ==================
|
||||||
// 与其他表格共用 setListPager/applyListPager 统一逻辑(搜索 + 分页)
|
// 与其他表格共用 setListPager/applyListPager 统一逻辑(搜索 + 分页)
|
||||||
let _tsDevices=[];
|
let _tsDevices=[];
|
||||||
@@ -408,9 +403,9 @@ async function loadDevPool(){
|
|||||||
const tb=document.getElementById('tb-devpool');
|
const tb=document.getElementById('tb-devpool');
|
||||||
const status=document.getElementById('devpool-status');
|
const status=document.getElementById('devpool-status');
|
||||||
if(!tb)return;
|
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');
|
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 rows=r.devices||[];
|
||||||
const onlineCnt=rows.filter(x=>x.online).length;
|
const onlineCnt=rows.filter(x=>x.online).length;
|
||||||
status.textContent='共 '+rows.length+' 台,在线 '+onlineCnt+' 台';
|
status.textContent='共 '+rows.length+' 台,在线 '+onlineCnt+' 台';
|
||||||
@@ -424,6 +419,7 @@ async function loadDevPool(){
|
|||||||
return '<tr>'+
|
return '<tr>'+
|
||||||
'<td style="font-family:monospace">'+esc(d.serial)+'</td>'+
|
'<td style="font-family:monospace">'+esc(d.serial)+'</td>'+
|
||||||
'<td>'+esc(d.name||'')+'</td>'+
|
'<td>'+esc(d.name||'')+'</td>'+
|
||||||
|
'<td>'+esc(d.model||'<span style="color:var(--text-light)">-</span>')+'</td>'+
|
||||||
'<td>'+online+'</td>'+
|
'<td>'+online+'</td>'+
|
||||||
'<td>'+st+'</td>'+
|
'<td>'+st+'</td>'+
|
||||||
'<td style="color:var(--text-light)">'+esc(d.created_at||'')+'</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" onclick="togglePoolDev(\''+esc(d.serial)+'\','+(!d.enabled)+')">'+(d.enabled?'停用':'启用')+'</button> '+
|
||||||
'<button class="btn btn-xs btn-danger" onclick="removePoolDev(\''+esc(d.serial)+'\')">删除</button>'+
|
'<button class="btn btn-xs btn-danger" onclick="removePoolDev(\''+esc(d.serial)+'\')">删除</button>'+
|
||||||
'</td></tr>';
|
'</td></tr>';
|
||||||
}).join('')||'<tr><td colspan="6" class="empty">设备池为空,添加第一台设备开始</td></tr>';
|
}).join('')||'<tr><td colspan="7" class="empty">设备池为空,添加第一台设备开始</td></tr>';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addPoolDev(){
|
async function addPoolDev(){
|
||||||
@@ -470,3 +466,10 @@ async function reconnectPoolDevices(){
|
|||||||
if(r.ok){showToast(r.msg,'success');setTimeout(loadDevPool,12000);}
|
if(r.ok){showToast(r.msg,'success');setTimeout(loadDevPool,12000);}
|
||||||
else showToast(r.error,'error');
|
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');
|
||||||
|
}
|
||||||
|
|||||||
@@ -630,12 +630,13 @@ select.form-control{cursor:pointer}
|
|||||||
placeholder="备注名(可选)" onkeydown="if(event.key==='Enter')addPoolDev()">
|
placeholder="备注名(可选)" onkeydown="if(event.key==='Enter')addPoolDev()">
|
||||||
<button class="btn btn-primary" onclick="addPoolDev()">添加设备</button>
|
<button class="btn btn-primary" onclick="addPoolDev()">添加设备</button>
|
||||||
<button class="btn" onclick="reconnectPoolDevices()" title="并发 adb connect 池内全部设备">一键重连</button>
|
<button class="btn" onclick="reconnectPoolDevices()" title="并发 adb connect 池内全部设备">一键重连</button>
|
||||||
|
<button class="btn" onclick="refreshPoolModels()" title="采集池内在线设备的型号(getprop)">采集型号</button>
|
||||||
<button class="btn" onclick="loadDevPool()">刷新</button>
|
<button class="btn" onclick="loadDevPool()">刷新</button>
|
||||||
<span id="devpool-status" class="text-muted"></span>
|
<span id="devpool-status" class="text-muted"></span>
|
||||||
</div>
|
</div>
|
||||||
<table class="table table-hover table-sm" style="margin-top:8px">
|
<table class="table table-hover table-sm" style="margin-top:8px">
|
||||||
<thead><tr>
|
<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>
|
</tr></thead>
|
||||||
<tbody id="tb-devpool"></tbody>
|
<tbody id="tb-devpool"></tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
+117
-2
@@ -64,8 +64,7 @@ login_manager.login_view = "login"
|
|||||||
|
|
||||||
# 先初始化数据库(含旧 JSON 迁移),再创建 TaskManager(需要 app context 读写 DB)
|
# 先初始化数据库(含旧 JSON 迁移),再创建 TaskManager(需要 app context 读写 DB)
|
||||||
init_db(app)
|
init_db(app)
|
||||||
# 设备池(本地清单 + adb 在线状态):阶段 0 起作为 STF 的替代数据源,
|
# 设备池(本地清单 + adb 在线状态):任务调度的数据源
|
||||||
# 首次启动自动从 STF 导入现有设备;阶段 3 摘除 STF 后此模块独立运行
|
|
||||||
from core import device_pool
|
from core import device_pool
|
||||||
device_pool.init_app(app)
|
device_pool.init_app(app)
|
||||||
mgr = TaskManager(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]}"
|
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"])
|
@app.route("/api/devices/pool", methods=["GET"])
|
||||||
@perm_required(PERM_DEVICES)
|
@perm_required(PERM_DEVICES)
|
||||||
def api_devices_pool_list():
|
def api_devices_pool_list():
|
||||||
@@ -1328,6 +1425,10 @@ def api_devices_pool_add():
|
|||||||
adb_connect(serial)
|
adb_connect(serial)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
# 后台采集型号(不阻塞添加响应)
|
||||||
|
import threading
|
||||||
|
threading.Thread(target=device_pool.refresh_model, args=(serial,),
|
||||||
|
daemon=True).start()
|
||||||
_log.info(f"设备池管理: {msg} {serial}")
|
_log.info(f"设备池管理: {msg} {serial}")
|
||||||
return jsonify({"ok": True, "msg": msg, "is_new": is_new})
|
return jsonify({"ok": True, "msg": msg, "is_new": is_new})
|
||||||
|
|
||||||
@@ -1377,11 +1478,25 @@ def api_devices_pool_reconnect():
|
|||||||
for _ in as_completed(futures):
|
for _ in as_completed(futures):
|
||||||
pass
|
pass
|
||||||
_log.info(f"设备池一键重连完成({len(serials)} 台)")
|
_log.info(f"设备池一键重连完成({len(serials)} 台)")
|
||||||
|
# 重连后顺手刷新型号
|
||||||
|
try:
|
||||||
|
device_pool.refresh_all_models()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
import threading
|
import threading
|
||||||
threading.Thread(target=_run, daemon=True).start()
|
threading.Thread(target=_run, daemon=True).start()
|
||||||
return jsonify({"ok": True, "msg": "重连已启动(后台并发,约 10-20 秒)"})
|
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 管理(仅管理员) ==================
|
# ================== API:Tailscale 管理(仅管理员) ==================
|
||||||
# 通过 Tailscale 官方 API v2 管理 tailnet 设备(列表/改名/授权/密钥不过期/删除/生成 auth key)。
|
# 通过 Tailscale 官方 API v2 管理 tailnet 设备(列表/改名/授权/密钥不过期/删除/生成 auth key)。
|
||||||
# 设备 IP 由 tailnet 自动分配,API 无法修改,列表只读展示。
|
# 设备 IP 由 tailnet 自动分配,API 无法修改,列表只读展示。
|
||||||
|
|||||||
Reference in New Issue
Block a user