Commit 9c39674e223d153952bc5e01b12d834b152c94c3
1 parent
52823fc4
feat(ops): wechat-viewer /ops 后台监管页 + UI 总闸;修复开机自启/回复监督器 pid 复用
- /ops:服务/任务/UI 操作审计/crontab/日志,可启停服务、终止进程(admin 登录) - ui_guard:ui.paused 存在时拒绝 input/am start/monkey,覆盖 MCP、hook 发送、orchestrator、reply-sender、orig-save - wechat_reply_daemon.sh:校验锁文件 pid 的 cmdline,避免重启后 pid 复用导致监督器不启动 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Showing
10 changed files
with
317 additions
and
1 deletions
device/pixelphone/event_bus/device_tools.py
| ... | ... | @@ -47,6 +47,8 @@ import json |
| 47 | 47 | import os |
| 48 | 48 | import re |
| 49 | 49 | import subprocess |
| 50 | + | |
| 51 | +import ui_guard | |
| 50 | 52 | from pathlib import Path |
| 51 | 53 | |
| 52 | 54 | # ── shell routing (on-device su / off-device adb) ────────────────────────── |
| ... | ... | @@ -74,6 +76,8 @@ def _run(cmd: str, timeout: int = _TIMEOUT) -> subprocess.CompletedProcess: |
| 74 | 76 | `su -c`. Off-device we drive the phone through `adb shell`, whose uid |
| 75 | 77 | already holds those permissions — no `su` needed. |
| 76 | 78 | """ |
| 79 | + if ui_guard.check(cmd): # ui.paused:拒绝动屏幕的命令 | |
| 80 | + return subprocess.CompletedProcess(cmd, 75, "", "UI paused (ui.paused)") | |
| 77 | 81 | if _ON_DEVICE: |
| 78 | 82 | argv = ["su", "-c", cmd] |
| 79 | 83 | else: | ... | ... |
device/pixelphone/event_bus/ui_control_tools.py
| ... | ... | @@ -48,6 +48,8 @@ import json |
| 48 | 48 | import os |
| 49 | 49 | import re |
| 50 | 50 | import subprocess |
| 51 | + | |
| 52 | +import ui_guard | |
| 51 | 53 | import time |
| 52 | 54 | from pathlib import Path |
| 53 | 55 | from typing import Any, Optional |
| ... | ... | @@ -78,6 +80,8 @@ def _run(cmd: str, timeout: int = _TIMEOUT) -> subprocess.CompletedProcess: |
| 78 | 80 | uid already holds those permissions — no `su` needed (and it avoids the |
| 79 | 81 | nested-quoting trap of `adb shell su -c '...'`). |
| 80 | 82 | """ |
| 83 | + if ui_guard.check(cmd): # ui.paused:拒绝动屏幕的命令 | |
| 84 | + return subprocess.CompletedProcess(cmd, 75, "", "UI paused (ui.paused)") | |
| 81 | 85 | if _ON_DEVICE: |
| 82 | 86 | argv = ["su", "-c", cmd] |
| 83 | 87 | else: | ... | ... |
device/pixelphone/event_bus/ui_guard.py
0 → 100644
| 1 | +"""UI 操作总闸:ui.paused 存在 → 所有会动屏幕的命令(input / am start / monkey)被拒(rc=75)。 | |
| 2 | +所有 UI 驱动方(ui_control_tools/device_tools 的 _run、orchestrator 的前后台切换、reply-sender、orig-save.sh) | |
| 3 | +共用这一个文件。wechat-viewer 的 /ops 页面负责创建/删除它。动作审计写 ui-actions.log 供 /ops 展示。""" | |
| 4 | +import os | |
| 5 | +import re | |
| 6 | +import sys | |
| 7 | +import time | |
| 8 | + | |
| 9 | +PAUSE_FILE = "/data/data/com.termux/files/home/pixelphone/ui.paused" | |
| 10 | +AUDIT_FILE = "/data/data/com.termux/files/home/pixelphone/ui-actions.log" | |
| 11 | +_UI_RE = re.compile(r"\binput\s+\w+|\bam\s+start\b|\bmonkey\b") # 只管"动手"的;dump/screencap 只读不拦 | |
| 12 | +_MAX = 512 * 1024 | |
| 13 | + | |
| 14 | + | |
| 15 | +def paused() -> bool: | |
| 16 | + return os.path.exists(PAUSE_FILE) | |
| 17 | + | |
| 18 | + | |
| 19 | +def is_ui(cmd: str) -> bool: | |
| 20 | + return bool(_UI_RE.search(cmd)) | |
| 21 | + | |
| 22 | + | |
| 23 | +def audit(what: str, blocked: bool = False) -> None: | |
| 24 | + try: | |
| 25 | + if os.path.exists(AUDIT_FILE) and os.path.getsize(AUDIT_FILE) > _MAX: | |
| 26 | + os.replace(AUDIT_FILE, AUDIT_FILE + ".1") | |
| 27 | + new = not os.path.exists(AUDIT_FILE) | |
| 28 | + who = os.path.basename(sys.argv[0] or "?") | |
| 29 | + with open(AUDIT_FILE, "a") as f: | |
| 30 | + f.write(f"{time.strftime('%F %T')}\t{'BLOCKED' if blocked else 'ok'}\t{who}[{os.getpid()}]\t{what[:200]}\n") | |
| 31 | + if new: | |
| 32 | + os.chmod(AUDIT_FILE, 0o666) | |
| 33 | + except OSError: | |
| 34 | + pass | |
| 35 | + | |
| 36 | + | |
| 37 | +def check(cmd: str) -> bool: | |
| 38 | + """是 UI 命令则记审计;返回 True = 该拦截。""" | |
| 39 | + if not is_ui(cmd): | |
| 40 | + return False | |
| 41 | + b = paused() | |
| 42 | + audit(cmd, b) | |
| 43 | + return b | ... | ... |
device/pixelphone/scripts/orig-save.sh
| ... | ... | @@ -13,6 +13,8 @@ LOG=$HOME/tmp/orig_save_$(date +%Y%m%d_%H%M).log |
| 13 | 13 | mkdir -p "$HOME/tmp" |
| 14 | 14 | say() { echo "[$(date +%H:%M:%S)] $*" | tee -a "$LOG"; } |
| 15 | 15 | |
| 16 | +[ -e "$HOME/pixelphone/ui.paused" ] && { say "UI 已暂停,跳过原图保存"; exit 0; } | |
| 17 | + | |
| 16 | 18 | LOCK=$TMPDIR/orig-save.lock # send 阶段看到它在就等下一轮(两边都要用微信界面) |
| 17 | 19 | if ! mkdir "$LOCK" 2>/dev/null; then |
| 18 | 20 | [ -n "$(find "$LOCK" -maxdepth 0 -mmin +90 2>/dev/null)" ] && { rmdir "$LOCK"; mkdir "$LOCK"; } || { say "上一轮还在跑,跳过"; exit 0; } | ... | ... |
device/pixelphone/scripts/reply-sender.py
| ... | ... | @@ -139,6 +139,9 @@ def _rm(path): |
| 139 | 139 | |
| 140 | 140 | |
| 141 | 141 | def main(): |
| 142 | + if os.path.exists("/data/data/com.termux/files/home/pixelphone/ui.paused"): | |
| 143 | + print(f"[{time.strftime('%H:%M:%S')}] SKIP: UI paused (队列保留,恢复后再发)") | |
| 144 | + return | |
| 142 | 145 | # ── WeChat Login Gate ── |
| 143 | 146 | gate = check_wechat_ready() |
| 144 | 147 | if not gate["ready"]: | ... | ... |
device/pixelphone/wechat_reply/wechat_reply_daemon.sh
| ... | ... | @@ -18,7 +18,8 @@ mkdir -p "$W" |
| 18 | 18 | # ── 单实例:已有活着的监督器就退出 ── |
| 19 | 19 | if [ -f "$LOCK" ]; then |
| 20 | 20 | OLD=$(cat "$LOCK" 2>/dev/null) |
| 21 | - if [ -n "$OLD" ] && kill -0 "$OLD" 2>/dev/null; then | |
| 21 | + if [ -n "$OLD" ] && kill -0 "$OLD" 2>/dev/null \ | |
| 22 | + && grep -q wechat_reply_daemon "/proc/$OLD/cmdline" 2>/dev/null; then # 重启后 pid 会被复用,必须校验 cmdline | |
| 22 | 23 | echo "$(date) 监督器已在跑 pid=$OLD,退出" >> "$LOG" |
| 23 | 24 | exit 0 |
| 24 | 25 | fi | ... | ... |
device/pixelphone/wechat_reply/wechat_reply_orchestrator.py
| ... | ... | @@ -347,10 +347,21 @@ VERIFY_POLL = 3 # 前台停留期每次查 status 的间隔 |
| 347 | 347 | DWELL_POLLS = 4 # 一次 onResume 后停留前台查几次(给网络发送时间,不立刻再背景化打断) |
| 348 | 348 | |
| 349 | 349 | |
| 350 | +def _ui_paused(what): | |
| 351 | + """UI 总闸(见 event_bus/ui_guard.py);非暂停时记一笔审计。""" | |
| 352 | + sys.path.insert(0, EVENT_BUS) | |
| 353 | + import ui_guard | |
| 354 | + b = ui_guard.paused() | |
| 355 | + ui_guard.audit(what, b) | |
| 356 | + return b | |
| 357 | + | |
| 358 | + | |
| 350 | 359 | def _onresume_cycle(): |
| 351 | 360 | """一次真实的 onPause→onResume:HOME 背景化 → 停顿 → 拉回微信前台。 |
| 352 | 361 | 触发 WeChat NetSceneQueue flush 卡在 status=1 的发送。切换要给足时间——太快 |
| 353 | 362 | (背景/前台 <2s)微信来不及走完 onPause/onResume+网络 tick,flush 不了。""" |
| 363 | + if _ui_paused("HOME+am start LauncherUI (onResume cycle)"): | |
| 364 | + return | |
| 354 | 365 | try: |
| 355 | 366 | subprocess.run(["input", "keyevent", "KEYCODE_HOME"], capture_output=True, timeout=6) |
| 356 | 367 | time.sleep(2.5) # 让 onPause 走完、微信真的进后台 |
| ... | ... | @@ -465,6 +476,8 @@ def _send_text_verified(to_wxid, text, key): |
| 465 | 476 | 超时仍卡在发送中/失败 → 返回 ok=False,由调用方标 failed 进审核(不假装成功)。 |
| 466 | 477 | ponytail: 校验靠反复拷库(~96MB/次)读 status;发送稀疏可接受,首轮多半即达。 |
| 467 | 478 | 上限=拷库 I/O;升级=让 hook 直接回报送达 / 常驻前台。""" |
| 479 | + if _ui_paused("send_text_verified " + to_wxid): | |
| 480 | + return {"ok": False, "reason": "ui_paused"} | |
| 468 | 481 | _, db, _ = w.account_paths() |
| 469 | 482 | st = w.copy_db(db, "_stage_verify") |
| 470 | 483 | try: | ... | ... |
device/wechat-viewer/app.py
| ... | ... | @@ -6810,6 +6810,177 @@ def _sync_scores_to_db(): |
| 6810 | 6810 | db.close() |
| 6811 | 6811 | |
| 6812 | 6812 | |
| 6813 | +# ── /ops:后台任务监管 + UI 总闸 ─────────────────────────────────── | |
| 6814 | +# 路径刻意不放 /api/ 下:/api/ 对本机 127.0.0.1 免登录,而这里能杀进程,必须登录(且仅 admin,见 check_auth)。 | |
| 6815 | +# UI 总闸 = 文件 ui.paused,由 event_bus/ui_guard.py 等各 UI 驱动方共同遵守。 | |
| 6816 | +import re as _re | |
| 6817 | +_PX = "/data/data/com.termux/files/home/pixelphone" | |
| 6818 | +_UI_PAUSE = f"{_PX}/ui.paused" | |
| 6819 | +_UI_AUDIT = f"{_PX}/ui-actions.log" | |
| 6820 | +_TERMUX_ENV = ("HOME=/data/data/com.termux/files/home PREFIX=/data/data/com.termux/files/usr " | |
| 6821 | + "PATH=/data/data/com.termux/files/usr/bin:/system/bin TMPDIR=/data/data/com.termux/files/usr/tmp") | |
| 6822 | +# (key, 显示名, ARGS 正则, 类型, 是否会动 UI) | |
| 6823 | +_OPS_PROCS = [ | |
| 6824 | + ("reply-daemon", "微信回复·监督器", r"wechat_reply_daemon\.sh", "service", False), | |
| 6825 | + ("reply-watcher", "微信回复·watcher", r"wechat_reply_watcher\.py", "service", False), | |
| 6826 | + ("orchestrator", "微信回复·orchestrator(生成+发送)", r"wechat_reply_orchestrator\.py", "task", True), | |
| 6827 | + ("reply-sender", "回复队列发送器", r"reply-sender\.py", "task", True), | |
| 6828 | + ("orig-save", "原图保存(接管屏幕)", r"orig-save\.sh|save_originals_batch|upload_originals", "task", True), | |
| 6829 | + ("dsh-headless", "dsh headless 任务(手机MCP)", r"dsh --profile headless|run-headless\.sh|run-reply-member\.sh", "task", True), | |
| 6830 | + ("digest", "日报链/预摘要", r"daily-digest\.sh|gen_daily|send_digest|tgo_summarize_batch", "task", False), | |
| 6831 | + ("boss-resume", "BOSS 简历管线", r"boss-resume\.sh", "task", False), | |
| 6832 | + ("dsh-web", "dsh web", r"\.bin/dsh web", "service", False), | |
| 6833 | + ("glm-proxy", "GLM failover proxy", r"glm_failover_proxy", "service", False), | |
| 6834 | + ("crond", "crond", r"^crond", "service", False), | |
| 6835 | + ("viewer", "wechat-viewer(本页)", r"python3 app\.py", "service", False), | |
| 6836 | +] | |
| 6837 | +_OPS_SVC = { # 可启停的常驻服务:key -> (start 命令, stop 用的 pkill -f 模式;None=pkill -x key) | |
| 6838 | + "reply-daemon": (f"{_TERMUX_ENV} nohup sh {_PX}/wechat_reply/wechat_reply_daemon.sh >/dev/null 2>&1 &", | |
| 6839 | + ["wechat_reply_daemon.sh", "wechat_reply_watcher.py"]), | |
| 6840 | + "dsh-web": (f"su -G 3003 10329 -c '{_TERMUX_ENV} nohup /data/data/com.termux/files/home/dsh/run-web.sh " | |
| 6841 | + f"> /data/data/com.termux/files/home/dsh/web.log 2>&1 < /dev/null &'", | |
| 6842 | + [r"\.bin/dsh web"]), | |
| 6843 | + "crond": (f"su -G 3003 10329 -c '{_TERMUX_ENV} /data/data/com.termux/files/usr/bin/crond'", None), | |
| 6844 | +} | |
| 6845 | + | |
| 6846 | + | |
| 6847 | +def _ops_su(cmd, timeout=15): | |
| 6848 | + r = subprocess.run(["su", "-c", cmd], capture_output=True, text=True, timeout=timeout) | |
| 6849 | + return (r.stdout or "").strip() | |
| 6850 | + | |
| 6851 | + | |
| 6852 | +def _ops_ps(): | |
| 6853 | + out = _ops_su("ps -A -o PID,ETIME,ARGS") | |
| 6854 | + rows = [] | |
| 6855 | + for line in out.splitlines()[1:]: | |
| 6856 | + parts = line.split(None, 2) | |
| 6857 | + if len(parts) < 3 or not parts[0].isdigit(): | |
| 6858 | + continue | |
| 6859 | + pid, etime, args = int(parts[0]), parts[1], parts[2] | |
| 6860 | + if args.startswith(("su ", "ps ", "sh -c", "grep ")) or "ps -A -o" in args: | |
| 6861 | + continue | |
| 6862 | + for key, name, rx, kind, ui in _OPS_PROCS: | |
| 6863 | + if _re.search(rx, args): | |
| 6864 | + rows.append({"pid": pid, "etime": etime, "key": key, "name": name, "kind": kind, | |
| 6865 | + "ui": ui, "args": args[:300]}) | |
| 6866 | + break | |
| 6867 | + return rows | |
| 6868 | + | |
| 6869 | + | |
| 6870 | +def _ops_ui_actions(n=60): | |
| 6871 | + try: | |
| 6872 | + with open(_UI_AUDIT, errors="replace") as f: | |
| 6873 | + lines = f.readlines()[-n:] | |
| 6874 | + except OSError: | |
| 6875 | + return [] | |
| 6876 | + out = [] | |
| 6877 | + for l in reversed(lines): | |
| 6878 | + p = l.rstrip("\n").split("\t", 3) | |
| 6879 | + if len(p) == 4: | |
| 6880 | + out.append({"ts": p[0], "result": p[1], "who": p[2], "what": p[3]}) | |
| 6881 | + return out | |
| 6882 | + | |
| 6883 | + | |
| 6884 | +def _ops_guard(): | |
| 6885 | + if request.headers.get("X-Requested-With") != "ops": | |
| 6886 | + return jsonify(error="forbidden"), 403 | |
| 6887 | + | |
| 6888 | + | |
| 6889 | +@app.route("/ops") | |
| 6890 | +def ops_page(): | |
| 6891 | + return render_template("ops.html") | |
| 6892 | + | |
| 6893 | + | |
| 6894 | +@app.route("/ops/state") | |
| 6895 | +def ops_state(): | |
| 6896 | + procs = _ops_ps() | |
| 6897 | + screen = _ops_su("dumpsys power | grep -m1 mWakefulness=; dumpsys window | grep -m1 mCurrentFocus") | |
| 6898 | + try: | |
| 6899 | + cron = subprocess.run(["crontab", "-l"], capture_output=True, text=True, timeout=5).stdout | |
| 6900 | + except Exception: # noqa: BLE001 | |
| 6901 | + cron = "" | |
| 6902 | + paused_at = "" | |
| 6903 | + if os.path.exists(_UI_PAUSE): | |
| 6904 | + paused_at = time.strftime("%F %T", time.localtime(os.path.getmtime(_UI_PAUSE))) | |
| 6905 | + present = {p["key"] for p in procs} | |
| 6906 | + services = [{"key": k, "name": n, "up": k in present, "controllable": k in _OPS_SVC, | |
| 6907 | + "pids": [p["pid"] for p in procs if p["key"] == k]} | |
| 6908 | + for k, n, _, kind, _ in _OPS_PROCS if kind == "service"] | |
| 6909 | + return jsonify(paused=bool(paused_at), paused_at=paused_at, screen=screen, services=services, | |
| 6910 | + tasks=[p for p in procs if p["kind"] == "task"], ui_actions=_ops_ui_actions(), | |
| 6911 | + cron=[l for l in cron.splitlines() if l.strip() and not l.startswith(("PATH", "HOME", "TMPDIR", "D=", "L="))]) | |
| 6912 | + | |
| 6913 | + | |
| 6914 | +@app.route("/ops/pause", methods=["POST"]) | |
| 6915 | +def ops_pause(): | |
| 6916 | + bad = _ops_guard() | |
| 6917 | + if bad: | |
| 6918 | + return bad | |
| 6919 | + on = bool((request.get_json(silent=True) or {}).get("on")) | |
| 6920 | + killed = [] | |
| 6921 | + if on: | |
| 6922 | + with open(_UI_PAUSE, "w") as f: | |
| 6923 | + f.write(time.strftime("%F %T") + "\n") | |
| 6924 | + os.chmod(_UI_PAUSE, 0o666) | |
| 6925 | + # 长时间接管屏幕的脚本直接掐掉(其余 UI 驱动方每条命令都会被总闸拒绝) | |
| 6926 | + for p in _ops_ps(): | |
| 6927 | + if p["key"] == "orig-save": | |
| 6928 | + _ops_su(f"kill {p['pid']}") | |
| 6929 | + killed.append(p["pid"]) | |
| 6930 | + else: | |
| 6931 | + try: | |
| 6932 | + os.remove(_UI_PAUSE) | |
| 6933 | + except FileNotFoundError: | |
| 6934 | + pass | |
| 6935 | + return jsonify(ok=True, paused=on, killed=killed) | |
| 6936 | + | |
| 6937 | + | |
| 6938 | +@app.route("/ops/kill", methods=["POST"]) | |
| 6939 | +def ops_kill(): | |
| 6940 | + bad = _ops_guard() | |
| 6941 | + if bad: | |
| 6942 | + return bad | |
| 6943 | + d = request.get_json(silent=True) or {} | |
| 6944 | + pid = int(d.get("pid", 0)) | |
| 6945 | + tgt = next((p for p in _ops_ps() if p["pid"] == pid and p["key"] != "viewer"), None) # 只能杀受管列表里的,且不能杀自己 | |
| 6946 | + if not tgt: | |
| 6947 | + return jsonify(ok=False, error="pid 不在受管列表"), 404 | |
| 6948 | + _ops_su(f"kill {'-9 ' if d.get('force') else ''}{pid}") | |
| 6949 | + return jsonify(ok=True, killed=pid) | |
| 6950 | + | |
| 6951 | + | |
| 6952 | +@app.route("/ops/service", methods=["POST"]) | |
| 6953 | +def ops_service(): | |
| 6954 | + bad = _ops_guard() | |
| 6955 | + if bad: | |
| 6956 | + return bad | |
| 6957 | + d = request.get_json(silent=True) or {} | |
| 6958 | + key, act = d.get("key"), d.get("action") | |
| 6959 | + if key not in _OPS_SVC or act not in ("start", "stop", "restart"): | |
| 6960 | + return jsonify(ok=False, error="不支持"), 400 | |
| 6961 | + start_cmd, pats = _OPS_SVC[key] | |
| 6962 | + if act in ("stop", "restart"): | |
| 6963 | + for pat in (pats or []): | |
| 6964 | + _ops_su(f"pkill -f '{pat}'") | |
| 6965 | + if not pats: | |
| 6966 | + _ops_su(f"pkill -x {key}") | |
| 6967 | + time.sleep(1) | |
| 6968 | + if act in ("start", "restart"): | |
| 6969 | + _ops_su(start_cmd) | |
| 6970 | + return jsonify(ok=True) | |
| 6971 | + | |
| 6972 | + | |
| 6973 | +@app.route("/ops/log") | |
| 6974 | +def ops_log(): | |
| 6975 | + paths = {"reply-daemon": f"{_PX}/wechat_reply/daemon.log", "dsh-web": "/data/data/com.termux/files/home/dsh/web.log", | |
| 6976 | + "reply-sender": f"{_PX}/cron-logs/reply-sender.log", "orig-save": f"{_PX}/cron-logs/orig-save.log", | |
| 6977 | + "digest": f"{_PX}/cron-logs/digest-send.log", "ui": _UI_AUDIT} | |
| 6978 | + p = paths.get(request.args.get("name", "")) | |
| 6979 | + if not p: | |
| 6980 | + return jsonify(text="无此日志"), 404 | |
| 6981 | + return jsonify(text=_ops_su(f"tail -n 80 '{p}' 2>&1")[-8000:]) | |
| 6982 | + | |
| 6983 | + | |
| 6813 | 6984 | if __name__ == "__main__": |
| 6814 | 6985 | app.config['TEMPLATES_AUTO_RELOAD'] = True |
| 6815 | 6986 | app.run(host="0.0.0.0", port=5001, debug=False) | ... | ... |
device/wechat-viewer/templates/base.html
| ... | ... | @@ -198,6 +198,9 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; b |
| 198 | 198 | <a href="/cron" class="{% if request.path.startswith('/cron') %}active{% endif %}"> |
| 199 | 199 | <span class="icon">⏱</span> Cron 管理 |
| 200 | 200 | </a> |
| 201 | + <a href="/ops" class="{% if request.path.startswith('/ops') %}active{% endif %}"> | |
| 202 | + <span class="icon">🛠</span> 后台监管 | |
| 203 | + </a> | |
| 201 | 204 | {% endif %} |
| 202 | 205 | <a href="/boss-jobs" class="{% if request.path.startswith('/boss-job') or request.path == '/boss-jobs' %}active{% endif %}"> |
| 203 | 206 | <span class="icon">🏢</span> BOSS 岗位 | ... | ... |
device/wechat-viewer/templates/ops.html
0 → 100644
| 1 | +{% extends "base.html" %} | |
| 2 | +{% block title %}后台监管 - WeChat Viewer{% endblock %} | |
| 3 | +{% block body %} | |
| 4 | +<style> | |
| 5 | +body{overflow-y:auto!important;height:auto!important;} | |
| 6 | +.ops{max-width:900px;margin:0 auto;padding:16px;} | |
| 7 | +.ops h1{font-size:20px;color:#2c3e50;margin-bottom:10px;} | |
| 8 | +.ops h2{font-size:15px;color:#2c3e50;margin:20px 0 8px;} | |
| 9 | +.banner{border-radius:10px;padding:14px 16px;display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:6px;} | |
| 10 | +.banner.run{background:#e8f6ee;border:1px solid #27ae60;} | |
| 11 | +.banner.pause{background:#fdecea;border:1px solid #e74c3c;} | |
| 12 | +.banner b{font-size:16px;}.banner small{color:#666;display:block;margin-top:2px;} | |
| 13 | +.banner .grow{flex:1;min-width:180px;} | |
| 14 | +.btn{border:none;border-radius:8px;padding:9px 18px;font-size:14px;color:#fff;cursor:pointer;} | |
| 15 | +.btn.red{background:#e74c3c}.btn.green{background:#27ae60}.btn.gray{background:#7f8c8d}.btn.sm{padding:4px 10px;font-size:12px;border-radius:6px}.btn.blue{background:#3498db} | |
| 16 | +.card{background:#fff;border-radius:8px;padding:10px 14px;margin-bottom:6px;box-shadow:0 1px 2px rgba(0,0,0,.06);display:flex;align-items:center;gap:10px;flex-wrap:wrap;} | |
| 17 | +.card .nm{font-weight:600;font-size:14px;}.card .meta{font-size:11px;color:#888;word-break:break-all;flex:1;min-width:160px} | |
| 18 | +.dot{width:10px;height:10px;border-radius:50%;flex-shrink:0}.dot.up{background:#27ae60}.dot.down{background:#bbb} | |
| 19 | +.tag{font-size:10px;padding:2px 7px;border-radius:9px;background:#fdebd0;color:#b9770e;font-weight:600} | |
| 20 | +table{width:100%;border-collapse:collapse;font-size:12px;background:#fff;border-radius:8px;overflow:hidden} | |
| 21 | +td,th{padding:6px 8px;border-bottom:1px solid #f0f0f0;text-align:left;vertical-align:top;word-break:break-all} | |
| 22 | +td.BLOCKED{color:#e74c3c;font-weight:600} | |
| 23 | +pre{background:#1e1e1e;color:#ddd;padding:10px;border-radius:8px;font-size:11px;overflow:auto;max-height:260px;white-space:pre-wrap} | |
| 24 | +.empty{color:#999;font-size:13px;padding:8px} | |
| 25 | +</style> | |
| 26 | +<div class="ops"> | |
| 27 | + <h1>🛠 后台监管</h1> | |
| 28 | + <div id="banner" class="banner run"></div> | |
| 29 | + <div id="screen" style="font-size:12px;color:#888;margin-bottom:4px"></div> | |
| 30 | + | |
| 31 | + <h2>🖐 UI 操作记录 <small style="font-weight:normal;color:#888">谁在动屏幕(最近 60 条,红色 = 被总闸拦下)</small></h2> | |
| 32 | + <div id="ui"></div> | |
| 33 | + | |
| 34 | + <h2>▶ 运行中的任务</h2><div id="tasks"></div> | |
| 35 | + <h2>⚙ 常驻服务</h2><div id="svcs"></div> | |
| 36 | + <h2>⏱ Crontab</h2><pre id="cron"></pre> | |
| 37 | + <h2>📜 日志</h2> | |
| 38 | + <div style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:6px" id="logbtns"></div> | |
| 39 | + <pre id="log">点上面的按钮看日志</pre> | |
| 40 | +</div> | |
| 41 | +<script> | |
| 42 | +const H={'Content-Type':'application/json','X-Requested-With':'ops'}; | |
| 43 | +const post=(u,b)=>fetch(u,{method:'POST',headers:H,body:JSON.stringify(b||{})}).then(r=>r.json()); | |
| 44 | +const esc=s=>String(s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c])); | |
| 45 | +let busy=false; | |
| 46 | +async function pause(on){ if(on||confirm('恢复 UI 操作?排队的回复会开始发送。')){await post('/ops/pause',{on});load();} } | |
| 47 | +async function kill(pid,force){ if(confirm((force?'强杀 ':'终止 ')+pid+' ?')){await post('/ops/kill',{pid,force});load();} } | |
| 48 | +async function svc(key,action){ if(confirm(action+' '+key+' ?')){await post('/ops/service',{key,action});setTimeout(load,1500);} } | |
| 49 | +async function log(name){ const r=await fetch('/ops/log?name='+name).then(r=>r.json()); document.getElementById('log').textContent=r.text||'(空)'; } | |
| 50 | +async function load(){ | |
| 51 | + if(busy)return; busy=true; | |
| 52 | + try{ | |
| 53 | + const d=await fetch('/ops/state').then(r=>r.json()); | |
| 54 | + const b=document.getElementById('banner'); | |
| 55 | + b.className='banner '+(d.paused?'pause':'run'); | |
| 56 | + b.innerHTML=d.paused | |
| 57 | + ?`<div class="grow"><b>⏸ UI 操作已暂停</b><small>自 ${esc(d.paused_at)};所有 input / am start 被拒绝,回复照常生成但留在队列,不会发出</small></div><button class="btn green" onclick="pause(false)">恢复 UI 操作</button>` | |
| 58 | + :`<div class="grow"><b>▶ UI 操作正常</b><small>后台任务可以操作手机屏幕</small></div><button class="btn red" onclick="pause(true)">⏸ 暂停所有 UI 操作</button>`; | |
| 59 | + document.getElementById('screen').textContent=d.screen.replace(/\s+/g,' '); | |
| 60 | + document.getElementById('ui').innerHTML=d.ui_actions.length | |
| 61 | + ?'<table><tr><th>时间</th><th>结果</th><th>来源</th><th>动作</th></tr>'+d.ui_actions.map(a=>`<tr><td>${esc(a.ts)}</td><td class="${a.result}">${a.result}</td><td>${esc(a.who)}</td><td>${esc(a.what)}</td></tr>`).join('')+'</table>' | |
| 62 | + :'<div class="empty">暂无记录(新加的审计,下一次有 UI 动作开始记)</div>'; | |
| 63 | + document.getElementById('tasks').innerHTML=d.tasks.length?d.tasks.map(t=>`<div class="card"><span class="dot up"></span><span class="nm">${esc(t.name)}</span>${t.ui?'<span class="tag">会动UI</span>':''}<span class="meta">pid ${t.pid} · ${esc(t.etime)} · ${esc(t.args)}</span><button class="btn sm gray" onclick="kill(${t.pid},false)">终止</button><button class="btn sm red" onclick="kill(${t.pid},true)">强杀</button></div>`).join(''):'<div class="empty">当前没有运行中的任务</div>'; | |
| 64 | + document.getElementById('svcs').innerHTML=d.services.map(s=>`<div class="card"><span class="dot ${s.up?'up':'down'}"></span><span class="nm">${esc(s.name)}</span><span class="meta">${s.up?'pid '+s.pids.join(','):'未运行'}</span>${s.controllable?`<button class="btn sm green" onclick="svc('${s.key}','start')">启动</button><button class="btn sm blue" onclick="svc('${s.key}','restart')">重启</button><button class="btn sm gray" onclick="svc('${s.key}','stop')">停止</button>`:''}</div>`).join(''); | |
| 65 | + document.getElementById('cron').textContent=d.cron.join('\n'); | |
| 66 | + }catch(e){ document.getElementById('screen').textContent='加载失败: '+e; } | |
| 67 | + busy=false; | |
| 68 | +} | |
| 69 | +document.getElementById('logbtns').innerHTML=[['ui','UI 操作审计'],['reply-daemon','回复 daemon'],['reply-sender','reply-sender'],['orig-save','原图保存'],['digest','日报发送'],['dsh-web','dsh web']].map(([k,n])=>`<button class="btn sm gray" onclick="log('${k}')">${n}</button>`).join(''); | |
| 70 | +load(); setInterval(load,4000); | |
| 71 | +</script> | |
| 72 | +{% endblock %} | ... | ... |