feat: 维护 Tab(STF 容器一键重启 + adb 远程终端,含设备快捷选择/卸载 agent/重连)
This commit is contained in:
@@ -64,3 +64,9 @@ APK_DIR = os.path.join(DATA_DIR, "apks")
|
||||
# 启动时是否自动释放"仍被本账户占用"的设备(上次崩溃/强杀残留)。
|
||||
# 仅单实例部署时设为 True:多实例共用同一 STF 账户时不要开,会误释放另一实例的运行中任务。
|
||||
AUTO_RELEASE_STALE_OCCUPY = _env("AUTO_RELEASE_STALE_OCCUPY", "false").lower() in ("1", "true", "yes")
|
||||
|
||||
# ================== 维护(STF 容器一键重启) ==================
|
||||
# 维护页"一键重启 STF 容器"通过 SSH 到部署机执行 docker restart。
|
||||
# 前置条件:本机可免密 SSH 到目标机(stf@220 需已授权本机公钥)。
|
||||
STF_SSH_TARGET = _env("STF_SSH_TARGET", "[email protected]")
|
||||
STF_DOCKER_CONTAINER = _env("STF_DOCKER_CONTAINER", "stf")
|
||||
|
||||
+85
-1
@@ -258,7 +258,7 @@ function showTab(name){
|
||||
document.querySelectorAll('.tab-panel').forEach(p=>p.classList.remove('active'));
|
||||
document.querySelectorAll('.navbar .tab').forEach(t=>t.classList.remove('active'));
|
||||
document.getElementById('tab-'+name).classList.add('active');
|
||||
document.querySelectorAll('.navbar .tab')[['monitor','tasks','apps','groups','logs','users'].indexOf(name)].classList.add('active');
|
||||
document.querySelectorAll('.navbar .tab')[['monitor','tasks','apps','groups','logs','users','maintenance'].indexOf(name)].classList.add('active');
|
||||
// 停止所有定时器
|
||||
if(_monitorTimer){clearInterval(_monitorTimer);_monitorTimer=null;}
|
||||
if(_logTimer){clearInterval(_logTimer);_logTimer=null;}
|
||||
@@ -266,6 +266,7 @@ function showTab(name){
|
||||
if(name==='monitor'){loadMonitor();_monitorTimer=setInterval(loadMonitor,5000);}
|
||||
if(name==='tasks'){loadTasks();loadCustomActions();}
|
||||
if(name==='apps')loadApks();
|
||||
if(name==='maintenance')loadAdbDevices();
|
||||
if(name==='groups')loadGroups();
|
||||
if(name==='logs'){loadLogs();if(document.getElementById('log-auto').checked)_logTimer=setInterval(loadLogs,3000);}
|
||||
if(name==='users')loadUsers();
|
||||
@@ -1927,6 +1928,89 @@ async function deleteUser(uid){
|
||||
handleResult(r,()=>loadUsers());
|
||||
}
|
||||
|
||||
// ================== Tab: 维护(仅管理员) ==================
|
||||
// 服务器类命令(不需要 -s 目标设备)
|
||||
const _NO_DEVICE_CMDS=['devices','version','help','start-server','connect','kill-server','disconnect'];
|
||||
function _selectedSerial(){
|
||||
const sel=document.getElementById('adb-device');
|
||||
return sel?sel.value:'';
|
||||
}
|
||||
// 选中目标设备时自动附加 -s <serial>;服务器类命令保持原样
|
||||
function _attachDevice(cmd){
|
||||
const serial=_selectedSerial();
|
||||
if(!serial)return cmd;
|
||||
if(/^-s\s/.test(cmd))return cmd; // 已手写 -s 不再重复附加
|
||||
const first=(cmd.split(/\s+/)[0]||'').toLowerCase();
|
||||
if(_NO_DEVICE_CMDS.indexOf(first)>=0)return cmd;
|
||||
return '-s '+serial+' '+cmd;
|
||||
}
|
||||
async function loadAdbDevices(){
|
||||
const sel=document.getElementById('adb-device');
|
||||
if(!sel)return;
|
||||
const r=await apiGet('/api/adb/devices');
|
||||
if(!r||!r.ok)return;
|
||||
const cur=sel.value;
|
||||
const stateTxt={device:'在线',offline:'离线',stf:'STF池(未连接)'};
|
||||
const opts=['<option value="">(不指定设备)</option>'].concat(
|
||||
(r.devices||[]).map(d=>
|
||||
'<option value="'+esc(d.serial)+'">'+esc(d.serial)+' ('+(stateTxt[d.state]||d.state)+')</option>'
|
||||
)
|
||||
);
|
||||
sel.innerHTML=opts.join('');
|
||||
if(cur)sel.value=cur;
|
||||
}
|
||||
function fillAdbCmd(cmd){
|
||||
document.getElementById('adb-cmd-input').value=cmd;
|
||||
}
|
||||
async function _runAdbFull(full){
|
||||
const out=document.getElementById('adb-output');
|
||||
out.textContent='$ '+full+'\n执行中...';
|
||||
const r=await apiPost('/api/adb/cmd',{cmd:full});
|
||||
if(!r)return;
|
||||
if(!r.ok){out.textContent='$ '+full+'\n⚠ '+r.error;return;}
|
||||
out.textContent='$ '+full+'\n\n'+(r.stdout||'')+(r.stderr?'\n[stderr] '+r.stderr:'')+'\n\n[exit '+r.code+']';
|
||||
out.scrollTop=out.scrollHeight;
|
||||
}
|
||||
async function runAdbCmd(){
|
||||
const input=document.getElementById('adb-cmd-input');
|
||||
let cmd=(input.value||'').trim();
|
||||
if(!cmd){showToast('请输入 adb 命令','error');return;}
|
||||
cmd=cmd.replace(/^adb\s+/i,''); // 兼容手输/快捷按钮带的 "adb " 前缀,避免拼成 "adb adb devices"
|
||||
cmd=_attachDevice(cmd);
|
||||
await _runAdbFull('adb '+cmd);
|
||||
}
|
||||
function uninstallStfAgent(){
|
||||
const serial=_selectedSerial();
|
||||
if(!serial){showToast('请先选择目标设备','error');return;}
|
||||
if(!confirm('确定卸载 '+serial+' 上的 STF agent(jp.co.cyberagent.stf)?\n\n卸载后 STF 会在设备重连时自动重新推送安装。'))return;
|
||||
_runAdbFull('adb -s '+serial+' uninstall jp.co.cyberagent.stf');
|
||||
}
|
||||
function reconnectAdbDevice(){
|
||||
const serial=_selectedSerial();
|
||||
if(!serial){showToast('请先选择目标设备','error');return;}
|
||||
_runAdbFull('adb connect '+serial);
|
||||
}
|
||||
function checkStfAgent(){
|
||||
const serial=_selectedSerial();
|
||||
if(!serial){showToast('请先选择目标设备','error');return;}
|
||||
_runAdbFull('adb -s '+serial+' shell pm list packages | grep cyberagent');
|
||||
}
|
||||
async function restartStf(){
|
||||
if(!confirm('确定重启 STF 容器?\n\n影响:STF 约 30 秒不可用,期间所有设备占用状态重置,正在执行的任务会受影响。\n\n确认继续吗?'))return;
|
||||
const st=document.getElementById('stf-restart-status');
|
||||
st.textContent='正在重启 STF 容器(SSH 执行中,最长 60s)...';
|
||||
const r=await apiPost('/api/stf/restart',{});
|
||||
if(!r)return;
|
||||
if(r.ok){st.textContent='✅ '+r.msg+(r.detail?' ['+r.detail+']':'');showToast(r.msg,'success');}
|
||||
else{st.textContent='❌ '+r.error;showToast(r.error,'error');}
|
||||
}
|
||||
|
||||
// 测试此步骤弹窗关闭
|
||||
function closeTestModal(){
|
||||
const ov=document.getElementById('test-overlay');
|
||||
if(ov)ov.classList.remove('show');
|
||||
}
|
||||
|
||||
// ================== Tab: 应用 ==================
|
||||
let _apksCache=[];
|
||||
let _installTimer=null;
|
||||
|
||||
@@ -58,6 +58,7 @@ body{background:var(--bg);font-family:-apple-system,"Segoe UI",Roboto,"PingFang
|
||||
.list-pager{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin:8px 0 20px}
|
||||
.list-pager .lp-total{font-size:12px;color:var(--text-light);margin-right:auto}
|
||||
.list-search{width:240px}
|
||||
.adb-console{background:#0f172a;color:#e2e8f0;font-family:monospace;font-size:12px;padding:12px;border-radius:8px;min-height:200px;max-height:420px;overflow:auto;white-space:pre-wrap;word-break:break-all}
|
||||
|
||||
/* 区块标题 */
|
||||
.section-title{font-size:15px;font-weight:600;color:var(--text);margin:0 0 12px;padding-bottom:8px;border-bottom:2px solid var(--primary);display:inline-block}
|
||||
@@ -267,13 +268,14 @@ select.form-control{cursor:pointer}
|
||||
<div class="tabs">
|
||||
<button class="tab active" onclick="showTab('monitor')">监控</button>
|
||||
<button class="tab" onclick="showTab('tasks')">任务</button>
|
||||
<button class="tab" onclick="showTab('apps')">应用</button>
|
||||
<button class="tab" onclick="showTab('apps')" data-perm="apks">应用</button>
|
||||
<button class="tab" onclick="showTab('groups')">分组</button>
|
||||
<button class="tab" onclick="showTab('logs')">日志</button>
|
||||
<button class="tab" onclick="showTab('users')">用户</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('maintenance')" data-perm="admin">维护</button>
|
||||
</div>
|
||||
<div class="user-info">
|
||||
<span>admin</span>
|
||||
<span id="me-username">...</span>
|
||||
<a href="/logout">退出</a>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -464,15 +466,73 @@ select.form-control{cursor:pointer}
|
||||
</div>
|
||||
<table class="table">
|
||||
<thead><tr>
|
||||
<th>ID</th><th>用户名</th><th>管理员</th><th>操作</th>
|
||||
<th>ID</th><th>用户名</th><th>管理员</th><th>权限</th><th>操作</th>
|
||||
</tr></thead>
|
||||
<tbody id="tb-users"></tbody>
|
||||
</table>
|
||||
<div class="list-pager" id="pager-users"></div>
|
||||
</div>
|
||||
|
||||
<!-- ========== Tab 7: 维护(仅管理员) ========== -->
|
||||
<div id="tab-maintenance" class="tab-panel">
|
||||
<div class="page-title">维护</div>
|
||||
<div class="page-sub">管理员专用:STF 服务管理与 adb 远程终端</div>
|
||||
|
||||
<div class="section-title">STF 服务</div>
|
||||
<div class="toolbar">
|
||||
<button class="btn btn-danger" onclick="restartStf()">一键重启 STF 容器</button>
|
||||
<span id="stf-restart-status" class="text-muted" style="margin-left:10px"></span>
|
||||
</div>
|
||||
<div class="help" style="margin-top:8px">
|
||||
通过 SSH 到部署机执行 <code>docker restart</code>(目标与容器名可用环境变量
|
||||
<code>STF_SSH_TARGET</code> / <code>STF_DOCKER_CONTAINER</code> 配置)。
|
||||
影响:STF 约 30 秒不可用,期间设备占用状态重置;<b>有任务在运行时会被拒绝执行</b>。
|
||||
</div>
|
||||
|
||||
<div class="section-title" style="margin-top:16px">adb 远程终端</div>
|
||||
<div class="help">选中目标设备后,命令里的 <code>-s <设备></code> 自动附加,直接写
|
||||
<code>shell ls /sdcard</code> 即可。离线设备也可选,用"重连设备"恢复。
|
||||
<b>禁止 kill-server / disconnect</b>(会中断 STF provider 的设备监控)。</div>
|
||||
<div style="display:flex;gap:8px;margin:8px 0;align-items:center">
|
||||
<label style="font-size:12px;color:var(--text-light)">目标设备</label>
|
||||
<select id="adb-device" class="form-control" style="flex:1;max-width:340px;font-family:monospace"></select>
|
||||
<button class="btn btn-xs" onclick="loadAdbDevices()" title="刷新设备列表">刷新</button>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;margin:8px 0">
|
||||
<input type="text" id="adb-cmd-input" class="form-control" style="flex:1;font-family:monospace"
|
||||
placeholder="shell ls /sdcard(选中设备后 -s 自动附加)"
|
||||
onkeydown="if(event.key==='Enter')runAdbCmd()">
|
||||
<button class="btn btn-primary" onclick="runAdbCmd()">执行</button>
|
||||
</div>
|
||||
<div style="display:flex;gap:6px;margin-bottom:8px;flex-wrap:wrap">
|
||||
<button class="btn btn-xs" onclick="fillAdbCmd('adb devices')">adb devices</button>
|
||||
<button class="btn btn-xs" onclick="fillAdbCmd('shell dumpsys window | grep mCurrentFocus')">前台App</button>
|
||||
<button class="btn btn-xs" onclick="fillAdbCmd('shell ls /sdcard')">列文件</button>
|
||||
<button class="btn btn-xs" onclick="fillAdbCmd('shell screencap -p /sdcard/shot.png')">截屏</button>
|
||||
<button class="btn btn-xs" onclick="fillAdbCmd('shell pm list packages -3')">已装应用</button>
|
||||
</div>
|
||||
<div style="display:flex;gap:6px;margin-bottom:8px;flex-wrap:wrap">
|
||||
<button class="btn btn-xs btn-danger" onclick="uninstallStfAgent()" title="卸载 jp.co.cyberagent.stf,STF 会在设备重连时重新推送">卸载 STF agent(卡死时用)</button>
|
||||
<button class="btn btn-xs" onclick="reconnectAdbDevice()" title="重新 adb connect 该设备">重连设备</button>
|
||||
<button class="btn btn-xs" onclick="checkStfAgent()" title="查看 jp.co.cyberagent.stf 是否已安装">检查 agent</button>
|
||||
</div>
|
||||
<pre id="adb-output" class="adb-console">(执行命令后输出显示在这里)</pre>
|
||||
</div>
|
||||
|
||||
</div><!-- /.content -->
|
||||
|
||||
<!-- ========== 测试此步骤弹窗 ========== -->
|
||||
<div id="test-overlay" class="modal-overlay" onclick="if(event.target===this)closeTestModal()">
|
||||
<div class="modal" id="test-modal-box">
|
||||
<div class="modal-header">
|
||||
<h3 id="test-modal-title">测试步骤</h3>
|
||||
<button class="close" onclick="closeTestModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body" id="test-modal-body"></div>
|
||||
<div class="modal-footer"><button class="btn" onclick="closeTestModal()">关闭</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ========== 模态框 ========== -->
|
||||
<div id="modal-overlay" class="modal-overlay" onclick="if(event.target===this)closeModal()">
|
||||
<div class="modal" id="modal-box">
|
||||
|
||||
+120
@@ -18,6 +18,8 @@ import time
|
||||
import uuid
|
||||
import atexit
|
||||
import secrets
|
||||
import functools
|
||||
import shlex
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
|
||||
@@ -732,6 +734,124 @@ def api_apks_install_status():
|
||||
return jsonify({"ok": True, "status": status})
|
||||
|
||||
|
||||
# ================== API:维护(仅管理员) ==================
|
||||
# 维护功能:STF 容器一键重启 + adb 远程终端。两个接口都仅管理员可用。
|
||||
# adb 终端安全红线(技术约束):绝不 kill-server / 绝不 disconnect IP:5555——
|
||||
# STF provider 共享该 adb transport,断开会让 STF 误判全部设备离线并触发重连。
|
||||
_ADB_BLOCKED_PATTERNS = ("kill-server", "disconnect")
|
||||
|
||||
|
||||
def _blocked_adb_cmd(cmd):
|
||||
"""命中红线的 adb 命令(kill-server / disconnect)直接拒绝。"""
|
||||
low = cmd.lower()
|
||||
return any(p in low for p in _ADB_BLOCKED_PATTERNS)
|
||||
|
||||
|
||||
@app.route("/api/adb/devices")
|
||||
@admin_required
|
||||
def api_adb_devices():
|
||||
"""维护终端设备列表(仅管理员):本地 adb 已连接 + STF 在线设备池。
|
||||
|
||||
供终端设备选择器使用——选中后自动附加 `-s <serial>`,
|
||||
离线/未连接的设备也可选,配合"重连设备"按钮恢复。
|
||||
"""
|
||||
states = {}
|
||||
out = _adb("devices")
|
||||
for line in (out or "").splitlines()[1:]:
|
||||
parts = line.split()
|
||||
if len(parts) >= 2 and parts[0] and not parts[0].startswith("*"):
|
||||
states[parts[0]] = parts[1]
|
||||
try:
|
||||
for d in stf.list_all_devices():
|
||||
serial = d.get("serial", "")
|
||||
if serial and d.get("present") and d.get("ready") and serial not in states:
|
||||
states[serial] = "stf"
|
||||
except Exception:
|
||||
pass
|
||||
devices = [{"serial": s, "state": st} for s, st in sorted(states.items())]
|
||||
return jsonify({"ok": True, "devices": devices})
|
||||
|
||||
|
||||
@app.route("/api/adb/cmd", methods=["POST"])
|
||||
@admin_required
|
||||
def api_adb_cmd():
|
||||
"""维护终端:执行 adb 命令(仅管理员)。
|
||||
|
||||
请求: {"cmd": "adb -s 100.100.10.11:5555 shell ls /sdcard"}
|
||||
用平台 adb 二进制执行(config.ADB_PATH),带 20s 超时。
|
||||
"""
|
||||
data = request.json or {}
|
||||
cmd = (data.get("cmd") or "").strip()
|
||||
if not cmd:
|
||||
return jsonify({"ok": False, "error": "命令不能为空"}), 400
|
||||
if _blocked_adb_cmd(cmd):
|
||||
return jsonify({"ok": False,
|
||||
"error": "禁止执行 kill-server / disconnect(会中断 STF 设备监控,影响所有任务)"}), 400
|
||||
tokens = shlex.split(cmd)
|
||||
if tokens and tokens[0] == "adb":
|
||||
tokens = tokens[1:]
|
||||
if not tokens:
|
||||
return jsonify({"ok": False, "error": "命令不能为空"}), 400
|
||||
try:
|
||||
# 与 worker 的 adb 调用共用锁,避免并发操作同一 adb server
|
||||
with _ADB_LOCK:
|
||||
r = subprocess.run([ADB_PATH, *tokens], capture_output=True, timeout=20)
|
||||
out = (r.stdout or b"").decode("utf-8", errors="replace")
|
||||
err = (r.stderr or b"").decode("utf-8", errors="replace")
|
||||
_log.info(f"维护终端执行 adb {' '.join(tokens)} -> exit {r.returncode}")
|
||||
return jsonify({"ok": True, "stdout": out, "stderr": err, "code": r.returncode})
|
||||
except subprocess.TimeoutExpired:
|
||||
return jsonify({"ok": False, "error": "命令执行超时(20s)"}), 408
|
||||
except Exception as e:
|
||||
_log.warning(f"维护终端 adb 执行异常: {e}")
|
||||
return jsonify({"ok": False, "error": f"执行失败: {e}"}), 500
|
||||
|
||||
|
||||
@app.route("/api/stf/restart", methods=["POST"])
|
||||
@admin_required
|
||||
def api_stf_restart():
|
||||
"""一键重启 STF Docker 容器(仅管理员)。
|
||||
|
||||
通过 SSH(config.STF_SSH_TARGET,默认 [email protected])执行
|
||||
`docker restart <容器>`(config.STF_DOCKER_CONTAINER,默认 stf),
|
||||
随后轮询 STF API 确认恢复。
|
||||
影响:STF 约 30s 不可用,期间占用状态重置、任务中断——有任务在跑时拒绝执行。
|
||||
"""
|
||||
running = mgr.get_running()
|
||||
if running:
|
||||
return jsonify({"ok": False,
|
||||
"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:
|
||||
r = subprocess.run(ssh_cmd, capture_output=True, timeout=60)
|
||||
except subprocess.TimeoutExpired:
|
||||
return jsonify({"ok": False, "error": "SSH 执行超时(60s)"}), 408
|
||||
except FileNotFoundError:
|
||||
return jsonify({"ok": False, "error": "本机未安装 ssh,无法远程操作"}), 500
|
||||
out = (r.stdout or b"").decode("utf-8", errors="replace")
|
||||
err = (r.stderr or b"").decode("utf-8", errors="replace")
|
||||
if r.returncode != 0:
|
||||
_log.error(f"STF 容器重启失败: {err or out}")
|
||||
return jsonify({"ok": False,
|
||||
"error": f"SSH 执行失败:{err or out}(请确认本机可免密 SSH 到 {STF_SSH_TARGET},且容器名 {STF_DOCKER_CONTAINER} 正确)"}), 502
|
||||
# 轮询 STF API 确认恢复(最长 30s)
|
||||
alive = False
|
||||
for _ in range(30):
|
||||
try:
|
||||
stf.list_all_devices()
|
||||
alive = True
|
||||
break
|
||||
except Exception:
|
||||
time.sleep(1)
|
||||
_log.info(f"STF 容器 {STF_DOCKER_CONTAINER} 已重启,API 恢复={alive}")
|
||||
return jsonify({"ok": True,
|
||||
"msg": "STF 容器已重启" + ("" if alive else "(STF API 尚未恢复,请稍后刷新)"),
|
||||
"detail": out.strip(), "api_alive": alive})
|
||||
|
||||
|
||||
# ================== uiautodev 自动启动 ==================
|
||||
_uiauto_proc = None
|
||||
# PID 文件:web_server 异常退出(kill -9/崩溃)时 uiautodev 成孤儿残留,
|
||||
|
||||
Reference in New Issue
Block a user