移植自 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 带网关原话 / 正常返回)。
1805 lines
70 KiB
Python
1805 lines
70 KiB
Python
"""
|
||
系统监控面板 — 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)
|