Files
auto_control/web_server.py
T
butubb 621d48c800 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。
2026-09-16 08:58:43 +08:00

356 lines
15 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""设备自动化管理后台入口:Flask app 装配 + 启动。
路由按功能域拆分到 web/ 蓝图包(auth/monitor/tasks/admin/tools/devices/apks/tailscale),
本文件只负责:app 创建、数据库初始化、共享对象装配、蓝图注册、uiautodev 生命周期、启动。
"""
import os
import time
import atexit
import secrets
import socket
import subprocess
import sys
import threading
from flask import Flask
from flask_login import LoginManager
from core.task_manager import TaskManager
from core.apk_manager import ApkManager
from core.logger import get_logger
from core.models import db, init_db, User
from core import uiauto_helper
from config import WEB_HOST, WEB_PORT
_log = get_logger("web")
app = Flask(__name__)
# 会话密钥:优先 .env 的 WEB_SECRET_KEY;未配置则随机生成(重启后登录态失效,生产务必配置固定值)
_web_secret = os.environ.get("WEB_SECRET_KEY") or secrets.token_hex(32)
if not os.environ.get("WEB_SECRET_KEY"):
_log.warning("未配置 WEB_SECRET_KEY,已随机生成会话密钥(重启后登录态失效)")
app.config["SECRET_KEY"] = _web_secret
# debug=False 时模板默认不自动重载,开发期改 HTML 需重启才生效;这里显式开启
app.config["TEMPLATES_AUTO_RELOAD"] = True
# 数据库目标与连接参数统一由 core/db_config 装配(.env 的 DEPLOY_ENV / DB_* 决定)。
# 这里就把配置校验做完:连不上、环境与库名不匹配 → 直接拒绝启动,不要带着错配置跑起来。
from core import db_config
try:
_DB_URI = db_config.build_db_uri()
db_config.check_connection(_DB_URI)
except db_config.DBConfigError as _e:
_log.error("数据库配置校验失败,拒绝启动:\n%s", _e)
raise SystemExit(2)
app.config["SQLALCHEMY_DATABASE_URI"] = _DB_URI
app.config["SQLALCHEMY_ENGINE_OPTIONS"] = db_config.engine_options(_DB_URI)
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
login_manager = LoginManager(app)
login_manager.login_view = "auth.login" # 蓝图化后路由前缀 auth
def _consume_pending_restore():
"""消费「待生效的备份恢复」(若存在 data/restore_pending/users.db)。
必须在 init_db 之后调用:现在是「单事务整库替换」而不是换文件,
要先有建好的表和 app context。导入仍然要重启才生效——因为 TaskManager /
device_pool 把任务与设备缓存在内存里,整库替换后内存副本全部失效。
"""
try:
from core.system_backup import consume_pending_restore
with app.app_context():
consume_pending_restore()
except Exception as _e:
_log.error(f"消费备份恢复任务异常(不影响启动): {_e}")
# 先初始化数据库(含旧 JSON 迁移),再创建 TaskManager(需要 app context 读写 DB)
init_db(app)
# 通知模块:**必须早于 _consume_pending_restore()**——启动期就要能发「备份已恢复」
# 这类通知(notify 在未就绪时静默丢弃,晚初始化就丢事件了)
try:
from core import notifier as _notifier
_notifier.init_app(app)
except Exception as _e: # 通知出问题不影响启动
_log.warning(f"通知模块初始化失败(不影响启动): {_e}")
# 任务步骤明细:起一个专用写线程,任务线程只入队(见 core/step_log.py)
try:
from core import step_log as _step_log
_step_log.init_app(app)
except Exception as _e:
_log.warning(f"步骤明细模块初始化失败(不影响启动): {_e}")
# 消费「待生效的备份恢复」——必须在 init_db 之后(表已建好,且现在是事务替换而非换文件)
_consume_pending_restore()
# 库环境标签校验 + 启动横幅:每次启动都明确写出「现在连的是哪个库」,
# 避免在不知情的情况下对着生产库操作(app_meta 表要存在,所以放在 init_db 之后)
try:
with app.app_context(): # db.engine / app_meta 读取都需要 app context
db_config.verify_deployment_label()
db_config.print_banner()
# 页面顶部环境徽标(防混库第 4 层:点开任何页面都能看出连的是哪个库)
app.jinja_env.globals["db_env"] = db_config.env_badge()
except db_config.DBConfigError as _e:
_log.error("数据库环境校验失败,拒绝启动:\n%s", _e)
raise SystemExit(2)
# 设备池(本地清单 + adb 在线状态):任务调度的数据源
from core import device_pool
device_pool.init_app(app)
# 设备自动发现:定时扫描网段 → 待连接池(确认后才入池)
from core import device_discovery
device_discovery.init_app(app)
mgr = TaskManager(app=app)
# 设备换地址(认领/迁址)后同步分组与任务的引用:它们的"内存副本"在 TaskManager 里
# (调度用内存对象),device_pool 只改库不改内存不生效,所以在此注册回调打通
device_pool.set_move_hook(mgr.sync_device_serial)
apk_mgr = ApkManager(app=app)
# Web 层按模块拆分(web/ 蓝图包):认证/监控/任务/管理/工具/设备池/APK/Tailscale
from web import context, register_blueprints
from web.auth import _csrf_protect, _csrf_token
context.init(mgr, apk_mgr, device_pool)
register_blueprints(app)
def _purge_step_log():
"""清理超期的任务步骤明细(启动时 + 每日 04:13)。
app context 由本函数自建:APScheduler 的作业跑在调度线程里,没有请求上下文。
"""
try:
from core.step_log import purge_old
with app.app_context():
purge_old()
except Exception as e:
_log.warning(f"步骤明细清理失败(不影响主服务): {e}")
def _purge_step_log_async():
"""启动时后台清理一次(不阻塞启动;服务若频繁重启,定时任务可能一直轮不到)。"""
threading.Thread(target=_purge_step_log, daemon=True).start()
# 经验库每日 AI 巡检(凌晨 03:47):评审标记疑似问题经验,删除只走人工确认。
# 巡检线程在 run_experience_audit 内自建 app context,不依赖这里。
try:
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
from web.agent_api import run_experience_audit
_audit_sched = BackgroundScheduler(timezone="Asia/Shanghai")
_audit_sched.add_job(run_experience_audit, CronTrigger(hour=3, minute=47))
# 步骤明细清理:超过保留期(core/step_log.KEEP_DAYS)的记录每天删一次。
# 不清理的话这张表是唯一无界增长的表,会一路顶大备份包。
_audit_sched.add_job(_purge_step_log, CronTrigger(hour=4, minute=13))
_audit_sched.start()
_log.info("经验库每日巡检已注册(03:47 Asia/Shanghai)")
except Exception as e:
_log.warning(f"经验库巡检调度注册失败(不影响主服务): {e}")
@login_manager.user_loader
def load_user(user_id):
return User.query.get(int(user_id))
# ================== API:健康检查(无需登录,供探活) ==================
_UIAUTO_PID_FILE = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "data", "uiauto.pid")
_uiauto_proc = None
def _pid_cmdline_has(pid, keyword):
"""校验 PID 对应的进程命令行含 keyword(容器重启后 PID 复用会误杀,
杀前必须确认目标真是残留的 uiautodev,而不是 MCP/web 等其它进程)。"""
try:
with open(f"/proc/{pid}/cmdline", "rb") as f:
cmd = f.read().replace(b"\x00", b" ").decode(errors="replace")
return keyword in cmd
except Exception:
return False
def _kill_stale_uiauto():
"""清理上次 web_server 残留的 uiautodev 进程(读取 PID 文件)。
容器重启后 PID namespace 重建、PID 会被复用(MCP/其它进程可能拿到与
uiauto.pid 相同的值),因此 kill 前先校验命令行确为 uiautodev,防止
误杀同容器内的 MCP server 等进程导致连锁崩溃。
"""
if not os.path.exists(_UIAUTO_PID_FILE):
return
try:
with open(_UIAUTO_PID_FILE) as f:
pid = int(f.read().strip())
if pid > 0 and pid != os.getpid() and _pid_cmdline_has(pid, "uiautodev"):
try:
os.kill(pid, 0) # 探测进程是否存活
os.kill(pid, 15) # 终止残留
_log.warning(f"已清理残留的 uiautodev 进程 (PID={pid})")
except ProcessLookupError:
pass
except PermissionError:
_log.warning(f"无权限清理残留 uiautodev (PID={pid}),忽略")
except Exception:
pass
finally:
try:
os.remove(_UIAUTO_PID_FILE)
except OSError:
pass
def _ensure_uiauto_running():
"""确保 uiautodev 本地服务在运行(元素抓取功能依赖,端口 20242)。
已在运行则跳过;未运行则用子进程启动 `uiautodev server --no-browser`。
启动失败只记日志,不影响主服务。
"""
global _uiauto_proc
_kill_stale_uiauto()
if uiauto_helper.is_running():
_log.info("uiauto2 服务已在运行 (端口 20242)")
return
try:
_log.info("正在自动启动 uiautodev 服务 (端口 20242)...")
kwargs = dict(stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
if sys.platform == "win32":
kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
_uiauto_proc = subprocess.Popen(
[sys.executable, "-m", "uiautodev", "server", "--no-browser"],
**kwargs,
)
_log.info(f"uiautodev 服务已启动 (PID={_uiauto_proc.pid})")
try:
with open(_UIAUTO_PID_FILE, "w") as f:
f.write(str(_uiauto_proc.pid))
except OSError:
pass
atexit.register(_stop_uiauto)
except FileNotFoundError:
_log.warning("uiautodev 未安装,元素抓取功能不可用。请运行: pip install uiautodev")
except Exception as e:
_log.warning(f"自动启动 uiautodev 失败: {e}(元素抓取功能不可用)")
def _stop_uiauto():
"""停止自动启动的 uiautodev 子进程。"""
global _uiauto_proc
try:
if os.path.exists(_UIAUTO_PID_FILE):
os.remove(_UIAUTO_PID_FILE)
except OSError:
pass
if not _uiauto_proc:
return
try:
_uiauto_proc.terminate()
_uiauto_proc.wait(timeout=5)
_log.info("uiautodev 服务已停止")
except Exception:
try:
_uiauto_proc.kill()
except Exception:
pass
_uiauto_proc = None
def _preconnect_pool_devices():
"""容器/服务重启后 adb server 重启,设备全部掉线,逐个重连要几分钟;
启动时后台并发 connect 设备池里的网络设备(IP:5555),把恢复时间降到秒级。
USB 设备无需 connect(adb server 自动重枚举),只连网络设备。
预连接幂等:已连接的设备返回 already connected,无副作用。
"""
def _run():
try:
from core import device_pool
from core.adb_helper import _adb
serials = [s for s in device_pool.list_configured() if ":" in s]
if not serials:
return
_log.info(f"预连接设备池 {len(serials)} 台网络设备(重启后加速恢复)...")
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor(max_workers=8) as ex:
list(ex.map(lambda s: _adb("connect", s), serials))
out = _adb("devices")
online = sum(1 for ln in (out or "").splitlines()[1:]
if len(ln.split()) >= 2 and ln.split()[1] == "device")
_log.info(f"设备池预连接完成: {online} 台在线")
except Exception as e:
_log.warning(f"设备池预连接失败: {e}")
threading.Thread(target=_run, daemon=True).start()
def _run_server(host, port):
"""启动 Flask,自动处理端口冲突和权限问题。
常见失败原因:
WinError 10013 — 端口在 Windows 动态端口范围内被出站连接占用,
或非管理员绑定 0.0.0.0。前者换端口,后者降级 127.0.0.1。
WinError 10048 — 端口已被其他进程监听。换端口重试。
策略:原端口失败 → 试 127.0.0.1:原端口 → 试 127.0.0.1:原端口+1..+5
"""
import socket
# 先探测原端口是否可用(避免 flask 报错打日志难看)
candidates = [(host, port)]
if host == "0.0.0.0":
candidates.append(("127.0.0.1", port))
for i in range(1, 6):
candidates.append(("127.0.0.1", port + i))
for h, p in candidates:
try:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind((h, p))
# 探测成功,启动 flask
if (h, p) != (host, port):
_log.warning(f"原端口 {host}:{port} 不可用,改用 {h}:{p}")
_log.info(f"启动服务: http://localhost:{p}/")
app.run(host=h, port=p, debug=False, threaded=True)
return
except OSError as e:
_log.warning(f"绑定 {h}:{p} 失败: {e}")
# 全部失败
raise OSError(f"无法绑定任何候选端口({host}:{port} 及 127.0.0.1:{port}~{port+5})")
if __name__ == "__main__":
from config import WEB_HOST, WEB_PORT
# 自动启动 uiautodev 服务(元素抓取功能依赖,端口 20242)
_ensure_uiauto_running()
# 预连接设备池:adb server 重启后设备全掉线,后台并发重连加速恢复
_preconnect_pool_devices()
# 步骤明细清理:服务频繁重启时定时任务可能一直轮不到,启动也清一次
_purge_step_log_async()
_log.info(f"管理后台: http://localhost:{WEB_PORT}/ (admin/admin123)")
# 启动通知:放在 __main__ 里是刻意的——以 WSGI 方式 import 本模块只有"半个启动"
# (阶段 A~F 在 import 期就跑完了),不该发"服务已启动"。
try:
from core import notifier as _notifier
with app.app_context():
_devs = len(device_pool.list_configured())
_notifier.notify("service.started", env=db_config.env_badge().get("env", ""),
db_target=db_config.describe_target(),
device_count=_devs, job_count=len(mgr.jobs))
except Exception as _e:
_log.warning(f"发送启动通知失败(不影响启动): {_e}")
_t0 = time.time()
try:
_run_server(WEB_HOST, WEB_PORT)
finally:
try:
from core import notifier as _notifier
_notifier.notify("service.stopping", uptime_s=int(time.time() - _t0))
_notifier.shutdown(timeout=3.0)
except Exception:
pass
try:
from core import step_log as _step_log
_step_log.shutdown(timeout=3.0) # 排空队列后再退
except Exception:
pass
mgr.shutdown()
device_discovery.shutdown()
_stop_uiauto()