3 Commits
12 changed files with 401 additions and 17 deletions
+1 -1
View File
@@ -252,7 +252,7 @@ self.set_progress(done=5, total=80, unit="视频",
| Tab | 功能 | 可见性 | | Tab | 功能 | 可见性 |
|-----|------|--------| |-----|------|--------|
| 监控 | 设备状态大屏:在线/离线、运行任务、当前动作、进度条、前台 App、截图、停止 | 所有登录用户(设备操作按钮需"设备控制"权限) | | 监控 | 设备状态大屏:在线/离线、运行任务、当前动作、进度条、前台 App、截图、停止 | 所有登录用户(设备操作按钮需"设备控制"权限) |
| 任务 | 子分栏:任务计划(CRUD/启用停用/立即执行/下次运行时间)、自定义动作(打包复用) | 所有登录用户可看,写操作需"任务管理"权限 | | 任务 | 子分栏:任务计划(CRUD/启用停用/立即执行/下次运行时间/**离线设备自动跳过**)、自定义动作(打包复用) | 所有登录用户可看,写操作需"任务管理"权限 |
| 分组 | 设备分组管理:创建/编辑/删除分组 | 所有登录用户可看,写操作需"任务管理"权限 | | 分组 | 设备分组管理:创建/编辑/删除分组 | 所有登录用户可看,写操作需"任务管理"权限 |
| 日志 | 实时日志查看:按模块切换(core/task/web/action) | 需"日志查看"权限 | | 日志 | 实时日志查看:按模块切换(core/task/web/action) | 需"日志查看"权限 |
| 用户 | 用户管理:创建/删除/修改密码/分配权限 | 仅管理员 | | 用户 | 用户管理:创建/删除/修改密码/分配权限 | 仅管理员 |
+29 -3
View File
@@ -305,12 +305,38 @@ class ApkManager:
"设备未在本地 adb 列表中,请检查 USB 连接/驱动") "设备未在本地 adb 列表中,请检查 USB 连接/驱动")
return return
# 安装 # 强制安装:绕过厂商安全防护(MIUI 等用 adb install 会弹"通过 USB 安装应用"
self._set_item(serial, "installing", "正在安装...") # 确认框需要手点;改为 推送 + pm install,shell 安装不弹窗)
# 1. 关闭 adb 安装校验(包校验器/安全守护拦截)
try:
_adb("-s", serial, "shell", "settings", "put", "global",
"verifier_verify_adb_installs", "0")
except Exception:
pass
# 2. 推送 APK 到设备(大 APK 经 WiFi 推送较慢,超时与安装共用)
self._set_item(serial, "installing", "正在推送 APK...")
r = subprocess.run( r = subprocess.run(
[ADB_PATH, "-s", serial, "install", "-r", apk_path], [ADB_PATH, "-s", serial, "push", apk_path, "/data/local/tmp/_install.apk"],
capture_output=True, timeout=_INSTALL_TIMEOUT capture_output=True, timeout=_INSTALL_TIMEOUT
) )
push_out = ((r.stdout or b"") + (r.stderr or b"")).decode("utf-8", errors="replace")
if r.returncode != 0 or "1 file pushed" not in push_out:
msg = push_out.strip().replace("\n", " ")[:150] or f"推送失败(returncode={r.returncode})"
self._set_item(serial, "failed", msg)
_log.warning(f"[{serial}] APK 推送失败: {msg}")
return
# 3. pm install 强制安装(-r 覆盖安装 / -t 允许测试包 / -d 允许降级版本)
self._set_item(serial, "installing", "正在安装...")
r = subprocess.run(
[ADB_PATH, "-s", serial, "shell", "pm", "install", "-r", "-t", "-d",
"/data/local/tmp/_install.apk"],
capture_output=True, timeout=_INSTALL_TIMEOUT
)
# 4. 清理设备临时文件(失败残留下次推送会覆盖,不影响)
try:
_adb("-s", serial, "shell", "rm", "-f", "/data/local/tmp/_install.apk")
except Exception:
pass
out = ((r.stdout or b"") + (r.stderr or b"")) out = ((r.stdout or b"") + (r.stderr or b""))
try: try:
out = out.decode("utf-8", errors="replace") out = out.decode("utf-8", errors="replace")
+3
View File
@@ -10,6 +10,7 @@
失败抛 SSHError(连接失败/超时),由调用方转成友好错误返回。 失败抛 SSHError(连接失败/超时),由调用方转成友好错误返回。
""" """
import re import re
import socket
import subprocess import subprocess
import paramiko import paramiko
@@ -91,6 +92,8 @@ def _run_paramiko(cmd, timeout):
client.close() client.close()
except paramiko.AuthenticationException: except paramiko.AuthenticationException:
raise SSHError(f"SSH 密码认证失败(请检查 .env 的 STF_SSH_PASSWORD)") raise SSHError(f"SSH 密码认证失败(请检查 .env 的 STF_SSH_PASSWORD)")
except socket.timeout:
raise SSHError(f"SSH 执行超时({timeout}s 内未完成,命令可能耗时较长)")
except paramiko.SSHException as e: except paramiko.SSHException as e:
raise SSHError(f"SSH 连接失败: {e}") raise SSHError(f"SSH 连接失败: {e}")
except Exception as e: except Exception as e:
+19
View File
@@ -160,6 +160,25 @@ class STFClient:
_log.error(f"释放 {serial} 失败: {msg}") _log.error(f"释放 {serial} 失败: {msg}")
return released, failed return released, failed
def delete_device(self, serial):
"""从 STF 池彻底删除设备记录(清理离线幽灵设备用)。
DELETE /api/v1/devices/{serial}(实测可用):断开后 STF 数据库里残留的
present=False 陈旧记录会一直显示在设备池,用此端点彻底移除。
"""
import urllib.parse
try:
r = requests.delete(
f"{self.base_url}/api/v1/devices/{urllib.parse.quote(serial, safe='')}",
headers=self.headers, timeout=_TIMEOUT)
if r.status_code in (200, 202, 204):
_log.info(f"已从 STF 池删除设备 {serial}")
return True, "已从 STF 池删除"
return False, f"HTTP {r.status_code}: {r.text[:150]}"
except requests.exceptions.RequestException as e:
_log.warning(f"删除 STF 设备 {serial} 请求异常: {e}")
return False, str(e)
# ================== 远程 ADB 隧道 ================== # ================== 远程 ADB 隧道 ==================
def remote_connect(self, serial): def remote_connect(self, serial):
"""建立远程 ADB 隧道,返回 remoteConnectUrl。 """建立远程 ADB 隧道,返回 remoteConnectUrl。
+25
View File
@@ -131,3 +131,28 @@ def remove_device(ip):
msgs.append(out.strip() or (f"adb disconnect 执行完成(exit {code})" if code == 0 else "adb disconnect 无输出")) msgs.append(out.strip() or (f"adb disconnect 执行完成(exit {code})" if code == 0 else "adb disconnect 无输出"))
_log.info(f"STF 移除设备 {ip}: {msgs}") _log.info(f"STF 移除设备 {ip}: {msgs}")
return msgs return msgs
def delete_device(ip, stf=None):
"""彻底删除设备:脚本移除 + disconnect + 清 STF 池记录(幽灵设备)。"""
msgs = remove_device(ip)
if stf is not None:
ok, msg = stf.delete_device(f"{_norm_ip(ip)}:5555")
msgs.append(msg)
_log.info(f"STF 彻底删除设备 {ip}: {msgs}")
return msgs
def reconnect_all():
"""一键重连:后台运行 220 的 connect_devices.sh(cron 之外的立即补连)。
脚本对未连接设备逐个 connect(不可达设备可能挂 40s+),全程可能 1-3 分钟,
因此 nohup 后台执行立即返回,不阻塞请求;结果用"刷新"看设备连接状态。
"""
cmd = (f"nohup bash {shlex.quote(STF_SCRIPT_PATH)} >/tmp/connect_devices.out 2>&1 &")
code, out, err = _ssh(cmd, timeout=15)
if code != 0:
raise StfDevError(f"启动重连失败: {err.strip() or out.strip()}")
_log.info("STF 一键重连已在 220 后台启动")
return True, ("重连已在 220 后台执行(对未连接设备逐个 connect,约 1-3 分钟),"
"稍后点\"刷新\"查看设备连接状态")
+29 -4
View File
@@ -146,15 +146,40 @@ class TaskJob:
"""根据 target 解析出实际要跑的 serial 列表。""" """根据 target 解析出实际要跑的 serial 列表。"""
mode = self.target.get("mode", "all") mode = self.target.get("mode", "all")
if mode == "serial": if mode == "serial":
return [self.target["serial"]] serials = [self.target["serial"]]
if mode == "group": elif mode == "group":
g = manager.groups.get(self.target.get("group_name")) g = manager.groups.get(self.target.get("group_name"))
return list(g.serials) if g else [] serials = list(g.serials) if g else []
# all:返回所有空闲设备 else:
# all:返回所有空闲设备(天然只含在线设备)
try: try:
return [d["serial"] for d in manager.stf.list_free_devices()] return [d["serial"] for d in manager.stf.list_free_devices()]
except Exception: except Exception:
return [] return []
# 离线自动跳过(serial/group 模式):跳过 STF 池里不在线(present=False)或
# 未就绪(ready=False,provider 刚接入还在初始化)的设备,
# 避免对离线/未就绪设备反复尝试占用后报"设备离线"。默认开启,可在任务编辑器取消勾选。
if serials and self.params.get("skip_offline", True):
try:
devs = manager.stf.list_all_devices()
except Exception:
devs = None # STF 查询失败时不过滤,维持原行为
if devs is not None:
present = {d["serial"] for d in devs if d.get("present")}
ready = {d["serial"] for d in devs if d.get("present") and d.get("ready")}
kept, skipped = [], []
for s in serials:
if s not in present:
skipped.append((s, "离线"))
elif s not in ready:
skipped.append((s, "未就绪(provider初始化中)"))
else:
kept.append(s)
if skipped:
detail = ", ".join(f"{s}({why})" for s, why in skipped)
_log.info(f"任务 {self.name} 跳过 {len(skipped)} 台设备: {detail}")
serials = kept
return serials
# ================== 前台 App 扫描器 ================== # ================== 前台 App 扫描器 ==================
+4
View File
@@ -1045,6 +1045,10 @@ async function saveTask(jobId){
catch(e){showToast('参数 JSON 格式错误','error');return;} catch(e){showToast('参数 JSON 格式错误','error');return;}
} }
// 离线自动跳过(serial/group 模式):任务触发时跳过 STF 池中不在线的设备
const skipEl=document.getElementById('f-skip_offline');
params.skip_offline = skipEl?skipEl.checked:true;
const targetMode=document.getElementById('f-target_mode').value; const targetMode=document.getElementById('f-target_mode').value;
const target={mode:targetMode}; const target={mode:targetMode};
if(targetMode==='group')target.group_name=document.getElementById('f-target_group').value; if(targetMode==='group')target.group_name=document.getElementById('f-target_group').value;
+42 -3
View File
@@ -1,6 +1,7 @@
// 监控页:设备状态表(排序/多选/截图/异常汇总/任务运行概况) // 监控页:设备状态表(排序/多选/截图/异常汇总/任务运行概况)
// ================== 设备列表排序与多选 ================== // ================== 设备列表排序与多选 ==================
let _selectedSerials=new Set(); // 已勾选的 serial 集合 let _selectedSerials=new Set(); // 已勾选的 serial 集合
let _locatingSerials=new Set(); // 定位中的设备(按钮显示"结束定位")
// 排序比较:从设备对象取排序值(统一排序用,见 setListPager/sortValue) // 排序比较:从设备对象取排序值(统一排序用,见 setListPager/sortValue)
function _sortValue(dev,key){ function _sortValue(dev,key){
@@ -71,6 +72,38 @@ async function stopSelectedDevices(){
setTimeout(loadMonitor,500); setTimeout(loadMonitor,500);
} }
// 定位设备:点亮屏幕 + 打开大字定位页(显示设备 IP),便于在机架上找到目标
// 注意:打开定位页会切换设备前台,任务运行中慎用
async function locateDevice(serial){
if(!confirm('点亮 '+serial+' 屏幕并打开大字定位页?\n\n(会切换设备前台显示,任务运行中慎用)'))return;
const r=await apiPost('/api/device/locate',{serial:serial,show:true});
if(!r)return;
if(r.ok){
_locatingSerials.add(serial);
showToast(r.msg||'已定位','success');
renderDevices();
}else showToast(r.error||'定位失败','error');
}
// 结束定位:关闭设备上的定位页(浏览器),按钮恢复为"定位"
async function stopLocate(serial){
const r=await apiPost('/api/device/locate/stop',{serial:serial});
if(!r)return;
_locatingSerials.delete(serial);
renderDevices();
if(r.ok)showToast(r.msg||'已结束定位','success');
else showToast(r.error||'结束定位失败','error');
}
// 批量亮屏/息屏所有在线设备(息屏会中断运行中任务,前端已确认)
async function screenAllDevices(mode){
if(mode==='off'&&!confirm('确定让所有在线设备息屏?\n\n运行中的任务会因屏幕熄灭而中断,请谨慎操作。'))return;
const r=await apiPost('/api/device/screen_all',{mode:mode});
if(!r)return;
if(r.ok)showToast((mode==='off'?'已息屏':'已亮屏')+' '+r.success+'/'+r.total+' 台设备','success');
else showToast(r.error||'操作失败','error');
}
// 清除单台设备的异常状态(error/failed → 未启动) // 清除单台设备的异常状态(error/failed → 未启动)
async function clearDeviceError(serial){ async function clearDeviceError(serial){
if(!confirm('确认清除设备 '+serial+' 的异常状态?'))return; if(!confirm('确认清除设备 '+serial+' 的异常状态?'))return;
@@ -266,9 +299,10 @@ async function loadSummary(){
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:_st.sort}); const _sig=JSON.stringify({d:_statusData.devices,p:_st.page,k:_st.kw,s:_st.sort,
l:[..._locatingSerials].sort().join(',')});
if(_sig===_devicesSig)return; if(_sig===_devicesSig)return;
_devicesSig=_sig; _devicesSig=_sig;
// 排序由 applyListPager 统一处理(sortValue + _lpSort) // 排序由 applyListPager 统一处理(sortValue + _lpSort)
@@ -306,10 +340,15 @@ function renderDeviceRow(dev){
const stopBtn=canDev&&(dev.worker_status==='running'||dev.worker_status==='connecting')?'<button class="btn btn-danger btn-xs" onclick="apiAction(\'/api/stop_device\',{serial:\''+esc(dev.serial)+'\'})">停止</button>':''; const stopBtn=canDev&&(dev.worker_status==='running'||dev.worker_status==='connecting')?'<button class="btn btn-danger btn-xs" onclick="apiAction(\'/api/stop_device\',{serial:\''+esc(dev.serial)+'\'})">停止</button>':'';
// 截图按钮(任何状态都可看,离线时会报错) // 截图按钮(任何状态都可看,离线时会报错)
const shotBtn='<button class="btn btn-xs" onclick="openScreenshot(\''+esc(dev.serial)+'\')" title="查看设备画面(不影响任务)">截图</button>'; const shotBtn='<button class="btn btn-xs" onclick="openScreenshot(\''+esc(dev.serial)+'\')" title="查看设备画面(不影响任务)">截图</button>';
// 定位按钮:定位中显示"结束定位"(关闭定位页),否则显示"定位"(点亮+大字页)
const locating=_locatingSerials.has(dev.serial);
const locateBtn=canDev?(locating
?'<button class="btn btn-xs btn-danger" onclick="stopLocate(\''+esc(dev.serial)+'\')" title="关闭定位页(退出浏览器)">结束定位</button>'
:'<button class="btn btn-xs" onclick="locateDevice(\''+esc(dev.serial)+'\')" title="点亮屏幕并显示设备IP大字页(会切换前台,任务运行中慎用)">定位</button>'):'';
// 异常/失败设备提供"清除异常"(把状态恢复为未启动) // 异常/失败设备提供"清除异常"(把状态恢复为未启动)
const clearBtn=canDev&&(dev.worker_status==='error'||dev.worker_status==='failed') const clearBtn=canDev&&(dev.worker_status==='error'||dev.worker_status==='failed')
?'<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+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>';
// 复选框列 // 复选框列
+4
View File
@@ -104,6 +104,10 @@ function openTaskModal(jobId){
'<div id="f-target_serial_wrap" class="form-group" style="margin-top:8px;margin-bottom:0">'+ '<div id="f-target_serial_wrap" class="form-group" style="margin-top:8px;margin-bottom:0">'+
'<select class="form-control" id="f-target_serial"><option value="">选择设备...</option>'+dl+'</select>'+ '<select class="form-control" id="f-target_serial"><option value="">选择设备...</option>'+dl+'</select>'+
'</div>'+ '</div>'+
'<div class="form-check" style="margin-top:8px">'+
'<input type="checkbox" id="f-skip_offline" '+(j.params&&j.params.skip_offline===false?'':'checked')+'>'+
'<label for="f-skip_offline">离线设备自动跳过(推荐)</label>'+
'</div>'+
'</div>'+ '</div>'+
'<div class="form-row">'+ '<div class="form-row">'+
'<div class="form-group"><label>调度方式</label>'+ '<div class="form-group"><label>调度方式</label>'+
+31 -1
View File
@@ -126,7 +126,37 @@ function renderStfDevRow(x){
return '<tr><td style="font-family:monospace">'+esc(x.ip)+'</td>'+ 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>'+(x.inCfg?'<span class="label label-info">脚本中</span>':'<span class="label label-default">未配置</span>')+'</td>'+
'<td>'+stHtml+'</td>'+ '<td>'+stHtml+'</td>'+
'<td><button class="btn btn-xs btn-danger" onclick="removeStfDev(\''+esc(x.ip)+'\')">移除(断开+删脚本)</button></td></tr>'; '<td style="white-space:nowrap">'+
'<button class="btn btn-xs btn-danger" onclick="removeStfDev(\''+esc(x.ip)+'\')" title="断开+删脚本">移除</button> '+
'<button class="btn btn-xs btn-danger" onclick="deleteStfDev(\''+esc(x.ip)+'\')" title="断开+删脚本+清 STF 池记录(设备从池中彻底消失)">彻底删除</button>'+
'</td></tr>';
}
// 一键重连:手动运行 220 的 connect_devices.sh,补连所有配置设备
async function reconnectStfDevices(){
if(!confirm('立即运行 220 上的 connect_devices.sh?\n\n会补连脚本里所有未连接的设备(在线设备跳过)。'))return;
const r=await apiPost('/api/stf/devmgmt/reconnect',{});
if(!r)return;
const box=document.getElementById('stfdev-reconnect-out');
if(r.ok){
box.style.display='block';
box.textContent=r.output||'执行完成';
showToast('重连完成','success');
loadStfDevMgmt();
}else{
showToast('重连失败: '+(r.error||''),'error');
}
}
// 彻底删除:脚本移除 + 断开 + 清 STF 池记录(设备从池中彻底消失)
async function deleteStfDev(ip){
if(!confirm('彻底删除 '+ip+'?\n\n1. 从 connect_devices.sh 移除\n2. 断开 220 的 adb 连接\n3. 调用 STF API 清除设备记录(设备从设备池彻底消失,需重新添加才会回来)'))return;
const r=await apiPost('/api/stf/devmgmt/delete',{ip:ip});
if(!r)return;
if(r.ok){
showToast((r.msgs||[]).join(';'),'success');
loadStfDevMgmt();
}else showToast('删除失败: '+(r.error||''),'error');
} }
async function addStfDev(){ async function addStfDev(){
+4
View File
@@ -314,6 +314,8 @@ select.form-control{cursor:pointer}
<button class="btn btn-danger" onclick="clearAllErrors()" title="清除所有异常/失败设备的错误状态(运行中/等待重试的跳过)" data-perm="devices">清除全部异常</button> <button class="btn btn-danger" onclick="clearAllErrors()" title="清除所有异常/失败设备的错误状态(运行中/等待重试的跳过)" data-perm="devices">清除全部异常</button>
<button class="btn" onclick="apiAction('/api/release')" data-perm="devices">强制释放占用</button> <button class="btn" onclick="apiAction('/api/release')" data-perm="devices">强制释放占用</button>
<button class="btn btn-primary" id="btn-scan-fg" onclick="scanForeground()" data-perm="devices">扫描前台App</button> <button class="btn btn-primary" id="btn-scan-fg" onclick="scanForeground()" data-perm="devices">扫描前台App</button>
<button class="btn" onclick="screenAllDevices('on')" data-perm="devices" title="所有在线设备亮屏并解锁">一键亮屏</button>
<button class="btn" onclick="screenAllDevices('off')" data-perm="devices" title="所有在线设备息屏(运行中任务会中断)">一键息屏</button>
<button class="btn" onclick="loadMonitor()">刷新</button> <button class="btn" onclick="loadMonitor()">刷新</button>
<input class="form-control list-search" id="search-devices" placeholder="搜索设备/型号/App/任务..."> <input class="form-control list-search" id="search-devices" placeholder="搜索设备/型号/App/任务...">
<span class="hint"><span id="fg-status"></span> · 每 5 秒自动刷新 · <span id="srvTime"></span> · <span id="sel-count">未选中</span></span> <span class="hint"><span id="fg-status"></span> · 每 5 秒自动刷新 · <span id="srvTime"></span> · <span id="sel-count">未选中</span></span>
@@ -640,10 +642,12 @@ select.form-control{cursor:pointer}
<input type="text" id="stfdev-ip" class="form-control" style="width:220px;font-family:monospace" <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()"> placeholder="设备 IP,如 100.100.10.20" onkeydown="if(event.key==='Enter')addStfDev()">
<button class="btn btn-primary" onclick="addStfDev()">添加设备并连接</button> <button class="btn btn-primary" onclick="addStfDev()">添加设备并连接</button>
<button class="btn" onclick="reconnectStfDevices()" title="立即运行 220 的 connect_devices.sh,补连所有配置设备">一键重连</button>
<button class="btn" onclick="loadStfDevMgmt()">刷新</button> <button class="btn" onclick="loadStfDevMgmt()">刷新</button>
<input class="form-control list-search" id="search-stfdev" placeholder="搜索 IP..."> <input class="form-control list-search" id="search-stfdev" placeholder="搜索 IP...">
<span id="stfdev-status" class="text-muted"></span> <span id="stfdev-status" class="text-muted"></span>
</div> </div>
<div class="help" id="stfdev-reconnect-out" style="display:none;background:#0f172a;color:#e2e8f0;font-family:monospace;font-size:12px;padding:10px;border-radius:8px;margin-bottom:10px;white-space:pre-wrap"></div>
<table class="table"> <table class="table">
<thead><tr> <thead><tr>
<th data-sort="ip" onclick="_lpSort('stfdev','ip')">设备</th> <th data-sort="ip" onclick="_lpSort('stfdev','ip')">设备</th>
+206 -1
View File
@@ -22,11 +22,14 @@ import functools
import shlex import shlex
import re import re
import subprocess import subprocess
import urllib.parse
import threading
from datetime import datetime from datetime import datetime
from concurrent.futures import ThreadPoolExecutor, as_completed, 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) render_template_string, Response, make_response, session)
from markupsafe import escape as _esc
from flask_login import (LoginManager, login_user, logout_user, from flask_login import (LoginManager, login_user, logout_user,
login_required, current_user) login_required, current_user)
@@ -547,6 +550,183 @@ def api_stop_device():
return jsonify({"ok": False, "error": f"{serial} 没有运行中的任务"}), 400 return jsonify({"ok": False, "error": f"{serial} 没有运行中的任务"}), 400
@app.route("/locate")
def locate_page():
"""设备端定位页(免登录):点亮后浏览器打开,大字显示设备 IP/序列号。
只展示 serial 文本,无任何平台数据,免登录可接受。
"""
serial = request.args.get("serial", "")
return render_template_string(
"""<!DOCTYPE html><html><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>设备定位</title><style>
body{margin:0;background:#000;color:#fff;display:flex;flex-direction:column;
align-items:center;justify-content:center;height:100vh;font-family:monospace}
.serial{font-size:min(9vw,72px);font-weight:700;color:#ffd700;word-break:break-all;padding:0 20px;text-align:center}
.ip{font-size:min(5vw,36px);color:#7dd3fc;margin-top:24px;word-break:break-all;padding:0 20px;text-align:center}
.hint{font-size:min(3vw,16px);color:#64748b;margin-top:40px}
</style></head><body>
<div class="serial">{{ serial }}</div>
<div class="ip">{{ ip }}</div>
<div class="hint">定位完成 · 按返回键退出</div>
</body></html>""",
serial=_esc(serial), ip=_esc(serial.split(":")[0]))
# 常见浏览器包名(结束定位时 force-stop 用)
_BROWSER_PKGS = {"com.android.browser", "com.miui.browser", "com.android.chrome",
"com.brave.browser", "com.opera.browser", "org.mozilla.firefox",
"com.UCMobile", "com.tencent.mtt", "com.baidu.browser.apps"}
# 定位时启动的浏览器:serial -> 包名(结束定位时无论前后台都关闭它)
_locate_browsers = {}
_locate_lock = threading.Lock()
def _current_focus_pkg(serial):
"""取设备当前前台包名(dumpsys window 解析,失败返回空串)。"""
try:
r = subprocess.run([ADB_PATH, "-s", serial, "shell", "dumpsys", "window"],
capture_output=True, timeout=20)
out = (r.stdout or b"").decode("utf-8", errors="replace")
m = re.search(r"mCurrentFocus=.*?([\w.]+)/", out)
return m.group(1) if m else ""
except Exception:
return ""
@app.route("/api/device/locate/stop", methods=["POST"])
@perm_required(PERM_DEVICES)
def api_device_locate_stop():
"""结束定位:关闭定位启动的浏览器(无论当前是否前台)。
优先 force-stop 定位时记录下的浏览器包名(切到后台也能关掉);
无记录时退回:前台是浏览器 → force-stop;否则按返回键(不误杀任务应用)。
"""
serial = str((request.json or {}).get("serial", "")).strip()
if not serial:
return jsonify({"ok": False, "error": "缺少设备 serial"}), 400
try:
if ":" in serial:
adb_connect(serial)
# 1. 优先关闭定位时启动的浏览器(无论前后台)
with _locate_lock:
started = _locate_browsers.pop(serial, None)
if started:
subprocess.run([ADB_PATH, "-s", serial, "shell", "am", "force-stop", started],
capture_output=True, timeout=20)
msg = f"已关闭定位浏览器 {started}"
else:
# 2. 退回:前台是浏览器则关闭,否则返回键轻量退出
pkg = _current_focus_pkg(serial)
if pkg in _BROWSER_PKGS:
subprocess.run([ADB_PATH, "-s", serial, "shell", "am", "force-stop", pkg],
capture_output=True, timeout=20)
msg = f"已关闭浏览器 {pkg}"
else:
subprocess.run([ADB_PATH, "-s", serial, "shell", "input", "keyevent", "4"],
capture_output=True, timeout=15) # BACK 轻量退出
msg = "已按返回键退出" + (f"(前台 {pkg})" if pkg else "")
_log.info(f"结束定位 {serial}: {msg}")
return jsonify({"ok": True, "msg": msg})
except Exception as e:
return jsonify({"ok": False, "error": f"结束定位失败: {e}"}), 502
@app.route("/api/device/screen_all", methods=["POST"])
@perm_required(PERM_DEVICES)
def api_device_screen_all():
"""批量亮屏/息屏所有在线设备(并发)。mode: on=亮屏解锁 / off=息屏。
息屏会中断运行中的任务(u2 无法操作),前端已有确认提示。
"""
data = request.json or {}
mode = data.get("mode", "on")
try:
serials = [d["serial"] for d in stf.list_all_devices() if d.get("present")]
except Exception as e:
return jsonify({"ok": False, "error": f"获取设备列表失败: {e}"}), 502
if not serials:
return jsonify({"ok": False, "error": "无在线设备"}), 400
def _do(serial):
try:
if ":" in serial:
adb_connect(serial)
if mode == "off":
subprocess.run([ADB_PATH, "-s", serial, "shell", "input", "keyevent", "26"],
capture_output=True, timeout=15) # KEYCODE_POWER 息屏
else:
subprocess.run([ADB_PATH, "-s", serial, "shell", "input", "keyevent", "224"],
capture_output=True, timeout=15) # WAKEUP
subprocess.run([ADB_PATH, "-s", serial, "shell", "wm", "dismiss-keyguard"],
capture_output=True, timeout=15)
return True
except Exception:
return False
with ThreadPoolExecutor(max_workers=min(10, len(serials))) as pool:
results = list(pool.map(_do, serials))
ok_n = sum(1 for x in results if x)
_log.info(f"批量{'息屏' if mode == 'off' else '亮屏'}: 成功 {ok_n}/{len(serials)}")
return jsonify({"ok": True, "success": ok_n, "total": len(serials)})
@app.route("/api/device/locate", methods=["POST"])
@perm_required(PERM_DEVICES)
def api_device_locate():
"""定位设备:点亮屏幕并解除锁屏,可选打开大字定位页(查找设备用)。
请求 {"serial": "...", "show": true}:show=true 时额外用浏览器打开
平台 /locate 定位页,全屏大字显示设备 IP(更醒目,但会切换前台,
任务运行中慎用)。点亮组合:WAKEUP → dismiss-keyguard → MENU 兜底。
绝不 disconnect,红线。
"""
data = request.json or {}
serial = str(data.get("serial", "")).strip()
if not serial:
return jsonify({"ok": False, "error": "缺少设备 serial"}), 400
try:
if ":" in serial:
adb_connect(serial)
for cmd in (["shell", "input", "keyevent", "224"],
["shell", "wm", "dismiss-keyguard"],
["shell", "input", "keyevent", "82"]):
try:
subprocess.run([ADB_PATH, "-s", serial, *cmd],
capture_output=True, timeout=15)
except Exception:
pass # 单步失败不阻塞,继续下一步
msgs = ["屏幕已点亮"]
if data.get("show"):
# 用设备浏览器打开定位页(设备走 tailnet 访问本机 100.100.10.2:18050)
locate_url = f"http://100.100.10.2:18050/locate?serial={urllib.parse.quote(serial)}"
r = subprocess.run([ADB_PATH, "-s", serial, "shell", "am", "start",
"-a", "android.intent.action.VIEW", "-d", locate_url],
capture_output=True, timeout=20)
out = ((r.stdout or b"") + (r.stderr or b"")).decode("utf-8", errors="replace")
if "error" in out.lower() or "exception" in out.lower():
msgs.append(f"打开定位页失败: {out.strip()[:80]}")
else:
msgs.append("已打开大字定位页(按返回键退出)")
# 记录启动的浏览器包名:结束定位时无论前后台都关闭它。
# 优先从 am start 输出解析 pkg=(浏览器可能尚未到前台,前台检测会扑空)
pkg = ""
m2 = re.search(r"pkg=([\w.]+)", out)
if m2 and m2.group(1) in _BROWSER_PKGS:
pkg = m2.group(1)
else:
time.sleep(1.5) # 等浏览器切到前台再查
pkg = _current_focus_pkg(serial)
if pkg in _BROWSER_PKGS:
with _locate_lock:
_locate_browsers[serial] = pkg
_log.info(f"定位设备 {serial}: {'; '.join(msgs)}")
return jsonify({"ok": True, "msg": f"{serial} {';'.join(msgs)}"})
except Exception as e:
return jsonify({"ok": False, "error": f"定位失败: {e}"}), 502
@app.route("/api/stop_all", methods=["POST"]) @app.route("/api/stop_all", methods=["POST"])
@perm_required(PERM_DEVICES) @perm_required(PERM_DEVICES)
def api_stop_all(): def api_stop_all():
@@ -1098,6 +1278,31 @@ def api_tools_appver():
"total": len(results)}) "total": len(results)})
@app.route("/api/stf/devmgmt/delete", methods=["POST"])
@admin_required
def api_stf_devmgmt_delete():
"""STF 设备管理:彻底删除(脚本移除 + disconnect + 清 STF 池记录)。"""
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)
return jsonify({"ok": True, "msgs": msgs})
except StfDevError as e:
return jsonify({"ok": False, "error": str(e)}), 502
@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
@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():