feat: 监控页按名称排序 + 按钮改「看屏 / 打开设备端 Agent」;设备名匹配补 USB 设备
用户反馈的三件事:
1) **监控页设备排序**改成按**名称**(A01/A02/…),没名字的排最后按地址。
之前是 adb 返回的顺序,看着像按 IP 排,同型号多台时对不上号。
排序放在 `/api/status`(后端),大屏页一并受益。
2) **操作按钮改名 + 换实现**:
- 「截图」→「**看屏**」(行为不变,就是查看设备实时画面)
- 「定位」→「**打开设备端 Agent**」:改成让设备上的 Agent 弹身份大字页
(名称/IP/平台地址),替代原来"推 HTML 让设备浏览器打开定位页"那套
(那套要设备装浏览器、页面还依赖设备能访问平台,backlog A1/A2 一直没修)
* 新接口 `POST /api/agent/show-info {serial, close?}`;没装 Agent 时明确报出来
* **先点亮屏幕 + 解除锁屏再拉起** —— 设备息屏时 am start 会把大字页开在锁屏后面,
人看到的还是一片黑(实测踩过)
* 平台地址取操作者当前访问地址;若是 localhost 则换成本机局域网地址
(设备上显示 127.0.0.1 毫无意义)
3) **安装应用的选择设备界面仍显示裸序列号**:USB 设备在 adb 里的 serial 就是
`ro.serialno`,等于池里那条网络记录的**指纹** —— 之前只按 serial 匹配,插着 USB
时会冒出一个没名字的 `s8o7nrt8pbzhfadq`。现在按 serial 或指纹任一命中都取名称,
三处(剪贴板注入 / 安装弹窗 / 安装进度表)统一。
顺带修掉几个自己踩出来的坑:
- `device_pool.list_configured()` 返回的是 **serial 字符串列表**,我当成字典用了
(`.get("serial")` 抛异常被 except 吞掉 → 名字全空)→ 改用 `list_devices()`
- `web/device_agent_api.py` 用了 `PERM_DEVICES` 但没导入 → 服务直接起不来(502)
- Agent 自检里的版本号是写死的常量(升到 1.4 还显示 1.0)→ 改成读包信息
- `doc/API.md` 章节编号(插了一节没把后面整体下移,出现两个 §16)
验证:安装弹窗里 USB 那台现在显示「A07」(指纹匹配);监控页按钮为
「看屏 / 打开设备端 Agent」;对装/未装 Agent 的设备分别得到大字页 / 明确报错;
大字页实测显示 A07 + 192.168.20.250:18050 + Agent v1.4。
This commit is contained in:
+7
-3
@@ -277,12 +277,16 @@ class ApkManager:
|
||||
|
||||
# 设备名(平台给设备起的名字,如 A08)—— 进度表里显示这个而不是型号:
|
||||
# 8 台同型号时型号根本区分不了,名字才行
|
||||
pool_names = {}
|
||||
pool_names, fp_names = {}, {}
|
||||
try:
|
||||
from core import device_pool
|
||||
for d in device_pool.list_configured(enabled_only=False):
|
||||
# list_devices() 返回字典(含 name/fingerprint)
|
||||
for d in device_pool.list_devices():
|
||||
if d.get("serial"):
|
||||
pool_names[d["serial"]] = d.get("name") or ""
|
||||
# USB 设备的 adb serial == ro.serialno == 池里那条记录的指纹
|
||||
if d.get("fingerprint"):
|
||||
fp_names[d["fingerprint"]] = d.get("name") or ""
|
||||
except Exception as e:
|
||||
_log.warning(f"读取设备名失败(进度表将显示地址): {e}")
|
||||
|
||||
@@ -290,7 +294,7 @@ class ApkManager:
|
||||
items = {}
|
||||
for s in serials:
|
||||
items[s] = {
|
||||
"name": pool_names.get(s) or s,
|
||||
"name": pool_names.get(s) or fp_names.get(s) or s,
|
||||
"status": "pending",
|
||||
"msg": "",
|
||||
}
|
||||
|
||||
+10
-8
@@ -21,9 +21,10 @@
|
||||
- [11. 运维工具(adb / 剪贴板 / 应用版本 / Tailscale)](#11-运维工具adb--剪贴板--应用版本--tailscale)
|
||||
- [12. AI 控制台](#12-ai-控制台)
|
||||
- [13. 系统备份](#13-系统备份)
|
||||
- [14. 非 JSON 响应汇总](#14-非-json-响应汇总)
|
||||
- [15. 错误分支速查](#15-错误分支速查)
|
||||
- [16. 已知问题](#16-已知问题)
|
||||
- [14. 设备端应用商店](#14-设备端应用商店)
|
||||
- [15. 非 JSON 响应汇总](#15-非-json-响应汇总)
|
||||
- [16. 错误分支速查](#16-错误分支速查)
|
||||
- [17. 已知问题](#17-已知问题)
|
||||
|
||||
---
|
||||
|
||||
@@ -48,14 +49,14 @@
|
||||
### 1.3 CSRF
|
||||
|
||||
- `GET /api/csrf` 取 token(存在 session);前端对所有非 GET 请求带 `X-CSRF-Token`。
|
||||
- ⚠️ **当前服务端并未强制校验**(`web_server.py` 只 import 了 `_csrf_protect`,没有注册 `before_request`)。即 token 已签发、前端已携带,但**伪造请求不会被拦**。见 [§16 已知问题](#16-已知问题)。
|
||||
- ⚠️ **当前服务端并未强制校验**(`web_server.py` 只 import 了 `_csrf_protect`,没有注册 `before_request`)。即 token 已签发、前端已携带,但**伪造请求不会被拦**。见 [§17 已知问题](#17-已知问题)。
|
||||
|
||||
### 1.4 响应约定
|
||||
|
||||
- 绝大多数接口返回 **JSON**:成功 `{"ok": true, ...}`,失败 `{"ok": false, "error": "中文原因"}`(部分接口额外带 `msg`)。
|
||||
- **HTTP 状态码与 `ok` 并存**:参数错误用 400,权限 403,不存在 404,冲突 409,依赖不可用 502/503。少数接口用 `{"ok":false}` + HTTP 200(如 `POST /api/jobs/<id>/run` 的"任务类型不存在")。以本文每节标注为准。
|
||||
- 时间统一字符串:`"YYYY-MM-DD HH:MM"`(展示)或 `"YYYY-MM-DD HH:MM:SS"`(cron 相关)。
|
||||
- 非 JSON 接口(HTML / 图片 / SSE / MJPEG / zip)见 [§14](#14-非-json-响应汇总)。
|
||||
- 非 JSON 接口(HTML / 图片 / SSE / MJPEG / zip)见 [§15](#15-非-json-响应汇总)。
|
||||
|
||||
---
|
||||
|
||||
@@ -237,6 +238,7 @@
|
||||
| GET | `/api/agent-store/config` | 读配置(含设备令牌) |
|
||||
| POST | `/api/agent-store/config` | 启用/停用、重置令牌 |
|
||||
| GET | `/api/agent-store/logs` | 设备端安装记录 |
|
||||
| POST | `/api/agent/show-info` | 让设备上的 Agent 显示/关闭**身份大字页**(监控页「打开设备端 Agent」按钮)|
|
||||
|
||||
> 完整协议、鉴权与版本约定见 [DEVICE_AGENT.md](DEVICE_AGENT.md)。
|
||||
|
||||
@@ -749,7 +751,7 @@ multipart 上传 `.zip` 或 `.db`(字段 `file`)→ 校验并暂存:
|
||||
|
||||
| 方法 | 路径 | 响应类型 | 说明 |
|
||||
|------|------|---------|------|
|
||||
| GET | `/` `/wall` `/login` `/locate` | `text/html` | 四个页面(`/locate` 免登录) |
|
||||
| GET | `/` `/wall` `/login` `/locate` | `text/html` | 四个页面(`/locate` 免登录;**已被设备端 Agent 的身份页取代**,仅作历史保留)|
|
||||
| GET | `/api/device/screenshot` | `image/png` | 原始截图(`Cache-Control: no-store`) |
|
||||
| GET | `/api/screen/thumb` | `image/jpeg` | 360px 缩略图 + `X-Screen-State` |
|
||||
| GET | `/api/screen/stream` | `multipart/x-mixed-replace` | MJPEG 实时流 |
|
||||
@@ -759,7 +761,7 @@ multipart 上传 `.zip` 或 `.db`(字段 `file`)→ 校验并暂存:
|
||||
|
||||
---
|
||||
|
||||
## 15. 错误分支速查
|
||||
## 16. 错误分支速查
|
||||
|
||||
| 码 | 典型触发 |
|
||||
|----|---------|
|
||||
@@ -775,7 +777,7 @@ multipart 上传 `.zip` 或 `.db`(字段 `file`)→ 校验并暂存:
|
||||
|
||||
---
|
||||
|
||||
## 16. 已知问题
|
||||
## 17. 已知问题
|
||||
|
||||
| 问题 | 影响 | 位置 |
|
||||
|------|------|------|
|
||||
|
||||
+18
-17
@@ -70,27 +70,28 @@ async function stopSelectedDevices(){
|
||||
setTimeout(loadMonitor,500);
|
||||
}
|
||||
|
||||
// 定位设备:点亮屏幕 + 打开大字定位页(显示设备 IP),便于在机架上找到目标
|
||||
// 注意:打开定位页会切换设备前台,任务运行中慎用
|
||||
async function locateDevice(serial){
|
||||
if(!confirm('点亮 '+serial+' 屏幕并打开大字定位页?\n\n(会切换设备前台显示,任务运行中慎用)'))return;
|
||||
const r=await apiPost('/api/device/locate',{serial:serial,show:true});
|
||||
// 打开设备端 Agent:让设备上的 Agent 弹出「身份大字页」(名称/IP/平台地址),
|
||||
// 人站在机架前一眼看出这台是平台上的哪一台。
|
||||
// 替代原来的"推 HTML 让设备浏览器打开定位页"——那条路要设备装浏览器、页面还依赖
|
||||
// 设备能访问平台,且在 MIUI 上本来就不稳。
|
||||
async function showAgentInfo(serial){
|
||||
const r=await apiPost('/api/agent/show-info',{serial:serial});
|
||||
if(!r)return;
|
||||
if(r.ok){
|
||||
_locatingSerials.add(serial);
|
||||
showToast(r.msg||'已定位','success');
|
||||
showToast(r.msg||'已让设备显示身份信息','success');
|
||||
renderDevices();
|
||||
}else showToast(r.error||'定位失败','error');
|
||||
}else showToast(r.error||'打开失败','error');
|
||||
}
|
||||
|
||||
// 结束定位:关闭设备上的定位页(浏览器),按钮恢复为"定位"
|
||||
async function stopLocate(serial){
|
||||
const r=await apiPost('/api/device/locate/stop',{serial:serial});
|
||||
// 关闭设备端 Agent 的身份页
|
||||
async function hideAgentInfo(serial){
|
||||
const r=await apiPost('/api/agent/show-info',{serial:serial,close:true});
|
||||
if(!r)return;
|
||||
_locatingSerials.delete(serial);
|
||||
renderDevices();
|
||||
if(r.ok)showToast(r.msg||'已结束定位','success');
|
||||
else showToast(r.error||'结束定位失败','error');
|
||||
if(r.ok)showToast(r.msg||'已关闭','success');
|
||||
else showToast(r.error||'关闭失败','error');
|
||||
}
|
||||
|
||||
// 批量亮屏/息屏所有在线设备(息屏会中断运行中任务,前端已确认)
|
||||
@@ -344,13 +345,13 @@ function renderDeviceRow(dev){
|
||||
// 设备操作按钮需要"设备控制"权限(无权限只保留截图查看)
|
||||
const canDev=_can('devices');
|
||||
const stopBtn=canDev&&(dev.worker_status==='running'||dev.worker_status==='connecting')?'<button class="btn btn-danger btn-xs" onclick="apiAction(\'/api/stop_device\',{serial:\''+esc(dev.serial)+'\'})">停止</button>':'';
|
||||
// 截图按钮(任何状态都可看,离线时会报错)
|
||||
const shotBtn='<button class="btn btn-xs" onclick="openScreenshot(\''+esc(dev.serial)+'\')" title="查看设备画面(不影响任务)">截图</button>';
|
||||
// 定位按钮:定位中显示"结束定位"(关闭定位页),否则显示"定位"(点亮+大字页)
|
||||
// 看屏按钮(任何状态都可看,离线时会报错)
|
||||
const shotBtn='<button class="btn btn-xs" onclick="openScreenshot(\''+esc(dev.serial)+'\')" title="查看设备实时画面(不影响任务)">看屏</button>';
|
||||
// 设备端 Agent:开着身份页时显示"关闭",否则"打开设备端 Agent"
|
||||
const locating=_locatingSerials.has(dev.serial);
|
||||
const locateBtn=canDev?(locating
|
||||
?'<button class="btn btn-xs btn-danger" onclick="stopLocate(\''+esc(dev.serial)+'\')" title="关闭定位页(退出浏览器)">结束定位</button>'
|
||||
:'<button class="btn btn-xs" onclick="locateDevice(\''+esc(dev.serial)+'\')" title="点亮屏幕并显示设备IP大字页(会切换前台,任务运行中慎用)">定位</button>'):'';
|
||||
?'<button class="btn btn-xs btn-danger" onclick="hideAgentInfo(\''+esc(dev.serial)+'\')" title="让设备上的 Agent 关掉身份大字页">关闭 Agent</button>'
|
||||
:'<button class="btn btn-xs" onclick="showAgentInfo(\''+esc(dev.serial)+'\')" title="让设备上的 Agent 显示身份大字页(名称/IP/平台地址)——人在机架前对号用(会切换设备前台)">打开设备端 Agent</button>'):'';
|
||||
// 异常/失败设备提供"清除异常"(把状态恢复为未启动)
|
||||
const clearBtn=canDev&&(dev.worker_status==='error'||dev.worker_status==='failed')
|
||||
?'<button class="btn btn-xs" onclick="clearDeviceError(\''+esc(dev.serial)+'\')" title="清除该设备的异常状态,恢复为未启动">清除异常</button>':'';
|
||||
|
||||
+16
-6
@@ -65,23 +65,33 @@ def api_apks_install_devices():
|
||||
source: pool=设备池 / usb=本机有线 adb / adb=本机网络 adb。
|
||||
"""
|
||||
devices, seen = [], set()
|
||||
name_by_serial, name_by_fp = {}, {}
|
||||
try:
|
||||
online = set(device_pool.list_online())
|
||||
for d in device_pool.list_configured(enabled_only=False):
|
||||
# list_devices() 返回字典(含 name/fingerprint);list_configured() 只有 serial 字符串
|
||||
for d in device_pool.list_devices():
|
||||
serial = d.get("serial")
|
||||
if serial in online and serial not in seen:
|
||||
devices.append({"serial": serial, "model": "",
|
||||
"name": d.get("name") or "", "source": "pool"})
|
||||
seen.add(serial)
|
||||
except Exception:
|
||||
pass
|
||||
if serial:
|
||||
name_by_serial[serial] = d.get("name") or ""
|
||||
# USB 设备的 adb serial == ro.serialno == 池里那条网络记录的指纹,
|
||||
# 靠指纹才认得出是同一台(否则插着 USB 时会冒出一个没名字的裸序列号)
|
||||
if d.get("fingerprint"):
|
||||
name_by_fp[d["fingerprint"]] = d.get("name") or ""
|
||||
except Exception as e:
|
||||
_log.warning("读取设备名失败(列表将只显示地址): %s", e)
|
||||
out = _adb("devices")
|
||||
for line in (out or "").splitlines()[1:]:
|
||||
parts = line.split()
|
||||
if len(parts) >= 2 and parts[0] and parts[0] not in seen:
|
||||
devices.append({"serial": parts[0], "model": "",
|
||||
"name": "", "source": "usb" if ":" not in parts[0] else "adb"})
|
||||
seen.add(parts[0])
|
||||
s = parts[0]
|
||||
devices.append({"serial": s, "model": "",
|
||||
"name": name_by_serial.get(s) or name_by_fp.get(s) or "",
|
||||
"source": "usb" if ":" not in s else "adb"})
|
||||
seen.add(s)
|
||||
return jsonify({"ok": True, "devices": devices})
|
||||
|
||||
@bp.route("/api/apks/install", methods=["POST"])
|
||||
|
||||
+14
-5
@@ -20,20 +20,29 @@ def _merged_device_list():
|
||||
parts = line.split()
|
||||
if len(parts) >= 2 and parts[0] and not parts[0].startswith("*"):
|
||||
states[parts[0]] = parts[1]
|
||||
names = {}
|
||||
# 注意用的是 list_devices()(**字典**,含 name/fingerprint);
|
||||
# list_configured() 只返回 serial 字符串列表,别拿它当字典用(实测踩过:.get 抛异常
|
||||
# 被 except 吞掉 → 名字全空)
|
||||
names, fp_names = {}, {}
|
||||
try:
|
||||
for d in device_pool.list_configured(enabled_only=False):
|
||||
for d in device_pool.list_devices():
|
||||
if d.get("serial"):
|
||||
names[d["serial"]] = d.get("name") or ""
|
||||
except Exception:
|
||||
pass
|
||||
# USB 设备在 adb 里的 serial 就是 ro.serialno,等于池里那条网络记录的
|
||||
# **指纹** —— 插着 USB 时会以另一个 serial 出现,靠指纹才认得出是同一台,
|
||||
# 否则界面上会冒出一个没名字的裸序列号(实测踩过)
|
||||
if d.get("fingerprint"):
|
||||
fp_names[d["fingerprint"]] = d.get("name") or ""
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.getLogger("web").warning(f"读取设备名失败: {e}")
|
||||
try:
|
||||
for serial in device_pool.list_configured():
|
||||
if serial not in states:
|
||||
states[serial] = "pool"
|
||||
except Exception:
|
||||
pass
|
||||
return [{"serial": s, "name": names.get(s, ""), "state": st}
|
||||
return [{"serial": s, "name": names.get(s) or fp_names.get(s) or "", "state": st}
|
||||
for s, st in sorted(states.items())]
|
||||
|
||||
|
||||
|
||||
+80
-2
@@ -28,7 +28,7 @@ from config import APK_DIR
|
||||
from core import device_pool
|
||||
from core.db_config import meta_get, meta_set
|
||||
from core.logger import get_logger
|
||||
from web.auth import PERM_APKS, perm_required
|
||||
from web.auth import PERM_APKS, PERM_DEVICES, perm_required
|
||||
|
||||
bp = Blueprint("device_agent", __name__)
|
||||
_log = get_logger("web.devagent")
|
||||
@@ -86,7 +86,7 @@ def _identify():
|
||||
if d:
|
||||
return fp, d.get("serial", serial), d.get("name") or "", True
|
||||
if serial:
|
||||
for d in device_pool.list_configured(enabled_only=False):
|
||||
for d in device_pool.list_devices(): # 返回字典(list_configured 只有 serial)
|
||||
if d.get("serial") == serial:
|
||||
return fp, serial, d.get("name") or "", True
|
||||
except Exception as e:
|
||||
@@ -264,6 +264,84 @@ def _prune_logs(keep=_MAX_LOG):
|
||||
|
||||
|
||||
# ================== 管理端接口(登录 + 权限)==================
|
||||
@bp.route("/api/agent/show-info", methods=["POST"])
|
||||
@perm_required(PERM_DEVICES)
|
||||
def api_agent_show_info():
|
||||
"""让设备上的 Agent 打开「身份大字页」(监控页那个按钮调它)。
|
||||
|
||||
替代原来那套「推 HTML 让设备浏览器打开定位页」——那套在 MIUI 上要装浏览器、
|
||||
页面还依赖设备能访问平台;Agent 是常驻 App,一条 `am start` 就出画面。
|
||||
|
||||
请求:{"serial": "…", "close": false}
|
||||
返回:{ok, msg};设备没装 Agent 时明确报出来(不静默失败)
|
||||
"""
|
||||
from core.adb_helper import _adb
|
||||
|
||||
data = request.json or {}
|
||||
serial = (data.get("serial") or "").strip()
|
||||
close = bool(data.get("close"))
|
||||
if not serial:
|
||||
return jsonify({"ok": False, "error": "缺少 serial"}), 400
|
||||
|
||||
if close:
|
||||
args = ["--es", "close", "1"]
|
||||
else:
|
||||
# 设备信息:名称/IP/平台地址 —— Agent 用它们显示大字页
|
||||
name, ip = "", ""
|
||||
ip = serial.split(":")[0] if ":" in serial else ""
|
||||
for d in device_pool.list_devices(): # 字典列表(list_configured 只有 serial)
|
||||
if d.get("serial") == serial:
|
||||
name = d.get("name") or ""
|
||||
break
|
||||
if not name:
|
||||
# USB 设备的 serial 就是指纹:池里那条记录的 serial 是网络地址,
|
||||
# 靠指纹才认得出来(否则大字页标题会显示一串裸序列号)
|
||||
for d in device_pool.list_devices():
|
||||
if d.get("fingerprint") and d["fingerprint"] == serial:
|
||||
name = d.get("name") or ""
|
||||
break
|
||||
if not ip:
|
||||
# USB 设备:平台不知道它的 IP,现问一下设备(只读命令,不影响设备)
|
||||
out = _adb("-s", serial, "shell", "ip", "-4", "addr", "show", "wlan0")
|
||||
import re as _re
|
||||
m = _re.search(r"inet\s+(\d+\.\d+\.\d+\.\d+)", out or "")
|
||||
ip = m.group(1) if m else ""
|
||||
# 平台地址:用操作者当前访问的地址;如果是 localhost,设备根本连不上 →
|
||||
# 换成局域网地址(设备上显示 127.0.0.1 毫无意义)
|
||||
server = request.host_url.rstrip("/")
|
||||
if "127.0.0.1" in server or "localhost" in server:
|
||||
from web.devices_api import _lan_ip
|
||||
server = "http://%s:18050" % _lan_ip()
|
||||
args = ["--es", "name", name or serial.split(":")[0],
|
||||
"--es", "serial", serial,
|
||||
"--es", "server", server]
|
||||
if ip:
|
||||
args += ["--es", "ip", ip]
|
||||
|
||||
# 先点亮屏幕、解除锁屏:设备息屏时 am start 会把大字页开在锁屏**后面**,
|
||||
# 人看到的还是一片黑(实测踩过)——这正是原来"定位"按钮里的"点亮屏幕"那一步
|
||||
if not close:
|
||||
try:
|
||||
_adb("-s", serial, "shell", "input", "keyevent", "KEYCODE_WAKEUP")
|
||||
_adb("-s", serial, "shell", "wm", "dismiss-keyguard")
|
||||
except Exception as e:
|
||||
_log.warning("点亮屏幕失败(不影响显示身份页): %s", e)
|
||||
|
||||
try:
|
||||
out = _adb("-s", serial, "shell", "am", "start", "-n",
|
||||
"com.example.deviceagent/.InfoActivity", *args) or ""
|
||||
except Exception as e:
|
||||
return jsonify({"ok": False, "error": f"adb 执行失败: {e}"}), 500
|
||||
|
||||
low = out.lower()
|
||||
if "unable to resolve intent" in low or "does not exist" in low or "error" in low:
|
||||
return jsonify({"ok": False,
|
||||
"error": "这台设备没装设备 Agent(com.example.deviceagent)——"
|
||||
"可以从「应用管理 → 设备端应用商店」装,或用扫码配置"}), 400
|
||||
_log.info("设备端 Agent %s: %s", "关闭身份页" if close else "打开身份页", serial)
|
||||
return jsonify({"ok": True, "msg": "已关闭" if close else "已让设备显示身份信息"})
|
||||
|
||||
|
||||
@bp.route("/api/agent-store/config", methods=["GET", "POST"])
|
||||
@perm_required(PERM_APKS)
|
||||
def api_agent_store_config():
|
||||
|
||||
@@ -102,6 +102,11 @@ def api_status():
|
||||
devices, err = context.mgr.get_status()
|
||||
if err:
|
||||
return jsonify({"ok": False, "error": err}), 500
|
||||
# 按**名称**排(A01/A02/…)——之前是 adb 返回的顺序,看着像按 IP 排,很别扭。
|
||||
# 没名字的排在最后、彼此按地址排(历史数据里可能有没命名的设备)。
|
||||
devices.sort(key=lambda d: (
|
||||
1 if not (d.get("device_name") or "").strip() else 0,
|
||||
(d.get("device_name") or "").strip() or d.get("serial", "")))
|
||||
return jsonify({
|
||||
"ok": True, "devices": devices, "server_time": time.time(),
|
||||
"fg_scanning": context.mgr._fg_scanner.is_scanning,
|
||||
|
||||
Reference in New Issue
Block a user