平台此前出了问题只能靠人盯页面。现在各组件统一走 `notifier.notify(事件, **字段)`, 推到企业微信 / 自建服务;**所有可通知点都登记进事件目录,默认全关,用户按 webhook 勾选**。 ## 架构(`core/notifier.py` + `core/notify_events.py`) 业务线程调 `notify()` → 只做内存操作(读配置快照/匹配订阅/入队)→ 返回; 后台 1 个 dispatcher(聚合 + 每 hook 限流 + 折叠摘要)+ 3 个 sender(真实 HTTP、退避重试) 负责真正发出去。硬约束:**notify 零 DB、零 HTTP、零阻塞、异常不冒泡**——所以任务线程里 可以直接调(不用 app_context、不用 try/except),但**必须放在所有 `with self._lock` 之外**。 - **事件目录 36 条**(任务批次/单设备/设备/Worker/业务/安装/系统/AI),支持 `task.*` 通配订阅; 语义分工避免重复告警:`worker.*` 是单次尝试级,`task.device.success/failed` 是唯一权威结论。 - **适配器可插拔**:`wecom`(markdown,按 4096 **字节**截断、超限不截半个汉字)+ `json`(模板占位符,替换值按 JSON 转义,保存前干跑校验);钉钉/飞书留了插槽(前端置灰)。 - **防打爆四层**:聚合窗口(默认 30s,同批次合并成一条并带样本)→ 令牌桶限流(默认 18/分, 对齐企微硬限 20)→ 被限流的**折叠成摘要不丢弃** → 有界队列背压。取舍:失败通知最多延迟 一个窗口,换来群不被刷屏。 ## 安全与存储 - 配置只落 `app_meta.notify_webhooks` 一个键(**不建表** → 不涉及备份覆盖红线)。 - URL 本身就是凭据(企微 `?key=`)→ 接口回显/发送记录/日志一律 `mask_url()/scrub()`; 编辑时留空即不修改;secret 永不回显。DATA_MODEL 的明文凭据告警补上了这一条。 - 发送记录:内存环形缓冲 200 条(重启清空)+ 独立 `logs/notify.log`。 ## 接入点(每个都放在锁外、不改 return 顺序) task_manager(批次开始/结束用新增的 `_BatchTracker` 统一在 finally 计数、单设备成功/失败/ 离线/重试/停止/抢占/归还/cron 停止)、device_worker 心跳看门狗、generic 任务选择器连续失效、 apk 安装开始/完成、设备上下线(**状态沿检测**,只报新变化)、备份导出/恢复、经验巡检、 用户登录、服务启停。 ## 前端 系统 Tab 新增「通知 / Webhook」子分栏:多条 webhook 列表(URL 打码)+ 编辑弹窗(格式/URL/ 密钥/事件勾选树带 ★建议/聚合/限流/自定义模板/预览)+ 发送测试 + 发送记录。 ## 自测 - 进程内逻辑 10 组断言全绿:聚合合并、限流+折叠、无配置/全局关静默丢弃、未知事件、 内部异常不外泄、JSON 转义(标题含引号换行仍合法)、URL/异常消息脱敏、配置校验。 - 端到端(假 webhook 接收端)17 项断言全绿:真实事件投递(user.login / task.batch.no_device)、 企微请求体形状、**HTTP 200 + errcode 93000 判为失败**、500 重试 3 次、记录里 URL 打码。 - 韧性:webhook 指向黑洞地址时登录耗时 100~114ms(基线 107~133ms,**异步隔离生效**); 配置写成坏 JSON 服务照常启动、通知静默不发、日志有 error(服务端实测后已复原)。 - 页面:系统 → 通知 面板/弹窗/36 个事件复选框/预览全部正常,无 JS 报错。 - 自测数据已清理(webhook、自建任务、写坏又复原的配置键)。 文档:新增 doc/NOTIFY.md(事件表/配置/格式约束/防刷屏/加事件三步骤/排障)并登记进 doc/README; API.md §2.11;DATA_MODEL 的 app_meta 键表与明文凭据告警;ARCHITECTURE 线程表/分层/扩展点; 根 README 功能索引与日志表。
188 lines
8.7 KiB
JavaScript
188 lines
8.7 KiB
JavaScript
// 通用基础:esc/CSRF/权限/API/Toast、Tab 切换与页内子分栏、模态框、行内按钮操作
|
||
// ================== 通用工具 ==================
|
||
const ACTION_LABELS={like:'点赞',comment:'评论',follow:'关注',share:'分享'};
|
||
const STATUS_MAP={idle:{t:'未启动',c:'default'},connecting:{t:'连接中',c:'warning'},running:{t:'运行中',c:'success'},done:{t:'完成',c:'primary'},error:{t:'异常',c:'danger'},released:{t:'已释放',c:'default'},failed:{t:'失败',c:'danger'}};
|
||
const TARGET_MAP={all:'全部空闲',group:'按分组',serial:'指定设备'};
|
||
|
||
// 设备显示文案:**名称是设备身份**,地址只是当前连接位置。
|
||
// 有名称 → 「名称 · serial」;没名称(历史数据)→ 只显示 serial
|
||
function devText(name, serial){
|
||
const n=String(name||'').trim();
|
||
return n ? (n + ' · ' + serial) : String(serial||'');
|
||
}
|
||
const APP_NAMES={'com.ss.android.ugc.aweme':'抖音','com.ss.android.ugc.aweme.lite':'抖音极速版','com.smile.gifmaker':'快手','com.kuaishou.nebula':'快手极速版','com.tencent.mm':'微信','com.tencent.mobileqq':'QQ','com.tencent.qqlive':'腾讯视频','com.eg.android.AlipayGphone':'支付宝','com.taobao.taobao':'淘宝','com.tmall.wireless':'天猫','com.jingdong.app.mall':'京东','com.xunmeng.pinduoduo':'拼多多','com.ss.android.article.news':'今日头条','com.zhihu.android':'知乎','com.miui.home':'桌面','com.android.launcher':'桌面','com.android.settings':'设置','com.android.systemui':'系统','com.github.uiautomator':'U2','com.github.uiautomator.test':'U2测试'};
|
||
|
||
function esc(s){return String(s||'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));}
|
||
|
||
// ===== CSRF =====
|
||
let _csrfToken='';
|
||
// 页面加载后调用:获取 CSRF token,非 GET 请求需携带
|
||
async function initCsrf(){
|
||
try{
|
||
const r=await fetch('/api/csrf');
|
||
const d=await r.json();
|
||
_csrfToken=d.token||'';
|
||
}catch(e){}
|
||
}
|
||
function _csrfHeaders(){
|
||
return _csrfToken?{'X-CSRF-Token':_csrfToken}:{};
|
||
}
|
||
|
||
// ===== 当前用户与权限 =====
|
||
// _me 来自 /api/me:{username, is_admin, perms:[tasks/devices/apks/logs]}
|
||
// 管理员 perms 恒为全部。无权限的功能入口(tab/按钮)用 data-perm 标记或 _can() 判断隐藏。
|
||
let _me={is_admin:false,perms:[],username:''};
|
||
async function loadMe(){
|
||
try{
|
||
const r=await apiGet('/api/me');
|
||
if(r&&r.ok)_me=r.user||{is_admin:false,perms:[],username:''};
|
||
const un=document.getElementById('me-username');
|
||
if(un)un.textContent=_me.username||'';
|
||
// 隐藏无权限的 tab 和按钮(data-perm="admin" 表示仅管理员)
|
||
document.querySelectorAll('[data-perm]').forEach(function(el){
|
||
const p=el.dataset.perm;
|
||
const ok=(p==='admin')?_me.is_admin:(_me.perms||[]).includes(p);
|
||
if(!ok)el.style.display='none';
|
||
});
|
||
}catch(e){}
|
||
}
|
||
function _can(perm){
|
||
return _me.is_admin||(_me.perms||[]).includes(perm);
|
||
}
|
||
const PERM_LABELS={tasks:'任务管理',devices:'设备控制',apks:'应用管理',logs:'日志查看'};
|
||
|
||
async function apiGet(url){
|
||
const r=await fetch(url);
|
||
if(r.status===401){window.location='/login';return null;}
|
||
return r.json();
|
||
}
|
||
async function apiPost(url,body){
|
||
const r=await fetch(url,{method:'POST',headers:{'Content-Type':'application/json',..._csrfHeaders()},body:body?JSON.stringify(body):'{}'});
|
||
if(r.status===401){window.location='/login';return null;}
|
||
return r.json();
|
||
}
|
||
async function apiPut(url,body){
|
||
const r=await fetch(url,{method:'PUT',headers:{'Content-Type':'application/json',..._csrfHeaders()},body:body?JSON.stringify(body):'{}'});
|
||
if(r.status===401){window.location='/login';return null;}
|
||
return r.json();
|
||
}
|
||
|
||
async function apiDelete(url){
|
||
const r=await fetch(url,{method:'DELETE',headers:_csrfHeaders()});
|
||
if(r.status===401){window.location='/login';return null;}
|
||
return r.json();
|
||
}
|
||
|
||
function showToast(msg,type){
|
||
const t=document.getElementById('toast');
|
||
t.className='toast show toast-'+(type||'success');
|
||
t.textContent=msg;
|
||
setTimeout(()=>t.className='toast',3000);
|
||
}
|
||
function handleResult(r,callback){
|
||
if(!r)return;
|
||
if(r.ok){showToast(r.msg||'操作成功','success');if(callback)callback();}
|
||
else{showToast(r.error||'操作失败','error');}
|
||
}
|
||
|
||
// ================== Tab 切换 ==================
|
||
let _activeTab='monitor';
|
||
let _monitorTimer=null;
|
||
let _logTimer=null;
|
||
|
||
function showTab(name){
|
||
_activeTab=name;
|
||
document.querySelectorAll('.tab-panel').forEach(p=>p.classList.remove('active'));
|
||
document.querySelectorAll('.navbar .tab').forEach(t=>t.classList.remove('active'));
|
||
const panel=document.getElementById('tab-'+name);
|
||
if(panel)panel.classList.add('active');
|
||
const btn=document.querySelector('.navbar .tab[data-tab="'+name+'"]');
|
||
if(btn)btn.classList.add('active');
|
||
// 停止所有定时器
|
||
if(_monitorTimer){clearInterval(_monitorTimer);_monitorTimer=null;}
|
||
if(_logTimer){clearInterval(_logTimer);_logTimer=null;}
|
||
if(_discoveryTimer){clearInterval(_discoveryTimer);_discoveryTimer=null;}
|
||
// 按需加载数据
|
||
if(name==='monitor'){loadMonitor();_monitorTimer=setInterval(loadMonitor,5000);}
|
||
if(name==='tasks'){showSubTab('tasks',_activeSubs.tasks);loadTasks();loadCustomActions();}
|
||
if(name==='tools'){showSubTab('tools',_activeSubs.tools);loadToolsDevices();loadAdbDevices();loadTailscaleDevices();loadApks();}
|
||
if(name==='logs'){loadLogs();if(document.getElementById('log-auto').checked)_logTimer=setInterval(loadLogs,3000);}
|
||
if(name==='agent'){
|
||
showSubTab('agent', _activeSubs.agent||'chat'); // 聊天 / AI 建任务
|
||
if(typeof initAgentChat==='function') initAgentChat();
|
||
}
|
||
if(name==='users')loadUsers();
|
||
if(name==='system'){showSubTab('system',_activeSubs.system||'backup');}
|
||
}
|
||
|
||
// ================== 页内子分栏(任务/工具 通用) ==================
|
||
// 每个带子分栏的 Tab 记住上次选中的子分栏,切走再切回来保持原位
|
||
let _activeSubs = {tasks: 'plan', tools: 'clipboard', system: 'backup', agent: 'chat'};
|
||
let _discoveryTimer = null; // 设备自动发现 10s 轮询(仅 devpool 子分栏激活时)
|
||
|
||
function showSubTab(tabId, name){
|
||
_activeSubs[tabId] = name;
|
||
const tab = document.getElementById('tab-'+tabId);
|
||
tab.querySelectorAll('.sub-tab').forEach(b=>b.classList.toggle('active', b.dataset.sub===name));
|
||
tab.querySelectorAll('.sub-panel').forEach(p=>p.classList.toggle('active', p.id===tabId+'-sub-'+name));
|
||
if(tabId==='agent' && name==='taskgen' && typeof initTaskGen==='function') initTaskGen();
|
||
if(tabId==='system' && name==='notify' && typeof loadNotifyPanel==='function') loadNotifyPanel();
|
||
if(name==='groups' && typeof loadGroups==='function') loadGroups();
|
||
if(name==='apks' && typeof loadAgentStore==='function') loadAgentStore();
|
||
if(name==='devpool' && typeof loadDevPool==='function'){
|
||
loadDevPool();
|
||
if(typeof loadDiscovery==='function'){
|
||
loadDiscovery();
|
||
if(_discoveryTimer){clearInterval(_discoveryTimer);_discoveryTimer=null;}
|
||
_discoveryTimer=setInterval(loadDiscovery,10000); // 10s 轮询:扫完自动出新结果
|
||
}
|
||
} else if(_discoveryTimer){clearInterval(_discoveryTimer);_discoveryTimer=null;}
|
||
}
|
||
|
||
// ================== 模态框 ==================
|
||
function showModal(){
|
||
document.getElementById('modal-overlay').classList.add('show');
|
||
}
|
||
function closeModal(){
|
||
// 先关闭弹窗(最重要的一步,确保一定执行)
|
||
const ov=document.getElementById('modal-overlay');
|
||
if(ov)ov.classList.remove('show');
|
||
// 再做清理工作,任何异常都不影响弹窗关闭
|
||
try{
|
||
stopShotTimer();
|
||
_shotSerial='';
|
||
const box=document.getElementById('modal-box');
|
||
if(box){box.classList.remove('shot-modal');box.classList.remove('wide-modal');}
|
||
}catch(e){console.warn('closeModal cleanup error:',e);}
|
||
}
|
||
|
||
// 测试此步骤弹窗关闭(editor.js 的测试按钮用;曾随 STF 清理误删)
|
||
function closeTestModal(){
|
||
const ov=document.getElementById('test-overlay');
|
||
if(ov)ov.classList.remove('show');
|
||
}
|
||
|
||
// 关闭元素抓取弹窗(不影响任务编辑窗口)
|
||
function closeElPicker(){
|
||
var ov=document.getElementById('el-picker-overlay');
|
||
if(ov)ov.classList.remove('show');
|
||
// 清理状态
|
||
try{ _stepEditor._elData=null; _stepEditor._elSerial=''; }catch(e){}
|
||
}
|
||
|
||
// ================== 行内按钮操作(停止/执行/启停等,自动 toast + 刷新)==================
|
||
async function apiAction(url,body){
|
||
const r=await apiPost(url,body);
|
||
if(!r)return;
|
||
if(r.ok){showToast(r.msg||'操作成功','success');if(_activeTab==='monitor')loadMonitor();if(_activeTab==='tasks')loadTasks();}
|
||
else{showToast(r.error||'操作失败','error');}
|
||
}
|
||
|
||
async function scanForeground(){
|
||
const r=await apiPost('/api/scan_foreground');
|
||
if(!r)return;
|
||
if(r.ok){showToast('扫描已启动,请等待几秒...','success');loadMonitor();}
|
||
else{showToast(r.error||'扫描失败','error');}
|
||
}
|
||
|