2069 lines
86 KiB
Python
2069 lines
86 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
dashboard.py - AgentsMeeting management dashboard backend
|
||
=========================================================
|
||
Flask app on :5803. Monitors agents across platforms via:
|
||
- SSH + ejabberdctl connected_users (cross-platform, authoritative)
|
||
- xmpp_bot HTTP API :5807 (/health, /muc - fallback)
|
||
- Local process/port checks (Windows only)
|
||
|
||
Auto-recovery: restarts local Windows agents after 3 consecutive offline checks.
|
||
"""
|
||
import os, sys, re, json, time, socket, subprocess, logging, urllib.request, sqlite3, shutil, threading
|
||
from pathlib import Path
|
||
from datetime import datetime, timedelta
|
||
from flask import Flask, jsonify, request, send_from_directory
|
||
|
||
# ---- Paths (platform-agnostic: auto-detect from script location) ----
|
||
_SCRIPT_DIR = Path(__file__).resolve().parent # gateway/scripts/
|
||
_GATEWAY_DIR = _SCRIPT_DIR.parent # gateway/
|
||
_PROJECT_DIR = _GATEWAY_DIR.parent # AgentsMeeting/
|
||
TEMPLATES_DIR = _SCRIPT_DIR / "templates"
|
||
CONFIG_DIR = _PROJECT_DIR / "config"
|
||
LOGS_DIR = _GATEWAY_DIR / "logs"
|
||
TEMP_DIR = _GATEWAY_DIR / "temp"
|
||
XMPP_BRIDGE_URL = os.environ.get("XMPP_BRIDGE_URL", "http://192.168.1.16:5807")
|
||
EJABBERD_HOST = os.environ.get("EJABBERD_HOST", "192.168.1.246")
|
||
|
||
# On Linux (mohe), dashboard runs from systemd with different paths. Support explicit override.
|
||
_PROJECT_OVERRIDE = os.environ.get("AGENTSMEETING_ROOT")
|
||
if _PROJECT_OVERRIDE:
|
||
_PROJECT_DIR = Path(_PROJECT_OVERRIDE)
|
||
_GATEWAY_DIR = _PROJECT_DIR / "gateway"
|
||
TEMPLATES_DIR = _GATEWAY_DIR / "scripts" / "templates"
|
||
CONFIG_DIR = _PROJECT_DIR / "config"
|
||
LOGS_DIR = _GATEWAY_DIR / "logs"
|
||
TEMP_DIR = _GATEWAY_DIR / "temp"
|
||
|
||
# Auto-recovery script name map (used by /api/agents)
|
||
SCRIPT_NAMES = {
|
||
"xmpp_bot": "xmpp_bot.py",
|
||
"wechat_bridge": "wechat_agent.py",
|
||
"api_proxy": "api_proxy.py",
|
||
"health_check": "health_check_xxm.py",
|
||
"mohe_watcher": "mohe_watcher.py",
|
||
"watchdog": "xmpp_watchdog.py",
|
||
}
|
||
|
||
sys.path.insert(0, str(_GATEWAY_DIR / "scripts"))
|
||
from proc_guard import guard
|
||
|
||
app = Flask(__name__, template_folder=str(TEMPLATES_DIR))
|
||
|
||
# ---- Logging ----
|
||
LOG_FILE = LOGS_DIR / "dashboard.log"
|
||
LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||
logging.basicConfig(
|
||
filename=str(LOG_FILE),
|
||
level=logging.INFO,
|
||
format="%(asctime)s [dashboard] %(message)s",
|
||
)
|
||
log = logging.getLogger("dashboard")
|
||
|
||
# ---- Constants ----
|
||
AGENTS_YAML = CONFIG_DIR / "agents.yaml"
|
||
PYTHON = os.environ.get("PYTHON", sys.executable)
|
||
SCRIPTS_DIR = _GATEWAY_DIR / "scripts"
|
||
|
||
# Linux agents on local 246 — use systemctl
|
||
LOCAL_LINUX_HOSTS = ("192.168.1.246", "127.0.0.1", "localhost")
|
||
|
||
|
||
def _is_local_linux(agent):
|
||
"""Check if agent is on the same Linux machine (manageable via systemctl)."""
|
||
return agent.get("platform") == "linux" and agent.get("host", "") in LOCAL_LINUX_HOSTS
|
||
|
||
|
||
def _agent_bot_service(agent):
|
||
"""Derive systemd service name from agent JID localpart (e.g. mohe → xmpp-mohe)."""
|
||
jid = agent.get("jid", "")
|
||
localpart = jid.split("@")[0] if "@" in jid else ""
|
||
return f"xmpp-{localpart}" if localpart else ""
|
||
|
||
|
||
# Auto-recovery: restart after this many consecutive offline checks
|
||
AUTO_RECOVER_THRESHOLD = 3
|
||
_offline_counter: dict[str, int] = {}
|
||
|
||
|
||
# ============================================================
|
||
# Config
|
||
# ============================================================
|
||
|
||
def load_agents_config():
|
||
if AGENTS_YAML.exists():
|
||
import yaml
|
||
with open(AGENTS_YAML, "r", encoding="utf-8") as f:
|
||
return yaml.safe_load(f).get("agents", [])
|
||
return _default_agents()
|
||
|
||
|
||
def _default_agents():
|
||
return [
|
||
{
|
||
"id": "agent-001", "name": "R&D Assistant", "display_name": "xxm",
|
||
"jid": "xxm@yoin.fun", "platform": "windows", "host": "192.168.1.16",
|
||
"bot_type": "xmpp", "provider": "volcengine",
|
||
"services": [{"type": "xmpp_bot", "port": 5807}],
|
||
},
|
||
{
|
||
"id": "agent-002", "name": "Automation Manager", "display_name": "mohe",
|
||
"jid": "mohe@yoin.fun", "platform": "linux", "host": "192.168.1.246",
|
||
"bot_type": "hermes", "provider": "ocg-new",
|
||
"services": [{"type": "hermes_gateway", "port": 8646}, {"type": "xmpp_bot"}],
|
||
},
|
||
{
|
||
"id": "agent-004", "name": "Position Analyst", "display_name": "zhiwei",
|
||
"jid": "zhiwei@yoin.fun", "platform": "linux", "host": "192.168.1.246",
|
||
"bot_type": "hermes", "provider": "ocg-old",
|
||
"services": [{"type": "hermes_gateway", "port": 8643}, {"type": "xmpp_bot"}],
|
||
},
|
||
]
|
||
|
||
|
||
# ============================================================
|
||
# Cross-platform monitoring (XMPP + SSH)
|
||
# ============================================================
|
||
|
||
def _xmpp_health():
|
||
"""Query xmpp_bot /health for XMPP connection and ejabberd status."""
|
||
try:
|
||
req = urllib.request.Request(f"{XMPP_BRIDGE_URL}/health")
|
||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||
return json.loads(resp.read())
|
||
except Exception as e:
|
||
return {"ok": False, "error": str(e), "xmpp_connected": False, "ejabberd_alive": False}
|
||
|
||
|
||
def _ejabberd_online_jids():
|
||
"""Query ejabberdctl connected_users.
|
||
On Linux (local): direct docker exec.
|
||
On Windows (remote): SSH to EJABBERD_HOST.
|
||
Returns set of bare JIDs currently connected to ejabberd."""
|
||
try:
|
||
if sys.platform == "win32":
|
||
# Windows: SSH to Linux server
|
||
ssh_cmd = ["ssh", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=5",
|
||
"-o", "BatchMode=yes", f"hmo@{EJABBERD_HOST}",
|
||
"docker exec ejabberd ejabberdctl connected_users"]
|
||
result = subprocess.run(ssh_cmd, capture_output=True, text=True, timeout=15)
|
||
else:
|
||
# Linux: direct docker exec
|
||
result = subprocess.run(
|
||
["docker", "exec", "ejabberd", "ejabberdctl", "connected_users"],
|
||
capture_output=True, text=True, timeout=10)
|
||
if result.returncode != 0:
|
||
log.debug(f"ejabberdctl failed (exit={result.returncode}): {result.stderr[:100]}")
|
||
return set()
|
||
jids = set()
|
||
for line in result.stdout.strip().split("\n"):
|
||
line = line.strip()
|
||
if line and "@" in line:
|
||
jids.add(line.split("/")[0])
|
||
return jids
|
||
except Exception as e:
|
||
log.debug(f"ejabberd query failed: {e}")
|
||
return set()
|
||
|
||
|
||
def _muc_participants():
|
||
"""Fallback: query xmpp_bot /muc for room participants.
|
||
Currently unreliable due to MUC join timeout (R01)."""
|
||
try:
|
||
req = urllib.request.Request(f"{XMPP_BRIDGE_URL}/muc")
|
||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||
data = json.loads(resp.read())
|
||
except Exception:
|
||
return set()
|
||
participants = set()
|
||
for room_data in data.get("rooms", {}).values():
|
||
for p in room_data.get("participants", []):
|
||
jid = p.get("jid", "")
|
||
if jid:
|
||
participants.add(jid)
|
||
nick = p.get("nick", "")
|
||
if nick and "@" in nick:
|
||
participants.add(nick)
|
||
return participants
|
||
|
||
|
||
# ============================================================
|
||
# Local process detection (Windows only)
|
||
# ============================================================
|
||
|
||
def _get_local_processes():
|
||
processes = []
|
||
try:
|
||
result = subprocess.run(["ps", "aux"], capture_output=True, text=True, timeout=5)
|
||
for line in result.stdout.split("\n"):
|
||
if "xmpp_bot" in line or "wechat_agent" in line or "dashboard" in line:
|
||
parts = line.split()
|
||
if len(parts) >= 11:
|
||
processes.append({"pid": int(parts[1]), "cmdline": " ".join(parts[10:])})
|
||
except Exception as e:
|
||
log.error(f"Process scan failed: {e}")
|
||
return processes
|
||
|
||
|
||
# ============================================================
|
||
# Log helpers
|
||
# ============================================================
|
||
|
||
def _tail_logs(max_lines=50):
|
||
all_lines = []
|
||
log_files = sorted(LOGS_DIR.glob("*.log"), key=lambda p: p.stat().st_mtime, reverse=True)
|
||
for lf in log_files:
|
||
try:
|
||
with open(lf, "r", encoding="utf-8", errors="replace") as f:
|
||
f.seek(0, os.SEEK_END)
|
||
size = f.tell()
|
||
read_size = min(size, max_lines * 500)
|
||
if read_size > 0:
|
||
f.seek(max(0, size - read_size))
|
||
for line in f.read().strip().split("\n"):
|
||
if line.strip():
|
||
all_lines.append(f"[{lf.name}] {line}")
|
||
except Exception:
|
||
pass
|
||
return all_lines[-max_lines:] if len(all_lines) > max_lines else all_lines
|
||
|
||
|
||
def _count_recent_messages(minutes=5):
|
||
log_path = LOGS_DIR / "xmpp_bot.log"
|
||
if not log_path.exists():
|
||
return 0
|
||
try:
|
||
with open(log_path, "r", encoding="utf-8", errors="replace") as f:
|
||
lines = f.readlines()
|
||
cutoff = datetime.now() - timedelta(minutes=minutes)
|
||
count = 0
|
||
pattern = re.compile(r"(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})")
|
||
for line in reversed(lines):
|
||
m = pattern.search(line)
|
||
if m:
|
||
try:
|
||
if datetime.strptime(m.group(1), "%Y-%m-%d %H:%M:%S") < cutoff:
|
||
break
|
||
count += 1
|
||
except ValueError:
|
||
pass
|
||
return count
|
||
except Exception:
|
||
return 0
|
||
|
||
|
||
# ============================================================
|
||
# Routes
|
||
# ============================================================
|
||
|
||
@app.route("/")
|
||
def index():
|
||
return send_from_directory(str(TEMPLATES_DIR), "dashboard.html")
|
||
|
||
|
||
@app.route("/api/agents")
|
||
def api_agents():
|
||
agents_config = load_agents_config()
|
||
local_procs = _get_local_processes()
|
||
message_count = _count_recent_messages(5)
|
||
|
||
# Primary: SSH ejabberdctl for cross-platform presence
|
||
online_jids = _ejabberd_online_jids()
|
||
if not online_jids:
|
||
online_jids = _muc_participants() # fallback
|
||
|
||
result = []
|
||
for agent in agents_config:
|
||
agent_id = agent["id"]
|
||
jid = agent.get("jid", "")
|
||
platform = agent.get("platform", "")
|
||
host = agent.get("host", "")
|
||
|
||
# --- Presence ---
|
||
ejabberd_query_ok = online_jids is not None and len(online_jids) > 0
|
||
xmpp_in_ejabberd = jid in online_jids if online_jids else None
|
||
|
||
# --- Local process (Windows only) ---
|
||
local_pid = None
|
||
xmpp_connected = False
|
||
if platform == "windows" and host in ("192.168.1.16", "127.0.0.1", "localhost"):
|
||
for proc in local_procs:
|
||
if "xmpp_bot.py" in proc.get("cmdline", ""):
|
||
local_pid = proc["pid"]
|
||
break
|
||
health = _xmpp_health()
|
||
xmpp_connected = health.get("xmpp_connected", False)
|
||
|
||
# --- Status reason ---
|
||
status_reason = ""
|
||
if xmpp_in_ejabberd is None and not online_jids:
|
||
status_reason = "ejabberd SSH 查询失败,MUC 降级检测也无结果"
|
||
elif xmpp_in_ejabberd is False:
|
||
status_reason = "XMPP JID 未登录 ejabberd"
|
||
elif xmpp_in_ejabberd is None:
|
||
status_reason = "无法检测 XMPP 状态"
|
||
elif xmpp_in_ejabberd is True:
|
||
status_reason = "OK"
|
||
if platform == "windows" and host in ("192.168.1.16", "127.0.0.1", "localhost"):
|
||
if status_reason == "OK":
|
||
status_reason = "OK"
|
||
elif xmpp_connected:
|
||
status_reason = "Bot 运行中,但 XMPP 未登录 ejabberd"
|
||
elif local_pid:
|
||
status_reason = "Bot 进程存在但 XMPP 连接断开"
|
||
else:
|
||
status_reason = "Bot 进程不存在"
|
||
|
||
# --- Service status ---
|
||
services = []
|
||
for svc in agent.get("services", []):
|
||
svc_type = svc.get("type", "")
|
||
svc_port = svc.get("port")
|
||
|
||
if svc_type == "xmpp_bot":
|
||
if platform == "windows" and local_pid:
|
||
svc_status = "running" if xmpp_connected else "degraded"
|
||
svc_pid = local_pid
|
||
elif xmpp_in_ejabberd is True:
|
||
svc_status = "running"
|
||
svc_pid = None
|
||
elif xmpp_in_ejabberd is False:
|
||
svc_status = "stopped"
|
||
svc_pid = None
|
||
else:
|
||
svc_status = "unknown"
|
||
svc_pid = None
|
||
elif svc_type in ("hermes_gateway", "omlx_server"):
|
||
svc_status = "unknown"
|
||
svc_pid = None
|
||
else:
|
||
svc_status = "stopped"
|
||
svc_pid = None
|
||
for proc in local_procs:
|
||
script_name = SCRIPT_NAMES.get(svc_type, "")
|
||
if script_name and script_name in proc.get("cmdline", ""):
|
||
svc_status = "running"
|
||
svc_pid = proc["pid"]
|
||
break
|
||
|
||
services.append({
|
||
"type": svc_type,
|
||
"port": svc_port,
|
||
"status": svc_status,
|
||
"pid": svc_pid,
|
||
})
|
||
|
||
# --- Overall status ---
|
||
if xmpp_in_ejabberd is True:
|
||
status = "online"
|
||
elif xmpp_in_ejabberd is False:
|
||
status = "offline"
|
||
elif platform == "windows" and host in ("192.168.1.16", "127.0.0.1", "localhost"):
|
||
if xmpp_connected:
|
||
status = "online"
|
||
elif local_pid:
|
||
status = "degraded"
|
||
else:
|
||
status = "offline"
|
||
else:
|
||
status = "unknown"
|
||
|
||
# --- Auto-recovery ---
|
||
if status == "offline" and (platform == "windows" or (platform == "linux" and host in LOCAL_LINUX_HOSTS)):
|
||
_offline_counter[agent_id] = _offline_counter.get(agent_id, 0) + 1
|
||
if _offline_counter[agent_id] >= AUTO_RECOVER_THRESHOLD:
|
||
log.warning(f"Auto-recovery: restarting {agent_id}")
|
||
_try_auto_recover(agent)
|
||
else:
|
||
_offline_counter[agent_id] = 0
|
||
|
||
result.append({
|
||
"id": agent_id,
|
||
"name": agent.get("name", ""),
|
||
"display_name": agent.get("display_name", ""),
|
||
"jid": jid,
|
||
"platform": platform,
|
||
"host": host,
|
||
"status": status,
|
||
"status_reason": status_reason,
|
||
"xmpp_connected": xmpp_connected,
|
||
"pid": local_pid,
|
||
"last_message": None,
|
||
"message_count_5min": message_count,
|
||
"errors": 0,
|
||
"offline_checks": _offline_counter.get(agent_id, 0),
|
||
"restartable": (platform == "windows" and host in ("192.168.1.16", "127.0.0.1", "localhost")) or
|
||
(platform == "linux" and host in LOCAL_LINUX_HOSTS),
|
||
"services": services,
|
||
})
|
||
|
||
return jsonify(result)
|
||
|
||
|
||
def _try_auto_recover(agent):
|
||
agent_id = agent["id"]
|
||
platform = agent.get("platform", "")
|
||
host = agent.get("host", "")
|
||
|
||
# Linux local → systemctl start
|
||
if platform == "linux" and host in LOCAL_LINUX_HOSTS:
|
||
svc = _agent_bot_service(agent)
|
||
if not svc:
|
||
return
|
||
try:
|
||
subprocess.run(["sudo", "systemctl", "start", svc], capture_output=True, timeout=15)
|
||
log.info(f"Auto-restarted {svc} for {agent_id}")
|
||
except Exception as e:
|
||
log.error(f"Auto-restart failed for {svc}: {e}")
|
||
return
|
||
|
||
# Windows local → subprocess
|
||
if platform != "windows" or host not in ("192.168.1.16", "127.0.0.1", "localhost"):
|
||
return
|
||
for svc in agent.get("services", []):
|
||
script_name = SCRIPT_NAMES.get(svc.get("type", ""))
|
||
if not script_name:
|
||
continue
|
||
script_path = SCRIPTS_DIR / script_name
|
||
if not script_path.exists():
|
||
continue
|
||
try:
|
||
subprocess.Popen(
|
||
[PYTHON, str(script_path)],
|
||
cwd=str(SCRIPTS_DIR),
|
||
creationflags=subprocess.CREATE_NO_WINDOW,
|
||
)
|
||
log.info(f"Auto-restarted {script_name} for {agent_id}")
|
||
except Exception as e:
|
||
log.error(f"Auto-restart failed: {e}")
|
||
|
||
|
||
@app.route("/api/ejabberd")
|
||
def api_ejabberd():
|
||
health = _xmpp_health()
|
||
online_jids = _ejabberd_online_jids()
|
||
return jsonify({
|
||
"alive": len(online_jids) > 0,
|
||
"xmpp_bot_connected": health.get("xmpp_connected", False),
|
||
"online_jids": sorted(list(online_jids)) if online_jids else [],
|
||
"bot_jid": health.get("bot_jid", ""),
|
||
})
|
||
|
||
|
||
@app.route("/api/agents/<agent_id>/logs")
|
||
def api_agent_logs(agent_id):
|
||
lines = request.args.get("lines", 50, type=int)
|
||
return jsonify({"lines": _tail_logs(lines)})
|
||
|
||
|
||
@app.route("/api/agents/<agent_id>/start", methods=["POST"])
|
||
def api_agent_start(agent_id):
|
||
agents_config = load_agents_config()
|
||
agent = next((a for a in agents_config if a["id"] == agent_id), None)
|
||
if not agent:
|
||
return jsonify({"ok": False, "error": "Agent not found"}), 404
|
||
|
||
# Linux local → systemctl start
|
||
if _is_local_linux(agent):
|
||
svc = _agent_bot_service(agent)
|
||
if not svc:
|
||
return jsonify({"ok": False, "error": "Cannot derive service name"}), 400
|
||
try:
|
||
r = subprocess.run(["sudo", "systemctl", "start", svc], capture_output=True, timeout=15)
|
||
if r.returncode == 0:
|
||
_offline_counter[agent_id] = 0
|
||
log.info(f"Started {svc} for {agent_id}")
|
||
return jsonify({"ok": True, "service": svc})
|
||
else:
|
||
err = r.stderr.decode().strip()
|
||
return jsonify({"ok": False, "error": err}), 500
|
||
except Exception as e:
|
||
return jsonify({"ok": False, "error": str(e)}), 500
|
||
|
||
# Windows local → subprocess
|
||
if agent.get("platform") != "windows":
|
||
return jsonify({"ok": False, "error": "Remote restart not supported yet"}), 400
|
||
started = []
|
||
for svc in agent.get("services", []):
|
||
script_name = SCRIPT_NAMES.get(svc.get("type", ""))
|
||
if not script_name:
|
||
continue
|
||
script_path = SCRIPTS_DIR / script_name
|
||
if not script_path.exists():
|
||
continue
|
||
try:
|
||
subprocess.Popen(
|
||
[PYTHON, str(script_path)],
|
||
cwd=str(SCRIPTS_DIR),
|
||
creationflags=subprocess.CREATE_NO_WINDOW,
|
||
)
|
||
started.append(script_name)
|
||
log.info(f"Started {script_name} for {agent_id}")
|
||
except Exception as e:
|
||
log.error(f"Failed to start {script_name}: {e}")
|
||
_offline_counter[agent_id] = 0
|
||
return jsonify({"ok": True, "started": started})
|
||
|
||
|
||
@app.route("/api/agents/<agent_id>/stop", methods=["POST"])
|
||
def api_agent_stop(agent_id):
|
||
agents_config = load_agents_config()
|
||
agent = next((a for a in agents_config if a["id"] == agent_id), None)
|
||
if not agent:
|
||
return jsonify({"ok": False, "error": "Agent not found"}), 404
|
||
|
||
# Linux local → systemctl stop
|
||
if _is_local_linux(agent):
|
||
svc = _agent_bot_service(agent)
|
||
if not svc:
|
||
return jsonify({"ok": False, "error": "Cannot derive service name"}), 400
|
||
try:
|
||
r = subprocess.run(["sudo", "systemctl", "stop", svc], capture_output=True, timeout=15)
|
||
if r.returncode == 0:
|
||
log.info(f"Stopped {svc} for {agent_id}")
|
||
return jsonify({"ok": True, "service": svc})
|
||
else:
|
||
err = r.stderr.decode().strip()
|
||
return jsonify({"ok": False, "error": err}), 500
|
||
except Exception as e:
|
||
return jsonify({"ok": False, "error": str(e)}), 500
|
||
|
||
# Windows local → taskkill
|
||
if agent.get("platform") != "windows":
|
||
return jsonify({"ok": False, "error": "Remote stop not supported yet"}), 400
|
||
processes = _get_local_processes()
|
||
stopped = []
|
||
for svc in agent.get("services", []):
|
||
script_name = SCRIPT_NAMES.get(svc.get("type", ""))
|
||
if not script_name:
|
||
continue
|
||
for proc in processes:
|
||
if script_name in proc.get("cmdline", ""):
|
||
pid = proc["pid"]
|
||
try:
|
||
subprocess.run(["taskkill", "/f", "/pid", str(pid)], capture_output=True)
|
||
stopped.append({"script": script_name, "pid": pid})
|
||
except Exception as e:
|
||
log.error(f"Failed to stop {script_name}: {e}")
|
||
return jsonify({"ok": True, "stopped": stopped})
|
||
|
||
|
||
@app.route("/api/agents/<agent_id>/restart", methods=["POST"])
|
||
def api_agent_restart(agent_id):
|
||
api_agent_stop(agent_id)
|
||
time.sleep(2)
|
||
return api_agent_start(agent_id)
|
||
|
||
|
||
PLATFORM_SERVICES = [
|
||
{"id": "wechat_bridge", "name": "莫荷微信 (Linux :3001)", "type": "ChannelBridge",
|
||
"desc": "246 Docker wechatbot-webhook → 微信收发 + webhook 触发 → Hermes Gateway",
|
||
"host": "192.168.1.246", "port": 3001},
|
||
{"id": "article_processor", "name": "文章抓取服务 (5810)", "type": "wechat-fetch",
|
||
"desc": "fetches wechat article content + OCR (DrissionPage)",
|
||
"host": "192.168.1.16", "health_url": "http://192.168.1.16:5810/health"},
|
||
]
|
||
|
||
|
||
def _docker_session_failed():
|
||
"""Check docker logs for recent loginCheck errors (WeChat session expired silently)."""
|
||
try:
|
||
dr = subprocess.run(
|
||
["docker", "logs", "wxBotWebhook", "--since", "15m"],
|
||
capture_output=True, text=True, timeout=5
|
||
)
|
||
logs = dr.stdout + dr.stderr
|
||
err_lines = [l for l in logs.split("\n") if "loginCheck" in l and "Error" in l]
|
||
return len(err_lines) >= 3
|
||
except Exception:
|
||
return False
|
||
return False
|
||
|
||
|
||
def _docker_login_check():
|
||
"""Check docker logs for recent WeChat login. Returns datetime or None."""
|
||
try:
|
||
dr = subprocess.run(
|
||
["docker", "logs", "wxBotWebhook", "--since", "120m"],
|
||
capture_output=True, text=True, timeout=5
|
||
)
|
||
for line in (dr.stdout + dr.stderr).split("\n"):
|
||
ansi_re = re.compile(r'\x1b\[[0-9;]*m')
|
||
clean = ansi_re.sub('', line)
|
||
m = re.match(r"^\[?(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})", clean)
|
||
if m and "logged in" in line:
|
||
try:
|
||
return datetime.strptime(m.group(1)[:19], "%Y-%m-%dT%H:%M:%S")
|
||
except ValueError:
|
||
pass
|
||
except Exception:
|
||
pass
|
||
return None
|
||
|
||
|
||
def _augment_wechat_status(entry):
|
||
"""Add real login status and QR code to wechat_bridge platform entry."""
|
||
import socket as _sock
|
||
now = datetime.now()
|
||
entry["login_ok"] = False
|
||
entry["message"] = ""
|
||
entry["qr_url"] = None
|
||
entry["qr_timestamp"] = None
|
||
entry["session_age_hours"] = 0
|
||
|
||
# 1. Fetch QR code (always available)
|
||
try:
|
||
req = urllib.request.Request("http://localhost:3001/login?token=mowechat_fixed_token_001")
|
||
html = urllib.request.urlopen(req, timeout=5).read().decode("utf-8", errors="replace")
|
||
m = re.search(r'qrcode\.makeCode\("([^"]+)"\)', html)
|
||
if m:
|
||
entry["qr_url"] = m.group(1)
|
||
entry["qr_timestamp"] = now.isoformat()
|
||
except Exception:
|
||
pass
|
||
|
||
# 2. Check webhook log for real login status
|
||
webhook_log = str(_GATEWAY_DIR / "linux" / "logs" / "webhook.log")
|
||
try:
|
||
r = subprocess.run(["tail", "-200", webhook_log],
|
||
capture_output=True, text=True, timeout=5)
|
||
lines = r.stdout.strip().split("\n") if r.stdout else []
|
||
last_ok, last_err, last_login, last_logout = None, None, None, None
|
||
for line in reversed(lines):
|
||
ts_match = re.match(r"^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})", line)
|
||
ts = ts_match.group(1) if ts_match else None
|
||
if "WeChat send OK" in line and ts and not last_ok:
|
||
last_ok = ts
|
||
elif "WeChat send error" in line and ts and not last_err:
|
||
last_err = ts
|
||
if last_ok and last_err:
|
||
break
|
||
# Login/logout events: search full log (not just tail -200)
|
||
try:
|
||
for ev, key in (("system_event_logout", "last_logout"), ("system_event_login", "last_login")):
|
||
er = subprocess.run(["grep", ev, webhook_log],
|
||
capture_output=True, text=True, timeout=5)
|
||
ev_lines = [l for l in er.stdout.strip().split("\n") if l.strip()]
|
||
if ev_lines:
|
||
m = re.match(r"^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})", ev_lines[-1])
|
||
if m:
|
||
if key == "last_login":
|
||
last_login = m.group(1)
|
||
else:
|
||
last_logout = m.group(1)
|
||
except Exception:
|
||
pass
|
||
|
||
ok_dt = err_dt = logout_dt = None
|
||
if last_ok:
|
||
ok_dt = datetime.strptime(last_ok, "%Y-%m-%d %H:%M:%S")
|
||
entry["session_age_hours"] = round((now - ok_dt).total_seconds() / 3600, 1)
|
||
if last_err:
|
||
err_dt = datetime.strptime(last_err, "%Y-%m-%d %H:%M:%S")
|
||
if last_logout:
|
||
logout_dt = datetime.strptime(last_logout, "%Y-%m-%d %H:%M:%S")
|
||
|
||
# ═══ 检测"正在等待扫码"状态 ═══
|
||
# 如果 docker 日志最近显示二维码, 说明可能未登录等待扫码。
|
||
# 注意: docker 日志是追加的 — 登录成功后旧二维码文本仍在 5min 窗口内,
|
||
# 因此必须先确认"没有登录成功"再判定为等待扫码(登录成功是最高优先级)。
|
||
try:
|
||
_dr = subprocess.run(
|
||
["docker", "logs", "wxBotWebhook", "--since", "5m"],
|
||
capture_output=True, text=True, timeout=5
|
||
)
|
||
_dlogs = _dr.stdout + _dr.stderr
|
||
_logged_in = "logged in" in _dlogs
|
||
_has_msg = "Message:" in _dlogs
|
||
_showing_qr = ("扫描以下二维码" in _dlogs) or ("Access the URL to login" in _dlogs)
|
||
_login_err = _dlogs.count("loginCheck") >= 2
|
||
except Exception:
|
||
_logged_in = False
|
||
_has_msg = False
|
||
_showing_qr = False
|
||
_login_err = False
|
||
if _logged_in or _has_msg:
|
||
# 登录成功 / 正在收发消息 = 已登录(覆盖二维码残留文本的误判)
|
||
entry["login_ok"] = True
|
||
entry["status"] = "running"
|
||
entry["message"] = "已登录"
|
||
_done_flag = True
|
||
elif _showing_qr or _login_err:
|
||
entry["login_ok"] = False
|
||
entry["status"] = "logged_out"
|
||
entry["message"] = "等待扫码登录" if _showing_qr else "会话失效(loginCheck错误)"
|
||
entry["qr_url"] = entry.get("qr_url") or ""
|
||
_done_flag = True
|
||
else:
|
||
_done_flag = False
|
||
|
||
# Check docker logs for recent login (catches fresh logins before webhook sees them)
|
||
# Check webhook log for login event
|
||
login_dt = None
|
||
if last_login:
|
||
try:
|
||
login_dt = datetime.strptime(last_login, "%Y-%m-%d %H:%M:%S")
|
||
except ValueError:
|
||
pass
|
||
dlogin_dt = _docker_login_check()
|
||
# Session-failed: loginCheck errors = WeChat session expired silently
|
||
if _docker_session_failed():
|
||
entry["login_ok"] = False
|
||
entry["message"] = "会话失效(loginCheck错误)"
|
||
entry["status"] = "logged_out"
|
||
# Logout is the most authoritative signal: if logout happened after the last login, session is dead
|
||
if _done_flag:
|
||
pass
|
||
elif logout_dt and login_dt and logout_dt > login_dt:
|
||
entry["login_ok"] = False
|
||
h = round((now - logout_dt).total_seconds() / 3600, 1)
|
||
entry["message"] = "已掉线" if h < 1 else f"已掉线 {h:.0f}h"
|
||
entry["status"] = "logged_out"
|
||
elif logout_dt and dlogin_dt and logout_dt > dlogin_dt:
|
||
entry["login_ok"] = False
|
||
h = round((now - logout_dt).total_seconds() / 3600, 1)
|
||
entry["message"] = "已掉线" if h < 1 else f"已掉线 {h:.0f}h"
|
||
entry["status"] = "logged_out"
|
||
elif err_dt and login_dt and login_dt > err_dt:
|
||
entry["login_ok"] = True
|
||
entry["session_age_hours"] = round((now - login_dt).total_seconds() / 3600, 1)
|
||
h = entry["session_age_hours"]
|
||
entry["message"] = "\u5df2\u767b\u5f55" if h < 0.1 else f"\u5df2\u767b\u5f55 {h:.0f}h"
|
||
entry["status"] = "running"
|
||
elif err_dt and dlogin_dt and dlogin_dt > err_dt:
|
||
entry["login_ok"] = True
|
||
entry["session_age_hours"] = round((now - dlogin_dt).total_seconds() / 3600, 1)
|
||
h = entry["session_age_hours"]
|
||
entry["message"] = "\u5df2\u767b\u5f55" if h < 0.1 else f"\u5df2\u767b\u5f55 {h:.0f}h"
|
||
entry["status"] = "running"
|
||
elif err_dt and ok_dt and err_dt > ok_dt:
|
||
entry["login_ok"] = False
|
||
h = round((now - err_dt).total_seconds() / 3600, 1)
|
||
entry["message"] = "已掉线" if h < 1 else f"已掉线 {h:.0f}h"
|
||
entry["status"] = "logged_out"
|
||
elif err_dt and not ok_dt:
|
||
entry["login_ok"] = False
|
||
h = round((now - err_dt).total_seconds() / 3600, 1)
|
||
entry["message"] = "已掉线" if h < 1 else f"已掉线 {h:.0f}h"
|
||
entry["status"] = "logged_out"
|
||
elif ok_dt:
|
||
entry["login_ok"] = True
|
||
h = entry["session_age_hours"]
|
||
entry["message"] = f"已登录 {h:.0f}h"
|
||
else:
|
||
entry["message"] = "状态未知"
|
||
except Exception:
|
||
entry["message"] = "无法检测"
|
||
|
||
@app.route("/api/platform")
|
||
def api_platform():
|
||
"""Return platform services status by querying health endpoints."""
|
||
import urllib.request as _ur
|
||
result = []
|
||
for ps in PLATFORM_SERVICES:
|
||
status = "stopped"
|
||
health_data = None
|
||
health_url = ps.get("health_url", "")
|
||
if health_url:
|
||
try:
|
||
req = _ur.Request(health_url)
|
||
with _ur.urlopen(req, timeout=3) as resp:
|
||
body = resp.read()
|
||
status = "running"
|
||
try:
|
||
health_data = json.loads(body)
|
||
except Exception:
|
||
pass
|
||
except Exception:
|
||
status = "stopped"
|
||
elif "port" in ps:
|
||
import socket
|
||
try:
|
||
s = socket.socket()
|
||
s.settimeout(2)
|
||
s.connect((ps.get("host", "127.0.0.1"), ps["port"]))
|
||
s.close()
|
||
status = "running"
|
||
except Exception:
|
||
status = "stopped"
|
||
entry = {
|
||
"id": ps["id"],
|
||
"name": ps["name"],
|
||
"type": ps["type"],
|
||
"desc": ps["desc"],
|
||
"status": status,
|
||
}
|
||
if health_data:
|
||
entry["health_data"] = health_data
|
||
result.append(entry)
|
||
|
||
# Augment wechat_bridge with real login status + QR code
|
||
for entry in result:
|
||
if entry["id"] == "wechat_bridge":
|
||
_augment_wechat_status(entry)
|
||
break
|
||
|
||
return jsonify(result)
|
||
|
||
|
||
@app.route("/api/service/5810/logs")
|
||
def api_article_processor_logs():
|
||
"""Proxy logs from article-processor service on Windows."""
|
||
try:
|
||
req = urllib.request.Request("http://192.168.1.16:5810/logs?lines=200")
|
||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||
data = json.loads(resp.read())
|
||
lines = data.get("lines", [])
|
||
response = jsonify({"lines": lines})
|
||
response.headers["Access-Control-Allow-Origin"] = "*"
|
||
return response
|
||
except Exception as e:
|
||
response = jsonify({"lines": ["<error: {}>".format(str(e))], "error": True})
|
||
response.headers["Access-Control-Allow-Origin"] = "*"
|
||
return response
|
||
|
||
|
||
@app.route("/api/kanban")
|
||
def api_kanban():
|
||
"""Return kanban tasks from shared Hermes kanban.db."""
|
||
kanban_db = Path(os.path.expanduser("~/.hermes/kanban.db"))
|
||
if not kanban_db.exists():
|
||
return jsonify({"tasks": [], "db_exists": False})
|
||
try:
|
||
db = sqlite3.connect(str(kanban_db))
|
||
rows = db.execute(
|
||
"SELECT id, title, body, status, assignee, created_by, created_at FROM tasks ORDER BY created_at DESC"
|
||
).fetchall()
|
||
db.close()
|
||
tasks = []
|
||
for r in rows:
|
||
tasks.append({
|
||
"id": r[0], "title": r[1], "body": r[2], "status": r[3],
|
||
"assignee": r[4], "created_by": r[5], "created_at": r[6]
|
||
})
|
||
return jsonify({"tasks": tasks, "db_exists": True, "count": len(tasks)})
|
||
except Exception as e:
|
||
return jsonify({"error": str(e), "db_exists": True})
|
||
|
||
|
||
# ════════════════════════════════════════════════════════════
|
||
# A — 源码管理可视化
|
||
# ════════════════════════════════════════════════════════════
|
||
@app.route("/api/git")
|
||
def api_git():
|
||
"""最近 git 提交历史 + 分支状态(从 .git 直接读取,不依赖 git 命令)"""
|
||
try:
|
||
# 先用 _PROJECT_DIR(已处理 AGENTSMEETING_ROOT 覆盖),不行再探测常见路径
|
||
candidates = [str(_PROJECT_DIR)]
|
||
if _PROJECT_OVERRIDE:
|
||
candidates.append(str(_PROJECT_DIR))
|
||
# 246 生产环境常见路径
|
||
if sys.platform != "win32":
|
||
candidates.extend([
|
||
"/home/hmo/projects/AgentsMeeting",
|
||
os.path.expanduser("~/projects/AgentsMeeting"),
|
||
])
|
||
git_dir = None
|
||
for c in candidates:
|
||
if os.path.exists(os.path.join(c, ".git")):
|
||
git_dir = c
|
||
break
|
||
if not git_dir:
|
||
return jsonify({"ok": True, "log": [], "dirty": False, "note": "未找到.git目录,请设置AGENTSMEETING_ROOT"})
|
||
dot_git = os.path.join(git_dir, ".git")
|
||
log_lines = []
|
||
# 优先用 git log 命令(真实提交信息),降级到解析 reflog
|
||
tried_git = False
|
||
# Linux nohup 环境 PATH 可能不含 /usr/bin,必须用绝对路径
|
||
GIT_CANDIDATES = [
|
||
os.environ.get("GIT_CMD", ""),
|
||
shutil.which("git") or "",
|
||
"/usr/bin/git", "/usr/local/bin/git",
|
||
]
|
||
git_bin = ""
|
||
for c in GIT_CANDIDATES:
|
||
if c and os.path.exists(c):
|
||
git_bin = c
|
||
break
|
||
if git_bin:
|
||
try:
|
||
r = subprocess.run(
|
||
[git_bin, "log", "--format=%h|%ad|%s", "--date=format:%m-%d %H:%M", "--no-decorate"],
|
||
cwd=git_dir, capture_output=True, text=True, timeout=10)
|
||
if r.returncode == 0 and r.stdout.strip():
|
||
tried_git = True
|
||
for line in r.stdout.strip().split("\n"):
|
||
line = line.strip()
|
||
if line:
|
||
parts = line.split("|", 2)
|
||
if len(parts) == 3:
|
||
log_lines.append(f"{parts[0]} {parts[1]} {parts[2]}")
|
||
else:
|
||
log_lines.append(line)
|
||
except:
|
||
pass
|
||
if not tried_git:
|
||
reflog = os.path.join(dot_git, "logs", "HEAD")
|
||
if os.path.exists(reflog):
|
||
with open(reflog, "r", encoding="utf-8", errors="replace") as f:
|
||
lines = f.readlines()
|
||
for line in reversed(lines):
|
||
line = line.strip()
|
||
if not line:
|
||
continue
|
||
parts = line.split("\t")
|
||
msg = parts[-1] if len(parts) > 1 else line
|
||
hash_part = line.split()[1][:7] if len(line.split()) > 1 else "?"
|
||
# 从 reflog 提取时间戳: 第3+4字段 = unix时间 + 时区
|
||
ts = ""
|
||
try:
|
||
cols = line.split()
|
||
if len(cols) >= 5:
|
||
ts = datetime.fromtimestamp(int(cols[3])).strftime("%m-%d %H:%M") + " "
|
||
except:
|
||
pass
|
||
log_lines.append(f"{ts}{hash_part} {msg}")
|
||
# 检查是否有未提交的改动
|
||
dirty = False
|
||
head_file = os.path.join(dot_git, "HEAD")
|
||
index_file = os.path.join(dot_git, "index")
|
||
if os.path.exists(head_file) and os.path.exists(index_file):
|
||
try:
|
||
dirty = os.path.getmtime(index_file) > os.path.getmtime(head_file)
|
||
except:
|
||
pass
|
||
# 分支名
|
||
branch = "master"
|
||
try:
|
||
with open(head_file, "r") as f:
|
||
ref = f.read().strip()
|
||
if ref.startswith("ref: refs/heads/"):
|
||
branch = ref[16:]
|
||
except:
|
||
pass
|
||
return jsonify({"ok": True, "log": log_lines, "dirty": dirty, "branch": branch, "source": git_dir, "method": ("git_log" if tried_git else "reflog")})
|
||
except Exception as e:
|
||
return jsonify({"ok": False, "error": str(e), "log": [], "dirty": False})
|
||
|
||
|
||
# ════════════════════════════════════════════════════════════
|
||
# B — 服务架构可视化
|
||
# ════════════════════════════════════════════════════════════
|
||
@app.route("/api/services")
|
||
def api_services():
|
||
"""服务功能树 + watchdog 覆盖矩阵"""
|
||
try:
|
||
services = []
|
||
# xmpp_bot (跨平台:通过 XMPP_BRIDGE_URL 检测 Windows .16)
|
||
xmpp_url = f"{XMPP_BRIDGE_URL}/health"
|
||
svc1 = {"name": "xmpp_bot", "port": 5807, "health": _health_status(xmpp_url),
|
||
"watchdog": True, "pid_lock": True, "type": "core", "depends_on": ["ejabberd"]}
|
||
services.append(svc1)
|
||
# article_processor (跨平台:通过 PLATFORM_SERVICES 中的 health_url)
|
||
ap_url = "http://192.168.1.16:5810/health"
|
||
svc2 = {"name": "article_processor", "port": 5810, "health": _health_status(ap_url),
|
||
"watchdog": True, "pid_lock": False, "type": "bridge", "depends_on": []}
|
||
services.append(svc2)
|
||
# dashboard (本地)
|
||
svc3 = {"name": "dashboard", "port": 5803, "health": _health_status("http://127.0.0.1:5803/api/health"),
|
||
"watchdog": False, "pid_lock": True, "type": "mgmt", "depends_on": ["xmpp_bot"]}
|
||
services.append(svc3)
|
||
# ejabberd
|
||
online = _ejabberd_online_jids()
|
||
services.append({"name": "ejabberd", "port": 5222, "health": {"ok": len(online) > 0},
|
||
"watchdog": True, "pid_lock": False, "type": "infra", "depends_on": []})
|
||
# remote gateways (R01 monitoring)
|
||
g1 = {"name": "gateway_mohe", "port": 8646, "health": _health_status("http://192.168.1.246:8646/v1/health"),
|
||
"watchdog": False, "pid_lock": False, "type": "gateway", "depends_on": ["ejabberd"]}
|
||
services.append(g1)
|
||
g2 = {"name": "gateway_zhiwei", "port": 8643, "health": _health_status("http://192.168.1.246:8643/v1/health"),
|
||
"watchdog": False, "pid_lock": False, "type": "gateway", "depends_on": ["ejabberd"]}
|
||
services.append(g2)
|
||
# 统计
|
||
watched = sum(1 for s in services if s.get("watchdog"))
|
||
return jsonify({"ok": True, "services": services, "watched": watched, "total": len(services)})
|
||
except Exception as e:
|
||
return jsonify({"ok": False, "error": str(e)})
|
||
|
||
|
||
def _health_status(url, timeout=3):
|
||
try:
|
||
r = urllib.request.urlopen(url, timeout=timeout)
|
||
return {"ok": r.status == 200, "status": r.status, "detail": "ok"}
|
||
except urllib.error.HTTPError as e:
|
||
return {"ok": False, "status": e.code, "detail": f"HTTP {e.code}"}
|
||
except Exception as e:
|
||
return {"ok": False, "status": 0, "detail": str(e)[:50]}
|
||
|
||
|
||
# ════════════════════════════════════════════════════════════
|
||
# C — 监控结果可视化
|
||
# ════════════════════════════════════════════════════════════
|
||
@app.route("/api/monitor")
|
||
def api_monitor():
|
||
"""Tier1/Tier2 最近检查结果 + 定时任务状态"""
|
||
result = {"ok": True, "tier1": None, "tier2": None, "tasks": [], "platform": sys.platform}
|
||
# Tier1 最新报告
|
||
t1_file = TEMP_DIR / "last_health_check.json"
|
||
if t1_file.exists():
|
||
try:
|
||
with open(str(t1_file)) as f:
|
||
result["tier1"] = json.load(f)
|
||
except:
|
||
pass
|
||
else:
|
||
result["tier1"] = {"status": "no_data", "note": f"{t1_file} 不存在", "summary": {"ok": 0, "total": 0}}
|
||
# Tier2 最新报告
|
||
t2_file = TEMP_DIR / "last_daily_health.json"
|
||
if t2_file.exists():
|
||
try:
|
||
with open(str(t2_file)) as f:
|
||
result["tier2"] = json.load(f)
|
||
except:
|
||
pass
|
||
else:
|
||
result["tier2"] = {"status": "no_data", "note": f"{t2_file} 不存在", "summary": {"ok": 0, "total": 0}}
|
||
# 定时任务状态
|
||
expected_tasks = [
|
||
("agents-health-check", "agents_health_check.py"),
|
||
("agents-daily-health", "agents_daily_health.py"),
|
||
("agents-todo-executor", "self_todo_executor.py"),
|
||
]
|
||
if sys.platform == "win32":
|
||
for name, filename in expected_tasks:
|
||
try:
|
||
r = subprocess.run(["schtasks", "/Query", "/TN", name, "/FO", "CSV", "/NH"],
|
||
capture_output=True, text=True, timeout=5,
|
||
creationflags=subprocess.CREATE_NO_WINDOW)
|
||
status = "ok" if ("Ready" in r.stdout or "\u5c31\u7eea" in r.stdout) else ("missing" if name not in r.stdout else "other")
|
||
except:
|
||
status = "error"
|
||
result["tasks"].append({"name": name, "status": status})
|
||
else:
|
||
# Linux: 直接检查本地 crontab(不在 Windows 上跑了)
|
||
for name, filename in expected_tasks:
|
||
try:
|
||
r = subprocess.run(["crontab", "-l"], capture_output=True, text=True, timeout=5)
|
||
if filename in r.stdout:
|
||
result["tasks"].append({"name": name, "status": "cron_ok"})
|
||
else:
|
||
result["tasks"].append({"name": name, "status": "not_deployed"})
|
||
except Exception as e:
|
||
result["tasks"].append({"name": name, "status": "not_deployed", "note": str(e)[:60]})
|
||
return jsonify(result)
|
||
|
||
|
||
# ════════════════════════════════════════════════════════════
|
||
# E — 元成长回路可视化
|
||
# ════════════════════════════════════════════════════════════
|
||
@app.route("/api/metagrowth")
|
||
def api_metagrowth():
|
||
"""元成长回路(Phase 3 规划中)"""
|
||
try:
|
||
# 统计已完成的修复次数
|
||
todo_file = TEMP_DIR / "health_todos.jsonl"
|
||
total_fixes = 0
|
||
completed_fixes = 0
|
||
if todo_file.exists():
|
||
with open(str(todo_file), encoding="utf-8-sig") as f:
|
||
for line in f:
|
||
line = line.strip()
|
||
if line:
|
||
total_fixes += 1
|
||
try:
|
||
entry = json.loads(line)
|
||
except json.JSONDecodeError:
|
||
continue
|
||
if entry.get("status") == "completed":
|
||
completed_fixes += 1
|
||
# 统计 git 提交次数(从 reflog 行数计算,跟 api_git 同样路径探测逻辑)
|
||
commit_count = 0
|
||
git_candidates = [str(_PROJECT_DIR)]
|
||
if sys.platform != "win32":
|
||
git_candidates.extend(["/home/hmo/projects/AgentsMeeting",
|
||
os.path.expanduser("~/projects/AgentsMeeting")])
|
||
for c in git_candidates:
|
||
rfl = os.path.join(c, ".git", "logs", "HEAD")
|
||
if os.path.exists(rfl):
|
||
try:
|
||
with open(rfl, "r", encoding="utf-8", errors="replace") as f:
|
||
commit_count = sum(1 for line in f if line.strip())
|
||
except:
|
||
pass
|
||
break
|
||
return jsonify({
|
||
"ok": True,
|
||
"status": "planned", # Phase 3
|
||
"total_fixes": total_fixes,
|
||
"completed_fixes": completed_fixes,
|
||
"commit_count": commit_count,
|
||
"meta_growth_enabled": False,
|
||
"description": "E 元成长回路是 Phase 3 范围。启用后将自动分析修复模式并扩展检查规则。",
|
||
})
|
||
except Exception as e:
|
||
return jsonify({"ok": False, "error": str(e)})
|
||
|
||
|
||
# ════════════════════════════════════════════════════════════
|
||
# D — 自修复流水线可视化
|
||
# ════════════════════════════════════════════════════════════
|
||
@app.route("/api/todos")
|
||
def api_todos():
|
||
"""TODO 执行记录 + escalation 日志"""
|
||
todos = []
|
||
todo_file = TEMP_DIR / "health_todos.jsonl"
|
||
if todo_file.exists():
|
||
try:
|
||
with open(str(todo_file)) as f:
|
||
for line in f:
|
||
line = line.strip()
|
||
if line:
|
||
todos.append(json.loads(line))
|
||
except:
|
||
pass
|
||
# executor 日志最新行
|
||
executor_log = LOGS_DIR / "todo_executor.log"
|
||
executor_tail = ""
|
||
if executor_log.exists():
|
||
try:
|
||
lines = open(str(executor_log)).read().strip().split("\n")
|
||
executor_tail = "\n".join(lines[-5:])
|
||
except:
|
||
pass
|
||
return jsonify({"todos": todos, "executor_log_tail": executor_tail, "count": len(todos)})
|
||
|
||
|
||
@app.route("/api/autoheal")
|
||
def api_autoheal():
|
||
"""最近一次自动修复记录(auto_heal.py 写入的摘要)。"""
|
||
sf = TEMP_DIR / "last_auto_heal.json"
|
||
if sf.exists():
|
||
try:
|
||
with open(str(sf)) as f:
|
||
return jsonify(json.load(f))
|
||
except:
|
||
pass
|
||
return jsonify({"time": None, "dry_run": False, "total_expected": 0, "anomalies_found": 0, "actions_taken": []})
|
||
|
||
|
||
# ════════════════════════════════════════════════════════════
|
||
# F — 期望状态 vs 实际状态
|
||
# ════════════════════════════════════════════════════════════
|
||
@app.route("/api/expected")
|
||
def api_expected():
|
||
"""动态期望矩阵:从 agents.yaml + PLATFORM_SERVICES 生成,跨平台区分检测"""
|
||
expected = []
|
||
# 1. 从 agents.yaml 生成各 Agent 服务的期望
|
||
agents = load_agents_config()
|
||
for agent in agents:
|
||
display = agent.get("display_name", agent["name"])
|
||
host = agent.get("host", "127.0.0.1")
|
||
bot_type = agent.get("bot_type", "xmpp")
|
||
for svc in agent.get("services", []):
|
||
svc_type = svc["type"]
|
||
# hermes 协议 agent 不跑独立 xmpp_bot,跳过
|
||
if bot_type == "hermes" and svc_type == "xmpp_bot":
|
||
continue
|
||
port = svc.get("port")
|
||
# key 供前端 M 映射表查找唯一标签(实例级,不按类型折叠)
|
||
key = f"{agent['id']}:{svc_type}"
|
||
spec = svc_type # spec 模块名,供 ?§ 按钮链接 specs/{module}.json
|
||
entry = {"name": f"{display}({host}):{svc_type}", "key": key, "spec": spec, "host": host, "expected": "running", "critical": True}
|
||
if port:
|
||
entry["port"] = port
|
||
entry["check"] = "tcp"
|
||
elif svc_type == "xmpp_bot":
|
||
entry["check"] = "xmpp" # xmpp bot without port means ejabberd-connected
|
||
entry["jid"] = agent.get("jid", "")
|
||
else:
|
||
entry["check"] = "agent_online"
|
||
expected.append(entry)
|
||
|
||
# 2. 从 PLATFORM_SERVICES 生成平台服务的期望
|
||
for ps in PLATFORM_SERVICES:
|
||
entry = {"name": f"{ps['name']} ({ps['id']})", "key": ps["id"], "spec": ps["id"], "host": ps.get("host", "127.0.0.1"), "expected": "running", "critical": True}
|
||
if ps.get("health_url"):
|
||
entry["health_url"] = ps["health_url"]
|
||
entry["check"] = "health_url"
|
||
elif ps.get("port"):
|
||
entry["port"] = ps["port"]
|
||
entry["check"] = "tcp"
|
||
else:
|
||
continue
|
||
expected.append(entry)
|
||
|
||
# 3. 固定定时任务期望
|
||
expected.append({"name": "agents-health-check", "check": "scheduled", "expected": "scheduled", "critical": False})
|
||
expected.append({"name": "agents-daily-health", "check": "scheduled", "expected": "scheduled", "critical": False})
|
||
|
||
# ── 实际状态检测 ──
|
||
actual = {}
|
||
for e in expected:
|
||
name = e["name"]
|
||
host = e.get("host", "127.0.0.1")
|
||
check = e.get("check", "unknown")
|
||
|
||
if check == "scheduled":
|
||
task_name = name
|
||
if sys.platform == "win32":
|
||
try:
|
||
r = subprocess.run(["schtasks", "/Query", "/TN", task_name, "/FO", "CSV", "/NH"],
|
||
capture_output=True, text=True, timeout=5, creationflags=subprocess.CREATE_NO_WINDOW)
|
||
ready = "Ready" in r.stdout or "\u5c31\u7eea" in r.stdout
|
||
actual[name] = "scheduled" if ready else "missing"
|
||
except:
|
||
actual[name] = "error"
|
||
else:
|
||
try:
|
||
r = subprocess.run(["systemctl", "list-timers", "--all", "--no-pager"],
|
||
capture_output=True, text=True, timeout=5)
|
||
if task_name in r.stdout:
|
||
actual[name] = "timer_ok"
|
||
else:
|
||
r2 = subprocess.run(["crontab", "-l"], capture_output=True, text=True, timeout=5)
|
||
actual[name] = "cron_ok" if task_name in r2.stdout else "not_deployed"
|
||
except:
|
||
actual[name] = "not_deployed"
|
||
|
||
elif check == "health_url":
|
||
actual[name] = "running" if _health_status(e["health_url"])["ok"] else "stopped"
|
||
|
||
elif check == "tcp":
|
||
# 判断目标是否是本机
|
||
is_local = _is_local_host(host)
|
||
if is_local:
|
||
actual[name] = "running" if port_open(e["port"], host) else "stopped"
|
||
else:
|
||
# 远程服务:状态由 /api/platform 精确检测,这里标记为远程引用
|
||
actual[name] = "remote (见平台Tab)"
|
||
|
||
elif check == "xmpp":
|
||
# 无端口号的 xmpp_bot:通过 ejabberdctl 查 JID 是否在线
|
||
jid = e.get("jid", "")
|
||
if jid:
|
||
online = _ejabberd_online_jids()
|
||
actual[name] = "online" if jid in online else "offline"
|
||
else:
|
||
actual[name] = "unknown"
|
||
elif check == "agent_online":
|
||
actual[name] = "unknown"
|
||
else:
|
||
actual[name] = "unknown"
|
||
|
||
return jsonify({"expected": expected, "actual": actual})
|
||
|
||
|
||
def _is_local_host(host):
|
||
"""判断目标主机是否是本机(Dashboard 运行所在的机器)。"""
|
||
if host in ("127.0.0.1", "localhost", "::1"):
|
||
return True
|
||
# 246 是 Dashboard 的生产部署机器(同时运行 ejabberd)
|
||
if host == EJABBERD_HOST:
|
||
return True
|
||
try:
|
||
# 解析本机 hostname → IP,与 host 对比
|
||
local_name = socket.gethostname()
|
||
if host == local_name:
|
||
return True
|
||
local_ip = socket.gethostbyname(local_name)
|
||
if host == local_ip:
|
||
return True
|
||
except:
|
||
pass
|
||
return False
|
||
|
||
|
||
def port_open(port, host="127.0.0.1"):
|
||
"""Check if a port is listening on a specific host."""
|
||
try:
|
||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||
s.settimeout(2)
|
||
result = s.connect_ex((host, port))
|
||
s.close()
|
||
return result == 0
|
||
except:
|
||
return False
|
||
|
||
|
||
# EasyTier/RDP API key for xmpp_bot on Windows
|
||
_BRIDGE_KEY = "xxm_bridge_8f3a2c"
|
||
|
||
def _bridge_post(path, payload, timeout=15):
|
||
"""POST to xmpp_bot HTTP bridge on Windows."""
|
||
req = urllib.request.Request(
|
||
f"{XMPP_BRIDGE_URL}{path}",
|
||
data=json.dumps(payload).encode(),
|
||
headers={"Content-Type": "application/json", "X-Api-Key": _BRIDGE_KEY},
|
||
method="POST")
|
||
resp = urllib.request.urlopen(req, timeout=timeout)
|
||
return json.loads(resp.read())
|
||
|
||
|
||
# ════════════════════════════════════════════════════════════
|
||
# EasyTier VPN + RDP 远程桌面控制
|
||
# ════════════════════════════════════════════════════════════
|
||
@app.route("/api/easytier")
|
||
def api_easytier_status():
|
||
"""EasyTier VPN 状态 — proxy to xmpp_bot on Windows."""
|
||
try:
|
||
windows_running = False
|
||
status_246 = "unknown"
|
||
try:
|
||
data = _bridge_post("/easytier", {"action": "status"}, timeout=5)
|
||
windows_running = data.get("running", False)
|
||
status_246 = "running" if data.get("running") else "stopped"
|
||
except:
|
||
pass
|
||
return jsonify({
|
||
"ok": True,
|
||
"status": {"windows": "running" if windows_running else "stopped", "246": status_246},
|
||
"virtual_ips": {"windows": "10.144.144.3", "246": "10.144.144.1"},
|
||
"windows_running": windows_running,
|
||
})
|
||
except Exception as e:
|
||
return jsonify({"ok": False, "error": str(e)})
|
||
|
||
|
||
@app.route("/api/easytier/toggle", methods=["POST"])
|
||
def api_easytier_toggle():
|
||
"""EasyTier VPN 开关 — proxy to xmpp_bot on Windows."""
|
||
try:
|
||
body = json.loads(request.data)
|
||
action = body.get("action", "")
|
||
if action not in ("start", "stop"):
|
||
return jsonify({"ok": False, "error": "action must be start|stop"})
|
||
data = _bridge_post("/easytier", {"action": action}, timeout=15)
|
||
return jsonify({"ok": data.get("ok", False), "message": data.get("message", "")})
|
||
except Exception as e:
|
||
return jsonify({"ok": False, "error": str(e)})
|
||
|
||
|
||
@app.route("/api/rdp")
|
||
def api_rdp_status():
|
||
"""RDP 远程桌面状态 — proxy to xmpp_bot on Windows."""
|
||
try:
|
||
rdp_enabled = False
|
||
tunnel_running = False
|
||
try:
|
||
data = _bridge_post("/rdp", {"action": "status"}, timeout=5)
|
||
rdp_enabled = data.get("rdp_enabled", False)
|
||
tunnel_running = data.get("tunnel_running", False)
|
||
except:
|
||
pass
|
||
return jsonify({
|
||
"ok": True,
|
||
"rdp_enabled": rdp_enabled,
|
||
"tunnel_running": tunnel_running,
|
||
"rdp_port": 3389,
|
||
"tunnel_port": 8080,
|
||
"public_endpoint": "47.115.32.206:8080",
|
||
})
|
||
except Exception as e:
|
||
return jsonify({"ok": False, "error": str(e)})
|
||
|
||
|
||
@app.route("/api/rdp/toggle", methods=["POST"])
|
||
def api_rdp_toggle():
|
||
"""RDP 远程桌面开关 — proxy to xmpp_bot on Windows."""
|
||
try:
|
||
body = json.loads(request.data)
|
||
action = body.get("action", "")
|
||
if action not in ("start", "stop"):
|
||
return jsonify({"ok": False, "error": "action must be start|stop"})
|
||
data = _bridge_post("/rdp", {"action": action}, timeout=15)
|
||
return jsonify({"ok": data.get("ok", False), "message": data.get("message", "")})
|
||
except Exception as e:
|
||
return jsonify({"ok": False, "error": str(e)})
|
||
|
||
|
||
# ════════════════════════════════════════════════════════════
|
||
# OpenCode Go Usage Monitor — 4个账号用量配额监控 (246 本地采集)
|
||
# See gateway/scripts/specs/usage_monitor.json
|
||
# ════════════════════════════════════════════════════════════
|
||
_USAGE_STATS_FILE = TEMP_DIR / "usage_stats.json"
|
||
_USAGE_COLLECTOR_SCRIPT = _SCRIPT_DIR / "usage_collector.py"
|
||
_USAGE_MONITOR_DIR = _SCRIPT_DIR / "usage_monitor"
|
||
_usage_collector_running = False
|
||
|
||
@app.route("/api/usage")
|
||
def api_usage():
|
||
"""读取 usage_stats.json,合并 Kimi 数据(如果存在)。"""
|
||
accounts = []
|
||
providers = {}
|
||
|
||
# 1. 主 OCG 数据
|
||
if _USAGE_STATS_FILE.exists():
|
||
try:
|
||
with open(str(_USAGE_STATS_FILE), "r", encoding="utf-8") as f:
|
||
data = json.load(f)
|
||
for a in data.get("accounts", []):
|
||
a["provider"] = a.get("provider", "opencode_go")
|
||
accounts.append(a)
|
||
providers["opencode_go"] = providers.get("opencode_go", 0) + 1
|
||
except Exception as e:
|
||
log.warning(f"usage: read failed: {e}")
|
||
|
||
# 2. Kimi 数据
|
||
kimi_file = TEMP_DIR / "usage_stats_kimi.json"
|
||
if kimi_file.exists():
|
||
try:
|
||
with open(str(kimi_file), "r", encoding="utf-8") as f:
|
||
kimi_data = json.load(f)
|
||
for a in kimi_data.get("accounts", []):
|
||
a["provider"] = a.get("provider", "kimi")
|
||
accounts.append(a)
|
||
providers["kimi"] = providers.get("kimi", 0) + 1
|
||
except Exception:
|
||
pass
|
||
|
||
# 3. SenseNova 数据(2026-07-23 新增:Token Plan 三模型余量)
|
||
sn_file = TEMP_DIR / "usage_stats_sensenova.json"
|
||
if sn_file.exists():
|
||
try:
|
||
with open(str(sn_file), "r", encoding="utf-8") as f:
|
||
sn_data = json.load(f)
|
||
for a in sn_data.get("accounts", []):
|
||
a["provider"] = a.get("provider", "sensenova")
|
||
accounts.append(a)
|
||
providers["sensenova"] = providers.get("sensenova", 0) + 1
|
||
except Exception:
|
||
pass
|
||
|
||
if not accounts:
|
||
return jsonify({"ok": False, "error": "no data yet",
|
||
"last_refresh_iso": None, "accounts": [], "providers": {}})
|
||
return jsonify({
|
||
"ok": True,
|
||
"last_refresh_iso": max((a.get("last_update_iso","") for a in accounts), default=None),
|
||
"account_count": len(accounts),
|
||
"collected_count": sum(1 for a in accounts if a.get("error") is None),
|
||
"providers": providers,
|
||
"accounts": accounts,
|
||
})
|
||
|
||
|
||
@app.route("/api/usage/refresh", methods=["POST"])
|
||
def api_usage_refresh():
|
||
"""触发 OCG + Kimi 采集(异步 subprocess,立即返回)"""
|
||
global _usage_collector_running
|
||
if _usage_collector_running:
|
||
return jsonify({"ok": True, "message": "collection already in-flight"})
|
||
_usage_collector_running = True
|
||
|
||
def _runner():
|
||
global _usage_collector_running
|
||
try:
|
||
# 1. OCG 本地采集
|
||
log.info("usage: starting OCG collector")
|
||
proc = subprocess.run(
|
||
[sys.executable, str(_USAGE_COLLECTOR_SCRIPT)],
|
||
capture_output=True, text=True, timeout=120)
|
||
log.info(f"usage: OCG done (rc={proc.returncode})")
|
||
if proc.returncode != 0 and proc.stderr:
|
||
log.warning(f"usage_collector stderr:\n{proc.stderr[-1000:]}")
|
||
|
||
# 2. Kimi 远程采集(SSH 到 Windows)+ scp 回 246
|
||
try:
|
||
_collect_kimi_usage()
|
||
except Exception as e:
|
||
log.warning(f"usage: Kimi collection failed: {e}")
|
||
|
||
# 3. SenseNova 远程采集(SSH 到 Windows,CDP 刮 console 余量)
|
||
try:
|
||
_collect_sensenova_usage()
|
||
except Exception as e:
|
||
log.warning(f"usage: SenseNova collection failed: {e}")
|
||
except Exception as e:
|
||
log.error(f"usage: collector crash: {e}")
|
||
finally:
|
||
_usage_collector_running = False
|
||
|
||
t = threading.Thread(target=_runner, name="usage_collector", daemon=True)
|
||
t.start()
|
||
return jsonify({"ok": True, "message": "collection triggered (~15s), poll /api/usage shortly"})
|
||
|
||
|
||
def _collect_kimi_usage():
|
||
"""SSH 到 Windows 跑 usage_collector_kimi.py,scp 结果回 246。
|
||
|
||
被 /api/usage/refresh 和 usage auto-timer 共用,保证 Kimi 数据新鲜。
|
||
注意: Windows 控制台默认 GBK 编码,SSH 输出需容错解码(bytes + errors=replace),
|
||
否则中文日志会让 text=True 的 UTF-8 解码抛异常,导致 scp 步骤不执行。
|
||
"""
|
||
log.info("usage: starting Kimi collector via SSH")
|
||
kimi_cmd = [
|
||
"ssh", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=10",
|
||
"-o", "BatchMode=yes", "hmo@192.168.1.16",
|
||
r'cmd /c "set PYTHONIOENCODING=utf-8 && C:\Users\hmo\AppData\Local\Programs\Python\Python310\python.exe D:\F\NewI\opencode\daily-workspace\projects\AgentsMeeting\gateway\scripts\usage_collector_kimi.py"'
|
||
]
|
||
proc2 = subprocess.run(kimi_cmd, capture_output=True, timeout=60)
|
||
log.info(f"usage: Kimi done (rc={proc2.returncode})")
|
||
if proc2.returncode != 0 and proc2.stderr:
|
||
# Windows 输出可能是 GBK,容错解码
|
||
try:
|
||
err_text = proc2.stderr.decode("utf-8", errors="replace")
|
||
except Exception:
|
||
err_text = repr(proc2.stderr[-300:])
|
||
log.warning(f"usage_kimi stderr: {err_text[-300:]}")
|
||
|
||
# scp Kimi 结果回 246
|
||
scp_cmd = [
|
||
"scp", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=10",
|
||
"hmo@192.168.1.16:D:/F/NewI/opencode/daily-workspace/projects/AgentsMeeting/gateway/temp/usage_stats_kimi.json",
|
||
str(TEMP_DIR / "usage_stats_kimi.json")
|
||
]
|
||
proc3 = subprocess.run(scp_cmd, capture_output=True, timeout=15)
|
||
log.info(f"usage: Kimi scp done (rc={proc3.returncode})")
|
||
|
||
|
||
def _collect_sensenova_usage():
|
||
"""SSH 到 Windows 跑 usage_collector_sensenova.py,scp 结果回 246。"""
|
||
log.info("usage: starting SenseNova collector via SSH")
|
||
sn_cmd = [
|
||
"ssh", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=10",
|
||
"-o", "BatchMode=yes", "hmo@192.168.1.16",
|
||
r'cmd /c "set PYTHONIOENCODING=utf-8 && C:\Users\hmo\AppData\Local\Programs\Python\Python310\python.exe D:\F\NewI\opencode\daily-workspace\projects\AgentsMeeting\gateway\scripts\usage_collector_sensenova.py"'
|
||
]
|
||
proc4 = subprocess.run(sn_cmd, capture_output=True, timeout=90)
|
||
log.info(f"usage: SenseNova done (rc={proc4.returncode})")
|
||
if proc4.returncode != 0 and proc4.stderr:
|
||
try:
|
||
sn_err = proc4.stderr.decode("utf-8", errors="replace")
|
||
except Exception:
|
||
sn_err = repr(proc4.stderr[-300:])
|
||
log.warning(f"usage_sensenova stderr: {sn_err[-300:]}")
|
||
scp4 = subprocess.run([
|
||
"scp", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=10",
|
||
"hmo@192.168.1.16:D:/F/NewI/opencode/daily-workspace/projects/AgentsMeeting/gateway/temp/usage_stats_sensenova.json",
|
||
str(TEMP_DIR / "usage_stats_sensenova.json")
|
||
], capture_output=True, timeout=15)
|
||
log.info(f"usage: SenseNova scp done (rc={scp4.returncode})")
|
||
|
||
|
||
# ── Key Registry ──────────────────────────────────────────────
|
||
_ACCOUNTS_FILE = _USAGE_MONITOR_DIR / "accounts.json"
|
||
|
||
@app.route("/api/keys")
|
||
def api_keys():
|
||
"""返回所有可用 API key(masked 首尾)及各自用量。
|
||
合并 accounts.json(key 原文)和 usage_stats.json(用量数据)。
|
||
"""
|
||
try:
|
||
# 读 key 原文
|
||
if not _ACCOUNTS_FILE.exists():
|
||
return jsonify({"ok": False, "error": "accounts.json not found", "keys": []})
|
||
with open(str(_ACCOUNTS_FILE), "r", encoding="utf-8") as f:
|
||
accounts_data = json.load(f)
|
||
accounts = accounts_data.get("accounts", [])
|
||
|
||
# 读用量数据(OCG + Kimi)
|
||
usage_by_kid = {}
|
||
if _USAGE_STATS_FILE.exists():
|
||
with open(str(_USAGE_STATS_FILE), "r", encoding="utf-8") as f:
|
||
usage_data = json.load(f)
|
||
for a in usage_data.get("accounts", []):
|
||
usage_by_kid[a.get("key_id", "")] = a
|
||
# Kimi 独立文件
|
||
kimi_file = TEMP_DIR / "usage_stats_kimi.json"
|
||
if kimi_file.exists():
|
||
with open(str(kimi_file), "r", encoding="utf-8") as f:
|
||
kimi_data = json.load(f)
|
||
for a in kimi_data.get("accounts", []):
|
||
usage_by_kid[a.get("key_id", "")] = a
|
||
# SenseNova 独立文件(2026-07-23 新增)
|
||
sn_file = TEMP_DIR / "usage_stats_sensenova.json"
|
||
if sn_file.exists():
|
||
with open(str(sn_file), "r", encoding="utf-8") as f:
|
||
sn_data = json.load(f)
|
||
for a in sn_data.get("accounts", []):
|
||
usage_by_kid[a.get("key_id", "")] = a
|
||
|
||
# 合并
|
||
keys_out = []
|
||
_registry_kids = set()
|
||
for a in accounts:
|
||
kid = a.get("key_id", "")
|
||
_registry_kids.add(kid)
|
||
raw_key = a.get("key", "")
|
||
# Mask: 前8后4,不足12字符则不mask
|
||
if len(raw_key) > 12:
|
||
masked = raw_key[:8] + "..." + raw_key[-4:]
|
||
else:
|
||
masked = raw_key # 短 key 不 mask
|
||
|
||
entry = {
|
||
"key_id": kid,
|
||
"label": a.get("label", kid),
|
||
"key_masked": masked,
|
||
"provider": "kimi" if kid == "key7" else "opencode_go",
|
||
}
|
||
# 附加用量
|
||
usage = usage_by_kid.get(kid)
|
||
if usage:
|
||
entry["provider"] = usage.get("provider", entry["provider"])
|
||
entry["workspace_id"] = usage.get("workspace_id", "")
|
||
for period in ["rolling", "weekly", "monthly"]:
|
||
p = usage.get(period)
|
||
if p:
|
||
entry[period] = {
|
||
"usage_percent": p.get("usage_percent"),
|
||
"reset_in_sec": p.get("reset_in_sec"),
|
||
"status": p.get("status", "unknown"),
|
||
}
|
||
entry["session_expired"] = usage.get("session_expired", False)
|
||
entry["last_update"] = usage.get("last_update_iso", "")
|
||
keys_out.append(entry)
|
||
|
||
# 非注册表的用量条目(如 SenseNova key8/9/10:同一账户的模型视角,
|
||
# 不属于 OCG 注册表——2026-07-23 老爸:不要混进 OpenCode Go 卡片)
|
||
for kid, usage in usage_by_kid.items():
|
||
if kid in _registry_kids:
|
||
continue
|
||
entry = {
|
||
"key_id": kid,
|
||
"label": usage.get("label", kid),
|
||
"key_masked": "(共享SenseNova账户)",
|
||
"provider": usage.get("provider", "other"),
|
||
"last_update": usage.get("last_update_iso", ""),
|
||
}
|
||
for period in ["rolling", "weekly", "monthly"]:
|
||
p = usage.get(period)
|
||
if p:
|
||
entry[period] = {
|
||
"usage_percent": p.get("usage_percent"),
|
||
"reset_in_sec": p.get("reset_in_sec"),
|
||
"status": p.get("status", "unknown"),
|
||
}
|
||
keys_out.append(entry)
|
||
|
||
return jsonify({
|
||
"ok": True,
|
||
"count": len(keys_out),
|
||
"keys": keys_out,
|
||
})
|
||
except Exception as e:
|
||
return jsonify({"ok": False, "error": str(e), "keys": []})
|
||
|
||
|
||
# ── Auto-collect timer (every 5 min, keeps cookies alive) ──
|
||
_USAGE_AUTO_INTERVAL = 300
|
||
|
||
def _start_usage_auto_timer():
|
||
"""后台线程:OCG 每 5 分钟自动采集;Kimi/SenseNova 每 30 分钟采集(SSH 到 Windows)。"""
|
||
_KIMI_INTERVAL_MULT = 6 # 每 6 个 OCG 周期(30min)采一次 Kimi/SenseNova
|
||
def _loop():
|
||
time.sleep(15) # initial delay: let dashboard stabilize
|
||
tick = 0
|
||
while True:
|
||
try:
|
||
global _usage_collector_running
|
||
if not _usage_collector_running:
|
||
_usage_collector_running = True
|
||
proc = subprocess.run(
|
||
[sys.executable, str(_USAGE_COLLECTOR_SCRIPT)],
|
||
capture_output=True, text=True, timeout=120)
|
||
log.info(f"usage auto-timer: rc={proc.returncode}")
|
||
if proc.returncode != 0 and proc.stderr:
|
||
log.warning(f"usage auto-timer stderr:\n{proc.stderr[-800:]}")
|
||
|
||
# Kimi/SenseNova:每 30 分钟刷新一次(SSH 到 Windows + CDP,较重)
|
||
tick += 1
|
||
if tick % _KIMI_INTERVAL_MULT == 0:
|
||
try:
|
||
_collect_kimi_usage()
|
||
except Exception as e:
|
||
log.warning(f"usage auto-timer: Kimi failed: {e}")
|
||
try:
|
||
_collect_sensenova_usage()
|
||
except Exception as e:
|
||
log.warning(f"usage auto-timer: SenseNova failed: {e}")
|
||
_usage_collector_running = False
|
||
except Exception as e:
|
||
log.error(f"usage auto-timer error: {e}")
|
||
_usage_collector_running = False
|
||
time.sleep(_USAGE_AUTO_INTERVAL)
|
||
|
||
t = threading.Thread(target=_loop, name="usage_auto_timer", daemon=True)
|
||
t.start()
|
||
log.info(f"usage auto-timer started (interval={_USAGE_AUTO_INTERVAL}s, kimi every {_KIMI_INTERVAL_MULT * _USAGE_AUTO_INTERVAL}s)")
|
||
|
||
|
||
# ════════════════════════════════════════════════════════════
|
||
# Module Spec — 功能模块 co-located 文档
|
||
# ════════════════════════════════════════════════════════════
|
||
_SPECS_DIR = _SCRIPT_DIR / "specs"
|
||
|
||
@app.route("/api/module-spec/<module>")
|
||
def api_module_spec(module):
|
||
"""返回指定模块的 co-located spec JSON(人看 + AI 看)"""
|
||
spec_file = _SPECS_DIR / f"{module}.json"
|
||
if not spec_file.exists():
|
||
return jsonify({"ok": False, "error": f"spec not found: {module}"})
|
||
try:
|
||
with open(spec_file, "r", encoding="utf-8") as f:
|
||
return jsonify(json.load(f))
|
||
except Exception as e:
|
||
return jsonify({"ok": False, "error": str(e)})
|
||
|
||
|
||
@app.route("/api/module-specs")
|
||
def api_module_specs():
|
||
"""列出所有可用的 module spec"""
|
||
specs = []
|
||
if _SPECS_DIR.exists():
|
||
for p in sorted(_SPECS_DIR.glob("*.json")):
|
||
try:
|
||
with open(p, "r", encoding="utf-8") as f:
|
||
data = json.load(f)
|
||
specs.append({
|
||
"module": data.get("module", p.stem),
|
||
"purpose": data.get("purpose", ""),
|
||
"version": data.get("version", ""),
|
||
})
|
||
except:
|
||
pass
|
||
return jsonify({"ok": True, "specs": specs})
|
||
|
||
|
||
# ════════════════════════════════════════════════════════════
|
||
# Spec 文档展示
|
||
# ════════════════════════════════════════════════════════════
|
||
@app.route("/api/spec")
|
||
def api_spec():
|
||
"""返回开发规范文档内容"""
|
||
# Try multiple possible locations
|
||
candidates = [
|
||
# Git-tracked copy in project docs/ (priority)
|
||
os.path.join(_PROJECT_DIR, "docs", "dev-spec.md"),
|
||
os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))), "projects", "AgentsMeeting", "docs", "dev-spec.md"),
|
||
# Legacy .memory/ locations (gitignored, no history)
|
||
os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))), ".memory", "dev-spec.md"),
|
||
os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), ".memory", "dev-spec.md"),
|
||
os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "..", "..", ".memory", "dev-spec.md"),
|
||
os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))), ".memory", "dev-spec.md"),
|
||
os.path.expanduser("~/.memory/dev-spec.md"),
|
||
]
|
||
spec_path = None
|
||
for c in candidates:
|
||
p = os.path.normpath(c)
|
||
if os.path.exists(p):
|
||
spec_path = p
|
||
break
|
||
if not spec_path:
|
||
return jsonify({"ok": False, "error": "dev-spec.md not found", "content": ""})
|
||
try:
|
||
with open(spec_path, "r", encoding="utf-8") as f:
|
||
content = f.read()
|
||
return jsonify({"ok": True, "content": content, "path": spec_path})
|
||
except Exception as e:
|
||
return jsonify({"ok": False, "error": str(e), "content": ""})
|
||
|
||
|
||
@app.route("/api/spec/history")
|
||
def api_spec_history():
|
||
"""返回 dev-spec.md 的 git 历史版本"""
|
||
return _git_history_for(".memory/dev-spec.md")
|
||
|
||
|
||
@app.route("/api/prd/history")
|
||
def api_prd_history():
|
||
"""返回 PRD.md 的 git 历史版本"""
|
||
return _git_history_for("docs/PRD.md")
|
||
|
||
|
||
def _git_history_for(filepath):
|
||
"""Helper: run git log for a file and return formatted history"""
|
||
project_root = _PROJECT_DIR
|
||
# For dev-spec.md, also check docs/ variant which is git-tracked
|
||
if filepath == ".memory/dev-spec.md":
|
||
filepath = "docs/dev-spec.md"
|
||
try:
|
||
cmd = ["git", "log", "--format=%h %ai %s", "--", filepath]
|
||
result = subprocess.run(
|
||
cmd,
|
||
cwd=project_root,
|
||
capture_output=True,
|
||
text=True,
|
||
timeout=15,
|
||
)
|
||
if result.returncode == 0 and result.stdout.strip():
|
||
lines = result.stdout.strip().split("\n")
|
||
return jsonify({"ok": True, "log": lines, "count": len(lines)})
|
||
else:
|
||
return jsonify({"ok": True, "log": [], "count": 0})
|
||
except Exception as e:
|
||
return jsonify({"ok": True, "log": [], "count": 0, "error": str(e)})
|
||
|
||
|
||
@app.route("/api/prd")
|
||
def api_prd():
|
||
"""返回产品需求文档 (PRD) 内容"""
|
||
candidates = [
|
||
os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "docs", "PRD.md"),
|
||
os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "docs", "PRD.md"),
|
||
# venv deployment: ~/projects/AgentsMeeting/docs/PRD.md
|
||
os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))), "projects", "AgentsMeeting", "docs", "PRD.md"),
|
||
os.path.expanduser("~/projects/AgentsMeeting/docs/PRD.md"),
|
||
]
|
||
prd_path = None
|
||
for c in candidates:
|
||
p = os.path.normpath(c)
|
||
if os.path.exists(p):
|
||
prd_path = p
|
||
break
|
||
if not prd_path:
|
||
return jsonify({"ok": False, "error": "PRD.md not found", "content": ""})
|
||
try:
|
||
with open(prd_path, "r", encoding="utf-8") as f:
|
||
content = f.read()
|
||
return jsonify({"ok": True, "content": content, "path": prd_path})
|
||
except Exception as e:
|
||
return jsonify({"ok": False, "error": str(e), "content": ""})
|
||
|
||
|
||
# ════════════════════════════════════════════════════════════
|
||
# K — 自动测试接口
|
||
# ════════════════════════════════════════════════════════════
|
||
@app.route("/api/tests")
|
||
def api_tests():
|
||
"""运行自动测试并返回结果"""
|
||
try:
|
||
from tests_api import run_tests
|
||
return jsonify(run_tests())
|
||
except Exception as e:
|
||
return jsonify({"ok": False, "error": str(e)})
|
||
|
||
|
||
# ── WeChat Bridge Status ──────────────────────────────────────
|
||
@app.route("/api/wechat/status")
|
||
def api_wechat_status():
|
||
"""Returns real WeChat bridge login status + QR code."""
|
||
import socket as _sock
|
||
result = {"online": False, "message": "", "port_ok": False,
|
||
"qr_url": None, "qr_timestamp": None,
|
||
"session_age_hours": 0, "last_ok_at": None, "last_err_at": None}
|
||
now = datetime.now()
|
||
|
||
# 1. TCP check port 3001
|
||
try:
|
||
s = _sock.socket(); s.settimeout(2)
|
||
s.connect(("127.0.0.1", 3001)); s.close()
|
||
result["port_ok"] = True
|
||
except Exception:
|
||
result["message"] = "Docker container port 3001 unreachable"
|
||
return jsonify(result)
|
||
|
||
# 2. Fetch QR code from login page
|
||
try:
|
||
req = urllib.request.Request("http://localhost:3001/login?token=mowechat_fixed_token_001")
|
||
html = urllib.request.urlopen(req, timeout=5).read().decode("utf-8", errors="replace")
|
||
m = re.search(r'qrcode\.makeCode\("([^"]+)"\)', html)
|
||
if m:
|
||
result["qr_url"] = m.group(1)
|
||
result["qr_timestamp"] = now.isoformat()
|
||
except Exception as e:
|
||
result["message"] = f"QR page fetch failed: {e}"
|
||
|
||
# 3a. 优先检测: docker 日志是否正在显示登录二维码 (未登录等待扫码)
|
||
try:
|
||
_dr = subprocess.run(
|
||
["docker", "logs", "wxBotWebhook", "--since", "5m"],
|
||
capture_output=True, text=True, timeout=5
|
||
)
|
||
_dlogs = _dr.stdout + _dr.stderr
|
||
_showing_qr = ("扫描以下二维码" in _dlogs) or ("Access the URL to login" in _dlogs)
|
||
_login_err = _dlogs.count("loginCheck") >= 2
|
||
_logged_in = "logged in" in _dlogs
|
||
_has_msg = "Message:" in _dlogs
|
||
except Exception:
|
||
_showing_qr = False
|
||
_login_err = False
|
||
_logged_in = False
|
||
_has_msg = False
|
||
# 登录成功是最高优先级: 有 logged in 或正在收发消息 = 已登录
|
||
if _logged_in or _has_msg:
|
||
result["online"] = True
|
||
result["message"] = "已登录"
|
||
result["qr_url"] = None
|
||
result["qr_timestamp"] = None
|
||
return jsonify(result)
|
||
if _showing_qr:
|
||
result["online"] = False
|
||
result["message"] = "等待扫码登录"
|
||
return jsonify(result)
|
||
if _login_err:
|
||
result["online"] = False
|
||
result["message"] = "会话失效(loginCheck错误)"
|
||
return jsonify(result)
|
||
|
||
# 3. Check login status from webhook log (last WeChat send)
|
||
webhook_log = str(_GATEWAY_DIR / "linux" / "logs" / "webhook.log")
|
||
try:
|
||
r = subprocess.run(["tail", "-200", webhook_log],
|
||
capture_output=True, text=True, timeout=5)
|
||
lines = r.stdout.strip().split("\n") if r.stdout else []
|
||
for line in reversed(lines):
|
||
ts_match = re.match(r"^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})", line)
|
||
ts = ts_match.group(1) if ts_match else None
|
||
if "WeChat send OK" in line and ts and not result["last_ok_at"]:
|
||
result["last_ok_at"] = ts
|
||
elif "WeChat send error" in line and ts and not result["last_err_at"]:
|
||
result["last_err_at"] = ts
|
||
if result["last_ok_at"] and result["last_err_at"]:
|
||
break
|
||
# Login/logout events: search full log (not just tail -200)
|
||
try:
|
||
for ev, key in (("system_event_logout", "last_logout_at"), ("system_event_login", "last_login_at")):
|
||
er = subprocess.run(["grep", ev, webhook_log],
|
||
capture_output=True, text=True, timeout=5)
|
||
ev_lines = [l for l in er.stdout.strip().split("\n") if l.strip()]
|
||
if ev_lines:
|
||
m = re.match(r"^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})", ev_lines[-1])
|
||
if m:
|
||
result[key] = m.group(1)
|
||
except Exception:
|
||
pass
|
||
except Exception:
|
||
pass
|
||
|
||
# 4. Determine online status
|
||
if result["last_ok_at"]:
|
||
try:
|
||
last_ok_dt = datetime.strptime(result["last_ok_at"], "%Y-%m-%d %H:%M:%S")
|
||
result["session_age_hours"] = round((now - last_ok_dt).total_seconds() / 3600, 1)
|
||
except ValueError:
|
||
pass
|
||
|
||
ok_dt = err_dt = logout_dt = None
|
||
try:
|
||
if result["last_ok_at"]:
|
||
ok_dt = datetime.strptime(result["last_ok_at"], "%Y-%m-%d %H:%M:%S")
|
||
if result["last_err_at"]:
|
||
err_dt = datetime.strptime(result["last_err_at"], "%Y-%m-%d %H:%M:%S")
|
||
if result.get("last_logout_at"):
|
||
logout_dt = datetime.strptime(result["last_logout_at"], "%Y-%m-%d %H:%M:%S")
|
||
except ValueError:
|
||
pass
|
||
|
||
# Check docker logs for recent login
|
||
dlogin_dt = _docker_login_check()
|
||
# Session-failed: loginCheck errors = WeChat session expired silently
|
||
if _docker_session_failed():
|
||
result["online"] = False
|
||
result["message"] = "会话失效(loginCheck错误)"
|
||
# Check webhook login event
|
||
login_dt = None
|
||
if result.get("last_login_at"):
|
||
try:
|
||
login_dt = datetime.strptime(result["last_login_at"], "%Y-%m-%d %H:%M:%S")
|
||
except ValueError:
|
||
pass
|
||
if logout_dt and login_dt and logout_dt > login_dt:
|
||
result["online"] = False
|
||
h = round((now - logout_dt).total_seconds() / 3600, 1)
|
||
result["message"] = "已掉线" if h < 1 else f"已掉线 {h:.0f}h 前"
|
||
elif logout_dt and dlogin_dt and logout_dt > dlogin_dt:
|
||
result["online"] = False
|
||
h = round((now - logout_dt).total_seconds() / 3600, 1)
|
||
result["message"] = "已掉线" if h < 1 else f"已掉线 {h:.0f}h 前"
|
||
elif err_dt and login_dt and login_dt > err_dt:
|
||
result["online"] = True
|
||
result["session_age_hours"] = round((now - login_dt).total_seconds() / 3600, 1)
|
||
h = result["session_age_hours"]
|
||
result["message"] = "\u5df2\u767b\u5f55" if h < 0.1 else f"\u5df2\u767b\u5f55 {h:.0f}h"
|
||
elif err_dt and dlogin_dt and dlogin_dt > err_dt:
|
||
result["online"] = True
|
||
result["session_age_hours"] = round((now - dlogin_dt).total_seconds() / 3600, 1)
|
||
h = result["session_age_hours"]
|
||
result["message"] = "\u5df2\u767b\u5f55" if h < 0.1 else f"\u5df2\u767b\u5f55 {h:.0f}h"
|
||
elif err_dt and ok_dt and err_dt > ok_dt:
|
||
result["online"] = False
|
||
h = round((now - err_dt).total_seconds() / 3600, 1)
|
||
result["message"] = "已掉线" if h < 1 else f"已掉线 {h:.0f}h 前"
|
||
elif err_dt and not ok_dt:
|
||
result["online"] = False
|
||
h = round((now - err_dt).total_seconds() / 3600, 1)
|
||
result["message"] = "已掉线" if h < 1 else f"已掉线 {h:.0f}h 前"
|
||
elif ok_dt and not err_dt:
|
||
result["online"] = True
|
||
h = result["session_age_hours"]
|
||
result["message"] = f"已登录 {h:.0f}h"
|
||
elif ok_dt and err_dt and ok_dt > err_dt:
|
||
result["online"] = True
|
||
h = result["session_age_hours"]
|
||
result["message"] = f"已登录 {h:.0f}h"
|
||
else:
|
||
result["message"] = "状态未知"
|
||
|
||
return jsonify(result)
|
||
|
||
@app.route("/api/platform")
|
||
@app.route("/api/health")
|
||
def api_health():
|
||
xmpp = _xmpp_health()
|
||
return jsonify({
|
||
"ok": True,
|
||
"time": datetime.now().isoformat(),
|
||
"xmpp_bot_alive": xmpp.get("xmpp_connected", False),
|
||
"ejabberd_alive": xmpp.get("ejabberd_alive", False),
|
||
})
|
||
|
||
|
||
# ── OCG Router Proxy Status ──────────────────────────────────
|
||
# 代理跑在 246 :19878,本地直读 status API
|
||
_ROUTER_STATUS_URL = "http://127.0.0.1:19878/api/status"
|
||
|
||
@app.route("/api/proxy/status")
|
||
def api_proxy_status():
|
||
"""返回 OCG 路由代理的运行状态(从本地 :19878 拉取)。"""
|
||
try:
|
||
req = urllib.request.Request(_ROUTER_STATUS_URL, method="GET")
|
||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||
data = json.loads(resp.read().decode("utf-8"))
|
||
return jsonify(data)
|
||
except Exception as e:
|
||
return jsonify({"ok": False, "error": str(e), "proxy_running": False})
|
||
|
||
|
||
@app.route("/api/proxy/toggle-key", methods=["POST"])
|
||
def api_proxy_toggle_key():
|
||
"""启用/停用某个 OCG key(转发到 router :19878,立即生效)。"""
|
||
try:
|
||
payload = request.get_json(force=True, silent=True) or {}
|
||
kid = payload.get("key_id", "")
|
||
enabled = bool(payload.get("enabled", True))
|
||
if not kid:
|
||
return jsonify({"ok": False, "error": "key_id required"})
|
||
req = urllib.request.Request(
|
||
"http://127.0.0.1:19878/api/keys/toggle",
|
||
data=json.dumps({"key_id": kid, "enabled": enabled}).encode("utf-8"),
|
||
headers={"Content-Type": "application/json"},
|
||
method="POST",
|
||
)
|
||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||
data = json.loads(resp.read().decode("utf-8"))
|
||
return jsonify(data)
|
||
except Exception as e:
|
||
return jsonify({"ok": False, "error": str(e)})
|
||
|
||
|
||
# ============================================================
|
||
# Main
|
||
# ============================================================
|
||
|
||
def main():
|
||
lock = guard("dashboard")
|
||
if not lock.ok:
|
||
log.error(lock.message)
|
||
print(f"[dashboard] {lock.message}")
|
||
sys.exit(1)
|
||
port = int(os.environ.get("DASHBOARD_PORT", 5803))
|
||
log.info(f"Dashboard starting on :{port}")
|
||
print(f"[dashboard] Starting on http://127.0.0.1:{port}")
|
||
_start_usage_auto_timer()
|
||
app.run(host="0.0.0.0", port=port, debug=False, use_reloader=False, threaded=True)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|