17 Commits
Author SHA1 Message Date
butubb 92d71c0e27 Merge branch 'dev'——日志筛选/下载 + 任务步骤明细 + 通知降噪 2026-09-16 10:32:57 +08:00
butubb 243df3071b Merge branch 'fix/notify-noise'——通知降噪:多设备批次不逐台报成功、批次汇总瘦身、设备只显示名字 2026-09-16 10:32:51 +08:00
butubb 16e3e162b2 fix(通知): 多设备批次不再逐台报成功;批次汇总瘦身并列出失败设备;设备只显示名字
用户反馈:一条任务覆盖 13 台设备,每台成功都推一条,群里被刷屏;批次汇总里
「任务:抖音养号」与标题重复、「样本:抖音养号」毫无信息量、「stopped」还是英文。

- task_manager:
  · **多设备批次不发 `task.device.success`**(批次汇总里已有成功台数;单台任务照发);
    失败仍逐台发——那是少数,且要知道是哪台;
  · `_BatchTracker` 收集失败设备(`名字(型号):原因`,最多 5 条)并加进批次汇总;
  · 设备级事件带 `model`:型号取自**设备池快照**(批次开始时一次查好带下去),
    不是 worker 每次连接现采的(那次 `d.info()` 可能超时 → 空),也不查库;
- notifier:
  · 聚合窗口口径修正:事件声明 `agg_window=0`(批次结束/服务启停/备份恢复等低频
    高危事件)**不再被 hook 的窗口拖住**——原先事件级设置完全失效;
  · 「样本」行只在真的合并了多条(>1)时出现,且按**设备**维度写(合并多台时写
    任务名每条都一样);与标题重复的字段行(「任务:x」)不再重复渲染;
  · 设备名与地址同时存在时**只显示名字**(IP 是给日志看的);
  · 补 `stopped`/`failed_devices` 中文标签(原先直接显示英文 key);
- notify_events:批次事件补 `skipped`/`failed_devices` 字段,设备事件补 `model`。

实测(dev 真机 + 假接收端):
  批次 2 台 → 只收到 batch.started/finished,无 device.success,汇总 总数2/成功1/跳过1;
  单台任务 → 收到 device.success,显示「设备:cs1 / 型号:M2010J19SC」不含 IP。
文档:doc/NOTIFY.md §3(多设备批次怎么发 + 批次消息样例)、§6(窗口口径与注意事项)。
2026-09-16 09:47:25 +08:00
butubb 49544b1985 Merge branch 'feat/task-step-log'——任务步骤明细表 + 日志页「步骤明细」面板 2026-09-16 09:10:24 +08:00
butubb 8879da72b3 Merge branch 'feat/log-query'——日志页:关键字/级别/时间过滤 + 下载(含滚动历史) 2026-09-16 09:10:18 +08:00
butubb 621d48c800 feat(日志): 任务步骤明细表 + 「日志 → 步骤明细」面板(按设备/任务/时间过滤、按运行归组、导出 CSV)
文本日志只能 grep,"这台设备这次运行为什么失败"翻起来很费劲。新增一张
**结构化**的步骤明细表,把"哪一步、什么类型、哪个选择器、结果、耗时"落库。

- core/models.py:新增 task_step_log(run_id/job/设备/step_path/selector/
  result/detail/duration_ms),索引 run_id、created_at、(serial,created_at)、
  (job_id,created_at);
- core/step_log.py(新):**专用写线程 + 有界队列**批量落库——步骤执行是热路径,
  任务线程只 put_nowait(实测 9000 次入队 31ms),队列满丢弃并计数,绝不阻塞;
  另有保留期清理(默认 14 天,每日 04:13 + 每次启动);
- tasks/generic/task.py:_exec_one 记一条(异常=error / handler 返回 False=miss /
  未知类型=unknown / 概率未触发=skip);_exec_steps 维护路径栈得到 "2.1.3"
  这样的嵌套位置;**单次运行封顶 2000 条**——forever 循环任务否则会写爆表;
- 运行上下文 ctx(run_id/job_id/job_name/device_name)由 TaskManager 生成,
  经 create_worker(serial, params, ctx=None) 传入 worker(扩展点向后兼容);
- 接口:/api/step_logs(过滤+分页)、/runs(按运行归组)、/filters(下拉选项)、
  /download(CSV,带 BOM);
- 前端:日志页拆成「文件日志 / 步骤明细」子分栏 + static/admin/steplog.js。

红线:新表自动进备份覆盖清单(SUMMARY_TABLES 由元数据派生),已补
TABLE_LABELS 中文标签,导出实测 14 张表、coverage_missing 为空。

文档:DATA_MODEL §2.8/§1、API §10.2、ARCHITECTURE §1.1/§2.2/§3.1、
DEVELOPMENT §5.2 与红线表、DEPLOY §5.2、TASK_DEV §8.3、README。
2026-09-16 08:58:43 +08:00
butubb 2c32c397c8 feat(日志): 日志页支持关键字/级别/时间过滤 + 下载(含滚动历史)
「日志」页原来只能 tail 固定行数、且文件下拉写死了 4 项(新增的 notify.log
根本选不到)。本次:

- core/logger.py 新增读取接口:list_log_files()(模块文件 + .1/.2 滚动历史,
  白名单)、query_log()(关键字/最低级别/时间过滤,返回结构化行)、
  read_log_text()(导出)。
  · 时间/级别过滤对**续行**用继承值(traceback 缩进行本身没有前缀),
    按 ERROR 筛不会把堆栈拆散;
  · 过滤按"最近 N 条命中"返回,保持文件原顺序;
- GET /api/logs 换新协议(rows/files/matched/scanned/truncated),
  新增 GET /api/logs/download(附件下载,支持同样过滤;无一条件时整文件);
- 顺带修 **目录穿越**:原实现 os.path.join(_LOG_DIR, file) 可用 ../../ 逃逸,
  现在 file 必须命中白名单;
- 顺带修 web/admin_api.py 里 _log 未定义(用户增删改的日志行会 NameError);
- 前端:文件下拉改为按接口渲染、加关键字(命中高亮)/级别/时间范围/下载/重置,
  自动刷新时不再把正在上翻的用户拽回底部。

文档:doc/API.md §10.1(参数与返回)、README 日志节。
2026-09-16 08:44:19 +08:00
butubb 6fa90a505c Merge branch 'dev' 2026-09-15 14:32:11 +08:00
butubb 3b6ec8c98f feat(通知): 加钉钉 / 飞书两种格式(含各自的加签算法)
## 两家签名算法不一样,照抄必错
| | 钉钉 | 飞书 |
|---|---|---|
| HMAC key | `secret` | `"{timestamp}\n{secret}"` |
| 被签内容 | `"{timestamp}\n{secret}"` | 空 |
| timestamp | 毫秒 | 秒 |
| 拼在哪 | URL query | JSON body |
| 结果 | Base64 再 urlencode | Base64 |
| 出错码 | `errcode 310000` | `code 19021` |

实现为 `BaseAdapter.sign_request()` 钩子(默认不动),两家各写各的;
`core/notifier.py` 的 `_post_once` 在发请求前调用它。

## 另外
- 钉钉:markdown 消息(title+text);成功码 0;默认限流 15/分(官方 20/分,**超限会被限 10 分钟**,留余量)
- 飞书:交互式卡片(header 颜色按事件级别 + markdown 元素);成功码 0;默认 60/分
- `PLANNED_FORMATS` 清空(下拉里不再有置灰项)
- **修一个泄漏**:`mask_url` 原来只打码 query 参数——**飞书的 token 在 URL 路径里**(`/hook/<token>`)
  → 接口回显会漏出去。现在同时处理路径 token(≥20 位随机串)与 Slack 的 `/services/T…/B…/X…` 三段。

## 验证(都跑过)
- **签名对拍官方示例**:钉钉逐字节一致(含 urlencode,`%2B` 级别)、飞书的 key/message 口径一致;
  时间戳钉住后比对,不靠"自己跟自己一致"
- 成功码按格式:wecom/dingtalk/feishu 的 0 与 bark 的 200 分别判成功,各自的错误码判失败
- 端到端 15 项:钉钉/飞书在假接收端上真发(URL 带签名、body 带 timestamp/sign、卡片是 markdown 元素、
  毫秒 vs 秒的时间戳),签名错时正确判失败(310000 / 19021),token 在回显里被打码
2026-09-15 14:29:11 +08:00
butubb 515cb5d580 docs: 根 README 结构表/前端 JS 加载清单补上 notify(含 notify_api/notifier/taskgen/notify.js,并修正文件数) 2026-09-15 14:05:44 +08:00
butubb 24ea289c15 feat(通知): 加 Bark 格式 + 格式切换时界面提示全部跟着变
## 用户报的第二件事:格式改了,提示没跟着改
弹窗里的 URL 示例/说明、密钥标签、限流说明都写死成企业微信了——选「通用 JSON」时
占位还是 `qyapi.weixin.qq.com`,限流还写着「企微硬限 20」。现在这些文案挂在**适配器**上
(`url_hint/url_help/secret_label/secret_help/limit_help`),由接口随格式下发,切格式即时更新;
限流值在用户没手动改过时也跟着格式的推荐值走(企微 20 / 通用 JSON 60 / Bark 60)。

## 新格式:Bark(iOS 推送)
- `POST {url}`,body `{title, body, markdown, group, level[, device_key]}`
  —— `markdown` 传富文本、`body` 传纯文本兜底(老版本 App 不认 markdown 字段时也能看清)
- URL 两种填法都支持:直接粘 Bark 复制的那串(`https://api.day.app/<key>`,key 在路径里),
  或填 `https://api.day.app/push` + 把 key 填到「设备 Key」(作为 device_key 发送)
- **成功判定按格式**:Bark 是 `code==200`(企业微信是 `errcode==0`)→ 新增
  `BaseAdapter.ok_codes`,`_post_once` 用它判定。少了这一步 Bark 的每次成功都会被误判成失败
- 正文按 2048 字节截断(走 APNs,体量有限)

## 顺带
- 通用 JSON 的 `secret` 现在会作为 `X-Webhook-Secret` 请求头发出(原先填了没用)
- `_formats()` 改为序列化适配器元信息,新增格式只改一处

## 验证
- 格式切换 14 项断言全绿:三种格式的 URL 示例/密钥标签/密钥说明/限流说明/模板区/限流默认值
  全部跟着切换;下拉里 Bark 出现在已实现区
- Bark 端到端 7 项:`code=200` 判成功、`code=400` 判失败(含重试)、请求体带
  device_key/title/body/markdown/group、URL 里的 key 在接口回显里被打码
- 文档:NOTIFY.md §5 的格式表补 Bark 列(URL 怎么填 / 成功码 / 约束),并说明
  "提示文案挂在适配器上,别写死在页面里"
2026-09-15 14:05:33 +08:00
butubb 1e223e847b chore(通知): 补上漏提交的 web_server.py 接线(init_app 时序 + 启动/停止通知)
上一轮的 git add 路径列表漏了 web_server.py——功能本身在跑(文件在磁盘上),
但提交里缺这一段,别人拉下来会因为没调 init_app 而收不到任何通知。
2026-09-15 13:59:13 +08:00
butubb 9a6c0db65c fix(通知): 新建/编辑弹窗缺「保存/关闭」整条操作栏(点进去出不来、也存不了)
用户报:新建 webhook 弹窗**没有关闭按钮**——点进去不想建就出不来了(只能刷新页面)。
顺着查发现更严重的一处:**底部操作栏整个漏了**——「创建/保存」「预览」「取消」全都没有,
只是靠"以为有保存按钮所以先点开看看"才没被立刻发现:其实填完也提交不了。

修(static/admin/notify.js + monitor.html):
- 弹窗顶部加「✕ 关闭」,底部加「创建/保存 · 预览请求体 · 取消」操作栏
- 四条关闭路径都可用:✕ / 取消 / **Esc** / **点空白处**(overlay 的 onclick)

同批复核出的其它问题:
- 通配订阅区被我上一版写成永远隐藏(`display:none` 且没人改回来)→ 恢复可见
  (任何格式都适用,标签补成「通配订阅(可选,任何格式都适用)」)
- `_notifyFormatChanged` 里残留 `(f === 'json' || true)` 的无意义判断 → 清掉
- 工具条上一个 input 写了**两个 style 属性**(后者会被浏览器忽略)→ 合并成一个

复核方式(这次全部**走页面按钮**,不再只测接口):
- UI 全流程 13 项:四条关闭路径 + 底部按钮存在 + 页面创建(列出/打码 URL/事件数)
  + 编辑回填与改名 + 发送测试(假接收端真收到)+ 页面删除,无 JS 报错
- 通配订阅 6 项:区可见 / 非法通配被拒 / 芯片渲染 / 保存后列出 / 编辑回填 / 清理
- 接口边界 10 项:提交打码 URL 不覆盖真实值、只传 enabled 的局部更新、非法模板被拒、
  预览字节数、limit 非法值兜底、404 分支、未登录被挡
教训:交付物是界面时,自测必须走界面点一遍——上一轮我用接口建的 webhook,
把「按钮不存在」整类问题漏掉了。
2026-09-15 13:59:00 +08:00
butubb 97cec6e211 feat(通知): 系统级 Webhook 通知子系统(事件目录 + 可插拔适配器 + 防刷屏)
平台此前出了问题只能靠人盯页面。现在各组件统一走 `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 功能索引与日志表。
2026-09-15 13:55:14 +08:00
butubb 5e0a7d5556 fix(屏幕): 一键息屏不再唤醒已眠设备 + 「保持亮屏」对不充电的设备生效
用户反馈两条:
1. 监控页「一键息屏」像是把设备**唤醒**了(按钮行为"反");
2. 任务里的「保持亮屏」没用。

根因:
1. `POST /api/device/screen_all` 的 off 分支发 `input keyevent 26`(KEYCODE_POWER)——
   那是电源键**开关**:对亮着的设备是熄屏,对**已经息屏的设备反而是唤醒**。
2. `keep_screen` 只发 `svc power stayon true`,它管的是「**充电时**屏幕常亮」
   (stay_on_while_plugged_in)。设备走 WiFi 跑任务、没插充电器 → 完全不生效。

修法:
- 息屏改用 `KEYCODE_SLEEP(223)`(单向:只熄不亮)。
- 保持亮屏改成把系统**息屏超时**顶到最大(`settings put system screen_off_timeout
  2147483647`)+ 顺带 `svc power stayon true` + 立刻 `KEYCODE_WAKEUP` 唤醒一次;
  `mode=off` 时写回原值(进入时读一次记在内存;进程重启丢了记录就写回 10 分钟兜底,
  `settings get` 返回 "null" 的机型也走兜底)。
- 文案/文档同步:编辑器里的步骤说明、TASK_DEV 步骤表、API.md 的接口行为。

自测(按用户要求不动机器):py_compile / node --check 通过;用假设备对象断言命令序列——
保持亮屏发 `settings get` → `stayon true` → `put …2147483647` → `keyevent 224` 并记住原值;
恢复写回原值 60000 且清空备份;原值为 null 时兜底 600000。
2026-09-15 13:24:28 +08:00
butubb afdea347ee Merge branch 'dev' 2026-09-14 10:34:13 +08:00
butubb 6f14529db0 docs(流程): 明确「修复也走分支流程,含线上紧急修复」
2026-09-14 当天两次 APK 安装修复(615f9cf / 7b6c838)因为"生产卡着"直接提交在 dev 上、
紧接着合 main 部署,跳过了 ① 切分支 与 ③ 交负责人确认(把"合 dev 确认"与"部署确认"
揉成了一次)。负责人指出后补齐这条约束。

补充内容(§1.2 git 铁律):
- 紧急修复照样从 dev 切 fix/xxx,不允许直接 commit/push 到 dev
- 第 ② 步自测要跑通本机服务,不能只有脚本级验证
- 第 ③ 步确认要点名两件事:「是否合 dev」与「是否合 main + 部署生产」
- 线上正卡住时先恢复(运维动作,经确认即可),再按流程走修复
2026-09-14 10:33:43 +08:00
35 changed files with 3774 additions and 94 deletions
+18 -4
View File
@@ -53,6 +53,7 @@
| **AI 控制台** | 用自然语言驱动 AI 操作指定设备(MCP 工具 + 截图),流式输出、Markdown 渲染、推理链折叠、token 统计;成功操作自动沉淀「经验库 / 动作库」并在相似任务中召回 | | **AI 控制台** | 用自然语言驱动 AI 操作指定设备(MCP 工具 + 截图),流式输出、Markdown 渲染、推理链折叠、token 统计;成功操作自动沉淀「经验库 / 动作库」并在相似任务中召回 |
| **MCP 接入** | 20 个 `de_*` 工具,把手机控制开放给外部 AI;写操作有开关、设备忙时拒绝、全量审计 | | **MCP 接入** | 20 个 `de_*` 工具,把手机控制开放给外部 AI;写操作有开关、设备忙时拒绝、全量审计 |
| **备份导出/导入** | 一键导出 zip(库快照 + manifest + 可选 APK),导入前校验预览、自动预备份、重启生效 | | **备份导出/导入** | 一键导出 zip(库快照 + manifest + 可选 APK),导入前校验预览、自动预备份、重启生效 |
| **通知 / Webhook** | 任务成功失败、设备上下线、安装完成、备份恢复等事件推送到企业微信 / 钉钉 / 飞书 / Bark / 自建服务(Slack 用通用 JSON);多条 webhook 各自订阅;聚合+限流防刷屏(见 [doc/NOTIFY.md](doc/NOTIFY.md)) |
--- ---
@@ -120,6 +121,7 @@ auto_control/
│ ├── tailscale_api.py # Tailscale 管理(改名/授权/密钥/IP/auth key) │ ├── tailscale_api.py # Tailscale 管理(改名/授权/密钥/IP/auth key)
│ ├── agent_api.py # AI 控制台(会话/SSE/经验库/动作库/巡检) │ ├── agent_api.py # AI 控制台(会话/SSE/经验库/动作库/巡检)
│ ├── system_api.py # 系统数据备份导出/导入 │ ├── system_api.py # 系统数据备份导出/导入
│ ├── notify_api.py # 通知 / Webhook 配置(系统 Tab)
│ ├── common.py # 跨模块共享工具(合并设备列表、屏幕状态) │ ├── common.py # 跨模块共享工具(合并设备列表、屏幕状态)
│ └── context.py # 共享对象注入(mgr / apk_mgr / device_pool) │ └── context.py # 共享对象注入(mgr / apk_mgr / device_pool)
│ │
@@ -136,6 +138,8 @@ auto_control/
│ ├── clipboard_helper.py # 剪贴板注入(ClipInject 通道) │ ├── clipboard_helper.py # 剪贴板注入(ClipInject 通道)
│ ├── apk_manager.py # APK 上传/解析/批量安装 │ ├── apk_manager.py # APK 上传/解析/批量安装
│ ├── system_backup.py # 数据备份导出/导入(重启生效) │ ├── system_backup.py # 数据备份导出/导入(重启生效)
│ ├── notifier.py # 通知分发(队列/聚合/限流/适配器)+ notify_events.py 事件目录
│ ├── step_log.py # 任务步骤明细(异步写线程 + 保留期清理)
│ ├── tailscale_client.py # Tailscale API v2 客户端 │ ├── tailscale_client.py # Tailscale API v2 客户端
│ ├── ssh_client.py # SSH 封装(当前无人调用,预留) │ ├── ssh_client.py # SSH 封装(当前无人调用,预留)
│ ├── logger.py # 分文件日志(core/task/web/action) │ ├── logger.py # 分文件日志(core/task/web/action)
@@ -162,7 +166,7 @@ auto_control/
│ ├── monitor.html # 主单页应用(7 个顶级 Tab + 模态框 + 内联样式) │ ├── monitor.html # 主单页应用(7 个顶级 Tab + 模态框 + 内联样式)
│ ├── login.html # 登录页 │ ├── login.html # 登录页
│ └── wall.html # 监控大屏(独立页面,自包含) │ └── wall.html # 监控大屏(独立页面,自包含)
├── static/admin/ # 前端 JS(11 个文件,按顺序同步加载,见下) ├── static/admin/ # 前端 JS(13 个文件,按顺序同步加载,见下)
├── static/fonts/ # 自托管字体(Bricolage Grotesque + IBM Plex Mono) ├── static/fonts/ # 自托管字体(Bricolage Grotesque + IBM Plex Mono)
│ │
├── data/ # 运行时数据(不入 git) ├── data/ # 运行时数据(不入 git)
@@ -181,7 +185,7 @@ auto_control/
``` ```
base.js → markdown.js → list.js → monitor.js → editor.js → tasks.js base.js → markdown.js → list.js → monitor.js → editor.js → tasks.js
→ tools.js → apps.js → admin.js → agent.js → system.js → tools.js → apps.js → admin.js → agent.js → taskgen.js → system.js → notify.js → steplog.js
``` ```
--- ---
@@ -243,7 +247,7 @@ self.set_progress(done=5, total=80, unit="视频", action_counts={"like": 3}, el
|-----|------|--------| |-----|------|--------|
| **监控** | 统计卡片 + 设备表(在线/型号/任务状态/进度/前台 App/最近错误,支持排序、搜索、分页、多选批量操作)+ 异常汇总 + **任务运行概况**(每张任务卡:执行任务 / 停用任务 + 覆盖设备彩色 chip);导航栏「📺 大屏」打开 `/wall` | 所有登录用户(设备操作按钮需"设备控制") | | **监控** | 统计卡片 + 设备表(在线/型号/任务状态/进度/前台 App/最近错误,支持排序、搜索、分页、多选批量操作)+ 异常汇总 + **任务运行概况**(每张任务卡:执行任务 / 停用任务 + 覆盖设备彩色 chip);导航栏「📺 大屏」打开 `/wall` | 所有登录用户(设备操作按钮需"设备控制") |
| **任务** | 子分栏:**任务计划**(CRUD / 启停 / 执行 / 下次运行时间 / 离线跳过 / 步骤编辑器)、**自定义动作**(步骤打包复用) | 可看;写操作需"任务管理" | | **任务** | 子分栏:**任务计划**(CRUD / 启停 / 执行 / 下次运行时间 / 离线跳过 / 步骤编辑器)、**自定义动作**(步骤打包复用) | 可看;写操作需"任务管理" |
| **日志** | 实时日志(按文件切换、可自动刷新) | 需"日志查看" | | **日志** | 子分栏:**文件日志**(文件切换含滚动历史、关键字/级别/时间过滤、命中高亮、下载筛选结果、自动刷新)、**步骤明细**(按设备/任务/结果/时间过滤、按运行归组的概览、导出 CSV) | 需"日志查看" |
| **用户** | 用户 CRUD、改密、分配权限 | 仅管理员 | | **用户** | 用户 CRUD、改密、分配权限 | 仅管理员 |
| **工具** | 8 个子分栏:剪贴板注入 / adb 远程终端 / Tailscale 管理 / 应用管理 / 应用版本管理 / 设备已装应用 / **设备池管理**(含自动发现)/ 设备分组 | 仅管理员 | | **工具** | 8 个子分栏:剪贴板注入 / adb 远程终端 / Tailscale 管理 / 应用管理 / 应用版本管理 / 设备已装应用 / **设备池管理**(含自动发现)/ 设备分组 | 仅管理员 |
| **AI 控制台** | 会话列表 + 对话区(Markdown 渲染、推理链折叠、token 统计)+ 实时画面(MJPEG)+ 目标设备选择;右上角:经验库 / 动作库 / 模型配置 | 仅管理员 | | **AI 控制台** | 会话列表 + 对话区(Markdown 渲染、推理链折叠、token 统计)+ 实时画面(MJPEG)+ 目标设备选择;右上角:经验库 / 动作库 / 模型配置 | 仅管理员 |
@@ -347,13 +351,23 @@ MCP_ALLOW_WRITE=1 MCP_PLATFORM_USER=admin MCP_PLATFORM_PASS=<密码> \
| `logs/task.log` | `task.*` | 任务执行(worker 业务逻辑) | | `logs/task.log` | `task.*` | 任务执行(worker 业务逻辑) |
| `logs/web.log` | `web.*` | Web 请求与管理操作 | | `logs/web.log` | `web.*` | Web 请求与管理操作 |
| `logs/action.log` | `action.*` | 操作执行 | | `logs/action.log` | `action.*` | 操作执行 |
| `logs/notify.log` | `notify` | 通知发送(成功/失败/平台错误码) |
```python ```python
from core.logger import get_logger from core.logger import get_logger
log = get_logger("task.generic") # → logs/task.log log = get_logger("task.generic") # → logs/task.log
``` ```
「日志」Tab 可在线查看。 「日志」Tab 可在线查看:文件下拉包含 `.1/.2…` 滚动历史(可回溯更早的日志),
支持关键字(命中高亮)/最低级别/时间范围过滤,点「下载」导出当前筛选结果
(无筛选就是整个文件)。读取逻辑在 `core/logger.py` 的 `list_log_files()/query_log()/
read_log_text()`,`file` 参数走白名单,不接受任意路径。
「日志 → 步骤明细」是**结构化**的另一半:每一次步骤执行落一行 `task_step_log`
(设备/任务/步骤路径/结果/耗时/选择器),因此能按设备、任务、结果、时间过滤,
也能按 `run_id` 归组看"这一次运行为什么失败"、导出 CSV。写入是异步的
(`core/step_log.py`,任务线程只入队,不阻塞执行),保留 14 天、单次运行最多
2000 条 —— 这两个上限决定这张表(以及备份包)能长多大。
--- ---
+13
View File
@@ -30,6 +30,7 @@ from concurrent.futures import (ThreadPoolExecutor, as_completed,
from config import APK_DIR, ADB_PATH from config import APK_DIR, ADB_PATH
from core.logger import get_logger from core.logger import get_logger
from core import notifier
from core.models import db, ApkFile as ApkRow from core.models import db, ApkFile as ApkRow
from .device_worker import get_all_worker_status from .device_worker import get_all_worker_status
@@ -320,6 +321,8 @@ class ApkManager:
name="apk-install", daemon=True) name="apk-install", daemon=True)
t.start() t.start()
_log.info(f"开始安装 {apk_name} 到 {len(serials)} 台设备") _log.info(f"开始安装 {apk_name} 到 {len(serials)} 台设备")
notifier.notify("apk.install.started", apk_id=apk_id, apk_name=apk_name,
package_name=package_name, total=len(serials))
return True, f"开始安装 {apk_name} 到 {len(serials)} 台设备" return True, f"开始安装 {apk_name} 到 {len(serials)} 台设备"
def _install_worker(self, apk_id, apk_path, apk_name, package_name, def _install_worker(self, apk_id, apk_path, apk_name, package_name,
@@ -378,6 +381,16 @@ class ApkManager:
skipped = sum(1 for v in self._install_task["items"].values() skipped = sum(1 for v in self._install_task["items"].values()
if v["status"] == "skipped") if v["status"] == "skipped")
_log.info(f"安装完成 {apk_name}: 成功={success}, 失败={failed}, 跳过={skipped}") _log.info(f"安装完成 {apk_name}: 成功={success}, 失败={failed}, 跳过={skipped}")
# 通知:批量安装结束(带失败设备与原因,运维要知道哪几台没装上)
try:
items = ((self._install_task or {}).get("items") or {})
failed_items = [f"{v.get('name') or s}·{v.get('msg') or ''}"
for s, v in items.items() if v.get("status") == "failed"][:10]
notifier.notify("apk.install.finished", apk_name=apk_name,
success=success, failed=failed, skipped=skipped,
total=len(items), failed_items=failed_items)
except Exception as e:
_log.warning(f"发送安装完成通知失败(不影响安装): {e}")
def _install_one(self, serial, apk_path, package_name=""): def _install_one(self, serial, apk_path, package_name=""):
"""直连设备安装 APK。 """直连设备安装 APK。
+28
View File
@@ -22,6 +22,7 @@ from config import (USB_ADB_HOST,
DISCOVERY_PORT, DISCOVERY_SUBNETS, DISCOVERY_INTERVAL) DISCOVERY_PORT, DISCOVERY_SUBNETS, DISCOVERY_INTERVAL)
from core.adb_helper import _adb, adb_connect_light from core.adb_helper import _adb, adb_connect_light
from core.logger import get_logger from core.logger import get_logger
from core import notifier
from core.models import db, Device, PendingDevice from core.models import db, Device, PendingDevice
_log = get_logger("core.disc") _log = get_logger("core.disc")
@@ -42,6 +43,8 @@ _PROBE_TIMEOUT = 0.4
_app = None _app = None
_scan_lock = threading.Lock() # 定时/手动扫描互斥 _scan_lock = threading.Lock() # 定时/手动扫描互斥
# 上一轮"池内离线"集合:通知只报**状态沿**(这一轮新变成离线的),不每轮刷屏
_last_offline = set()
_stop_event = threading.Event() # shutdown 用 _stop_event = threading.Event() # shutdown 用
_scanning = False # 状态快照(API/前端) _scanning = False # 状态快照(API/前端)
_last_scan = None # (时间串, 开放数, 可连数, 新增数) _last_scan = None # (时间串, 开放数, 可连数, 新增数)
@@ -266,6 +269,7 @@ def scan_once(manual=False):
now = _fmt() now = _fmt()
existing = {p.serial for p in PendingDevice.query.all()} existing = {p.serial for p in PendingDevice.query.all()}
added = 0 added = 0
added_serials = []
from core import device_pool from core import device_pool
fps = {} fps = {}
for serial in verified: for serial in verified:
@@ -283,6 +287,7 @@ def scan_once(manual=False):
first_seen=now, last_seen=now, first_seen=now, last_seen=now,
fingerprint=fp)) fingerprint=fp))
added += 1 added += 1
added_serials.append(serial)
db.session.commit() db.session.commit()
# 5.5 自动认领(可选,默认关):指纹命中池中已有设备 → 直接把记录迁到新地址。 # 5.5 自动认领(可选,默认关):指纹命中池中已有设备 → 直接把记录迁到新地址。
# 默认关是因为认领会改写分组/任务引用(数据结构变动),交人工点一下更稳妥; # 默认关是因为认领会改写分组/任务引用(数据结构变动),交人工点一下更稳妥;
@@ -293,11 +298,34 @@ def scan_once(manual=False):
# 全局锁串行),连上即恢复在线,无需人工干预。pending 池是给 # 全局锁串行),连上即恢复在线,无需人工干预。pending 池是给
# 「未授权新设备」的,正式池设备断联不进 pending,而是自动重连。 # 「未授权新设备」的,正式池设备断联不进 pending,而是自动重连。
back = _reconnect_offline(configured) back = _reconnect_offline(configured)
# 7. 离线集合(供"状态沿"通知用:只报**这一轮新变成离线**的,不每轮刷屏)
offline_now = {s for s in configured if not device_pool.is_online(s)}
_log.info(f"发现: 探测开放 {len(open_ips)} 台,可连 {len(verified)} 台," _log.info(f"发现: 探测开放 {len(open_ips)} 台,可连 {len(verified)} 台,"
f"新增待连接 {added} 台" f"新增待连接 {added} 台"
+ (f",自动重连恢复 {len(back)} 台 {back}" if back else "") + (f",自动重连恢复 {len(back)} 台 {back}" if back else "")
+ (f",自动认领 {len(claimed)} 台 {[c[0] + '→' + c[1] for c in claimed]}" + (f",自动认领 {len(claimed)} 台 {[c[0] + '→' + c[1] for c in claimed]}"
if claimed else "")) if claimed else ""))
# 通知统一放在 `with _ctx():` 之后(别在 DB 事务期间做额外的事)
global _last_offline
try:
newly_offline = sorted(offline_now - _last_offline)
_last_offline = set(offline_now)
if newly_offline:
notifier.notify("device.offline", serials=newly_offline,
count=len(newly_offline),
devices=newly_offline[:10])
if back:
notifier.notify("device.online", serials=list(back), count=len(back))
if added_serials:
notifier.notify("device.discovered", serials=added_serials[:10],
count=len(added_serials))
if claimed:
notifier.notify("device.claimed",
pairs=[f"{c[0]}→{c[1]}" for c in claimed][:10],
count=len(claimed))
except Exception as e:
_log.warning(f"发送设备上下线通知失败(不影响扫描): {e}")
_last_scan = (now, len(open_ips), len(verified), added) _last_scan = (now, len(open_ips), len(verified), added)
_last_error = "" _last_error = ""
return True, {"found": len(open_ips), "verified": len(verified), return True, {"found": len(open_ips), "verified": len(verified),
+15 -2
View File
@@ -19,6 +19,7 @@ import uiautomator2 as u2
from config import USB_ADB_HOST, USB_ADB_PORT from config import USB_ADB_HOST, USB_ADB_PORT
from core.logger import get_logger from core.logger import get_logger
from core import device_pool from core import device_pool
from core import notifier
from .adb_helper import _adb, adb_connect, _adb_remote from .adb_helper import _adb, adb_connect, _adb_remote
_log = get_logger("core.worker") _log = get_logger("core.worker")
@@ -188,9 +189,18 @@ class _Watchdog(threading.Thread):
if hb and now - hb > _HEARTBEAT_TIMEOUT: if hb and now - hb > _HEARTBEAT_TIMEOUT:
stale.append(serial) stale.append(serial)
for serial in stale: for serial in stale:
_log.error(f"[{serial}] 心跳超时 {now - get_worker_heartbeat(serial):.0f}s,标记卡死") hb_age = now - get_worker_heartbeat(serial)
_log.error(f"[{serial}] 心跳超时 {hb_age:.0f}s,标记卡死")
with _WORKERS_LOCK:
cur = dict(_WORKERS.get(serial, {}))
_update_status(serial, status="error", _update_status(serial, status="error",
last_error=f"心跳超时 {_HEARTBEAT_TIMEOUT}s,worker 可能卡死") last_error=f"心跳超时 {_HEARTBEAT_TIMEOUT}s,worker 可能卡死")
# 设备卡死是最需要人工介入的静默故障——通知一次
# (天然只报一次:下面的检查只针对 running/connecting,标成 error 后不再命中)
notifier.notify("device.heartbeat_timeout", serial=serial,
device_name=cur.get("device_name") or "",
timeout_s=int(hb_age), task_job=cur.get("task_job") or "",
model=cur.get("model") or "")
def stop(self): def stop(self):
self._stop.set() self._stop.set()
@@ -265,10 +275,13 @@ class BaseWorker(threading.Thread):
其他异常 — 可重试 其他异常 — 可重试
""" """
def __init__(self, serial, params=None, daemon=True): def __init__(self, serial, params=None, daemon=True, ctx=None):
super().__init__(daemon=daemon) super().__init__(daemon=daemon)
self.serial = serial self.serial = serial
self.params = params or {} self.params = params or {}
# 本次运行的上下文(run_id / job_id / job_name / device_name),由
# TaskManager 传入,供步骤明细等"结构化记录"标注来源。可空。
self.ctx = dict(ctx or {})
self._stop_flag = threading.Event() self._stop_flag = threading.Event()
self.d = None # u2.Device,run_task 里用 self.d = None # u2.Device,run_task 里用
# 通用进度字段(子类通过 set_progress 上报) # 通用进度字段(子类通过 set_progress 上报)
+193
View File
@@ -16,7 +16,9 @@
文件按 10MB 滚动,保留 5 个历史文件。 文件按 10MB 滚动,保留 5 个历史文件。
""" """
import os import os
import re
import logging import logging
from collections import deque
from logging.handlers import RotatingFileHandler from logging.handlers import RotatingFileHandler
_LOG_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "logs") _LOG_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "logs")
@@ -31,6 +33,8 @@ _MODULE_FILES = {
"task": "task.log", "task": "task.log",
"web": "web.log", "web": "web.log",
"action": "action.log", "action": "action.log",
# 通知单独一个文件:排障时"哪条通知发了/失败了/为什么"要能一眼翻到
"notify": "notify.log",
} }
_FORMAT = "%(asctime)s [%(levelname)s] [%(name)s] %(message)s" _FORMAT = "%(asctime)s [%(levelname)s] [%(name)s] %(message)s"
@@ -82,3 +86,192 @@ def log_print(msg, level="info", module="core"):
"""print 的替代品,转发到 logging。""" """print 的替代品,转发到 logging。"""
log = get_logger(module) log = get_logger(module)
getattr(log, level, log.info)(msg) getattr(log, level, log.info)(msg)
# ================== 日志读取(「日志」页的筛选 / 下载) ==================
#
# 读日志的代码放这里而不是 web 层:文件命名与滚动规则归本模块管
# (_MODULE_FILES + RotatingFileHandler 的 backupCount),**白名单校验必须和
# 写入侧用同一份事实**,否则改了文件名就会漏掉一处,变成目录穿越。
# 行格式与 _FORMAT 对应:2026-09-16 00:27:11 [INFO] [web.notify] 消息
_LINE_RE = re.compile(r"^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) "
r"\[([A-Z]+)\] \[([^\]]*)\] ?(.*)$")
_LEVEL_ORDER = {"DEBUG": 10, "INFO": 20, "WARNING": 30, "ERROR": 40, "CRITICAL": 50}
# 单次读取的行数上限:文件本身被限在 10MB(约 10 万行),这里再兜一层,
# 万一将来有人调大 maxBytes,也不会把内存吃爆。
_MAX_SCAN_LINES = 500000
_MODULE_LABELS = {
"core.log": "核心(adb/调度)",
"task.log": "任务执行",
"web.log": "Web/AI",
"action.log": "动作执行",
"notify.log": "通知发送",
}
def _rotation_files(base):
"""基础名 + 磁盘上实际存在的滚动副本(core.log → core.log.1/.2…)。
RotatingFileHandler 的备份编号**越大越旧**,所以 base 最新,接着 .1/.2…
"""
names = []
if os.path.exists(os.path.join(_LOG_DIR, base)):
names.append(base)
i = 1
while i <= 99 and os.path.exists(os.path.join(_LOG_DIR, f"{base}.{i}")):
names.append(f"{base}.{i}")
i += 1
return names
def list_log_files():
"""可查看的日志文件(模块文件 + 滚动历史),最近写入的排前面。
`file` 参数只能取这里的 name(白名单),杜绝 `../../` 之类的路径穿越。
"""
out = []
for base in sorted(set(_MODULE_FILES.values())):
label = _MODULE_LABELS.get(base, base)
for name in _rotation_files(base):
try:
st = os.stat(os.path.join(_LOG_DIR, name))
except OSError:
continue
rotated = name != base
out.append({
"name": name,
"label": f"{label} · {name}" + ("(历史)" if rotated else ""),
"module": base,
"size": st.st_size,
"mtime": int(st.st_mtime),
"rotated": rotated,
})
out.sort(key=lambda f: (-f["mtime"], f["name"]))
return out
def norm_ts(value, end=False):
"""把前端的时间输入归一成 'YYYY-MM-DD HH:MM:SS'(可比字符串)。
接受 datetime-local 的 'YYYY-MM-DDTHH:MM'、'YYYY-MM-DD HH:MM' 和纯日期;
纯日期按整天算(end=True 取 23:59:59,否则 00:00:00)。
"""
s = (value or "").strip().replace("T", " ")
if not s:
return ""
if len(s) == 10: # 只给了日期 → 按整天
return s + (" 23:59:59" if end else " 00:00:00")
if len(s) == 16: # YYYY-MM-DD HH:MM
return s + (":59" if end else ":00")
return s[:19]
def _iter_filtered(fh, keyword, min_level, since, until, stats=None):
"""逐行读文件,产出命中的 {ts, level, module, msg, cont}。
过滤口径:
- 时间/级别用**继承值**——续行(traceback 的缩进行等)本身没有前缀,
继承上一条带前缀的行,这样按 ERROR 筛选时整段堆栈不会被拆散;
- 关键字匹配本行原文(续行也参与),大小写不敏感。
stats(可选 dict)里回填 scanned:本次实际读了多少行(用于告诉用户
"是读到文件开头了,还是被行数上限截断的")。
"""
kw = (keyword or "").strip().lower()
floor = _LEVEL_ORDER.get((min_level or "").upper(), 0)
cur_ts = cur_level = cur_module = ""
for raw in fh:
if stats is not None:
stats["scanned"] += 1
line = raw.rstrip("\r\n")
m = _LINE_RE.match(line)
if m:
cur_ts, cur_level, cur_module, msg = m.groups()
cont = False
else:
# 续行(traceback、被切成多行的长消息):沿用上一条的元信息
msg, cont = line, True
if since and (not cur_ts or cur_ts < since):
continue
if until and (not cur_ts or cur_ts > until):
continue
if floor and _LEVEL_ORDER.get(cur_level, 0) < floor:
continue
if kw and kw not in line.lower():
continue
yield {"ts": cur_ts, "level": cur_level, "module": cur_module,
"msg": msg, "cont": cont}
def query_log(name, keyword="", min_level="", since="", until="",
limit=300, max_scan=_MAX_SCAN_LINES):
"""取日志文件里**最近** limit 条满足条件的行。
返回 dict:
rows — [{ts, level, module, msg, cont}],保持文件原顺序(旧→新)
matched — 命中总数(可能大于 len(rows):更早的命中没返回)
scanned — 实际读了多少行(达到 max_scan 说明是被上限截断的)
truncated — matched > len(rows),即"还有更早的命中没显示"
error — 出错时的原因(如文件不存在)
正序读整文件 + deque 保留最后 N 条:文件被 RollingFileHandler 限在 10MB
(约 10 万行),整读一次百毫秒级;换来的是续行能正确继承时间戳——倒着
读的写法在遇到 traceback 时必须把续行攒着等前面那行,容易出错。
"""
# 白名单:`name` 必须来自 list_log_files(),否则一律拒绝(防目录穿越)
if name not in {f["name"] for f in list_log_files()}:
return {"rows": [], "matched": 0, "scanned": 0, "truncated": False,
"error": "日志文件不存在或不可读取"}
since, until = norm_ts(since), norm_ts(until, end=True)
try:
keep = max(1, min(int(limit or 300), 5000))
except (TypeError, ValueError):
keep = 300
rows, matched, stats = deque(maxlen=keep), 0, {"scanned": 0}
try:
with open(os.path.join(_LOG_DIR, name), encoding="utf-8",
errors="replace") as fh:
for row in _iter_filtered(fh, keyword, min_level, since, until, stats):
matched += 1
rows.append(row)
if stats["scanned"] >= max_scan:
break
except OSError as e:
return {"rows": [], "matched": 0, "scanned": 0, "truncated": False,
"error": f"读取失败: {e}"}
return {"rows": list(rows), "matched": matched, "scanned": stats["scanned"],
"truncated": matched > len(rows), "error": ""}
def read_log_text(name, keyword="", min_level="", since="", until="",
limit=200000):
"""导出用的整段文本:命中行按原格式拼回去(下载按钮)。
没给任何条件时直接读整个文件(含滚动历史由调用方选定的那一个)。
"""
if name not in {f["name"] for f in list_log_files()}:
return None, "日志文件不存在或不可读取"
since, until = norm_ts(since), norm_ts(until, end=True)
any_filter = bool((keyword or "").strip() or min_level or since or until)
path = os.path.join(_LOG_DIR, name)
try:
with open(path, encoding="utf-8", errors="replace") as fh:
if not any_filter:
return fh.read(), ""
buf, n = [], 0
for row in _iter_filtered(fh, keyword, min_level, since, until):
if row["cont"]:
buf.append(row["msg"]) # 续行原样输出
else:
buf.append(f"{row['ts']} [{row['level']}] "
f"[{row['module']}] {row['msg']}")
n += 1
if n >= limit:
buf.append(f"...(已达导出上限 {limit} 行,请缩小时间范围)")
break
return "\n".join(buf) + ("\n" if buf else ""), ""
except OSError as e:
return None, f"读取失败: {e}"
+34
View File
@@ -369,6 +369,40 @@ class DeviceInstallLog(db.Model):
created_at = db.Column(db.String(20), default="") created_at = db.Column(db.String(20), default="")
class TaskStepLog(db.Model):
"""任务步骤明细:每一次步骤执行的落库记录(「日志 → 步骤明细」页)。
与 `logs/task.log` 的分工:文本日志是**排障时的原始现场**(什么都往里写、
10MB 滚动),本表是**结构化的一份**——设备/任务/步骤/结果/耗时都是列,
所以能按设备、任务、时间、结果过滤和统计,文本日志只能 grep。
写入方是 `core/step_log.py` 的专用写线程(异步批量落库),**任务线程不直接
写库**:一次运行可能上万步,每步一次 INSERT 会拖慢热路径。
保留期由 `core/step_log.py` 的清理任务控制(默认 14 天,见该模块常量)。
"""
__tablename__ = "task_step_log"
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
run_id = db.Column(db.String(24), default="", index=True) # 一次运行=设备×任务×第几次尝试
job_id = db.Column(db.String(32), default="")
job_name = db.Column(db.String(120), default="")
serial = db.Column(db.String(120), default="")
device_name = db.Column(db.String(80), default="")
step_path = db.Column(db.String(32), default="") # 嵌套位置,如 "2.1.3"
step_label = db.Column(db.String(120), default="")
step_type = db.Column(db.String(40), default="") # click_el / loop / input_text …
selector = db.Column(db.String(300), default="") # 元素选择器(长选择器截断)
result = db.Column(db.String(16), default="") # ok/skip/miss/error/unknown/cap
detail = db.Column(db.String(500), default="") # 异常消息、跳过原因等
duration_ms = db.Column(db.Integer, default=0)
created_at = db.Column(db.String(20), default="", index=True)
__table_args__ = (
db.Index("ix_step_log_serial_ts", "serial", "created_at"),
db.Index("ix_step_log_job_ts", "job_id", "created_at"),
)
class AgentConversation(db.Model): class AgentConversation(db.Model):
"""AI 控制台会话:整个消息序列以 JSON 存在一行里(单会话几十 KB,够用)。""" """AI 控制台会话:整个消息序列以 JSON 存在一行里(单会话几十 KB,够用)。"""
__tablename__ = "agent_conversation" __tablename__ = "agent_conversation"
+1107
View File
File diff suppressed because it is too large Load Diff
+208
View File
@@ -0,0 +1,208 @@
"""通知事件目录(**唯一真相**)。
约定:
- **key 点分层**:`模块.对象.动作`(如 `task.device.failed`),支持通配订阅
(`task.*`、`device.*`、`*`),匹配规则见 `match()`。
- **登记 ≠ 会发**:所有事件 `default` 一律 False —— 用户在「系统 → 通知」里
按 webhook 勾选才会推送。`recommend` 只用于界面高亮"建议开启",不改默认值。
- **聚合**:`agg_window` 秒内的同 `(hook, event, agg_key 取值)` 事件合并成一条
(保留前几个样本);`agg_window=0` = 不聚合,立即发(低频高危事件用它)。
- **fields**:该事件保证携带的上下文字段,供前端画字段表、也是模板占位符白名单。
新增事件:在 `EVENTS` 里加一条 + 在触发点调 `notifier.notify(key, **fields)`,
并同步 `doc/NOTIFY.md` 的事件表(见 doc/README.md 的文档同步红线)。
"""
from collections import namedtuple
# key, label, category, default, recommend, agg_window, agg_key, fields, desc
EventDef = namedtuple(
"EventDef",
"key label category default recommend agg_window agg_key fields desc")
def _e(key, label, category, fields, desc="", agg_window=30, agg_key=None,
recommend=False):
"""构造一条事件定义(default 恒为 False:登记不等于推送)。"""
return EventDef(key, label, category, False, recommend, agg_window,
agg_key, tuple(fields), desc)
# 聚合键:按"任务批次"合并(同一次任务的多台设备结果合成一条)
_BY_JOB = ("job_id",)
# 按"设备"合并(一台设备的重复抖动合成一条)
_BY_SERIAL = ("serial",)
EVENTS = [
# ---------------- 任务批次 ----------------
_e("task.batch.started", "任务批次开始", "任务批次",
["job_id", "job_name", "task_type", "device_count", "serials_preview"],
"一次任务开始铺开到 N 台设备", agg_window=0, recommend=True),
_e("task.batch.finished", "任务批次结束", "任务批次",
["job_id", "job_name", "total", "success", "failed", "stopped", "skipped",
"failed_devices", "duration_s"],
"整批跑完(成功/失败/停止台数 + 耗时;有失败时列出失败设备与型号)",
agg_window=0, recommend=True),
_e("task.batch.no_device", "任务无可用设备", "任务批次",
["job_id", "job_name", "target"],
"触发时一台可用设备都没有(任务空跑)", agg_window=0, recommend=True),
_e("task.batch.unknown_type", "任务类型不存在", "任务批次",
["job_id", "job_name", "task_type"],
"任务类型已从代码里删掉,任务永远不会执行", agg_window=0, recommend=True),
_e("task.cron.stopped", "定时停止任务", "任务批次",
["job_id", "job_name", "stopped_count", "serials"],
"cron_stop 到点,停掉了正在跑的设备", agg_window=0),
# ---------------- 任务 · 单设备(权威结论点) ----------------
_e("task.device.success", "设备任务成功", "任务·单设备",
["serial", "device_name", "model", "job_id", "job_name", "attempt", "duration_s"],
"某台设备上的任务最终成功。**只在目标设备只有 1 台时发送**——多设备批次看"
"「任务批次结束」就够了,13 台设备就是 13 条刷屏", agg_key=_BY_JOB),
_e("task.device.failed", "设备任务失败", "任务·单设备",
["serial", "device_name", "model", "job_id", "job_name", "attempts",
"cause", "msg"],
"某台设备上的任务最终失败(带真实原因与型号)", agg_key=_BY_JOB, recommend=True),
_e("task.device.offline", "设备离线放弃", "任务·单设备",
["serial", "device_name", "model", "job_id", "job_name", "error"],
"设备离线,不重试直接失败", agg_key=_BY_JOB, recommend=True),
_e("task.device.error", "单次尝试异常", "任务·单设备",
["serial", "device_name", "model", "job_name", "attempt", "error"],
"某次尝试抛异常(后面还会重试,噪音较大)", agg_key=_BY_JOB),
_e("task.device.retry", "设备任务重试", "任务·单设备",
["serial", "device_name", "job_name", "attempt", "next_attempt", "delay_s", "reason"],
"即将重试", agg_key=_BY_JOB),
_e("task.device.stopped", "设备任务被停止", "任务·单设备",
["serial", "device_name", "model", "job_name", "attempt", "phase"],
"用户手动停止 / cron 停止", agg_key=_BY_JOB),
_e("task.device.preempted", "设备被抢占", "任务·单设备",
["serial", "device_name", "job_name", "preempted_job_id", "preempted_job_name"],
"本任务抢占了该设备上正在跑的其他任务", agg_key=_BY_JOB, recommend=True),
_e("task.device.preempt_timeout", "抢占超时跳过", "任务·单设备",
["serial", "device_name", "job_name", "preempted_job_id"],
"等被抢占任务退出超时,本设备放弃执行", agg_window=0, recommend=True),
_e("task.device.released", "抢占结束归还", "任务·单设备",
["serial", "device_name", "job_name", "preempted_job_id", "returned", "reason"],
"抢占结束时重新拉起被抢占的任务", agg_key=_BY_JOB),
# ---------------- Worker 状态机(单次 attempt 级,默认关) ----------------
_e("worker.connected", "设备已连接", "Worker",
["serial", "model", "remote_adb_url"],
"worker 连上设备(单次尝试级,噪音大)"),
_e("worker.attempt.done", "单次执行完成", "Worker",
["serial", "task_type", "duration_s", "max_duration_hit"],
"单次 attempt 正常跑完(不代表任务最终成功)"),
_e("worker.attempt.error", "单次执行出错", "Worker",
["serial", "task_type", "error", "transient"],
"单次 attempt 抛异常"),
# ---------------- 业务(任务内容) ----------------
_e("task.selector.invalid", "选择器连续失效", "业务",
["serial", "device_name", "selector", "miss_count"],
"某选择器连续 10 次未命中——任务可能显示成功但什么都没做", agg_window=0,
recommend=True),
# ---------------- 设备 ----------------
_e("device.online", "设备恢复在线", "设备",
["serials", "count"],
"原本断联的设备又能连上了", agg_window=0, recommend=True),
_e("device.offline", "设备断联", "设备",
["serials", "count", "devices"],
"设备池里的设备连不上了", agg_window=0, recommend=True),
_e("device.discovered", "发现新设备", "设备",
["serials", "count"],
"扫描到尚未在池内的设备(待认领)", agg_window=0),
_e("device.claimed", "设备自动认领", "设备",
["pairs", "count"],
"指纹匹配成功,自动把旧记录迁到新地址", agg_window=0),
_e("device.heartbeat_timeout", "心跳超时", "设备",
["serial", "device_name", "timeout_s", "task_job", "model"],
"设备卡死(长时间没心跳),任务可能已中断", agg_window=0, recommend=True),
# ---------------- 应用安装 ----------------
_e("apk.install.started", "应用安装开始", "安装",
["apk_id", "apk_name", "package_name", "total"],
"开始往 N 台设备推装", agg_window=0),
_e("apk.install.finished", "应用安装完成", "安装",
["apk_name", "success", "failed", "skipped", "total", "failed_items"],
"批量安装结束(含失败台数与原因)", agg_window=0, recommend=True),
# ---------------- 系统 ----------------
_e("system.backup.exported", "备份已导出", "系统",
["filename", "size", "tables", "include_apk", "user"],
"有人导出了整库备份", agg_window=0, recommend=True),
_e("system.backup.imported", "备份导入已挂起", "系统",
["token_prefix", "env_label", "force"],
"上传了备份并确认导入(重启后生效)", agg_window=0),
_e("system.backup.restored", "备份已恢复", "系统",
["applied_rows", "schema_version"],
"启动时应用了待恢复的备份(所以这条是重启后才发)", agg_window=0,
recommend=True),
_e("system.backup.restore_failed", "备份恢复失败", "系统",
["error", "fail_dir"],
"恢复校验没过,已搁置(数据未被改动)", agg_window=0, recommend=True),
_e("service.started", "服务已启动", "系统",
["version", "env", "db_target", "device_count", "job_count"],
"平台进程起来了", agg_window=0, recommend=True),
_e("service.stopping", "服务正在停止", "系统",
["uptime_s"],
"平台进程收到退出信号", agg_window=0),
_e("user.login", "用户登录", "系统",
["username"],
"有人登录了后台", agg_window=0),
# ---------------- AI ----------------
_e("ai.audit.finished", "经验巡检完成", "AI",
["reviewed", "suggested", "summary"],
"每日经验库巡检跑完(或手动触发)", agg_window=0, recommend=True),
_e("ai.audit.failed", "经验巡检异常", "AI",
["error"],
"巡检过程中报错", agg_window=0),
_e("ai.audit.skipped", "经验巡检跳过", "AI",
["reason"],
"没配模型 Key 等原因跳过巡检", agg_window=0),
# ---------------- 其它 ----------------
_e("notify.test", "测试通知", "其它",
["hook_name", "operator"],
"「发送测试」按钮专用", agg_window=0),
]
BY_KEY = {e.key: e for e in EVENTS}
# 界面上的分组顺序
CATEGORIES = ("任务批次", "任务·单设备", "业务", "设备", "安装", "系统", "AI",
"Worker", "其它")
def get(key):
"""按 key 取事件定义;未知 key 返回 None(未知事件会被 notify 丢弃)。"""
return BY_KEY.get(key)
def match(pattern, key):
"""事件订阅匹配。
- `*` → 全部
- `task.*` → task 下所有(含 task.device.failed)
- `task.device.*` → 任意层级前缀匹配
- `task.device.failed` → 精确
"""
if not pattern:
return False
if pattern == "*" or pattern == key:
return True
if pattern.endswith(".*"):
return key.startswith(pattern[:-1]) # "task.*" → "task."
return False
def list_events():
"""给接口/前端用的事件目录(按 CATEGORIES 排序)。"""
order = {c: i for i, c in enumerate(CATEGORIES)}
rows = [{"key": e.key, "label": e.label, "category": e.category,
"default": e.default, "recommend": e.recommend,
"agg_window": e.agg_window, "agg_key": list(e.agg_key or ()),
"fields": list(e.fields), "desc": e.desc}
for e in EVENTS]
rows.sort(key=lambda r: (order.get(r["category"], 99), r["key"]))
return rows
+247
View File
@@ -0,0 +1,247 @@
"""任务步骤明细:异步落库(专用写线程)+ 保留期清理。
为什么不让任务线程直接写库:步骤执行是**热路径**(一次运行可能上万步),每步
一次 INSERT 要建会话、等 MySQL 往返,还会和业务事务抢连接。这里改成「专用写
线程 + 有界队列」:任务线程只做一次 put_nowait(微秒级),落库、重排、批量都
在写线程里做。
铁律(与 `core/notifier.py` 一致):
- `record()` 零阻塞、零 DB、**绝不抛异常** —— 记录绝不影响任务执行;
- 落库失败只写日志、丢弃该批,**不重排**(不为补数据把内存撑爆)。
线程用裸 `threading.Thread(daemon=True)`(与项目其它后台线程一致;线程池的
非 daemon 线程会在退出时被 atexit join 卡住)。
"""
import queue
import threading
import time
from core.logger import get_logger
_log = get_logger("task.step")
# 一次运行最多记录多少条:防「2 小时无限循环」这类任务把表写爆。
# 达到上限后本次运行不再逐条记,只补一条 cap 说明行。
MAX_ROWS_PER_RUN = 2000
# 保留天数:每日清理任务删除更早的记录。**这个值直接决定备份包体积**
# (task_step_log 会进整库备份,见 doc/DATA_MODEL.md §6),改大前先想清楚。
KEEP_DAYS = 14
# 队列上限与批量:队列满 = 丢弃并计数(宁可丢明细,也不能拖住任务线程)
_QUEUE_MAX = 5000
_BATCH = 200
_FLUSH_INTERVAL = 1.0
_app = None
_q = queue.Queue(maxsize=_QUEUE_MAX)
_writer = None
_stop = threading.Event()
_stats = {"queued": 0, "written": 0, "dropped": 0, "failed": 0}
# ================== 生命周期 ==================
def init_app(app):
"""启动写线程(web_server 启动时调用;重复调用安全)。"""
global _app, _writer
_app = app
if _writer is not None and _writer.is_alive():
return
_stop.clear()
_writer = threading.Thread(target=_writer_loop, name="step-log-writer",
daemon=True)
_writer.start()
_log.info(f"步骤明细写线程已启动(保留 {KEEP_DAYS} 天,单次运行上限 "
f"{MAX_ROWS_PER_RUN} 条)")
def shutdown(timeout=3.0):
"""退出前停写线程并尽量排空队列(超时就算了,明细不是关键数据)。"""
_stop.set()
if _writer is not None:
_writer.join(timeout)
def stats():
"""本进程内的计数(排障用:看有没有在丢记录)。"""
return dict(_stats, queue_size=_q.qsize())
# ================== 写入侧 ==================
def record(run_id="", job_id="", job_name="", serial="", device_name="",
step_path="", step_label="", step_type="", selector="",
result="", detail="", duration_ms=0):
"""记录一次步骤执行。**任务线程直接调用**——零阻塞、零 DB、不抛异常。
队列满时丢弃并计数(dropped),不会阻塞调用方。
"""
try:
row = {
"run_id": (run_id or "")[:24],
"job_id": (job_id or "")[:32],
"job_name": (job_name or "")[:120],
"serial": (serial or "")[:120],
"device_name": (device_name or "")[:80],
"step_path": (step_path or "")[:32],
"step_label": (step_label or "")[:120],
"step_type": (step_type or "")[:40],
"selector": (selector or "")[:300],
"result": (result or "")[:16],
"detail": (detail or "")[:500],
"duration_ms": int(duration_ms or 0),
"created_at": time.strftime("%Y-%m-%d %H:%M:%S"),
}
_q.put_nowait(row)
_stats["queued"] += 1
except queue.Full:
_stats["dropped"] += 1
except Exception: # 截断/类型异常都不该冒泡到任务线程
_stats["dropped"] += 1
def _collect():
"""取一批待写记录:最多等 _FLUSH_INTERVAL,凑够 _BATCH 就走。"""
rows = []
try:
rows.append(_q.get(timeout=_FLUSH_INTERVAL))
except queue.Empty:
return rows
while len(rows) < _BATCH:
try:
rows.append(_q.get_nowait())
except queue.Empty:
break
return rows
def _insert(rows):
"""批量落库。失败只记日志——明细丢了可以接受,任务不能被拖住。"""
if not rows or _app is None:
return
from core.models import TaskStepLog, db
try:
with _app.app_context():
db.session.execute(db.insert(TaskStepLog), rows)
db.session.commit()
_stats["written"] += len(rows)
except Exception as e:
_stats["failed"] += len(rows)
try:
db.session.rollback()
except Exception:
pass
_log.error(f"步骤明细落库失败(丢弃 {len(rows)} 条): {e}")
def _writer_loop():
while not _stop.is_set():
batch = _collect()
if batch:
_insert(batch)
# 退出前再排空一次,尽量不丢最后几条
while True:
batch = _collect()
if not batch:
break
_insert(batch)
# ================== 查询(给 API 用,在请求线程里跑) ==================
def query(serial="", job_id="", result="", run_id="", keyword="",
since="", until="", limit=200, offset=0):
"""按条件查明细,返回 (rows, total)。**倒序**(最近的在前)。"""
from sqlalchemy import or_
from core.models import TaskStepLog
q = TaskStepLog.query
if serial:
q = q.filter(TaskStepLog.serial == serial)
if job_id:
q = q.filter(TaskStepLog.job_id == job_id)
if result:
q = q.filter(TaskStepLog.result == result)
if run_id:
q = q.filter(TaskStepLog.run_id == run_id)
if since:
q = q.filter(TaskStepLog.created_at >= since)
if until:
q = q.filter(TaskStepLog.created_at <= until)
if keyword:
kw = f"%{keyword}%"
q = q.filter(or_(TaskStepLog.detail.like(kw),
TaskStepLog.step_label.like(kw),
TaskStepLog.step_type.like(kw),
TaskStepLog.selector.like(kw),
TaskStepLog.job_name.like(kw)))
total = q.count()
rows = (q.order_by(TaskStepLog.id.desc())
.offset(max(0, int(offset or 0)))
.limit(max(1, min(int(limit or 200), 2000)))
.all())
return rows, total
def runs(serial="", job_id="", since="", until="", limit=100):
"""按 run_id 归组的一次运行概览(哪次运行失败、失败几步)。
用一次 GROUP BY 查出来,避免前端按明细自己聚合。
"""
from sqlalchemy import case, func
from core.models import TaskStepLog, db
q = db.session.query(
TaskStepLog.run_id,
func.min(TaskStepLog.created_at).label("started_at"),
func.max(TaskStepLog.created_at).label("ended_at"),
func.max(TaskStepLog.job_name).label("job_name"),
func.max(TaskStepLog.job_id).label("job_id"),
func.max(TaskStepLog.serial).label("serial"),
func.max(TaskStepLog.device_name).label("device_name"),
func.count(TaskStepLog.id).label("steps"),
func.sum(case((TaskStepLog.result.in_(("error", "miss", "unknown")), 1),
else_=0)).label("failures"),
)
if serial:
q = q.filter(TaskStepLog.serial == serial)
if job_id:
q = q.filter(TaskStepLog.job_id == job_id)
if since:
q = q.filter(TaskStepLog.created_at >= since)
if until:
q = q.filter(TaskStepLog.created_at <= until)
q = (q.filter(TaskStepLog.run_id != "")
.group_by(TaskStepLog.run_id)
.order_by(func.max(TaskStepLog.id).desc())
.limit(max(1, min(int(limit or 100), 500))))
return q.all()
def purge_old(keep_days=None, batch=5000):
"""删除超过保留期的明细。返回删除行数。
分批删(先取 id 再按 id 删,**不用 DELETE ... LIMIT**——SQLite 默认编译
选项不支持它),避免一条长事务把表锁住。
"""
from core.models import TaskStepLog, db
days = KEEP_DAYS if keep_days is None else int(keep_days)
cutoff = time.strftime("%Y-%m-%d %H:%M:%S",
time.localtime(time.time() - days * 86400))
deleted = 0
try:
while True:
ids = [r.id for r in db.session.query(TaskStepLog.id)
.filter(TaskStepLog.created_at < cutoff).limit(batch).all()]
if not ids:
break
deleted += db.session.query(TaskStepLog) \
.filter(TaskStepLog.id.in_(ids)) \
.delete(synchronize_session=False)
db.session.commit()
except Exception as e:
db.session.rollback()
_log.error(f"步骤明细清理失败(已删 {deleted} 条): {e}")
return deleted
if deleted:
_log.info(f"步骤明细清理: 删除 {deleted} 条(保留 {days} 天)")
return deleted
+7
View File
@@ -36,6 +36,7 @@ from config import (DATA_DIR, APK_DIR, BACKUP_DIR,
RESTORE_STAGING_DIR, RESTORE_PENDING_DIR) RESTORE_STAGING_DIR, RESTORE_PENDING_DIR)
from core.logger import get_logger from core.logger import get_logger
from core.models import CURRENT_SCHEMA_VERSION, db from core.models import CURRENT_SCHEMA_VERSION, db
from core import notifier
_log = get_logger("core.backup") _log = get_logger("core.backup")
@@ -55,6 +56,7 @@ TABLE_LABELS = {
"pending_device": "待连接设备", "agent_conversation": "AI 会话", "pending_device": "待连接设备", "agent_conversation": "AI 会话",
"agent_experience": "经验库", "experience_audit": "经验巡检", "agent_experience": "经验库", "experience_audit": "经验巡检",
"agent_action": "动作库", "device_install_log": "设备端安装记录", "agent_action": "动作库", "device_install_log": "设备端安装记录",
"task_step_log": "任务步骤明细",
} }
_STAGE_TTL = 1800 # 导入暂存有效期(秒) _STAGE_TTL = 1800 # 导入暂存有效期(秒)
@@ -547,6 +549,8 @@ def consume_pending_restore():
shutil.move(pending_db, os.path.join(fail_dir, "users.db")) shutil.move(pending_db, os.path.join(fail_dir, "users.db"))
remove_quiet(RESTORE_PENDING_DIR) remove_quiet(RESTORE_PENDING_DIR)
_log.error(f"恢复任务校验失败,已搁置到 {fail_dir},继续使用当前库: {msg}") _log.error(f"恢复任务校验失败,已搁置到 {fail_dir},继续使用当前库: {msg}")
notifier.notify("system.backup.restore_failed", error=str(msg)[:200],
fail_dir=fail_dir)
return False return False
try: try:
@@ -569,6 +573,9 @@ def consume_pending_restore():
remove_quiet(RESTORE_PENDING_DIR) remove_quiet(RESTORE_PENDING_DIR)
schema_version = msg.get("schema_version", "?") if isinstance(msg, dict) else "?" schema_version = msg.get("schema_version", "?") if isinstance(msg, dict) else "?"
_log.info(f"已应用备份恢复(单事务替换): {applied},schema v{schema_version},apks 已合并") _log.info(f"已应用备份恢复(单事务替换): {applied},schema v{schema_version},apks 已合并")
# 通知在"下一次启动"才发得出去(恢复本身是重启生效的),文案里说明白
notifier.notify("system.backup.restored", applied_rows=applied,
schema_version=schema_version)
return True return True
+195 -9
View File
@@ -21,6 +21,7 @@ import json
import time import time
import uuid import uuid
import threading import threading
from collections import Counter
from datetime import datetime from datetime import datetime
from concurrent.futures import ThreadPoolExecutor, as_completed from concurrent.futures import ThreadPoolExecutor, as_completed
@@ -31,6 +32,7 @@ from config import DATA_DIR
from core.logger import get_logger from core.logger import get_logger
from core.models import db, DeviceGroup as GroupRow, TaskJob as JobRow from core.models import db, DeviceGroup as GroupRow, TaskJob as JobRow
from core import device_pool from core import device_pool
from core import notifier
from .adb_helper import get_foreground_app, get_foreground_app_remote from .adb_helper import get_foreground_app, get_foreground_app_remote
from .device_worker import ( from .device_worker import (
DeviceOfflineError, DeviceOfflineError,
@@ -47,6 +49,81 @@ _log = get_logger("core.tm")
_START_STAGGER_SEC = 0.2 _START_STAGGER_SEC = 0.2
def _dev_model(serial, tracker=None):
"""设备型号:优先用批次开始时查好的设备池快照,退回 worker 上报的状态表。
为什么优先快照:worker 每次启动都会 `_update_status(model=…)`,但那次采集
(`d.info()`)可能超时/失败 → 型号是空;而设备池的型号是后台统一采集的、
稳定得多。快照还有个好处:不查库(通知路径上不碰 DB)。
"""
if tracker is not None:
m = (tracker.models or {}).get(serial)
if m:
return m
with _WORKERS_LOCK:
return _WORKERS.get(serial, {}).get("model") or ""
def _dev_fail_note(serial, dname, tracker=None):
"""失败设备的一行说明:`名字(型号):原因`——批次汇总里直接列出来。
型号同上用快照;原因取 worker 上报的 last_error(刚写进去,还在)。
"""
with _WORKERS_LOCK:
err = _WORKERS.get(serial, {}).get("last_error") or ""
cause = err.strip().splitlines()[0] if err.strip() else ""
model = _dev_model(serial, tracker)
head = f"{dname}({model})" if model else str(dname)
return f"{head}:{cause[:60]}" if cause else head
class _BatchTracker:
"""一次任务批次的收尾统计:**所有设备都出结果**后发一条 `task.batch.finished`。
为什么不每台设备各发一条"批次结束":100 台设备就是 100 条刷屏。这里只计数,
归零那一刻发一条汇总(锁外发,见 done())。
每台设备的结果由 `_run_with_retry` 的 finally 调一次 `done(serial, outcome)`——
放在 finally 里是为了保证"任何一个 return 分支都会被计数一次",不会漏也不会重。
"""
def __init__(self, job, total, names=None, models=None):
self.job = job
self.total = total
# serial -> 设备名(通知里显示名字而不是裸地址);取不到就是空
self.names = dict(names or {})
# serial -> 型号(批次开始时从设备池查一次带下来,通知里直接用)
self.models = dict(models or {})
self._lock = threading.Lock()
self._left = total
self._stats = Counter()
self._failed = [] # 失败设备明细(型号 + 原因),汇总里有失败时列出
self._t0 = time.time()
def done(self, serial, outcome="failed", detail=""):
"""登记一台设备的结果(success/failed/stopped/skipped)。
detail 只对 failed 有意义("设备名(型号):原因"),最多留 5 条 ——
批次汇总里列出来,用户不用再去翻日志。
"""
with self._lock:
self._left -= 1
self._stats[outcome] += 1
if outcome == "failed" and detail and len(self._failed) < 5:
self._failed.append(detail)
left = self._left
s = dict(self._stats)
fails = list(self._failed)
if left > 0:
return
notifier.notify("task.batch.finished", job_id=self.job.id,
job_name=self.job.name, total=self.total,
success=s.get("success", 0), failed=s.get("failed", 0),
stopped=s.get("stopped", 0), skipped=s.get("skipped", 0),
failed_devices=fails,
duration_s=int(time.time() - self._t0))
def _in_run_window(schedule, now=None): def _in_run_window(schedule, now=None):
"""是否在当前运行窗口内。 """是否在当前运行窗口内。
@@ -633,6 +710,8 @@ class TaskManager:
w.stop() w.stop()
stopped.append(serial) stopped.append(serial)
_log.info(f"定时停止 {job.name}({job.id}): 停止 {len(stopped)} 台设备 {stopped}") _log.info(f"定时停止 {job.name}({job.id}): 停止 {len(stopped)} 台设备 {stopped}")
notifier.notify("task.cron.stopped", job_id=job.id, job_name=job.name,
stopped_count=len(stopped), serials=stopped[:10])
def next_run_of(self, job): def next_run_of(self, job):
"""任务下次真正执行的时间(考虑运行窗口)。停用/无 cron 返回 None。""" """任务下次真正执行的时间(考虑运行窗口)。停用/无 cron 返回 None。"""
@@ -664,23 +743,43 @@ class TaskManager:
serials = job.resolve_serials(self) serials = job.resolve_serials(self)
if not serials: if not serials:
_log.warning(f"任务 {job.name} 无可用设备") _log.warning(f"任务 {job.name} 无可用设备")
notifier.notify("task.batch.no_device", job_id=job.id, job_name=job.name,
target=job.target)
return return
task_cls = get_task_class(job.task_type) task_cls = get_task_class(job.task_type)
if not task_cls: if not task_cls:
_log.error(f"未知任务类型: {job.task_type}") _log.error(f"未知任务类型: {job.task_type}")
notifier.notify("task.batch.unknown_type", job_id=job.id, job_name=job.name,
task_type=job.task_type)
return return
task = task_cls() task = task_cls()
max_attempts = max(1, job.retry.get("max_attempts", 1)) max_attempts = max(1, job.retry.get("max_attempts", 1))
delay = job.retry.get("delay", 60) delay = job.retry.get("delay", 60)
notifier.notify("task.batch.started", job_id=job.id, job_name=job.name,
task_type=job.task_type, device_count=len(serials),
serials_preview=serials[:5])
# 批次收尾统计:所有设备都出结果后发一条汇总(见 _BatchTracker)
# 设备名一次查好带下去(worker 线程里没有 app context,查库要显式包 context)
names, models = {}, {}
try:
with self._db():
rows = device_pool.list_devices()
names = {d["serial"]: (d.get("name") or "") for d in rows}
models = {d["serial"]: (d.get("model") or "") for d in rows}
except Exception as e:
_log.warning(f"读取设备名/型号失败(通知里将显示地址): {e}")
tracker = _BatchTracker(job, len(serials), names, models)
for idx, serial in enumerate(serials): for idx, serial in enumerate(serials):
# 每台设备一个重试循环线程,互不影响;错峰延迟在各自线程内等待 # 每台设备一个重试循环线程,互不影响;错峰延迟在各自线程内等待
t = threading.Thread(target=self._run_with_retry, t = threading.Thread(target=self._run_with_retry,
args=(task, serial, job, max_attempts, delay, args=(task, serial, job, max_attempts, delay,
idx * _START_STAGGER_SEC), daemon=True) idx * _START_STAGGER_SEC, tracker), daemon=True)
t.start() t.start()
def _run_with_retry(self, task, serial, job, max_attempts, delay, start_delay=0): def _run_with_retry(self, task, serial, job, max_attempts, delay, start_delay=0,
tracker=None):
"""单设备任务执行 + 重试。 """单设备任务执行 + 重试。
异常分类: 异常分类:
@@ -691,36 +790,58 @@ class TaskManager:
# 错峰启动:在各自线程内等待,分摊批量启动的连接/占用压力 # 错峰启动:在各自线程内等待,分摊批量启动的连接/占用压力
if start_delay > 0: if start_delay > 0:
time.sleep(start_delay) time.sleep(start_delay)
# 设备名(通知里显示"设备:A08"而不是裸地址)
dname = (tracker.names.get(serial) if tracker is not None else "") or serial
# 被抢占的原任务 id(抢占结束后归还)——必须在循环外: # 被抢占的原任务 id(抢占结束后归还)——必须在循环外:
# 重试时若重置为 None,finally 归还逻辑会丢失信息,被抢占任务永不恢复 # 重试时若重置为 None,finally 归还逻辑会丢失信息,被抢占任务永不恢复
preempted_job = None preempted_job = None
# 本台设备的结果(供批次统计与通知用)。默认 "failed":走到重试耗尽就是失败;
# 各提前 return 的分支会覆盖它。**在 finally 里统一上报**,保证不漏不重。
outcome = "failed"
try: try:
for attempt in range(1, max_attempts + 1): for attempt in range(1, max_attempts + 1):
# 用户已请求停止 → 不再启动新 attempt # 用户已请求停止 → 不再启动新 attempt
stopped_by_user = False
with self._lock: with self._lock:
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})")
stopped_by_user = True
if stopped_by_user:
outcome = "stopped"
notifier.notify("task.device.stopped", serial=serial, device_name=dname,
job_id=job.id, job_name=job.name, attempt=attempt,
phase="start")
return return
# 同一 serial 同时只能一个 worker;不同任务可配置抢占 # 同一 serial 同时只能一个 worker;不同任务可配置抢占
preempt = False preempt = False
skip_reason = ""
with self._lock: with self._lock:
if serial in self._running: if serial in self._running:
cur = self._running[serial] cur = self._running[serial]
if cur.get("job_id") == job.id: if cur.get("job_id") == job.id:
# 同一任务重复触发:跳过(原行为) # 同一任务重复触发:跳过(原行为)
_log.warning(f"{serial} 已有任务在跑,跳过 (job={job.name})") _log.warning(f"{serial} 已有任务在跑,跳过 (job={job.name})")
return skip_reason = "同一任务已在运行"
if not job.params.get("preempt"): elif not job.params.get("preempt"):
# 不同任务且本任务未开启抢占:跳过 # 不同任务且本任务未开启抢占:跳过
_log.warning(f"{serial} 已有任务在跑,跳过 (job={job.name})") _log.warning(f"{serial} 已有任务在跑,跳过 (job={job.name})")
return skip_reason = "设备被其他任务占用"
else:
preempt = True preempt = True
if not preempted_job: if not preempted_job:
preempted_job = cur.get("job_id") preempted_job = cur.get("job_id")
if skip_reason:
outcome = "skipped"
return
if preempt: if preempt:
# 抢占:锁外停止该设备上的其他任务(stop_device 内部拿同一把锁, # 抢占:锁外停止该设备上的其他任务(stop_device 内部拿同一把锁,
# 在锁内调用会死锁!),等其释放后接管 # 在锁内调用会死锁!),等其释放后接管
_log.warning(f"{serial} 抢占:停止任务 {preempted_job} 后执行 {job.name}") _log.warning(f"{serial} 抢占:停止任务 {preempted_job} 后执行 {job.name}")
notifier.notify("task.device.preempted", serial=serial, device_name=dname,
job_id=job.id, job_name=job.name,
preempted_job_id=preempted_job,
preempted_job_name=(self.jobs.get(preempted_job).name
if self.jobs.get(preempted_job) else ""))
self.stop_device(serial) self.stop_device(serial)
deadline = time.time() + 30 deadline = time.time() + 30
while time.time() < deadline: while time.time() < deadline:
@@ -730,6 +851,10 @@ class TaskManager:
time.sleep(0.5) time.sleep(0.5)
else: else:
_log.warning(f"{serial} 抢占超时(旧任务 30s 未退出),跳过 (job={job.name})") _log.warning(f"{serial} 抢占超时(旧任务 30s 未退出),跳过 (job={job.name})")
outcome = "skipped"
notifier.notify("task.device.preempt_timeout", serial=serial,
device_name=dname, job_id=job.id, job_name=job.name,
preempted_job_id=preempted_job)
return return
with self._lock: 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(),
@@ -739,8 +864,14 @@ class TaskManager:
max_attempts=max_attempts) max_attempts=max_attempts)
worker = None worker = None
t_attempt = time.time() # 单次 attempt 耗时(通知里带)
# 本次运行的上下文:步骤明细按 run_id 归组,把同一设备同一次尝试
# 执行的步骤串起来(重试会生成新的 run_id,两次尝试不混在一起)
run_ctx = {"run_id": uuid.uuid4().hex[:12], "job_id": job.id,
"job_name": job.name, "device_name": dname,
"attempt": attempt}
try: try:
worker = task.create_worker(serial, job.params) worker = task.create_worker(serial, job.params, ctx=run_ctx)
with self._lock: with self._lock:
self._running[serial]["worker"] = worker self._running[serial]["worker"] = worker
_log.info(f"{serial} 开始任务 {job.name} (第{attempt}/{max_attempts}次)") _log.info(f"{serial} 开始任务 {job.name} (第{attempt}/{max_attempts}次)")
@@ -756,11 +887,27 @@ class TaskManager:
_update_status(serial, task_job="") _update_status(serial, task_job="")
if st == "done": if st == "done":
_log.info(f"{serial} 任务 {job.name} 成功完成") _log.info(f"{serial} 任务 {job.name} 成功完成")
outcome = "success"
# 单设备成功:**只在"这一批本来就只有一台设备"时发**。
# 多设备批次里逐台报成功没有信息量(批次汇总里已有成功台数),
# 13 台就是 13 条刷屏;失败仍然逐台发——那是少数且要知道是哪台。
if tracker is None or tracker.total <= 1:
notifier.notify("task.device.success", serial=serial,
device_name=dname,
model=_dev_model(serial, tracker),
job_id=job.id, job_name=job.name,
attempt=attempt,
duration_s=int(time.time() - t_attempt))
return return
# 用户请求停止(无论 attempt 第几次、status 是什么)→ 不重试 # 用户请求停止(无论 attempt 第几次、status 是什么)→ 不重试
with self._lock: with self._lock:
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})")
outcome = "stopped"
notifier.notify("task.device.stopped", serial=serial,
device_name=dname, job_id=job.id,
job_name=job.name, attempt=attempt,
phase="after")
return return
_log.warning(f"{serial} 任务未成功(status={st})") _log.warning(f"{serial} 任务未成功(status={st})")
except DeviceOfflineError as e: except DeviceOfflineError as e:
@@ -770,30 +917,49 @@ class TaskManager:
self._running.pop(serial, None) self._running.pop(serial, None)
_update_status(serial, status="failed", task_job="", _update_status(serial, status="failed", task_job="",
last_error=f"设备离线: {e}") last_error=f"设备离线: {e}")
outcome = "failed"
notifier.notify("task.device.offline", serial=serial, device_name=dname,
model=_dev_model(serial, tracker),
job_id=job.id, job_name=job.name,
error=str(e)[:200])
return return
except Exception as e: except Exception as e:
_log.error(f"{serial} 执行异常: {e}", exc_info=True) _log.error(f"{serial} 执行异常: {e}", exc_info=True)
with self._lock: with self._lock:
self._running.pop(serial, None) self._running.pop(serial, None)
_update_status(serial, task_job="") _update_status(serial, task_job="")
notifier.notify("task.device.error", serial=serial, device_name=dname,
model=_dev_model(serial, tracker),
job_name=job.name, attempt=attempt,
error=str(e)[:200])
if attempt < max_attempts: if attempt < max_attempts:
# 用户在 sleep 期间点停止也能中断 # 用户在 sleep 期间点停止也能中断
with self._lock: with self._lock:
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})")
outcome = "stopped"
notifier.notify("task.device.stopped", serial=serial,
device_name=dname, job_name=job.name,
attempt=attempt, phase="retry-wait")
return return
# 检查是否是端口耗尽类临时错误,需要更长退避等端口释放 # 检查是否是端口耗尽类临时错误,需要更长退避等端口释放
with _WORKERS_LOCK: with _WORKERS_LOCK:
err = _WORKERS.get(serial, {}).get("last_error", "") err = _WORKERS.get(serial, {}).get("last_error", "")
if err.startswith("[transient]"): transient = err.startswith("[transient]")
if transient:
# Windows TCP 端口耗尽,TIME_WAIT 默认 2-4 分钟,等 120 秒 # Windows TCP 端口耗尽,TIME_WAIT 默认 2-4 分钟,等 120 秒
extra_delay = max(delay, 120) extra_delay = max(delay, 120)
_log.info(f"{serial} ADB 连接临时错误(端口耗尽),{extra_delay}s 后重试 ({attempt+1}/{max_attempts})") _log.info(f"{serial} ADB 连接临时错误(端口耗尽),{extra_delay}s 后重试 ({attempt+1}/{max_attempts})")
time.sleep(extra_delay) wait = extra_delay
else: else:
_log.info(f"{serial} {delay}s 后重试 ({attempt+1}/{max_attempts})") _log.info(f"{serial} {delay}s 后重试 ({attempt+1}/{max_attempts})")
time.sleep(delay) wait = delay
notifier.notify("task.device.retry", serial=serial, device_name=dname,
job_name=job.name, attempt=attempt,
next_attempt=attempt + 1, delay_s=int(wait),
reason="transient" if transient else "normal")
time.sleep(wait)
_log.error(f"{serial} 任务 {job.name} 重试耗尽,放弃") _log.error(f"{serial} 任务 {job.name} 重试耗尽,放弃")
# 带上最后一次的真实失败原因——只写"重试N次失败"会让用户看不到为什么失败 # 带上最后一次的真实失败原因——只写"重试N次失败"会让用户看不到为什么失败
@@ -805,20 +971,40 @@ class TaskManager:
if cause and cause != msg: if cause and cause != msg:
msg += f":{cause}" msg += f":{cause}"
_update_status(serial, status="failed", last_error=msg[:200]) _update_status(serial, status="failed", last_error=msg[:200])
outcome = "failed"
notifier.notify("task.device.failed", serial=serial, device_name=dname,
model=_dev_model(serial, tracker), job_id=job.id,
job_name=job.name, attempts=max_attempts,
cause=cause, msg=msg)
finally: finally:
# 清除停止标志:整个重试循环结束(成功/失败/停止)后允许下次任务 # 清除停止标志:整个重试循环结束(成功/失败/停止)后允许下次任务
with self._lock: with self._lock:
self._stop_requested.discard(serial) self._stop_requested.discard(serial)
# 批次统计:**每台设备只在这里上报一次**(所有 return 分支都会走到 finally)
if tracker is not None:
try:
tracker.done(serial, outcome,
detail=_dev_fail_note(serial, dname, tracker)
if outcome == "failed" else "")
except Exception as e:
_log.warning(f"批次统计上报失败(不影响任务): {e}")
# 归还:本任务是抢占任务,结束后自动重新启动被抢占的原任务 # 归还:本任务是抢占任务,结束后自动重新启动被抢占的原任务
if preempted_job: if preempted_job:
j = self.jobs.get(preempted_job) j = self.jobs.get(preempted_job)
if j and j.enabled: if j and j.enabled:
_log.info(f"{serial} 抢占任务 {job.name} 结束,归还设备给任务 {j.name}") _log.info(f"{serial} 抢占任务 {job.name} 结束,归还设备给任务 {j.name}")
notifier.notify("task.device.released", serial=serial, device_name=dname,
job_name=job.name, preempted_job_id=preempted_job,
returned=True, reason="归还并重启被抢占任务")
threading.Thread(target=self._run_job, args=(j,), daemon=True).start() threading.Thread(target=self._run_job, args=(j,), daemon=True).start()
else: else:
# 打日志便于排查:被抢占任务已删除/停用时不会归还,但要知道原因 # 打日志便于排查:被抢占任务已删除/停用时不会归还,但要知道原因
_log.info(f"{serial} 抢占任务 {job.name} 结束," _log.info(f"{serial} 抢占任务 {job.name} 结束,"
f"被抢占任务 {preempted_job} {'已停用,不归还' if j else '已不存在,不归还'}") f"被抢占任务 {preempted_job} {'已停用,不归还' if j else '已不存在,不归还'}")
notifier.notify("task.device.released", serial=serial, device_name=dname,
job_name=job.name, preempted_job_id=preempted_job,
returned=False,
reason="被抢占任务已停用" if j else "被抢占任务已不存在")
# ---- 运行控制 ---- # ---- 运行控制 ----
def stop_device(self, serial): def stop_device(self, serial):
+77 -4
View File
@@ -139,7 +139,12 @@
| POST | `/api/users` | Admin | 新建用户 | | POST | `/api/users` | Admin | 新建用户 |
| PUT | `/api/users/<int:uid>` | Admin | 更新用户(改密/权限/管理员) | | PUT | `/api/users/<int:uid>` | Admin | 更新用户(改密/权限/管理员) |
| DELETE | `/api/users/<int:uid>` | Admin | 删除用户 | | DELETE | `/api/users/<int:uid>` | Admin | 删除用户 |
| GET | `/api/logs` | G | 读日志文件尾部 N 行 | | GET | `/api/logs` | G | 读日志(关键字/级别/时间过滤,返回结构化行) |
| GET | `/api/logs/download` | G | 下载日志(同样支持过滤;无过滤即整个文件) |
| GET | `/api/step_logs` | G | 任务步骤明细(按设备/任务/结果/时间/关键字过滤,分页) |
| GET | `/api/step_logs/runs` | G | 按 `run_id` 归组的一次运行概览(几步、失败几步) |
| GET | `/api/step_logs/filters` | G | 步骤明细的筛选项(明细里出现过的设备/任务 + 结果枚举) |
| GET | `/api/step_logs/download` | G | 导出步骤明细 CSV(带 UTF-8 BOM,Excel 直接打开) |
### 2.5 devices(`web/devices_api.py`) ### 2.5 devices(`web/devices_api.py`)
@@ -238,7 +243,23 @@
| POST | `/api/system/backup/preview` | 上传备份校验预览(multipart,字段 `file`) | | POST | `/api/system/backup/preview` | 上传备份校验预览(multipart,字段 `file`) |
| POST | `/api/system/backup/apply` | 应用恢复(重启生效) | | POST | `/api/system/backup/apply` | 应用恢复(重启生效) |
### 2.11 device_agent(`web/device_agent_api.py`) ### 2.11 notify(`web/notify_api.py`,全部 Admin)
通知 / Webhook 配置与发送记录(事件目录、推送格式、限流语义见 [NOTIFY.md](NOTIFY.md)):
| 方法 | 路径 | 功能 |
|------|------|------|
| GET | `/api/notify/webhooks` | 全部 webhook(`url` 打码、`secret` 只回 `secret_set`)+ 格式清单 |
| POST | `/api/notify/webhooks` | 新建(body 见 NOTIFY.md §4) |
| PUT | `/api/notify/webhooks/<id>` | 更新(`url`/`secret` 省略或为打码值 → **保持原值**) |
| DELETE | `/api/notify/webhooks/<id>` | 删除 |
| POST | `/api/notify/webhooks/<id>/test` | **同步**发一条测试消息(不占业务令牌桶,单独限 10 次/分) |
| POST | `/api/notify/preview` | 预览真实请求体 + UTF-8 字节数 + 是否截断 |
| GET | `/api/notify/events` | 事件目录(含字段清单) |
| GET | `/api/notify/logs?limit=` | 最近发送记录(内存 200 条,重启清空) |
| POST | `/api/notify/settings` | 全局开关与默认聚合/限流 |
### 2.12 device_agent(`web/device_agent_api.py`)
**设备端专用**(无登录会话,靠 `X-Device-Token` 鉴权;平台未启用时统一 404): **设备端专用**(无登录会话,靠 `X-Device-Token` 鉴权;平台未启用时统一 404):
@@ -321,7 +342,7 @@
| `POST /api/stop_all` | — | 停止全部 | | `POST /api/stop_all` | — | 停止全部 |
| `POST /api/device/clear_error` | `{"serial":"…"}` | 运行中/重试等待中拒绝清理 | | `POST /api/device/clear_error` | `{"serial":"…"}` | 运行中/重试等待中拒绝清理 |
| `POST /api/device/clear_all_errors` | — | 跳过 running/connecting | | `POST /api/device/clear_all_errors` | — | 跳过 running/connecting |
| `POST /api/device/screen_all` | `{"mode":"on"\|"off", "serials":[…]}` | 不传 serials 则对全部在线设备 | | `POST /api/device/screen_all` | `{"mode":"on"\|"off", "serials":[…]}` | 不传 serials 则对全部在线设备。**熄屏是单向的**(`KEYCODE_SLEEP`),不会把已息屏的设备唤醒;亮屏用 `KEYCODE_WAKEUP` + `dismiss-keyguard` |
| `POST /api/scan_foreground` | — | 已扫描中时返回 `{"ok":false}`(**HTTP 200**) | | `POST /api/scan_foreground` | — | 已扫描中时返回 `{"ok":false}`(**HTTP 200**) |
| `POST /api/device/locate` | `{"serial":"…","show":true}` | `show=true` 时在设备上打开 `/locate` | | `POST /api/device/locate` | `{"serial":"…","show":true}` | `show=true` 时在设备上打开 `/locate` |
| `POST /api/device/locate/stop` | `{"serial":"…"}` | 结束定位 | | `POST /api/device/locate/stop` | `{"serial":"…"}` | 结束定位 |
@@ -599,7 +620,59 @@
| `POST /api/users` | Admin | `{"username","password","is_admin"?,"perms"?}` | 用户名/密码空、重名 → 400 | | `POST /api/users` | Admin | `{"username","password","is_admin"?,"perms"?}` | 用户名/密码空、重名 → 400 |
| `PUT /api/users/<int:uid>` | Admin | `{"password"?,"is_admin"?,"perms"?}` | 取消最后一个管理员 → 400;不存在 404 | | `PUT /api/users/<int:uid>` | Admin | `{"password"?,"is_admin"?,"perms"?}` | 取消最后一个管理员 → 400;不存在 404 |
| `DELETE /api/users/<int:uid>` | Admin | — | 删 `admin`/删自己/删最后一个管理员 → 400 | | `DELETE /api/users/<int:uid>` | Admin | — | 删 `admin`/删自己/删最后一个管理员 → 400 |
| `GET /api/logs?file=core.log&lines=300` | G | — | 返回日志尾部 + 可选文件清单 | | `GET /api/logs?file=core.log&lines=300` | G | `q` 关键字、`level` 最低级别、`since`/`until` 时间 | 见下 §10.1 |
| `GET /api/logs/download?file=core.log` | G | 同上(参数一致) | 以附件返回 `.txt`,文件名带时间戳 |
#### 10.1 日志查询(`GET /api/logs`)
参数:
| 参数 | 说明 |
|------|------|
| `file` | 日志文件名,**白名单校验**(只能取接口返回的 `files[].name`,含 `.1/.2` 滚动历史);非法值返回 `ok:false` |
| `lines` | 返回**最近**多少条命中(默认 300,上限 5000) |
| `q` | 关键字,大小写不敏感,匹配整行原文 |
| `level` | 最低级别:`ERROR` / `WARNING` / `INFO` / `DEBUG`(含以上);留空为全部 |
| `since` / `until` | 时间范围,接受 `YYYY-MM-DDTHH:MM`(datetime-local)或 `YYYY-MM-DD`(按整天) |
返回:
```json
{"ok": true, "file": "core.log",
"files": [{"name": "core.log", "label": "核心(adb/调度) · core.log",
"size": 1175910, "mtime": 1789519242, "rotated": false}],
"rows": [{"ts": "2026-09-16 08:26:50", "level": "INFO",
"module": "core.disc", "msg": "发现: …", "cont": false}],
"matched": 11423, "scanned": 11423, "truncated": true}
```
- `rows` 保持文件原顺序(旧→新),即"最近 N 条命中"按时间正序排列;
- `cont=true` 是**续行**(traceback 的缩进行等):它本身没有时间/级别前缀,
过滤时**继承上一条带前缀的行**,所以按 ERROR 筛选不会把堆栈拆散;
- `matched` 是命中总数,`truncated=true` 表示更早的命中没返回(应缩小时间范围或加关键字);
- `scanned` 是实际扫描行数(上限 50 万行)。
#### 10.2 任务步骤明细(`/api/step_logs*`)
数据源是 `task_step_log` 表(**结构化**,与上面的文本日志不是一回事):每一次步骤
执行一条,能按设备/任务/结果/时间过滤、能按运行归组、能导出 CSV。写入侧见
[DATA_MODEL.md](DATA_MODEL.md) §2.8 与 `core/step_log.py`。
| 接口 | 参数 | 返回 |
|------|------|------|
| `GET /api/step_logs` | `serial` `job_id` `result` `run_id` `q` `since` `until` `limit`(≤2000,默认 200) `offset` | `rows`(**最近的在前**)、`total`、`stats`(本次进程的 queued/written/dropped/failed)、`keep_days`、`max_rows_per_run` |
| `GET /api/step_logs/runs` | `serial` `job_id` `since` `until` `limit`(≤500) | `runs[]`:`run_id` `started_at` `ended_at` `steps` `failures` |
| `GET /api/step_logs/filters` | — | `devices[]` / `jobs[]`(**只列明细里真的出现过的**,不依赖设备池与任务表)、`results[]` |
| `GET /api/step_logs/download` | 同列表接口 | 附件 `.csv`(UTF-8 **带 BOM**),最多 2 万行,按时间正序 |
`result` 取值:`ok` / `miss`(handler 返回 False,如元素没找到)/ `error`(抛异常)/
`unknown`(未知步骤类型)/ `skip`(概率未触发)/ `cap`(本次运行已达记录上限)。
`run_id` 是"设备 × 任务 × 第几次尝试",同一行里能拿到 `step_path`(如 `2.1.3`)
还原嵌套结构——**步骤明细面板的「最近运行概览 → 查看」就是按它过滤**。
> 稳定性:记录走异步队列(`record()` 零阻塞),**队列满会丢弃**(计数在 `stats.dropped`,
> 页面提示栏会显示),所以明细允许缺条 —— 权威结论仍看任务状态与 [NOTIFY.md](NOTIFY.md) 的事件。
--- ---
+10 -3
View File
@@ -30,6 +30,8 @@
│ │
┌───────────────────────────▼──────────────────────────────────────────┐ ┌───────────────────────────▼──────────────────────────────────────────┐
│ 基础层 adb_helper · u2_helper · uiauto_helper · ocr · clipboard │ │ 基础层 adb_helper · u2_helper · uiauto_helper · ocr · clipboard │
│ notifier(通知分发:队列/聚合/限流/适配器) │
│ step_log(步骤明细:队列 + 批量落库 + 保留期清理) │
│ models(SQLite)· logger · config │ │ models(SQLite)· logger · config │
└──────────────────────────────────────────────────────────────────────┘ └──────────────────────────────────────────────────────────────────────┘
▲ ▲
@@ -77,15 +79,15 @@
|------|------|---------| |------|------|---------|
| **B** | `:28-58` | `Flask(__name__)`;会话密钥(`.env` 的 `WEB_SECRET_KEY`,缺失则随机生成并 warning);`TEMPLATES_AUTO_RELOAD=True`;**数据库目标由 `core/db_config` 装配**(`.env` 的 `DEPLOY_ENV`/`DB_*` → URI + 引擎参数),配置错直接 `SystemExit(2)`;`LoginManager` + `login_view="auth.login"` | | **B** | `:28-58` | `Flask(__name__)`;会话密钥(`.env` 的 `WEB_SECRET_KEY`,缺失则随机生成并 warning);`TEMPLATES_AUTO_RELOAD=True`;**数据库目标由 `core/db_config` 装配**(`.env` 的 `DEPLOY_ENV`/`DB_*` → URI + 引擎参数),配置错直接 `SystemExit(2)`;`LoginManager` + `login_view="auth.login"` |
| **C** | `:60-67` | **恢复任务消费** `consume_pending_restore()`。SQLite 时代它必须在 engine 首次打开 `users.db` **之前**(Windows 无法替换被持有的文件);改用 MySQL 后这一步的语义会变成"启动期事务替换",见 §7 | | **C** | `:60-67` | **恢复任务消费** `consume_pending_restore()`。SQLite 时代它必须在 engine 首次打开 `users.db` **之前**(Windows 无法替换被持有的文件);改用 MySQL 后这一步的语义会变成"启动期事务替换",见 §7 |
| **D** | `:69-80` | `init_db(app)`(建表 → 补列 → 版本账本 → 唯一索引 → 默认管理员 → 旧 JSON 迁移)→ **库环境标签校验 + 启动横幅**(`db_config.verify_deployment_label/print_banner`,不符拒绝启动);`device_pool.init_app`(**起一次性线程**,3s 后采集型号);`device_discovery.init_app`(**起常驻扫描线程**);`TaskManager(app=app)`(APScheduler + 看门狗 + 从库加载分组/任务 + 重注册 cron);`ApkManager(app=app)` | | **D** | `:69-88` | `init_db(app)`(建表 → 补列 → 版本账本 → 唯一索引 → 默认管理员 → 旧 JSON 迁移)→ `notifier.init_app`(通知 dispatcher/sender 线程)与 `step_log.init_app`(步骤明细写线程)→ **恢复任务消费** `consume_pending_restore()` → **库环境标签校验 + 启动横幅**(`db_config.verify_deployment_label/print_banner`,不符拒绝启动);`device_pool.init_app`(**起一次性线程**,3s 后采集型号);`device_discovery.init_app`(**起常驻扫描线程**);`TaskManager(app=app)`(APScheduler + 看门狗 + 从库加载分组/任务 + 重注册 cron);`ApkManager(app=app)` |
### 2.3 阶段 E~G:蓝图、巡检调度器、真正启动 ### 2.3 阶段 E~G:蓝图、巡检调度器、真正启动
| 阶段 | 位置 | 做了什么 | | 阶段 | 位置 | 做了什么 |
|------|------|---------| |------|------|---------|
| **E** | `:64-67` | `context.init(...)`;`register_blueprints(app)`(10 个蓝图);`agent_api.set_app(app)`(供后台线程推 app context) | | **E** | `:64-67` | `context.init(...)`;`register_blueprints(app)`(10 个蓝图);`agent_api.set_app(app)`(供后台线程推 app context) |
| **F** | `:71-80` | **第二个独立 APScheduler**:`CronTrigger(hour=3, minute=47)` 挂经验库巡检;失败仅 warning | | **F** | 同上附近 | **第二个独立 APScheduler**:`CronTrigger(hour=3, minute=47)` 挂经验库巡检、`hour=4, minute=13` 挂步骤明细清理(`_purge_step_log`,自建 app context);失败仅 warning |
| **G** | `:253-265`(`__main__`) | `_ensure_uiauto_running()`(拉起 uiautodev:20242,写 `data/uiauto.pid`,`atexit` 清理)→ `_preconnect_pool_devices()`(后台并发 connect 池内网络设备)→ `_run_server()`(候选端口依次 bind:`0.0.0.0:18050` → `127.0.0.1:18050` → `127.0.0.1:18051..18055`);退出时 `mgr.shutdown()` + `device_discovery.shutdown()` + 停 uiautodev | | **G** | `__main__` | `_ensure_uiauto_running()`(拉起 uiautodev:20242,写 `data/uiauto.pid`,`atexit` 清理)→ `_preconnect_pool_devices()`(后台并发 connect 池内网络设备)→ `_purge_step_log_async()`(后台清理超期步骤明细)→ `_run_server()`(候选端口依次 bind:`0.0.0.0:18050` → `127.0.0.1:18050` → `127.0.0.1:18051..18055`);退出时 `notifier.shutdown()` + `step_log.shutdown()` + `mgr.shutdown()` + `device_discovery.shutdown()` + 停 uiautodev |
> ⚠️ **阶段 A~F 在 import 期就会起线程/调度器**,只有 uiautodev 拉起与预连接在 `__main__` 分支。以 WSGI 方式 import 本模块会得到"半个启动"的进程——本地调试请直接 `python web_server.py`。 > ⚠️ **阶段 A~F 在 import 期就会起线程/调度器**,只有 uiautodev 拉起与预连接在 `__main__` 分支。以 WSGI 方式 import 本模块会得到"半个启动"的进程——本地调试请直接 `python web_server.py`。
@@ -109,6 +111,9 @@
| `apk-install` | `ApkManager.install` | 并发 5 台安装 APK | 安装期,全局单任务 | | `apk-install` | `ApkManager.install` | 并发 5 台安装 APK | 安装期,全局单任务 |
| Agent 执行线程 | `web/agent_api.py` | AI 控制台一轮会话 | 按需 | | Agent 执行线程 | `web/agent_api.py` | AI 控制台一轮会话 | 按需 |
| 巡检手动线程 | `web/agent_api.py` | 手动触发巡检 | 按需 | | 巡检手动线程 | `web/agent_api.py` | 手动触发巡检 | 按需 |
| **通知 dispatcher** | `core/notifier.init_app` | 通知聚合 + 每 hook 限流 + 折叠摘要 | 常驻 1 个 |
| **通知 sender ×3** | 同上 | 真实发 webhook(退避重试、环形记录) | 常驻 3 个 |
| **步骤明细写线程** | `core/step_log.init_app` | 批量落库 `task_step_log`(队列满丢弃并计数) | 常驻 1 个 |
两个 APScheduler 相互独立,时区均固定 `Asia/Shanghai`。 两个 APScheduler 相互独立,时区均固定 `Asia/Shanghai`。
@@ -391,5 +396,7 @@ connecting ──获取设备──▶ u2 连接 ──▶ running ──▶ set
| 加一张表 | `core/models.py` 模型 + `SCHEMA_MIGRATIONS`(老库) | **登记进备份覆盖清单** + [DATA_MODEL.md](DATA_MODEL.md)(红线) | | 加一张表 | `core/models.py` 模型 + `SCHEMA_MIGRATIONS`(老库) | **登记进备份覆盖清单** + [DATA_MODEL.md](DATA_MODEL.md)(红线) |
| 加一个常驻线程 | 参考 `device_discovery` 的 `init_app` / `shutdown` 模式 | 更新本文 §3 与 [DEVELOPMENT.md](DEVELOPMENT.md) | | 加一个常驻线程 | 参考 `device_discovery` 的 `init_app` / `shutdown` 模式 | 更新本文 §3 与 [DEVELOPMENT.md](DEVELOPMENT.md) |
| 加一个前端模块 | `static/admin/<name>.js` + 在 `monitor.html` 按序引入 | 更新本文 §6 与 [DEVELOPMENT.md](DEVELOPMENT.md) | | 加一个前端模块 | `static/admin/<name>.js` + 在 `monitor.html` 按序引入 | 更新本文 §6 与 [DEVELOPMENT.md](DEVELOPMENT.md) |
| **加一个通知事件** | `core/notify_events.py` 的 `EVENTS` 加一条 + 触发点调 `notifier.notify(key, **fields)` | 事件目录要做全、默认不启用;**notify 必须放在锁外**(见 [NOTIFY.md](NOTIFY.md) §7) |
| 加一种通知格式 | `core/notifier.py` 的 `ADAPTERS` 注册一个 `BaseAdapter` 子类 | 补字节上限/默认限流;更新 [NOTIFY.md](NOTIFY.md) §5 |
| 加一个 MCP 工具 | `mcp_server/mcp_server.py`(需要时加 `direct_ops.py`) | 写操作要挂门控三连;更新 [MCP.md](MCP.md) | | 加一个 MCP 工具 | `mcp_server/mcp_server.py`(需要时加 `direct_ops.py`) | 写操作要挂门控三连;更新 [MCP.md](MCP.md) |
| 加一个配置键 | `config.py`(程序级)或 `.env`(密钥类) | 更新 `.env.example` + [DEVELOPMENT.md](DEVELOPMENT.md) 配置速查 | | 加一个配置键 | `config.py`(程序级)或 `.env`(密钥类) | 更新 `.env.example` + [DEVELOPMENT.md](DEVELOPMENT.md) 配置速查 |
+41 -1
View File
@@ -27,7 +27,7 @@
启动时校验「`.env` 声明」与「库名」「库中登记的 `app_meta.deployment_env`」三方一致,不符**拒绝启动**。 启动时校验「`.env` 声明」与「库名」「库中登记的 `app_meta.deployment_env`」三方一致,不符**拒绝启动**。
**表清单(12 张,全部是 `core/models.py` 里的 ORM 模型)** **表清单(14 张,全部是 `core/models.py` 里的 ORM 模型)**
| # | 表 | 用途 | | # | 表 | 用途 |
|---|---|------| |---|---|------|
@@ -44,6 +44,7 @@
| 11 | `experience_audit` | 经验巡检结论 | | 11 | `experience_audit` | 经验巡检结论 |
| 12 | `agent_action` | 动作库(命名动作) | | 12 | `agent_action` | 动作库(命名动作) |
| 13 | `device_install_log` | 设备端应用商店的下载/安装记录(设备上报,见 [DEVICE_AGENT.md](DEVICE_AGENT.md)) | | 13 | `device_install_log` | 设备端应用商店的下载/安装记录(设备上报,见 [DEVICE_AGENT.md](DEVICE_AGENT.md)) |
| 14 | `task_step_log` | 任务步骤明细(每次步骤执行一条,见 §2.8;**唯一有无界增长风险的表**,靠保留期清理) |
> 2026-09-13 之前,`app_meta` 与 4 张 `agent_*` 表是各模块里的裸 `CREATE TABLE` > 2026-09-13 之前,`app_meta` 与 4 张 `agent_*` 表是各模块里的裸 `CREATE TABLE`
> (不进模型层)。迁 MySQL 时那批 SQL 的 `AUTOINCREMENT`/`TEXT DEFAULT ''`/`TEXT PRIMARY KEY` > (不进模型层)。迁 MySQL 时那批 SQL 的 `AUTOINCREMENT`/`TEXT DEFAULT ''`/`TEXT PRIMARY KEY`
@@ -136,6 +137,41 @@
> ⚠️ SQLAlchemy 模型的 `default=` 是 **Python 侧默认值**,SQLite 建表语句里没有 `DEFAULT` 子句;只有原生建表的表才有真正的 SQL DEFAULT。 > ⚠️ SQLAlchemy 模型的 `default=` 是 **Python 侧默认值**,SQLite 建表语句里没有 `DEFAULT` 子句;只有原生建表的表才有真正的 SQL DEFAULT。
### 2.8 `task_step_log` — 任务步骤明细
每一次步骤执行一条(`tasks/generic/task.py:_exec_one` 里记录),是「日志 → 步骤明细」
页的数据源。与 `logs/task.log` 的分工:那边是**排障原文**(什么都写、10MB 滚动),
这边是**结构化的一份**——设备/任务/步骤/结果/耗时都是列,能过滤、能统计、能导出 CSV。
| 列 | 类型 | 默认 | 说明 |
|----|------|------|------|
| `id` | Integer | — | 主键 |
| `run_id` | String(24) | `""` | 一次运行 = 设备 × 任务 × 第几次尝试;`TaskManager._run_with_retry` 每次尝试生成一个(12 位 hex),把这次尝试的所有步骤串起来 |
| `job_id` / `job_name` | String(32/120) | `""` | 任务快照(任务删了明细还在,名字仍可读) |
| `serial` / `device_name` | String(120/80) | `""` | 设备地址与**当时**的名字(快照,改名不影响历史) |
| `step_path` | String(32) | `""` | 嵌套位置,如 `2.1.3`;容器步骤(loop/group/if_el)会记自己那条,children 追加一级 |
| `step_label` / `step_type` | String(120/40) | `""` | 步骤标签与类型(`click_el`/`loop`/`wait`…) |
| `selector` | String(300) | `""` | 元素选择器(长选择器截断) |
| `result` | String(16) | `""` | `ok` / `miss`(handler 返回 False)/ `error`(抛异常)/ `unknown`(未知步骤类型)/ `skip`(概率未触发)/ `cap`(本次运行已达上限) |
| `detail` | String(500) | `""` | 异常消息、跳过原因等 |
| `duration_ms` | Integer | `0` | 本步耗时(慢步骤一眼可辨) |
| `created_at` | String(20) | `""` | 执行时刻(**保留期按它算**) |
索引:`run_id`、`created_at`、`(serial, created_at)`、`(job_id, created_at)`。
**两条硬边界**(都在 `core/step_log.py`):
| 常量 | 默认 | 作用 |
|---|---|---|
| `MAX_ROWS_PER_RUN` | 2000 | 单次运行最多记 2000 条,超出只补一条 `cap` 说明行。**没有它,`forever` 循环任务会瞬间写爆这张表** |
| `KEEP_DAYS` | 14 | 保留期:每天 04:13(+ 每次启动)清理更早的记录 |
> ⚠️ **这张表是唯一有无界增长风险的表**,而它会**自动进整库备份**(§6 的派生规则),
> 所以 `KEEP_DAYS` 直接决定备份包体积。调大之前先想清楚导出的 zip 会有多大。
写入走 `core/step_log.py` 的**专用写线程 + 有界队列**(任务线程只 `put_nowait`,
微秒级;队列满丢弃并计数)——步骤执行是热路径,绝不能在任务线程里同步写库。
--- ---
## 3. 非模型表 ## 3. 非模型表
@@ -240,8 +276,12 @@ UTF-8 等价于字节序)。
| `deployment_env` | **库环境标签**(`dev`/`prod`),启动时与 `.env` 比对 | `core/db_config.py`(首次连接)/ 迁移脚本 | | `deployment_env` | **库环境标签**(`dev`/`prod`),启动时与 `.env` 比对 | `core/db_config.py`(首次连接)/ 迁移脚本 |
| `deployment_id` | 库唯一标识(uuid),用于识别"这份备份来自哪个库" | 同上 | | `deployment_id` | 库唯一标识(uuid),用于识别"这份备份来自哪个库" | 同上 |
| `deployment_claimed_at` | 标签写入时间 | 同上 | | `deployment_claimed_at` | 标签写入时间 | 同上 |
| `notify_webhooks` | **通知 / Webhook 全部配置**(JSON:`{version, settings, webhooks[]}`,见 [NOTIFY.md](NOTIFY.md) §4)。**不建表**——新增/删除 webhook 都只改这一个键 | 系统 → 通知 页 |
> ⚠️ `agent_api_key` 是**明文存储**,导出备份的 zip 里也含它——备份预览会固定给出"含敏感信息"告警。 > ⚠️ `agent_api_key` 是**明文存储**,导出备份的 zip 里也含它——备份预览会固定给出"含敏感信息"告警。
> **同理 `notify_webhooks` 里的 webhook URL 本身就是凭据**(企业微信 `?key=`、钉钉 `?access_token=`、
> 飞书 `/hook/<token>`):拿到它就能往群里发消息。接口回显/发送记录/日志一律走
> `notifier.mask_url()/scrub()` 打码,导出备份时也按敏感信息对待。
> `app_meta` 的列名 `key` 在 MySQL 里是保留字,**不要直接拼裸 SQL**,统一走 > `app_meta` 的列名 `key` 在 MySQL 里是保留字,**不要直接拼裸 SQL**,统一走
> `core/db_config.meta_get / meta_set`(方言中立、自动加引号)。 > `core/db_config.meta_get / meta_set`(方言中立、自动加引号)。
+7 -2
View File
@@ -212,9 +212,14 @@ tail -20 logs/web.log # 无 ERROR/Traceback
SUMMARY_TABLES = tuple(sorted(t.name for t in db.metadata.tables.values())) SUMMARY_TABLES = tuple(sorted(t.name for t in db.metadata.tables.values()))
``` ```
当前 12 张表:`app_meta` / `user` / `device_group` / `task_job` / `custom_action` / 当前 14 张表:`app_meta` / `user` / `device_group` / `task_job` / `custom_action` /
`apk_file` / `device` / `pending_device` / `agent_conversation` / `agent_experience` / `apk_file` / `device` / `pending_device` / `agent_conversation` / `agent_experience` /
`experience_audit` / `agent_action`。完整说明见 [DATA_MODEL.md](DATA_MODEL.md) §6。 `experience_audit` / `agent_action` / `device_install_log` / `task_step_log`。
完整说明见 [DATA_MODEL.md](DATA_MODEL.md) §6。
> ⚠️ `task_step_log`(任务步骤明细)是**唯一会持续增长**的表:它按 `KEEP_DAYS`
> (默认 14 天,见 `core/step_log.py`)自动清理,但备份包里会带上保留期内的全部行。
> 设备多、任务密时导出 zip 会明显变大——需要更小的包就调小那个常量。
> **新增一张 ORM 表,就自动进了覆盖清单**,不可能再漏(2026-09-10 动作库 `agent_action` > **新增一张 ORM 表,就自动进了覆盖清单**,不可能再漏(2026-09-10 动作库 `agent_action`
> 曾因手工维护漏登记,数据其实在快照里,只是清单没列 → 被误判为"没有备份")。 > 曾因手工维护漏登记,数据其实在快照里,只是清单没列 → 被误判为"没有备份")。
+14 -3
View File
@@ -35,6 +35,16 @@
> 每个改动**单独分支 + 单独 commit**,主题单一,便于评审与回退。不允许"开发完顺手 commit/push"。 > 每个改动**单独分支 + 单独 commit**,主题单一,便于评审与回退。不允许"开发完顺手 commit/push"。
**修复也走这条流程,包括线上紧急修复**(2026-09-14 补:当天两次 APK 安装修复因为"生产卡着"
直接提交在 dev 上、紧接着就合 main 部署,被负责人纠正):
- 紧急修复照样从 dev 切 `fix/xxx` 分支——**不允许直接 commit/push 到 dev**,
哪怕改动只有几行、哪怕生产正卡着
- 第 ② 步的"自测"要**跑通本机服务**(起服务/点页面/看日志),不能只有脚本级验证
- 第 ③ 步的确认要点名两件事:**「是否合 dev」**和**「是否合 main + 部署生产」**——
紧急场景可以一次性问清,但**不能把两个节点合并成一次默认同意**
- 线上正卡住时:先**恢复**(重启/回滚等运维动作,经确认即可执行),再按流程走修复
--- ---
## 2. 技术红线(违反会打断共享 adb transport 或造成生产事故) ## 2. 技术红线(违反会打断共享 adb transport 或造成生产事故)
@@ -46,7 +56,7 @@
| 3 | **空闲设备扫描不主动 connect/disconnect** | 避免扰动共享连接 | 前台扫描对空闲设备直接返回"空闲";设备发现用 socket 探测 | | 3 | **空闲设备扫描不主动 connect/disconnect** | 避免扰动共享连接 | 前台扫描对空闲设备直接返回"空闲";设备发现用 socket 探测 |
| 4 | **adb key 保持历史 key 不变** | 设备信任该 key,换 key 全部 `unauthorized` | 部署沿用 `~/.android/adbkey` | | 4 | **adb key 保持历史 key 不变** | 设备信任该 key,换 key 全部 `unauthorized` | 部署沿用 `~/.android/adbkey` |
| 5 | **生产(220)默认只读** | 生产事故成本高 | 任何写操作(pull/重启/改文件)都需负责人确认 | | 5 | **生产(220)默认只读** | 生产事故成本高 | 任何写操作(pull/重启/改文件)都需负责人确认 |
| 6 | **新增持久化表必须登记备份覆盖清单** | 漏登记 = 等于没备份 | `core/system_backup.py` 的 `SUMMARY_TABLES` + `TABLE_LABELS`,详见 [DEPLOY.md](DEPLOY.md) §5.2 | | 6 | **新增持久化表必须进备份覆盖清单** | 漏登记 = 等于没备份 | `SUMMARY_TABLES` 已由模型元数据自动派生(加了 ORM 表就进清单);**手工要做的只有补 `TABLE_LABELS` 中文标签**,详见 [DEPLOY.md](DEPLOY.md) §5.2 |
| 7 | **功能/配置/接口改动必须同步文档** | 文档落后会误导开发与运维 | 见 §6;索引 [doc/README.md](README.md) | | 7 | **功能/配置/接口改动必须同步文档** | 文档落后会误导开发与运维 | 见 §6;索引 [doc/README.md](README.md) |
### 其它开发约束 ### 其它开发约束
@@ -174,9 +184,10 @@ MCP_ALLOW_WRITE=1 MCP_PLATFORM_USER=admin MCP_PLATFORM_PASS=<密码> \
### 5.2 新增数据库字段/表 ### 5.2 新增数据库字段/表
- 模型改 `core/models.py`;新表 `create_all()` 会建 - 模型改 `core/models.py`;新表 `create_all()` 会建(**幂等**:已是模型就会自动建/补列)
- **老库**要在 `SCHEMA_MIGRATIONS` 里加迁移(版本号递增 + SQL) - **老库**要在 `SCHEMA_MIGRATIONS` 里加迁移(版本号递增 + SQL)
- **新增表**:登记进 `core/system_backup.py` 的 `SUMMARY_TABLES` + `TABLE_LABELS`(**红线**) - **新增表**:`SUMMARY_TABLES` 由模型元数据**自动派生**(=自动进备份覆盖清单),
仍需手工做的是补 `TABLE_LABELS` 的中文标签(**红线**)
- 更新 [DATA_MODEL.md](DATA_MODEL.md) 与 [DEPLOY.md](DEPLOY.md) §5.2 - 更新 [DATA_MODEL.md](DATA_MODEL.md) 与 [DEPLOY.md](DEPLOY.md) §5.2
### 5.3 修改前端 ### 5.3 修改前端
+230
View File
@@ -0,0 +1,230 @@
# 通知 / Webhook(NOTIFY)
> 面向:要给平台接告警的运维、以及往各组件加通知点的开发。
> 相关:[API.md](API.md)(接口)、[DATA_MODEL.md](DATA_MODEL.md)(配置存哪)、
> [ARCHITECTURE.md](ARCHITECTURE.md)(线程模型)。
---
## 1. 它是什么
平台各组件(任务、设备、安装、备份、AI 巡检…)在关键时刻调一个统一入口:
```python
from core import notifier
notifier.notify("task.device.failed", serial="192.168.20.71:5555",
device_name="A02", job_name="抖音养号", cause="选择器连续 10 次未命中")
```
`notify()` **只做内存操作**(读配置快照 → 匹配订阅 → 丢进队列),真正发 HTTP 的是后台
daemon 线程。所以:
- 任务线程里可以直接调,**不用包 app_context、不用 try/except**(内部全兜住了)
- **但必须放在所有 `with self._lock` 之外**——别让通知拖住调度锁
- 通知模块自己出问题(地址写错、对方挂了、配置坏了)**绝不影响任务/设备/备份**,
最多在日志里留一条警告
```
业务线程 notify() ──入队──▶ dispatcher(1 线程)──▶ sender ×3 ──▶ 企业微信/自建服务
① 聚合 ② 限流 ③ 折叠
└─▶ 发送记录(内存 200 条 + logs/notify.log)
```
---
## 2. 快速上手(企业微信)
1. 企业微信群里 → 群机器人 → 添加 → 复制 Webhook 地址(形如
`https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxxx`)
2. 后台 → **系统 → 通知 / Webhook → + 新建 Webhook**:名称随便起、格式选「企业微信」、
粘贴地址、在事件树里勾上关心的事件(★ 是建议开的)
3. 点该行的 **发送测试** —— 群里立刻能收到一条 markdown;收不到就看返回的 HTTP/errcode
---
## 3. 事件目录
**权威定义在 `core/notify_events.py`**(后台「通知」页也是从它渲染的,不会漂移)。
所有事件**默认不启用**——登记不等于推送,勾了才发。
| 类别 | 事件 |
|---|---|
| 任务批次 | `task.batch.started` · `.finished` · `.no_device` · `.unknown_type` · `task.cron.stopped` |
| 任务·单设备 | `task.device.success` · `.failed` · `.offline` · `.error` · `.retry` · `.stopped` · `.preempted` · `.preempt_timeout` · `.released` |
| 业务 | `task.selector.invalid`(选择器连续 10 次未命中——"任务成功但什么都没做"的隐蔽故障) |
| Worker | `worker.connected` · `.attempt.done` · `.attempt.error`(单次尝试级,噪音大,默认没人勾) |
| 设备 | `device.online` · `.offline` · `.discovered` · `.claimed` · `device.heartbeat_timeout` |
| 安装 | `apk.install.started` · `.finished` |
| 系统 | `system.backup.exported` · `.imported` · `.restored` · `.restore_failed` · `service.started` · `.stopping` · `user.login` |
| AI | `ai.audit.finished` · `.failed` · `.skipped` |
| 其它 | `notify.test`(测试按钮专用) |
订阅支持通配:`*`(全部)、`task.*`、`device.*`。
> 语义分工(**避免重复告警**):`worker.*` 是**单次尝试**层面;`task.device.success/failed`
> 是"这台设备最终成功/失败"的**唯一权威点**。所以一个设备重试 3 次后失败,只会收到 **1 条**
> `task.device.failed`,不会收到 3 条噪音。
**多设备批次怎么发**(设备一多,逐台发就等于刷屏):
| 事件 | 多设备批次(>1 台) | 单台任务 |
|---|---|---|
| `task.batch.started` | 发 1 条(N 台) | 发 |
| `task.device.success` | **不发**(批次汇总里已有成功台数) | 发 |
| `task.device.failed` / `.offline` | 逐台发(失败是少数,且要知道是哪台) | 发 |
| `task.batch.finished` | **发 1 条汇总** | 发 |
批次汇总长这样(有失败时才会多出一行「失败设备」,列出名字(型号):原因,最多 5 条):
```
### ✅ 任务批次结束:抖音养号
> **任务ID**:b370bbbc
> **总数**:13
> **成功**:13
> **失败**:0
> **停止**:0
> **跳过**:0
> **耗时(秒)**:1347
> **时间**:2026-09-16 09:22:57
```
单设备事件里的设备一律**显示名字**(`cs1`)而不是地址;只有设备没命名时才退回 IP。
`型号` 取自设备池的快照(后台统一采集的那份),比 worker 每次连接时现采的稳。
---
## 4. 配置
存在 `app_meta.notify_webhooks` 一个键里(JSON,**不建表**,因此不涉及备份覆盖清单):
```json
{
"version": 1,
"settings": {"global_enabled": true, "default_agg_window": 30,
"default_rate_limit": 18, "log_keep": 200, "http_timeout": 5},
"webhooks": [
{"id": "wh_ab12cd34", "name": "运维群", "enabled": true, "format": "wecom",
"url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=…",
"secret": "", "events": ["task.device.failed", "device.*"],
"agg_window": 30, "rate_limit_per_min": 18,
"title_template": "", "body_template": "", "headers": {}}
]
}
```
上限(超了**拒绝保存**,不静默截断):webhook ≤ 20 条、整体 JSON ≤ 60000 字符、URL ≤ 2048。
**安全**:URL 里带凭据(企微 `?key=`、钉钉 `?access_token=`、飞书 `/hook/<token>`、
Bark `/…/<key>`、Slack `/services/T…/B…/X…`)→ 接口回显、发送记录、日志一律打码
(`mask_url` 同时处理 query 与**路径里的 token**;异常消息过 `scrub`);
编辑时**留空即不修改**;`secret` 永不回显(只回"已配置")。
---
## 5. 推送格式
| 格式 | 请求体 | URL 怎么填 | 成功判定 / 关键约束 |
|---|---|---|---|
| `wecom` 企业微信 | `{"msgtype":"markdown","markdown":{"content":"…"}}` | 群机器人 → 复制的 Webhook 地址(含 `?key=`) | **`errcode==0`**;content **≤4096 字节**(按字节截断、不会截出半个汉字);**每机器人每分钟 20 条**,超限 `45009` |
| `dingtalk` 钉钉 | `{"msgtype":"markdown","markdown":{"title","text"}}` | 群设置 → 智能群助手 → 添加机器人 → 自定义 → 复制的 Webhook 地址(含 `?access_token=`) | **`errcode==0`**;正文 ≤20000 字节;官方 20 条/分,**超限会被限流 10 分钟**(这里默认 15 留余量);机器人安全设置选「加签」时密钥必填 |
| `feishu` 飞书 | `{"msg_type":"interactive","card":{header + markdown 元素[,"timestamp","sign"]}}` | 群设置 → 群机器人 → 添加 → 自定义机器人 → 复制的 Webhook 地址(`…/bot/v2/hook/<token>`) | **`code==0`**;请求体 ≤20KB;官方 100 条/分(这里默认 60);开了「签名校验」时密钥必填 |
| `bark` iOS 推送 | `{"title","body","markdown","group","level"[,"device_key"]}` | Bark App 里复制的那串(`https://api.day.app/<key>`)**或** `https://api.day.app/push` + 设备 Key 填到「设备 Key」 | **`code==200`**(注意和企业微信不一样!);走 APNs,正文按 2048 字节截断;`markdown` 传富文本、`body` 传纯文本兜底 |
| `json` 通用 / Slack | 由 `body_template` 决定 | 你自己的接收端;Slack 填它的 Incoming Webhook URL | HTTP 200(响应体里有 `code`/`errcode` 时必须为 0);模板保存前**干跑校验**;`secret` 会作为 `X-Webhook-Secret` 头发出 |
> 成功码**按格式**判定(企微/钉钉/飞书 = 0,Bark = 200),写在适配器的 `ok_codes` 上——
> 加新格式时别忘了一起定。
### 加签:钉钉和飞书**算法不一样**,别互相照抄
| | 钉钉 | 飞书 |
|---|---|---|
| key(HMAC 的密钥) | `secret` | `"{timestamp}\n{secret}"` |
| message(被签内容) | `"{timestamp}\n{secret}"` | **空** |
| timestamp 单位 | **毫秒** | **秒** |
| 拼在哪 | **URL query**(`&timestamp=…&sign=…`) | **JSON body** 的 `timestamp`/`sign` |
| 结果编码 | Base64 后再 **urlencode** | Base64 |
| 出错码 | `errcode 310000 invalid signature` | `code 19021 sign match fail` |
实现见 `core/notifier.py` 的 `DingtalkAdapter.sign_request` / `FeishuAdapter.sign_request`;
两家的算法都用官方示例代码对拍过(钉钉逐字节一致,含 urlencode)。
**字段一律渲染成 `> **字段**:值` 引用行,不用 Markdown 表格**——企业微信/钉钉的 markdown
子集不支持表格,表格会原样吐出来。
通用 JSON 的占位符:`{{event}} {{title}} {{summary}} {{ts}} {{level}} {{markdown}}
{{fields}} {{fields_json}} {{hook_name}} {{field.<字段名>}}`。
替换值按 JSON 字符串转义,所以标题里带引号/换行也不会打坏请求体。Slack 直接写
`{"text":"{{markdown}}"}` 就行。
**格式相关的提示文案(URL 示例/说明、密钥叫什么、限流上限)都挂在适配器上**
(`BaseAdapter.url_hint/url_help/secret_label/secret_help/limit_help`),界面按当前格式渲染、
切格式即时更新——新增格式时把这些一起填上,别把某个平台的说明写死在页面上。
**再加新格式**:写一个 `BaseAdapter` 子类(`render` + 可选 `sign_request`)并把元信息
(`byte_limit`/`ok_codes`/`limit_default`/`url_hint`/`url_help`/`secret_*`/`limit_help`)填全,
然后注册进 `ADAPTERS` —— 界面上的下拉项与提示文案会自动跟着出来,**不用改前端**。
`PLANNED_FORMATS` 是"还没实现、下拉里置灰"的占位表(目前为空)。
---
## 6. 防打爆(四层)
100 台设备同时失败 = 100 条消息,群里会被刷到静音——**刷屏会让通知彻底失效**。所以:
| 层 | 机制 | 默认 |
|---|---|---|
| L1 聚合 | 同 webhook、同事件、同聚合键(如 `job_id`)在一个窗口内合并成一条,保留前 3 个样本 | 30s(**事件声明 `agg_window=0` 的一律立即发**,如批次结束 / 服务启停 / 备份恢复) |
| L2 限流 | 每 webhook 一个令牌桶 | 18 条/分(企业微信硬限 20,留余量) |
| L3 折叠 | 被限流的事件**不丢弃**,压成一条「被限流折叠 N 条」摘要 | 最多 60s 一条 |
| L4 背压 | 有界队列(event 2000 / send 1000),满了丢弃并计数 | 溢出会告警一次 |
取舍写明白:**失败通知最多延迟一个聚合窗口(默认 30s)**,换来群不被刷屏。
窗口口径(代码在 `core/notifier.py` 的 `notify()`):
- 事件自己写了 `agg_window=0` → **立即发**,不受 webhook 的窗口影响;
- 其余事件 → 用该 webhook 配置的 `agg_window`(它表示"这个群最多等多久合并")。
「样本」行只在**真的合并了多条**(>1)时出现,且按**设备**维度写(`cs1 · 超时`)——
合并多台设备时写任务名每条都一样,等于没写。
> ⚠️ 保存通知配置(`save_config`)会清空**待发聚合**与限流令牌桶。清理 hook 时
> 顺手丢掉的正是还没到窗口的聚合事件——排障时别把它当成"没发"。
---
## 7. 开发:给新功能加通知
1. 在 `core/notify_events.py` 的 `EVENTS` 里加一条 `_e("模块.对象.动作", "中文标签",
"分类", ["字段1", "字段2"], "什么时候发", agg_window=…, recommend=…)`
2. 在触发点调 `notifier.notify("模块.对象.动作", 字段1=…, 字段2=…)`
—— **放在所有 `with self._lock` 之外**,且不要改变原有 `return` 的顺序
3. 把事件补进本文档 §3 的表格
约定:
- `notify()` **不阻塞、不抛异常、不碰 DB**——这是硬约束,别在它里面加 HTTP 或查库
- 事件的 `fields` 是前端字段表与模板占位符的白名单,只写真正有用的
- 高频事件(每台设备/每次尝试都会发生的)把 `agg_window` 设大一点或 `recommend=False`
---
## 8. 排障
| 现象 | 看哪里 |
|---|---|
| 完全没收到 | ① 全局开关是不是关了(列表页「启用通知」)② 该 webhook 是否启用 ③ 事件勾了没(`task.device.failed` 是**单设备最终失败**,不是每次尝试) |
| 收到但内容不全 | 消息被 4096 字节截断了(末尾有「…(已截断)」);把 `cause` 之类长字段在事件侧截短 |
| 只在群里看到「被限流折叠」 | 短时间内同类事件太多,触发了 L2/L3;调大该 webhook 的限流值(企微上限 20)或调大聚合窗口 |
| 失败原因 | 后台「通知」页的**发送记录**(内存,重启清空)或 `logs/notify.log`(完整历史)。`errcode 45009`=企微限流、`93000`=Webhook 地址无效、`HTTP 200 + errcode≠0` **也算失败** |
| 配置坏了 | 服务照常启动(启动日志有 error),通知静默不发;在后台删掉坏配置或直接改 `app_meta.notify_webhooks` |
---
## 9. 已知限制
- 发送记录在**内存**(最近 200 条,重启清空);持久历史只有 `logs/notify.log` 文本
- **不支持自定义 webhook 请求头**(`headers` 字段留着但界面没暴露)——需要的话说一声
- 只做 http/https,**不做内网 IP 黑名单**(内网自建 webhook 是合法用法),但禁止重定向
(`allow_redirects=False`)
- `system.backup.restored` 是**重启后**才发(恢复本身就是重启生效的)
+1
View File
@@ -18,6 +18,7 @@
| [MCP_DESIGN.md](MCP_DESIGN.md) | **MCP 设计文档**:边界划分、错误码、白名单/审计设计、演进方向 | 平台开发者 | | [MCP_DESIGN.md](MCP_DESIGN.md) | **MCP 设计文档**:边界划分、错误码、白名单/审计设计、演进方向 | 平台开发者 |
| [AI_CONSOLE.md](AI_CONSOLE.md) | **AI 控制台**:会话/SSE、经验库、动作库、巡检、Markdown 渲染、推理链、token 统计 | 使用者、平台开发者 | | [AI_CONSOLE.md](AI_CONSOLE.md) | **AI 控制台**:会话/SSE、经验库、动作库、巡检、Markdown 渲染、推理链、token 统计 | 使用者、平台开发者 |
| [AI_TASK_GEN.md](AI_TASK_GEN.md) | **AI 建任务**:AI 自己在真机探索 → 写出可调度任务 → 人工确认入库(P0 已实现;契约与红线) | 平台开发者、使用者 | | [AI_TASK_GEN.md](AI_TASK_GEN.md) | **AI 建任务**:AI 自己在真机探索 → 写出可调度任务 → 人工确认入库(P0 已实现;契约与红线) | 平台开发者、使用者 |
| [NOTIFY.md](NOTIFY.md) | **通知 / Webhook**:事件目录、企业微信/通用 JSON 适配、防刷屏(聚合/限流/折叠)、排障、怎么加事件 | 运维、平台开发者 |
| [DEVICE_AGENT.md](DEVICE_AGENT.md) | **设备端 Agent 接口契约**:应用商店的设备专用接口(清单/下载/上报)、adb 指令协议、版本约定 —— 与设备端 APK 仓库共享的契约 | 设备端开发者、平台开发者 | | [DEVICE_AGENT.md](DEVICE_AGENT.md) | **设备端 Agent 接口契约**:应用商店的设备专用接口(清单/下载/上报)、adb 指令协议、版本约定 —— 与设备端 APK 仓库共享的契约 | 设备端开发者、平台开发者 |
| [DEPLOY.md](DEPLOY.md) | **部署与运维**:环境准备、生产容器、数据备份导出/导入、故障排查 | 运维、部署者 | | [DEPLOY.md](DEPLOY.md) | **部署与运维**:环境准备、生产容器、数据备份导出/导入、故障排查 | 运维、部署者 |
| [DEVELOPMENT.md](DEVELOPMENT.md) | **开发手册**:git 流程、技术红线、本地开发与调试、常见开发任务、文档同步约定 | 所有开发者 | | [DEVELOPMENT.md](DEVELOPMENT.md) | **开发手册**:git 流程、技术红线、本地开发与调试、常见开发任务、文档同步约定 | 所有开发者 |
+8 -5
View File
@@ -118,7 +118,7 @@ from .generic import task # 触发 @register_task(当前唯一任务类型)
| 2 | `stop_app` | 结束 App | `package` | — | — | `app_stop`(`am force-stop`,下次打开是冷启动) | | 2 | `stop_app` | 结束 App | `package` | — | — | `app_stop`(`am force-stop`,下次打开是冷启动) |
| 3 | `screen_on` | 亮屏 | — | — | — | `d.unlock()`(息屏时唤醒并滑动解锁) | | 3 | `screen_on` | 亮屏 | — | — | — | `d.unlock()`(息屏时唤醒并滑动解锁) |
| 4 | `screen_off` | 息屏 | — | — | — | `d.screen_off()` | | 4 | `screen_off` | 息屏 | — | — | — | `d.screen_off()` |
| 5 | `keep_screen` | 保持亮屏 | `mode`("on") | — | — | `mode="off"` → 恢复自动息屏;否则 `svc power stayon true`(充电时常亮,适合长任务) | | 5 | `keep_screen` | 保持亮屏 | `mode`("on") | — | — | `mode="off"` → 恢复自动息屏;否则**把系统息屏超时顶到最大**(`screen_off_timeout=2147483647`)+ `svc power stayon true` + 唤醒一次。⚠️ 只发 `svc power stayon true` 对**没插充电器**的设备(走 WiFi 的那些)**完全无效**——它管的是"充电时屏幕常亮",2026-09-14 就是因此被反馈"保持亮屏没用" |
| 6 | `key_event` | 按键 | `key`("back") | — | — | `d.press(key)`;**值直传,无白名单校验** | | 6 | `key_event` | 按键 | `key`("back") | — | — | `d.press(key)`;**值直传,无白名单校验** |
| 7 | `swipe` | 滑动 | `direction`("up") | `duration_min`(0.25)、`duration_max`(0.50) | — | 时长取随机值;up = 中轴从 0.8h → 0.2h(down 反向,left/right 同理);**非法 direction 静默不滑** | | 7 | `swipe` | 滑动 | `direction`("up") | `duration_min`(0.25)、`duration_max`(0.50) | — | 时长取随机值;up = 中轴从 0.8h → 0.2h(down 反向,left/right 同理);**非法 direction 静默不滑** |
| 8 | `swipe_until` | 滑动直到元素 | `selector_type`、`selector_value` | `direction`("up")、`max_swipes`(8)、`click_when_found`(True) | — | 每轮滑动后 sleep 0.5s;命中即(可选)点击并返回 True;**只有 `direction="down"` 才是向下滑**,其余按上滑 | | 8 | `swipe_until` | 滑动直到元素 | `selector_type`、`selector_value` | `direction`("up")、`max_swipes`(8)、`click_when_found`(True) | — | 每轮滑动后 sleep 0.5s;命中即(可选)点击并返回 True;**只有 `direction="down"` 才是向下滑**,其余按上滑 |
@@ -348,9 +348,9 @@ class MyTask(BaseTask):
def get_action_class(cls, action_type): def get_action_class(cls, action_type):
return None return None
def create_worker(self, serial, params): def create_worker(self, serial, params, ctx=None):
merged = {**DEFAULT_PARAMS, **(params or {})} merged = {**DEFAULT_PARAMS, **(params or {})}
return MyWorker(serial, params=merged) return MyWorker(serial, params=merged, ctx=ctx)
``` ```
```python ```python
@@ -369,8 +369,11 @@ from .myapp import task # ← 新增
### 8.3 签名约定 ### 8.3 签名约定
- `BaseWorker.__init__(self, serial, params=None, daemon=True)` - `BaseWorker.__init__(self, serial, params=None, daemon=True, ctx=None)`
- `Task.create_worker(self, serial, params)` —— **不要带 `stf_client` / `stf` 形参**(STF 已摘除,历史签名已清理) - `Task.create_worker(self, serial, params, ctx=None)` —— **不要带 `stf_client` / `stf` 形参**(STF 已摘除,历史签名已清理)
- `ctx` 是本次运行的上下文(`run_id` / `job_id` / `job_name` / `device_name`),由
`TaskManager._run_with_retry` 传入;**只用于结构化记录**(如步骤明细
`core/step_log.py`),不参与业务逻辑。不关心就原样透传给 `BaseWorker` 即可。
--- ---
+77 -14
View File
@@ -91,22 +91,85 @@ async function deleteGroup(name){
} }
// ================== Tab 4: 日志 ================== // ================== Tab 4: 日志 ==================
// 条件(文件/关键字/级别/时间)→ /api/logs(结构化行);下载走 /api/logs/download
function _logParams(){
const v=id=>document.getElementById(id);
const p=new URLSearchParams();
p.set('file', v('log-file').value||'');
p.set('lines', v('log-lines').value);
const q=v('log-q').value.trim(); if(q)p.set('q',q);
const lv=v('log-level').value; if(lv)p.set('level',lv);
const since=v('log-since').value; if(since)p.set('since',since);
const until=v('log-until').value; if(until)p.set('until',until);
return p;
}
// 文件下拉由接口返回的清单渲染(写死的选项会漏掉新增模块,如 notify.log)
function _renderLogFiles(files,current){
const sel=document.getElementById('log-file');
const sig=(files||[]).map(f=>f.name).join(',');
if(sel.dataset.sig!==sig){ // 只在清单变化时重建,避免打断当前选择
sel.dataset.sig=sig;
sel.innerHTML=(files||[]).map(f=>
'<option value="'+esc(f.name)+'">'+esc(f.label)+'</option>').join('');
}
if(current)sel.value=current;
}
async function loadLogs(){ async function loadLogs(){
const file=document.getElementById('log-file').value;
const lines=document.getElementById('log-lines').value;
const r=await apiGet('/api/logs?file='+encodeURIComponent(file)+'&lines='+lines);
if(!r||!r.ok)return;
const content=r.content||'';
// 简单着色
const colored=esc(content)
.replace(/\[ERROR\]/g,'<span class="log-err">[ERROR]</span>')
.replace(/\[WARNING\]/g,'<span class="log-warn">[WARNING]</span>')
.replace(/\[INFO\]/g,'<span class="log-info">[INFO]</span>');
document.getElementById('log-content').innerHTML=colored||'<span class="text-muted">(空)</span>';
document.getElementById('log-hint').textContent=file+' · '+content.split('\n').length+' 行';
// 自动滚动到底部
const el=document.getElementById('log-content'); const el=document.getElementById('log-content');
el.scrollTop=el.scrollHeight; if(!el)return;
// 用户正往上翻看历史时不要被自动刷新拽回底部
const atBottom=el.scrollHeight-el.scrollTop-el.clientHeight<40;
const r=await apiGet('/api/logs?'+_logParams().toString());
if(!r)return;
_renderLogFiles(r.files,r.file);
const hint=document.getElementById('log-hint');
if(!r.ok){
el.innerHTML='<span class="text-muted">'+esc(r.error||'读取失败')+'</span>';
hint.textContent='';
return;
}
const rows=r.rows||[], kw=document.getElementById('log-q').value.trim();
el.innerHTML=rows.length
? rows.map(row=>_logRowHtml(row,kw)).join('')
: '<span class="text-muted">(无匹配行)</span>';
let text=r.file+' · 命中 '+r.matched+' 行'
+(rows.length<r.matched?'(显示最近 '+rows.length+' 行)':'')
+' · 已扫描 '+r.scanned+' 行';
if(r.truncated)text+=' · 更早的命中已省略:缩小时间范围或加关键字';
hint.textContent=text;
if(atBottom)el.scrollTop=el.scrollHeight;
}
function _logRowHtml(r,kw){
const lvl=(r.level||'').toUpperCase();
const cls=lvl==='ERROR'||lvl==='CRITICAL'?'log-err'
:lvl==='WARNING'?'log-warn':'log-info';
// 续行(traceback 缩进行)没有前缀,只缩进显示消息体
const head=r.cont?'':esc(r.ts)+' <span class="'+cls+'">['+esc(lvl)+']</span> ['
+esc(r.module)+'] ';
return '<div class="log-row'+(r.cont?' log-cont':'')+'">'+head+_logHl(r.msg,kw)+'</div>';
}
// 关键字高亮:**先转义再匹配**——反过来的话消息里的 < > 会先被吃成实体
function _logHl(text,kw){
const safe=esc(text||'');
if(!kw)return safe;
const k=esc(kw);
if(!k)return safe;
const re=new RegExp(k.replace(/[.*+?^${}()|[\]\\]/g,'\\$&'),'gi');
return safe.replace(re,m=>'<span class="log-hit">'+m+'</span>');
}
function downloadLogs(){
window.location='/api/logs/download?'+_logParams().toString();
}
function resetLogFilters(){
['log-q','log-since','log-until'].forEach(id=>{document.getElementById(id).value='';});
document.getElementById('log-level').value='';
loadLogs();
} }
function toggleLogAuto(){ function toggleLogAuto(){
+10 -2
View File
@@ -106,7 +106,11 @@ function showTab(name){
if(name==='monitor'){loadMonitor();_monitorTimer=setInterval(loadMonitor,5000);} if(name==='monitor'){loadMonitor();_monitorTimer=setInterval(loadMonitor,5000);}
if(name==='tasks'){showSubTab('tasks',_activeSubs.tasks);loadTasks();loadCustomActions();} if(name==='tasks'){showSubTab('tasks',_activeSubs.tasks);loadTasks();loadCustomActions();}
if(name==='tools'){showSubTab('tools',_activeSubs.tools);loadToolsDevices();loadAdbDevices();loadTailscaleDevices();loadApks();} 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==='logs'){
showSubTab('logs', _activeSubs.logs||'files'); // 文件日志 / 步骤明细
if((_activeSubs.logs||'files')==='files'
&& document.getElementById('log-auto').checked)_logTimer=setInterval(loadLogs,3000);
}
if(name==='agent'){ if(name==='agent'){
showSubTab('agent', _activeSubs.agent||'chat'); // 聊天 / AI 建任务 showSubTab('agent', _activeSubs.agent||'chat'); // 聊天 / AI 建任务
if(typeof initAgentChat==='function') initAgentChat(); if(typeof initAgentChat==='function') initAgentChat();
@@ -117,7 +121,8 @@ function showTab(name){
// ================== 页内子分栏(任务/工具 通用) ================== // ================== 页内子分栏(任务/工具 通用) ==================
// 每个带子分栏的 Tab 记住上次选中的子分栏,切走再切回来保持原位 // 每个带子分栏的 Tab 记住上次选中的子分栏,切走再切回来保持原位
let _activeSubs = {tasks: 'plan', tools: 'clipboard', system: 'backup', agent: 'chat'}; let _activeSubs = {tasks: 'plan', tools: 'clipboard', system: 'backup', agent: 'chat',
logs: 'files'};
let _discoveryTimer = null; // 设备自动发现 10s 轮询(仅 devpool 子分栏激活时) let _discoveryTimer = null; // 设备自动发现 10s 轮询(仅 devpool 子分栏激活时)
function showSubTab(tabId, name){ function showSubTab(tabId, name){
@@ -126,6 +131,9 @@ function showSubTab(tabId, name){
tab.querySelectorAll('.sub-tab').forEach(b=>b.classList.toggle('active', b.dataset.sub===name)); 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)); 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==='agent' && name==='taskgen' && typeof initTaskGen==='function') initTaskGen();
if(tabId==='system' && name==='notify' && typeof loadNotifyPanel==='function') loadNotifyPanel();
if(tabId==='logs' && name==='files' && typeof loadLogs==='function') loadLogs();
if(tabId==='logs' && name==='steps' && typeof loadStepLogs==='function') loadStepLogs(true);
if(name==='groups' && typeof loadGroups==='function') loadGroups(); if(name==='groups' && typeof loadGroups==='function') loadGroups();
if(name==='apks' && typeof loadAgentStore==='function') loadAgentStore(); if(name==='apks' && typeof loadAgentStore==='function') loadAgentStore();
if(name==='devpool' && typeof loadDevPool==='function'){ if(name==='devpool' && typeof loadDevPool==='function'){
+1 -1
View File
@@ -291,7 +291,7 @@ var _stepEditor={
h+='<div class="help">息屏。任务结束后用,避免长时间亮屏导致烧屏/发热</div>'; h+='<div class="help">息屏。任务结束后用,避免长时间亮屏导致烧屏/发热</div>';
}else if(step.type==='keep_screen'){ }else if(step.type==='keep_screen'){
h+='<div class="form-group"><label>模式</label><select class="form-control" data-key="mode">'+ h+='<div class="form-group"><label>模式</label><select class="form-control" data-key="mode">'+
'<option value="on"'+(p.mode!=='off'?' selected':'')+'>保持亮屏(充电时屏幕常亮)</option>'+ '<option value="on"'+(p.mode!=='off'?' selected':'')+'>保持亮屏(把系统息屏超时顶到最大,不充电也不会黑屏)</option>'+
'<option value="off"'+(p.mode==='off'?' selected':'')+'>恢复自动息屏</option></select></div>'; '<option value="off"'+(p.mode==='off'?' selected':'')+'>恢复自动息屏</option></select></div>';
h+='<div class="help">长任务(如养号看视频几小时)放"保持亮屏"防屏幕超时熄灭;任务结束前放"恢复自动息屏"</div>'; h+='<div class="help">长任务(如养号看视频几小时)放"保持亮屏"防屏幕超时熄灭;任务结束前放"恢复自动息屏"</div>';
}else if(step.type==='swipe'){ }else if(step.type==='swipe'){
+384
View File
@@ -0,0 +1,384 @@
// 系统 → 通知:多 webhook 配置(每条自己订阅事件)+ 测试发送 + 发送记录
// 后端:web/notify_api.py(全部 admin_required);配置落 app_meta.notify_webhooks 单键。
// 安全口径:URL 里有凭据(企微 ?key=)→ 列表/记录里一律打码;编辑时**留空即不修改**。
let _notifyHooks = [];
let _notifyEvents = [];
let _notifyFormats = [];
let _notifySettings = {};
let _notifyEditingId = null; // null=新建
let _notifyPatterns = []; // 编辑中的通配订阅(如 task.*)
let _notifyPrevFormat = ''; // 上一个选中格式(切换时判断限流值要不要跟着走)
let _notifyRateTouched = false; // 用户是否手动改过限流值(没改过就跟随格式的推荐值)
// ================== 加载与渲染 ==================
function loadNotifyPanel(){
Promise.all([apiGet('/api/notify/webhooks'),
apiGet('/api/notify/events'),
apiGet('/api/notify/logs?limit=50')]).then(function(res){
const [hooks, events, logs] = res;
if(hooks && hooks.ok){
_notifyHooks = hooks.webhooks || [];
_notifyFormats = hooks.formats || [];
_notifySettings = hooks.settings || {};
}
if(events && events.ok) _notifyEvents = events.events || [];
renderNotifySettings();
renderNotifyHooks();
renderNotifyLogs(logs && logs.ok ? (logs.logs || []) : []);
});
}
function renderNotifySettings(){
const on = document.getElementById('notify-global');
if(on) on.checked = _notifySettings.global_enabled !== false;
const agg = document.getElementById('notify-agg');
if(agg) agg.value = _notifySettings.default_agg_window != null ? _notifySettings.default_agg_window : 30;
const rate = document.getElementById('notify-rate');
if(rate) rate.value = _notifySettings.default_rate_limit != null ? _notifySettings.default_rate_limit : 18;
}
function renderNotifyHooks(){
const tb = document.getElementById('tb-notify-hooks');
if(!tb) return;
if(!_notifyHooks.length){
tb.innerHTML = '<tr><td colspan="6" class="empty">还没有配置 webhook。点「+ 新建 Webhook」加一条——'
+ '比如把「任务失败」推到企业微信群。</td></tr>';
return;
}
tb.innerHTML = _notifyHooks.map(function(h){
const fmt = (_notifyFormats.find(f => f.name === h.format) || {}).label || h.format;
const evs = (h.events || []).length;
return '<tr>'
+ '<td>' + esc(h.name) + (h.secret_set ? ' <span class="label label-info" title="已配置密钥">🔑</span>' : '') + '</td>'
+ '<td>' + esc(fmt) + '</td>'
+ '<td style="max-width:280px;word-break:break-all;font-family:var(--mono);font-size:11px">'
+ esc(h.url || '') + '</td>'
+ '<td>' + evs + ' 个事件</td>'
+ '<td>' + (h.enabled ? '<span class="label label-success">启用</span>'
: '<span class="label">停用</span>') + '</td>'
+ '<td style="white-space:nowrap">'
+ '<button class="btn btn-xs" onclick="testNotifyHook(\'' + h.id + '\')" title="立刻发一条测试消息,看群里收不收得到">发送测试</button> '
+ '<button class="btn btn-xs" onclick="toggleNotifyHook(\'' + h.id + '\',' + (h.enabled ? 'false' : 'true') + ')">'
+ (h.enabled ? '停用' : '启用') + '</button> '
+ '<button class="btn btn-xs" onclick="openNotifyModal(\'' + h.id + '\')">编辑</button> '
+ '<button class="btn btn-xs btn-danger" onclick="deleteNotifyHook(\'' + h.id + '\')">删除</button>'
+ '</td></tr>';
}).join('');
}
function renderNotifyLogs(logs){
const tb = document.getElementById('tb-notify-logs');
if(!tb) return;
if(!logs.length){
tb.innerHTML = '<tr><td colspan="5" class="empty">本次运行还没有发送记录(重启后清空,历史见 logs/notify.log)</td></tr>';
return;
}
tb.innerHTML = logs.map(function(r){
const t = r.ts ? new Date(r.ts * 1000).toLocaleString('zh-CN', {hour12:false}) : '';
const st = r.ok ? '<span class="label label-success">成功</span>'
: '<span class="label label-warning">失败</span>';
const detail = r.ok ? ('HTTP ' + (r.http_status == null ? '' : r.http_status)
+ (r.errcode ? ' errcode ' + r.errcode : ''))
: esc(r.error || '');
return '<tr><td style="white-space:nowrap">' + esc(t) + '</td>'
+ '<td>' + esc(r.hook_name || '') + '</td>'
+ '<td style="font-family:var(--mono);font-size:11px">' + esc(r.event || '') + '</td>'
+ '<td>' + st + '</td>'
+ '<td style="font-size:11px">' + detail + (r.elapsed_ms != null ? ' · ' + r.elapsed_ms + 'ms' : '') + '</td></tr>';
}).join('');
}
// ================== 编辑弹窗 ==================
function notifyEventMatches(pattern, key){
if(!pattern) return false;
if(pattern === '*' || pattern === key) return true;
if(pattern.slice(-2) === '.*') return key.indexOf(pattern.slice(0, -1)) === 0;
return false;
}
function openNotifyModal(id){
_notifyEditingId = id || null;
const h = id ? (_notifyHooks.find(x => x.id === id) || {}) : {};
_notifyPatterns = (h.events || []).filter(p => p.indexOf('*') >= 0);
const checked = (h.events || []).filter(p => p.indexOf('*') < 0);
const fmtOpts = _notifyFormats.map(function(f){
const sel = (h.format || 'wecom') === f.name ? ' selected' : '';
return '<option value="' + f.name + '"' + sel + (f.implemented ? '' : ' disabled') + '>'
+ esc(f.label) + '</option>';
}).join('');
const box = document.getElementById('notify-modal-box');
if(!box) return;
box.innerHTML =
'<div style="display:flex;align-items:center;margin-bottom:12px">'
+ '<div style="font-size:15px;font-weight:700;flex:1">'
+ (id ? '编辑 Webhook' : '新建 Webhook') + '</div>'
+ '<button class="btn btn-xs" title="关闭(不保存)" onclick="closeNotifyModal()">✕ 关闭</button>'
+ '</div>'
+ '<div class="form-row">'
+ '<div class="form-group"><label>名称</label>'
+ '<input id="nf-name" class="form-control" value="' + esc(h.name || '') + '" placeholder="如:运维群"></div>'
+ '<div class="form-group"><label>格式</label>'
+ '<select id="nf-format" class="form-control" onchange="_notifyFormatChanged()">' + fmtOpts + '</select></div>'
+ '</div>'
+ '<div class="form-group"><label>Webhook URL</label>'
// 占位与说明都由**当前格式**决定(见 _notifyFormatChanged),切换格式会跟着变
+ '<input id="nf-url" class="form-control" value="">'
+ '<div class="help" id="nf-url-help"></div></div>'
+ '<div class="form-row">'
+ '<div class="form-group"><label id="nf-secret-label">签名密钥(可选)</label>'
+ '<input id="nf-secret" type="password" class="form-control" placeholder="'
+ (h.secret_set ? '已配置,留空不修改' : '留空即可') + '">'
+ '<div class="help" id="nf-secret-help"></div></div>'
+ '<div class="form-group" style="max-width:150px"><label>聚合窗口(秒)</label>'
+ '<input id="nf-agg" type="number" class="form-control" min="0" max="600" value="'
+ (h.agg_window != null ? h.agg_window : (_notifySettings.default_agg_window || 30)) + '">'
+ '<div class="help">0=不聚合</div></div>'
+ '<div class="form-group" style="max-width:150px"><label>限流(条/分)</label>'
+ '<input id="nf-rate" type="number" class="form-control" min="0" max="60" '
+ 'oninput="_notifyRateTouched=true" value="'
+ (h.rate_limit_per_min != null ? h.rate_limit_per_min
: ((_notifyFormats.find(x => x.name === (h.format || 'wecom')) || {}).limit_default
|| _notifySettings.default_rate_limit || 18)) + '">'
+ '<div class="help" id="nf-limit-help"></div></div>'
+ '</div>'
+ '<div class="form-group"><label>订阅事件(不勾就不推)</label>'
+ '<div class="help" style="margin-bottom:6px">按类别分的完整事件目录;'
+ '带 <b>★</b> 的是建议开启的。也可以直接写通配(如 <code>task.*</code>)——在下面的芯片框里加。</div>'
+ '<div id="nf-events" style="max-height:260px;overflow-y:auto;border:1px solid var(--border);border-radius:8px;padding:10px"></div>'
+ '</div>'
+ '<div class="form-group" id="nf-pattern-wrap"><label>通配订阅(可选,任何格式都适用)</label>'
+ '<div id="nf-patterns" style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:6px"></div>'
+ '<div style="display:flex;gap:6px"><input id="nf-pattern-input" class="form-control" placeholder="task.* 或 device.* 或 *">'
+ '<button class="btn" onclick="addNotifyPattern()">添加</button></div></div>'
+ '<div class="form-group" id="nf-body-wrap" style="display:none"><label>请求体模板(JSON)</label>'
+ '<textarea id="nf-body" class="form-control" rows="5" placeholder=\'{"text":"{{title}}","desc":"{{summary}}"}\'></textarea>'
+ '<div class="help">占位符:{{event}} {{title}} {{summary}} {{ts}} {{level}} {{markdown}} {{fields}} {{fields_json}} {{hook_name}} {{field.名称}}。'
+ '保存前会做干跑校验(渲染后必须是合法 JSON)。Slack 可直接用:'
+ '<code>{"text":"{{markdown}}"}</code></div></div>'
+ '<div class="form-group">'
+ '<label>预览</label><div id="nf-preview" class="help">点「预览请求体」看真实会发出去的内容与字节数。</div>'
+ '</div>'
// 底部操作栏(缺了它就只能靠刷新页面逃出去)
+ '<div style="display:flex;gap:8px;align-items:center;margin-top:16px;padding-top:12px;border-top:1px solid var(--border)">'
+ '<button class="btn btn-primary" onclick="saveNotifyHook()">' + (id ? '保存' : '创建') + '</button>'
+ '<button class="btn" onclick="previewNotifyHook()">预览请求体</button>'
+ '<button class="btn" onclick="closeNotifyModal()">取消</button>'
+ '<span class="help" style="margin-left:auto">Esc 或点空白处也能关</span>'
+ '</div>';
box.querySelector('#nf-body').value = h.body_template || '';
renderEventTree(checked);
renderNotifyPatterns();
_notifyPrevFormat = (h.format || 'wecom');
_notifyRateTouched = (h.rate_limit_per_min != null); // 编辑已有配置时视为"已定"
_notifyFormatChanged();
document.getElementById('notify-modal-overlay').style.display = 'flex';
}
function _notifyFormatChanged(){
const name = (document.getElementById('nf-format') || {}).value;
const meta = _notifyFormats.find(x => x.name === name) || {};
const h = _notifyEditingId
? (_notifyHooks.find(x => x.id === _notifyEditingId) || {}) : {};
// URL:编辑时占位显示现有(打码)地址,新建时显示该格式的示例
const url = document.getElementById('nf-url');
if(url){
url.placeholder = (h.url && h.url !== undefined) ? h.url : (meta.url_hint || 'https://…');
}
const urlHelp = document.getElementById('nf-url-help');
if(urlHelp){
urlHelp.innerHTML = (h.url ? ('已配置:' + esc(h.url) + '(留空=不修改)<br>') : '')
+ esc(meta.url_help || '');
}
// 密钥:不同格式叫法/用途不同(企业微信不需要、Bark 是设备 Key、通用 JSON 是自定义头)
const sl = document.getElementById('nf-secret-label');
if(sl) sl.textContent = (h.secret_set ? '🔑 ' : '') + (meta.secret_label || '密钥(可选)');
const sh = document.getElementById('nf-secret-help');
if(sh) sh.innerHTML = esc(meta.secret_help || '')
+ (h.secret_set ? '<br>已配置过:留空=不修改,想清除请点上面的 🔑 后填写新值' : '');
// 限流:上限因格式而异(企微 20/分、Bark 无硬限)
const lh = document.getElementById('nf-limit-help');
if(lh) lh.textContent = meta.limit_help || '';
// 用户没手动改过限流值时,跟着格式的推荐值走(企微 20 / 通用 JSON 60 / Bark 60)
const rate = document.getElementById('nf-rate');
if(rate && !_notifyRateTouched && meta.limit_default){
rate.value = meta.limit_default;
}
// 请求体模板:只有需要模板的格式(通用 JSON)才显示
const bodyWrap = document.getElementById('nf-body-wrap');
if(bodyWrap) bodyWrap.style.display = meta.needs_template ? 'block' : 'none';
_notifyPrevFormat = name;
}
// Esc 关闭弹窗(点空白处关闭在 overlay 的 onclick 上)
document.addEventListener('keydown', function(ev){
if(ev.key !== 'Escape') return;
const ov = document.getElementById('notify-modal-overlay');
if(ov && ov.style.display !== 'none') closeNotifyModal();
});
function renderEventTree(checked){
const box = document.getElementById('nf-events');
if(!box) return;
const cats = [];
_notifyEvents.forEach(function(e){ if(cats.indexOf(e.category) < 0) cats.push(e.category); });
box.innerHTML = cats.map(function(cat){
const rows = _notifyEvents.filter(e => e.category === cat);
const body = rows.map(function(e){
const id = 'nfe-' + e.key.replace(/\./g, '-');
return '<label style="display:flex;gap:6px;align-items:flex-start;padding:3px 0;cursor:pointer" title="'
+ esc(e.desc || '') + '">'
+ '<input type="checkbox" class="nf-ev" value="' + esc(e.key) + '" id="' + id + '"'
+ (checked.indexOf(e.key) >= 0 ? ' checked' : '') + '>'
+ '<span style="flex:1"><b>' + esc(e.label) + '</b>'
+ (e.recommend ? ' <span class="label label-success">★建议</span>' : '')
+ ' <code style="font-size:10.5px;opacity:.7">' + esc(e.key) + '</code></span></label>';
}).join('');
return '<div style="margin-bottom:8px"><div style="font-weight:600;font-size:12px;margin-bottom:2px">'
+ esc(cat) + ' <button class="btn btn-xs" onclick="_notifyToggleCat(\'' + esc(cat) + '\')">全选/取消</button></div>'
+ body + '</div>';
}).join('');
}
function _notifyToggleCat(cat){
const keys = _notifyEvents.filter(e => e.category === cat).map(e => e.key);
const boxes = [...document.querySelectorAll('#nf-events .nf-ev')].filter(b => keys.indexOf(b.value) >= 0);
const allOn = boxes.every(b => b.checked);
boxes.forEach(b => { b.checked = !allOn; });
}
function addNotifyPattern(){
const inp = document.getElementById('nf-pattern-input');
const v = (inp.value || '').trim();
if(!v) return;
if(v !== '*' && v.slice(-2) !== '.*'){ showToast('通配请写成 task.* 或 device.* 或 *', 'error'); return; }
if(_notifyPatterns.indexOf(v) < 0) _notifyPatterns.push(v);
inp.value = '';
renderNotifyPatterns();
}
function renderNotifyPatterns(){
const box = document.getElementById('nf-patterns');
if(!box) return;
box.innerHTML = _notifyPatterns.map(function(p, i){
return '<span class="label label-info" style="cursor:pointer" title="点一下移除" onclick="removeNotifyPattern(' + i + ')">'
+ esc(p) + ' ×</span>';
}).join('') || '<span class="help">(无)</span>';
}
function removeNotifyPattern(i){
_notifyPatterns.splice(i, 1);
renderNotifyPatterns();
}
function closeNotifyModal(){
const ov = document.getElementById('notify-modal-overlay');
if(ov) ov.style.display = 'none';
}
// ================== 保存 / 删除 / 启停 / 测试 ==================
function collectNotifyForm(){
const events = [...document.querySelectorAll('#nf-events .nf-ev')]
.filter(b => b.checked).map(b => b.value);
_notifyPatterns.forEach(function(p){ if(events.indexOf(p) < 0) events.push(p); });
const body = {name: (document.getElementById('nf-name').value || '').trim(),
format: document.getElementById('nf-format').value,
events: events,
agg_window: parseInt(document.getElementById('nf-agg').value, 10) || 0,
rate_limit_per_min: parseInt(document.getElementById('nf-rate').value, 10) || 0,
body_template: (document.getElementById('nf-body').value || '')};
const url = (document.getElementById('nf-url').value || '').trim();
if(url) body.url = url; // 留空 = 不修改(后端保留原值)
const sec = (document.getElementById('nf-secret').value || '').trim();
if(sec) body.secret = sec;
return body;
}
function saveNotifyHook(){
const body = collectNotifyForm();
if(!body.name){ showToast('请填名称', 'error'); return; }
if(!body.events.length){ showToast('至少要勾一个事件', 'error'); return; }
const id = _notifyEditingId;
const req = id ? apiPut('/api/notify/webhooks/' + id, body)
: apiPost('/api/notify/webhooks', body);
req.then(function(r){
if(!r || !r.ok){
let msg = (r && r.error) || '保存失败';
if(r && r.errors && r.errors.length) msg = r.errors.join(';');
showToast(msg, 'error');
return;
}
showToast('已保存', 'success');
closeNotifyModal();
loadNotifyPanel();
});
}
function deleteNotifyHook(id){
const h = _notifyHooks.find(x => x.id === id) || {};
if(!confirm('删除 webhook「' + (h.name || '') + '」?\n(只是不再推送,不影响任何任务/设备数据)')) return;
apiDelete('/api/notify/webhooks/' + id).then(function(r){
if(!r || !r.ok){ showToast((r && r.error) || '删除失败', 'error'); return; }
showToast('已删除', 'success');
loadNotifyPanel();
});
}
function toggleNotifyHook(id, on){
apiPut('/api/notify/webhooks/' + id, {enabled: on}).then(function(r){
if(!r || !r.ok){ showToast((r && r.error) || '操作失败', 'error'); return; }
loadNotifyPanel();
});
}
function testNotifyHook(id){
showToast('正在发送测试消息…', 'success');
apiPost('/api/notify/webhooks/' + id + '/test', {}).then(function(r){
if(!r){ showToast('请求失败', 'error'); return; }
const detail = 'HTTP ' + (r.http_status == null ? '-' : r.http_status)
+ (r.errcode ? ' · errcode ' + r.errcode : '')
+ (r.elapsed_ms != null ? ' · ' + r.elapsed_ms + 'ms' : '')
+ (r.attempts > 1 ? ' · 重试 ' + r.attempts + ' 次' : '')
+ (r.error ? '\n' + r.error : '');
showToast(r.ok ? ('测试消息已发出(' + detail + ')') : ('发送失败(' + detail + ')'),
r.ok ? 'success' : 'error');
loadNotifyPanel();
});
}
function previewNotifyHook(){
const body = collectNotifyForm();
apiPost('/api/notify/preview', {hook: body, event: 'notify.test'}).then(function(r){
const box = document.getElementById('nf-preview');
if(!box) return;
if(!r || !r.ok){ box.innerHTML = '<span style="color:#dc2626">预览失败:' + esc((r && r.error) || '') + '</span>'; return; }
let html = '<div class="help">UTF-8 字节数:' + (r.bytes == null ? '-' : r.bytes)
+ (r.byte_limit ? ' / 上限 ' + r.byte_limit : '')
+ (r.truncated ? ' <b style="color:#b45309">(会截断)</b>' : '') + '</div>';
html += '<pre style="max-height:220px;overflow:auto;font-size:11px;background:#f6f7f9;padding:8px;border-radius:6px">'
+ esc(r.request_body || '') + '</pre>';
box.innerHTML = html;
});
}
function saveNotifyGlobal(){
const on = document.getElementById('notify-global').checked;
const body = {global_enabled: on,
default_agg_window: parseInt(document.getElementById('notify-agg').value, 10) || 0,
default_rate_limit: parseInt(document.getElementById('notify-rate').value, 10) || 18};
apiPost('/api/notify/settings', body).then(function(r){
if(!r || !r.ok){ showToast((r && r.error) || '保存失败', 'error'); return; }
showToast('已保存(新配置对之后的事件生效)', 'success');
loadNotifyPanel();
});
}
function toggleNotifyGlobal(el){
apiPost('/api/notify/settings', {global_enabled: el.checked}).then(function(r){
if(!r || !r.ok){ el.checked = !el.checked; showToast((r && r.error) || '操作失败', 'error'); return; }
showToast(el.checked ? '通知总开关:已开启' : '通知总开关:已关闭(所有 webhook 暂停推送)', 'success');
});
}
+143
View File
@@ -0,0 +1,143 @@
// ================== 「日志 → 步骤明细」子分栏 ==================
// 数据源:task_step_log 表(结构化),接口 /api/step_logs*(见 web/admin_api.py)。
// 与「文件日志」的分工:那边是原始文本,这边能按设备/任务/时间/结果过滤、能导出。
let _slOffset = 0; // 分页偏移
let _slTotal = 0; // 命中总数
let _slRunId = ''; // "只看某次运行"时的 run_id(空 = 不限)
let _slInited = false; // 下拉选项只拉一次
function _slVal(id){const el=document.getElementById(id);return el?el.value:'';}
function _slParams(){
const p=new URLSearchParams();
const put=(k,v)=>{if(v)p.set(k,v);};
put('serial', _slVal('sl-device'));
put('job_id', _slVal('sl-job'));
put('result', _slVal('sl-result'));
put('q', _slVal('sl-q').trim());
put('since', _slVal('sl-since'));
put('until', _slVal('sl-until'));
put('run_id', _slRunId);
p.set('limit', _slVal('sl-limit')||200);
p.set('offset', _slOffset);
return p;
}
async function loadStepLogs(reset){
const body=document.getElementById('sl-body');
if(!body)return;
if(reset)_slOffset=0;
const p=_slParams();
const r=await apiGet('/api/step_logs?'+p.toString());
if(!r||!r.ok){body.innerHTML='<tr><td colspan="9" class="text-muted">读取失败</td></tr>';return;}
_slTotal=r.total||0;
const rows=r.rows||[];
body.innerHTML = rows.length ? rows.map(_slRowHtml).join('')
: '<tr><td colspan="9" class="text-muted">(没有匹配的步骤记录——先跑一个任务,'
+'或在上面放宽筛选条件)</td></tr>';
// 提示栏:分页位置 + 保留期 + 运行期间丢弃计数(排障用)
const from=_slTotal?(_slOffset+1):0, to=_slOffset+rows.length;
let hint=`命中 ${_slTotal} 条 · 显示 ${from}-${to}`;
if(_slRunId)hint=`只看运行 ${_slRunId} · `+hint;
const st=r.stats||{};
if(st.dropped)hint+=` · 队列溢出丢弃 ${st.dropped} 条`;
if(st.failed)hint+=` · 落库失败 ${st.failed} 条`;
document.getElementById('sl-hint').textContent=hint;
document.getElementById('sl-page').textContent=
`第 ${Math.floor(_slOffset/(parseInt(_slVal('sl-limit'),10)||200))+1} 页`;
document.getElementById('sl-prev').disabled = _slOffset<=0;
document.getElementById('sl-next').disabled = _slOffset+rows.length>=_slTotal;
if(!_slInited){_slInited=true;loadStepFilters();}
if(reset)loadStepRuns();
}
// 结果 → 颜色。注意不能复用 .log-err/.log-warn/.log-info:那套的作用域是
// .log-content(文件日志面板),表格里得用下面这套 res-*
const _SL_RESULT={error:'res-error',unknown:'res-error',miss:'res-miss',
cap:'res-miss',skip:'res-skip',ok:'res-ok'};
function _slRowHtml(r){
const cls=_SL_RESULT[r.result]||'';
const dev=esc(r.device_name||r.serial||'');
const dur=r.duration_ms>=1000?(r.duration_ms/1000).toFixed(1)+'s':(r.duration_ms||0)+'ms';
return '<tr>'
+'<td class="text-muted" style="white-space:nowrap">'+esc(r.created_at||'')+'</td>'
+'<td title="'+esc(r.serial||'')+'">'+dev+'</td>'
+'<td>'+esc(r.job_name||'')+'</td>'
+'<td>'+esc(r.step_path||'')+'</td>'
+'<td>'+esc(r.step_label||'')+'</td>'
+'<td class="text-muted">'+esc(r.step_type||'')+'</td>'
+'<td class="'+cls+'">'+esc(r.result||'')+'</td>'
+'<td class="text-muted">'+dur+'</td>'
+'<td class="text-muted" title="'+esc(r.detail||'')+'">'+esc(_slTrim(r.detail,120))+'</td>'
+'</tr>';
}
function _slTrim(s,n){s=s||'';return s.length>n?s.slice(0,n)+'…':s;}
function stepLogPage(delta){
const size=parseInt(_slVal('sl-limit'),10)||200;
_slOffset=Math.max(0,_slOffset+delta*size);
loadStepLogs(false);
}
function resetStepFilters(){
['sl-q','sl-since','sl-until'].forEach(id=>{const el=document.getElementById(id);if(el)el.value='';});
['sl-device','sl-job','sl-result'].forEach(id=>{const el=document.getElementById(id);if(el)el.value='';});
_slRunId='';
loadStepLogs(true);
}
function downloadStepLogs(){
const p=_slParams();
p.delete('limit');p.delete('offset');
window.location='/api/step_logs/download?'+p.toString();
}
// ---------- 筛选下拉(只列"明细里真的出现过"的设备/任务) ----------
async function loadStepFilters(){
const r=await apiGet('/api/step_logs/filters');
if(!r||!r.ok)return;
const dev=document.getElementById('sl-device');
const job=document.getElementById('sl-job');
const keep=(sel,val)=>{const cur=sel.value;sel.innerHTML=val;sel.value=cur;};
keep(dev,'<option value="">全部设备</option>'+(r.devices||[]).map(d=>
'<option value="'+esc(d.serial)+'">'+esc(d.device_name||d.serial)+'</option>').join(''));
keep(job,'<option value="">全部任务</option>'+(r.jobs||[]).map(j=>
'<option value="'+esc(j.job_id)+'">'+esc(j.job_name||j.job_id)+'</option>').join(''));
document.getElementById('sl-note').textContent=
`明细保留 ${r.keep_days} 天(超期自动清理);单次运行最多记录 `
+`${r.max_rows_per_run} 条(无限循环任务不会写爆这张表)。`;
}
// ---------- 最近运行概览(点一行 = 只看那次运行) ----------
async function loadStepRuns(){
const p=new URLSearchParams();
if(_slVal('sl-device'))p.set('serial',_slVal('sl-device'));
if(_slVal('sl-job'))p.set('job_id',_slVal('sl-job'));
if(_slVal('sl-since'))p.set('since',_slVal('sl-since'));
if(_slVal('sl-until'))p.set('until',_slVal('sl-until'));
p.set('limit','20');
const r=await apiGet('/api/step_logs/runs?'+p.toString());
const body=document.getElementById('sl-runs-body');
if(!r||!r.ok){body.innerHTML='';return;}
const runs=r.runs||[];
document.getElementById('sl-runs-summary').textContent=
`最近运行概览(${runs.length} 次,点「查看」只看那次)`;
body.innerHTML = runs.length ? runs.map(x=>'<tr>'
+'<td class="text-muted" style="white-space:nowrap">'+esc(x.started_at||'')+'</td>'
+'<td>'+esc(x.device_name||x.serial||'')+'</td>'
+'<td>'+esc(x.job_name||'')+'</td>'
+'<td>'+x.steps+'</td>'
+'<td class="'+(x.failures?'res-error':'text-muted')+'">'+(x.failures||0)+'</td>'
+'<td><button class="btn btn-sm" onclick="filterByRun(\''+esc(x.run_id)+'\')">查看</button></td>'
+'</tr>').join('')
: '<tr><td colspan="6" class="text-muted">(暂无运行记录)</td></tr>';
}
function filterByRun(runId){
_slRunId=runId||'';
document.getElementById('sl-runs-box').open=false;
loadStepLogs(true);
}
+8 -4
View File
@@ -49,8 +49,8 @@
from .actions import get_action_class from .actions import get_action_class
return get_action_class(action_type) return get_action_class(action_type)
def create_worker(self, serial, params): def create_worker(self, serial, params, ctx=None):
return MyWorker(serial, params=params) return MyWorker(serial, params=params, ctx=ctx)
5. 在 my_task/__init__.py 加:from . import task (触发注册) 5. 在 my_task/__init__.py 加:from . import task (触发注册)
6. 在 tasks/__init__.py 加:from .my_task import task (触发注册) 6. 在 tasks/__init__.py 加:from .my_task import task (触发注册)
@@ -85,8 +85,12 @@ class BaseTask:
description = "" description = ""
default_params = {} default_params = {}
def create_worker(self, serial, params): def create_worker(self, serial, params, ctx=None):
"""返回一个 threading.Thread(已启动或待启动),执行实际任务。""" """返回一个 threading.Thread(已启动或待启动),执行实际任务。
ctx:本次运行的上下文(`run_id`/`job_id`/`job_name`/`device_name`),
由 TaskManager 传入,供步骤明细等结构化记录标注来源;可为 None。
"""
raise NotImplementedError raise NotImplementedError
@classmethod @classmethod
+99 -10
View File
@@ -37,6 +37,7 @@ from tasks.base import BaseTask, register_task
from core.device_worker import BaseWorker, _update_status from core.device_worker import BaseWorker, _update_status
from core.u2_helper import ensure_app_running, wait_for_app_home, random_sleep from core.u2_helper import ensure_app_running, wait_for_app_home, random_sleep
from core.logger import get_logger from core.logger import get_logger
from core import notifier, step_log
_log = get_logger("task.generic") _log = get_logger("task.generic")
@@ -45,6 +46,13 @@ _log = get_logger("task.generic")
_LEGACY_IDX_XPATH = re.compile(r'^(//\*\[@[^\]]+\])\[(\d+)\](.*)$') _LEGACY_IDX_XPATH = re.compile(r'^(//\*\[@[^\]]+\])\[(\d+)\](.*)$')
# ---- 「保持亮屏」用的系统设置 ----
# 系统息屏超时(毫秒):保持亮屏时顶到最大,恢复时写回原值
_SCREEN_TIMEOUT_MAX = "2147483647"
_SCREEN_TIMEOUT_DEFAULT = "600000" # 兜底恢复值:10 分钟(原值记不住时用)
_SCREEN_TIMEOUT_BACKUP = {} # serial -> 原始 screen_off_timeout
def _norm_legacy_xpath(sel_val): def _norm_legacy_xpath(sel_val):
"""把历史 `//*[@attr=…][k]` 纠正为 `(//*[@attr=…])[k]`(只改整体前缀,保留后续子路径)。 """把历史 `//*[@attr=…][k]` 纠正为 `(//*[@attr=…])[k]`(只改整体前缀,保留后续子路径)。
@@ -116,13 +124,18 @@ class GenericStepsWorker(BaseWorker):
只实现 run_task,按步骤类型分发到 _exec_<type> 方法。 只实现 run_task,按步骤类型分发到 _exec_<type> 方法。
""" """
def __init__(self, serial, params=None): def __init__(self, serial, params=None, ctx=None):
super().__init__(serial, params) super().__init__(serial, params, ctx=ctx)
p = {**DEFAULT_PARAMS, **(self.params or {})} p = {**DEFAULT_PARAMS, **(self.params or {})}
self.max_duration = int(p.get("max_duration", 0)) self.max_duration = int(p.get("max_duration", 0))
self.steps = p.get("steps", []) self.steps = p.get("steps", [])
self._action_counts = {} # 步骤执行计数 {step_label: count} self._action_counts = {} # 步骤执行计数 {step_label: count}
self._miss_counts = {} # 选择器健康:selector -> 连续未命中次数 self._miss_counts = {} # 选择器健康:selector -> 连续未命中次数
# 步骤路径:容器步骤(loop/group/if_el)进出时压栈,得到 "2.1.3" 这种位置,
# 写进「步骤明细」用来还原嵌套结构。worker 每台设备一个线程,实例级状态安全。
self._path = []
self._step_rows = 0 # 本次运行已记录的明细条数(封顶见 _record_step)
self._step_capped = False
# 某 click 选择器连续未找到元素的次数达到该值,判定可能失效(App 改版) # 某 click 选择器连续未找到元素的次数达到该值,判定可能失效(App 改版)
_MAX_CONSECUTIVE_MISS = 10 _MAX_CONSECUTIVE_MISS = 10
@@ -141,6 +154,10 @@ class GenericStepsWorker(BaseWorker):
_log.error(f"[{self.serial}] 选择器连续 {n} 次未找到元素,可能已失效(App 改版?):{sel_val}") _log.error(f"[{self.serial}] 选择器连续 {n} 次未找到元素,可能已失效(App 改版?):{sel_val}")
_update_status(self.serial, last_warning=f"选择器连续{n}次未命中") _update_status(self.serial, last_warning=f"选择器连续{n}次未命中")
self._miss_counts.pop(sel_val, None) # 已上报,重置避免刷屏 self._miss_counts.pop(sel_val, None) # 已上报,重置避免刷屏
# 通知:这是"任务显示成功但什么都没做"的隐蔽故障,值得人看一眼
# (重置计数后要再攒满 10 次才会再报,天然不刷屏)
notifier.notify("task.selector.invalid", serial=self.serial,
selector=str(sel_val)[:200], miss_count=n)
def run_task(self, d): def run_task(self, d):
"""按 steps 顺序执行。loop 步骤递归执行其 children。""" """按 steps 顺序执行。loop 步骤递归执行其 children。"""
@@ -165,10 +182,16 @@ class GenericStepsWorker(BaseWorker):
if depth > 5: if depth > 5:
_log.warning(f"[{self.serial}] 步骤嵌套深度超限(>5),跳过") _log.warning(f"[{self.serial}] 步骤嵌套深度超限(>5),跳过")
return return
for step in steps: for i, step in enumerate(steps):
if self.stopped() or self.is_time_up(): if self.stopped() or self.is_time_up():
return return
# 压入本步在兄弟里的序号(1 起):容器步骤执行 children 时会在其后
# 继续追加,得到 "2.1" 这样的嵌套路径
self._path.append(str(i + 1))
try:
self._exec_one(d, step, depth) self._exec_one(d, step, depth)
finally:
self._path.pop()
def _exec_one(self, d, step, depth=0): def _exec_one(self, d, step, depth=0):
"""执行单个步骤。""" """执行单个步骤。"""
@@ -185,19 +208,29 @@ class GenericStepsWorker(BaseWorker):
prob = float(params.get("probability", 100)) prob = float(params.get("probability", 100))
if prob < 100 and random.random() * 100 > prob: if prob < 100 and random.random() * 100 > prob:
_log.info(f"[{self.serial}] 步骤 '{label}' 概率 {prob}% 未触发,跳过") _log.info(f"[{self.serial}] 步骤 '{label}' 概率 {prob}% 未触发,跳过")
self._record_step(step, "skip", f"概率 {prob}% 未触发", 0)
return return
self.set_action(f"执行: {label}") self.set_action(f"执行: {label}")
_log.info(f"[{self.serial}] 步骤: {label}({stype}) params={params}") _log.info(f"[{self.serial}] 步骤: {label}({stype}) params={params}")
ret = None ret = None
# 结果归类:异常 > 未命中(handler 返回 False)> 正常
# (True/None 都算正常——None 是"没有判断语义"的步骤,如点击坐标)
result, detail = "ok", ""
t0 = time.time()
handler = getattr(self, f"_exec_{stype}", None) handler = getattr(self, f"_exec_{stype}", None)
if handler: if handler:
try: try:
ret = handler(d, params, depth) ret = handler(d, params, depth)
if ret is False:
result, detail = "miss", "未命中/超时"
except Exception as e: except Exception as e:
_log.error(f"[{self.serial}] 步骤 {label}({stype}) 异常: {e}") _log.error(f"[{self.serial}] 步骤 {label}({stype}) 异常: {e}")
result, detail = "error", str(e)
else: else:
_log.warning(f"[{self.serial}] 未知步骤类型: {stype}") _log.warning(f"[{self.serial}] 未知步骤类型: {stype}")
result, detail = "unknown", f"未知步骤类型: {stype}"
self._record_step(step, result, detail, int((time.time() - t0) * 1000))
# 容器步骤(loop/group/if_el)不计入操作计数,避免监控显示噪音 # 容器步骤(loop/group/if_el)不计入操作计数,避免监控显示噪音
if stype not in ("loop", "group", "if_el"): if stype not in ("loop", "group", "if_el"):
self._action_counts[label] = self._action_counts.get(label, 0) + 1 self._action_counts[label] = self._action_counts.get(label, 0) + 1
@@ -208,6 +241,44 @@ class GenericStepsWorker(BaseWorker):
action_counts=dict(self._action_counts), elapsed=elapsed) action_counts=dict(self._action_counts), elapsed=elapsed)
return ret # 命中结果(True/False/None),"测试此步骤"用 return ret # 命中结果(True/False/None),"测试此步骤"用
def _record_step(self, step, result, detail, duration_ms):
"""把这一步写进「步骤明细」(异步落库,不阻塞任务线程)。
单次运行条数封顶:`forever` 循环任务会执行成千上万步,不封顶这张表
会被瞬间写爆。到顶后只补一条 cap 说明行,此后本次运行不再记录。
"""
try:
# 只记"任务运行"里的步骤:编辑器里的「测试此步骤」也会走 _exec_one,
# 但没有 run_id(不归任何一次运行),记进来只是噪音
if not self.ctx.get("run_id") or self._step_capped:
return
params = step.get("params") or {}
label = step.get("label") or step.get("type", "")
if self._step_rows >= step_log.MAX_ROWS_PER_RUN:
self._step_capped = True
step_log.record(
run_id=self.ctx.get("run_id", ""), serial=self.serial,
device_name=self.ctx.get("device_name", ""),
job_id=self.ctx.get("job_id", ""),
job_name=self.ctx.get("job_name", ""),
step_path=".".join(self._path), step_label=label,
step_type=step.get("type", ""), result="cap",
detail=f"本次运行明细已达上限 {step_log.MAX_ROWS_PER_RUN} 条,"
f"后续步骤不再记录(保留期与上限见 core/step_log.py)")
return
self._step_rows += 1
step_log.record(
run_id=self.ctx.get("run_id", ""), serial=self.serial,
device_name=self.ctx.get("device_name", ""),
job_id=self.ctx.get("job_id", ""),
job_name=self.ctx.get("job_name", ""),
step_path=".".join(self._path), step_label=label,
step_type=step.get("type", ""),
selector=str(params.get("selector_value", "") or ""),
result=result, detail=detail, duration_ms=duration_ms)
except Exception:
pass # 记录失败绝不能影响任务执行
# ================== 步骤执行器 ================== # ================== 步骤执行器 ==================
def _exec_screen_on(self, d, params, depth=0): def _exec_screen_on(self, d, params, depth=0):
"""亮屏:息屏时唤醒并滑动解锁。任务执行前用(息屏时 u2 无法操作)。""" """亮屏:息屏时唤醒并滑动解锁。任务执行前用(息屏时 u2 无法操作)。"""
@@ -228,17 +299,35 @@ class GenericStepsWorker(BaseWorker):
def _exec_keep_screen(self, d, params, depth=0): def _exec_keep_screen(self, d, params, depth=0):
"""保持亮屏 / 恢复自动息屏。 """保持亮屏 / 恢复自动息屏。
长任务(养号看视频数小时)时屏幕会按系统超时自动熄灭导致任务无法执行, ⚠️ 只用 `svc power stayon true` **不管用**:它管的是「**充电时**屏幕常亮」
用 svc power stayon 让充电时屏幕常亮;任务结束前用 mode=off 恢复自动息屏。 (= stay_on_while_plugged_in)。设备走 WiFi 跑任务、没插充电器时它完全不起作用
——以前"保持亮屏"看着像没生效就是这个原因(2026-09-14 用户反馈)。
真正管用的是把系统**息屏超时**顶到最大(`screen_off_timeout`):不充电也不会
自动黑屏。进入时记下原值,`mode=off` 写回;进程重启丢了记录就写回常规的 10 分钟。
""" """
mode = params.get("mode", "on") mode = params.get("mode", "on")
try: try:
if mode == "off": if mode == "off":
d.shell("svc power stayon false") d.shell("svc power stayon false")
_log.info(f"[{self.serial}] 恢复自动息屏") orig = _SCREEN_TIMEOUT_BACKUP.pop(self.serial, _SCREEN_TIMEOUT_DEFAULT)
d.shell(f"settings put system screen_off_timeout {orig}")
_log.info(f"[{self.serial}] 恢复自动息屏(超时 {orig}ms)")
else: else:
d.shell("svc power stayon true") cur = ""
_log.info(f"[{self.serial}] 保持亮屏(充电时屏幕常亮)") try:
cur = (d.shell("settings get system screen_off_timeout") or "").strip()
except Exception:
pass
if self.serial not in _SCREEN_TIMEOUT_BACKUP:
# 没设过时 `settings get` 会返回 "null"(走系统默认)——别把 null 写回去
_SCREEN_TIMEOUT_BACKUP[self.serial] = \
cur if cur.isdigit() else _SCREEN_TIMEOUT_DEFAULT
d.shell("svc power stayon true") # 充电场景(顺带)
d.shell(f"settings put system screen_off_timeout {_SCREEN_TIMEOUT_MAX}")
d.shell("input keyevent 224") # 立刻唤醒(KEYCODE_WAKEUP,单向)
_log.info(f"[{self.serial}] 保持亮屏(息屏超时→最大,原值 "
f"{_SCREEN_TIMEOUT_BACKUP.get(self.serial)}ms)")
except Exception as e: except Exception as e:
_log.warning(f"[{self.serial}] keep_screen({mode}) 异常: {e}") _log.warning(f"[{self.serial}] keep_screen({mode}) 异常: {e}")
@@ -641,9 +730,9 @@ class GenericStepsTask(BaseTask):
def get_action_class(cls, action_type): def get_action_class(cls, action_type):
return None return None
def create_worker(self, serial, params): def create_worker(self, serial, params, ctx=None):
merged = {**DEFAULT_PARAMS, **(params or {})} merged = {**DEFAULT_PARAMS, **(params or {})}
return GenericStepsWorker(serial, params=merged) return GenericStepsWorker(serial, params=merged, ctx=ctx)
def test_step(serial, step): def test_step(serial, step):
+145 -7
View File
@@ -112,7 +112,19 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
.log-content .log-err{color:#f87171} .log-content .log-err{color:#f87171}
.log-content .log-warn{color:#fbbf24} .log-content .log-warn{color:#fbbf24}
.log-content .log-info{color:#67e8f9} .log-content .log-info{color:#67e8f9}
.log-toolbar{display:flex;gap:10px;margin-bottom:12px;align-items:center} .log-row{white-space:pre-wrap}
.log-cont{color:#8a94a6;padding-left:2.2em} /* 续行(traceback 缩进行):淡色缩进 */
.log-hit{background:#fbbf2433} /* 关键字命中高亮 */
/* 「步骤明细」表格的结果列配色(.log-* 那套作用域在 .log-content 里,表格用这套) */
.res-error{color:#dc2626;font-weight:600} /* 异常 / 未知步骤类型 */
.res-miss{color:#d97706} /* 未命中 / 超时 / 已达上限 */
.res-skip{color:var(--text-light)} /* 概率未触发 */
.res-ok{color:#059669}
/* 工具栏:控件较多,允许换行;控件本身不参与收缩(否则会被挤成竖排文字) */
.log-toolbar{display:flex;flex-wrap:wrap;gap:8px 10px;margin-bottom:12px;align-items:center}
.log-toolbar>*{flex:0 0 auto}
.log-toolbar .hint{flex:1 1 200px;min-width:160px}
/* 区块标题 */ /* 区块标题 */
.section-title{font-family:var(--display);font-size:15px;font-weight:700;color:var(--text);margin:0 0 12px;padding-bottom:8px;border-bottom:2px solid var(--primary);display:inline-block;letter-spacing:.2px} .section-title{font-family:var(--display);font-size:15px;font-weight:700;color:var(--text);margin:0 0 12px;padding-bottom:8px;border-bottom:2px solid var(--primary);display:inline-block;letter-spacing:.2px}
@@ -778,25 +790,103 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
<!-- ========== Tab 4: 日志 ========== --> <!-- ========== Tab 4: 日志 ========== -->
<div id="tab-logs" class="tab-panel"> <div id="tab-logs" class="tab-panel">
<div class="page-title">日志查看</div> <div class="page-title">日志查看</div>
<div class="page-sub">查看系统各模块运行日志</div> <div class="page-sub">原始文本日志 + 任务步骤明细(结构化,可按设备/任务/时间过滤)</div>
<div class="sub-tabs">
<button class="sub-tab active" data-sub="files" onclick="showSubTab('logs','files')">文件日志</button>
<button class="sub-tab" data-sub="steps" onclick="showSubTab('logs','steps')">步骤明细</button>
</div>
<!-- 子分栏 1:文件日志 -->
<div class="sub-panel active" id="logs-sub-files">
<div class="log-toolbar"> <div class="log-toolbar">
<select id="log-file" class="form-control" style="width:auto" onchange="loadLogs()"> <!-- 文件下拉由 /api/logs 返回的清单动态渲染(含 .1/.2 滚动历史与 notify.log) -->
<option value="core.log">核心日志 (core.log)</option> <select id="log-file" class="form-control" style="width:auto" onchange="loadLogs()"></select>
<option value="task.log">任务日志 (task.log)</option> <input id="log-q" class="form-control" style="width:190px" placeholder="关键字(设备号/包名/报错…)"
<option value="web.log">Web日志 (web.log)</option> onkeydown="if(event.key==='Enter')loadLogs()">
<option value="action.log">操作日志 (action.log)</option> <select id="log-level" class="form-control" style="width:auto" onchange="loadLogs()">
<option value="">全部级别</option>
<option value="ERROR">仅 ERROR</option>
<option value="WARNING">WARNING 及以上</option>
<option value="INFO">INFO 及以上</option>
</select> </select>
<input id="log-since" type="datetime-local" class="form-control" style="width:auto"
title="起始时间" onchange="loadLogs()">
<span class="text-muted">~</span>
<input id="log-until" type="datetime-local" class="form-control" style="width:auto"
title="结束时间" onchange="loadLogs()">
<select id="log-lines" class="form-control" style="width:auto" onchange="loadLogs()"> <select id="log-lines" class="form-control" style="width:auto" onchange="loadLogs()">
<option value="100">最近 100 行</option> <option value="100">最近 100 行</option>
<option value="300" selected>最近 300 行</option> <option value="300" selected>最近 300 行</option>
<option value="500">最近 500 行</option> <option value="500">最近 500 行</option>
<option value="1000">最近 1000 行</option> <option value="1000">最近 1000 行</option>
<option value="5000">最近 5000 行</option>
</select> </select>
<label class="form-check"><input type="checkbox" id="log-auto" checked onchange="toggleLogAuto()"> 自动刷新</label> <label class="form-check"><input type="checkbox" id="log-auto" checked onchange="toggleLogAuto()"> 自动刷新</label>
<button class="btn" onclick="loadLogs()">刷新</button> <button class="btn" onclick="loadLogs()">刷新</button>
<button class="btn" onclick="downloadLogs()" title="下载当前筛选结果(无筛选即整个文件)">下载</button>
<button class="btn" onclick="resetLogFilters()">重置</button>
<span class="hint" id="log-hint"></span> <span class="hint" id="log-hint"></span>
</div> </div>
<div id="log-content" class="log-content"></div> <div id="log-content" class="log-content"></div>
</div><!-- /logs-sub-files -->
<!-- 子分栏 2:步骤明细(task_step_log 表,结构化,可按设备/任务/时间/结果过滤) -->
<div class="sub-panel" id="logs-sub-steps">
<div class="log-toolbar">
<select id="sl-device" class="form-control" style="width:auto" onchange="loadStepLogs(true)"></select>
<select id="sl-job" class="form-control" style="width:auto" onchange="loadStepLogs(true)"></select>
<select id="sl-result" class="form-control" style="width:auto" onchange="loadStepLogs(true)">
<option value="">全部结果</option>
<option value="error">异常 error</option>
<option value="miss">未命中 miss</option>
<option value="unknown">未知类型 unknown</option>
<option value="skip">概率跳过 skip</option>
<option value="ok">正常 ok</option>
<option value="cap">已截断 cap</option>
</select>
<input id="sl-q" class="form-control" style="width:170px" placeholder="关键字(步骤名/选择器/详情)"
onkeydown="if(event.key==='Enter')loadStepLogs(true)">
<input id="sl-since" type="datetime-local" class="form-control" style="width:auto" onchange="loadStepLogs(true)">
<span class="text-muted">~</span>
<input id="sl-until" type="datetime-local" class="form-control" style="width:auto" onchange="loadStepLogs(true)">
<select id="sl-limit" class="form-control" style="width:auto" onchange="loadStepLogs(true)">
<option value="100">每页 100</option>
<option value="200" selected>每页 200</option>
<option value="500">每页 500</option>
</select>
<button class="btn" onclick="loadStepLogs(true)">刷新</button>
<button class="btn" onclick="downloadStepLogs()" title="导出当前筛选结果(CSV)">导出 CSV</button>
<button class="btn" onclick="resetStepFilters()">重置</button>
<span class="hint" id="sl-hint"></span>
</div>
<div class="hint" id="sl-note"></div>
<details id="sl-runs-box" style="margin:10px 0">
<summary style="cursor:pointer;font-size:13px" id="sl-runs-summary">最近运行概览</summary>
<table class="table" style="margin-top:8px">
<thead><tr>
<th style="width:150px">开始</th><th style="width:110px">设备</th><th>任务</th>
<th style="width:70px">步骤数</th><th style="width:80px">失败</th><th style="width:70px"></th>
</tr></thead>
<tbody id="sl-runs-body"></tbody>
</table>
</details>
<table class="table">
<thead><tr>
<th style="width:145px">时间</th><th style="width:105px">设备</th><th style="width:150px">任务</th>
<th style="width:60px">步骤</th><th>标签</th><th style="width:95px">类型</th>
<th style="width:75px">结果</th><th style="width:65px">耗时</th><th>详情</th>
</tr></thead>
<tbody id="sl-body"></tbody>
</table>
<div class="toolbar" style="margin-top:10px">
<button class="btn" id="sl-prev" onclick="stepLogPage(-1)">上一页</button>
<span class="hint" id="sl-page"></span>
<button class="btn" id="sl-next" onclick="stepLogPage(1)">下一页</button>
</div>
</div><!-- /logs-sub-steps -->
</div> </div>
<!-- ========== Tab 5: 用户 ========== --> <!-- ========== Tab 5: 用户 ========== -->
@@ -1112,6 +1202,7 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
<div class="sub-tabs"> <div class="sub-tabs">
<button class="sub-tab active" data-sub="backup" onclick="showSubTab('system','backup')">数据备份</button> <button class="sub-tab active" data-sub="backup" onclick="showSubTab('system','backup')">数据备份</button>
<button class="sub-tab" data-sub="restore" onclick="showSubTab('system','restore')">导入恢复</button> <button class="sub-tab" data-sub="restore" onclick="showSubTab('system','restore')">导入恢复</button>
<button class="sub-tab" data-sub="notify" onclick="showSubTab('system','notify')">通知 / Webhook</button>
</div> </div>
<!-- 子分栏 1:数据备份 --> <!-- 子分栏 1:数据备份 -->
@@ -1151,6 +1242,51 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
</div> </div>
<div id="restore-preview" style="margin-top:8px"></div> <div id="restore-preview" style="margin-top:8px"></div>
</div> </div>
<!-- 子分栏 3:通知 / Webhook -->
<div class="sub-panel" id="system-sub-notify">
<div class="help">
<b>通知</b>:把平台事件(任务失败/成功、设备上下线、安装完成、备份恢复…)推到 webhook,
例如企业微信群机器人。每条 webhook <b>自己勾选关心哪些事件</b>——不勾就不推。
<br><span class="text-muted">为防刷屏:同一条 webhook 默认每 30 秒合并一次同类事件、每分钟最多 18 条
(企业微信硬限 20),超出的会压成一条「被限流折叠」摘要而不是丢掉。详见 <code>doc/NOTIFY.md</code>。</span>
</div>
<div class="toolbar" style="gap:10px;flex-wrap:wrap">
<button class="btn btn-primary" onclick="openNotifyModal(null)" data-perm="admin">+ 新建 Webhook</button>
<button class="btn" onclick="loadNotifyPanel()">刷新</button>
<label style="display:flex;align-items:center;gap:6px;font-size:12.5px;cursor:pointer"
title="关掉后所有 webhook 暂停推送(配置保留)">
<input type="checkbox" id="notify-global" onchange="toggleNotifyGlobal(this)"> 启用通知
</label>
<span class="text-muted" style="font-size:11.5px">默认聚合
<input type="number" id="notify-agg" min="0" max="600" class="form-control"
style="width:64px;display:inline-block"> 秒 ·
限流 <input type="number" id="notify-rate" min="0" max="60" class="form-control"
style="width:64px;display:inline-block"> 条/分
<button class="btn btn-xs" onclick="saveNotifyGlobal()">保存默认</button>
</span>
</div>
<table class="table">
<thead><tr><th>名称</th><th>格式</th><th>URL</th><th>订阅</th><th>状态</th><th>操作</th></tr></thead>
<tbody id="tb-notify-hooks"></tbody>
</table>
<div style="margin-top:18px;display:flex;align-items:center;gap:10px">
<b>发送记录</b>
<span class="text-muted" style="font-size:11.5px">仅本次运行期间(重启清空);完整历史见 <code>logs/notify.log</code></span>
<button class="btn btn-xs" onclick="loadNotifyPanel()">刷新记录</button>
</div>
<table class="table">
<thead><tr><th style="width:160px">时间</th><th style="width:120px">Webhook</th><th style="width:180px">事件</th><th style="width:70px">结果</th><th>详情</th></tr></thead>
<tbody id="tb-notify-logs"></tbody>
</table>
</div>
</div>
<!-- 通知:编辑弹窗(内容由 static/admin/notify.js 渲染) -->
<div id="notify-modal-overlay" onclick="if(event.target===this)closeNotifyModal()"
style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:1340;align-items:flex-start;justify-content:center;overflow-y:auto;padding:30px 0">
<div id="notify-modal-box" style="background:#fff;border-radius:14px;padding:20px;width:760px;max-width:94vw;margin:auto"></div>
</div> </div>
</div><!-- /.content --> </div><!-- /.content -->
@@ -1219,5 +1355,7 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
<script src="/static/admin/agent.js"></script> <script src="/static/admin/agent.js"></script>
<script src="/static/admin/taskgen.js"></script> <script src="/static/admin/taskgen.js"></script>
<script src="/static/admin/system.js"></script> <script src="/static/admin/system.js"></script>
<script src="/static/admin/notify.js"></script>
<script src="/static/admin/steplog.js"></script>
</body> </body>
</html> </html>
+3 -1
View File
@@ -11,6 +11,7 @@
tailscale — Tailscale 管理 tailscale — Tailscale 管理
system — 系统数据备份导出/导入 system — 系统数据备份导出/导入
device_agent— 设备端 Agent 专用接口(应用商店:清单/下载/上报)+ 其管理端配置 device_agent— 设备端 Agent 专用接口(应用商店:清单/下载/上报)+ 其管理端配置
notify — 通知 / Webhook 管理(配置、测试发送、发送记录、事件目录)
""" """
from flask import Blueprint from flask import Blueprint
@@ -30,7 +31,8 @@ def register_blueprints(app):
_agent_mod.set_app(app) _agent_mod.set_app(app)
from .system_api import bp as system_bp from .system_api import bp as system_bp
from .device_agent_api import bp as devagent_bp from .device_agent_api import bp as devagent_bp
from .notify_api import bp as notify_bp
for bp in (auth_bp, monitor_bp, tasks_bp, admin_bp, tools_bp, for bp in (auth_bp, monitor_bp, tasks_bp, admin_bp, tools_bp,
devices_bp, apks_bp, tailscale_bp, agent_bp, system_bp, devices_bp, apks_bp, tailscale_bp, agent_bp, system_bp,
devagent_bp): devagent_bp, notify_bp):
app.register_blueprint(bp) app.register_blueprint(bp)
+167 -14
View File
@@ -1,17 +1,19 @@
"""管理域 API:用户管理/日志查看。""" """管理域 API:用户管理/日志查看。"""
import os
import time import time
from io import BytesIO
from flask import Blueprint, jsonify, request from flask import Blueprint, jsonify, request, send_file
from flask_login import current_user from flask_login import current_user
from flask import session from flask import session
from core.logger import _LOG_DIR, _MODULE_FILES from core import logger
from core.logger import get_logger
from core.models import db, User from core.models import db, User
from web import context from web import context
from web.auth import (admin_required, perm_required, _validate_perms, from web.auth import (admin_required, perm_required, _validate_perms,
PERM_LOGS) PERM_LOGS)
_log = get_logger("web")
bp = Blueprint("admin", __name__) bp = Blueprint("admin", __name__)
@bp.route("/api/users") @bp.route("/api/users")
@@ -79,22 +81,173 @@ def api_users_delete(uid):
# ================== API:日志查看 ================== # ================== API:日志查看 ==================
#
# 过滤/白名单都在 core.logger 里做(读取侧与写入侧共用同一份文件清单,
# 见 core/logger.py「日志读取」一节);这里只负责取参数 + 组装响应。
def _log_filters():
"""从 query string 取过滤条件(/api/logs 与 /api/logs/download 共用)。"""
return dict(keyword=request.args.get("q", ""),
min_level=request.args.get("level", ""),
since=request.args.get("since", ""),
until=request.args.get("until", ""))
@bp.route("/api/logs") @bp.route("/api/logs")
@perm_required(PERM_LOGS) @perm_required(PERM_LOGS)
def api_logs(): def api_logs():
files = list(_MODULE_FILES.values()) """读日志:可按关键字 / 最低级别 / 时间范围过滤,返回结构化行。
current = request.args.get("file", "core.log")
lines = int(request.args.get("lines", 300)) 参数:file 文件名(白名单)、q 关键字、level 最低级别(ERROR 含以上…)、
content = "" since/until 时间、lines 返回条数(取**最近** N 条命中)。
path = os.path.join(_LOG_DIR, current) """
if os.path.exists(path): files = logger.list_log_files()
names = [f["name"] for f in files]
# 默认仍落在 core.log(接口按 mtime 倒序返回,names[0] 会随当前哪个模块在
# 写而漂移,作为"打开日志页时看哪个"不稳定)
default = "core.log" if "core.log" in names else (names[0] if names else "")
current = request.args.get("file") or default
try: try:
with open(path, encoding="utf-8") as f: lines = int(request.args.get("lines", 300))
content = "".join(f.readlines()[-lines:]) except (TypeError, ValueError):
except Exception as e: lines = 300
content = f"读取失败: {e}" res = logger.query_log(current, limit=lines, **_log_filters())
return jsonify({"ok": True, "content": content, "file": current, "files": files}) return jsonify({"ok": not res["error"], "error": res["error"], "file": current,
"files": files, "rows": res["rows"], "matched": res["matched"],
"scanned": res["scanned"], "truncated": res["truncated"]})
@bp.route("/api/logs/download")
@perm_required(PERM_LOGS)
def api_logs_download():
"""下载日志文件(带同样的过滤条件;无条件时就是整个文件)。
用 BytesIO 发送而不是 send_file(路径):Windows 上流式发送时文件句柄可能
到 close 仍未释放,而日志文件正被日志线程持续写入,按路径发容易踩锁。
"""
name = request.args.get("file", "")
text, err = logger.read_log_text(name, **_log_filters())
if err:
return jsonify({"ok": False, "error": err}), 404
data = text.encode("utf-8")
fname = f"{name}_{time.strftime('%Y%m%d_%H%M%S')}.txt"
_log.info("下载日志: %s(%d 字节)by %s", name, len(data),
getattr(current_user, "username", ""))
return send_file(BytesIO(data), as_attachment=True, download_name=fname,
mimetype="text/plain; charset=utf-8")
# ================== API:任务步骤明细 ==================
#
# 数据源是 task_step_log 表(结构化),不是 logs/task.log 文本——所以能按设备/
# 任务/时间/结果过滤。写入侧见 core/step_log.py(异步写线程 + 保留期清理)。
def _step_filters():
"""从 query string 取步骤明细的过滤条件(列表与下载共用)。"""
return dict(serial=request.args.get("serial", ""),
job_id=request.args.get("job_id", ""),
result=request.args.get("result", ""),
run_id=request.args.get("run_id", ""),
keyword=request.args.get("q", ""),
since=logger.norm_ts(request.args.get("since", "")),
until=logger.norm_ts(request.args.get("until", ""), end=True))
def _step_to_dict(r):
return {"id": r.id, "run_id": r.run_id, "job_id": r.job_id,
"job_name": r.job_name, "serial": r.serial,
"device_name": r.device_name, "step_path": r.step_path,
"step_label": r.step_label, "step_type": r.step_type,
"selector": r.selector, "result": r.result, "detail": r.detail,
"duration_ms": r.duration_ms, "created_at": r.created_at}
@bp.route("/api/step_logs")
@perm_required(PERM_LOGS)
def api_step_logs():
"""任务步骤明细(分页,最近的在前)。"""
from core import step_log
try:
limit = int(request.args.get("limit", 200))
offset = int(request.args.get("offset", 0))
except (TypeError, ValueError):
limit, offset = 200, 0
rows, total = step_log.query(limit=limit, offset=offset, **_step_filters())
return jsonify({"ok": True, "rows": [_step_to_dict(r) for r in rows],
"total": total, "limit": limit, "offset": offset,
"stats": step_log.stats(),
"keep_days": step_log.KEEP_DAYS,
"max_rows_per_run": step_log.MAX_ROWS_PER_RUN})
@bp.route("/api/step_logs/runs")
@perm_required(PERM_LOGS)
def api_step_logs_runs():
"""按 run_id 归组的一次运行概览(哪次运行、几步、失败几步)。"""
from core import step_log
f = _step_filters()
rows = step_log.runs(serial=f["serial"], job_id=f["job_id"],
since=f["since"], until=f["until"],
limit=request.args.get("limit", 100))
return jsonify({"ok": True, "runs": [
{"run_id": r.run_id, "job_id": r.job_id, "job_name": r.job_name,
"serial": r.serial, "device_name": r.device_name,
"started_at": r.started_at, "ended_at": r.ended_at,
"steps": int(r.steps or 0), "failures": int(r.failures or 0)}
for r in rows]})
@bp.route("/api/step_logs/filters")
@perm_required(PERM_LOGS)
def api_step_logs_filters():
"""筛选下拉的可选项:**只在明细里出现过的**设备与任务(按数据自洽,不依赖
设备池/任务表,也不要求调用方另有 task/device 权限)。"""
from core import step_log
from core.models import TaskStepLog, db
devices = (db.session.query(TaskStepLog.serial, TaskStepLog.device_name)
.filter(TaskStepLog.serial != "").distinct().limit(500).all())
jobs = (db.session.query(TaskStepLog.job_id, TaskStepLog.job_name)
.filter(TaskStepLog.job_id != "").distinct().limit(500).all())
return jsonify({"ok": True,
"devices": sorted(({"serial": s, "device_name": n}
for s, n in devices),
key=lambda x: x["device_name"] or x["serial"]),
"jobs": sorted(({"job_id": i, "job_name": n} for i, n in jobs),
key=lambda x: x["job_name"] or x["job_id"]),
"results": ["ok", "miss", "error", "unknown", "skip", "cap"],
"keep_days": step_log.KEEP_DAYS,
"max_rows_per_run": step_log.MAX_ROWS_PER_RUN})
@bp.route("/api/step_logs/download")
@perm_required(PERM_LOGS)
def api_step_logs_download():
"""导出步骤明细为 CSV(带同样的过滤条件)。
加 UTF-8 BOM:不加的话 Excel 打开中文是乱码(这是给运维看的表,
大概率会被 Excel 打开)。
"""
import csv
from io import StringIO
from core import step_log
f = _step_filters()
# 导出上限:和列表页共用同一次查询,最多 2 万行(再多请缩小时间范围)
rows, total = step_log.query(limit=20000, offset=0, **f)
buf = StringIO()
w = csv.writer(buf)
w.writerow(["时间", "设备", "设备名", "任务", "运行ID", "步骤路径", "步骤",
"类型", "选择器", "结果", "耗时(ms)", "详情"])
for r in reversed(rows): # 导出的时间序与页面相反:文件里按正序更好读
w.writerow([r.created_at, r.serial, r.device_name, r.job_name,
r.run_id, r.step_path, r.step_label, r.step_type,
r.selector, r.result, r.duration_ms, r.detail])
data = buf.getvalue().encode("utf-8-sig") # utf-8-sig = UTF-8 带 BOM(Excel 中文不乱码)
fname = f"step_log_{time.strftime('%Y%m%d_%H%M%S')}.csv"
_log.info("导出步骤明细: %d 行(命中 %d)by %s", len(rows), total,
getattr(current_user, "username", ""))
return send_file(BytesIO(data), as_attachment=True, download_name=fname,
mimetype="text/csv; charset=utf-8")
# ================== API:运行控制 ================== # ================== API:运行控制 ==================
+10
View File
@@ -836,6 +836,8 @@ def run_experience_audit():
cfg = _read_cfg() cfg = _read_cfg()
if not cfg.get("api_key"): if not cfg.get("api_key"):
_log.info("经验巡检跳过:未配置 API Key") _log.info("经验巡检跳过:未配置 API Key")
from core import notifier
notifier.notify("ai.audit.skipped", reason="未配置模型 API Key")
return return
rows = db.session.execute(db.text( rows = db.session.execute(db.text(
"SELECT id, task_prompt, recipe, tool_seq, hits FROM agent_experience " "SELECT id, task_prompt, recipe, tool_seq, hits FROM agent_experience "
@@ -868,8 +870,16 @@ def run_experience_audit():
summary = f"评审 {reviewed} 条,建议删除 {suggested} 条(待人工确认)" summary = f"评审 {reviewed} 条,建议删除 {suggested} 条(待人工确认)"
_audit_state.update(last=now, last_summary=summary) _audit_state.update(last=now, last_summary=summary)
_log.info("经验巡检完成: %s", summary) _log.info("经验巡检完成: %s", summary)
from core import notifier
notifier.notify("ai.audit.finished", reviewed=reviewed,
suggested=suggested, summary=summary)
except Exception as e: except Exception as e:
_log.warning(f"经验巡检异常: {e}") _log.warning(f"经验巡检异常: {e}")
try:
from core import notifier
notifier.notify("ai.audit.failed", error=str(e)[:200])
except Exception:
pass
finally: finally:
_audit_state["running"] = False _audit_state["running"] = False
+5
View File
@@ -144,6 +144,11 @@ def login():
login_user(user) login_user(user)
_csrf_token() # 建立 CSRF token,前端通过 /api/csrf 获取 _csrf_token() # 建立 CSRF token,前端通过 /api/csrf 获取
_log.info(f"用户 {username} 登录") _log.info(f"用户 {username} 登录")
try:
from core import notifier
notifier.notify("user.login", username=username)
except Exception:
pass
return redirect(request.args.get("next") or url_for("auth.index")) return redirect(request.args.get("next") or url_for("auth.index"))
return render_template("admin/login.html", error="用户名或密码错误") return render_template("admin/login.html", error="用户名或密码错误")
return render_template("admin/login.html", error=None) return render_template("admin/login.html", error=None)
+5 -2
View File
@@ -239,8 +239,11 @@ def api_device_screen_all():
if ":" in serial: if ":" in serial:
adb_connect(serial) adb_connect(serial)
if mode == "off": if mode == "off":
subprocess.run([ADB_PATH, "-s", serial, "shell", "input", "keyevent", "26"], # KEYCODE_SLEEP(223) 是**单向**熄屏;不能用 KEYCODE_POWER(26)——
capture_output=True, timeout=15) # KEYCODE_POWER 息屏 # 那是电源键**开关**,对已经息屏的设备会把屏幕点亮(2026-09-14 用户反馈:
# "一键息屏"把睡着的设备唤醒了,看着像按钮反了)
subprocess.run([ADB_PATH, "-s", serial, "shell", "input", "keyevent", "223"],
capture_output=True, timeout=15)
else: else:
subprocess.run([ADB_PATH, "-s", serial, "shell", "input", "keyevent", "224"], subprocess.run([ADB_PATH, "-s", serial, "shell", "input", "keyevent", "224"],
capture_output=True, timeout=15) # WAKEUP capture_output=True, timeout=15) # WAKEUP
+190
View File
@@ -0,0 +1,190 @@
"""通知 / Webhook 管理接口(系统级设置,全部 admin_required)。
配置读写统一走 `core/notifier`(它落 `app_meta.notify_webhooks` 一个键)——本模块
只做"取配置 → 改一处 → 存回去"的编排,**校验与持久化都在 notifier 里**,不在 web 层
重复实现(避免两份规则漂移)。
安全口径:
- URL 里有凭据(企微 `?key=`)→ 回显一律 `notifier.mask_url()`;
- 前端"留空不修改":提交上来的 url 若是打码值或空,`save_config` 会保留原值;
- `secret` 永不回显,只回 `secret_set`。
"""
import uuid
from flask import Blueprint, jsonify, request
from flask_login import current_user
from core import notify_events, notifier
from core.logger import get_logger
from web.auth import admin_required
_log = get_logger("web.notify")
bp = Blueprint("notify", __name__)
def _operator():
try:
return current_user.username or "admin"
except Exception:
return "admin"
def _formats():
"""可用格式(含未实现的,前端据此置灰)。
每项带**该格式自己的提示文案**(URL 占位/说明、密钥标签、限流说明)——
这些必须跟着格式走,不能把企业微信的说明写死在页面上。
"""
out = [cls.meta() for _, cls in notifier.ADAPTERS.items()]
for name, label in notifier.PLANNED_FORMATS.items():
out.append({"name": name, "label": label + "(未实现)", "implemented": False,
"byte_limit": 0, "limit_default": 20, "needs_template": False,
"secret_label": "签名密钥(可选)", "secret_help": "",
"url_hint": "", "url_help": "该格式尚未实现,可先用「通用 JSON」手搓",
"limit_help": ""})
return out
@bp.route("/api/notify/webhooks")
@admin_required
def api_notify_webhooks():
"""全部 webhook 配置(url 打码、secret 只回是否配置过)+ 格式清单 + 事件目录。"""
cfg = notifier.get_public_config()
return jsonify({"ok": True, "webhooks": cfg["webhooks"], "settings": cfg["settings"],
"formats": _formats(), "meta_key": notifier.META_KEY,
"max_hooks": notifier.MAX_HOOKS,
"planned": list(notifier.PLANNED_FORMATS)})
@bp.route("/api/notify/webhooks", methods=["POST"])
@admin_required
def api_notify_webhook_create():
"""新建一条 webhook。body 即该条配置(见 doc/NOTIFY.md 的字段表)。"""
data = request.json or {}
cfg = notifier.get_config()
if len(cfg["webhooks"]) >= notifier.MAX_HOOKS:
return jsonify({"ok": False, "error": f"最多 {notifier.MAX_HOOKS} 条"}), 400
new_id = "wh_" + uuid.uuid4().hex[:8]
hook = dict(data)
hook["id"] = new_id
cfg["webhooks"].append(hook)
ok, msg = notifier.save_config(cfg)
if not ok:
return jsonify({"ok": False, "error": msg, "errors": msg
if isinstance(msg, list) else None}), 400
_log.info("新增通知 webhook: %s(%s)by %s", hook.get("name"), new_id, _operator())
return jsonify({"ok": True, "msg": "已创建", "id": new_id})
@bp.route("/api/notify/webhooks/<hook_id>", methods=["PUT"])
@admin_required
def api_notify_webhook_update(hook_id):
"""更新一条(部分字段;url/secret 省略或为打码值时保持原值)。"""
data = request.json or {}
cfg = notifier.get_config()
target = next((h for h in cfg["webhooks"] if h["id"] == hook_id), None)
if not target:
return jsonify({"ok": False, "error": "webhook 不存在"}), 404
for k, v in data.items():
if k in ("id", "created_at"):
continue
target[k] = v
ok, msg = notifier.save_config(cfg)
if not ok:
return jsonify({"ok": False, "error": msg, "errors": msg
if isinstance(msg, list) else None}), 400
_log.info("更新通知 webhook: %s by %s", hook_id, _operator())
return jsonify({"ok": True, "msg": "已保存"})
@bp.route("/api/notify/webhooks/<hook_id>", methods=["DELETE"])
@admin_required
def api_notify_webhook_delete(hook_id):
cfg = notifier.get_config()
before = len(cfg["webhooks"])
cfg["webhooks"] = [h for h in cfg["webhooks"] if h["id"] != hook_id]
if len(cfg["webhooks"]) == before:
return jsonify({"ok": False, "error": "webhook 不存在"}), 404
ok, msg = notifier.save_config(cfg)
if not ok:
return jsonify({"ok": False, "error": msg}), 400
_log.info("删除通知 webhook: %s by %s", hook_id, _operator())
return jsonify({"ok": True, "msg": "已删除"})
@bp.route("/api/notify/webhooks/<hook_id>/test", methods=["POST"])
@admin_required
def api_notify_webhook_test(hook_id):
"""同步发一条测试消息(用户等结果),返回真实 HTTP 状态与平台错误码。
⚠ 不占业务令牌桶:否则管理员点两下测试就把业务通知的配额吃掉了。
"""
cfg = notifier.get_config()
hook = next((h for h in cfg["webhooks"] if h["id"] == hook_id), None)
if not hook:
return jsonify({"ok": False, "error": "webhook 不存在"}), 404
event = ((request.json or {}).get("event") or "notify.test").strip()
rec = notifier.send_test(hook, event=event, operator=_operator())
return jsonify({"ok": bool(rec.get("ok")),
"msg": "测试消息已发出" if rec.get("ok") else "发送失败",
"http_status": rec.get("http_status"),
"errcode": rec.get("errcode"),
"elapsed_ms": rec.get("elapsed_ms"),
"attempts": rec.get("attempts"),
"error": rec.get("error") or "",
"url": notifier.mask_url(hook.get("url", ""))})
@bp.route("/api/notify/preview", methods=["POST"])
@admin_required
def api_notify_preview():
"""保存前预览:真实请求体 + UTF-8 字节数 + 是否会被截断。"""
data = request.json or {}
hook = data.get("hook") or {}
# 预览时 URL 可能还没填:给个占位,只关心渲染结果
hook = dict(hook)
hook.setdefault("url", "https://example.invalid/hook")
out = notifier.preview(hook, event=(data.get("event") or "notify.test"))
return jsonify({"ok": not out.get("error"), **out})
@bp.route("/api/notify/events")
@admin_required
def api_notify_events():
"""事件目录(前端画勾选树 / 模板占位符速查)。"""
rows = notify_events.list_events()
cats = []
for e in rows:
if e["category"] not in cats:
cats.append(e["category"])
return jsonify({"ok": True, "events": rows, "categories": cats})
@bp.route("/api/notify/logs")
@admin_required
def api_notify_logs():
"""最近发送记录(内存环形缓冲,重启清空;历史见 logs/notify.log)。"""
limit = request.args.get("limit", 50)
try:
limit = max(1, min(int(limit), 200))
except (TypeError, ValueError):
limit = 50
return jsonify({"ok": True, "logs": notifier.get_logs(limit),
"note": "仅显示本次运行期间记录;历史见 logs/notify.log"})
@bp.route("/api/notify/settings", methods=["POST"])
@admin_required
def api_notify_settings():
"""全局设置:global_enabled / default_agg_window / default_rate_limit / log_keep。"""
data = request.json or {}
cfg = notifier.get_config()
for k in ("global_enabled", "default_agg_window", "default_rate_limit",
"log_keep", "http_timeout"):
if k in data and data[k] is not None:
cfg["settings"][k] = data[k]
ok, msg = notifier.save_config(cfg)
if not ok:
return jsonify({"ok": False, "error": msg}), 400
_log.info("更新通知全局设置 by %s: %s", _operator(), data)
return jsonify({"ok": True, "msg": "已保存"})
+9
View File
@@ -9,6 +9,7 @@
from io import BytesIO from io import BytesIO
from flask import Blueprint, jsonify, request, send_file from flask import Blueprint, jsonify, request, send_file
from flask_login import current_user
from web.auth import admin_required from web.auth import admin_required
from core import system_backup as sb from core import system_backup as sb
@@ -39,6 +40,14 @@ def api_system_backup_export():
with open(zip_path, "rb") as f: with open(zip_path, "rb") as f:
data = f.read() data = f.read()
sb.remove_quiet(zip_path) sb.remove_quiet(zip_path)
try:
from core import notifier
notifier.notify("system.backup.exported", filename=fname, size=len(data),
tables=len(manifest.get("tables") or []),
include_apk=include_apk,
user=getattr(current_user, "username", ""))
except Exception:
pass # 通知失败不影响导出
resp = send_file(BytesIO(data), as_attachment=True, download_name=fname, resp = send_file(BytesIO(data), as_attachment=True, download_name=fname,
mimetype="application/zip") mimetype="application/zip")
return resp return resp
+59
View File
@@ -66,6 +66,19 @@ def _consume_pending_restore():
# 先初始化数据库(含旧 JSON 迁移),再创建 TaskManager(需要 app context 读写 DB) # 先初始化数据库(含旧 JSON 迁移),再创建 TaskManager(需要 app context 读写 DB)
init_db(app) init_db(app)
# 通知模块:**必须早于 _consume_pending_restore()**——启动期就要能发「备份已恢复」
# 这类通知(notify 在未就绪时静默丢弃,晚初始化就丢事件了)
try:
from core import notifier as _notifier
_notifier.init_app(app)
except Exception as _e: # 通知出问题不影响启动
_log.warning(f"通知模块初始化失败(不影响启动): {_e}")
# 任务步骤明细:起一个专用写线程,任务线程只入队(见 core/step_log.py)
try:
from core import step_log as _step_log
_step_log.init_app(app)
except Exception as _e:
_log.warning(f"步骤明细模块初始化失败(不影响启动): {_e}")
# 消费「待生效的备份恢复」——必须在 init_db 之后(表已建好,且现在是事务替换而非换文件) # 消费「待生效的备份恢复」——必须在 init_db 之后(表已建好,且现在是事务替换而非换文件)
_consume_pending_restore() _consume_pending_restore()
# 库环境标签校验 + 启动横幅:每次启动都明确写出「现在连的是哪个库」, # 库环境标签校验 + 启动横幅:每次启动都明确写出「现在连的是哪个库」,
@@ -97,6 +110,24 @@ from web.auth import _csrf_protect, _csrf_token
context.init(mgr, apk_mgr, device_pool) context.init(mgr, apk_mgr, device_pool)
register_blueprints(app) register_blueprints(app)
def _purge_step_log():
"""清理超期的任务步骤明细(启动时 + 每日 04:13)。
app context 由本函数自建:APScheduler 的作业跑在调度线程里,没有请求上下文。
"""
try:
from core.step_log import purge_old
with app.app_context():
purge_old()
except Exception as e:
_log.warning(f"步骤明细清理失败(不影响主服务): {e}")
def _purge_step_log_async():
"""启动时后台清理一次(不阻塞启动;服务若频繁重启,定时任务可能一直轮不到)。"""
threading.Thread(target=_purge_step_log, daemon=True).start()
# 经验库每日 AI 巡检(凌晨 03:47):评审标记疑似问题经验,删除只走人工确认。 # 经验库每日 AI 巡检(凌晨 03:47):评审标记疑似问题经验,删除只走人工确认。
# 巡检线程在 run_experience_audit 内自建 app context,不依赖这里。 # 巡检线程在 run_experience_audit 内自建 app context,不依赖这里。
try: try:
@@ -105,6 +136,9 @@ try:
from web.agent_api import run_experience_audit from web.agent_api import run_experience_audit
_audit_sched = BackgroundScheduler(timezone="Asia/Shanghai") _audit_sched = BackgroundScheduler(timezone="Asia/Shanghai")
_audit_sched.add_job(run_experience_audit, CronTrigger(hour=3, minute=47)) _audit_sched.add_job(run_experience_audit, CronTrigger(hour=3, minute=47))
# 步骤明细清理:超过保留期(core/step_log.KEEP_DAYS)的记录每天删一次。
# 不清理的话这张表是唯一无界增长的表,会一路顶大备份包。
_audit_sched.add_job(_purge_step_log, CronTrigger(hour=4, minute=13))
_audit_sched.start() _audit_sched.start()
_log.info("经验库每日巡检已注册(03:47 Asia/Shanghai)") _log.info("经验库每日巡检已注册(03:47 Asia/Shanghai)")
except Exception as e: except Exception as e:
@@ -287,10 +321,35 @@ if __name__ == "__main__":
_ensure_uiauto_running() _ensure_uiauto_running()
# 预连接设备池:adb server 重启后设备全掉线,后台并发重连加速恢复 # 预连接设备池:adb server 重启后设备全掉线,后台并发重连加速恢复
_preconnect_pool_devices() _preconnect_pool_devices()
# 步骤明细清理:服务频繁重启时定时任务可能一直轮不到,启动也清一次
_purge_step_log_async()
_log.info(f"管理后台: http://localhost:{WEB_PORT}/ (admin/admin123)") _log.info(f"管理后台: http://localhost:{WEB_PORT}/ (admin/admin123)")
# 启动通知:放在 __main__ 里是刻意的——以 WSGI 方式 import 本模块只有"半个启动"
# (阶段 A~F 在 import 期就跑完了),不该发"服务已启动"。
try:
from core import notifier as _notifier
with app.app_context():
_devs = len(device_pool.list_configured())
_notifier.notify("service.started", env=db_config.env_badge().get("env", ""),
db_target=db_config.describe_target(),
device_count=_devs, job_count=len(mgr.jobs))
except Exception as _e:
_log.warning(f"发送启动通知失败(不影响启动): {_e}")
_t0 = time.time()
try: try:
_run_server(WEB_HOST, WEB_PORT) _run_server(WEB_HOST, WEB_PORT)
finally: finally:
try:
from core import notifier as _notifier
_notifier.notify("service.stopping", uptime_s=int(time.time() - _t0))
_notifier.shutdown(timeout=3.0)
except Exception:
pass
try:
from core import step_log as _step_log
_step_log.shutdown(timeout=3.0) # 排空队列后再退
except Exception:
pass
mgr.shutdown() mgr.shutdown()
device_discovery.shutdown() device_discovery.shutdown()
_stop_uiauto() _stop_uiauto()