Files
MediaCrawler/mac-agent-os-main/05_tools/10_dashboard/app.py
T
butubb 2112c1a870
Deploy VitePress site to Pages / build (push) Canceled after 0s
Deploy VitePress site to Pages / Deploy (push) Canceled after 0s
feat(monitor): 抖音 Web 接口客户端 —— 绕开爬虫子进程,直接发 HTTP
移植自 mac-agent-os 的 mediacrawler_adapter:不起子进程、不开页面,用浏览器里那份
登录态直接调抖音 Web 接口。产物键名照抄 store/douyin,所以 ingest 那条链路一个字不用改。

**目前能用的(真环境实测,非推断)**:

    profile/other : 200, 7075 字节  —— 博主主页指标(粉丝/获赞/作品数/昵称)
    aweme/detail  : 200, 45425 字节 —— 单条作品详情(含点赞/评论/收藏/分享)

**目前不能用的:作品列表 `aweme/post`。** 两个互相独立的原因:
  1. 这个接口被抖音单独升级成了真校验:不带 x-tt-argus 回 403「Uifid Not Found」,
     带上 dummy 值回 200 + **空 body**。也就是说「头在不在」骗得过,「真校验」过不了。
     同一套头打 profile/other 和 aweme/detail 都是通的 —— 抖音是挑着接口加保护的,
     挑中的恰好是「批量拉作品列表」这个最敏感的动作。
  2. 改走页面截获也不行:CDP 浏览器打开博主主页会落到「验证码中间页」(当天大量探测的
     代价,过几小时要重测)。

  所以现在的边界是:**已知作品的指标刷新能做,自动发现新作品做不了**。

**排查中控住变量后得到的两条事实**(都写进注释了):
  · `Accept` / `Accept-Language` / `Referer` 才是主页接口能返回真数据的原因 —— 只有
    UA+client hints+Cookie 时是 200 但仅 121 字节的空壳,补上这三个头变 7074 字节。
    (我先前猜的 sec-ch-ua 不是关键。)
  · 因此 UA 与 client hints 必须**成套地取自同一个浏览器**,所以 BrowserIdentity 一次
    从 CDP 取齐 cookie + UA + hints,而不是各自写死。

「200 + 空 body 必须当场报错」也是刻意写死的:放过去它会在下游变成「这个博主没作品」,
把一次失败伪装成一条正常结果 —— 爬虫那条路正是这么栽的,还被翻译成「账号被封」。

测试 +11:cookie 解析、请求头成套性(含 uifid 缺失/回退)、产物键名与 store 对齐、
以及 _get 的三条失败路径(空 body / 403 带网关原话 / 正常返回)。
2026-10-10 17:12:25 +08:00

1805 lines
70 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
系统监控面板 — FastAPI 后端 (v2.0 联邦协同版)
架构:
插件式数据源架构 + 联邦多机标签。
每个数据点都标注来源机器 hostname,支持 ?plugin= 切换数据源。
联邦数据模型:
- 本机数据 (agent-local/) → 带 source_hostname 标注
- 共享数据 (agent-sync/) → 天然跨机器可用
- 前端渲染时按 hostname 分组展示
API:
GET /api/plugins — 本机插件列表
GET /api/summary — 聚合总览 (每插件+每机器)
GET /api/productions — 生产列表 (?plugin= 切换数据源)
GET /api/productions/{id} — 生产详情
GET /api/assets — 资产列表 (?plugin= 切换数据源)
GET /api/assets/search — 高级资产搜索
GET /api/assets/tags — 标签云
GET /api/assets/stats — 素材库统计
GET /api/assets/disk — 磁盘占用
GET /api/costs/breakdown — 费用分析 (?plugin= 切换数据源)
GET /api/machines — 联邦机器状态 (来自 guardd 插件)
GET /api/health — 健康检查
"""
import sys, os, json, time, logging
from pathlib import Path
from typing import Optional
from datetime import datetime, timezone
logger = logging.getLogger("dashboard")
# ── 联邦身份 ───────────────────────────────────────────────
from utils.identity import resolve_hostname
HOSTNAME = resolve_hostname()
# ── 添加 AVE scripts 目录到 sys.path ──────────────────────
_AVE_SCRIPTS = Path(__file__).resolve().parent.parent / "09_ave" / "scripts"
if str(_AVE_SCRIPTS) not in sys.path:
sys.path.insert(0, str(_AVE_SCRIPTS))
from fastapi import FastAPI, Query, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from plugins.base import DashboardPlugin, AGENT_SYNC, AGENT_LOCAL
# ── 联邦路径 ───────────────────────────────────────────────
_CROSS_MACHINE_DIR = Path(__file__).resolve().parent.parent.parent / "04_memory" / "cross_machine"
_PLUGINS: dict[str, DashboardPlugin] = {}
_AVAILABLE: dict[str, bool] = {}
def _register_plugins():
"""自动发现并注册所有插件"""
from plugins import discover_plugins
discovered = discover_plugins()
for name, inst in discovered.items():
try:
_PLUGINS[name] = inst
_AVAILABLE[name] = inst.is_available()
# 写入共享数据(加超时保护,防某些插件的 write_shared 卡死)
try:
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FutTimeout
with ThreadPoolExecutor(max_workers=1) as pool:
fut = pool.submit(inst.write_shared)
fut.result(timeout=10)
except FutTimeout:
logger.warning(f" 插件 {name} write_shared 超时,跳过")
except:
pass
except Exception as e:
_PLUGINS[name] = None
_AVAILABLE[name] = False
logger.warning(f" 插件 {name} 加载失败: {e}")
def _get_plugin(name: str) -> DashboardPlugin:
"""获取插件实例,失败抛 503"""
p = _PLUGINS.get(name)
if p is None or not _AVAILABLE.get(name, False):
raise HTTPException(503, detail=f"插件 {name} 不可用")
return p
# ── FastAPI App ────────────────────────────────────────────
app = FastAPI(title="系统监控面板", version="2.0.0",
description="联邦协同监控 — AVE / guardd (多机数据源)")
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True,
allow_methods=["*"], allow_headers=["*"])
_static_dir = Path(__file__).parent / "static"
_static_dir.mkdir(parents=True, exist_ok=True)
app.mount("/static", StaticFiles(directory=str(_static_dir)), name="static")
@app.get("/favicon.ico")
async def favicon():
return ""
@app.get("/")
def index():
index_path = _static_dir / "index.html"
from fastapi.responses import HTMLResponse
if index_path.exists():
content = index_path.read_text(encoding="utf-8")
return HTMLResponse(content=content, headers={
"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
"Pragma": "no-cache",
"Expires": "0",
})
return {"error": "index.html not found"}
# 模块加载时注册插件(移除了 @app.on_event("startup") 避免 uvicorn 0.48+ 不兼容)
_register_plugins()
# ═══════════════════════════════════════════════════════════
# 联邦基础 API
# ═══════════════════════════════════════════════════════════
@app.get("/api/plugins")
def api_plugins():
"""返回所有注册插件的元信息 (v2)"""
result = []
for p in sorted([p for p in _PLUGINS.values() if p], key=lambda p: p.order):
result.append({
"name": p.name, "label": p.label, "icon": p.icon,
"version": p.version, "description": p.description,
"order": p.order, "available": _AVAILABLE.get(p.name, False),
"sub_views": p.get_sub_views(),
"source_hostname": HOSTNAME,
})
return {"plugins": result, "total": len(result), "source_hostname": HOSTNAME}
# ═════════════════════════════════════════════════════════
# 插件导航 API(agentos 插件注册导航)
# ═════════════════════════════════════════════════════════
def _load_agentos_nav():
"""从 agentos 插件读取导航定义"""
import sys as _sys
from pathlib import Path as _Path
agentos_dir = _Path(__file__).resolve().parent.parent / "07_matrix" / "scripts" / "agentos"
if not agentos_dir.exists():
return []
_sys.path.insert(0, str(agentos_dir.parent))
try:
from agentos.base import discover_plugins
plugins = discover_plugins()
nav_data = []
for plugin_cls in plugins:
inst = plugin_cls()
if inst.nav:
nav_data.append(inst.nav)
# 按 order 排序
nav_data.sort(key=lambda x: x.get('order', 99))
return nav_data
except Exception as e:
return []
@app.get("/api/plugins/nav")
def api_plugins_nav():
"""返回 agentos 插件注册的导航结构"""
return {"nav": _load_agentos_nav()}
@app.get("/api/plugins/{name}/summary")
def api_plugin_summary(name: str):
"""返回指定插件的概览数据"""
inst = _get_plugin(name)
from plugins._registry import get_machine_list
try:
data = inst.summary(get_machine_list())
return {"plugin": name, "data": data, "source_hostname": HOSTNAME}
except Exception as e:
return {"plugin": name, "error": str(e)}
@app.get("/api/plugins/{name}/detail")
def api_plugin_detail(name: str, machine: str = ""):
"""返回指定插件的详细面板"""
inst = _get_plugin(name)
try:
data = inst.detail(machine)
return {"plugin": name, "machine": machine or "all", "data": data}
except Exception as e:
return {"plugin": name, "error": str(e)}
@app.get("/api/plugins/{name}/actions")
def api_plugin_actions(name: str):
"""返回指定插件的可执行操作"""
inst = _get_plugin(name)
try:
return {"plugin": name, "actions": inst.actions()}
except Exception as e:
return {"plugin": name, "actions": [], "error": str(e)}
@app.get("/api/summary")
def api_summary():
"""聚合总览 (所有插件 summary + 各机器心跳)"""
result = {}
for name, inst in sorted(_PLUGINS.items(), key=lambda x: x[1].order if x[1] else 99):
if inst is None:
continue
if name == 'matrix':
# matrix 的 summary 遍历所有养号账号耗时 ~6秒,且前端未使用其数据
result[name] = {"meta": {"label": inst.label, "icon": inst.icon, "version": inst.version}, "data": {}}
continue
try:
from plugins._registry import get_machine_list
result[name] = {
"meta": {"label": inst.label, "icon": inst.icon, "version": inst.version},
"data": inst.summary(get_machine_list()),
}
except Exception as e:
result[name] = {"meta": {"label": inst.label}, "error": str(e)}
return {"plugins": result, "source_hostname": HOSTNAME}
@app.get("/api/identity")
def api_identity():
"""返回本机身份信息 (顶栏用)"""
from plugins.base import MACHINE_UID, HOSTNAME, AGENT_SYNC
import subprocess
git_ver = ""; git_repo = ""
try:
r = subprocess.run(["git","log","-1","--format=%h %ci"], capture_output=True, text=True,
timeout=5, cwd=str(AGENT_SYNC))
git_ver = r.stdout.strip()
r2 = subprocess.run(["git","remote","get-url","origin"], capture_output=True, text=True,
timeout=5, cwd=str(AGENT_SYNC))
git_repo = r2.stdout.strip().split("/")[-1].replace(".git","")
except: pass
role = "工作站"
reg_dir = _CROSS_MACHINE_DIR / "registry"
if reg_dir.exists():
for f in reg_dir.iterdir():
if f.suffix == ".json":
try:
d = json.loads(f.read_text())
if d.get("hostname","") == HOSTNAME or MACHINE_UID[:8] in f.name:
role = d.get("role", "工作站")
except: pass
return {"hostname": HOSTNAME, "uid": MACHINE_UID[:12]+"...", "role": role,
"git_version": git_ver, "git_repo": git_repo}
# ═══════════════════════════════════════════════════════════
# 生产列表 (兼容旧版 → 委托给插件 detail)
# ═══════════════════════════════════════════════════════════
@app.get("/api/productions")
def api_productions(
plugin: str = Query("ave", description="数据源插件名"),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
strategy: Optional[str] = Query(None),
status: Optional[str] = Query(None),
):
"""生产列表 (v4 兼容)"""
from plugins._registry import get_machine_list
p = _get_plugin(plugin)
try:
data = p.detail()
# 统一转成生产记录列表
items = []
if isinstance(data, dict):
for hn, info in data.items():
prods = []
if isinstance(info, dict):
prods = info.get("productions", info.get("productions", []))
# 如果 info 本身不是 production 格式, 尝试直接放行
if not prods and "管线" in info:
prods = []
for prod in prods:
if isinstance(prod, dict):
prod["_source_hostname"] = hn
items.append(prod)
return {"data": items, "source_hostname": HOSTNAME, "plugin": plugin}
except Exception as e:
return {"data": [], "source_hostname": HOSTNAME, "plugin": plugin, "error": str(e)}
@app.get("/api/productions/{production_id}")
def api_production_detail(
production_id: str,
plugin: str = Query("ave", description="数据源插件名"),
):
"""生产详情 (v4 兼容)"""
p = _get_plugin(plugin)
try:
data = p.detail()
if isinstance(data, dict):
for hn, info in data.items():
if production_id in str(hn) or production_id in str(info):
return {"data": info, "source_hostname": hn, "plugin": plugin}
return {"data": None, "plugin": plugin}
except Exception as e:
return {"data": None, "plugin": plugin, "error": str(e)}
# ═══════════════════════════════════════════════════════════
# 资产 (AVE 专有)
# ═══════════════════════════════════════════════════════════
ASSET_SEARCH_AVAILABLE = False
_asset_search = None
try:
from asset_manager.tags import AssetSearch
_asset_search = AssetSearch()
ASSET_SEARCH_AVAILABLE = True
except ImportError:
pass
@app.get("/api/assets")
def api_assets(
plugin: str = Query("ave", description="数据源插件名"),
type: Optional[str] = Query(None, alias="type"),
tag: Optional[str] = Query(None),
limit: int = Query(100, ge=1, le=500),
offset: int = Query(0, ge=0),
):
"""资产列表,标注数据来源"""
p = _get_plugin(plugin)
from lib.dashboard import get_assets as _get_assets
data = _get_assets(asset_type=type, tag=tag, limit=limit, offset=offset)
if isinstance(data, dict):
data["_source_hostname"] = HOSTNAME
elif isinstance(data, list):
for item in data:
if isinstance(item, dict) and "_source_hostname" not in item:
item["_source_hostname"] = HOSTNAME
return {"data": data, "source_hostname": HOSTNAME, "plugin": plugin}
@app.get("/api/assets/search")
def api_assets_search(
plugin: str = Query("ave", description="数据源插件名"),
keyword: str = Query(""),
asset_type: Optional[str] = Query(None, alias="type"),
source: Optional[str] = Query(None),
tag: Optional[str] = Query(None),
min_duration: float = Query(0.0), max_duration: float = Query(0.0),
limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0),
):
if not ASSET_SEARCH_AVAILABLE or _asset_search is None:
raise HTTPException(501, detail="asset_manager not installed")
tags = [tag] if tag else None
results = _asset_search.search(keyword=keyword, asset_type=asset_type,
source=source, tags=tags, min_duration=min_duration,
max_duration=max_duration, limit=limit, offset=offset)
return {"total": len(results), "results": results, "source_hostname": HOSTNAME, "plugin": plugin}
@app.get("/api/assets/tags")
def api_assets_tags():
if not ASSET_SEARCH_AVAILABLE:
raise HTTPException(501, detail="asset_manager not installed")
return {"tags": _asset_search.tag_stats(), "source_hostname": HOSTNAME}
@app.get("/api/assets/stats")
def api_assets_stats():
if not ASSET_SEARCH_AVAILABLE:
raise HTTPException(501, detail="asset_manager not installed")
from asset_manager.index import AssetIndex
return {**AssetIndex().summarize(), "_source_hostname": HOSTNAME}
@app.get("/api/assets/disk")
def api_assets_disk():
if not ASSET_SEARCH_AVAILABLE:
raise HTTPException(501, detail="asset_manager not installed")
from asset_manager.cache import CacheManager
return {**CacheManager().disk_usage(), "_source_hostname": HOSTNAME}
# ═══════════════════════════════════════════════════════════
# 角色管理 API (AVE 插件数据)
# ═══════════════════════════════════════════════════════════
@app.get("/api/characters")
def api_characters():
"""角色管理:列出所有已注册角色"""
p = _PLUGINS.get("ave")
if p is None or not _AVAILABLE.get("ave", False):
return {"characters": {}, "active": "", "total": 0, "error": "ave plugin unavailable",
"_source_hostname": HOSTNAME}
try:
data = p.get_characters()
data["_source_hostname"] = HOSTNAME
return data
except Exception as e:
return {"characters": {}, "active": "", "total": 0, "error": str(e),
"_source_hostname": HOSTNAME}
# ── 角色生成/定制 API ──
@app.post("/api/characters/generate-portrait")
def api_generate_portrait(data: dict):
"""为已有角色生成定妆照(工作流编辑器调用)"""
character_name = data.get("character_name", "")
description = data.get("description", "")
force = data.get("force", False)
seed = data.get("seed", 0)
if not character_name:
raise HTTPException(400, detail="缺少 character_name")
try:
from character_generator.pipeline import CharacterGenerationPipeline
pipeline = CharacterGenerationPipeline()
result = pipeline.generate_portrait(character_name, force=force, seed=seed)
return {"status": "ok", "name": character_name, "result": result}
except Exception as e:
logger.error(f"定妆照生成失败: {e}")
raise HTTPException(500, detail=f"生成失败: {str(e)}")
@app.post("/api/characters/generate-from-direction")
def api_generate_from_direction(data: dict):
"""
从粗方向生成完整角色(方向→扩展→属性→生成→注册)
请求:
direction: str — 粗方向描述
name: str (可选) — 角色名
generate_images: bool (默认 true) — 是否生成图像
seed: int (可选) — 固定种子
layers: dict (可选) — 前端模块化面板的逐层数据
返回:
{name, description, attributes, baseline, angles, expressions, registered}
"""
direction = data.get("direction", "")
name = data.get("name", "")
generate_images = data.get("generate_images", True)
seed = data.get("seed", 0)
layers = data.get("layers", {}) # 新增:前端模块化面板的逐层数据
if not direction:
raise HTTPException(400, detail="缺少 direction")
try:
from character_generator.pipeline import CharacterGenerationPipeline
pipeline = CharacterGenerationPipeline()
result = pipeline.run_full(
direction, character_name=name,
generate_variants=generate_images, seed=seed,
)
return {"status": "ok", **result}
except Exception as e:
logger.error(f"角色生成失败: {e}")
raise HTTPException(500, detail=f"角色生成失败: {str(e)}")
@app.get("/api/portrait-files/{character_name}/{file_name}")
def api_portrait_file(character_name: str, file_name: str):
"""提供角色定妆照图片文件"""
# 支持多种可能的路径
_assets_dir = _AVE_SCRIPTS / "character_registry" / "assets"
possible_dirs = [
_assets_dir / character_name,
_assets_dir / character_name.replace(" ", "_"),
_assets_dir / character_name.lower(),
]
for d in possible_dirs:
if d.exists():
for f in d.iterdir():
if f.name == file_name or f.name == file_name.replace("_grid_", "_"):
return FileResponse(str(f), media_type="image/png" if f.suffix == ".png" else "image/jpeg")
# 没找到精确匹配,返回第一个图片作为兜底
for f in d.iterdir():
if f.suffix in (".png", ".jpg", ".jpeg"):
return FileResponse(str(f), media_type="image/png" if f.suffix == ".png" else "image/jpeg")
# 无任何文件,返回空响应
return FileResponse(str(_static_dir / "favicon.ico")) if (_static_dir / "favicon.ico").exists() else ""
@app.post("/api/characters/update")
def api_update_character(data: dict):
"""更新角色属性"""
name = data.get("name", "")
if not name:
raise HTTPException(400, detail="缺少 name")
try:
from character_generator.asset_registrar import AssetRegistrar
registrar = AssetRegistrar()
attrs = data.get("attributes", data)
registrar.register_character_properties(name, attrs)
return {"status": "ok", "name": name}
except Exception as e:
raise HTTPException(500, detail=str(e))
@app.get("/api/characters/capabilities")
def api_character_capabilities():
"""返回角色生成引擎支持的变体类型"""
from character_generator.prompt_assembler import PromptAssembler
assembler = PromptAssembler()
return {"variants": assembler.get_available_variants()}
@app.post("/api/characters/expand-direction")
def api_expand_direction(data: dict):
"""仅扩展方向描述,不生成图像"""
direction = data.get("direction", "")
if not direction:
raise HTTPException(400, detail="缺少 direction")
try:
from character_generator.direction_expander import DirectionExpander
expander = DirectionExpander()
description = expander.expand(direction)
return {"direction": direction, "description": description}
except Exception as e:
# 回退到直接使用原始方向
return {"direction": direction, "description": direction}
# ═══════════════════════════════════════════════════════════
# 原子能力 API (AVE 插件数据)
# ═══════════════════════════════════════════════════════════
@app.get("/api/capabilities")
def api_capabilities():
"""原子能力:列出 AVE 所有底层能力"""
p = _PLUGINS.get("ave")
if p is None or not _AVAILABLE.get("ave", False):
return {"groups": [], "total_items": 0, "error": "ave plugin unavailable",
"_source_hostname": HOSTNAME}
try:
data = p.get_capabilities()
data["_source_hostname"] = HOSTNAME
return data
except Exception as e:
return {"groups": [], "total_items": 0, "error": str(e),
"_source_hostname": HOSTNAME}
# ═══════════════════════════════════════════════════════════
# 费用分析
# ═══════════════════════════════════════════════════════════
@app.get("/api/costs/breakdown")
def api_cost_breakdown(
plugin: str = Query("ave", description="数据源插件名"),
):
"""费用统计 (v4 兼容)"""
p = _get_plugin(plugin)
try:
data = p.detail()
return {"data": data, "source_hostname": HOSTNAME, "plugin": plugin}
except Exception as e:
return {"data": [], "source_hostname": HOSTNAME, "plugin": plugin, "error": str(e)}
# ═══════════════════════════════════════════════════════════
# 联邦机器状态 (统一走 guardd 插件)
# ═══════════════════════════════════════════════════════════
@app.get("/api/machines")
def api_machines():
"""联邦机器状态 (v2) — 委托给 guardd 插件"""
p = _PLUGINS.get("guardd")
if p is None or not _AVAILABLE.get("guardd", False):
return {"machines": [], "total": 0, "error": "guardd plugin unavailable"}
try:
from plugins._registry import get_machine_list
data = p.summary(get_machine_list())
detail = p.detail()
machines = []
for hn, info in detail.items():
m = {"hostname": hn}
m.update(info)
m["_source_hostname"] = HOSTNAME
machines.append(m)
return {"machines": machines, "total": len(machines)}
except Exception as e:
return {"machines": [], "total": 0, "error": str(e)}
# ═══════════════════════════════════════════════════════════
# 健康检查
# ═══════════════════════════════════════════════════════════
@app.post("/api/git-sync")
def api_git_sync():
"""执行 git pull 拉取所有机器最新数据"""
import subprocess
from plugins.base import AGENT_SYNC
try:
r = subprocess.run(["git", "pull"], capture_output=True, text=True,
timeout=30, cwd=str(AGENT_SYNC))
return {"success": r.returncode == 0, "output": r.stdout[-200:]}
except Exception as e:
return {"success": False, "error": str(e)}
@app.get("/matrix-mgmt")
async def matrix_mgmt_page():
"""返回 matrix_mgmt.html 内容(旧版独立管理页)"""
path = _static_dir / "matrix_mgmt.html"
if not path.exists():
from starlette.responses import RedirectResponse
return RedirectResponse(url="/")
from starlette.responses import HTMLResponse
return HTMLResponse(content=path.read_text(encoding="utf-8"), status_code=200)
@app.get("/api/matrix/cross-machines")
def api_matrix_cross_machines():
"""跨机器矩阵总览 - 聚合各机器账号状态"""
import sys
from pathlib import Path
_mgr_path = Path(__file__).resolve().parent.parent / "07_matrix" / "scripts" / "matrix_mgmt.py"
try:
import importlib.util
spec = importlib.util.spec_from_file_location("matrix_mgmt", _mgr_path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
mgr = mod.MatrixManager()
accounts = mgr.list_accounts() or []
except Exception:
accounts = []
# 按owner_machine分组
machines = {}
for a in accounts:
m = a.get("owner_machine", "未分配")
if m not in machines:
machines[m] = {"hostname": m, "accounts": [], "total": 0, "logged_in": 0, "enabled": 0}
machines[m]["total"] += 1
if a.get("_status") == "logged_in" or a.get("has_cookie"):
machines[m]["logged_in"] += 1
if a.get("enabled"):
machines[m]["enabled"] += 1
return {
"total_machines": len(machines),
"total_accounts": len(accounts),
"machines": [{"hostname": k, "total": v["total"], "logged_in": v["logged_in"], "enabled": v["enabled"]} for k, v in machines.items()],
}
@app.get("/api/health")
async def health():
return {
"status": "ok",
"version": "2.0.0",
"hostname": HOSTNAME,
"plugins": {n: "available" if _AVAILABLE.get(n) else "unavailable" for n in _PLUGINS},
}
# ═══════════════════════════════════════════════════════════
# 联邦远程状态 API (供 mc remote 调用 / Tailscale 通道)
# ═══════════════════════════════════════════════════════════
@app.get("/api/machine/status")
def api_machine_status():
"""返回本机完整状态 (供 mc remote status 远程查询)"""
import subprocess, shutil
# 系统信息
uname = os.uname()
boot_time = None
try:
with open("/proc/uptime") as f:
uptime_secs = float(f.read().split()[0])
boot_time = datetime.now().timestamp() - uptime_secs
except:
try:
r = subprocess.run(["sysctl", "-n", "kern.boottime"], capture_output=True, text=True)
if "sec =" in r.stdout:
boot_time = int(r.stdout.split("sec =")[1].split(",")[0])
except: pass
# 磁盘
disk_total, disk_used = 0, 0
try:
du = shutil.disk_usage(str(Path.home()))
disk_total, disk_used = du.total, du.used
except: pass
# Matrix 账号摘要
try:
from scripts.matrix_mgmt import MatrixManager
mgr = MatrixManager()
sys_info = mgr.system_info()
accounts = mgr.list_accounts()
except Exception as e:
sys_info = {"error": str(e)}
accounts = []
# 主页采集信息
hp_path = AGENT_LOCAL / "tools" / "matrix" / "data" / "homepage_info.json"
hp_data = {}
if hp_path.exists():
try: hp_data = json.loads(hp_path.read_text())
except: pass
# guardd 状态
guardd_status = {"running": False}
try:
r = subprocess.run(["pgrep", "-f", "guardd"], capture_output=True, text=True)
guardd_status["running"] = bool(r.stdout.strip())
except: pass
return {
"hostname": HOSTNAME,
"version": "2.0.0",
"platform": "macOS",
"kernel": f"{uname.sysname} {uname.release}",
"boot_time": boot_time,
"disk": {
"total_gb": round(disk_total / (1024**3), 1),
"used_gb": round(disk_used / (1024**3), 1),
"free_gb": round((disk_total - disk_used) / (1024**3), 1),
},
"matrix": {
"total_accounts": sys_info.get("total_accounts", 0),
"enabled_accounts": sys_info.get("enabled_accounts", 0),
"logged_in_accounts": sys_info.get("logged_in_accounts", 0),
"identity_dirs": sys_info.get("identity_dirs", 0),
},
"homepage_info": {
"collected_at": hp_data.get("collected_at", ""),
"total_results": len(hp_data.get("results", [])),
},
"guardd": guardd_status,
"plugins": {n: "available" if _AVAILABLE.get(n) else "unavailable" for n in _PLUGINS},
"ts": datetime.now().isoformat(),
}
@app.post("/api/machine/exec")
def api_machine_exec(data: dict):
"""远程执行 mc 命令 (供 mc remote exec 调用, Token 认证)"""
command = data.get("command", "")
if not command:
return {"status": "error", "message": "command required"}
# 安全检查: 只允许 mc 命令
allowed_prefixes = ["mc collect", "mc status", "mc account", "mc sms",
"mc douyin", "mc xiaohongshu", "mc login", "mc record"]
if not any(command.startswith(p) for p in allowed_prefixes):
return {"status": "error", "message": f"command not allowed: {command[:50]}"}
import subprocess
mc_path = Path(__file__).resolve().parent.parent / "07_matrix" / "scripts" / "mc" / "cli.py"
# 转换为 --json 模式执行
cmd_parts = command.split()
cmd_parts.insert(1, "--json") # mc --json ...
full_cmd = [sys.executable, str(mc_path)] + cmd_parts[1:]
try:
r = subprocess.run(full_cmd, capture_output=True, text=True, timeout=120,
cwd=str(mc_path.parent))
return {
"status": "ok",
"returncode": r.returncode,
"stdout": r.stdout[:5000],
"stderr": r.stderr[:500],
}
except subprocess.TimeoutExpired:
return {"status": "error", "message": "timeout (120s)"}
except Exception as e:
return {"status": "error", "message": str(e)}
# ═══════════════════════════════════════════════════════════
# 联邦 PUSH API (反向连接, UID 认证)
# ═══════════════════════════════════════════════════════════
_REGISTERED_UIDS: dict[str, dict] = {}
_ALLOW_AUTO_REGISTER = True
_UID_WHITELIST: list[str] = []
# 心跳历史 (用于时间线/热力图)
_HEARTBEAT_HISTORY: dict[str, list[dict]] = {}
def _load_uids():
reg_dir = _CROSS_MACHINE_DIR / "registry"
if not reg_dir.exists():
return
for f in reg_dir.iterdir():
if f.suffix == ".json":
try:
data = json.loads(f.read_text())
uid = data.get("uid", "")
if uid:
_REGISTERED_UIDS[uid] = data
except:
pass
_load_uids()
@app.post("/api/push/heartbeat")
def api_push_heartbeat(data: dict):
"""接收实时心跳推送 — 以 UID 为主键存储, 无惧 hostname 变化"""
uid = data.get("uid", "")
hostname = data.get("hostname", "unknown")
if not uid:
raise HTTPException(400, detail="missing uid")
if _UID_WHITELIST and uid not in _UID_WHITELIST:
raise HTTPException(403, detail=f"uid not authorized")
# 注册/更新 UID 信息
now = datetime.now(timezone.utc)
if uid in _REGISTERED_UIDS:
_REGISTERED_UIDS[uid]["hostname"] = hostname
_REGISTERED_UIDS[uid]["last_seen"] = now.isoformat()
elif _ALLOW_AUTO_REGISTER:
_REGISTERED_UIDS[uid] = {
"uid": uid, "hostname": hostname,
"first_seen": now.isoformat(),
"last_seen": now.isoformat(),
}
# 以 UID 为文件名存储 live.json(非 hostname)
live_dir = _CROSS_MACHINE_DIR / "status" / "live"
live_dir.mkdir(parents=True, exist_ok=True)
hb = data.get("heartbeat", {})
hb["_source"] = "push"
hb["_received_at"] = now.isoformat()
hb["_uid"] = uid
hb["_hostname"] = hostname
hb["_events"] = data.get("events", [])
hb["_uptime"] = data.get("uptime", 0)
(live_dir / f"{uid}.json").write_text(
json.dumps(hb, indent=2, ensure_ascii=False), encoding="utf-8")
# 按 UID 记录心跳历史
hist = _HEARTBEAT_HISTORY.setdefault(uid, [])
hist.append({
"t": now.isoformat(),
"cpu": hb.get("cpu", {}).get("load_1m", 0),
"disk_pct": hb.get("disk", {}).get("used_gb", 0),
"ts": now.timestamp(),
"hostname": hostname,
})
if len(hist) > 1440:
_HEARTBEAT_HISTORY[uid] = hist[-1440:]
return {"status": "ok", "uid": uid[:8] + "...", "hostname": hostname}
@app.get("/api/push/status")
def api_push_status():
"""查看已注册机器的 UID 和 hostname"""
items = []
for uid, info in _REGISTERED_UIDS.items():
live_file = _CROSS_MACHINE_DIR / "status" / "live" / f"{uid}.json"
lp = None
if live_file.exists():
try:
lp = json.loads(live_file.read_text()).get("_received_at")
except:
pass
items.append({
"uid": uid[:8] + "...",
"hostname": info.get("hostname", "unknown"),
"first_seen": info.get("first_seen", ""),
"last_seen": info.get("last_seen", ""),
"last_push": lp,
"status": "active" if lp else "waiting",
})
return {"machines": items, "total": len(items)}
# ═══════════════════════════════════════════════════════════
# 功能 API: 时间线 / 热力图 / 告警 / 升级 / 唤醒 / 日报
# ═══════════════════════════════════════════════════════════
@app.get("/api/timeline/{uid}")
def api_timeline(uid: str, window: int = 300):
"""CPU/磁盘变化折线图 (按UID查询)"""
hist = _HEARTBEAT_HISTORY.get(uid, [])
reg = _REGISTERED_UIDS.get(uid, {})
return {
"uid": uid[:8] + "...",
"hostname": reg.get("hostname", "unknown"),
"points": hist[-window:],
"total": len(hist),
}
@app.get("/api/heatmap")
def api_heatmap():
"""心跳热力图:各机器24小时活动分布 (按UID)"""
now = datetime.now(timezone.utc)
day_ago = now.timestamp() - 86400
result = {}
for uid, hist in _HEARTBEAT_HISTORY.items():
recent = [p for p in hist if p.get("ts", 0) > day_ago]
if not recent:
continue
reg = _REGISTERED_UIDS.get(uid, {})
hostname = reg.get("hostname", uid[:8])
hourly = [0] * 24
for p in recent:
try:
hr = datetime.fromisoformat(p["t"]).hour
except:
hr = 0
hourly[hr] += 1
result[hostname] = {
"uid": uid[:8] + "...",
"total_pings": len(recent),
"hourly": hourly,
"last_seen": recent[-1]["t"] if recent else None,
}
return {"machines": result}
@app.get("/api/alerts")
def api_alerts():
"""离线告警:心跳超时5分钟以上的机器"""
now = datetime.now(timezone.utc)
alerts = []
for uid, hist in _HEARTBEAT_HISTORY.items():
if not hist:
continue
reg = _REGISTERED_UIDS.get(uid, {})
hostname = reg.get("hostname", uid[:8])
last = hist[-1]
try:
delta = (now - datetime.fromisoformat(last["t"])).total_seconds()
if delta > 300:
alerts.append({
"hostname": hostname,
"uid": uid[:8] + "...",
"level": "warning" if delta < 900 else "critical",
"since_sec": round(delta),
"last_seen": last["t"],
})
except:
pass
# 也检查有注册但无推送的机器
for uid, reg in _REGISTERED_UIDS.items():
hn = reg.get("hostname", "")
if hn and uid not in _HEARTBEAT_HISTORY:
alerts.append({
"hostname": hn,
"uid": uid[:8] + "...",
"level": "info",
"since_sec": 99999,
"note": "已注册但未推送",
})
return {"alerts": alerts, "total": len(alerts)}
@app.post("/api/wakeup/{uid}")
def api_wakeup(uid: str):
"""一键唤醒:通过 UID 找到目标机器"""
reg = _REGISTERED_UIDS.get(uid)
if not reg:
# 尝试从 live 目录反向查找
live_file = _CROSS_MACHINE_DIR / "status" / "live" / f"{uid}.json"
if live_file.exists():
try:
live = json.loads(live_file.read_text())
target = live.get("_hostname", uid)
except:
target = uid
else:
target = uid
else:
target = reg.get("hostname", uid)
task = {
"id": f"wakeup_{target}_{int(time.time())}",
"type": "run_guardd",
"target_host": target,
"source_host": HOSTNAME,
"created_at": datetime.now(timezone.utc).isoformat(),
"status": "pending",
}
task_dir = _CROSS_MACHINE_DIR / "tasks" / "pending"
task_dir.mkdir(parents=True, exist_ok=True)
(task_dir / f"{task['id']}.json").write_text(
json.dumps(task, indent=2, ensure_ascii=False), encoding="utf-8")
return {"status": "ok", "task_id": task["id"], "target": target}
@app.post("/api/upgrade/{uid}")
def api_upgrade(uid: str):
"""一键升级:通过 UID 找到目标机器"""
reg = _REGISTERED_UIDS.get(uid)
target = reg.get("hostname", uid) if reg else uid
task = {
"id": f"upgrade_{target}_{int(time.time())}",
"type": "run_script",
"script": "cd ~/workbuddy-agent-os/agent-sync && git pull && launchctl kickstart gui/$(id -u)/com.agentos.guardd",
"target_host": target,
"source_host": HOSTNAME,
"created_at": datetime.now(timezone.utc).isoformat(),
"status": "pending",
}
task_dir = _CROSS_MACHINE_DIR / "tasks" / "pending"
task_dir.mkdir(parents=True, exist_ok=True)
(task_dir / f"{task['id']}.json").write_text(
json.dumps(task, indent=2, ensure_ascii=False), encoding="utf-8")
return {"status": "ok", "task_id": task["id"], "target": target}
@app.get("/api/daily-summary")
def api_daily_summary():
"""今日联邦日报:汇总各机器的当天事件"""
today = datetime.now().strftime("%Y-%m-%d")
machines_report = {}
for uid, hist in _HEARTBEAT_HISTORY.items():
if not hist:
continue
reg = _REGISTERED_UIDS.get(uid, {})
hostname = reg.get("hostname", uid[:8])
last = hist[-1]
summary = {"ping_count": len(hist), "events": [], "uid": uid[:8] + "..."}
events = last.get("_events", []) if isinstance(last, dict) else []
for ev in events[-5:]:
summary["events"].append({
"type": ev.get("type", "unknown"),
"time": ev.get("timestamp", ""),
"payload": ev.get("payload", {}),
})
machines_report[hostname] = summary
return {
"date": today,
"machines": machines_report,
"total_machines": len(machines_report),
}
# ═══════════════════════════════════════════════════════════
# Matrix 管理 API (v3.0 新增)
# ═══════════════════════════════════════════════════════════
import importlib.util
_MATRIX_MGMT_PATH = Path(__file__).resolve().parent.parent / "07_matrix" / "scripts" / "matrix_mgmt.py"
def _init_matrix_mgmt():
"""初始化 MatrixManager 实例"""
if not _MATRIX_MGMT_PATH.exists():
return None
spec = importlib.util.spec_from_file_location("matrix_mgmt", _MATRIX_MGMT_PATH)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod.MatrixManager()
def _get_matrix_mgr():
mgr = _init_matrix_mgmt()
if mgr is None:
raise HTTPException(503, detail="matrix_mgmt module not found")
return mgr
# ── 注册模块化路由 ────────────────────────────────────────
from routes.matrix import router as matrix_router
app.include_router(matrix_router)
from routes.ops import router as ops_router
app.include_router(ops_router)
from routes.v2_accounts import router as v2_accounts_router
app.include_router(v2_accounts_router)
from routes.comment_workbench import router as comment_workbench_router
app.include_router(comment_workbench_router)
from routes.scrape import router as scrape_router
app.include_router(scrape_router)
# 人物置换(视频工厂·相对独立业务) — 失败不阻断 Dashboard 启动
try:
from routes.person_swap import router as person_swap_router
app.include_router(person_swap_router)
logger.info(" ✅ person_swap 路由已挂载 (/api/person-swap)")
except Exception as e:
logger.warning(f" ⚠️ person_swap 路由挂载失败: {e}")
# API Key 配置(各使用者自行配置密钥) — 失败不阻断 Dashboard 启动
try:
from routes.api_config import router as api_config_router
app.include_router(api_config_router)
logger.info(" ✅ api_config 路由已挂载 (/api/config/api-keys)")
except Exception as e:
logger.warning(f" ⚠️ api_config 路由挂载失败: {e}")
# 视频工厂·能力注册表(S1) — 失败不阻断 Dashboard 启动
try:
from routes.ave import router as ave_router
app.include_router(ave_router)
logger.info(" ✅ ave 路由已挂载 (/api/ave/capabilities)")
except Exception as e:
logger.warning(f" ⚠️ ave 路由挂载失败: {e}")
# 视频工厂·资源库(P3) — 角色/场景/道具/产品… 统一 CRUD
try:
from routes.ave_assets import router as ave_assets_router
app.include_router(ave_assets_router)
# 场景生成器(分层字段 → 提示词 → 图像)
# ⚠️ 必须用独立前缀:/api/ave/assets/{kind} 是通配路由,会把 scene-schema 当成资产类型
from routes.ave_scene import router as ave_scene_router
app.include_router(ave_scene_router, prefix="/api/ave/scene")
logger.info(" ✅ ave_assets 路由已挂载 (/api/ave/assets/{kind})")
except Exception as e:
logger.warning(f" ⚠️ ave_assets 路由挂载失败: {e}")
# 视频工厂·创意库(GapEngine) — 六层参数/矩阵/候选生成/创意卡
try:
from routes.ave_creatives import router as ave_creatives_router
app.include_router(ave_creatives_router)
logger.info(" ✅ ave_creatives 路由已挂载 (/api/ave/creatives)")
except Exception as e:
logger.warning(f" ⚠️ ave_creatives 路由挂载失败: {e}")
# 视频工厂·API 文档中心(本地存档的官方 API 文档)
try:
from routes.ave_docs import router as ave_docs_router
app.include_router(ave_docs_router)
logger.info(" ✅ ave_docs 路由已挂载 (/api/ave/docs)")
except Exception as e:
logger.warning(f" ⚠️ ave_docs 路由挂载失败: {e}")
# 视频工厂·关键帧工作台(流程第②步:剧本 → 关键帧 → 视频)
try:
from routes.ave_keyframe import router as ave_keyframe_router
app.include_router(ave_keyframe_router)
logger.info(" ✅ ave_keyframe 路由已挂载 (/api/ave/keyframe)")
except Exception as e:
logger.warning(f" ⚠️ ave_keyframe 路由挂载失败: {e}")
# 导演工作室 · 统一项目对象(单一真相源,替代 localStorage 传参)
try:
from routes.ave_project import router as ave_project_router
app.include_router(ave_project_router)
logger.info(" ✅ ave_project 路由已挂载 (/api/ave/project)")
except Exception as e:
logger.warning(f" ⚠️ ave_project 路由挂载失败: {e}")
from workflows import WORKFLOW_TEMPLATES, NODE_DEFINITIONS, get_node_categories, get_runner
@app.get("/api/workflow/nodes")
def api_workflow_nodes():
"""返回所有节点类型(按分类)"""
return get_node_categories()
@app.get("/api/workflow/node/{node_type}")
def api_workflow_node(node_type: str):
"""返回单个节点定义"""
node = NODE_DEFINITIONS.get(node_type)
if not node:
raise HTTPException(404, detail=f"未知节点类型: {node_type}")
return node
@app.get("/api/workflow/templates")
def api_workflow_templates():
"""返回所有工作流模板"""
return {"templates": WORKFLOW_TEMPLATES, "total": len(WORKFLOW_TEMPLATES)}
@app.post("/api/workflow/run")
def api_workflow_run(data: dict):
"""创建工作流运行并**真执行**(P2)
Body:
template_id: str 模板 id(不传 nodes 时用它展开)
nodes / edges: list 自定义 DAG
max_shots: int 生成类节点最多跑几个镜头(默认 3,控制试跑成本)
from_node: str 只跑该节点及其下游(可选)
"""
runner = get_runner()
nodes = data.get("nodes") or []
edges = data.get("edges") or []
template_id = data.get("template_id") or "custom"
if not nodes and template_id in WORKFLOW_TEMPLATES:
t = WORKFLOW_TEMPLATES[template_id]
nodes, edges = t["nodes"], t["edges"]
if not nodes:
raise HTTPException(400, detail="缺少节点列表(传 nodes 或有效的 template_id)")
max_shots = int(data.get("max_shots") or 3)
# 🛡️ 费用确认:生成类节点(调用可灵真实扣费)必须显式传 confirm_cost=true
confirm_cost = bool(data.get("confirm_cost"))
run_id = runner.create_run(template_id, nodes, edges)
runner.start_run(run_id, max_shots=max_shots, from_node=data.get("from_node"),
confirm_cost=confirm_cost)
logger.info(" ▶️ 工作流启动: %s(%s,%d 节点,限 %d 镜头,费用确认=%s)",
run_id, template_id, len(nodes), max_shots, confirm_cost)
return {"run_id": run_id, "status": "running",
"message": f"已启动:{len(nodes)} 个节点(生成类限 {max_shots} 镜头)"}
@app.get("/api/workflow/runs")
def api_workflow_runs():
"""列出所有运行(不含大产物)"""
runner = get_runner()
runs = runner.list_runs()
return {"runs": [
{k: v for k, v in r.items() if k != "context"}
for r in sorted(runs, key=lambda x: x.get("created") or 0, reverse=True)
], "total": len(runs)}
@app.get("/api/workflow/runs/{run_id}")
def api_workflow_run_status(run_id: str):
"""获取运行状态(节点状态 + 产物摘要,不含完整分镜等大对象)"""
runner = get_runner()
run = runner.get_run(run_id)
if not run:
raise HTTPException(404, detail="运行不存在")
keep = ("shot_count", "total_duration", "count", "director_id", "segments",
"output", "video_path", "_error", "message", "approved", "sample",
"resolution", "clips", "text", "audio_path", "char_count", "provider",
"model", "prompt")
summary = {}
for nid, out in (run.get("context") or {}).items():
if isinstance(out, dict):
summary[nid] = {k: out[k] for k in keep if k in out}
return {**run, "artifacts": summary}
@app.get("/api/workflow/runs/{run_id}/artifacts")
def api_workflow_artifacts(run_id: str):
"""各节点完整产物(供前端预览/编辑)"""
runner = get_runner()
run = runner.get_run(run_id)
if not run:
raise HTTPException(404, detail="运行不存在")
return {"run_id": run_id, "context": run.get("context") or {},
"node_states": run.get("node_states") or {}}
@app.post("/api/workflow/runs/{run_id}/resume")
def api_workflow_resume(run_id: str):
"""审核门通过 → 继续执行"""
runner = get_runner()
if not runner.resume(run_id):
raise HTTPException(400, detail="该运行不处于暂停状态(或不存在)")
logger.info(" ⏩ 工作流继续: %s", run_id)
return {"status": "ok", "message": "已继续执行"}
@app.post("/api/workflow/runs/{run_id}/cancel")
def api_workflow_cancel(run_id: str):
"""停止运行中的流程(在当前节点执行完后退出)"""
runner = get_runner()
if not runner.cancel(run_id):
raise HTTPException(400, detail="该运行不处于可停止状态(或不存在)")
logger.info(" ⏹️ 工作流停止请求: %s", run_id)
return {"status": "ok", "message": "已请求停止(当前节点执行完即退出)"}
@app.post("/api/workflow/runs/{run_id}/redo/{node_id}")
def api_workflow_redo(run_id: str, node_id: str, data: dict | None = None):
"""重做某节点及其下游(可顺带更新该节点配置)"""
data = data or {}
runner = get_runner()
run = runner.get_run(run_id)
if not run:
raise HTTPException(404, detail="运行不存在")
new_cfg = data.get("config")
if isinstance(new_cfg, dict):
for n in run["nodes"]:
if n["id"] == node_id:
n.setdefault("config", {}).update(new_cfg)
break
if not runner.redo_node(run_id, node_id, max_shots=int(data.get("max_shots") or 3)):
raise HTTPException(400, detail="重做失败(节点不存在)")
logger.info(" 🔄 工作流重做: %s 从 %s 起(含下游)", run_id, node_id)
return {"status": "ok", "message": f"已从节点 {node_id} 起重跑(含下游)"}
# ═══════════════════════════════════════════════
# C2 联邦命令与控制 API (v1.0)
# ═══════════════════════════════════════════════
_C2_BUS = None
def _get_c2_bus():
global _C2_BUS
if _C2_BUS is None:
c2_path = AGENT_SYNC / "05_tools" / "07_matrix" / "scripts" / "c2"
if c2_path.exists():
import importlib.util
spec = importlib.util.spec_from_file_location("command_bus",
c2_path / "command_bus.py")
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
_C2_BUS = mod.CommandBus()
return _C2_BUS
@app.get("/api/c2/ping")
def api_c2_ping():
"""远程健康检查"""
bus = _get_c2_bus()
if not bus:
raise HTTPException(503, detail="C2 模块不可用")
return bus.ping()
@app.post("/api/c2/command")
async def api_c2_receive_command(data: dict):
"""接收远程命令并执行"""
bus = _get_c2_bus()
if not bus:
raise HTTPException(503, detail="C2 模块不可用")
result = bus.receive_and_execute(data)
return result
@app.get("/api/c2/commands")
def api_c2_commands(limit: int = 20):
"""查询命令历史"""
bus = _get_c2_bus()
if not bus:
raise HTTPException(503, detail="C2 模块不可用")
return {
"recent": bus.list_recent_commands(limit),
"pending": bus.list_pending_commands(),
}
@app.get("/api/c2/status/{command_id}")
def api_c2_status(command_id: str):
"""查询单条命令状态"""
bus = _get_c2_bus()
if not bus:
raise HTTPException(503, detail="C2 模块不可用")
result = bus.check_status(command_id)
if not result:
raise HTTPException(404, detail="命令不存在")
return result
@app.post("/api/c2/send")
async def api_c2_send(data: dict):
"""从本机 Dashboard 发送命令到远程机器"""
bus = _get_c2_bus()
if not bus:
raise HTTPException(503, detail="C2 模块不可用")
target = data.get("target", "")
cmd_type = data.get("type", "")
params = data.get("params", {})
schedule_at = data.get("schedule_at", None)
if not target or not cmd_type:
raise HTTPException(400, detail="需要 target 和 type")
result = bus.send(target, cmd_type, params, schedule_at)
return result
@app.get("/api/c2/machines")
def api_c2_machines():
"""返回所有已知机器的实时状态"""
from plugins._registry import get_machine_list
# 从 command_bus 加载机器端点映射
try:
c2_path = AGENT_SYNC / "05_tools" / "07_matrix" / "scripts" / "c2" / "command_bus.py"
import importlib.util
spec = importlib.util.spec_from_file_location("cb_endpoints", c2_path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
endpoints = mod.MACHINE_ENDPOINTS
except:
endpoints = {}
return {
"machines": get_machine_list(),
"endpoints": endpoints,
}
@app.get("/api/c2/environment/{machine}")
def api_c2_remote_environment(machine: str):
"""远程环境检查快捷接口"""
bus = _get_c2_bus()
if not bus:
raise HTTPException(503, detail="C2 模块不可用")
if machine == HOSTNAME:
result = bus.send(machine, "check_environment")
if result.get("status") == "completed":
return result.get("output", {})
return result
result = bus.send(machine, "check_environment")
return result
# ═══════════════════════════════════════════════
# 知识库管理 API (可选 - 需要 fastapi)
# ═══════════════════════════════════════════════
try:
from plugins.kb_api import router as kb_router
app.include_router(kb_router)
print(" ✅ 知识库管理 API 已加载")
except Exception as e:
print(f" ⏸ 知识库管理 API 未加载: {e}")
# ═══════════════════════════════════════════════
# SMS 短信/代理管理 API
# ═══════════════════════════════════════════════
try:
from plugins.sms_proxy_api import router as sms_proxy_router
app.include_router(sms_proxy_router)
print(" ✅ 短信/代理管理 API 已加载")
except Exception as e:
print(f" ⏸ 短信/代理管理 API 未加载: {e}")
# ═════════════════════════════════════════════════════════
# 联邦 API
# ═════════════════════════════════════════════════════════
@app.get("/api/federation/accounts")
def api_federation_accounts():
"""聚合所有机器的账号列表"""
from services.data_aggregator import aggregate_accounts
return aggregate_accounts()
@app.get("/api/federation/status")
def api_federation_status():
"""聚合所有机器的详细状态"""
from services.data_aggregator import aggregate_status
return aggregate_status()
@app.get("/api/federation/health")
def api_federation_health():
"""聚合所有机器的健康状态"""
from services.data_aggregator import aggregate_health
return aggregate_health()
@app.post("/api/federation/exec")
async def api_federation_exec(data: dict):
"""在远程机器执行命令"""
from services.remote_exec import exec_remote
machine = data.get("machine", "")
command = data.get("command", "")
timeout = data.get("timeout", 60)
if not machine or not command:
return {"status": "error", "message": "machine 和 command 必填"}
return exec_remote(machine, command, timeout=timeout)
@app.post("/api/federation/nurture")
async def api_federation_nurture(data: dict):
"""在远程机器启动养号"""
from services.remote_exec import exec_nurture
machine = data.get("machine", "")
accounts = data.get("accounts", [])
blueprints = data.get("blueprints", [])
rounds = data.get("rounds", 3)
if not machine or not accounts or not blueprints:
return {"status": "error", "message": "machine/accounts/blueprints 必填"}
return exec_nurture(machine, accounts, blueprints, rounds)
@app.post("/api/federation/collect")
async def api_federation_collect(data: dict):
"""在远程机器启动采集"""
from services.remote_exec import exec_collect
machine = data.get("machine", "")
phone = data.get("phone", "")
if not machine:
return {"status": "error", "message": "machine 必填"}
return exec_collect(machine, phone)
@app.post("/api/federation/login")
async def api_federation_login(data: dict):
"""在远程机器登录账号"""
from services.remote_exec import exec_login
machine = data.get("machine", "")
account = data.get("account", "")
if not machine or not account:
return {"status": "error", "message": "machine 和 account 必填"}
return exec_login(machine, account)
@app.post("/api/federation/logout")
async def api_federation_logout(data: dict):
"""在远程机器清除登录"""
from services.remote_exec import exec_logout
machine = data.get("machine", "")
account = data.get("account", "")
if not machine or not account:
return {"status": "error", "message": "machine 和 account 必填"}
return exec_logout(machine, account)
@app.post("/api/federation/comment")
async def api_federation_comment(data: dict):
"""在远程机器执行定向评论"""
from services.remote_exec import exec_comment
machine = data.get("machine", "")
account = data.get("account", "")
url = data.get("url", "")
direction = data.get("direction", "")
if not machine or not account or not url:
return {"status": "error", "message": "machine/account/url 必填"}
return exec_comment(machine, account, url, direction)
@app.post("/api/federation/record")
async def api_federation_record(data: dict):
"""在远程机器启动录制"""
from services.remote_exec import exec_record
machine = data.get("machine", "")
account = data.get("account", "")
platform = data.get("platform", "douyin")
if not machine or not account:
return {"status": "error", "message": "machine/account 必填"}
return exec_record(machine, account, platform)
@app.post("/api/federation/nurture-stop")
async def api_federation_nurture_stop(data: dict):
"""停止远程机器的养号"""
from services.remote_exec import exec_nurture_stop
machine = data.get("machine", "")
if not machine:
return {"status": "error", "message": "machine 必填"}
return exec_nurture_stop(machine)
@app.get("/api/federation/machine-status/{machine}")
def api_federation_machine_status(machine: str):
"""获取远程机器状态"""
from services.remote_exec import exec_status
return exec_status(machine)
# ═════════════════════════════════════════════════════════
# 操作队列 API (Phase 3.1: 状态机引擎)
# ═════════════════════════════════════════════════════════
@app.post("/api/operations/submit")
def api_operation_submit(data: dict):
"""提交操作"""
from services.operation_queue import submit_operation
op_type = data.get("type", "")
target = data.get("target_machine", "")
params = data.get("params", {})
if not op_type or not target:
return {"status": "error", "message": "type 和 target_machine 必填"}
op = submit_operation(op_type, target, params)
return {"status": "ok", "operation": op}
@app.get("/api/operations/{op_id}")
def api_operation_get(op_id: str):
"""查询操作详情"""
from services.operation_queue import get_operation
op = get_operation(op_id)
if not op:
return {"status": "error", "message": "操作不存在"}
return {"status": "ok", "operation": op}
@app.get("/api/operations")
def api_operation_list(status: str = "", limit: int = 50):
"""列出操作"""
from services.operation_queue import list_operations
ops = list_operations(status=status, limit=limit)
return {"status": "ok", "operations": ops, "total": len(ops)}
@app.post("/api/operations/{op_id}/cancel")
def api_operation_cancel(op_id: str):
"""取消操作"""
from services.operation_queue import cancel_operation
if cancel_operation(op_id):
return {"status": "ok"}
return {"status": "error", "message": "取消失败"}
@app.post("/api/operations/{op_id}/transition")
def api_operation_transition(op_id: str, data: dict):
"""手动转换状态 (调试/管理用)"""
from services.operation_queue import transition_state
new_state = data.get("state", "")
message = data.get("message", "")
if transition_state(op_id, new_state, message):
return {"status": "ok"}
return {"status": "error", "message": "状态转换失败,当前状态不允许"}
# ═════════════════════════════════════════════════════════
# 资源锁 API (Phase 3.2)
# ═════════════════════════════════════════════════════════
@app.post("/api/locks/acquire")
def api_lock_acquire(data: dict):
"""获取资源锁"""
from services.resource_lock import acquire_lock
rtype = data.get("resource_type", "")
rid = data.get("resource_id", "")
holder = data.get("holder", "")
ttl = data.get("ttl", 3600)
if not rtype or not rid or not holder:
return {"status": "error", "message": "resource_type/resource_id/holder 必填"}
return acquire_lock(rtype, rid, holder, ttl)
@app.post("/api/locks/release")
def api_lock_release(data: dict):
"""释放资源锁"""
from services.resource_lock import release_lock
rtype = data.get("resource_type", "")
rid = data.get("resource_id", "")
holder = data.get("holder", "")
if not rtype or not rid:
return {"status": "error", "message": "resource_type/resource_id 必填"}
return {"success": release_lock(rtype, rid, holder)}
@app.get("/api/locks/check/{resource_type}/{resource_id}")
def api_lock_check(resource_type: str, resource_id: str):
"""检查资源锁"""
from services.resource_lock import check_lock
return check_lock(resource_type, resource_id)
@app.get("/api/locks")
def api_lock_list():
"""列出所有锁"""
from services.resource_lock import LOCKS_DIR
result = []
if not LOCKS_DIR.exists():
return {"locks": []}
for rtype_dir in LOCKS_DIR.iterdir():
if not rtype_dir.is_dir():
continue
for fp in rtype_dir.glob("*.json"):
try:
result.append(json.loads(fp.read_text()))
except:
pass
return {"locks": result}
@app.post("/api/locks/cleanup")
def api_lock_cleanup():
"""清理过期锁"""
from services.operation_queue import cleanup_stale_locks
cleanup_stale_locks()
return {"status": "ok"}
# ═════════════════════════════════════════════════════════
# Preflight Reset API (Phase 3.3)
# ═════════════════════════════════════════════════════════
@app.post("/api/preflight")
def api_preflight(data: dict = {}):
"""执行操作前状态归零检查"""
from services.preflight import run
machine = data.get("machine", "")
return run(machine)
# ═════════════════════════════════════════════════════════
# 日志聚合 API (Phase 3.4)
# ═════════════════════════════════════════════════════════
@app.post("/api/logs/push")
def api_log_push(data: dict):
"""推送操作日志"""
from services.log_aggregator import write_log
write_log(data)
return {"status": "ok"}
@app.get("/api/logs")
def api_logs(machine: str = "", type: str = "", status: str = "",
days: int = 7, limit: int = 200):
"""查询操作日志"""
from services.log_aggregator import query_logs
logs = query_logs(machine=machine, op_type=type, status=status, days=days, limit=limit)
return {"logs": logs, "total": len(logs)}
@app.get("/api/logs/stats")
def api_log_stats(days: int = 7):
"""日志统计"""
from services.log_aggregator import get_log_stats
return get_log_stats(days=days)
@app.post("/api/fleet/sync")
def api_fleet_sync():
"""一键同步所有机器"""
import subprocess
try:
r = subprocess.run(
["bash", str(_static_dir.parent.parent.parent / "00_bootstrap" / "fleet_sync.sh")],
capture_output=True, text=True, timeout=120
)
return {"success": r.returncode == 0, "output": r.stdout[-3000:]}
except subprocess.TimeoutExpired:
return {"success": False, "output": "⏰ 同步超时(120s)"}
except Exception as e:
return {"success": False, "output": f"❌ {str(e)}"}
# ── 接力执行链 API ──
@app.post("/api/ops/chain")
def api_chain_create(data: dict = {}):
"""创建并启动接力执行链"""
name = data.get("name", "untitled")
tasks = data.get("tasks", [])
if not tasks:
return {"error": "tasks 必填"}
from services.command_chain import CommandChain
run = CommandChain.create(name, tasks)
return {"status": "ok", "chain_id": run.chain_id, "name": run.name, "total_tasks": len(tasks)}
@app.get("/api/ops/chain/{chain_id}")
def api_chain_status(chain_id: str):
"""查询接力链状态"""
from services.command_chain import CommandChain
status = CommandChain.get_status(chain_id)
if not status:
from fastapi import HTTPException
raise HTTPException(404, detail=f"接力链不存在: {chain_id}")
return status
@app.post("/api/ops/chain/{chain_id}/cancel")
def api_chain_cancel(chain_id: str):
"""取消接力链"""
from services.command_chain import CommandChain
return CommandChain.cancel(chain_id)
@app.get("/api/ops/chains")
def api_chain_list(limit: int = 20):
"""列出接力链"""
from services.command_chain import CommandChain
return {"chains": CommandChain.list_chains(limit)}
@app.post("/api/fleet/collect")
def api_fleet_collect():
"""触发联邦信息采集聚合:SSH 拉取所有工作机的采集数据"""
from services.fleet_collector import collect_all
results = collect_all()
return {"status": "ok", "results": results}
@app.get("/api/fleet/collect/status")
def api_fleet_collect_status():
"""查询联邦信息采集缓存状态"""
import json
from services.fleet_collector import CACHE_DIR, get_merged_homepage
machines = {}
if CACHE_DIR.exists():
for machine_dir in sorted(CACHE_DIR.iterdir()):
if not machine_dir.is_dir():
continue
hp_file = machine_dir / "homepage_info.json"
pf_file = machine_dir / "profiles.json"
ts_file = machine_dir / "collected_at.txt"
machines[machine_dir.name] = {
"has_homepage": hp_file.exists(),
"has_profiles": pf_file.exists(),
"collected_at": ts_file.read_text().strip() if ts_file.exists() else None,
}
merged = get_merged_homepage()
return {
"machines": machines,
"total_identities": merged["total_identities"],
"collected_at": merged["collected_at"],
}
@app.post("/api/fleet/reconcile")
def api_fleet_reconcile():
"""对账检查"""
import subprocess
try:
r = subprocess.run(
["bash", str(_static_dir.parent.parent.parent / "00_bootstrap" / "fleet_reconcile.sh")],
capture_output=True, text=True, timeout=120
)
return {"success": r.returncode == 0, "output": r.stdout[-3000:]}
except subprocess.TimeoutExpired:
return {"success": False, "output": "⏰ 对账超时(60s)"}
except Exception as e:
return {"success": False, "output": f"❌ {str(e)}"}
if __name__ == "__main__":
import uvicorn
port = int(sys.argv[1]) if len(sys.argv) > 1 else 9988
print(f"📊 系统监控面板 v2.0 — 联邦协同版")
print(f" 本机: {HOSTNAME}")
print(f" → http://localhost:{port}")
print(f" 插件: {', '.join(_PLUGINS.keys())}")
uvicorn.run(app, host="0.0.0.0", port=port, reload=False)