diff --git a/core/device_pool.py b/core/device_pool.py index 14cb1fe..408f71f 100644 --- a/core/device_pool.py +++ b/core/device_pool.py @@ -39,13 +39,59 @@ def init_app(app): 并后台刷新一次设备型号(首次启动/设备更换后型号可能变化)。""" global _app _app = app + refresh_names() # 同步刷一次名称快照,让通知从第一条起就有名字 try: t = threading.Thread(target=_refresh_models_bg, daemon=True) t.start() + t2 = threading.Thread(target=_names_loop, name="device-names", daemon=True) + t2.start() except Exception: pass +# ================== 名称内存快照(给通知用) ================== +# 为什么单独存一份:webhook 通知里要显示"是哪台设备"(A08)而不是地址(IP), +# 但 `notifier.notify()` 有一条硬红线——**零 DB 访问**(不能为了取个名字去查库, +# 更不能在业务线程里阻塞)。所以由这里维护一份 serial→名称 的内存快照: +# 启动刷一次、池子有变动时刷一次、再兜底每 60s 刷一次(覆盖整库恢复等外部改动)。 +_names = {} +_names_lock = threading.Lock() +_NAMES_INTERVAL = 60 + + +def refresh_names(): + """把设备池的 serial→名称刷进内存快照(只查库,不碰 adb)。返回条数。""" + try: + rows = list_devices() + except Exception as e: + _log.debug(f"刷新设备名快照失败: {e}") + return 0 + m = {} + for d in rows: + s = d.get("serial") or "" + if s: + m[s] = d.get("name") or "" + with _names_lock: + _names.clear() + _names.update(m) + return len(m) + + +def name_of(serial): + """serial 对应的设备名称(**纯内存,可在通知路径上调用**)。没有则返回 ""。""" + if not serial: + return "" + with _names_lock: + return _names.get(str(serial), "") + + +def _names_loop(): + """兜底定时刷新名称快照(覆盖整库恢复这类进程外改动)。""" + while True: + time.sleep(_NAMES_INTERVAL) + refresh_names() + + def _refresh_models_bg(): """后台批量采集在线设备型号(启动时/手动触发)。失败静默,不影响启动。""" time.sleep(3) # 等服务起来再查 @@ -183,6 +229,7 @@ def _move_device_row(old, new_serial, fingerprint=""): if fingerprint: old.fingerprint = fingerprint db.session.commit() + refresh_names() # 键变了(serial → 名称的映射也跟着变) if _move_hook is not None: try: _move_hook(old_serial, new_serial) @@ -255,11 +302,13 @@ def add_device(serial, name="", note="", enabled=True, fingerprint=""): if fingerprint: d.fingerprint = fingerprint db.session.commit() + refresh_names() # 名字可能改了:通知里要立刻用新名字 return False db.session.add(Device(serial=serial, name=name or "", note=note or "", enabled=enabled, fingerprint=fingerprint or "", created_at=time.strftime("%Y-%m-%d %H:%M"))) db.session.commit() + refresh_names() _log.info(f"设备池新增设备: {serial}") return True @@ -274,6 +323,7 @@ def rename_device(serial, name): old = d.name or "" d.name = name db.session.commit() + refresh_names() _log.info(f"设备池重命名: {old or serial} → {name}({serial})") return True @@ -301,6 +351,7 @@ def remove_device(serial): return False db.session.delete(d) db.session.commit() + refresh_names() _log.info(f"设备池删除设备: {serial}") return True diff --git a/core/notifier.py b/core/notifier.py index d59c85c..c381241 100644 --- a/core/notifier.py +++ b/core/notifier.py @@ -357,6 +357,53 @@ def _label(k): return _FIELD_LABELS.get(k, k) +# ================== 设备名解析(让通知显示名字而不是 IP) ================== +# 由装配层注册(web_server.py):`notifier.set_device_name_resolver(device_pool.name_of)`。 +# **刻意做成"纯内存回调"**:notify() 的红线是零 DB——不能为了取个名字去查库。 +# 快照由 device_pool 维护(启动/池子变动/每 60s 兜底刷新)。 +_device_name_fn = None + + +def set_device_name_resolver(fn): + """注册 serial → 设备名 的解析器。必须是**纯内存**实现(不得查库/发请求)。""" + global _device_name_fn + _device_name_fn = fn + + +def _dev_name(serial): + """尽力把地址解析成设备名;解析不到返回 ""(调用方保留原值)。""" + if not serial or _device_name_fn is None: + return "" + try: + return str(_device_name_fn(str(serial)) or "") + except Exception: + return "" + + +def fill_device_names(fields): + """把 fields 里的设备地址补成设备名(**原地改**,返回同一个 dict)。 + + 两类字段: + · `serial` → 补 `device_name`(原来没填才补)——标题主体、字段表、 + 聚合样本认的都是 `device_name`,补上这一处就全带名字了; + · `serials` / `devices`(多设备事件,如设备断联)→ 逐项换成名字, + 通知里不该出现一串 IP。 + 查不到名字(设备没命名 / 已不在池里)就**保留原值**,绝不把内容弄丢。 + """ + if not isinstance(fields, dict): + return fields + s = fields.get("serial") + if s and not fields.get("device_name"): + n = _dev_name(s) + if n: + fields["device_name"] = n + for k in ("serials", "devices"): + v = fields.get(k) + if isinstance(v, (list, tuple)) and v: + fields[k] = [_dev_name(x) or x for x in v] + return fields + + def _subject(ev, fields): """标题主体:**用户自己写的 title 优先**,其次任务名,再次设备名/serial。""" if fields.get("title"): @@ -374,6 +421,7 @@ def _subject(ev, fields): def build_message(ev, fields, hook=None): """把事件渲染成统一消息体(title/summary/fields/level/markdown)。""" fields = dict(fields or {}) + fill_device_names(fields) # 同 notify():预览/测试发送也要显示设备名 merged = fields.pop("_merged", None) level = _level_of(ev.key, fields) subj = _subject(ev, fields) @@ -1008,6 +1056,7 @@ def notify(event, **fields): for p in h["events"])] if not hooks: return + fill_device_names(fields) # 地址 → 设备名(纯内存快照,不查库) for h in hooks: keys = tuple(str(fields.get(k, "")) for k in (ev.agg_key or ())) # 聚合窗口:事件自己声明「立即发」(agg_window=0,低频高危事件,如批次结束 / diff --git a/doc/API.md b/doc/API.md index 5574608..21b989f 100644 --- a/doc/API.md +++ b/doc/API.md @@ -577,7 +577,7 @@ | 接口 | 请求 | 响应要点 | |------|------|---------| | `GET /api/uiauto/status` | — | uiautodev(:20242)是否在跑,前端据此禁/启用"抓取元素" | -| `GET /api/uiauto/devices` | — | 可选设备列表(uiautodev 设备 + 池内在线补全) | +| `GET /api/uiauto/devices` | — | 可选设备列表(uiautodev 设备 + 池内在线补全)。每项 `{serial, name, model, product, status}`;**`name` 是平台给设备起的名字**(`cs1`/`A08`),池外设备退回 `serial`——uiautodev 自带的 name 是设备 codename(实测一柜子全是 `earth`),不能用来认设备 | | `GET /api/uiauto/elements?serial=` | — | 扁平元素列表(走 uiautodev),每项带 `suggested`、`bounds`、`depth` | | `GET /api/uiauto/snapshot?serial=` | — | **一次取齐**:截图 + 元素树(原生 u2 同一连接背靠背)→ `{image,width,height,elements,unstable,screen_state,cost_ms}`;抓取弹窗与 MCP 的 `de_snapshot` 都用它,失败返回 **502** | | `GET /api/uiauto/screenshot?serial=` | — | JPEG | diff --git a/doc/ARCHITECTURE.md b/doc/ARCHITECTURE.md index 20f3f12..87c3718 100644 --- a/doc/ARCHITECTURE.md +++ b/doc/ARCHITECTURE.md @@ -80,8 +80,7 @@ |------|------|---------| | **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-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`(**起常驻扫描线程**);`device_battery.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`(**刷一次 `serial→名称` 内存快照** + **起线程**:3s 后采集型号、另起 `device-names` 每 60s 刷名称);`device_discovery.init_app`(**起常驻扫描线程**);`device_battery.init_app`(**起常驻电量采集线程**);`TaskManager(app=app)`(APScheduler + 看门狗 + 从库加载分组/任务 + 重注册 cron);`ApkManager(app=app)`;`notifier.set_device_name_resolver(device_pool.name_of)`(通知里显示设备名而不是 IP,见 [NOTIFY.md](NOTIFY.md) §7) | ### 2.3 阶段 E~G:蓝图、巡检调度器、真正启动 | 阶段 | 位置 | 做了什么 | @@ -105,6 +104,7 @@ | **巡检调度器** `BackgroundScheduler` | `web_server.py` | 经验库 AI 巡检 | 每日 03:47 | | `worker-watchdog` | `start_watchdog()` | `running/connecting` 心跳超时 → 标 `error` | 30s 检查 / 120s 阈值 | | `device-discovery` | `device_discovery.init_app` | 网段扫描 + 断联重连 | 首轮延迟 15s,之后 interval(默认 60s) | +| `device-names` | `device_pool.init_app` | 刷 `serial → 名称` 内存快照(通知显示设备名用;只查库) | 60s(另有启动/增删改名时主动刷) | | `_refresh_models_bg` | `device_pool.init_app` | 启动后采集全部在线设备型号 | 一次性(3s 后) | | `device-battery` | `device_battery.init_app` | 采设备电量(`dumpsys battery` 只读)+ 低电量告警 | 首轮延迟 20s,之后 interval(默认 60s) | | `BaseWorker` × N | `TaskManager._run_with_retry` | 单设备任务执行 | 任务期 | @@ -297,7 +297,7 @@ connecting ──获取设备──▶ u2 连接 ──▶ running ──▶ set | `markdown.js` | 轻量 Markdown 渲染(`renderMarkdown`) | 无 CDN 依赖;**先转义再套标记** | | `list.js` | 统一列表组件:搜索 + 分页 + 排序 | 状态注册表 `_LIST_PAGERS`;`setListPager` **不重置**页码/搜索/排序(避免轮询刷新打断用户) | | `monitor.js` | 监控页:设备表(含**电量列**,按 `battery.tier` 上色)、批量操作、异常汇总、任务概况卡片(含覆盖设备 chip) | 5s 轮询 + 脏检查(签名不变不重渲染) | -| `editor.js` | 步骤编辑器(拖拽 / 参数表单 / 条件分支 / 元素抓取 / 测试此步骤)+ `saveTask` | 最大的前端文件;`_stepEditor` 单例 | +| `editor.js` | 步骤编辑器(拖拽 / 参数表单 / 条件分支 / 元素抓取 / 测试此步骤)+ `saveTask` | 最大的前端文件;`_stepEditor` 单例;设备选择卡 `_devCard` 供「抓取元素」「测试此步骤」共用(**名字优先**,型号·地址作副标题) | | `tasks.js` | 任务 Tab:任务 CRUD / 调度解析 / 运行窗口 + 自定义动作 | | | `tools.js` | 工具 Tab:剪贴板、adb 终端、Tailscale、设备池、自动发现、远程看屏、**电量监控配置**(阈值/间隔/立即采集) | 电量表单只回填一次,10s 轮询不冲掉用户正在输的值 | | `apps.js` | 应用管理:APK 上传/安装/删除、设备已装应用 | | diff --git a/doc/NOTIFY.md b/doc/NOTIFY.md index a8906a8..785c855 100644 --- a/doc/NOTIFY.md +++ b/doc/NOTIFY.md @@ -247,6 +247,23 @@ Bark `/…/`、Slack `/services/T…/B…/X…`)→ 接口回显、发送 - 事件的 `fields` 是前端字段表与模板占位符的白名单,只写真正有用的 - 高频事件(每台设备/每次尝试都会发生的)把 `agg_window` 设大一点或 `recommend=False` +**设备一律只传 `serial`,名字自动补**(不用每个调用点自己去查名字): + +群消息里没人想看 `192.168.20.110:5555`,要看「A09」。这件事在通知链路里 +**统一做掉**,调用点照旧只传 `serial` 就行: + +| 环节 | 做什么 | +|---|---| +| `core/device_pool.py` | 维护一份 `serial → 名称` 的**内存快照**:启动刷一次、池子增删改名/迁址时刷一次、每 60s 兜底刷一次(覆盖整库恢复这类进程外改动) | +| `web_server.py` | `notifier.set_device_name_resolver(device_pool.name_of)` 把快照接给通知 | +| `core/notifier.py` | `fill_device_names()` 在 `notify()` 与 `build_message()` 里把 `serial` 补成 `device_name`、把 `serials`/`devices` 列表逐项换成名字 | + +为什么绕这一圈:`notify()` 的红线是**零 DB**,不能为了取个名字去查库(那等于在业务 +线程里加一次阻塞查询)。所以名字由 `device_pool` 预先算好放内存,`notifier` 只读。 + +降级规则(**绝不把内容弄丢**):调用点自己传了 `device_name` 就用它的;查不到名字 +(设备没命名 / 已不在池里)就保留原来的地址。解析器缺失或抛异常都只是降级,不影响发送。 + --- ## 8. 排障 diff --git a/static/admin/editor.js b/static/admin/editor.js index aa2d2eb..c9f53e1 100644 --- a/static/admin/editor.js +++ b/static/admin/editor.js @@ -592,10 +592,7 @@ var _stepEditor={ if(!list)return; if(!r||!r.ok){list.innerHTML='
'+esc((r&&r.error)||'获取设备失败')+'
';return;} list.innerHTML=(r.devices||[]).map(function(d){ - return '
'+ - '
📱
'+ - '
'+esc(d.serial)+'
'+ - '
'+esc(d.model||d.product||'')+'
'; + return self._devCard(d, "_stepEditor._runTestStep('"+esc(d.serial)+"')"); }).join('')||'
无可用设备
'; }); }, @@ -918,8 +915,20 @@ var _stepEditor={ // 直接弹设备选择页(独立模态框,不影响任务编辑窗口) this._showDevicePicker(); }, + // 设备选择卡片(「抓取元素」与「测试此步骤」两处共用): + // **设备名优先**,型号与地址降为副标题——同型号好几台时只有平台起的名字 + // (A08)能认出"是哪一台",光看 IP 选错设备是常事。 + _devCard:function(d,onclick){ + var sub=[d.model||d.product||'', d.serial||''].filter(Boolean).join(' · '); + return '
'+ + '
📱
'+ + '
'+esc(d.name||d.serial)+'
'+ + '
'+esc(sub)+'
'+ + (d.status?''+esc(d.status)+'':'')+'
'; + }, // 步骤1:设备选择页 _showDevicePicker:function(){ + var self=this; document.getElementById('el-picker-title').textContent='抓取元素 - 选择设备'; document.getElementById('el-picker-body').innerHTML='
选择要抓取元素的设备
加载中...
'; document.getElementById('el-picker-footer').innerHTML=''; @@ -930,11 +939,7 @@ var _stepEditor={ var devices=res.devices||[]; if(!devices.length){list.innerHTML='
uiauto2 未发现已连接设备
';return;} list.innerHTML=devices.map(function(d){ - return '
'+ - '
📱
'+ - '
'+esc(d.serial)+'
'+ - '
'+esc(d.model||d.product||'')+'
'+ - ''+esc(d.status||'device')+'
'; + return self._devCard(d, "_stepEditor._showElPicker('"+esc(d.serial)+"')"); }).join(''); }).catch(function(e){ document.getElementById('el-device-list').innerHTML='
请求失败: '+esc(e)+'
'; diff --git a/templates/admin/monitor.html b/templates/admin/monitor.html index bedeb09..c9aef55 100644 --- a/templates/admin/monitor.html +++ b/templates/admin/monitor.html @@ -309,7 +309,8 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14 .el-device-card:hover{border-color:var(--primary);box-shadow:var(--shadow-md);transform:translateY(-1px)} .el-device-card .edc-icon{width:36px;height:36px;background:#f1efe8;border-radius:8px;display:flex;align-items:center;justify-content:center;font-size:18px;flex-shrink:0} .el-device-card .edc-info{flex:1} -.el-device-card .edc-serial{font-size:13px;font-weight:600;color:var(--text);font-family:var(--mono)} +.el-device-card .edc-name{font-size:13.5px;font-weight:700;color:var(--text)} +.el-device-card .edc-serial{font-size:12px;color:var(--text-light);margin-top:2px;font-family:var(--mono)} .el-device-card .edc-model{font-size:12px;color:var(--text-light);margin-top:2px} .el-device-card .edc-status{font-size:11px;padding:2px 10px;border-radius:99px;background:var(--success-soft);color:#15803d;flex-shrink:0;font-weight:600} .el-picker-main{display:flex;flex:1;overflow:hidden} diff --git a/web/monitor.py b/web/monitor.py index 7bf07d6..c188482 100644 --- a/web/monitor.py +++ b/web/monitor.py @@ -6,8 +6,13 @@ import subprocess import shlex from concurrent.futures import ThreadPoolExecutor, as_completed -from flask import (Blueprint, jsonify, request, Response, render_template) +from flask import (Blueprint, jsonify, request, Response, render_template, + render_template_string) from flask_login import login_required +# /locate 定位页要把 serial 拼进 HTML,而 render_template_string 的模板名是 +# "