feat: 自动认领开关 + 全站按设备名称显示
一、指纹匹配自动认领(可选,默认关)
- 发现设置新增「指纹匹配自动认领」勾选(app_meta: discovery_auto_claim,默认 0)
- 打开后:扫描发现某设备指纹与池中已有记录一致(同一台换了 IP)→ 自动迁移记录到新地址
并同步分组/任务引用,零点击;关闭时维持"识别自动 + 人工点一次确认"
- 默认关的原因:认领会改写分组/任务引用(数据结构变动),交人工确认更稳妥
- 扫描结果与状态行会显示本轮自动认领了几台
二、设备名称在界面上呈现(凡选择/展示设备处都显示名称)
- 新增前端 helper `devText(name, serial)`(base.js):有名称→「名称 · serial」
- 监控页设备表:名称加粗为主、地址作副行(未命名显示橙色提醒);任务概况的覆盖设备
chip 也优先显示名称(tooltip 保留完整地址)
- AI 控制台:目标设备下拉、实时画面设备下拉、目标/运行中提示都带名称(serial→name 映射)
- 任务编辑器「指定设备」下拉、分组编辑的设备勾选列表:带名称
- 后端 `/api/devices` 新增 `items`([{serial,name,model}],`devices` 保持兼容);
`/api/agent/devices` 增加 `name` 字段
- MCP `de_list_devices` 返回 `name`,并在工具说明与 Agent 系统提示里要求"汇报用名称、
调工具用 serial"
文档:API.md(items/name/auto_claim + §6.2.1 名称呈现表)、MCP.md(工具返回)
自测(全通过):自动认领端到端(开开关→扫描→自动迁址 + 名称保留 + 分组/任务引用同步 +
待连接池清理 + 开关默认关且可持久化);名称显示浏览器验证(监控页/覆盖设备 chip/AI 目标与
观看下拉/任务编辑器/分组弹窗/发现设置开关);设备指纹与人工认领回归
This commit is contained in:
@@ -31,6 +31,8 @@ _K_ENABLED = "discovery_enabled"
|
|||||||
_K_SUBNETS = "discovery_subnets"
|
_K_SUBNETS = "discovery_subnets"
|
||||||
_K_INTERVAL = "discovery_interval"
|
_K_INTERVAL = "discovery_interval"
|
||||||
_K_PORT = "discovery_port"
|
_K_PORT = "discovery_port"
|
||||||
|
# 指纹匹配时是否自动认领(默认关:认领会改写分组/任务引用,默认交人工确认)
|
||||||
|
_K_AUTO_CLAIM = "discovery_auto_claim"
|
||||||
|
|
||||||
# 单网段主机数上限(防误配 /8 之类),超过截断并告警
|
# 单网段主机数上限(防误配 /8 之类),超过截断并告警
|
||||||
_MAX_HOSTS_PER_SUBNET = 1024
|
_MAX_HOSTS_PER_SUBNET = 1024
|
||||||
@@ -72,10 +74,11 @@ def get_settings():
|
|||||||
"subnets": subnets,
|
"subnets": subnets,
|
||||||
"interval": int(_get(_K_INTERVAL, str(DISCOVERY_INTERVAL)) or DISCOVERY_INTERVAL),
|
"interval": int(_get(_K_INTERVAL, str(DISCOVERY_INTERVAL)) or DISCOVERY_INTERVAL),
|
||||||
"port": int(_get(_K_PORT, str(DISCOVERY_PORT)) or DISCOVERY_PORT),
|
"port": int(_get(_K_PORT, str(DISCOVERY_PORT)) or DISCOVERY_PORT),
|
||||||
|
"auto_claim": _get(_K_AUTO_CLAIM, "0") == "1", # 默认关
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def save_settings(enabled=None, subnets=None, interval=None, port=None):
|
def save_settings(enabled=None, subnets=None, interval=None, port=None, auto_claim=None):
|
||||||
"""写发现配置(部分字段更新)。subnets 逐项校验 CIDR,非法返回 (False, 原因)。"""
|
"""写发现配置(部分字段更新)。subnets 逐项校验 CIDR,非法返回 (False, 原因)。"""
|
||||||
if subnets is not None:
|
if subnets is not None:
|
||||||
clean = []
|
clean = []
|
||||||
@@ -109,6 +112,8 @@ def save_settings(enabled=None, subnets=None, interval=None, port=None):
|
|||||||
_put(_K_INTERVAL, str(int(interval)))
|
_put(_K_INTERVAL, str(int(interval)))
|
||||||
if port is not None:
|
if port is not None:
|
||||||
_put(_K_PORT, str(int(port)))
|
_put(_K_PORT, str(int(port)))
|
||||||
|
if auto_claim is not None:
|
||||||
|
_put(_K_AUTO_CLAIM, "1" if auto_claim else "0")
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return True, "已保存"
|
return True, "已保存"
|
||||||
|
|
||||||
@@ -196,6 +201,35 @@ def _parse_adb_devices(out):
|
|||||||
return devices
|
return devices
|
||||||
|
|
||||||
|
|
||||||
|
# ================== 自动认领(可选) ==================
|
||||||
|
def _auto_claim(fps):
|
||||||
|
"""指纹命中池中已有设备 → 自动认领:迁移记录到新地址并同步分组/任务引用。
|
||||||
|
|
||||||
|
仅在设置 discovery_auto_claim 打开时由扫描线程调用(默认关)。
|
||||||
|
返回 [(old_serial, new_serial, name), ...]。
|
||||||
|
"""
|
||||||
|
from core import device_pool
|
||||||
|
done = []
|
||||||
|
for serial, fp in (fps or {}).items():
|
||||||
|
if not fp:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
info = device_pool.find_by_fingerprint(fp)
|
||||||
|
if not info or info.get("serial") == serial:
|
||||||
|
continue # 没匹配到,或本来就是这条(无需迁移)
|
||||||
|
old, name = device_pool.claim_device(serial, fp)
|
||||||
|
if not old:
|
||||||
|
continue
|
||||||
|
PendingDevice.query.filter_by(serial=serial).delete()
|
||||||
|
db.session.commit()
|
||||||
|
done.append((old, serial, name or info.get("name") or ""))
|
||||||
|
_log.info(f"自动认领: 『{name or info.get('name') or old}』{old} → {serial}")
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
_log.warning(f"自动认领 {serial} 失败: {e}")
|
||||||
|
return done
|
||||||
|
|
||||||
|
|
||||||
# ================== 扫描 ==================
|
# ================== 扫描 ==================
|
||||||
def scan_once(manual=False):
|
def scan_once(manual=False):
|
||||||
"""执行一轮扫描。返回 (ok, result_dict);后台线程/API 调用。"""
|
"""执行一轮扫描。返回 (ok, result_dict);后台线程/API 调用。"""
|
||||||
@@ -234,12 +268,14 @@ def scan_once(manual=False):
|
|||||||
existing = {p.serial for p in PendingDevice.query.all()}
|
existing = {p.serial for p in PendingDevice.query.all()}
|
||||||
added = 0
|
added = 0
|
||||||
from core import device_pool
|
from core import device_pool
|
||||||
|
fps = {}
|
||||||
for serial in verified:
|
for serial in verified:
|
||||||
source = "tailscale" if serial.split(":")[0].startswith("100.") else "lan"
|
source = "tailscale" if serial.split(":")[0].startswith("100.") else "lan"
|
||||||
try:
|
try:
|
||||||
fp = device_pool.read_fingerprint(serial, timeout=4)
|
fp = device_pool.read_fingerprint(serial, timeout=4)
|
||||||
except Exception:
|
except Exception:
|
||||||
fp = ""
|
fp = ""
|
||||||
|
fps[serial] = fp
|
||||||
if serial in existing:
|
if serial in existing:
|
||||||
PendingDevice.query.filter_by(serial=serial).update(
|
PendingDevice.query.filter_by(serial=serial).update(
|
||||||
{"last_seen": now, "fingerprint": fp})
|
{"last_seen": now, "fingerprint": fp})
|
||||||
@@ -249,6 +285,10 @@ def scan_once(manual=False):
|
|||||||
fingerprint=fp))
|
fingerprint=fp))
|
||||||
added += 1
|
added += 1
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
# 5.5 自动认领(可选,默认关):指纹命中池中已有设备 → 直接把记录迁到新地址。
|
||||||
|
# 默认关是因为认领会改写分组/任务引用(数据结构变动),交人工点一下更稳妥;
|
||||||
|
# 打开后零点击完成,见 doc/API.md §6。
|
||||||
|
claimed = _auto_claim(fps) if settings.get("auto_claim") else []
|
||||||
# 6. 正式池断联设备自动重连:adb connect 会因 WiFi 波动/设备重启/
|
# 6. 正式池断联设备自动重连:adb connect 会因 WiFi 波动/设备重启/
|
||||||
# adb 服务重启而断开——扫描线程每轮顺带重试(幂等轻量,内部
|
# adb 服务重启而断开——扫描线程每轮顺带重试(幂等轻量,内部
|
||||||
# 全局锁串行),连上即恢复在线,无需人工干预。pending 池是给
|
# 全局锁串行),连上即恢复在线,无需人工干预。pending 池是给
|
||||||
@@ -256,10 +296,13 @@ def scan_once(manual=False):
|
|||||||
back = _reconnect_offline(configured)
|
back = _reconnect_offline(configured)
|
||||||
_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]}"
|
||||||
|
if claimed else ""))
|
||||||
_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), "new": added}
|
return True, {"found": len(open_ips), "verified": len(verified),
|
||||||
|
"new": added, "claimed": len(claimed)}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_log.warning(f"发现扫描异常: {e}")
|
_log.warning(f"发现扫描异常: {e}")
|
||||||
_last_error = str(e)[:200]
|
_last_error = str(e)[:200]
|
||||||
@@ -359,6 +402,7 @@ def get_status():
|
|||||||
"subnets": settings["subnets"],
|
"subnets": settings["subnets"],
|
||||||
"interval": settings["interval"],
|
"interval": settings["interval"],
|
||||||
"port": settings["port"],
|
"port": settings["port"],
|
||||||
|
"auto_claim": settings["auto_claim"],
|
||||||
"scanning": _scanning,
|
"scanning": _scanning,
|
||||||
"last_scan": _last_scan[0] if _last_scan else "",
|
"last_scan": _last_scan[0] if _last_scan else "",
|
||||||
"last_result": ({"found": _last_scan[1], "verified": _last_scan[2],
|
"last_result": ({"found": _last_scan[1], "verified": _last_scan[2],
|
||||||
|
|||||||
+17
-3
@@ -83,7 +83,7 @@
|
|||||||
| GET | `/api/summary` | L | 状态计数 + 异常设备列表(最多 50) |
|
| GET | `/api/summary` | L | 状态计数 + 异常设备列表(最多 50) |
|
||||||
| GET | `/api/health` | — | 健康检查(免登录探活) |
|
| GET | `/api/health` | — | 健康检查(免登录探活) |
|
||||||
| POST | `/api/scan_foreground` | D | 触发前台 App 扫描 |
|
| POST | `/api/scan_foreground` | D | 触发前台 App 扫描 |
|
||||||
| GET | `/api/devices` | L | 在线设备 serial 列表(分组表单用) |
|
| GET | `/api/devices` | L | 在线设备列表:`devices`(serial 列表) + `items`([{serial,name,model}],界面显示设备名用) |
|
||||||
| GET | `/api/devices/<serial>/apps` | L | 指定设备已安装应用列表 |
|
| GET | `/api/devices/<serial>/apps` | L | 指定设备已安装应用列表 |
|
||||||
| GET | `/api/device/screenshot` | L | 单张设备截图(PNG) |
|
| GET | `/api/device/screenshot` | L | 单张设备截图(PNG) |
|
||||||
| GET | `/api/screen/stream` | D | 远程看屏 MJPEG 流 |
|
| GET | `/api/screen/stream` | D | 远程看屏 MJPEG 流 |
|
||||||
@@ -194,7 +194,7 @@
|
|||||||
| 方法 | 路径 | 功能 |
|
| 方法 | 路径 | 功能 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| GET/POST | `/api/agent/config` | 读写 AI 配置(key 打码回显) |
|
| GET/POST | `/api/agent/config` | 读写 AI 配置(key 打码回显) |
|
||||||
| GET | `/api/agent/devices` | AI 可用设备(含 busy 标记) |
|
| GET | `/api/agent/devices` | AI 可用设备(含名称与 busy 标记) |
|
||||||
| POST | `/api/agent/run` | 启动一轮 AI 会话 |
|
| POST | `/api/agent/run` | 启动一轮 AI 会话 |
|
||||||
| GET | `/api/agent/run` | 运行状态(刷新恢复用) |
|
| GET | `/api/agent/run` | 运行状态(刷新恢复用) |
|
||||||
| GET | `/api/agent/stream` | **SSE 事件流** |
|
| GET | `/api/agent/stream` | **SSE 事件流** |
|
||||||
@@ -429,6 +429,20 @@
|
|||||||
| `POST /api/devices/pool/reconnect` | — | 后台并发重连全部网络设备 |
|
| `POST /api/devices/pool/reconnect` | — | 后台并发重连全部网络设备 |
|
||||||
| `POST /api/devices/pool/refresh_models` | — | 后台批量采型号 **+ 补齐缺失指纹** |
|
| `POST /api/devices/pool/refresh_models` | — | 后台批量采型号 **+ 补齐缺失指纹** |
|
||||||
|
|
||||||
|
### 6.2.1 名称在界面上的呈现
|
||||||
|
|
||||||
|
凡"选择设备 / 展示设备"的地方都以**名称**为主、地址为辅:
|
||||||
|
|
||||||
|
| 位置 | 呈现 |
|
||||||
|
|------|------|
|
||||||
|
| 监控页设备表 | 名称加粗为主行,`serial` 作副行(未命名显示橙色"未命名"提醒) |
|
||||||
|
| 任务运行概况的覆盖设备 chip | 名称(无名才退地址),tooltip 里带完整 `serial` |
|
||||||
|
| AI 控制台「目标设备」「观看设备」下拉 | `名称 · serial · 型号` |
|
||||||
|
| 任务编辑器「指定设备」下拉 | `名称 · serial` |
|
||||||
|
| 分组编辑的设备勾选列表 | `名称 · serial` |
|
||||||
|
| 设备池 / 待连接池 / 断联设备表 | 名称列 + 指纹列 |
|
||||||
|
| MCP `de_list_devices` | 返回 `name` 字段,提示 AI 汇报时用名称 |
|
||||||
|
|
||||||
### 6.3 自动发现
|
### 6.3 自动发现
|
||||||
|
|
||||||
| 接口 | 请求 | 说明 |
|
| 接口 | 请求 | 说明 |
|
||||||
@@ -438,7 +452,7 @@
|
|||||||
| `POST /api/devices/discovery/confirm` | `{"serial","name"?}` | 确认入池。**新设备必须有名称**(空 → 400);**指纹命中已有设备时不需要名称**——保留原记录与原名,直接认领到新地址 |
|
| `POST /api/devices/discovery/confirm` | `{"serial","name"?}` | 确认入池。**新设备必须有名称**(空 → 400);**指纹命中已有设备时不需要名称**——保留原记录与原名,直接认领到新地址 |
|
||||||
| `POST /api/devices/discovery/ignore` | `{"serial"}` | 从待连接删除 |
|
| `POST /api/devices/discovery/ignore` | `{"serial"}` | 从待连接删除 |
|
||||||
| `POST /api/devices/discovery/reconnect` | `{"serial"}` | 只接受池内设备,否则 404 |
|
| `POST /api/devices/discovery/reconnect` | `{"serial"}` | 只接受池内设备,否则 404 |
|
||||||
| `POST /api/devices/discovery/settings` | `{"enabled","subnets","interval","port"}` | 部分更新,存 `app_meta` |
|
| `POST /api/devices/discovery/settings` | `{"enabled","subnets","interval","port","auto_claim"}` | 部分更新,存 `app_meta`。`auto_claim`=**指纹匹配时自动认领**(默认 **false**:认领会改写分组/任务引用,默认交人工确认;打开后扫描到"同一台设备换了地址"自动迁移) |
|
||||||
|
|
||||||
> 扫描只做 socket 探测 + 只读校验,**不会把设备直接拉进设备池**(必须人工确认)。
|
> 扫描只做 socket 探测 + 只读校验,**不会把设备直接拉进设备池**(必须人工确认)。
|
||||||
> 扫描时会顺带读一遍候选设备的指纹(写入待连接池),用于提示"这台是已有设备换了地址"。
|
> 扫描时会顺带读一遍候选设备的指纹(写入待连接池),用于提示"这台是已有设备换了地址"。
|
||||||
|
|||||||
+1
-1
@@ -78,7 +78,7 @@ MCP_ALLOW_WRITE=1 MCP_PLATFORM_USER=admin MCP_PLATFORM_PASS=<平台密码> \
|
|||||||
|
|
||||||
| 工具 | 参数 | 返回 / 说明 |
|
| 工具 | 参数 | 返回 / 说明 |
|
||||||
|------|------|------------|
|
|------|------|------------|
|
||||||
| `de_list_devices` | — | `[{serial, model, online, task_job, worker_status, foreground_app}]`。**开局第一步** |
|
| `de_list_devices` | — | `[{serial, name, model, online, task_job, worker_status, foreground_app}]`。**开局第一步**;`name` 是设备在平台里的名称(身份),**向用户汇报时用名称**(同型号多台靠它区分),调工具仍用 `serial` |
|
||||||
| `de_foreground_app` | `serial` | `{foreground_app}` 当前前台包名(dumpsys,MIUI 焦点为空时兜底) |
|
| `de_foreground_app` | `serial` | `{foreground_app}` 当前前台包名(dumpsys,MIUI 焦点为空时兜底) |
|
||||||
| `de_list_tasks` | — | 平台任务计划 `[{id,name,task_type,enabled,schedule}]`(只读) |
|
| `de_list_tasks` | — | 平台任务计划 `[{id,name,task_type,enabled,schedule}]`(只读) |
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -31,7 +31,8 @@ class _UsageUnsupported(RuntimeError):
|
|||||||
SYSTEM_PROMPT = """你是手机自动化控制助手。你通过工具实时操作 Android 手机。
|
SYSTEM_PROMPT = """你是手机自动化控制助手。你通过工具实时操作 Android 手机。
|
||||||
|
|
||||||
工作规范:
|
工作规范:
|
||||||
1. 先 de_list_devices 确定目标设备(在线才可操作)
|
1. 先 de_list_devices 确定目标设备(在线才可操作);设备有 name(名称)与 serial(地址),
|
||||||
|
**给用户汇报时用名称**(同型号多台靠它区分),调工具时仍用 serial
|
||||||
2. 观察屏幕:先 de_screenshot 获取截图(图像会随后给你),基于截图理解当前界面
|
2. 观察屏幕:先 de_screenshot 获取截图(图像会随后给你),基于截图理解当前界面
|
||||||
3. 点击定位分优先级(不要自己推算像素坐标,那是精度最差的方式):
|
3. 点击定位分优先级(不要自己推算像素坐标,那是精度最差的方式):
|
||||||
a) 目标有可见文字(按钮/菜单/列表标题/标签/输入框提示)→ de_tap_text 直接给文字,
|
a) 目标有可见文字(按钮/菜单/列表标题/标签/输入框提示)→ de_tap_text 直接给文字,
|
||||||
|
|||||||
@@ -98,9 +98,11 @@ def _ok(data):
|
|||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def de_list_devices() -> dict:
|
def de_list_devices() -> dict:
|
||||||
"""列出可控制设备:serial/在线状态/型号/任务状态/前台 App。
|
"""列出可控制设备:名称/地址/在线状态/型号/任务状态/前台 App。
|
||||||
|
|
||||||
返回 [{serial, model, online, task_job, worker_status, foreground_app}]。
|
返回 [{serial, name, model, online, task_job, worker_status, foreground_app}]。
|
||||||
|
`name` 是设备在平台里的名称(设备池里的身份标识);对你的用户汇报时**优先用名称**
|
||||||
|
(多台同型号设备时靠它区分)。后续所有工具仍然用 `serial` 指定设备。
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
devs = platform().list_devices()
|
devs = platform().list_devices()
|
||||||
@@ -110,7 +112,8 @@ def de_list_devices() -> dict:
|
|||||||
for d in devs:
|
for d in devs:
|
||||||
out.append({
|
out.append({
|
||||||
"serial": d.get("serial"),
|
"serial": d.get("serial"),
|
||||||
"model": d.get("model") or d.get("device_name") or "",
|
"name": d.get("device_name") or "",
|
||||||
|
"model": d.get("model") or "",
|
||||||
"online": bool(d.get("present")),
|
"online": bool(d.get("present")),
|
||||||
"task_job": d.get("task_job") or "",
|
"task_job": d.get("task_job") or "",
|
||||||
"worker_status": d.get("worker_status") or "idle",
|
"worker_status": d.get("worker_status") or "idle",
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ async function loadGroups(){
|
|||||||
const r=await apiGet('/api/groups');if(!r)return;
|
const r=await apiGet('/api/groups');if(!r)return;
|
||||||
_groupsList=r.groups||[];
|
_groupsList=r.groups||[];
|
||||||
const dr=await apiGet('/api/devices');
|
const dr=await apiGet('/api/devices');
|
||||||
_devicesList=dr?dr.devices||[]:[];
|
// items 带名称(设备身份);老接口只有 serial 列表时兜底成对象
|
||||||
|
_devicesList=(dr&&dr.items)?dr.items
|
||||||
|
:((dr&&dr.devices)||[]).map(s=>({serial:s,name:'',model:''}));
|
||||||
renderGroupTable();
|
renderGroupTable();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,9 +45,12 @@ function openGroupModal(name){
|
|||||||
document.getElementById('modal-title').textContent=isEdit?'编辑分组':'新建分组';
|
document.getElementById('modal-title').textContent=isEdit?'编辑分组':'新建分组';
|
||||||
const g=group||{name:'',description:'',serials:[]};
|
const g=group||{name:'',description:'',serials:[]};
|
||||||
const current= new Set(g.serials||[]);
|
const current= new Set(g.serials||[]);
|
||||||
const allSerials=[...new Set([..._devicesList, ...(g.serials||[])])].sort();
|
const nameOf={};
|
||||||
|
(_devicesList||[]).forEach(x=>{ if(x&&x.serial) nameOf[x.serial]=x.name||''; });
|
||||||
|
const allSerials=[...new Set([...(_devicesList||[]).map(x=>x.serial||x), ...(g.serials||[])])].sort();
|
||||||
const deviceCheckboxes=allSerials.map(s=>
|
const deviceCheckboxes=allSerials.map(s=>
|
||||||
'<label><input type="checkbox" value="'+esc(s)+'" '+(current.has(s)?'checked':'')+'>'+esc(s)+'</label>'
|
'<label><input type="checkbox" value="'+esc(s)+'" '+(current.has(s)?'checked':'')+'>'
|
||||||
|
+esc(nameOf[s]?devText(nameOf[s], s):s)+'</label>'
|
||||||
).join('');
|
).join('');
|
||||||
|
|
||||||
document.getElementById('modal-body').innerHTML=
|
document.getElementById('modal-body').innerHTML=
|
||||||
|
|||||||
+19
-6
@@ -16,6 +16,12 @@ function _friendlyAgentError(msg){
|
|||||||
|
|
||||||
// ================== 初始化 ==================
|
// ================== 初始化 ==================
|
||||||
let _agentDefaultSerial = '';
|
let _agentDefaultSerial = '';
|
||||||
|
// 目标设备显示文案:名称优先(设备身份),没有名称退地址
|
||||||
|
let _agentTargets={}; // serial -> name
|
||||||
|
function _agentTargetText(serial){
|
||||||
|
const nm=_agentTargets[serial];
|
||||||
|
return nm ? (nm + ' · ' + serial) : String(serial||'');
|
||||||
|
}
|
||||||
function initAgentChat(){
|
function initAgentChat(){
|
||||||
if(_agentCfgLoaded)return;
|
if(_agentCfgLoaded)return;
|
||||||
_agentCfgLoaded = true;
|
_agentCfgLoaded = true;
|
||||||
@@ -81,9 +87,11 @@ function loadAgentTargetDevices(){
|
|||||||
if(!sel)return;
|
if(!sel)return;
|
||||||
const cur = sel.value;
|
const cur = sel.value;
|
||||||
const devs = (r.devices||[]).filter(x=>x.online);
|
const devs = (r.devices||[]).filter(x=>x.online);
|
||||||
|
_agentTargets = {};
|
||||||
|
devs.forEach(d=>{ if(d.name) _agentTargets[d.serial]=d.name; });
|
||||||
sel.innerHTML = '<option value="">请选择设备(AI 只操作选定设备)…</option>'
|
sel.innerHTML = '<option value="">请选择设备(AI 只操作选定设备)…</option>'
|
||||||
+ devs.map(d=>{
|
+ devs.map(d=>{
|
||||||
const label = d.serial + (d.model ? ' · ' + d.model : '')
|
const label = devText(d.name, d.serial) + (d.model ? ' · ' + d.model : '')
|
||||||
+ (d.busy ? ' ⛔ 任务中:' + d.task_job : '');
|
+ (d.busy ? ' ⛔ 任务中:' + d.task_job : '');
|
||||||
return '<option value="'+esc(d.serial)+'"' + (d.busy ? ' disabled' : '')
|
return '<option value="'+esc(d.serial)+'"' + (d.busy ? ' disabled' : '')
|
||||||
+ '>' + esc(label) + '</option>';
|
+ '>' + esc(label) + '</option>';
|
||||||
@@ -93,7 +101,7 @@ function loadAgentTargetDevices(){
|
|||||||
else if(_agentDefaultSerial && [...sel.options].some(o=>o.value===_agentDefaultSerial))
|
else if(_agentDefaultSerial && [...sel.options].some(o=>o.value===_agentDefaultSerial))
|
||||||
sel.value = _agentDefaultSerial;
|
sel.value = _agentDefaultSerial;
|
||||||
const hint = document.getElementById('agent-target-hint');
|
const hint = document.getElementById('agent-target-hint');
|
||||||
if(hint && sel.value) hint.textContent = '将操作:' + sel.value;
|
if(hint && sel.value) hint.textContent = '将操作:' + _agentTargetText(sel.value);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,10 +122,11 @@ function pollRunState(){
|
|||||||
if(hint){
|
if(hint){
|
||||||
hint.textContent = running
|
hint.textContent = running
|
||||||
? ('⏳ 运行中' + (r.started ? ' ' + r.started + ' 起' : '')
|
? ('⏳ 运行中' + (r.started ? ' ' + r.started + ' 起' : '')
|
||||||
+ (r.serial ? ' · ' + r.serial : '')
|
+ (r.serial ? ' · ' + _agentTargetText(r.serial) : '')
|
||||||
+ ':' + (r.prompt||'').slice(0,70))
|
+ ':' + (r.prompt||'').slice(0,70))
|
||||||
: (document.getElementById('agent-target-select').value
|
: (document.getElementById('agent-target-select').value
|
||||||
? '将操作:' + document.getElementById('agent-target-select').value : '');
|
? '将操作:' + _agentTargetText(document.getElementById('agent-target-select').value)
|
||||||
|
: '');
|
||||||
}
|
}
|
||||||
document.getElementById('btn-agent-send').disabled = running;
|
document.getElementById('btn-agent-send').disabled = running;
|
||||||
// 运行中同步 token(本窗口没订阅事件流时也能看到进度)
|
// 运行中同步 token(本窗口没订阅事件流时也能看到进度)
|
||||||
@@ -608,9 +617,13 @@ function loadLiveDevices(){
|
|||||||
const sel = document.getElementById('agent-live-select');
|
const sel = document.getElementById('agent-live-select');
|
||||||
if(!sel)return;
|
if(!sel)return;
|
||||||
const cur = sel.value;
|
const cur = sel.value;
|
||||||
const devs = (r.devices||[]).filter(x=>x.indexOf(':')>=0);
|
// items 带设备名称(设备身份);老接口只有 serial 列表时兜底
|
||||||
|
const items = (r.items && r.items.length ? r.items
|
||||||
|
: (r.devices||[]).map(x=>({serial:x, name:''})))
|
||||||
|
.filter(x=>String(x.serial||'').indexOf(':')>=0);
|
||||||
sel.innerHTML = '<option value="">选择设备观看…</option>'
|
sel.innerHTML = '<option value="">选择设备观看…</option>'
|
||||||
+ devs.map(s=>'<option value="'+esc(s)+'"'+(s===cur?' selected':'')+'>'+esc(s)+'</option>').join('');
|
+ items.map(x=>'<option value="'+esc(x.serial)+'"'+(x.serial===cur?' selected':'')+'>'
|
||||||
|
+esc(devText(x.name, x.serial))+'</option>').join('');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,13 @@
|
|||||||
const ACTION_LABELS={like:'点赞',comment:'评论',follow:'关注',share:'分享'};
|
const ACTION_LABELS={like:'点赞',comment:'评论',follow:'关注',share:'分享'};
|
||||||
const STATUS_MAP={idle:{t:'未启动',c:'default'},connecting:{t:'连接中',c:'warning'},running:{t:'运行中',c:'success'},done:{t:'完成',c:'primary'},error:{t:'异常',c:'danger'},released:{t:'已释放',c:'default'},failed:{t:'失败',c:'danger'}};
|
const STATUS_MAP={idle:{t:'未启动',c:'default'},connecting:{t:'连接中',c:'warning'},running:{t:'运行中',c:'success'},done:{t:'完成',c:'primary'},error:{t:'异常',c:'danger'},released:{t:'已释放',c:'default'},failed:{t:'失败',c:'danger'}};
|
||||||
const TARGET_MAP={all:'全部空闲',group:'按分组',serial:'指定设备'};
|
const TARGET_MAP={all:'全部空闲',group:'按分组',serial:'指定设备'};
|
||||||
|
|
||||||
|
// 设备显示文案:**名称是设备身份**,地址只是当前连接位置。
|
||||||
|
// 有名称 → 「名称 · serial」;没名称(历史数据)→ 只显示 serial
|
||||||
|
function devText(name, serial){
|
||||||
|
const n=String(name||'').trim();
|
||||||
|
return n ? (n + ' · ' + serial) : String(serial||'');
|
||||||
|
}
|
||||||
const APP_NAMES={'com.ss.android.ugc.aweme':'抖音','com.ss.android.ugc.aweme.lite':'抖音极速版','com.smile.gifmaker':'快手','com.kuaishou.nebula':'快手极速版','com.tencent.mm':'微信','com.tencent.mobileqq':'QQ','com.tencent.qqlive':'腾讯视频','com.eg.android.AlipayGphone':'支付宝','com.taobao.taobao':'淘宝','com.tmall.wireless':'天猫','com.jingdong.app.mall':'京东','com.xunmeng.pinduoduo':'拼多多','com.ss.android.article.news':'今日头条','com.zhihu.android':'知乎','com.miui.home':'桌面','com.android.launcher':'桌面','com.android.settings':'设置','com.android.systemui':'系统','com.github.uiautomator':'U2','com.github.uiautomator.test':'U2测试'};
|
const APP_NAMES={'com.ss.android.ugc.aweme':'抖音','com.ss.android.ugc.aweme.lite':'抖音极速版','com.smile.gifmaker':'快手','com.kuaishou.nebula':'快手极速版','com.tencent.mm':'微信','com.tencent.mobileqq':'QQ','com.tencent.qqlive':'腾讯视频','com.eg.android.AlipayGphone':'支付宝','com.taobao.taobao':'淘宝','com.tmall.wireless':'天猫','com.jingdong.app.mall':'京东','com.xunmeng.pinduoduo':'拼多多','com.ss.android.article.news':'今日头条','com.zhihu.android':'知乎','com.miui.home':'桌面','com.android.launcher':'桌面','com.android.settings':'设置','com.android.systemui':'系统','com.github.uiautomator':'U2','com.github.uiautomator.test':'U2测试'};
|
||||||
|
|
||||||
function esc(s){return String(s||'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));}
|
function esc(s){return String(s||'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));}
|
||||||
|
|||||||
+10
-4
@@ -355,8 +355,12 @@ function renderDeviceRow(dev){
|
|||||||
const clearBtn=canDev&&(dev.worker_status==='error'||dev.worker_status==='failed')
|
const clearBtn=canDev&&(dev.worker_status==='error'||dev.worker_status==='failed')
|
||||||
?'<button class="btn btn-xs" onclick="clearDeviceError(\''+esc(dev.serial)+'\')" title="清除该设备的异常状态,恢复为未启动">清除异常</button>':'';
|
?'<button class="btn btn-xs" onclick="clearDeviceError(\''+esc(dev.serial)+'\')" title="清除该设备的异常状态,恢复为未启动">清除异常</button>':'';
|
||||||
const btns='<div style="display:flex;gap:4px;flex-wrap:wrap">'+shotBtn+locateBtn+clearBtn+stopBtn+'</div>';
|
const btns='<div style="display:flex;gap:4px;flex-wrap:wrap">'+shotBtn+locateBtn+clearBtn+stopBtn+'</div>';
|
||||||
const devName=dev.device_name||dev.model||'';
|
// 名称优先(设备身份),地址作副标题;未命名的历史数据显示提醒
|
||||||
const devLabel=(devName?'<strong>'+esc(dev.serial)+'</strong><br><span style="font-size:11px;color:#94a3b8">'+esc(devName)+'</span>':'<strong>'+esc(dev.serial)+'</strong>')+offline;
|
const nm=(dev.device_name||'').trim();
|
||||||
|
const devLabel=(nm
|
||||||
|
? '<strong>'+esc(nm)+'</strong><br><span style="font-size:11px;color:#94a3b8;font-family:var(--mono)">'+esc(dev.serial)+'</span>'
|
||||||
|
: '<strong style="font-family:var(--mono)">'+esc(dev.serial)+'</strong>'
|
||||||
|
+'<br><span style="font-size:11px;color:#d97706">未命名</span>') + offline;
|
||||||
// 复选框列
|
// 复选框列
|
||||||
const checked=_selectedSerials.has(dev.serial)?'checked':'';
|
const checked=_selectedSerials.has(dev.serial)?'checked':'';
|
||||||
const rowCls=_selectedSerials.has(dev.serial)?'row-checked':'';
|
const rowCls=_selectedSerials.has(dev.serial)?'row-checked':'';
|
||||||
@@ -462,11 +466,13 @@ function _jobCoverageHtml(j){
|
|||||||
if(online)onlineN++;
|
if(online)onlineN++;
|
||||||
if(busy)busyN++;
|
if(busy)busyN++;
|
||||||
const cls=busy?'running':(online?'online':'offline');
|
const cls=busy?'running':(online?'online':'offline');
|
||||||
const title=s+(d.model?' · '+d.model:'')
|
// chip 优先显示设备名称(身份),没有名称才退回地址
|
||||||
|
const nm=(d.device_name||'').trim();
|
||||||
|
const title=s+(nm?' · '+nm:'')+(d.model?' · '+d.model:'')
|
||||||
+(busy?' · 任务中:'+(d.task_job||''):'')
|
+(busy?' · 任务中:'+(d.task_job||''):'')
|
||||||
+(online?'':' · 离线或不在设备池');
|
+(online?'':' · 离线或不在设备池');
|
||||||
return '<span class="cov-chip '+cls+'" title="'+esc(title)+'">'
|
return '<span class="cov-chip '+cls+'" title="'+esc(title)+'">'
|
||||||
+esc(s.replace(/:5555$/,''))+(busy?' ⏳':'')+(online?'':' ✕')+'</span>';
|
+esc(nm || s.replace(/:5555$/,''))+(busy?' ⏳':'')+(online?'':' ✕')+'</span>';
|
||||||
}).join('');
|
}).join('');
|
||||||
return '<div class="job-cov"><span class="kv">覆盖 '+serials.length+' 台 · 在线 '+onlineN
|
return '<div class="job-cov"><span class="kv">覆盖 '+serials.length+' 台 · 在线 '+onlineN
|
||||||
+(busyN?' · 运行中 '+busyN:'')+'</span>'
|
+(busyN?' · 运行中 '+busyN:'')+'</span>'
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ async function loadTasks(){
|
|||||||
_jobsData=jr.jobs||[];
|
_jobsData=jr.jobs||[];
|
||||||
_taskTypes=tr?tr.task_types||[]:[];
|
_taskTypes=tr?tr.task_types||[]:[];
|
||||||
_groupsList=gr?gr.groups||[]:[];
|
_groupsList=gr?gr.groups||[]:[];
|
||||||
_devicesList=dr?dr.devices||[]:[];
|
// items 带名称(设备身份);老接口只有 serial 列表时兜底成对象
|
||||||
|
_devicesList=(dr&&dr.items)?dr.items
|
||||||
|
:((dr&&dr.devices)||[]).map(s=>({serial:s,name:'',model:''}));
|
||||||
renderTaskTable();
|
renderTaskTable();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,7 +74,8 @@ function openTaskModal(jobId){
|
|||||||
const j=job||{name:'',task_type:'generic_steps',enabled:true,target:{mode:'all'},params:{},schedule:{mode:'once'},retry:{max_attempts:1,delay:60}};
|
const j=job||{name:'',task_type:'generic_steps',enabled:true,target:{mode:'all'},params:{},schedule:{mode:'once'},retry:{max_attempts:1,delay:60}};
|
||||||
const tt=_taskTypes.map(t=>'<option value="'+t.task_type+'"'+(t.task_type===j.task_type?' selected':'')+'>'+esc(t.name)+'</option>').join('');
|
const tt=_taskTypes.map(t=>'<option value="'+t.task_type+'"'+(t.task_type===j.task_type?' selected':'')+'>'+esc(t.name)+'</option>').join('');
|
||||||
const gl=_groupsList.map(g=>'<option value="'+g.name+'"'+(g.name===j.target.group_name?' selected':'')+'>'+esc(g.name)+'</option>').join('');
|
const gl=_groupsList.map(g=>'<option value="'+g.name+'"'+(g.name===j.target.group_name?' selected':'')+'>'+esc(g.name)+'</option>').join('');
|
||||||
const dl=_devicesList.map(s=>'<option value="'+s+'"'+(s===j.target.serial?' selected':'')+'>'+esc(s)+'</option>').join('');
|
const dl=_devicesList.map(x=>'<option value="'+esc(x.serial)+'"'+(x.serial===j.target.serial?' selected':'')+'>'
|
||||||
|
+esc(devText(x.name, x.serial))+'</option>').join('');
|
||||||
const sm=j.schedule.mode;
|
const sm=j.schedule.mode;
|
||||||
const startCron=(sm==='cron'||sm==='cron_stop')?(j.schedule.cron||''):'';
|
const startCron=(sm==='cron'||sm==='cron_stop')?(j.schedule.cron||''):'';
|
||||||
const stopCron=sm==='cron_stop'?(j.schedule.stop_cron||''):'';
|
const stopCron=sm==='cron_stop'?(j.schedule.stop_cron||''):'';
|
||||||
|
|||||||
+15
-1
@@ -51,13 +51,16 @@ function loadDiscovery(){
|
|||||||
const subnets=document.getElementById('discovery-subnets');
|
const subnets=document.getElementById('discovery-subnets');
|
||||||
if(!enabled)return; // 面板未渲染(离开子分栏)
|
if(!enabled)return; // 面板未渲染(离开子分栏)
|
||||||
enabled.checked=!!r.enabled;
|
enabled.checked=!!r.enabled;
|
||||||
|
const ac=document.getElementById('discovery-autoclaim');
|
||||||
|
if(ac)ac.checked=!!r.auto_claim;
|
||||||
interval.value=r.interval||60;
|
interval.value=r.interval||60;
|
||||||
subnets.value=(r.subnets||[]).join(', ');
|
subnets.value=(r.subnets||[]).join(', ');
|
||||||
// 状态行
|
// 状态行
|
||||||
if(r.scanning){status.textContent='扫描中...';}
|
if(r.scanning){status.textContent='扫描中...';}
|
||||||
else if(r.last_scan){
|
else if(r.last_scan){
|
||||||
let txt='上次扫描 '+esc(r.last_scan)+' · 开放 '+r.last_result.found+' · 可连 '
|
let txt='上次扫描 '+esc(r.last_scan)+' · 开放 '+r.last_result.found+' · 可连 '
|
||||||
+(r.last_result.verified||0)+' · 新增 '+(r.last_result.new||0);
|
+(r.last_result.verified||0)+' · 新增 '+(r.last_result.new||0)
|
||||||
|
+(r.last_result.claimed?' · 自动认领 '+r.last_result.claimed:'');
|
||||||
if(r.last_error)txt+=' · ⚠ '+esc(r.last_error);
|
if(r.last_error)txt+=' · ⚠ '+esc(r.last_error);
|
||||||
status.textContent=txt;
|
status.textContent=txt;
|
||||||
}else{status.textContent='尚未扫描';}
|
}else{status.textContent='尚未扫描';}
|
||||||
@@ -121,6 +124,17 @@ function reconnectPoolDev(serial){
|
|||||||
}else showToast('重连失败: '+((r&&r.error)||''),'error');
|
}else showToast('重连失败: '+((r&&r.error)||''),'error');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// 指纹匹配自动认领开关(默认关:认领会改写分组/任务引用,交人工确认更稳妥)
|
||||||
|
function toggleAutoClaim(checked){
|
||||||
|
const tip=checked
|
||||||
|
? '已开启自动认领:扫描到"同一台设备换了 IP"时会自动迁移记录并同步分组/任务引用'
|
||||||
|
: '已关闭自动认领:扫描到换 IP 的设备会提示,由你点确认';
|
||||||
|
apiPost('/api/devices/discovery/settings',{auto_claim:checked}).then(r=>{
|
||||||
|
if(r&&r.ok)showToast(tip,'success');
|
||||||
|
else{showToast('设置失败: '+((r&&r.error)||''),'error');loadDiscovery();}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function toggleDiscovery(checked){
|
function toggleDiscovery(checked){
|
||||||
apiPost('/api/devices/discovery/settings',{enabled:checked}).then(r=>{
|
apiPost('/api/devices/discovery/settings',{enabled:checked}).then(r=>{
|
||||||
if(r&&r.ok)showToast('定时扫描已'+(checked?'开启':'关闭'),'success');
|
if(r&&r.ok)showToast('定时扫描已'+(checked?'开启':'关闭'),'success');
|
||||||
|
|||||||
@@ -840,6 +840,10 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
|
|||||||
<span class="text-muted">秒 · 网段</span>
|
<span class="text-muted">秒 · 网段</span>
|
||||||
<input type="text" id="discovery-subnets" class="form-control" style="width:300px;font-family:monospace"
|
<input type="text" id="discovery-subnets" class="form-control" style="width:300px;font-family:monospace"
|
||||||
placeholder="192.168.20.0/24, 100.100.10.0/24" title="逗号分隔的 CIDR 网段">
|
placeholder="192.168.20.0/24, 100.100.10.0/24" title="逗号分隔的 CIDR 网段">
|
||||||
|
<label style="display:inline-flex;align-items:center;gap:4px;margin-left:10px"
|
||||||
|
title="开启后:扫描发现某台设备的指纹与设备池中已有记录一致(= 同一台设备换了 IP)时,自动把它认领到新地址——迁移原记录并同步分组/任务里的引用,不再需要人工确认。默认关:认领会改写分组与任务的引用,交人工点一下更稳妥。">
|
||||||
|
<input type="checkbox" id="discovery-autoclaim" onchange="toggleAutoClaim(this.checked)"> 指纹匹配自动认领
|
||||||
|
</label>
|
||||||
<button class="btn" onclick="saveDiscoverySettings()">保存设置</button>
|
<button class="btn" onclick="saveDiscoverySettings()">保存设置</button>
|
||||||
<button class="btn btn-primary" onclick="runDiscoveryScan()">立即扫描</button>
|
<button class="btn btn-primary" onclick="runDiscoveryScan()">立即扫描</button>
|
||||||
<span id="discovery-status" class="text-muted"></span>
|
<span id="discovery-status" class="text-muted"></span>
|
||||||
|
|||||||
@@ -1270,6 +1270,7 @@ def agent_devices():
|
|||||||
for d in (devs or []):
|
for d in (devs or []):
|
||||||
busy = d.get("worker_status") in ("running", "connecting")
|
busy = d.get("worker_status") in ("running", "connecting")
|
||||||
out.append({"serial": d.get("serial"),
|
out.append({"serial": d.get("serial"),
|
||||||
|
"name": d.get("device_name") or "", # 设备名称(设备池里的身份标识)
|
||||||
"model": d.get("model") or "",
|
"model": d.get("model") or "",
|
||||||
"online": bool(d.get("present")),
|
"online": bool(d.get("present")),
|
||||||
"busy": busy,
|
"busy": busy,
|
||||||
|
|||||||
+2
-1
@@ -310,7 +310,8 @@ def api_devices_discovery_settings():
|
|||||||
enabled=data.get("enabled"),
|
enabled=data.get("enabled"),
|
||||||
subnets=data.get("subnets"),
|
subnets=data.get("subnets"),
|
||||||
interval=data.get("interval"),
|
interval=data.get("interval"),
|
||||||
port=data.get("port"))
|
port=data.get("port"),
|
||||||
|
auto_claim=data.get("auto_claim"))
|
||||||
if not ok:
|
if not ok:
|
||||||
return jsonify({"ok": False, "error": msg}), 400
|
return jsonify({"ok": False, "error": msg}), 400
|
||||||
return jsonify({"ok": True, "msg": msg})
|
return jsonify({"ok": True, "msg": msg})
|
||||||
|
|||||||
+17
-2
@@ -41,9 +41,24 @@ def api_actions():
|
|||||||
@bp.route("/api/devices")
|
@bp.route("/api/devices")
|
||||||
@login_required
|
@login_required
|
||||||
def api_devices():
|
def api_devices():
|
||||||
"""返回所有在线设备 serial(供分组表单勾选用)。"""
|
"""在线设备列表(供分组表单/任务目标/AI 的「选择设备」下拉用)。
|
||||||
|
|
||||||
|
- `devices`:serial 字符串列表(兼容既有调用方)
|
||||||
|
- `items`:`[{serial, name, model}]` —— 界面显示**设备名称**用
|
||||||
|
(名称是设备的身份,见 doc/API.md §6)
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
return jsonify({"ok": True, "devices": context.mgr.list_all_serials()})
|
serials = context.mgr.list_all_serials()
|
||||||
|
info = {}
|
||||||
|
try:
|
||||||
|
for d in device_pool.list_devices():
|
||||||
|
info[d["serial"]] = d
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
items = [{"serial": s,
|
||||||
|
"name": (info.get(s) or {}).get("name") or "",
|
||||||
|
"model": (info.get(s) or {}).get("model") or ""} for s in serials]
|
||||||
|
return jsonify({"ok": True, "devices": serials, "items": items})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"ok": False, "error": str(e)}), 500
|
return jsonify({"ok": False, "error": str(e)}), 500
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user