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。
This commit is contained in:
+26
@@ -141,6 +141,10 @@
|
||||
| DELETE | `/api/users/<int:uid>` | Admin | 删除用户 |
|
||||
| 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`)
|
||||
|
||||
@@ -648,6 +652,28 @@
|
||||
- `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) 的事件。
|
||||
|
||||
---
|
||||
|
||||
## 11. 运维工具(adb / 剪贴板 / 应用版本 / Tailscale)
|
||||
|
||||
+5
-3
@@ -31,6 +31,7 @@
|
||||
┌───────────────────────────▼──────────────────────────────────────────┐
|
||||
│ 基础层 adb_helper · u2_helper · uiauto_helper · ocr · clipboard │
|
||||
│ notifier(通知分发:队列/聚合/限流/适配器) │
|
||||
│ step_log(步骤明细:队列 + 批量落库 + 保留期清理) │
|
||||
│ models(SQLite)· logger · config │
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
▲
|
||||
@@ -78,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"` |
|
||||
| **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:蓝图、巡检调度器、真正启动
|
||||
|
||||
| 阶段 | 位置 | 做了什么 |
|
||||
|------|------|---------|
|
||||
| **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 |
|
||||
| **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 |
|
||||
| **F** | 同上附近 | **第二个独立 APScheduler**:`CronTrigger(hour=3, minute=47)` 挂经验库巡检、`hour=4, minute=13` 挂步骤明细清理(`_purge_step_log`,自建 app context);失败仅 warning |
|
||||
| **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`。
|
||||
|
||||
@@ -112,6 +113,7 @@
|
||||
| 巡检手动线程 | `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`。
|
||||
|
||||
|
||||
+37
-1
@@ -27,7 +27,7 @@
|
||||
|
||||
启动时校验「`.env` 声明」与「库名」「库中登记的 `app_meta.deployment_env`」三方一致,不符**拒绝启动**。
|
||||
|
||||
**表清单(12 张,全部是 `core/models.py` 里的 ORM 模型)**
|
||||
**表清单(14 张,全部是 `core/models.py` 里的 ORM 模型)**
|
||||
|
||||
| # | 表 | 用途 |
|
||||
|---|---|------|
|
||||
@@ -44,6 +44,7 @@
|
||||
| 11 | `experience_audit` | 经验巡检结论 |
|
||||
| 12 | `agent_action` | 动作库(命名动作) |
|
||||
| 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`
|
||||
> (不进模型层)。迁 MySQL 时那批 SQL 的 `AUTOINCREMENT`/`TEXT DEFAULT ''`/`TEXT PRIMARY KEY`
|
||||
@@ -136,6 +137,41 @@
|
||||
|
||||
> ⚠️ 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. 非模型表
|
||||
|
||||
+7
-2
@@ -212,9 +212,14 @@ tail -20 logs/web.log # 无 ERROR/Traceback
|
||||
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` /
|
||||
`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`
|
||||
> 曾因手工维护漏登记,数据其实在快照里,只是清单没列 → 被误判为"没有备份")。
|
||||
|
||||
+4
-3
@@ -56,7 +56,7 @@
|
||||
| 3 | **空闲设备扫描不主动 connect/disconnect** | 避免扰动共享连接 | 前台扫描对空闲设备直接返回"空闲";设备发现用 socket 探测 |
|
||||
| 4 | **adb key 保持历史 key 不变** | 设备信任该 key,换 key 全部 `unauthorized` | 部署沿用 `~/.android/adbkey` |
|
||||
| 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) |
|
||||
|
||||
### 其它开发约束
|
||||
@@ -184,9 +184,10 @@ MCP_ALLOW_WRITE=1 MCP_PLATFORM_USER=admin MCP_PLATFORM_PASS=<密码> \
|
||||
|
||||
### 5.2 新增数据库字段/表
|
||||
|
||||
- 模型改 `core/models.py`;新表 `create_all()` 会建
|
||||
- 模型改 `core/models.py`;新表 `create_all()` 会建(**幂等**:已是模型就会自动建/补列)
|
||||
- **老库**要在 `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
|
||||
|
||||
### 5.3 修改前端
|
||||
|
||||
+7
-4
@@ -348,9 +348,9 @@ class MyTask(BaseTask):
|
||||
def get_action_class(cls, action_type):
|
||||
return None
|
||||
|
||||
def create_worker(self, serial, params):
|
||||
def create_worker(self, serial, params, ctx=None):
|
||||
merged = {**DEFAULT_PARAMS, **(params or {})}
|
||||
return MyWorker(serial, params=merged)
|
||||
return MyWorker(serial, params=merged, ctx=ctx)
|
||||
```
|
||||
|
||||
```python
|
||||
@@ -369,8 +369,11 @@ from .myapp import task # ← 新增
|
||||
|
||||
### 8.3 签名约定
|
||||
|
||||
- `BaseWorker.__init__(self, serial, params=None, daemon=True)`
|
||||
- `Task.create_worker(self, serial, params)` —— **不要带 `stf_client` / `stf` 形参**(STF 已摘除,历史签名已清理)
|
||||
- `BaseWorker.__init__(self, serial, params=None, daemon=True, ctx=None)`
|
||||
- `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` 即可。
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user