feat: introduce spec system + dashboard + health pipeline (AgentsMeeting template)

This commit is contained in:
hmo
2026-07-19 10:50:34 +08:00
parent 81c6dd2314
commit 747fdfe467
19 changed files with 1826 additions and 0 deletions
+141
View File
@@ -0,0 +1,141 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
agents_health_check.py — MoFin Tier1 快速健康检查
====================================================
每 5 分钟运行一次(crontab)。检查关键服务的端口/HTTP/DB 可用性。
全正常时静默(不输出)。异常时写入 TODO 文件和 JSON 报告。
部署: crontab */5 * * * * cd /home/hmo/MoFin && python3 agents_health_check.py
"""
import json, os, sys, socket, sqlite3, urllib.request
from datetime import datetime
from pathlib import Path
# ---- Config ----
SCRIPT_DIR = Path(__file__).resolve().parent
TEMP_DIR = SCRIPT_DIR / "gateway" / "temp"
LOGS_DIR = SCRIPT_DIR / "gateway" / "logs"
# Ensure dirs
TEMP_DIR.mkdir(parents=True, exist_ok=True)
LOGS_DIR.mkdir(parents=True, exist_ok=True)
REPORT_FILE = TEMP_DIR / "last_health_check.json"
TODO_FILE = TEMP_DIR / "health_todos.jsonl"
LOG_FILE = LOGS_DIR / "health_check.log"
# ---- Service List ----
SERVICES = [
{"name": "mofin_api", "label": "MoFin API", "host": "127.0.0.1", "port": 8899, "type": "http", "check": "/api/portfolio"},
{"name": "mofin_dashboard", "label": "Dashboard", "host": "127.0.0.1", "port": 5804, "type": "http", "check": "/api/health"},
{"name": "zhiwei_gateway", "label": "知微 Gateway", "host": "127.0.0.1", "port": 8643, "type": "http", "check": "/v1/health"},
{"name": "ejabberd", "label": "ejabberd XMPP", "host": "127.0.0.1", "port": 5222, "type": "tcp", "check": None},
{"name": "mofin_db", "label": "MoFin 数据库", "host": "127.0.0.1", "port": 0, "type": "db", "check": "/home/hmo/web-dashboard/data/mofin.db"},
]
# ---- Checkers ----
def check_tcp(host, port, timeout=3):
try:
sock = socket.create_connection((host, port), timeout=timeout)
sock.close()
return True, "ok"
except Exception as e:
return False, str(e)
def check_http(host, port, path, timeout=3):
try:
url = f"http://{host}:{port}{path}"
req = urllib.request.Request(url)
resp = urllib.request.urlopen(req, timeout=timeout)
return 200 <= resp.status < 300, f"HTTP {resp.status}"
except Exception as e:
return False, str(e)
def check_db(db_path):
try:
conn = sqlite3.connect(db_path)
conn.execute("SELECT 1")
conn.close()
return True, "ok"
except Exception as e:
return False, str(e)
# ---- Main ----
def run():
now = datetime.now()
results = []
issues = []
for svc in SERVICES:
if svc["type"] == "tcp":
ok, detail = check_tcp(svc["host"], svc["port"])
elif svc["type"] == "http":
ok, detail = check_http(svc["host"], svc["port"], svc["check"])
elif svc["type"] == "db":
ok, detail = check_db(svc["check"])
else:
ok, detail = False, "unknown type"
results.append({
"name": svc["name"],
"label": svc["label"],
"type": svc["type"],
"port": svc["port"],
"health": {"ok": ok},
"detail": detail,
})
if not ok:
issues.append(svc)
# Write report
report = {
"services": results,
"summary": {
"ok": sum(1 for r in results if r["health"]["ok"]),
"total": len(results),
},
"generated_at": now.strftime("%Y-%m-%d %H:%M:%S"),
}
with open(REPORT_FILE, "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
# Handle issues
if issues:
# Write TODO entries
with open(TODO_FILE, "a", encoding="utf-8") as f:
for svc in issues:
entry = {
"service": svc["name"],
"label": svc["label"],
"reason": next((r["detail"] for r in results if r["name"] == svc["name"]), "unknown"),
"timestamp": now.isoformat(),
}
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
# Log to file
with open(LOG_FILE, "a", encoding="utf-8") as f:
f.write(f"[{now.strftime('%Y-%m-%d %H:%M:%S')}] ISSUES: {len(issues)} failed\n")
for svc in issues:
detail = next((r["detail"] for r in results if r["name"] == svc["name"]), "")
f.write(f" - {svc['label']}: {detail}\n")
# Print to stdout (visible in cron log)
print(f"[{now.strftime('%H:%M')}] Health check: {len(issues)}/{len(SERVICES)} services failed")
for svc in issues:
detail = next((r["detail"] for r in results if r["name"] == svc["name"]), "")
print(f" FAIL: {svc['label']} ({svc['name']}) — {detail}")
else:
# All OK → silent
pass
if __name__ == "__main__":
run()
+304
View File
@@ -0,0 +1,304 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
dashboard.py - MoFin management dashboard backend
==================================================
Minimal Flask app on :5804. Monitors MoFin services and serves
module specs (human_help + ai_spec) via ?§ button system.
Adapted from AgentsMeeting dashboard.py. Does NOT modify server.py.
"""
import os, sys, json, socket, logging, time
from pathlib import Path
from datetime import datetime
from flask import Flask, jsonify, request, send_from_directory
# ---- Paths (auto-detect from script location) ----
_SCRIPT_DIR = Path(__file__).resolve().parent # MoFin/
_TEMPLATES_DIR = _SCRIPT_DIR / "templates"
_SPECS_DIR = _SCRIPT_DIR / "specs"
_GATEWAY_DIR = _SCRIPT_DIR / "gateway"
_LOGS_DIR = _GATEWAY_DIR / "logs"
_TEMP_DIR = _GATEWAY_DIR / "temp"
# Allow override via env
_PROJECT_ROOT = os.environ.get("MOFIN_ROOT")
if _PROJECT_ROOT:
_SCRIPT_DIR = Path(_PROJECT_ROOT)
_TEMPLATES_DIR = _SCRIPT_DIR / "templates"
_SPECS_DIR = _SCRIPT_DIR / "specs"
_GATEWAY_DIR = _SCRIPT_DIR / "gateway"
_LOGS_DIR = _GATEWAY_DIR / "logs"
_TEMP_DIR = _GATEWAY_DIR / "temp"
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 ----
PORT = int(os.environ.get("MOFIN_DASHBOARD_PORT", 5804))
START_TIME = time.time()
# ---- Monitored Services ----
SERVICES = [
{
"name": "mofin_api",
"label": "MoFin API",
"port": 8899,
"host": "127.0.0.1",
"type": "http",
"check": "/api/portfolio",
"layer": "核心服务",
"critical": True,
},
{
"name": "mofin_dashboard",
"label": "Dashboard",
"port": 5804,
"host": "127.0.0.1",
"type": "http",
"check": "/api/health",
"layer": "核心服务",
"critical": True,
},
{
"name": "zhiwei_gateway",
"label": "知微 Gateway",
"port": 8643,
"host": "127.0.0.1",
"type": "http",
"check": "/v1/health",
"layer": "AI 网关",
"critical": True,
},
{
"name": "ejabberd",
"label": "ejabberd XMPP",
"port": 5222,
"host": "127.0.0.1",
"type": "tcp",
"check": None,
"layer": "通信层",
"critical": True,
},
{
"name": "mofin_db",
"label": "MoFin 数据库",
"port": 0,
"host": "127.0.0.1",
"type": "db",
"check": "/home/hmo/web-dashboard/data/mofin.db",
"layer": "数据层",
"critical": True,
},
]
# ---- Service Check Helpers ----
def _check_tcp(host, port, timeout=3):
"""Check if TCP port is open."""
try:
sock = socket.create_connection((host, port), timeout=timeout)
sock.close()
return True
except Exception:
return False
def _check_http(host, port, path, timeout=3):
"""Check HTTP endpoint returns 2xx."""
import urllib.request
try:
url = f"http://{host}:{port}{path}" if host else f"http://127.0.0.1:{port}{path}"
req = urllib.request.Request(url)
resp = urllib.request.urlopen(req, timeout=timeout)
return 200 <= resp.status < 300
except Exception:
return False
def _check_db(db_path):
"""Check SQLite database is accessible."""
import sqlite3
try:
conn = sqlite3.connect(db_path)
conn.execute("SELECT 1")
conn.close()
return True
except Exception:
return False
def _check_service(svc):
"""Check a single service, return (ok, detail)."""
if svc["type"] == "tcp":
ok = _check_tcp(svc["host"], svc["port"])
return ok, "port open" if ok else "port closed"
elif svc["type"] == "http":
ok = _check_http(svc["host"], svc["port"], svc["check"])
return ok, "HTTP 2xx" if ok else "HTTP fail"
elif svc["type"] == "db":
ok = _check_db(svc["check"])
return ok, "DB accessible" if ok else "DB fail"
return False, "unknown type"
# ---- API Endpoints ----
@app.route("/")
def index():
return send_from_directory(str(_TEMPLATES_DIR), "dashboard.html")
@app.route("/api/health")
def api_health():
return jsonify({
"status": "ok",
"uptime": int(time.time() - START_TIME),
"version": "1.0",
})
@app.route("/api/services")
def api_services():
"""Return status of all monitored services."""
result = []
for svc in SERVICES:
ok, detail = _check_service(svc)
result.append({
"name": svc["name"],
"label": svc["label"],
"port": svc["port"],
"type": svc["type"],
"layer": svc["layer"],
"critical": svc["critical"],
"health": {"ok": ok},
"detail": detail,
})
ok_count = sum(1 for s in result if s["health"]["ok"])
return jsonify({
"services": result,
"summary": {"ok": ok_count, "total": len(result)},
})
@app.route("/api/expected")
def api_expected():
"""Return expectation matrix."""
expected = []
for svc in SERVICES:
expected.append({
"name": svc["name"],
"label": svc["label"],
"port": svc["port"],
"expected": "running",
"critical": svc["critical"],
"layer": svc["layer"],
"check": f"{svc['type']}:{svc['port']}" if svc["port"] else svc["type"],
})
# Actual status
actual = {}
for svc in SERVICES:
ok, _ = _check_service(svc)
actual[svc["name"]] = "running" if ok else "stopped"
return jsonify({
"expected": expected,
"actual": actual,
})
@app.route("/api/monitor")
def api_monitor():
"""Aggregate health check data from Tier1/Tier2 reports."""
tasks = []
tier1 = {"summary": {"ok": 0, "total": 0}, "services": []}
tier2 = {"summary": {"ok": 0, "total": 0}, "services": []}
# Try to read Tier1 report
t1_path = _TEMP_DIR / "last_health_check.json"
if t1_path.exists():
try:
with open(t1_path, encoding="utf-8") as f:
tier1 = json.load(f)
tasks.append({"name": "agents-health-check", "status": "cron_ok"})
except Exception:
tasks.append({"name": "agents-health-check", "status": "error"})
else:
tasks.append({"name": "agents-health-check", "status": "not_deployed"})
# Try to read Tier2 report
t2_path = _TEMP_DIR / "last_daily_health.json"
if t2_path.exists():
try:
with open(t2_path, encoding="utf-8") as f:
tier2 = json.load(f)
tasks.append({"name": "agents-daily-health", "status": "cron_ok"})
except Exception:
tasks.append({"name": "agents-daily-health", "status": "error"})
else:
tasks.append({"name": "agents-daily-health", "status": "not_deployed"})
# Self-check: are we running?
svc_result = api_services().get_json()
tasks.append({
"name": "dashboard",
"status": "running",
"detail": f"services: {svc_result.get('summary', {}).get('ok', 0)}/{svc_result.get('summary', {}).get('total', 0)}",
})
return jsonify({
"tasks": tasks,
"tier1": tier1,
"tier2": tier2,
"generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
})
@app.route("/api/module-spec/<module>")
def api_module_spec(module):
"""Serve spec JSON for a module."""
# Safety: prevent path traversal
module = module.replace("..", "").replace("/", "").replace("\\", "")
spec_path = _SPECS_DIR / f"{module}.json"
if spec_path.exists():
try:
with open(spec_path, encoding="utf-8") as f:
return jsonify(json.load(f))
except Exception as e:
return jsonify({"error": f"Failed to read spec: {e}"}), 500
return jsonify({"error": f"Module '{module}' not found"}), 404
# ---- Main ----
if __name__ == "__main__":
# Ensure directories exist
_LOGS_DIR.mkdir(parents=True, exist_ok=True)
_TEMP_DIR.mkdir(parents=True, exist_ok=True)
log.info(f"MoFin Dashboard starting on port {PORT}")
log.info(f"Specs dir: {_SPECS_DIR}")
log.info(f"Templates dir: {_TEMPLATES_DIR}")
# Optional: PID guard
try:
sys.path.insert(0, str(_SCRIPT_DIR))
from proc_guard import guard
if not guard("mofin_dashboard"):
log.error("Another dashboard instance is already running")
sys.exit(1)
except ImportError:
log.warning("proc_guard not available, skipping PID lock")
app.run(host="0.0.0.0", port=PORT, debug=False)
+105
View File
@@ -0,0 +1,105 @@
# MoFin — Dashboard API 参考
> 版本: v1.0 | 端口: 5804 | 入口: http://192.168.1.246:5804
---
## Tab 结构
```
MoFin Dashboard
├── Services — 服务状态总览(MoFin API / Dashboard / 知微 Gateway / ejabberd / DB
└── 开发原则
├── G 规范 — 开发规范 + Spec 文档
└── F 健康 — 系统健康监控(Tier1/Tier2
```
---
## API 端点清单
### 服务监控
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/services` | 所有注册服务状态(含健康检查结果) |
| GET | `/api/expected` | 期望状态矩阵(含实际状态对比) |
| GET | `/api/monitor` | 聚合监控数据(tasks + Tier1 + Tier2 |
| GET | `/api/health` | Dashboard 自身健康检查 |
### 知识管理
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/module-spec/<module>` | 读取 `specs/{module}.json`?§ 按钮后端) |
### 响应格式
**GET /api/services**:
```json
{
"services": [
{
"name": "mofin_api",
"label": "MoFin API",
"port": 8899,
"type": "http",
"layer": "核心服务",
"critical": true,
"health": {"ok": true},
"detail": "HTTP 200"
}
],
"summary": {"ok": 5, "total": 5}
}
```
**GET /api/monitor**:
```json
{
"tasks": [
{"name": "agents-health-check", "status": "cron_ok"},
{"name": "agents-daily-health", "status": "not_deployed"},
{"name": "dashboard", "status": "running", "detail": "services: 5/5"}
],
"tier1": {"summary": {"ok": 5, "total": 5}, "services": [...]},
"tier2": {"summary": {"ok": 0, "total": 0}, "services": []},
"generated_at": "2026-07-19 10:00:00"
}
```
---
## 前端架构
- 纯 HTML/CSS/JS(无框架)
- 深色主题(GitHub Dark 风格)
- 5 秒自动轮询(Services Tab
- ?§ Spec 系统(human_help + ai_spec
### Spec 系统(?§ 按钮)
每个有 spec 的模块在 UI 上显示两个按钮:
- `?` → 读取 `human_help` → 人类可读的帮助文档
- `§` → 读取 `ai_spec` → AI 可用的接口/约束/依赖
**Spec 文件位置**: `specs/{module}.json`
**API 端点**: `GET /api/module-spec/{module}`
---
## 数据流
```
crontab (每 5 分钟)
└── agents_health_check.py → last_health_check.json
Dashboard (:5804)
├── /api/services ← 实时 TCP/HTTP 检测
├── /api/monitor ← 聚合读取 health check JSON
└── /api/module-spec/<module> ← 读取 specs/
前端 (dashboard.html)
├── 5s 轮询 /api/services
└── 按需请求 /api/monitorF Tab 打开时)
```
+99
View File
@@ -0,0 +1,99 @@
# MoFin — 部署指南
> 版本: v1.0 | 部署目标: Linux 192.168.1.246
---
## 部署概览
| 组件 | 守护方式 | 端口 | 说明 |
|------|---------|------|------|
| **server.py** | systemd `mofin-api` | 8899 | 持仓情报 API(已有,不动) |
| **dashboard.py** | systemd `mofin-dashboard` | 5804 | 管理门户(新增) |
| **health_check** | crontab `*/5 * * * *` | — | Tier1 健康检查(新增) |
---
## 1. Dashboard 部署
### 1.1 创建 systemd 服务
```bash
sudo tee /etc/systemd/system/mofin-dashboard.service << 'EOF'
[Unit]
Description=MoFin Dashboard
After=network.target
[Service]
Type=simple
User=hmo
WorkingDirectory=/home/hmo/MoFin
Environment=MOFIN_ROOT=/home/hmo/MoFin
ExecStart=/usr/bin/python3 /home/hmo/MoFin/dashboard.py
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
EOF
```
### 1.2 启动
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now mofin-dashboard
sudo systemctl status mofin-dashboard
```
### 1.3 验证
```bash
curl http://127.0.0.1:5804/api/health
curl http://127.0.0.1:5804/api/services | python3 -m json.tool
# 浏览器访问: http://192.168.1.246:5804
```
---
## 2. 健康管线部署
```bash
# 添加到 crontab
(crontab -l 2>/dev/null; echo '# MoFin health pipeline'; echo '*/5 * * * * cd /home/hmo/MoFin && /usr/bin/python3 agents_health_check.py >> gateway/logs/health_check_cron.log 2>&1') | crontab -
# 验证
crontab -l | grep health
```
---
## 3. 防火墙
```bash
sudo ufw status | grep -E '8899|5804'
# 如果未开放:
# sudo ufw allow 5804/tcp
```
---
## 4. 部署后验证清单
- [ ] `curl http://127.0.0.1:5804/api/health``{"status":"ok"}`
- [ ] `curl http://127.0.0.1:5804/api/services` → 返回 5 个服务状态
- [ ] 浏览器打开 `http://192.168.1.246:5804` → Services Tab 显示服务状态
- [ ] Dashboard F 健康 Tab → 定时任务状态显示正常
- [ ] 点击各模块 ?§ 按钮 → 弹出 spec 帮助内容
- [ ] `python3 agents_health_check.py` → 无输出(全正常)
---
## 5. 故障恢复
| 问题 | 命令 |
|------|------|
| Dashboard 挂了 | `ssh hmo@246 'sudo systemctl restart mofin-dashboard'` |
| 健康检查不运行 | `ssh hmo@246 'crontab -l \| grep health'` |
| MoFin API 挂了 | `ssh hmo@246 'sudo systemctl restart mofin-api'` |
| 知微 Gateway 挂了 | `ssh hmo@246 'sudo systemctl restart hermes-gateway@zhiwei'` |
+111
View File
@@ -0,0 +1,111 @@
# MoFin — 健康监控管线
> 版本: v1.0 | 部署目标: Linux 246
---
## 概述
两层监控,通过 crontab 调度,聚合到 Dashboard F Tab。
```
┌─────────────────────────────────┐
│ Dashboard F Tab │
│ /api/monitor 聚合展示 │
└──────────┬──────────────────────┘
│ 读取报告文件
┌──────────┴──────────┐
│ │
┌────▼─────┐ ┌────▼─────┐
│ Tier 1 │ │ Tier 2 │
│ 每 5 分钟 │ │ 每天 8:00│
└──────────┘ └──────────┘
│ │
agents_health_check agents_daily_health
│ (规划中)
┌────▼─────┐
│ TODO 文件 │
│ .jsonl │
└──────────┘
```
---
## Tier 1: 快速健康检查(每 5 分钟)
**脚本**: `agents_health_check.py`
**调度**: `crontab: */5 * * * *`
**检查内容**:
- 5 个服务:MoFin API (:8899) / Dashboard (:5804) / 知微 Gateway (:8643) / ejabberd (:5222) / MoFin DB
- 检查方式:socket 端口 + HTTP /health + SQLite connect
- 全正常时静默(不输出、不写日志)
**异常处理**:
- 写入 `gateway/temp/health_todos.jsonl`
- 每条 TODO 包含:服务名、失败原因、时间戳
- 写入 `gateway/temp/last_health_check.json` 供 Dashboard 读取
**日志**: `gateway/logs/health_check.log`
**报告**: `gateway/temp/last_health_check.json`
---
## Tier 2: 每日全面检查(规划中)
**计划脚本**: `agents_daily_health.py`
**计划调度**: `crontab: 0 8 * * * 1-5`(交易日 8:00
**计划检查内容**:
- 在 Tier 1 基础上增加:
- 磁盘空间检查(阈值 10G 警告 / 2G 严重)
- crontab 存活检查(验证关键定时任务)
- MoFin DB 大小和新鲜度检查
- 生成结构化 JSON 报告
**注意**: MoFin 已有 `system_health_check.py`(每日 9:00)和 `morning_health_check.py`(交易日 8:00,8层48项),Tier2 将与现有检查互补,不重复。
---
## Dashboard 集成
### /api/monitor 端点
聚合展示两层数据:
```json
{
"tasks": [
{"name": "agents-health-check", "status": "cron_ok"},
{"name": "agents-daily-health", "status": "not_deployed"},
{"name": "dashboard", "status": "running"}
],
"tier1": { "services": [...], "summary": {"ok": 5, "total": 5} },
"tier2": { "services": [...], "summary": {"ok": 0, "total": 0} }
}
```
### F Tab 展示
- 系统概览(Tier1 通过率)
- 定时任务状态(绿色=正常,黄色=未部署,红色=异常)
- Tier1 服务详情
---
## 如何新增监控
1. **添加服务到 Tier 1** — 编辑 `agents_health_check.py``SERVICES` 列表
2. **更新 Dashboard** — 在 `dashboard.py``SERVICES` 中同步添加
3. **写 Spec** — 在 `specs/` 创建或更新对应模块的 JSON
---
## 故障排查
| 现象 | 检查 |
|------|------|
| F Tab 无数据 | `cat ~/MoFin/gateway/temp/last_health_check.json` 确认文件存在 |
| Tier1 任务显示"未部署" | `crontab -l \| grep health` 确认 crontab 条目 |
| TODO 堆积 | 手动检查失败服务的实际状态 |
| Dashboard 不显示新服务 | 确认 dashboard.py 的 SERVICES 列表和 health_check 同步 |
+86
View File
@@ -0,0 +1,86 @@
# MoFin — 快速操作手册
> 生产环境: Linux 192.168.1.246 | 端口: API 8899 / Dashboard 5804
---
## 日常检查
```bash
# 打开 Dashboard 看全局
http://192.168.1.246:5804
# 命令行快速状态
ssh hmo@192.168.1.246 "curl -s http://127.0.0.1:5804/api/services | python3 -m json.tool | head -20"
```
---
## Dashboard
```bash
# 查看状态
ssh hmo@192.168.1.246 "sudo systemctl status mofin-dashboard"
# 重启
ssh hmo@192.168.1.246 "sudo systemctl restart mofin-dashboard"
# 查看日志
ssh hmo@192.168.1.246 "tail -50 ~/MoFin/gateway/logs/dashboard.log"
```
---
## MoFin API
```bash
# 查看状态
ssh hmo@192.168.1.246 "sudo systemctl status mofin-api"
# 重启
ssh hmo@192.168.1.246 "sudo systemctl restart mofin-api"
# 测试 API
curl http://192.168.1.246:8899/api/portfolio
```
---
## 健康检查
```bash
# 查看定时任务
ssh hmo@192.168.1.246 "crontab -l | grep health"
# 手动运行(无输出 = 全正常)
ssh hmo@192.168.1.246 "cd ~/MoFin && python3 agents_health_check.py"
# 查看最近报告
ssh hmo@192.168.1.246 "cat ~/MoFin/gateway/temp/last_health_check.json | python3 -m json.tool"
```
---
## 部署更新
```bash
# 1. 拉代码
ssh hmo@192.168.1.246 "cd ~/MoFin && git pull --rebase"
# 2. 重启受影响的服务
ssh hmo@192.168.1.246 "sudo systemctl restart mofin-dashboard"
# 3. 验证
ssh hmo@192.168.1.246 "curl -s http://127.0.0.1:5804/api/health"
```
---
## 常见问题
| 现象 | 操作 |
|------|------|
| Dashboard 不响应 | `ssh hmo@246 sudo systemctl restart mofin-dashboard` |
| F Tab 定时任务显示"未部署" | `ssh hmo@246 crontab -l \| grep health` 确认 |
| MoFin API 不响应 | `ssh hmo@246 sudo systemctl restart mofin-api` |
| 数据库查询失败 | `ssh hmo@246 'ls -la /home/hmo/web-dashboard/data/mofin.db'` |
@@ -0,0 +1,26 @@
# 决策: 引入 spec 体系 + Dashboard
## Context
MoFin 项目已运行数月,有 30 个 API 端点、38 个 cron 任务、完善的编码规范(DEVELOPMENT_STANDARDS.md)和架构文档(SYSTEM_ARCHITECTURE.md)。但缺少:
- 统一的模块可见性("不可见即不存在")
- AI 和人类共享的接口文档(spec 过期即等于没写)
- 系统健康状态的一站式监控面板
## Decision
参照 AgentsMeeting 样板,为 MoFin 引入:
1. **spec 双轨体系** — 每个模块的 `specs/{module}.json`human_help + ai_spec
2. **Dashboard** — 独立 `dashboard.py`(端口 5804),深色主题 Web UI + ?§ 按钮
3. **健康管线** — Tier15min+ Tier2(日检),聚合到 Dashboard F Tab
4. **开发规范**`docs/dev-spec.md`(五条红线)
不改动任何现有业务代码(server.py :8899 保持不变)。
## Consequences
- 新增 Dashboard 维护负担(但代码最小化,复用 AgentsMeeting 模板)
- AI 开发前必须先读 spec,短期可能感觉慢,长期减少架构理解错误
- 健康检查需要纳入 crontab,增加系统负载(但轻量级,可忽略)
## Alternatives Considered
- **方案 A**: 在现有 server.py 中嵌入 Dashboard(被否 — 改动运行中业务代码风险大)
- **方案 B**: 不做 Dashboard,只补文档(被否 — "不可见即不存在",没有面板等于没做)
- **方案 C**: 独立 dashboard.py(✅ 选择 — 零风险,不影响现有服务)
+29
View File
@@ -0,0 +1,29 @@
# 架构决策日志
每次架构决策(引入新组件、增加抽象层、变更接口)记录在此目录下。
## 格式
文件名: `YYYY-MM-DD-简短描述.md`
```markdown
# 决策: [标题]
## Context
为什么需要做这个决策?当前状态是什么?
## Decision
做了什么选择?
## Consequences
这个选择的影响和后果是什么?
## Alternatives Considered
考虑了哪些替代方案?为什么没选?
```
## 已有决策
| 日期 | 决策 | 文件 |
|------|------|------|
| 2026-07-19 | 引入 spec 体系 + Dashboard(参照 AgentsMeeting 样板) | `2026-07-19-spec-and-dashboard.md` |
+214
View File
@@ -0,0 +1,214 @@
# MoFin 开发规范
> 版本: v1.0 | 更新: 2026-07-19 | 基于 AgentsMeeting 样板重构
>
> 📋 样板参考: [AgentsMeeting TEMPLATE-GUIDE.md](../AgentsMeeting/docs/TEMPLATE-GUIDE.md)
---
## 五条红线
1. **先读/写 Spec,再写代码** — 新增功能先写 spec 再实现;修改已有功能先读对应 spec 了解架构和约束再动手。没有 spec 的模块在 Dashboard 不可见,视为未完成
2. **部署必验** — 部署后不打开 Dashboard F Tab 验证 = 部署未完成
3. **不可见即不存在** — 组件不在 Dashboard 中显示 = 等于没部署。离线不告警 = 监控缺陷
4. **实现后同步 Spec** — 每轮开发完毕后,必须将 `specs/{module}.json` 更新为与实际实现一致的状态。文档过期 = 等于没写
5. **部署目标即验收标准** — 所有代码必须以部署目标环境(Linux 246)为基准编写和测试。禁止使用 Windows 专属 API`tasklist``netstat``schtasks``wmic`)在 246 部署的代码中
---
## 一、双轨同源规范体系
每新增/修改一个独立功能模块,必须先写 `specs/{module}.json`
一个来源同时产出两套文档:
```
specs/{module}.json
├── human_help → ? 按钮(人类看说明/排错)
└── ai_spec → § 按钮(AI 看接口/约束/依赖)
```
### 什么算一个模块
满足以下任一条件即视为独立模块,必须写 spec:
- 暴露独立的 HTTP API 端点
- 在 Dashboard 上有独立 UI 面板(`?` + `§` 按钮)
- 有独立的配置文件 / 数据文件
- 可独立部署(如定时任务、数据采集脚本)
### Spec 字段标准
```json
{
"module": "模块名(与 Dashboard 引用名一致)",
"version": "1.0",
"purpose": "一句话说明这个模块干什么",
"human_help": {
"title": "面向人类的标题",
"description": ["说明段落数组"],
"usage": ["使用步骤数组"],
"troubleshooting": ["常见问题数组"]
},
"ai_spec": {
"apis": [
{"method": "GET", "path": "/api/xxx", "returns": "返回值说明"}
],
"dependencies": ["依赖的服务或文件"],
"constraints": ["AI 必须遵守的约束"],
"must_not": ["AI 绝对不能做的事"],
"tests": [{"id": "T1", "name": "测试用例名"}],
"related_files": ["实现文件路径"]
}
}
```
### 当前模块清单
| 模块 | spec 路径 | 说明 | 状态 |
|------|----------|------|------|
| portfolio | `specs/portfolio.json` | 持仓数据 + 总览 | ✅ |
| watchlist | `specs/watchlist.json` | 自选股管理 | ✅ |
| decisions | `specs/decisions.json` | 策略决策库 | ✅ |
| market | `specs/market.json` | 市场观察数据 | ✅ |
| signals | `specs/signals.json` | 信号 + 小果扫描 | ✅ |
| evaluation | `specs/evaluation.json` | 策略评估 | ✅ |
| dashboard | `specs/dashboard.json` | Dashboard 自身 | ✅ |
| health | `specs/health.json` | 健康监控管线 | ✅ |
| price_monitor | `specs/price_monitor.json` | 价格监控 cron | 📋 |
| strategy_lifecycle | `specs/strategy_lifecycle.json` | 策略生命周期 | 📋 |
> 状态: ✅ = spec 已完成 | 📋 = 待编写
---
## 二、验证闭环
```
┌────────────┐ ┌──────────┐ ┌──────────┐
│ G: 规范体系 │────→│ K: 测试 │────→│ F: 健康 │
│ 定义期望 │ │ 验证实现 │ │ 持续监控 │
└────────────┘ └──────────┘ └──────────┘
↑ ↑ │
└────────────────┼────────────────┘
┌────────┴────────┐
│ F 异常 → 触发 K │
│ K 失败 → 更新 G │
└─────────────────┘
```
### 核心反馈链路
| 方向 | 触发条件 | 动作 |
|------|---------|------|
| G → K | 新增/修改 spec | 对应测试 ID 必须新增/更新 |
| K → F | 测试全部通过 | F Tab 组件标记为已验证 |
| **F → K** | **F Tab 发现异常** | 触发对应测试重跑,确认是服务故障还是测试过期 |
| **F → G** | **F Tab 持续异常但测试通过** | 期望矩阵或 spec 过时,应更新 G 和对应 spec |
### F — 系统健康度(Dashboard F Tab
- **期望矩阵**:应该运行的服务 vs 实际状态
- **监控数据**Tier15min)/ Tier2(日报)作为实时状态输入
- **服务拓扑**:所有服务的健康、端口状态
- **?§ 覆盖**:F Tab 中的每条服务必须有对应的 ?human_help)和 §(ai_spec)按钮
---
## 三、开发流程
### 新增功能流程
```
确定模块边界
├─ 1. 创建 specs/{module}.json
│ human_help + ai_spec
├─ 2. 实现功能代码
│ 包含 /health 端点 + PID 锁(proc_guard
├─ 3. 注册到系统
│ - 端口注册
│ - 添加到期望矩阵(F Tab 自动检测)
├─ 4. 编写测试
│ - ai_spec.tests 添加对应测试标识
├─ 5. 同步更新 Spec
│ - 将 specs/{module}.json 更新为与实际实现一致
└─ 6. 提交 → 部署 → 验证
```
### 修改已有功能流程
```
识别要修改的模块(查看模块清单确定 module 名)
├─ 1. 读 specs/{module}.json
│ 重点读 ai_specapis / constraints / dependencies / must_not
├─ 2. 确认理解
│ - 如果 spec 描述与代码实际行为不一致,优先怀疑 spec 过期
├─ 3. 修改功能代码
│ 只改动需求直接涉及的部分,不顺手优化无关代码
├─ 4. 同步更新 Spec
└─ 5. 提交 → 部署 → 验证
```
### Git 操作规范
| # | 规则 | 说明 |
|---|------|------|
| 1 | 开工前必 pull | `git pull --rebase` |
| 2 | 改完即 commit | 一个逻辑单元一次提交。禁止含密钥 |
| 3 | 推前必拉 + 配代理 | push 前 `git pull --rebase`。远程操作前配 `:15000` 代理 |
| 4 | trunk-based | 日常在 main。仅长周期大改开分支 |
### 已有编码规范
MoFin 已有的编码规范见 `docs/DEVELOPMENT_STANDARDS.md`,包含:
- 代码结构(mo_models → mo_data → mofin_db 三层)
- 数据规范(币种、汇率、数据源)
- DB 规范(表设计、迁移)
- LLM Prompt 规范
- Cron 规范(独立运行、幂等性)
- 测试要求(`run_all_tests.py`
以上规范与本文件互补,不冲突。本文件侧重"先 spec 后代码"和"通过 Dashboard 保证可见性"。
---
## 四、部署环境
| 项目 | 值 |
|------|-----|
| **生产环境** | Linux 192.168.1.246 |
| **代码目录** | `/home/hmo/MoFin/` |
| **数据库** | `/home/hmo/web-dashboard/data/mofin.db`SQLite |
| **Flask API** | `server.py``:8899` |
| **Dashboard** | `dashboard.py``:5804`(新增) |
| **Python** | 系统 Python 3 |
---
## 五、文档索引
| 文档 | 用途 |
|------|------|
| `docs/dev-spec.md` | 本文件 — 开发规范(含五条红线) |
| `docs/DEVELOPMENT_STANDARDS.md` | 编码规范(已有) |
| `SYSTEM_ARCHITECTURE.md` | 系统架构(已有) |
| `docs/cron-catalog.md` | Cron 任务清单(已有) |
| `docs/DEPLOY.md` | 部署指南 |
| `docs/QUICKSTART.md` | 快速操作 |
| `docs/DASHBOARD.md` | Dashboard API 参考 |
| `docs/HEALTH-PIPELINE.md` | 健康管线文档 |
| `docs/learned.md` | 经验教训记录 |
| `docs/decisions/` | 架构决策日志 |
+12
View File
@@ -0,0 +1,12 @@
# 经验教训记录
每次被纠正后追加一条记录。每次新任务前先扫一遍本文档。
## 格式
- [YYYY-MM-DD] 问题: xxx | 根因: xxx | 正确做法: xxx
---
## 记录
- [2026-07-19] 问题: MoFin 缺少 spec 体系和 Dashboard,功能模块不可见、不可监控 | 根因: 项目早期未引入"不可见即不存在"原则 | 正确做法: 参照 AgentsMeeting 样板重构,先建立 dev-spec.md + spec 体系 + Dashboard,再逐步迁移
+60
View File
@@ -0,0 +1,60 @@
{
"module": "dashboard",
"version": "1.0",
"purpose": "MoFin 管理门户。独立 Flask 应用(端口 5804),统一展示系统健康状态、模块 spec 帮助和监控数据。",
"human_help": {
"title": "Dashboard — 管理门户",
"description": [
"MoFin 统一管理面板,提供系统健康状态总览和各模块的帮助文档。",
"采用深色主题 Web UI,与 AgentsMeeting Dashboard 一致的视觉风格。",
"访问地址: http://192.168.1.246:5804"
],
"usage": [
"打开浏览器访问 http://192.168.1.246:5804",
"F 健康 Tab — 查看所有服务运行状态和健康管线数据",
"G 规范 Tab — 查看开发规范文档",
"点击 ? 按钮 — 查看面向人类的模块帮助",
"点击 § 按钮 — 查看面向 AI 的接口约束文档"
],
"troubleshooting": [
"Dashboard 不响应 → ssh 246 'sudo systemctl restart mofin-dashboard'",
"F Tab 无数据 → 检查 crontab 中健康检查任务是否运行",
"Spec 加载失败 → 检查 specs/ 目录权限和 JSON 格式"
]
},
"ai_spec": {
"apis": [
{"method": "GET", "path": "/api/health", "returns": "{status:'ok', uptime:N}"},
{"method": "GET", "path": "/api/services", "returns": "{services[{name, type, port, status}]} — 所有注册服务的运行状态"},
{"method": "GET", "path": "/api/expected", "returns": "{expected[{name, port, expected, critical}], actual{name:status}} — 期望状态矩阵"},
{"method": "GET", "path": "/api/monitor", "returns": "{tier1{summary{ok,total}}, tier2{summary{ok,total}}, tasks[{name,status}]} — 聚合监控数据"},
{"method": "GET", "path": "/api/module-spec/<module>", "returns": "specs/{module}.json 内容"}
],
"dependencies": [
"specs/ 目录 — 所有模块的 spec JSON 文件",
"agents_health_check.py — Tier1 健康检查(生成 last_health_check.json",
"agents_daily_health.py — Tier2 日检(生成 last_daily_health.json",
"server.py :8899 — 业务 APIDashboard 通过 HTTP 检测其可达性)"
],
"constraints": [
"Dashboard 部署在 Linux 246 上,端口 5804",
"通过 systemd 守护(mofin-dashboard.service",
"不依赖 server.py :8899(可独立运行)",
"前端 5 秒轮询 /api/services + /api/expected",
"?§ 按钮通过 /api/module-spec/<module> 读取 spec JSON"
],
"must_not": [
"不要修改 server.py :8899 来集成 Dashboard(保持独立)",
"不要在 Dashboard 中硬编码业务数据(只做监控和文档展示)",
"不要移除 ?§ 按钮系统(这是 spec 可视化的核心)"
],
"related_files": [
"dashboard.py — Flask 后端",
"templates/dashboard.html — 前端",
"specs/ — Spec 文件目录",
"gateway/logs/ — 运行时日志"
]
}
}
+53
View File
@@ -0,0 +1,53 @@
{
"module": "decisions",
"version": "1.0",
"purpose": "策略决策库。管理持仓和自选股的策略决策(止损/止盈/买入区/操作建议),支持新旧格式兼容。",
"human_help": {
"title": "策略决策库",
"description": [
"存储每只持仓/自选股的策略决策数据,包括止损价、止盈价、买入区间、操作建议等。",
"数据来自知微 LLM 分析,通过 /api/analysis/batch 写入。",
"支持新旧两种数据格式的自动兼容(新格式:stop_loss/take_profit 顶层字段;旧格式:trigger 对象)。"
],
"usage": [
"GET /api/decisions — 获取全部决策(按标签+执行状态排序)",
"POST /api/decisions/add — 新增/更新一条决策(同股票旧决策自动标记 superseded",
"POST /api/decisions/tag — 设置/清除推荐标签(current_recommend / active_manual",
"GET /api/decisions/pending — 获取有未确认建议的条目"
],
"troubleshooting": [
"决策列表为空 → 确认 regenerate_all 已执行或知微已写入",
"旧格式不显示 → 检查归一化逻辑(/api/decisions GET handler"
]
},
"ai_spec": {
"apis": [
{"method": "GET", "path": "/api/decisions", "returns": "{decisions[{code, name, type, status, tag, action, trigger{stop_loss, take_profit, entry_zone}, current, zone_breach, updated_reason, advice_timeline, changelog, execution, analysis}], total, regenerated_at}"},
{"method": "POST", "path": "/api/decisions/add", "returns": "{status:'ok', entry:{...}}"},
{"method": "POST", "path": "/api/decisions/tag", "returns": "{status:'ok', code, tag}"},
{"method": "GET", "path": "/api/decisions/pending", "returns": "[{code, name, current, pending_advice[{date, direction, price, summary, status}]}]"}
],
"dependencies": [
"mo_data.py — read_decisions()",
"mofin_db.py — write_holding_strategy()",
"strategy_lifecycle.py — regenerate_all 触发全量重评"
],
"constraints": [
"支持新旧两种格式的读取兼容(normalized 逻辑)",
"同股票新决策会自动将旧决策标记为 superseded",
"排序规则:current_recommend 标签 > 执行状态(partial_exit > executing > observing > code",
"advice_timeline 去重:同日期+同方向+摘要前40字相同 → skip"
],
"must_not": [
"不要在决策中硬编码价格阈值(应由 LLM 分析生成)",
"不要删除 superseded 的旧决策(保留历史记录)"
],
"related_files": [
"server.py — /api/decisions* 路由",
"mo_data.py — read_decisions()",
"mofin_db.py — holding_strategies 表"
]
}
}
+48
View File
@@ -0,0 +1,48 @@
{
"module": "evaluation",
"version": "1.0",
"purpose": "策略评估系统。提供策略双维度评估结果查询、手动触发评估和准确率统计。",
"human_help": {
"title": "策略评估",
"description": [
"评估每只股票策略的有效性,来自 strategy_evaluator.py(每周六 21:00 自动运行)。",
"支持手动触发评估(POST /api/evaluation/trigger)。",
"评估数据主源为 evaluation.json,备选为 decisions.json 中的 evaluation 字段。"
],
"usage": [
"GET /api/evaluation — 获取全部策略评估结果",
"POST /api/evaluation/trigger — 手动触发策略评估(执行 strategy_evaluator.py",
"GET /api/stats/accuracy — 获取准确率统计数据",
"GET /api/feedback — 获取策略反馈数据"
],
"troubleshooting": [
"评估数据为空 → 确认 strategy_evaluator.py 已运行过",
"触发失败 → 检查 strategy_evaluator.py 路径是否正确"
]
},
"ai_spec": {
"apis": [
{"method": "GET", "path": "/api/evaluation", "returns": "[{code, name, type, current, evaluations[{date, dimension, score, reason}]}]"},
{"method": "POST", "path": "/api/evaluation/trigger", "returns": "{status:'ok', output, error} — 执行 strategy_evaluator.pytimeout 60s"},
{"method": "GET", "path": "/api/stats/accuracy", "returns": "accuracy_stats.json 内容"},
{"method": "GET", "path": "/api/feedback", "returns": "strategy_feedback.json 内容"}
],
"dependencies": [
"strategy_evaluator.py — 双维度评估脚本(cron: 周六 21:00",
"evaluation.json — 评估结果主数据源",
"accuracy_stats.json — 准确率统计",
"strategy_feedback.json — 反馈数据"
],
"constraints": [
"POST /api/evaluation/trigger 会阻塞最多 60 秒",
"评估数据优先读 evaluation.jsonfallback 读 decisions.json 的 evaluation 字段"
],
"related_files": [
"server.py — /api/evaluation, /api/evaluation/trigger, /api/stats/accuracy, /api/feedback",
"strategy_evaluator.py",
"strategy_feedback.py"
]
}
}
+73
View File
@@ -0,0 +1,73 @@
{
"module": "health",
"version": "1.0",
"purpose": "MoFin 系统健康监控管线。三层监控(Tier1 快速检查 + Tier2 日检),聚合到 Dashboard F Tab 展示。",
"human_help": {
"title": "F 健康 — 系统健康",
"description": [
"实时监控 MoFin 所有关键服务(Flask API、数据库、cron 任务)的运行状态。",
"两层监控:",
" Tier1 — 每 5 分钟快速端口/进程检查",
" Tier2 — 每日 8:00 开盘前全面体检(进程+端口+DB+磁盘+cron",
"检查结果聚合到 Dashboard F Tab,异常自动告警。"
],
"usage": [
"1. 打开 Dashboard → F 健康 Tab 查看概览",
"2. 绿色 = 正常,黄色 = 部分异常,红色 = 严重异常",
"3. 异常服务列表直接显示影响描述",
"4. 定时任务区域检查所有 cron 是否正常运行"
],
"troubleshooting": [
"F Tab 显示无数据 → 检查 crontab 中健康检查是否部署",
"服务显示 down 但实际在运行 → 检查端口或 health 端点是否正确",
"TODO 堆积 → 检查 self_todo_executor 是否在 crontab 中"
]
},
"ai_spec": {
"apis": [
{"method": "GET", "path": "/api/services", "returns": "{services[{name, type, port, health{ok}, status}]}"},
{"method": "GET", "path": "/api/expected", "returns": "{expected[{name, port, expected, critical}], actual{name:status}}"},
{"method": "GET", "path": "/api/monitor", "returns": "{tier1{summary{ok,total}}, tier2{summary{ok,total}}, tasks[{name,status}]}"}
],
"dependencies": [
"agents_health_check.py — Tier1(每 5 分钟,socket 端口 + HTTP /health",
"agents_daily_health.py — Tier2(每日 8:00,端口+进程+DB+磁盘+cron",
"mofin.db — SQLite 数据库(检查可读写)"
],
"architecture": {
"monitored_services": [
{"name": "mofin_api", "port": 8899, "type": "http", "check": "GET /api/portfolio"},
{"name": "mofin_dashboard", "port": 5804, "type": "http", "check": "GET /api/health"},
{"name": "zhiwei_gateway", "port": 8643, "type": "http", "check": "GET /v1/health"},
{"name": "ejabberd", "port": 5222, "type": "tcp", "check": "socket connect"},
{"name": "mofin_db", "port": 0, "type": "file", "check": "sqlite3 connect + SELECT"}
]
},
"constraints": [
"Tier1 全正常时静默(不输出日志)",
"Tier1 异常写入 gateway/temp/health_todos.jsonl",
"Tier1 报告写入 gateway/temp/last_health_check.json",
"Tier2 报告写入 gateway/temp/last_daily_health.json",
"Dashboard /api/monitor 聚合读取以上 JSON 文件"
],
"must_not": [
"不要在健康检查中修改业务数据",
"不要硬编码 Windows 路径或命令",
"不要检查已停用的服务(wechat_agent 等)"
],
"tests": [
{"id": "H01", "name": "/api/services 返回服务列表", "endpoint": "GET /api/services"},
{"id": "H02", "name": "/api/expected 返回期望矩阵", "endpoint": "GET /api/expected"},
{"id": "H03", "name": "/api/monitor 返回监控数据", "endpoint": "GET /api/monitor"}
],
"related_files": [
"agents_health_check.py — Tier1 健康检查",
"agents_daily_health.py — Tier2 日检",
"dashboard.py — /api/services, /api/expected, /api/monitor",
"templates/dashboard.html — F Tab 渲染",
"specs/health.json — 本 spec 文件"
]
}
}
+42
View File
@@ -0,0 +1,42 @@
{
"module": "market",
"version": "1.0",
"purpose": "市场观察数据。提供大盘指数和板块数据的查询和更新。",
"human_help": {
"title": "市场观察",
"description": [
"展示大盘指数(上证、深证、恒生等)和板块热度数据。",
"数据由 market_watch.py cron(每 30 分钟)自动采集。",
"优先从 DB 读取(market_snapshots / sector_snapshots 表),DB 无数据时 fallback 到 market.json。"
],
"usage": [
"GET /api/market — 获取最新市场数据(指数 + 板块)",
"POST /api/update/market — 更新市场数据(由 market_watch 调用)"
],
"troubleshooting": [
"市场数据为空 → 检查 market_watch cron 是否正常运行",
"数据显示旧 → 手动运行 python3 market_watch.py 更新"
]
},
"ai_spec": {
"apis": [
{"method": "GET", "path": "/api/market", "returns": "{indices[{name, code, price, change_pct}], sectors[{name, change_pct, leader}]}"},
{"method": "POST", "path": "/api/update/market", "returns": "{status:'ok'}"}
],
"dependencies": [
"mofin_db.py — market_snapshots / sector_snapshots 表",
"market_watch.py — 大盘采集 cron*/30 9-15",
"market_screener.py — 全市场筛选 cron"
],
"constraints": [
"DB 优先读取,JSON 仅做 fallback"
],
"related_files": [
"server.py — /api/market, /api/update/market",
"market_watch.py — 大盘数据采集",
"market_screener.py — 全市场筛选"
]
}
}
+60
View File
@@ -0,0 +1,60 @@
{
"module": "portfolio",
"version": "1.0",
"purpose": "持仓数据查询与管理。提供持仓列表、资产概览、实时价格更新。",
"human_help": {
"title": "持仓管理",
"description": [
"本模块管理老爸的股票持仓数据,包括个股持仓明细和总资产概览。",
"数据存储在 SQLite (mofin.db),由 price_monitor cron 每 2 分钟更新价格。",
"港股以 HKD 存储,汇总时自动转换为 CNY。"
],
"usage": [
"GET /api/portfolio — 获取完整持仓列表(含价格、涨跌幅、盈亏)",
"GET /api/overview — 获取总资产概览(总资产、股票市值、现金、仓位、top movers",
"POST /api/update/portfolio — 批量更新持仓数据(由 cron 调用)",
"POST /api/update/realtime — 实时价格更新(由 price_monitor 调用)"
],
"troubleshooting": [
"数据库查询失败 → 检查 mofin.db 是否存在且可读写",
"港股价格异常 → 确认 hk_rate.py 汇率 API 可达",
"数据不更新 → 检查 crontab 中 price_monitor 是否正常运行"
]
},
"ai_spec": {
"apis": [
{"method": "GET", "path": "/api/portfolio", "returns": "{total_assets, stock_value, cash, position_pct, total_pnl, holdings[{code, name, price, cost, shares, change_pct, currency, ...}]}"},
{"method": "GET", "path": "/api/overview", "returns": "{total_assets, stock_value, cash, position_pct, total_pnl, top_movers, market, alerts, updated_at}"},
{"method": "POST", "path": "/api/update/portfolio", "returns": "{status:'ok'}"},
{"method": "POST", "path": "/api/update/realtime", "returns": "{status:'ok'}"}
],
"dependencies": [
"mo_data.py — read_portfolio() 统一读取层",
"mofin_db.py — get_conn(), query_holdings(), query_portfolio_summary()",
"price_monitor.py — 唯一价格写入者,cron: */2 9-16 1-5"
],
"constraints": [
"港股个股价格/成本以 HKD 存储,currency='HKD'",
"A股个股价格/成本以 CNY 存储,currency='CNY'",
"总资产/总市值以 CNY 汇总(calc_total_assets 自动转换)",
"禁止跨币种直接比较或加减",
"price_monitor 是唯一的价格写入源,其他脚本禁止直接写价格"
],
"must_not": [
"不要在各业务脚本中直接写 SQL(必须通过 mofin_db.py",
"不要硬编码汇率(必须通过 hk_rate.py 的 get_hk_rate()",
"不要直接 json.load 读数据(必须通过 mo_data.py",
"不要自己实现 calc_total_assets / is_hk_stock(必须用 mo_models.py"
],
"related_files": [
"server.py — API 路由定义",
"mo_models.py — 数据模型(calc_total_assets, is_hk_stock, to_cny",
"mo_data.py — 统一读取层",
"mofin_db.py — DB 层",
"price_monitor.py — 价格更新 cron",
"hk_rate.py — 港币汇率"
]
}
}
+44
View File
@@ -0,0 +1,44 @@
{
"module": "signals",
"version": "1.0",
"purpose": "信号与扫描数据。提供市场信号查询和小果扫描统计。",
"human_help": {
"title": "信号与扫描",
"description": [
"展示系统产生的交易信号和小果 LLM 扫描结果。",
"信号来自多个来源:xiaoguo_scanner(全市场)、macro_context_collector(宏观)、divergence_detector(背离)。",
"所有信号存储在 signal_news 表中。"
],
"usage": [
"GET /api/signals — 获取最近 20 条信号(含板块信号关联)",
"GET /api/xiaoguo-scan — 获取小果扫描统计(扫描总数/发现信号数/近期记录/今日来源分布)"
],
"troubleshooting": [
"信号为空 → 检查 xiaoguo_scanner 和 macro_context_collector cron 状态",
"小果扫描数据停更 → 检查小果 LLM API 是否可达"
]
},
"ai_spec": {
"apis": [
{"method": "GET", "path": "/api/signals", "returns": "[{id, sector, overall_sentiment, summary, source, created_at, signal_type, severity}] — 最近 20 条信号"},
{"method": "GET", "path": "/api/xiaoguo-scan", "returns": "{total_scanned, found_signals, recent[{code, name, last_scanned_at, found_count}], source_today{source:cnt}}"}
],
"dependencies": [
"mofin_db.py — signal_news / xiaoguo_scan_tracker 表",
"xiaoguo_scanner.py — 全市场扫描 cron*/5 9-15",
"macro_context_collector.py — 宏观信号采集 cron",
"divergence_detector.py — 背离检测 cron"
],
"constraints": [
"signal_news 和 sector_signals 通过 LEFT JOIN 关联",
"xiaoguo_scan_tracker 的 source 统计只取最近 24 小时"
],
"related_files": [
"server.py — /api/signals, /api/xiaoguo-scan",
"xiaoguo_scanner.py",
"macro_context_collector.py"
]
}
}
+42
View File
@@ -0,0 +1,42 @@
{
"module": "watchlist",
"version": "1.0",
"purpose": "自选股列表管理。提供自选股查询和批量更新。",
"human_help": {
"title": "自选股管理",
"description": [
"管理老爸的自选股列表,与持仓分开存储。",
"数据存储在 SQLite (mofin.db) 的 watchlist_stocks 表。",
"小果扫描器会消费自选股信号并自动添加到自选列表。"
],
"usage": [
"GET /api/watchlist — 获取完整自选股列表",
"POST /api/update/watchlist — 批量更新自选股(由 cron 调用)"
],
"troubleshooting": [
"数据库查询失败 → 检查 mofin.db 是否可读写",
"自选股列表为空 → 确认 regenerate_all 或手动添加过自选股"
]
},
"ai_spec": {
"apis": [
{"method": "GET", "path": "/api/watchlist", "returns": "{stocks[{code, name, price, change_pct, currency, analysis{...}}]}"},
{"method": "POST", "path": "/api/update/watchlist", "returns": "{status:'ok'}"}
],
"dependencies": [
"mo_data.py — read_watchlist()",
"mofin_db.py — query_watchlist(), write_watchlist_stock()",
"xiaoguo_signal_consumer.py — 消费小果信号,自动加自选"
],
"constraints": [
"自选股也必须标注 currency 字段(HKD/CNY"
],
"related_files": [
"server.py — API 路由定义",
"mo_data.py — read_watchlist()",
"mofin_db.py — watchlist_stocks 表"
]
}
}
+277
View File
@@ -0,0 +1,277 @@
<!DOCTYPE html>
<html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>MoFin Dashboard</title>
<style>
:root{--bg:#0d1117;--card:#161b22;--border:#30363d;--text:#c9d1d9;--dim:#8b949e;--accent:#58a6ff;--green:#3fb950;--red:#f85149;--yellow:#d29922}
*{margin:0;padding:0;box-sizing:border-box}body{background:var(--bg);color:var(--text);font:20px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;padding:28px}
h1{font-size:32px;font-weight:600;color:var(--accent);margin-bottom:4px}.subtitle{color:var(--dim);font-size:18px;margin-bottom:20px}
.tabs{display:flex;gap:0;margin-bottom:20px;border-bottom:1px solid var(--border);flex-wrap:wrap}
.tab-btn{padding:12px 24px;cursor:pointer;font-size:19px;color:var(--dim);border:none;background:none;border-bottom:2px solid transparent}
.tab-btn:hover{color:var(--text)}.tab-btn.active{color:var(--accent);border-bottom-color:var(--accent)}
.tab-pane{display:none}.tab-pane.active{display:block}
.stats{display:flex;gap:12px;margin-bottom:20px;flex-wrap:wrap}
.stat-c{background:var(--card);border:1px solid var(--border);border-radius:8px;padding:12px 16px;flex:1;min-width:120px}
.stat-c .n{font-size:42px;font-weight:700}.stat-c .l{font-size:18px;color:var(--dim)}
.stat-c.g .n{color:var(--green)}.stat-c.r .n{color:var(--red)}.stat-c.y .n{color:var(--yellow)}
.section{background:var(--card);border:1px solid var(--border);border-radius:8px;padding:14px 16px;margin-bottom:12px}
.section h2{font-size:19px;font-weight:600;color:var(--accent);display:flex;align-items:center}
.help-btn{display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;min-width:20px;border-radius:50%;border:1px solid var(--border);background:var(--card);color:var(--dim);cursor:pointer;font-size:14px;line-height:1;margin-left:8px;flex-shrink:0}
.help-btn:hover{border-color:var(--accent);color:var(--accent)}
.help-btn.ai{color:var(--yellow);border-color:var(--yellow);font-size:12px}
.help-btn.ai:hover{background:var(--yellow);color:#000}
.spec-modal{position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.7);z-index:1000;display:none;align-items:center;justify-content:center}
.spec-modal.show{display:flex}
.spec-modal .modal-box{background:var(--card);border:1px solid var(--border);border-radius:12px;max-width:700px;max-height:80vh;overflow-y:auto;padding:28px;margin:20px}
.spec-modal .modal-box h3{font-size:22px;color:var(--accent);margin-bottom:12px}
.spec-modal .modal-box .close{float:right;cursor:pointer;color:var(--dim);font-size:22px}
.spec-modal .modal-box .close:hover{color:var(--text)}
.spec-modal .modal-box h4{font-size:19px;color:var(--accent);margin:16px 0 6px}
.spec-modal .modal-box p{font-size:18px;color:var(--text);margin:4px 0}
.spec-modal .modal-box ul{margin:4px 0 4px 20px}
.spec-modal .modal-box li{font-size:18px;color:var(--text);margin:2px 0}
.spec-modal .modal-box pre{background:var(--bg);padding:12px;border-radius:6px;font-size:15px;overflow-x:auto;margin:8px 0}
.svc-row{display:flex;align-items:center;gap:8px;padding:8px 0;border-bottom:1px solid var(--border)}
.svc-row:last-child{border-bottom:none}
.svc-dot{width:6px;height:6px;border-radius:50%;flex-shrink:0}
.svc-dot.ok{background:var(--green)}.svc-dot.fail{background:var(--red)}
.svc-name{flex:1;min-width:0;font-size:18px}
.svc-status{font-size:16px;font-weight:600}
.svc-status.ok{color:var(--green)}.svc-status.fail{color:var(--red)}
.svc-meta{font-size:16px;color:var(--dim)}
.sub-bar{display:flex;gap:2px;margin-bottom:10px;flex-wrap:wrap}
.sub-btn{padding:4px 12px;font-size:18px;background:0 0;border:1px solid transparent;border-radius:12px;color:var(--dim);cursor:pointer}
.sub-btn:hover{color:var(--text);border-color:var(--border)}.sub-btn.active{color:var(--accent);border-color:var(--accent);background:var(--bg)}
.toast{position:fixed;top:16px;right:16px;padding:10px 16px;border-radius:6px;font-size:19px;z-index:999;opacity:0;pointer-events:none;transition:opacity .3s}
.toast.show{opacity:1}.toast.ok{background:#238636;color:#fff}.toast.err{background:#da3633;color:#fff}
.tag{display:inline-block;padding:1px 8px;border-radius:8px;font-size:16px;font-weight:600;margin-left:6px}
.tag.g{background:#3fb95020;color:var(--green)}.tag.r{background:#f8514920;color:var(--red)}
.tag.y{background:#d2992220;color:var(--yellow)}
.task-row{display:flex;align-items:center;gap:10px;padding:6px 0;border-bottom:1px solid var(--border)}
.task-row:last-child{border-bottom:none}
.mono{font-family:"Cascadia Code",Consolas,monospace;font-size:16px}
.section p,.section li{font-size:18px;line-height:1.8}.section code{font-size:18px}
</style></head><body>
<h1>MoFin</h1><div class="subtitle">持仓情报系统 · Dashboard</div>
<div class="tabs" id="tabs"></div><div id="panes"></div>
<div id="toast" class="toast"></div>
<div id="spec-modal" class="spec-modal"><div class="modal-box" id="spec-modal-box"></div></div>
<script>
var T=[
{id:"services",label:"Services"},
{id:"principles",label:"开发原则",children:[
{id:"spec",label:"G 规范"},
{id:"health",label:"F 健康"}
]}
];
var at=localStorage.getItem('mt')||'services',as=localStorage.getItem('ms')||'spec';
function esc(s){return String(s||'').replace(/&/g,'&amp;').replace(/</g,'<').replace(/>/g,'>')}
function qs(id){return document.getElementById(id)}
function toast(m,t){var e=qs('toast');e.textContent=m;e.className='toast show '+(t||'ok');setTimeout(function(){e.className='toast'},2500)}
// ---- Spec System ----
var _specCache={};
function showModuleHelp(module,type){
var modal=qs('spec-modal');var box=qs('spec-modal-box');
if(_specCache[module]){_renderSpecModal(_specCache[module],type);modal.classList.add('show');return}
fetch('/api/module-spec/'+module).then(function(r){return r.json()}).then(function(spec){
_specCache[module]=spec;_renderSpecModal(spec,type);modal.classList.add('show');
}).catch(function(){
box.innerHTML='<span class="close" onclick="qs(\'spec-modal\').classList.remove(\'show\')">&times;</span><h3>Spec not found</h3><p>Module: '+esc(module)+'</p>';modal.classList.add('show');
});
}
function _renderSpecModal(spec,type){
var box=qs('spec-modal-box');var h='';
h+='<span class="close" onclick="qs(\'spec-modal\').classList.remove(\'show\')">&times;</span>';
if(type==='human'){
var hh=spec.human_help||{};
h+='<h3>'+esc(hh.title||spec.module||'Help')+'</h3>';
if(hh.description){h+='<h4>说明</h4><ul>';hh.description.forEach(function(d){h+='<li>'+esc(d)+'</li>'});h+='</ul>'}
if(hh.usage){h+='<h4>使用方法</h4><ul>';hh.usage.forEach(function(d){h+='<li>'+esc(d)+'</li>'});h+='</ul>'}
if(hh.troubleshooting){h+='<h4>常见问题</h4><ul>';hh.troubleshooting.forEach(function(d){h+='<li>'+esc(d)+'</li>'});h+='</ul>'}
}else{
var ai=spec.ai_spec||{};
h+='<h3>§ AI Spec — '+esc(spec.module||'')+'</h3>';
h+='<p style="color:var(--dim)">'+esc(spec.purpose||'')+'</p>';
if(ai.apis){h+='<h4>API</h4><ul>';ai.apis.forEach(function(a){h+='<li><code>'+esc(a.method)+' '+esc(a.path)+'</code> — '+esc(a.returns||'')+'</li>'});h+='</ul>'}
if(ai.dependencies){h+='<h4>依赖</h4><ul>';ai.dependencies.forEach(function(d){h+='<li>'+esc(d)+'</li>'});h+='</ul>'}
if(ai.constraints){h+='<h4>关键约束</h4><ul>';ai.constraints.forEach(function(d){h+='<li style="color:var(--yellow)">'+esc(d)+'</li>'});h+='</ul>'}
if(ai.must_not){h+='<h4>禁止行为</h4><ul>';ai.must_not.forEach(function(d){h+='<li style="color:var(--red)">'+esc(d)+'</li>'});h+='</ul>'}
if(ai.related_files){h+='<h4>相关文件</h4><ul>';ai.related_files.forEach(function(f){h+='<li><code>'+esc(f)+'</code></li>'});h+='</ul>'}
}
box.innerHTML=h;
}
// ---- Tab System ----
function init(){
var b='',p='';
for(var i=0;i<T.length;i++){
var t=T[i],a=t.id===at||(t.children&&t.children.some(function(c){return c.id===at}));
b+='<button class="tab-btn'+(a?' active':'')+'" onclick="sw(\''+t.id+'\')">'+t.label+'</button>';
if(t.children){
p+='<div class="tab-pane'+(a?' active':'')+'" id="pn-'+t.id+'"><div class="sub-bar">';
for(var j=0;j<t.children.length;j++){
var c=t.children[j];
p+='<button class="sub-btn'+(c.id===as?' active':'')+'" onclick="sws(\''+c.id+'\')">'+c.label+'</button>';
}
p+='</div>';
for(var j=0;j<t.children.length;j++){
var c=t.children[j];
p+='<div id="sp-'+c.id+'"'+(c.id===as?'':' style="display:none"')+'><div id="ct-'+c.id+'"><span style="color:var(--dim)">Loading...</span></div></div>';
}
p+='</div>';
}else{
p+='<div class="tab-pane'+(a?' active':'')+'" id="pn-'+t.id+'"><div id="ct-'+t.id+'"><span style="color:var(--dim)">Loading...</span></div></div>';
}
}
qs('tabs').innerHTML=b;qs('panes').innerHTML=p;
}
function sw(id){
if(id==='principles'){at='principles';as=localStorage.getItem('ms')||'spec'}
else{at=id;localStorage.setItem('mt',id)}
document.querySelectorAll('.tab-btn').forEach(function(b){b.classList.toggle('active',b.textContent.trim()===T.find(function(t){return t.id===at||(t.children&&t.children.some(function(c){return c.id===at}))}).label)});
document.querySelectorAll('.tab-pane').forEach(function(p){p.classList.toggle('active',p.id==='pn-'+id)});
if(id==='principles'){document.querySelectorAll('.sub-btn').forEach(function(b){b.classList.toggle('active',b.textContent.trim()===T[1].children.find(function(c){return c.id===as}).label)});document.querySelectorAll('[id^="sp-"]').forEach(function(p){p.style.display=p.id==='sp-'+as?'':'none'});(FETCHES[as]||function(){})()}
}
function sws(id){as=id;localStorage.setItem('ms',id);document.querySelectorAll('.sub-btn').forEach(function(b){b.classList.toggle('active',b.textContent.trim()===T[1].children.find(function(c){return c.id===as}).label)});document.querySelectorAll('[id^="sp-"]').forEach(function(p){p.style.display=p.id==='sp-'+id?'':'none'});(FETCHES[id]||function(){})()}
function fill(id,h){var e=qs('ct-'+id);if(e)e.innerHTML=h}
// ====== Services Tab ======
async function loadServices(){
try{
var r=await fetch('/api/services'),d=await r.json();
var svcs=d.services||[],ok=d.summary.ok||0,total=d.summary.total||0;
var hasIssue=ok<total,allOk=ok===total;
var h='<h2>服务状态<span class="help-btn" onclick="showModuleHelp(\'dashboard\',\'human\')" title="使用说明">?</span><span class="help-btn ai" onclick="showModuleHelp(\'dashboard\',\'ai\')" title="AI Spec">§</span></h2>';
h+='<div class="stats"><div class="stat-c '+(allOk?'g':'r')+'"><div class="n">'+ok+'/'+total+'</div><div class="l">Services Online</div></div></div>';
h+='<div class="section"><h2>全部服务</h2>';
// Group by layer
var layers={};
svcs.forEach(function(s){
var l=s.layer||'Other';
if(!layers[l])layers[l]=[];
layers[l].push(s);
});
var layerOrder=['通信层','核心服务','AI 网关','数据层','Other'];
layerOrder.forEach(function(ln){
if(!layers[ln])return;
h+='<div style="margin-bottom:10px"><div style="color:var(--dim);font-size:16px;margin-bottom:4px">'+esc(ln)+'</div>';
layers[ln].forEach(function(s){
var hOk=s.health&&s.health.ok;
h+='<div class="svc-row">';
h+='<div class="svc-dot '+(hOk?'ok':'fail')+'"></div>';
h+='<div class="svc-name">'+esc(s.label||s.name);
if(s.port)h+=' <span class="mono">:'+s.port+'</span>';
h+='</div>';
h+='<div class="svc-meta">'+esc(s.type)+'</div>';
h+='<div class="svc-status '+(hOk?'ok':'fail')+'">'+(hOk?'UP':'DOWN')+'</div>';
h+='<span class="help-btn" onclick="showModuleHelp(\''+esc(s.name)+'\',\'human\')" title="帮助">?</span>';
h+='<span class="help-btn ai" onclick="showModuleHelp(\''+esc(s.name)+'\',\'ai\')" title="AI Spec">§</span>';
h+='</div>';
});
h+='</div>';
});
h+='</div>';
fill('services',h);
}catch(e){fill('services','<div class="section"><span style="color:var(--red)">Failed to load services: '+esc(e.message)+'</span></div>')}
}
// ====== G 规范 Tab ======
async function loadSpec(){
var h='<div class="section">';
h+='<h2>开发规范<span class="help-btn" onclick="showModuleHelp(\'dashboard\',\'human\')" title="说明">?</span></h2>';
h+='<p>MoFin 开发规范定义了五条红线和 spec 双轨同源体系。</p>';
h+='<h4 style="color:var(--accent);margin-top:12px">五条红线</h4>';
h+='<ol><li>先读/写 Spec,再写代码</li><li>部署必验</li><li>不可见即不存在</li><li>实现后同步 Spec</li><li>部署目标即验收标准(Linux 246</li></ol>';
h+='<h4 style="color:var(--accent);margin-top:12px">模块 Spec 清单</h4>';
h+='<p>以下模块已有 spec 文件,点击 ?§ 查看详情:</p>';
var modules=[
{id:'portfolio',label:'持仓管理'},
{id:'watchlist',label:'自选股'},
{id:'decisions',label:'策略决策库'},
{id:'market',label:'市场观察'},
{id:'signals',label:'信号与扫描'},
{id:'evaluation',label:'策略评估'},
{id:'dashboard',label:'Dashboard'},
{id:'health',label:'健康监控'}
];
h+='<div style="margin-top:8px">';
modules.forEach(function(m){
h+='<div style="display:flex;align-items:center;gap:8px;padding:4px 0">';
h+='<code style="flex:1">'+esc(m.id)+'</code><span style="color:var(--dim)">'+esc(m.label)+'</span>';
h+='<span class="help-btn" onclick="showModuleHelp(\''+esc(m.id)+'\',\'human\')" title="人类帮助">?</span>';
h+='<span class="help-btn ai" onclick="showModuleHelp(\''+esc(m.id)+'\',\'ai\')" title="AI Spec">§</span>';
h+='</div>';
});
h+='</div>';
h+='<p style="margin-top:12px;font-size:16px;color:var(--dim)">完整规范见 <code>docs/dev-spec.md</code> 和 <code>docs/DEVELOPMENT_STANDARDS.md</code></p>';
h+='</div>';
fill('spec',h);
}
// ====== F 健康 Tab ======
async function loadHealth(){
try{
var r=await fetch('/api/monitor'),d=await r.json();
var tasks=d.tasks||[],tier1=d.tier1||{},tier2=d.tier2||{};
var h='<h2>系统健康<span class="help-btn" onclick="showModuleHelp(\'health\',\'human\')" title="使用说明">?</span><span class="help-btn ai" onclick="showModuleHelp(\'health\',\'ai\')" title="AI Spec">§</span></h2>';
// Summary
var t1Ok=tier1.summary?tier1.summary.ok||0:0;
var t1Total=tier1.summary?tier1.summary.total||0:0;
var allOk=t1Ok===t1Total&&t1Total>0;
h+='<div class="stats"><div class="stat-c '+(allOk?'g':'y')+'"><div class="n">'+t1Ok+'/'+t1Total+'</div><div class="l">Tier1 通过</div></div></div>';
// Tasks
h+='<div class="section"><h2>定时任务</h2>';
tasks.forEach(function(t){
var cls=t.status==='cron_ok'||t.status==='running'?'g':(t.status==='not_deployed'?'y':'r');
var label=t.status==='cron_ok'?'正常':(t.status==='running'?'运行中':(t.status==='not_deployed'?'未部署':t.status));
h+='<div class="task-row">';
h+='<div class="tag '+cls+'">'+esc(label)+'</div>';
h+='<div>'+esc(t.name)+'</div>';
if(t.detail)h+='<div style="color:var(--dim);font-size:16px">'+esc(t.detail)+'</div>';
h+='</div>';
});
h+='</div>';
// Tier1 details
if(tier1.services&&tier1.services.length){
h+='<div class="section"><h2>Tier1 服务详情</h2>';
tier1.services.forEach(function(s){
var ok=s.health&&s.health.ok;
h+='<div class="svc-row"><div class="svc-dot '+(ok?'ok':'fail')+'"></div><div class="svc-name">'+esc(s.label||s.name)+'</div><div class="svc-status '+(ok?'ok':'fail')+'">'+(ok?'OK':'FAIL')+'</div></div>';
});
h+='</div>';
}
h+='<p style="font-size:16px;color:var(--dim);margin-top:8px">生成时间: '+esc(d.generated_at||'')+'</p>';
fill('health',h);
}catch(e){fill('health','<div class="section"><span style="color:var(--red)">Failed to load health data: '+esc(e.message)+'</span></div>')}
}
// ---- Fetch dispatch ----
var FETCHES={'services':loadServices,'spec':loadSpec,'health':loadHealth};
// ---- Auto-refresh ----
function autoRefresh(){
var activeTab=localStorage.getItem('mt')||'services';
if(activeTab==='services')loadServices();
else if(activeTab==='principles'){
var sub=localStorage.getItem('ms')||'spec';
if(sub==='health')loadHealth();
}
}
// ---- Boot ----
init();
// Load initial tab
var tab=localStorage.getItem('mt')||'services';
if(tab==='services')loadServices();
else if(tab==='principles'){
var sub=localStorage.getItem('ms')||'spec';
if(sub==='spec')loadSpec();
else if(sub==='health')loadHealth();
}
// Auto-refresh every 5 seconds
setInterval(autoRefresh,5000);
</script></body></html>