feat(任务): 任务级「公共巡检」——独立于步骤画布的守护条件(含 webhook 通知)
需求:任务编辑器里能单独配"这个任务每隔 N 秒检查一次"——熄屏就点亮、某个元素
出现就通知、掉出 App 就停本设备;通知标题正文要能自己写。
配置与执行分离(这是本次的关键设计):
- **配置是任务级的**(`params.watchers`),在任务编辑器单独一块,不进步骤画布;
- **执行是穿插的**:worker 每执行完一步、以及长等待的每个分片,看一眼哪个巡检
到点了。不起线程 → 不需要并发模型,也不会和主流程抢屏幕(两边同时点屏幕会
互相打断)。代价是精度受步长影响(某步卡 30s,巡检最多晚 30s),已在文档写明。
- core/patrol.py(新):检查项/动作注册表(CHECKS/ACTIONS)+ evaluate/act。
检查:屏幕熄灭/亮着、元素存在/不存在、前台是/不是某 App;
动作:只通知、点亮、息屏、停止本设备。屏幕走 `dumpsys power`(0.3s,
不用 d.info——那玩意在部分设备要 14s),前台走 d.app_current()(0.7s)。
- tasks/generic/task.py:`_maybe_patrol` / `_run_patrol`(冷却、命中记一条
步骤明细、发通知);**文案在动作之前渲染**——点亮后 {screen} 就成了"亮屏",
用户要看的是"发现熄屏,已点亮"。
- 任务编辑器新增「公共巡检」块(static/admin/tasks.js)+ 样式;保存进 params.watchers。
- 通知:新增事件 `task.patrol.hit`(巡检命中)与 `task.notify.custom`(步骤发通知);
给了 title 就用它当标题(不再拼前缀),level 字段可点名级别。
顺带(巡检需要的原语,也可单独用):
- if_el 条件判断支持 `selector_type=screen`(亮/熄)与 `foreground`(前台包名);
非元素条件不参与「选择器健康」统计(否则会攒出假的"选择器失效"告警)。
- 新增两个步骤:`notify`(发自定义通知)、`stop_self`(停本设备,记"被停止"
而不是失败,不触发重试)。步骤类型 18 → 20,相关文档计数一并更新。
修 bug:`wait` 步骤在巡检耗时超过剩余时间后 `sleep(负数)` 抛
"sleep length must be non-negative"(真机联调抓到,已 clamp 到 0)。
自测:假设备单测 12 组(命中/冷却/间隔/元素/前台/停止/静默/异常不炸);
真机联调:熄屏→点亮(False→True)+ 通知文案正确、掉出抖音按间隔命中 5 次、
长等待里穿插生效且步骤回到 ok、清理后用户通知配置原样恢复。
文档:TASK_DEV §4.5(含两个可抄的例子)与步骤表/条件类型、NOTIFY §3、README。
This commit is contained in:
+18
-7
@@ -310,10 +310,15 @@ _LEVEL_BY_HINT = (
|
||||
)
|
||||
|
||||
|
||||
def _level_of(event_key):
|
||||
for hints, lv in _LEVEL_BY_HINT:
|
||||
def _level_of(event_key, fields=None):
|
||||
"""消息级别:**事件自己带的 `level` 优先**(步骤「发通知」/巡检都能自己选),
|
||||
否则按事件 key 猜(failed→error、success→success…),猜不到算 info。"""
|
||||
lv = (fields or {}).get("level")
|
||||
if lv in ("info", "success", "warning", "error"):
|
||||
return lv
|
||||
for hints, lv2 in _LEVEL_BY_HINT:
|
||||
if any(h in event_key for h in hints):
|
||||
return lv
|
||||
return lv2
|
||||
return "info"
|
||||
|
||||
|
||||
@@ -331,6 +336,8 @@ _FIELD_LABELS = {
|
||||
"timeout_s": "超时(秒)", "task_job": "原任务", "model": "型号",
|
||||
"apk_name": "应用", "apk_id": "应用ID", "package_name": "包名",
|
||||
"success": "成功", "failed": "失败", "skipped": "跳过", "total": "总数",
|
||||
"title": "标题", "message": "内容", "patrol_name": "巡检",
|
||||
"check_label": "检查", "action_label": "动作", "detail": "结果",
|
||||
"stopped": "停止", "failed_devices": "失败设备",
|
||||
"failed_items": "失败明细", "filename": "文件", "size": "大小(字节)",
|
||||
"tables": "表数", "include_apk": "含APK", "user": "操作人", "operator": "操作人",
|
||||
@@ -350,7 +357,9 @@ def _label(k):
|
||||
|
||||
|
||||
def _subject(ev, fields):
|
||||
"""标题主体:优先任务名,其次设备名/serial。"""
|
||||
"""标题主体:**用户自己写的 title 优先**,其次任务名,再次设备名/serial。"""
|
||||
if fields.get("title"):
|
||||
return str(fields["title"])
|
||||
for k in ("job_name", "apk_name", "hook_name"):
|
||||
if fields.get(k):
|
||||
return str(fields[k])
|
||||
@@ -365,9 +374,11 @@ def build_message(ev, fields, hook=None):
|
||||
"""把事件渲染成统一消息体(title/summary/fields/level/markdown)。"""
|
||||
fields = dict(fields or {})
|
||||
merged = fields.pop("_merged", None)
|
||||
level = _level_of(ev.key)
|
||||
level = _level_of(ev.key, fields)
|
||||
subj = _subject(ev, fields)
|
||||
title = ev.label + (f":{subj}" if subj else "")
|
||||
# 「发通知」步骤自带标题时**直接用它**:用户写的是"抖音号1 掉线了",
|
||||
# 前面再挂一个"任务自定义通知:"纯属噪音(其它事件照旧带事件名)
|
||||
title = str(fields["title"]) if fields.get("title") else ev.label + (f":{subj}" if subj else "")
|
||||
if merged and merged.get("merged_count", 0) > 1:
|
||||
title = f"{ev.label}:{subj}" if subj else ev.label
|
||||
title += f"({merged['merged_count']} 次)"
|
||||
@@ -382,7 +393,7 @@ def build_message(ev, fields, hook=None):
|
||||
continue
|
||||
# 标题里已经写出来的主体(任务名/设备名)不再重复占一行:
|
||||
# "任务批次结束:抖音养号" 下面再来一行 "任务:抖音养号" 是纯噪音
|
||||
if k in ("job_name", "device_name", "apk_name", "hook_name") \
|
||||
if k in ("job_name", "device_name", "apk_name", "hook_name", "title") \
|
||||
and str(v) == str(subj):
|
||||
continue
|
||||
# 设备名优先:两个都带时只显示名字——IP 是给日志看的,通知里没人想读地址
|
||||
|
||||
Reference in New Issue
Block a user