Compare commits
2
Commits
a51857d143
...
c111cb9a5e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c111cb9a5e | ||
|
|
e48eeef49b |
+44
-4
@@ -151,7 +151,14 @@ class TaskJob:
|
|||||||
g = manager.groups.get(self.target.get("group_name"))
|
g = manager.groups.get(self.target.get("group_name"))
|
||||||
serials = list(g.serials) if g else []
|
serials = list(g.serials) if g else []
|
||||||
else:
|
else:
|
||||||
# all:返回所有空闲设备(天然只含在线设备)
|
# all:默认返回所有空闲设备(天然只含在线设备);
|
||||||
|
# 抢占模式返回全部在线就绪设备(含被占用,执行时抢占)
|
||||||
|
if self.params.get("preempt"):
|
||||||
|
try:
|
||||||
|
return [d["serial"] for d in manager.stf.list_all_devices()
|
||||||
|
if d.get("present") and d.get("ready")]
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
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:
|
||||||
@@ -688,13 +695,40 @@ class TaskManager:
|
|||||||
if serial in self._stop_requested:
|
if serial in self._stop_requested:
|
||||||
_log.info(f"{serial} 用户已请求停止,取消重试 (job={job.name})")
|
_log.info(f"{serial} 用户已请求停止,取消重试 (job={job.name})")
|
||||||
return
|
return
|
||||||
# 同一 serial 同时只能一个 worker
|
# 同一 serial 同时只能一个 worker;不同任务可配置抢占
|
||||||
|
preempt = False
|
||||||
|
preempted_job = None # 被抢占的原任务 id(抢占结束后归还)
|
||||||
with self._lock:
|
with self._lock:
|
||||||
if serial in self._running:
|
if serial in self._running:
|
||||||
_log.warning(f"{serial} 已有任务在跑,跳过 (job={job.name})")
|
cur = self._running[serial]
|
||||||
|
if cur.get("job_id") == job.id:
|
||||||
|
# 同一任务重复触发:跳过(原行为)
|
||||||
|
_log.warning(f"{serial} 已有任务在跑,跳过 (job={job.name})")
|
||||||
|
return
|
||||||
|
if not job.params.get("preempt"):
|
||||||
|
# 不同任务且本任务未开启抢占:跳过
|
||||||
|
_log.warning(f"{serial} 已有任务在跑,跳过 (job={job.name})")
|
||||||
|
return
|
||||||
|
preempt = True
|
||||||
|
preempted_job = cur.get("job_id")
|
||||||
|
if preempt:
|
||||||
|
# 抢占:锁外停止该设备上的其他任务(stop_device 内部拿同一把锁,
|
||||||
|
# 在锁内调用会死锁!),等其释放后接管
|
||||||
|
_log.warning(f"{serial} 抢占:停止任务 {preempted_job} 后执行 {job.name}")
|
||||||
|
self.stop_device(serial)
|
||||||
|
deadline = time.time() + 30
|
||||||
|
while time.time() < deadline:
|
||||||
|
with self._lock:
|
||||||
|
if serial not in self._running:
|
||||||
|
break
|
||||||
|
time.sleep(0.5)
|
||||||
|
else:
|
||||||
|
_log.warning(f"{serial} 抢占超时(旧任务 30s 未退出),跳过 (job={job.name})")
|
||||||
return
|
return
|
||||||
|
with self._lock:
|
||||||
self._running[serial] = {"job_id": job.id, "started_at": time.time(),
|
self._running[serial] = {"job_id": job.id, "started_at": time.time(),
|
||||||
"attempt": attempt, "task_type": job.task_type}
|
"attempt": attempt, "task_type": job.task_type,
|
||||||
|
"preempted_job": preempted_job}
|
||||||
_update_status(serial, task_job=job.name, attempt=attempt,
|
_update_status(serial, task_job=job.name, attempt=attempt,
|
||||||
max_attempts=max_attempts)
|
max_attempts=max_attempts)
|
||||||
|
|
||||||
@@ -761,6 +795,12 @@ class TaskManager:
|
|||||||
# 清除停止标志:整个重试循环结束(成功/失败/停止)后允许下次任务
|
# 清除停止标志:整个重试循环结束(成功/失败/停止)后允许下次任务
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._stop_requested.discard(serial)
|
self._stop_requested.discard(serial)
|
||||||
|
# 归还:本任务是抢占任务,结束后自动重新启动被抢占的原任务
|
||||||
|
if preempted_job:
|
||||||
|
j = self.jobs.get(preempted_job)
|
||||||
|
if j and j.enabled:
|
||||||
|
_log.info(f"{serial} 抢占任务 {job.name} 结束,归还设备给任务 {j.name}")
|
||||||
|
threading.Thread(target=self._run_job, args=(j,), daemon=True).start()
|
||||||
|
|
||||||
# ---- 运行控制 ----
|
# ---- 运行控制 ----
|
||||||
def stop_device(self, serial):
|
def stop_device(self, serial):
|
||||||
|
|||||||
@@ -341,13 +341,14 @@ var _stepEditor={
|
|||||||
h+='<div class="form-group form-check"><input type="checkbox" data-key="clear_first" '+(p.clear_first!==false?'checked':'')+'><label>输入前先清空</label></div>';
|
h+='<div class="form-group form-check"><input type="checkbox" data-key="clear_first" '+(p.clear_first!==false?'checked':'')+'><label>输入前先清空</label></div>';
|
||||||
h+='<div class="help">需先用"点击元素"步骤定位到输入框,本步骤只负责输入文字</div>';
|
h+='<div class="help">需先用"点击元素"步骤定位到输入框,本步骤只负责输入文字</div>';
|
||||||
}else if(step.type==='loop'){
|
}else if(step.type==='loop'){
|
||||||
const lm=p.loop_mode==='time'?'time':'rounds';
|
const lm=p.loop_mode==='time'?'time':(p.loop_mode==='forever'?'forever':'rounds');
|
||||||
h+='<div class="form-row"><div class="form-group"><label>循环方式</label><select class="form-control" data-key="loop_mode" onchange="_stepEditor._toggleLoopMode(this)">'+
|
h+='<div class="form-row"><div class="form-group"><label>循环方式</label><select class="form-control" data-key="loop_mode" onchange="_stepEditor._toggleLoopMode(this)">'+
|
||||||
'<option value="rounds"'+(lm==='rounds'?' selected':'')+'>按轮次</option>'+
|
'<option value="rounds"'+(lm==='rounds'?' selected':'')+'>按轮次</option>'+
|
||||||
'<option value="time"'+(lm==='time'?' selected':'')+'>按时间</option></select></div>'+
|
'<option value="time"'+(lm==='time'?' selected':'')+'>按时间</option>'+
|
||||||
|
'<option value="forever"'+(lm==='forever'?' selected':'')+'>一直循环(直到任务停止)</option></select></div>'+
|
||||||
'<div class="form-group" data-lmode="rounds" style="display:'+(lm==='rounds'?'block':'none')+'"><label>最大循环次数</label><input type="number" class="form-control" data-key="max_iterations" value="'+(p.max_iterations||10)+'" min="1" max="100000"></div>'+
|
'<div class="form-group" data-lmode="rounds" style="display:'+(lm==='rounds'?'block':'none')+'"><label>最大循环次数</label><input type="number" class="form-control" data-key="max_iterations" value="'+(p.max_iterations||10)+'" min="1" max="100000"></div>'+
|
||||||
'<div class="form-group" data-lmode="time" style="display:'+(lm==='time'?'block':'none')+'"><label>循环时长(秒)</label><input type="number" class="form-control" data-key="loop_duration" value="'+(p.loop_duration||600)+'" min="1"></div></div>';
|
'<div class="form-group" data-lmode="time" style="display:'+(lm==='time'?'block':'none')+'"><label>循环时长(秒)</label><input type="number" class="form-control" data-key="loop_duration" value="'+(p.loop_duration||600)+'" min="1"></div></div>';
|
||||||
h+='<div class="help">将下方子步骤(拖拽到本卡片内)循环执行指定轮次或时长;按时间时配合"运行时长上限"看预计结束时间</div>';
|
h+='<div class="help">将下方子步骤循环执行指定轮次/时长;<b>一直循环</b>无限跑直到任务被外部停止——配合调度"定时启动+停止"(如 9:00 启动 / 18:00 停止)实现全天候养号,或配合任务"运行时长上限"自动停止</div>';
|
||||||
}else if(step.type==='group'){
|
}else if(step.type==='group'){
|
||||||
h+='<div class="help">动作组:按顺序执行下方子步骤一次。把多个步骤打包成一个可复用的整体。</div>';
|
h+='<div class="help">动作组:按顺序执行下方子步骤一次。把多个步骤打包成一个可复用的整体。</div>';
|
||||||
h+='<div class="form-group"><button class="btn btn-xs btn-primary" onclick="_stepEditor.saveAsCustomAction(\''+esc(step.id||'')+'\')">存为自定义动作</button> <span class="help" style="display:inline">把本组步骤保存到左侧自定义动作库,方便复用</span></div>';
|
h+='<div class="form-group"><button class="btn btn-xs btn-primary" onclick="_stepEditor.saveAsCustomAction(\''+esc(step.id||'')+'\')">存为自定义动作</button> <span class="help" style="display:inline">把本组步骤保存到左侧自定义动作库,方便复用</span></div>';
|
||||||
@@ -1057,6 +1058,9 @@ async function saveTask(jobId){
|
|||||||
// 离线自动跳过(serial/group 模式):任务触发时跳过 STF 池中不在线的设备
|
// 离线自动跳过(serial/group 模式):任务触发时跳过 STF 池中不在线的设备
|
||||||
const skipEl=document.getElementById('f-skip_offline');
|
const skipEl=document.getElementById('f-skip_offline');
|
||||||
params.skip_offline = skipEl?skipEl.checked:true;
|
params.skip_offline = skipEl?skipEl.checked:true;
|
||||||
|
// 抢占设备:触发时停止目标设备上的其他任务,接管执行
|
||||||
|
const preemptEl=document.getElementById('f-preempt');
|
||||||
|
params.preempt = preemptEl?preemptEl.checked:false;
|
||||||
|
|
||||||
const targetMode=document.getElementById('f-target_mode').value;
|
const targetMode=document.getElementById('f-target_mode').value;
|
||||||
const target={mode:targetMode};
|
const target={mode:targetMode};
|
||||||
|
|||||||
@@ -108,6 +108,10 @@ function openTaskModal(jobId){
|
|||||||
'<input type="checkbox" id="f-skip_offline" '+(j.params&&j.params.skip_offline===false?'':'checked')+'>'+
|
'<input type="checkbox" id="f-skip_offline" '+(j.params&&j.params.skip_offline===false?'':'checked')+'>'+
|
||||||
'<label for="f-skip_offline">离线设备自动跳过(推荐)</label>'+
|
'<label for="f-skip_offline">离线设备自动跳过(推荐)</label>'+
|
||||||
'</div>'+
|
'</div>'+
|
||||||
|
'<div class="form-check" style="margin-top:6px">'+
|
||||||
|
'<input type="checkbox" id="f-preempt" '+(j.params&&j.params.preempt?'checked':'')+'>'+
|
||||||
|
'<label for="f-preempt" title="触发时停止目标设备上正在运行的其他任务,接管设备执行本任务">抢占设备(打断其他任务)</label>'+
|
||||||
|
'</div>'+
|
||||||
'</div>'+
|
'</div>'+
|
||||||
'<div class="form-row">'+
|
'<div class="form-row">'+
|
||||||
'<div class="form-group"><label>调度方式</label>'+
|
'<div class="form-group"><label>调度方式</label>'+
|
||||||
|
|||||||
+13
-4
@@ -29,7 +29,7 @@ async function loadToolsDevices(force){
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
wrap.innerHTML = _clipDevices.map(d=>{
|
wrap.innerHTML = _clipDevices.map(d=>{
|
||||||
const st = d.state==='stf' ? 'STF池' : (d.state==='device' ? '已连接' : d.state);
|
const st = d.state==='stf' ? 'STF池' : (d.state==='device' ? '已连接' : (d.state==='stf_not_ready' ? 'STF未就绪' : d.state));
|
||||||
const stCls = (d.state==='device'||d.state==='stf') ? 'label-success' : 'label-warning';
|
const stCls = (d.state==='device'||d.state==='stf') ? 'label-success' : 'label-warning';
|
||||||
return '<label data-serial="'+esc(d.serial)+'">'
|
return '<label data-serial="'+esc(d.serial)+'">'
|
||||||
+'<input type="checkbox" value="'+esc(d.serial)+'"'+( _clipSelected.has(d.serial)?' checked':'')+' onchange="toggleClipDevice(this)">'
|
+'<input type="checkbox" value="'+esc(d.serial)+'"'+( _clipSelected.has(d.serial)?' checked':'')+' onchange="toggleClipDevice(this)">'
|
||||||
@@ -222,7 +222,7 @@ async function loadAdbDevices(force){
|
|||||||
const list=await _fetchAdbDevices(force);
|
const list=await _fetchAdbDevices(force);
|
||||||
if(list===null)return;
|
if(list===null)return;
|
||||||
const cur=sel.value;
|
const cur=sel.value;
|
||||||
const stateTxt={device:'在线',offline:'离线',stf:'STF池(未连接)'};
|
const stateTxt={device:'在线',offline:'离线',stf:'STF池(未连接)',stf_not_ready:'STF未就绪'};
|
||||||
const opts=['<option value="">(不指定设备)</option>'].concat(
|
const opts=['<option value="">(不指定设备)</option>'].concat(
|
||||||
list.map(d=>
|
list.map(d=>
|
||||||
'<option value="'+esc(d.serial)+'">'+esc(d.serial)+' ('+(stateTxt[d.state]||d.state)+')</option>'
|
'<option value="'+esc(d.serial)+'">'+esc(d.serial)+' ('+(stateTxt[d.state]||d.state)+')</option>'
|
||||||
@@ -251,11 +251,20 @@ async function runAdbCmd(){
|
|||||||
cmd=_attachDevice(cmd);
|
cmd=_attachDevice(cmd);
|
||||||
await _runAdbFull('adb '+cmd);
|
await _runAdbFull('adb '+cmd);
|
||||||
}
|
}
|
||||||
|
// 修复类命令:目标为 IP:port 时先 adb connect(已连接自动跳过),
|
||||||
|
// 否则设备不在本机 adb server 里会报 "device not found"(STF 未就绪的设备尤其如此)。
|
||||||
|
async function _runAdbRepair(full){
|
||||||
|
const serial=_selectedSerial();
|
||||||
|
if(serial && serial.indexOf(':')>=0){
|
||||||
|
await _runAdbFull('adb connect '+serial);
|
||||||
|
}
|
||||||
|
await _runAdbFull(full);
|
||||||
|
}
|
||||||
function uninstallStfAgent(){
|
function uninstallStfAgent(){
|
||||||
const serial=_selectedSerial();
|
const serial=_selectedSerial();
|
||||||
if(!serial){showToast('请先选择目标设备','error');return;}
|
if(!serial){showToast('请先选择目标设备','error');return;}
|
||||||
if(!confirm('确定卸载 '+serial+' 上的 STF agent(jp.co.cyberagent.stf)?\n\n卸载后 STF 会在设备重连时自动重新推送安装。'))return;
|
if(!confirm('确定卸载 '+serial+' 上的 STF agent(jp.co.cyberagent.stf)?\n\n卸载后 STF 会在设备重连时自动重新推送安装。'))return;
|
||||||
_runAdbFull('adb -s '+serial+' uninstall jp.co.cyberagent.stf');
|
_runAdbRepair('adb -s '+serial+' uninstall jp.co.cyberagent.stf');
|
||||||
}
|
}
|
||||||
function reconnectAdbDevice(){
|
function reconnectAdbDevice(){
|
||||||
const serial=_selectedSerial();
|
const serial=_selectedSerial();
|
||||||
@@ -265,7 +274,7 @@ function reconnectAdbDevice(){
|
|||||||
function checkStfAgent(){
|
function checkStfAgent(){
|
||||||
const serial=_selectedSerial();
|
const serial=_selectedSerial();
|
||||||
if(!serial){showToast('请先选择目标设备','error');return;}
|
if(!serial){showToast('请先选择目标设备','error');return;}
|
||||||
_runAdbFull('adb -s '+serial+' shell pm list packages | grep cyberagent');
|
_runAdbRepair('adb -s '+serial+' shell pm list packages | grep cyberagent');
|
||||||
}
|
}
|
||||||
async function restartStf(){
|
async function restartStf(){
|
||||||
if(!confirm('确定重启 STF 容器?\n\n影响:STF 约 30 秒不可用,期间所有设备占用状态重置,正在执行的任务会受影响。\n\n确认继续吗?'))return;
|
if(!confirm('确定重启 STF 容器?\n\n影响:STF 约 30 秒不可用,期间所有设备占用状态重置,正在执行的任务会受影响。\n\n确认继续吗?'))return;
|
||||||
|
|||||||
+18
-2
@@ -502,14 +502,30 @@ class GenericStepsWorker(BaseWorker):
|
|||||||
def _exec_wait(self, d, params, depth=0):
|
def _exec_wait(self, d, params, depth=0):
|
||||||
min_s = float(params.get("min", 1.0))
|
min_s = float(params.get("min", 1.0))
|
||||||
max_s = float(params.get("max", 3.0))
|
max_s = float(params.get("max", 3.0))
|
||||||
random_sleep(min_s, max_s)
|
# 分片 sleep:每 0.5s 检查停止/超时信号,抢占任务能及时接管(纯 sleep 无法中断)
|
||||||
|
total = random.uniform(min_s, max_s)
|
||||||
|
end = time.time() + total
|
||||||
|
while time.time() < end:
|
||||||
|
if self.stopped() or self.is_time_up():
|
||||||
|
return
|
||||||
|
time.sleep(min(0.5, end - time.time()))
|
||||||
|
|
||||||
def _exec_loop(self, d, params, depth=0):
|
def _exec_loop(self, d, params, depth=0):
|
||||||
children = params.get("children", [])
|
children = params.get("children", [])
|
||||||
if not children:
|
if not children:
|
||||||
return
|
return
|
||||||
mode = params.get("loop_mode", "rounds")
|
mode = params.get("loop_mode", "rounds")
|
||||||
if mode == "time":
|
if mode == "forever":
|
||||||
|
# 一直循环:无限跑直到任务被外部停止(定时停止 cron / 运行时长上限 / 手动停止)。
|
||||||
|
# 配合"定时启动+停止"调度(如 9:00 启动 / 18:00 停止)实现全天候运行。
|
||||||
|
i = 0
|
||||||
|
while not self.stopped() and not self.is_time_up():
|
||||||
|
i += 1
|
||||||
|
self.set_action(f"循环第 {i} 轮(持续)")
|
||||||
|
if i % 10 == 0 or i <= 2:
|
||||||
|
_log.info(f"[{self.serial}] loop 第 {i} 轮(持续运行)")
|
||||||
|
self._exec_steps(d, children, depth + 1)
|
||||||
|
elif mode == "time":
|
||||||
# 按时间循环:跑满 loop_duration 秒,同时受全局 stopped/max_duration 约束
|
# 按时间循环:跑满 loop_duration 秒,同时受全局 stopped/max_duration 约束
|
||||||
duration = float(params.get("loop_duration", 0) or 0)
|
duration = float(params.get("loop_duration", 0) or 0)
|
||||||
if duration <= 0:
|
if duration <= 0:
|
||||||
|
|||||||
@@ -546,9 +546,9 @@ select.form-control{cursor:pointer}
|
|||||||
<button class="btn btn-xs" onclick="fillAdbCmd('tcpip 5555')" title="USB 设备开启网络调试(重启设备后失效,需重新执行)">tcpip 5555 转网络调试</button>
|
<button class="btn btn-xs" onclick="fillAdbCmd('tcpip 5555')" title="USB 设备开启网络调试(重启设备后失效,需重新执行)">tcpip 5555 转网络调试</button>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;gap:6px;margin-bottom:8px;flex-wrap:wrap">
|
<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 btn-danger" onclick="uninstallStfAgent()" title="自动先 adb connect 再卸载 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="reconnectAdbDevice()" title="重新 adb connect 该设备">重连设备</button>
|
||||||
<button class="btn btn-xs" onclick="checkStfAgent()" title="查看 jp.co.cyberagent.stf 是否已安装">检查 agent</button>
|
<button class="btn btn-xs" onclick="checkStfAgent()" title="自动先 adb connect 再查看 jp.co.cyberagent.stf 是否已安装">检查 agent</button>
|
||||||
</div>
|
</div>
|
||||||
<pre id="adb-output" class="adb-console">(执行命令后输出显示在这里)</pre>
|
<pre id="adb-output" class="adb-console">(执行命令后输出显示在这里)</pre>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+11
-2
@@ -1065,6 +1065,9 @@ def _merged_device_list():
|
|||||||
"""本地 adb(含 USB)+ STF 在线设备池的合并列表([{serial, state}])。
|
"""本地 adb(含 USB)+ STF 在线设备池的合并列表([{serial, state}])。
|
||||||
|
|
||||||
供维护终端/剪贴板注入/应用版本管理等设备选择场景共用。
|
供维护终端/剪贴板注入/应用版本管理等设备选择场景共用。
|
||||||
|
STF 里 adb 可达但 agent 未就绪的设备(手机端 agent 启动失败)也列入,
|
||||||
|
标记 stf_not_ready——这种设备恰恰需要用维护终端卸载/重装 agent 修复,
|
||||||
|
不列出来就永远无法修复(死循环)。
|
||||||
"""
|
"""
|
||||||
states = {}
|
states = {}
|
||||||
out = _adb("devices")
|
out = _adb("devices")
|
||||||
@@ -1075,8 +1078,13 @@ def _merged_device_list():
|
|||||||
try:
|
try:
|
||||||
for d in stf.list_all_devices():
|
for d in stf.list_all_devices():
|
||||||
serial = d.get("serial", "")
|
serial = d.get("serial", "")
|
||||||
if serial and d.get("present") and d.get("ready") and serial not in states:
|
if not serial or serial in states:
|
||||||
|
continue
|
||||||
|
if d.get("present") and d.get("ready"):
|
||||||
states[serial] = "stf"
|
states[serial] = "stf"
|
||||||
|
elif d.get("present") and not d.get("ready"):
|
||||||
|
# adb 可达但 STF agent 未就绪(启动失败/未安装):列入供修复
|
||||||
|
states[serial] = "stf_not_ready"
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return [{"serial": s, "state": st} for s, st in sorted(states.items())]
|
return [{"serial": s, "state": st} for s, st in sorted(states.items())]
|
||||||
@@ -1085,7 +1093,8 @@ def _merged_device_list():
|
|||||||
@app.route("/api/adb/devices")
|
@app.route("/api/adb/devices")
|
||||||
@admin_required
|
@admin_required
|
||||||
def api_adb_devices():
|
def api_adb_devices():
|
||||||
"""维护终端设备列表(仅管理员):本地 adb 已连接 + STF 在线设备池。
|
"""维护终端设备列表(仅管理员):本地 adb 已连接 + STF 在线设备池
|
||||||
|
(含 agent 未就绪的待修复设备,state=stf_not_ready)。
|
||||||
|
|
||||||
供终端设备选择器使用——选中后自动附加 `-s <serial>`,
|
供终端设备选择器使用——选中后自动附加 `-s <serial>`,
|
||||||
离线/未连接的设备也可选,配合"重连设备"按钮恢复。
|
离线/未连接的设备也可选,配合"重连设备"按钮恢复。
|
||||||
|
|||||||
Reference in New Issue
Block a user