Files
AgentsMeeting/gateway/scripts/dashboard.py
T
hmo 3238ba54fb 自检修复: reflog读git + threaded=True + TODO误判修正
- Git: 从.git/logs/HEAD读取提交历史,不依赖git二进制
- Flask: threaded=True防止单线程阻塞
- TODO: 'already running'→completed而非failed
- E元成长: 改用reflog行数计算commit数
2026-07-14 02:49:08 +08:00

845 lines
34 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 :5802 (/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, subprocess, logging, urllib.request, sqlite3
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://127.0.0.1:5802")
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"
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"
# 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": 5802}],
},
{
"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": 8642}, {"type": "xmpp_bot"}],
},
{
"id": "agent-003", "name": "Local Inference", "display_name": "xiaoguo",
"jid": "xiaoguo@yoin.fun", "platform": "mac", "host": "192.168.1.122",
"bot_type": "xmpp", "provider": "ocg-old",
"services": [{"type": "xmpp_bot"}, {"type": "omlx_server", "port": 18003}],
},
{
"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 ---
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)
# --- 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":
_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,
"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"),
"services": services,
})
return jsonify(result)
def _try_auto_recover(agent):
agent_id = agent["id"]
platform = agent.get("platform", "")
host = agent.get("host", "")
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
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
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": "WeChat Bridge", "type": "ChannelBridge",
"desc": "bridges WeChat to mohe's hermes gateway",
"health_url": "http://192.168.1.16:5801/health"},
{"id": "api_proxy", "name": "API Proxy", "type": "APIRouter",
"desc": "proxies volcengine API with retry/fallback",
"host": "192.168.1.16", "port": 8787},
{"id": "article_processor", "name": "文章抓取服务 (5810)", "type": "wechat-fetch",
"desc": "fetches wechat article content + OCR",
"host": "192.168.1.16", "health_url": "http://192.168.1.16:5810/health"},
]
@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)
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:
git_dir = os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", ".."))
dot_git = os.path.join(git_dir, ".git")
if not os.path.exists(dot_git):
alt = os.environ.get("AGENTSMEETING_ROOT", "")
if alt and os.path.exists(os.path.join(alt, ".git")):
git_dir = alt
dot_git = os.path.join(alt, ".git")
else:
return jsonify({"ok": True, "log": [], "dirty": False, "note": "no .git"})
# 从 .git/logs/HEAD 读取最近提交
reflog = os.path.join(dot_git, "logs", "HEAD")
log_lines = []
if os.path.exists(reflog):
with open(reflog, "r", encoding="utf-8", errors="replace") as f:
lines = f.readlines()
# 取最后 10 行非空行,格式: "hash hash user ... msg"
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
# 取前 7 位 hash
hash_part = line.split()[1][:7] if len(line.split()) > 1 else "?"
log_lines.append(f"{hash_part} {msg}")
if len(log_lines) >= 10:
break
# 检查是否有未提交的改动:比较 .git/index 的 mtime 与 HEAD
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
return jsonify({"ok": True, "log": log_lines, "dirty": dirty, "branch": "master", "source": "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
svc1 = {"name": "xmpp_bot", "port": 5802, "health": _health_status("http://127.0.0.1:5802/health"),
"watchdog": True, "pid_lock": True, "type": "core", "depends_on": ["ejabberd"]}
services.append(svc1)
# article_processor
svc2 = {"name": "article_processor", "port": 5810, "health": _health_status("http://127.0.0.1:5810/health"),
"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": []})
# 统计
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 = {"tier1": None, "tier2": None, "tasks": []}
# 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
# 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
# 定时任务状态
expected_tasks = ["agents-health-check", "agents-daily-health", "agents-todo-executor"]
for name 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)
# 兼容中文"就绪"和英文"Ready"
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})
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)) as f:
for line in f:
line = line.strip()
if line:
total_fixes += 1
entry = json.loads(line)
if entry.get("status") == "completed":
completed_fixes += 1
# 统计 git 提交次数(从 reflog 行数计算)
git_dir2 = os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", ".."))
commit_count = 0
reflog = os.path.join(git_dir2, ".git", "logs", "HEAD")
if os.path.exists(reflog):
try:
with open(reflog, "r", encoding="utf-8", errors="replace") as f:
commit_count = sum(1 for line in f if line.strip())
except:
pass
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)})
# ════════════════════════════════════════════════════════════
# F — 期望状态 vs 实际状态
# ════════════════════════════════════════════════════════════
@app.route("/api/expected")
def api_expected():
"""期望状态矩阵:应该运行的 vs 实际运行的"""
expected = [
{"name": "xmpp_bot", "port": 5802, "expected": "running", "critical": True},
{"name": "article_processor", "port": 5810, "expected": "running", "critical": True},
{"name": "dashboard", "port": 5803, "expected": "running", "critical": True},
{"name": "watchdog", "expected": "running", "critical": True},
{"name": "agents-health-check", "expected": "scheduled (5min)", "critical": False},
{"name": "agents-daily-health", "expected": "scheduled (daily 08:00)", "critical": False},
{"name": "agents-todo-executor", "expected": "scheduled (10min)", "critical": False},
]
actual = {}
for e in expected:
name = e["name"]
port = e.get("port")
if port:
actual[name] = "running" if port_open(port) else "stopped"
elif name == "watchdog":
try:
r = subprocess.run(
["tasklist", "/FI", "IMAGENAME eq python.exe", "/NH"],
capture_output=True, text=True, timeout=5,
creationflags=subprocess.CREATE_NO_WINDOW,
)
actual[name] = "running" if "python" in r.stdout else "stopped"
except:
actual[name] = "unknown"
elif "scheduled" in e["expected"]:
task_name = name
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 = "Ready" in r.stdout or "\u5c31\u7eea" in r.stdout
actual[name] = "scheduled" if ready else "missing"
except:
actual[name] = "error"
return jsonify({"expected": expected, "actual": actual})
def port_open(port):
try:
r = subprocess.run(["netstat", "-ano"], capture_output=True, text=True, timeout=5,
creationflags=subprocess.CREATE_NO_WINDOW)
return any(f":{port} " in line and "LISTENING" in line for line in r.stdout.splitlines())
except:
return False
@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),
})
# ============================================================
# 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}")
app.run(host="0.0.0.0", port=port, debug=False, use_reloader=False, threaded=True)
if __name__ == "__main__":
main()