commit d810abdceebadb2748f28517f9e3fe39b440743b Author: qihongkun Date: Fri May 22 15:44:50 2026 +0800 初始化仓库:AI 接口自动化测试平台 纳入 FastAPI 后端、Vue 管理端、MCP 桥接与文档;通过 .gitignore 排除本地数据库与构建产物。 Co-authored-by: Cursor diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8b9d193 --- /dev/null +++ b/.gitignore @@ -0,0 +1,73 @@ +# OS +.DS_Store +Thumbs.db + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +.venv/ +venv/ +env/ +ENV/ +*.egg-info/ +.eggs/ +dist/ +build/ +*.egg + +# Python tooling +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +htmlcov/ +.tox/ +.nox/ + +# SQLite / local data +ai_test.db +data.db +*.sqlite +*.sqlite3 +*.db-journal +*.db-wal +*.db-shm + +# SSH scripts (keep directory placeholder only) +data/ssh-scripts/* +!data/ssh-scripts/.gitkeep + +# Environment & secrets +.env +.env.* +!.env.example +!**/.env.example + +# IDE / editor +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# Cursor / local agent tooling (not part of app source) +.cursor/ +.serena/ + +# Project runtime logs +.run_logs/ +*.log + +# Frontend (Vue + Vite) +frontend-admin/node_modules/ +frontend-admin/dist/ +frontend-admin/.vite/ +frontend-admin/.cache/ +frontend-admin/coverage/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..49d7718 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,22 @@ +# AI Agent 工作约定(本仓库) + +## MCP 能力说明(必读) + +编排接口、工作流、跑批、执行历史时,**先阅读**: + +**[`docs/mcp_tools_for_ai.md`](docs/mcp_tools_for_ai.md)** + +该文档包含:工具决策表、鉴权要求、跑批三步、工作流节点约定、排障 Recipe。不要仅凭 `tools/list` 的短 description 猜测用法。 + +## 快速规则 + +- MCP 服务名:`quality-inspection-platform`(stdio 桥 `mcp_bridge.py`) +- 写操作与执行类工具须 `sto-` API Key +- 任务开始:`catalog_snapshot` +- 跑批:`workflow_batch_create` → `workflow_batch_update` → `workflow_batch_run` +- 后端:`uvicorn app.main:app --host 127.0.0.1 --port 8000` + +## 其它文档 + +- 安装桥接:`docs/mcp_install.md` +- 人类调用示例:`docs/mcp_quickstart.md` diff --git a/README.md b/README.md new file mode 100644 index 0000000..9083fb1 --- /dev/null +++ b/README.md @@ -0,0 +1,113 @@ +# 质量检测平台 + +一个基于 `FastAPI` 的质量检测平台,提供三类能力: + +- 接口定义管理 +- Mock 数据管理 +- Workflow 流程编排与执行 + +此外,项目还带了一个 `stdio MCP bridge`,可以把平台里暴露的工具注册成真正的 MCP Server,供 Cursor、Claude Desktop、Codex 等客户端调用。 + +## 技术栈 + +- Python +- FastAPI +- SQLAlchemy +- Pydantic +- SQLite +- Jinja2 +- HTMX +- Drawflow + +## 目录结构 + +```text +app/ + main.py FastAPI 入口、页面路由、REST API、MCP HTTP 网关 + database.py SQLite engine / session / Base + models.py SQLAlchemy 模型 + schemas.py Pydantic 请求与响应模型 + services/engine.py Workflow 执行引擎 + static/app.js 前端交互逻辑 + templates/ Jinja2 页面模板 +docs/ + mcp_tools_for_ai.md MCP 能力说明(AI Agent 首选) + mcp_install.md MCP bridge 安装说明 + mcp_quickstart.md MCP 调用说明(人类 / curl) +AGENTS.md Codex 等自动加载的项目 Agent 约定 +mcp_bridge.py stdio MCP Server 桥接入口 +requirements.txt Python 依赖 +``` + +## 安装依赖 + +建议先进入项目目录,再安装依赖: + +```bash +pip install -r requirements.txt +``` + +如果你使用虚拟环境,也可以先激活 `.venv` 再安装。 + +## 启动项目 + +### 方式一:命令行启动后端 + +```bash +python -m uvicorn app.main:app --host 127.0.0.1 --port 8000 --reload +``` + +启动后访问: + +- 首页:`http://127.0.0.1:8000/` +- API 文档:`http://127.0.0.1:8000/docs` +- MCP 工具列表:`http://127.0.0.1:8000/mcp/tools` + +### 方式二:在 PyCharm 里启动 + +推荐创建一个 `Python` Run Configuration,并使用 `Module name` 模式: + +- `Module name`: `uvicorn` +- `Parameters`: `app.main:app --host 127.0.0.1 --port 8000 --reload` +- `Working directory`: 项目根目录 + +注意:`app.main:app` 必须放在 `uvicorn` 后面作为位置参数传入,不能写到最后。 + +## 启动 MCP Bridge + +如果你要把本平台作为 MCP Server 暴露给其他客户端,再额外启动: + +```bash +python3 mcp_bridge.py +``` + +桥接默认读取环境变量: + +- `AI_TEST_BASE_URL`,默认值是 `http://127.0.0.1:8000` + +完整配置见: + +- [`docs/mcp_install.md`](./docs/mcp_install.md) + +AI 使用 MCP 前请阅读: + +- [`docs/mcp_tools_for_ai.md`](./docs/mcp_tools_for_ai.md) +- 项目根 [`AGENTS.md`](./AGENTS.md)(Codex 会自动加载) + +## 常用开发命令 + +```bash +rg --files +rg "workflow_run" app docs +python -m uvicorn app.main:app --host 127.0.0.1 --port 8000 --reload +python3 -m py_compile app/main.py app/services/engine.py mcp_bridge.py +``` + +## 当前已知情况 + +- 默认数据库文件是根目录下的 `ai_test.db` +- 项目当前没有现成的 `pytest`、`ruff`、`flake8` 配置 +- 前端是模板 + 原生 JS 结构,改动 UI 时通常集中在: + - `app/templates/index.html` + - `app/templates/partials/*.html` + - `app/static/app.js` diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ + diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..1f741cc --- /dev/null +++ b/app/database.py @@ -0,0 +1,16 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import declarative_base, sessionmaker + +DATABASE_URL = "sqlite:///./ai_test.db" + +engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False}) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) +Base = declarative_base() + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..545cd85 --- /dev/null +++ b/app/main.py @@ -0,0 +1,3509 @@ +import asyncio +import contextlib +import json +import os +import hashlib +import hmac +from datetime import datetime, timezone +from pathlib import Path +import secrets +import subprocess +import tempfile +import time +import uuid +import threading +from collections.abc import Callable +from typing import Any + +from fastapi import Depends, FastAPI, File, Form, Header, HTTPException, Request, UploadFile, WebSocket, WebSocketDisconnect +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles +from sqlalchemy import func, text +from sqlalchemy.orm import Session + +from .database import Base, SessionLocal, engine, get_db +from .models import ( + ApiDefinition, + FolderEntry, + McpToolConfig, + MockDataset, + SshProfile, + SshScript, + SshShortcut, + User, + UserApiKey, + Workflow, + WorkflowBatch, + WorkflowRun, +) +from .schemas import ( + ApiCreate, + ApiOut, + ApiUpdate, + CurrentUserOut, + LoginRequest, + LoginResponse, + McpToolCreate, + McpToolOut, + FolderMoveRequest, + McpInvokeBatchRequest, + McpInvokeRequest, + McpInvokeResponse, + MockDataCreate, + MockDataOut, + LokiLogLinkOut, + ReplayRunRequest, + RunNodeRequest, + RunNodeResponse, + RunWorkflowBatchRequest, + RunWorkflowBatchResponse, + RunWorkflowResponse, + WorkflowBatchCreate, + WorkflowBatchUpdate, + WorkflowBatchOut, + WorkflowBatchRunItem, + SshExecuteRequest, + SshExecuteResponse, + SshProfileCreate, + SshProfileOut, + SshProfileUpdate, + SshScriptCreate, + SshScriptOut, + SshScriptUpdate, + SshTreeProfileNode, + SshTreeScriptNode, + SshShortcutCreate, + SshShortcutOut, + SshShortcutUpdate, + RunWorkflowRequest, + UserApiKeyCreate, + UserApiKeyCreateResponse, + UserApiKeyOut, + UserCreate, + UserOut, + UserUpdate, + WorkflowCreate, + WorkflowOut, + WorkflowUpdate, +) +from .services.engine import execute_single_node, execute_workflow +from .services.loki import build_run_loki_link +from .services.ssh_runner import SSH_SCRIPTS_DIR, resolve_script_body, run_script_collect, stream_script_run + +Base.metadata.create_all(bind=engine) + + +def _ensure_sqlite_columns(): + with engine.begin() as conn: + user_cols = {row[1] for row in conn.execute(text("PRAGMA table_info(users)"))} + if user_cols and "display_name" not in user_cols: + conn.execute(text("ALTER TABLE users ADD COLUMN display_name VARCHAR(64) NOT NULL DEFAULT ''")) + if user_cols and "role" not in user_cols: + conn.execute(text("ALTER TABLE users ADD COLUMN role VARCHAR(32) NOT NULL DEFAULT 'user'")) + if user_cols and "is_active" not in user_cols: + conn.execute(text("ALTER TABLE users ADD COLUMN is_active BOOLEAN NOT NULL DEFAULT 1")) + + api_cols = {row[1] for row in conn.execute(text("PRAGMA table_info(api_definitions)"))} + if "folder_path" not in api_cols: + conn.execute(text("ALTER TABLE api_definitions ADD COLUMN folder_path VARCHAR(255) NOT NULL DEFAULT ''")) + if "creator_id" not in api_cols: + conn.execute(text("ALTER TABLE api_definitions ADD COLUMN creator_id INTEGER NOT NULL DEFAULT 1")) + if "creator_name" not in api_cols: + conn.execute(text("ALTER TABLE api_definitions ADD COLUMN creator_name VARCHAR(64) NOT NULL DEFAULT 'admin'")) + + workflow_cols = {row[1] for row in conn.execute(text("PRAGMA table_info(workflows)"))} + if "folder_path" not in workflow_cols: + conn.execute(text("ALTER TABLE workflows ADD COLUMN folder_path VARCHAR(255) NOT NULL DEFAULT ''")) + if "default_headers_json" not in workflow_cols: + conn.execute(text("ALTER TABLE workflows ADD COLUMN default_headers_json TEXT NOT NULL DEFAULT '{}'")) + if "creator_id" not in workflow_cols: + conn.execute(text("ALTER TABLE workflows ADD COLUMN creator_id INTEGER NOT NULL DEFAULT 1")) + if "creator_name" not in workflow_cols: + conn.execute(text("ALTER TABLE workflows ADD COLUMN creator_name VARCHAR(64) NOT NULL DEFAULT 'admin'")) + + mock_cols = {row[1] for row in conn.execute(text("PRAGMA table_info(mock_datasets)"))} + if "creator_id" not in mock_cols: + conn.execute(text("ALTER TABLE mock_datasets ADD COLUMN creator_id INTEGER NOT NULL DEFAULT 1")) + if "creator_name" not in mock_cols: + conn.execute(text("ALTER TABLE mock_datasets ADD COLUMN creator_name VARCHAR(64) NOT NULL DEFAULT 'admin'")) + + mcp_cols = {row[1] for row in conn.execute(text("PRAGMA table_info(mcp_tool_configs)"))} + if "creator_id" not in mcp_cols: + conn.execute(text("ALTER TABLE mcp_tool_configs ADD COLUMN creator_id INTEGER NOT NULL DEFAULT 1")) + if "creator_name" not in mcp_cols: + conn.execute(text("ALTER TABLE mcp_tool_configs ADD COLUMN creator_name VARCHAR(64) NOT NULL DEFAULT 'admin'")) + + folder_cols = {row[1] for row in conn.execute(text("PRAGMA table_info(folder_entries)"))} + if "creator_id" not in folder_cols: + conn.execute(text("ALTER TABLE folder_entries ADD COLUMN creator_id INTEGER NOT NULL DEFAULT 1")) + if "creator_name" not in folder_cols: + conn.execute(text("ALTER TABLE folder_entries ADD COLUMN creator_name VARCHAR(64) NOT NULL DEFAULT 'admin'")) + + ssh_shortcut_cols = {row[1] for row in conn.execute(text("PRAGMA table_info(ssh_shortcuts)"))} + if ssh_shortcut_cols: + if "description" not in ssh_shortcut_cols: + conn.execute(text("ALTER TABLE ssh_shortcuts ADD COLUMN description VARCHAR(255) NOT NULL DEFAULT ''")) + if "creator_id" not in ssh_shortcut_cols: + conn.execute(text("ALTER TABLE ssh_shortcuts ADD COLUMN creator_id INTEGER NOT NULL DEFAULT 1")) + if "creator_name" not in ssh_shortcut_cols: + conn.execute(text("ALTER TABLE ssh_shortcuts ADD COLUMN creator_name VARCHAR(64) NOT NULL DEFAULT 'admin'")) + + run_cols = {row[1] for row in conn.execute(text("PRAGMA table_info(workflow_runs)"))} + if run_cols and "batch_id" not in run_cols: + conn.execute(text("ALTER TABLE workflow_runs ADD COLUMN batch_id INTEGER")) + + +def _hash_password(raw: str) -> str: + return hashlib.sha256((raw or "").encode("utf-8")).hexdigest() + + +def _make_user_out(user: User) -> CurrentUserOut: + return CurrentUserOut( + id=user.id, + username=user.username, + display_name=user.display_name or user.username, + role="superadmin" if user.role == "superadmin" else "user", + ) + + +def _issue_auth_token(user: User) -> str: + secret = os.getenv("AUTH_TOKEN_SECRET", "ai-auto-test-dev-secret") + nonce = secrets.token_hex(8) + raw = f"{user.id}:{user.username}:{user.role}:{nonce}" + signature = hmac.new(secret.encode("utf-8"), raw.encode("utf-8"), hashlib.sha256).hexdigest() + return f"{raw}:{signature}" + + +def _resolve_user_by_token(token: str, db: Session) -> User | None: + secret = os.getenv("AUTH_TOKEN_SECRET", "ai-auto-test-dev-secret") + parts = (token or "").split(":") + if len(parts) != 5: + return None + user_id, username, role, nonce, signature = parts + raw = f"{user_id}:{username}:{role}:{nonce}" + expected = hmac.new(secret.encode("utf-8"), raw.encode("utf-8"), hashlib.sha256).hexdigest() + if not hmac.compare_digest(expected, signature): + return None + if not user_id.isdigit(): + return None + user = db.query(User).filter(User.id == int(user_id), User.is_active == True).first() + if not user: + return None + if user.username != username or user.role != role: + return None + return user + + +def _mask_api_key(prefix: str, hint: str) -> str: + return f"{prefix}••••••••{hint}" + + +def _generate_user_api_key() -> tuple[str, str, str, str]: + body = secrets.token_urlsafe(24).replace("-", "").replace("_", "") + body = "".join(ch for ch in body if ch.isalnum())[:32] or secrets.token_hex(16) + full_key = f"sto-{body}" + key_hash = _hash_password(full_key) + key_prefix = full_key[:8] + key_hint = full_key[-4:] + return full_key, key_hash, key_prefix, key_hint + + +def _parse_api_key_expires_at(value: str | None) -> datetime | None: + raw = (value or "").strip() + if not raw: + return None + normalized = raw.replace("Z", "+00:00") + try: + parsed = datetime.fromisoformat(normalized) + except ValueError as exc: + raise HTTPException(status_code=400, detail="expires_at must be ISO datetime") from exc + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + +def _api_key_is_valid(row: UserApiKey) -> bool: + if not row.is_active: + return False + if row.expires_at is None: + return True + expires_at = row.expires_at + if expires_at.tzinfo is None: + expires_at = expires_at.replace(tzinfo=timezone.utc) + return expires_at > datetime.now(timezone.utc) + + +def _resolve_user_by_api_key(api_key: str, db: Session) -> User | None: + token = (api_key or "").strip() + if not token.startswith("sto-"): + return None + key_hash = _hash_password(token) + row = db.query(UserApiKey).filter(UserApiKey.key_hash == key_hash).first() + if not row or not _api_key_is_valid(row): + return None + user = db.query(User).filter(User.id == row.user_id, User.is_active == True).first() + return user + + +def _resolve_user_by_credential(token: str, db: Session) -> User | None: + token = (token or "").strip() + if not token: + return None + if token.startswith("sto-"): + return _resolve_user_by_api_key(token, db) + return _resolve_user_by_token(token, db) + + +def _model_to_user_api_key_out(row: UserApiKey) -> UserApiKeyOut: + expires_at = None + if row.expires_at is not None: + value = row.expires_at + if value.tzinfo is None: + value = value.replace(tzinfo=timezone.utc) + expires_at = value.isoformat() + created_at = row.created_at.isoformat() if row.created_at else "" + return UserApiKeyOut( + id=row.id, + key_prefix=row.key_prefix, + key_hint=row.key_hint, + masked_key=_mask_api_key(row.key_prefix, row.key_hint), + expires_at=expires_at, + is_active=bool(row.is_active), + created_at=created_at, + ) + + +MCP_MUTATION_TOOLS = frozenset( + { + "folder_ensure", + "api_upsert", + "workflow_upsert", + "workflow_patch_json", + "workflow_move_folder", + "api_move_folder", + "mock_upsert", + "mcp_tool_upsert", + "ssh_script_upsert", + "workflow_batch_create", + "workflow_batch_update", + } +) + +# 执行类工具:会发真实 HTTP / 跑远端脚本,必须携带有效 API Key(禁止回落 superadmin) +MCP_EXECUTION_TOOLS = frozenset( + { + "workflow_run", + "workflow_run_node", + "workflow_batch_run", + "ssh_script_run", + "workflow_run_replay", + } +) + + +def _mcp_global_require_api_key() -> bool: + return os.getenv("MCP_REQUIRE_API_KEY", "").strip().lower() in {"1", "true", "yes", "on"} + + +def _mcp_tool_requires_credential(tool: str) -> bool: + if _mcp_global_require_api_key(): + return True + return tool in MCP_MUTATION_TOOLS or tool in MCP_EXECUTION_TOOLS + + +def _extract_mcp_token(request: Request, api_key: str | None = None) -> str: + authorization = (request.headers.get("Authorization") or "").strip() + if authorization.startswith("Bearer "): + return authorization[7:].strip() + header_key = (request.headers.get("X-API-Key") or request.headers.get("x-api-key") or "").strip() + if header_key: + return header_key + return (api_key or "").strip() + + +def _creator_fields(user: User) -> dict[str, Any]: + return { + "creator_id": user.id, + "creator_name": user.display_name or user.username, + } + + +def _resolve_mcp_actor( + request: Request, + db: Session, + *, + api_key: str | None = None, + require_credential: bool = False, +) -> User: + token = _extract_mcp_token(request, api_key) + if token: + user = _resolve_user_by_credential(token, db) + if not user: + raise HTTPException(status_code=401, detail="invalid api key or token") + return user + if require_credential: + detail = ( + "MCP requires API Key: Authorization: Bearer or X-API-Key header" + if _mcp_global_require_api_key() + else "MCP mutation/execution requires API Key: Authorization: Bearer or X-API-Key header" + ) + raise HTTPException(status_code=401, detail=detail) + return _get_system_user(db) + + +def _is_superadmin(user: User) -> bool: + return user.role == "superadmin" + + +def _get_system_user(db: Session) -> User: + user = db.query(User).filter(User.role == "superadmin", User.is_active == True).order_by(User.id.asc()).first() + if not user: + raise HTTPException(status_code=500, detail="superadmin not initialized") + return user + + +def _ensure_default_superadmin(): + username = os.getenv("DEFAULT_SUPERADMIN_USERNAME", "admin") + password = os.getenv("DEFAULT_SUPERADMIN_PASSWORD", "admin123456") + display_name = os.getenv("DEFAULT_SUPERADMIN_DISPLAY_NAME", "系统超管") + with Session(engine) as db: + row = db.query(User).filter(User.username == username).first() + if row: + if row.role != "superadmin": + row.role = "superadmin" + if not row.display_name: + row.display_name = display_name + db.commit() + return + db.add( + User( + username=username, + password_hash=_hash_password(password), + display_name=display_name, + role="superadmin", + is_active=True, + ) + ) + db.commit() + + +_ensure_sqlite_columns() +_ensure_default_superadmin() + +app = FastAPI(title="质量检测平台", version="0.1.0") +FRONTEND_DIST_DIR = Path(__file__).resolve().parent.parent / "frontend-admin" / "dist" +FRONTEND_ASSETS_DIR = FRONTEND_DIST_DIR / "assets" +FRONTEND_INDEX_FILE = FRONTEND_DIST_DIR / "index.html" +if FRONTEND_ASSETS_DIR.exists(): + app.mount("/assets", StaticFiles(directory=str(FRONTEND_ASSETS_DIR)), name="frontend-assets") + +RUNNING_WORKFLOW_SNAPSHOTS: dict[str, dict[str, Any]] = {} +RUNNING_WORKFLOW_LOCK = threading.RLock() + + +def _split_csv_env(value: str | None) -> list[str]: + return [item.strip() for item in (value or "").split(",") if item.strip()] + + +frontend_origins = _split_csv_env( + os.getenv("FRONTEND_CORS_ORIGINS", "http://localhost:5173,http://127.0.0.1:5173") +) +app.add_middleware( + CORSMiddleware, + allow_origins=frontend_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +def model_to_api_out(row: ApiDefinition) -> ApiOut: + cfg = json.loads(row.config_json) + return ApiOut( + id=row.id, + name=row.name, + folder_path=row.folder_path or "", + method=row.method, + url=row.url, + creator_id=row.creator_id or 0, + creator_name=row.creator_name or "", + **cfg, + ) + + +def model_to_workflow_out(row: Workflow) -> WorkflowOut: + definition = json.loads(row.definition_json) + last_run = json.loads(row.last_run_json) if row.last_run_json else None + default_headers = json.loads(row.default_headers_json or "{}") + return WorkflowOut( + id=row.id, + name=row.name, + folder_path=row.folder_path or "", + creator_id=row.creator_id or 0, + creator_name=row.creator_name or "", + definition=definition, + default_headers=default_headers if isinstance(default_headers, dict) else {}, + last_run=last_run, + ) + + +def model_to_mock_out(row: MockDataset) -> MockDataOut: + return MockDataOut( + id=row.id, + name=row.name, + data=json.loads(row.data_json), + creator_id=row.creator_id or 0, + creator_name=row.creator_name or "", + ) + + +def model_to_mcp_out(row: McpToolConfig) -> McpToolOut: + return McpToolOut( + id=row.id, + name=row.name, + enabled=row.enabled, + config=json.loads(row.config_json), + creator_id=row.creator_id or 0, + creator_name=row.creator_name or "", + ) + + +def _normalize_ssh_presets(value: Any) -> list[dict[str, str]]: + if not isinstance(value, list): + return [] + items: list[dict[str, str]] = [] + for item in value: + if not isinstance(item, dict): + continue + name = str(item.get("name", "")).strip() + command = str(item.get("command", "")).strip() + if not name or not command: + continue + items.append( + { + "name": name, + "command": command, + "description": str(item.get("description", "")).strip(), + } + ) + return items + + +def model_to_ssh_profile_out(row: SshProfile) -> SshProfileOut: + cfg = json.loads(row.config_json or "{}") + if not isinstance(cfg, dict): + cfg = {} + auth_type = "password" if cfg.get("auth_type") == "password" else "key" + return SshProfileOut( + id=row.id, + name=row.name, + host=row.host, + port=int(row.port or 22), + username=row.username, + auth_type=auth_type, + key_path=str(cfg.get("key_path", "")).strip(), + presets=_normalize_ssh_presets(cfg.get("presets")), + creator_id=row.creator_id or 0, + creator_name=row.creator_name or "", + ) + + +def model_to_ssh_shortcut_out(row: SshShortcut) -> SshShortcutOut: + return SshShortcutOut( + id=row.id, + name=row.name, + command=row.command, + description=row.description or "", + creator_id=row.creator_id or 0, + creator_name=row.creator_name or "", + ) + + +def model_to_ssh_script_out(row: SshScript) -> SshScriptOut: + return SshScriptOut( + id=row.id, + profile_id=row.profile_id, + name=row.name, + description=row.description or "", + source_type=row.source_type or "inline", + content=row.content or "", + storage_path=row.storage_path or "", + creator_id=row.creator_id or 0, + creator_name=row.creator_name or "", + ) + + +def _ensure_ssh_scripts_dir() -> Path: + SSH_SCRIPTS_DIR.mkdir(parents=True, exist_ok=True) + return SSH_SCRIPTS_DIR + + +def _sanitize_script_filename(name: str) -> str: + base = Path(name or "script.sh").name + safe = "".join(ch if ch.isalnum() or ch in "._-" else "_" for ch in base).strip("._") + if not safe: + safe = "script.sh" + if not safe.endswith((".sh", ".bash")): + safe = f"{safe}.sh" + return safe + + +def _get_ssh_script_row(db: Session, script_id: int) -> SshScript: + row = db.query(SshScript).filter(SshScript.id == script_id).first() + if not row: + raise HTTPException(status_code=404, detail="ssh script not found") + return row + + +def _build_ssh_tree(db: Session, user: User) -> list[SshTreeProfileNode]: + profiles = _scope_query_by_user(db.query(SshProfile), SshProfile, user).order_by(SshProfile.id.desc()).all() + if not profiles: + return [] + profile_ids = [row.id for row in profiles] + script_rows = ( + _scope_query_by_user(db.query(SshScript), SshScript, user) + .filter(SshScript.profile_id.in_(profile_ids)) + .order_by(SshScript.id.asc()) + .all() + ) + scripts_by_profile: dict[int, list[SshScript]] = {} + for script_row in script_rows: + scripts_by_profile.setdefault(script_row.profile_id, []).append(script_row) + + tree: list[SshTreeProfileNode] = [] + for profile_row in profiles: + cfg = json.loads(profile_row.config_json or "{}") + script_nodes = [ + SshTreeScriptNode( + id=script_row.id, + name=script_row.name, + description=script_row.description or "", + source_type=script_row.source_type or "inline", + storage_path=script_row.storage_path or "", + ) + for script_row in scripts_by_profile.get(profile_row.id, []) + ] + tree.append( + SshTreeProfileNode( + id=profile_row.id, + name=profile_row.name, + host=profile_row.host, + port=int(profile_row.port or 22), + username=profile_row.username, + auth_type=cfg.get("auth_type", "key"), + key_path=str(cfg.get("key_path", "")), + scripts=script_nodes, + ) + ) + return tree + + +def _delete_ssh_script_files(row: SshScript) -> None: + if row.source_type == "file" and row.storage_path: + file_path = (SSH_SCRIPTS_DIR / row.storage_path).resolve() + scripts_root = SSH_SCRIPTS_DIR.resolve() + if scripts_root in file_path.parents and file_path.is_file(): + with contextlib.suppress(OSError): + file_path.unlink() + + +MCP_TOOL_SPECS = [ + { + "name": "api_upsert", + "description": "Create or update one API definition.", + "input_schema": { + "type": "object", + "required": ["name", "method", "url"], + "properties": { + "api_id": {"type": "integer"}, + "name": {"type": "string"}, + "folder_path": {"type": "string"}, + "method": {"type": "string"}, + "url": {"type": "string"}, + "headers": {"type": "object"}, + "body": {"type": "object"}, + "query": {"type": "object"}, + "path_params": {"type": "object"}, + "timeout_seconds": {"type": "number"}, + }, + }, + }, + { + "name": "workflow_upsert", + "description": ( + "Create or update workflow JSON (requires sto- API Key; sets creator from key owner). " + "http nodes carry inline request fields " + "(method,url,headers,body,query,path_params,timeout_seconds), optional mock " + "{status_code,headers,json,text,is_mock}, mode in {http,mock,auto}, expect " + "{status_in,json_contains,text_includes}. " + "condition nodes branch true/false; loop nodes use body/done edges with while_* continue rules. " + "Each node persists last_run after exec. " + "Non-empty folder_path must be pre-registered for target workflows (folder_ensure) " + "before create or before changing folder_path on update." + ), + "input_schema": { + "type": "object", + "required": ["name", "definition"], + "properties": { + "workflow_id": {"type": "integer"}, + "name": {"type": "string"}, + "folder_path": { + "type": "string", + "description": "Empty string = root bucket. Any other path must exist in folder_entries for workflows.", + }, + "definition": {"type": "object"}, + }, + }, + }, + { + "name": "workflow_get", + "description": "Fetch full workflow including each node's last_run state.", + "input_schema": { + "type": "object", + "required": ["workflow_id"], + "properties": {"workflow_id": {"type": "integer"}}, + }, + }, + { + "name": "workflow_node_status", + "description": "Read the latest last_run status of a node inside a workflow.", + "input_schema": { + "type": "object", + "required": ["workflow_id", "node_id"], + "properties": { + "workflow_id": {"type": "integer"}, + "node_id": {"type": "string"}, + }, + }, + }, + { + "name": "folder_ensure", + "description": "Create folder path for apis/workflows.", + "input_schema": { + "type": "object", + "required": ["target", "path"], + "properties": { + "target": {"type": "string", "enum": ["apis", "workflows"]}, + "path": {"type": "string"}, + }, + }, + }, + { + "name": "folder_list", + "description": "List folder paths for apis/workflows.", + "input_schema": { + "type": "object", + "properties": {"target": {"type": "string", "enum": ["apis", "workflows"]}}, + }, + }, + { + "name": "api_move_folder", + "description": "Move an API definition into folder path.", + "input_schema": { + "type": "object", + "required": ["api_id", "folder_path"], + "properties": {"api_id": {"type": "integer"}, "folder_path": {"type": "string"}}, + }, + }, + { + "name": "workflow_move_folder", + "description": ( + "Move workflow into folder path. Destination must be pre-registered (folder_ensure workflows), " + "except empty root path." + ), + "input_schema": { + "type": "object", + "required": ["workflow_id", "folder_path"], + "properties": {"workflow_id": {"type": "integer"}, "folder_path": {"type": "string"}}, + }, + }, + { + "name": "workflow_patch_json", + "description": "Patch workflow definition JSON by deep merge.", + "input_schema": { + "type": "object", + "required": ["workflow_id", "patch"], + "properties": {"workflow_id": {"type": "integer"}, "patch": {"type": "object"}}, + }, + }, + { + "name": "workflow_run", + "description": "Run workflow and return node results (requires sto- API Key).", + "input_schema": { + "type": "object", + "required": ["workflow_id"], + "properties": { + "workflow_id": {"type": "integer"}, + "base_url": {"type": "string"}, + "fail_fast": {"type": "boolean"}, + }, + }, + }, + { + "name": "workflow_batch_create", + "description": ( + "Create a workflow batch job in draft status (requires sto- API Key). " + "Configure workflow_ids before workflow_batch_run." + ), + "input_schema": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "base_url": {"type": "string"}, + "fail_fast": {"type": "boolean"}, + "workflow_ids": {"type": "array", "items": {"type": "integer"}}, + }, + }, + }, + { + "name": "workflow_batch_update", + "description": ( + "Update a draft workflow batch: name, base_url, fail_fast, workflow_ids (requires sto- API Key)." + ), + "input_schema": { + "type": "object", + "required": ["batch_id"], + "properties": { + "batch_id": {"type": "integer"}, + "name": {"type": "string"}, + "base_url": {"type": "string"}, + "fail_fast": {"type": "boolean"}, + "workflow_ids": {"type": "array", "items": {"type": "integer"}}, + }, + }, + }, + { + "name": "workflow_batch_run", + "description": "Execute a draft workflow batch and return per-workflow run results (requires sto- API Key).", + "input_schema": { + "type": "object", + "required": ["batch_id"], + "properties": {"batch_id": {"type": "integer"}}, + }, + }, + { + "name": "workflow_batch_get", + "description": "Get workflow batch detail including linked run records.", + "input_schema": { + "type": "object", + "required": ["batch_id"], + "properties": {"batch_id": {"type": "integer"}}, + }, + }, + { + "name": "workflow_batch_list", + "description": "List workflow batches for the current user.", + "input_schema": { + "type": "object", + "properties": { + "limit": {"type": "integer"}, + "after_id": {"type": "integer"}, + "status": {"type": "string", "description": "Filter: draft|running|success|failed|partial"}, + }, + }, + }, + { + "name": "workflow_run_node", + "description": "Run single node in a workflow (requires sto- API Key).", + "input_schema": { + "type": "object", + "required": ["workflow_id", "node_id"], + "properties": { + "workflow_id": {"type": "integer"}, + "node_id": {"type": "string"}, + "base_url": {"type": "string"}, + }, + }, + }, + { + "name": "workflow_analyze_last_run", + "description": "Analyze last run summary of a workflow.", + "input_schema": { + "type": "object", + "required": ["workflow_id"], + "properties": {"workflow_id": {"type": "integer"}}, + }, + }, + { + "name": "workflow_run_list", + "description": "List workflow execution history (filter by workflow_id and/or batch_id).", + "input_schema": { + "type": "object", + "properties": { + "workflow_id": {"type": "integer"}, + "batch_id": {"type": "integer"}, + "limit": {"type": "integer"}, + "after_id": {"type": "integer"}, + }, + }, + }, + { + "name": "workflow_run_get", + "description": "Get one workflow run record including full payload (results, variables).", + "input_schema": { + "type": "object", + "required": ["run_id"], + "properties": {"run_id": {"type": "integer"}}, + }, + }, + { + "name": "workflow_run_replay", + "description": ( + "Replay a historical run using its stored workflow snapshot (requires sto- API Key)." + ), + "input_schema": { + "type": "object", + "required": ["run_id"], + "properties": { + "run_id": {"type": "integer"}, + "base_url": {"type": "string"}, + "fail_fast": {"type": "boolean"}, + }, + }, + }, + { + "name": "workflow_run_loki_link", + "description": "Build Grafana/Loki explore URL for one HTTP node in a run.", + "input_schema": { + "type": "object", + "required": ["run_id", "node_id"], + "properties": { + "run_id": {"type": "integer"}, + "node_id": {"type": "string"}, + }, + }, + }, + { + "name": "workflow_validate", + "description": "Validate workflow definition JSON (node ids, edge references) before upsert.", + "input_schema": { + "type": "object", + "required": ["definition"], + "properties": {"definition": {"type": "object"}}, + }, + }, + { + "name": "mock_upsert", + "description": "Create or update mock dataset.", + "input_schema": { + "type": "object", + "required": ["name", "data"], + "properties": {"name": {"type": "string"}, "data": {"type": "object"}}, + }, + }, + { + "name": "mcp_tool_upsert", + "description": "Create or update MCP tool config.", + "input_schema": { + "type": "object", + "required": ["name"], + "properties": { + "name": {"type": "string"}, + "enabled": {"type": "boolean"}, + "config": {"type": "object"}, + }, + }, + }, + { + "name": "catalog_snapshot", + "description": ( + "Return APIs/workflows/mocks/workflow_batches/mcp-tool configs and SSH host/script tree." + ), + "input_schema": {"type": "object", "properties": {}}, + }, + { + "name": "ssh_tree", + "description": "List SSH hosts and nested scripts (host tree for script management).", + "input_schema": {"type": "object", "properties": {}}, + }, + { + "name": "ssh_script_get", + "description": "Get SSH script metadata and inline content by script_id.", + "input_schema": { + "type": "object", + "required": ["script_id"], + "properties": {"script_id": {"type": "integer"}}, + }, + }, + { + "name": "ssh_script_upsert", + "description": ( + "Create or update inline bash script under an SSH host. " + "AI should pass full script content (shebang recommended). " + "Use profile_id + name for create; script_id for update." + ), + "input_schema": { + "type": "object", + "required": ["name", "content"], + "properties": { + "script_id": {"type": "integer"}, + "profile_id": {"type": "integer"}, + "name": {"type": "string"}, + "description": {"type": "string"}, + "content": {"type": "string", "description": "Full bash script body"}, + "source_type": {"type": "string", "enum": ["inline"], "default": "inline"}, + }, + }, + }, + { + "name": "ssh_script_run", + "description": ( + "Run SSH script on remote host and return collected stdout/stderr (requires sto- API Key). " + "password is required. Long-running scripts (e.g. tail -f) stop at timeout_seconds." + ), + "input_schema": { + "type": "object", + "required": ["profile_id", "script_id", "password"], + "properties": { + "profile_id": {"type": "integer"}, + "script_id": {"type": "integer"}, + "password": {"type": "string"}, + "timeout_seconds": {"type": "number", "default": 120}, + }, + }, + }, +] + + +def _get_current_user( + authorization: str | None = Header(default=None), + db: Session = Depends(get_db), +) -> User: + header = (authorization or "").strip() + if not header.startswith("Bearer "): + raise HTTPException(status_code=401, detail="missing bearer token") + token = header[7:].strip() + user = _resolve_user_by_credential(token, db) + if not user: + raise HTTPException(status_code=401, detail="invalid token") + return user + + +def _get_websocket_user(websocket: WebSocket) -> User: + token = (websocket.query_params.get("token") or "").strip() + if not token: + raise HTTPException(status_code=401, detail="missing bearer token") + with SessionLocal() as db: + user = _resolve_user_by_credential(token, db) + if not user: + raise HTTPException(status_code=401, detail="invalid token") + db.expunge(user) + return user + + +def _require_superadmin(user: User = Depends(_get_current_user)) -> User: + if not _is_superadmin(user): + raise HTTPException(status_code=403, detail="superadmin required") + return user + + +def _assert_owner_or_superadmin(user: User, row: Any) -> None: + if _is_superadmin(user): + return + if getattr(row, "creator_id", None) != user.id: + raise HTTPException(status_code=403, detail="no permission for this resource") + + +def _scope_query_by_user(query: Any, model: Any, user: User): + if _is_superadmin(user): + return query + return query.filter(model.creator_id == user.id) + + +def _frontend_index_response() -> FileResponse: + if not FRONTEND_INDEX_FILE.exists(): + raise HTTPException(status_code=503, detail="frontend dist not found, run `cd frontend-admin && npm run build`") + return FileResponse(FRONTEND_INDEX_FILE) + + +def _normalize_folder_path(value: str | None) -> str: + text_value = (value or "").strip().replace("\\", "/") + if text_value in {"", "/"}: + return "" + parts = [part for part in text_value.split("/") if part] + return "/".join(parts) + + +def _ensure_folder_entry(db: Session, target: str, path: str, user: User | None = None): + if target not in {"apis", "workflows"}: + raise HTTPException(status_code=400, detail="target must be apis or workflows") + normalized = _normalize_folder_path(path) + if not normalized: + return + row_query = db.query(FolderEntry).filter(FolderEntry.target == target, FolderEntry.path == normalized) + if user and not _is_superadmin(user): + row_query = row_query.filter(FolderEntry.creator_id == user.id) + row = row_query.first() + if not row: + db.add( + FolderEntry( + target=target, + path=normalized, + creator_id=(user.id if user else 1), + creator_name=(user.display_name or user.username) if user else "admin", + ) + ) + db.commit() + + +def _assert_workflow_folder_registered(db: Session, folder_path: str, user: User | None = None) -> None: + """工作流目录须先登记:非根路径必须在 folder_entries(workflows) 中存在;保存工作流时不再隐式创建目录。""" + normalized = _normalize_folder_path(folder_path) + if not normalized: + return + query = db.query(FolderEntry.id).filter(FolderEntry.target == "workflows", FolderEntry.path == normalized) + if user and not _is_superadmin(user): + query = query.filter(FolderEntry.creator_id == user.id) + exists = query.first() + if not exists: + raise HTTPException( + status_code=400, + detail=( + "工作流目录未登记:请先在左侧「工作流目录」新建该路径,或调用 " + "POST /api/folders/ensure(body: {\"target\":\"workflows\",\"path\":\"...\"})," + "再保存或移动工作流到该目录。" + ), + ) + + +def _replace_folder_path_prefix(path: str, old_prefix: str, new_prefix: str) -> str: + normalized = _normalize_folder_path(path) + old = _normalize_folder_path(old_prefix) + new = _normalize_folder_path(new_prefix) + if normalized == old: + return new + if old and normalized.startswith(old + "/"): + suffix = normalized[len(old) :] + return _normalize_folder_path(f"{new}{suffix}") + return normalized + + +def _folder_path_is_under(path: str, ancestor: str) -> bool: + normalized = _normalize_folder_path(path) + parent = _normalize_folder_path(ancestor) + if not parent: + return False + return normalized == parent or normalized.startswith(parent + "/") + + +def _move_folder_tree(db: Session, target: str, old_path: str, new_path: str, user: User) -> dict[str, Any]: + if target not in {"apis", "workflows"}: + raise HTTPException(status_code=400, detail="target must be apis or workflows") + old = _normalize_folder_path(old_path) + new = _normalize_folder_path(new_path) + if not old: + raise HTTPException(status_code=400, detail="from_path cannot be root") + if not new: + raise HTTPException(status_code=400, detail="to_path cannot be root") + if new == old: + return {"target": target, "from_path": old, "to_path": new, "moved_entries": 0, "moved_resources": 0} + if _folder_path_is_under(new, old): + raise HTTPException(status_code=400, detail="cannot move folder into its descendant") + + conflict_query = db.query(FolderEntry).filter(FolderEntry.target == target, FolderEntry.path == new) + if not _is_superadmin(user): + conflict_query = conflict_query.filter(FolderEntry.creator_id == user.id) + if conflict_query.first(): + raise HTTPException(status_code=400, detail=f"folder already exists: {new}") + + moved_entries = 0 + moved_resources = 0 + + entry_query = db.query(FolderEntry).filter(FolderEntry.target == target) + if not _is_superadmin(user): + entry_query = entry_query.filter(FolderEntry.creator_id == user.id) + for row in entry_query.all(): + if not _folder_path_is_under(row.path, old) and row.path != old: + continue + updated = _replace_folder_path_prefix(row.path, old, new) + if updated != row.path: + row.path = updated + moved_entries += 1 + + if target == "apis": + resource_query = _scope_query_by_user(db.query(ApiDefinition), ApiDefinition, user) + else: + resource_query = _scope_query_by_user(db.query(Workflow), Workflow, user) + for row in resource_query.all(): + if not _folder_path_is_under(row.folder_path, old) and _normalize_folder_path(row.folder_path) != old: + continue + updated = _replace_folder_path_prefix(row.folder_path, old, new) + if updated != row.folder_path: + row.folder_path = updated + moved_resources += 1 + + _ensure_folder_entry(db, target, new, user) + db.commit() + return { + "target": target, + "from_path": old, + "to_path": new, + "moved_entries": moved_entries, + "moved_resources": moved_resources, + } + + +def _build_folder_tree(path_count_map: dict[str, int]) -> dict[str, Any]: + root: dict[str, Any] = {"name": "/", "path": "", "count": 0, "children": {}} + for path, count in path_count_map.items(): + normalized = _normalize_folder_path(path) + if not normalized: + root["count"] += count + continue + parts = [item for item in normalized.split("/") if item] + node = root + node["count"] += count + current_path = "" + for part in parts: + current_path = f"{current_path}/{part}".strip("/") + if part not in node["children"]: + node["children"][part] = { + "name": part, + "path": current_path, + "count": 0, + "children": {}, + } + node = node["children"][part] + node["count"] += count + + def serialize(node: dict[str, Any]) -> dict[str, Any]: + children = [serialize(item) for item in node["children"].values()] + children.sort(key=lambda x: x["path"]) + return {"name": node["name"], "path": node["path"], "count": node["count"], "children": children} + + return serialize(root) + + +def _deep_merge_dict(base: dict[str, Any], patch: dict[str, Any]) -> dict[str, Any]: + output = dict(base) + for key, value in patch.items(): + if isinstance(value, dict) and isinstance(output.get(key), dict): + output[key] = _deep_merge_dict(output[key], value) + else: + output[key] = value + return output + + +def _collect_runtime_maps(db: Session, user: User): + apis = _scope_query_by_user(db.query(ApiDefinition), ApiDefinition, user).all() + mocks = _scope_query_by_user(db.query(MockDataset), MockDataset, user).all() + api_map = {} + for row in apis: + cfg = json.loads(row.config_json) + api_map[row.id] = {"method": row.method, "url": row.url, **cfg} + mock_map = {row.name: json.loads(row.data_json) for row in mocks} + return api_map, mock_map + + +def _build_ssh_config_json(payload: SshProfileCreate | SshProfileUpdate) -> str: + return json.dumps( + { + "auth_type": payload.auth_type, + "key_path": (payload.key_path or "").strip(), + "presets": [item.model_dump() for item in payload.presets], + }, + ensure_ascii=False, + ) + + +def _resolve_ssh_command(profile: SshProfileOut, payload: SshExecuteRequest) -> str: + command = (payload.command or "").strip() + if command: + return command + preset_name = (payload.preset_name or "").strip() + if preset_name: + for item in profile.presets: + if item.name == preset_name: + return item.command.strip() + raise HTTPException(status_code=400, detail="preset command not found") + return "echo connected" + + +def _run_ssh_command(profile: SshProfileOut, command: str, password: str, timeout_seconds: float) -> SshExecuteResponse: + timeout_value = max(3.0, min(float(timeout_seconds or 20.0), 120.0)) + ssh_args = [ + "ssh", + "-o", + "StrictHostKeyChecking=accept-new", + "-o", + "ConnectTimeout=10", + "-o", + "NumberOfPasswordPrompts=1", + "-p", + str(int(profile.port or 22)), + ] + if profile.auth_type == "key" and profile.key_path: + ssh_args.extend(["-i", profile.key_path]) + ssh_args.extend([f"{profile.username}@{profile.host}", command]) + + run_args = ssh_args + env = os.environ.copy() + askpass_dir = None + + if profile.auth_type == "password": + if not password.strip(): + raise HTTPException(status_code=400, detail="password is required for password auth profile") + askpass_dir = tempfile.TemporaryDirectory() + askpass_path = os.path.join(askpass_dir.name, "ssh_askpass.sh") + with open(askpass_path, "w", encoding="utf-8") as handle: + handle.write("#!/bin/sh\n") + handle.write("printf '%s' \"$SSH_PASSWORD\"\n") + os.chmod(askpass_path, 0o700) + env.update( + { + "SSH_ASKPASS": askpass_path, + "SSH_ASKPASS_REQUIRE": "force", + "SSH_PASSWORD": password, + "DISPLAY": env.get("DISPLAY", "codex:0"), + } + ) + run_args = ["setsid", *ssh_args] + + try: + completed = subprocess.run( + run_args, + check=False, + stdin=subprocess.DEVNULL, + capture_output=True, + text=True, + timeout=timeout_value, + env=env, + ) + except subprocess.TimeoutExpired as exc: + raise HTTPException(status_code=408, detail=f"ssh command timeout after {int(timeout_value)}s") from exc + except FileNotFoundError as exc: + missing_cmd = run_args[0] if run_args else "ssh" + raise HTTPException(status_code=500, detail=f"{missing_cmd} command is not available") from exc + finally: + if askpass_dir is not None: + askpass_dir.cleanup() + + return SshExecuteResponse( + ok=completed.returncode == 0, + command=command, + stdout=completed.stdout or "", + stderr=completed.stderr or "", + exit_status=int(completed.returncode or 0), + ) + + +def _detect_trigger_source(request: Request) -> str: + path = request.url.path or "" + if path.startswith("/mcp/"): + return "mcp" + referer = request.headers.get("referer", "") + if "127.0.0.1:8000" in referer or "localhost:8000" in referer: + return "ui" + return "api" + + +def _record_workflow_run( + db: Session, + workflow: Workflow, + *, + run_type: str, + triggered_by: str, + status: str, + duration_ms: int, + summary: dict[str, Any], + payload: dict[str, Any], + node_id: str | None = None, + batch_id: int | None = None, +) -> WorkflowRun: + row = WorkflowRun( + batch_id=batch_id, + workflow_id=workflow.id, + workflow_name=workflow.name or "", + run_type=run_type, + node_id=node_id, + triggered_by=triggered_by, + status=status, + duration_ms=duration_ms, + summary_json=json.dumps(summary, ensure_ascii=False), + payload_json=json.dumps(payload, ensure_ascii=False), + ) + db.add(row) + db.commit() + db.refresh(row) + return row + + +def _workflow_default_headers(workflow: Workflow) -> dict[str, Any]: + try: + value = json.loads(workflow.default_headers_json or "{}") + except Exception: + value = {} + return value if isinstance(value, dict) else {} + + +def _summarize_results(results: list[dict[str, Any]]) -> dict[str, Any]: + counters = {"success": 0, "failed": 0, "skipped": 0} + failed_nodes: list[str] = [] + for item in results: + status_value = str(item.get("status", "success")) + counters[status_value] = counters.get(status_value, 0) + 1 + if status_value == "failed": + failed_nodes.append(str(item.get("node_id", ""))) + return { + "total": len(results), + "success": counters["success"], + "failed": counters["failed"], + "skipped": counters["skipped"], + "failed_nodes": failed_nodes, + } + + +def _model_to_run_dict(row: WorkflowRun) -> dict[str, Any]: + return { + "id": row.id, + "batch_id": row.batch_id, + "workflow_id": row.workflow_id, + "workflow_name": row.workflow_name, + "run_type": row.run_type, + "node_id": row.node_id, + "triggered_by": row.triggered_by, + "status": row.status, + "duration_ms": row.duration_ms, + "summary": json.loads(row.summary_json or "{}"), + "created_at": row.created_at.isoformat() if row.created_at else None, + } + + +def _validate_workflow_definition(definition: dict[str, Any]) -> dict[str, Any]: + node_ids = set() + for node in definition.get("nodes", []): + if "id" not in node: + return {"ok": False, "reason": "node missing id"} + node_ids.add(node["id"]) + for edge in definition.get("edges", []): + if edge.get("source") not in node_ids or edge.get("target") not in node_ids: + return {"ok": False, "reason": "edge links unknown node"} + return {"ok": True} + + +def _list_workflow_runs_for_actor( + db: Session, + actor: User, + *, + workflow_id: int | None = None, + batch_id: int | None = None, + limit: int = 30, + after_id: int | None = None, +) -> list[dict[str, Any]]: + limit = max(1, min(int(limit or 30), 200)) + query = db.query(WorkflowRun) + if workflow_id is not None: + query = query.filter(WorkflowRun.workflow_id == workflow_id) + if batch_id is not None: + query = query.filter(WorkflowRun.batch_id == int(batch_id)) + if after_id is not None: + query = query.filter(WorkflowRun.id > int(after_id)) + if not _is_superadmin(actor): + owned_ids = [row.id for row in _scope_query_by_user(db.query(Workflow), Workflow, actor).all()] + if not owned_ids: + return [] + query = query.filter(WorkflowRun.workflow_id.in_(owned_ids)) + rows = query.order_by(WorkflowRun.id.desc()).limit(limit).all() + return [_model_to_run_dict(item) for item in rows] + + +def _get_workflow_run_for_actor(db: Session, actor: User, run_id: int) -> dict[str, Any]: + row = db.query(WorkflowRun).filter(WorkflowRun.id == run_id).first() + if not row: + raise HTTPException(status_code=404, detail="run not found") + workflow = db.query(Workflow).filter(Workflow.id == row.workflow_id).first() + if not workflow: + raise HTTPException(status_code=404, detail="workflow not found") + _assert_owner_or_superadmin(actor, workflow) + base = _model_to_run_dict(row) + base["payload"] = json.loads(row.payload_json or "{}") + return base + + +def _workflow_run_loki_link_for_actor(db: Session, actor: User, run_id: int, node_id: str) -> dict[str, Any]: + row = db.query(WorkflowRun).filter(WorkflowRun.id == run_id).first() + if not row: + raise HTTPException(status_code=404, detail="run not found") + workflow = db.query(Workflow).filter(Workflow.id == row.workflow_id).first() + if not workflow: + raise HTTPException(status_code=404, detail="workflow not found") + _assert_owner_or_superadmin(actor, workflow) + + payload = json.loads(row.payload_json or "{}") + node_result: dict[str, Any] | None = None + if row.run_type == "node": + candidate = payload.get("result") + if isinstance(candidate, dict) and str(candidate.get("node_id", "")) == node_id: + node_result = candidate + else: + for item in payload.get("results", []): + if isinstance(item, dict) and str(item.get("node_id", "")) == node_id: + node_result = item + break + if not node_result: + raise HTTPException(status_code=404, detail="node result not found in run payload") + return build_run_loki_link( + run_created_at=row.created_at.isoformat() if row.created_at else None, + duration_ms=int(row.duration_ms or 0), + node_result=node_result, + ) + + +async def _replay_workflow_run_for_actor( + db: Session, + actor: User, + run_id: int, + *, + base_url: str = "", + fail_fast: bool = True, + triggered_by: str = "mcp", +) -> dict[str, Any]: + row = db.query(WorkflowRun).filter(WorkflowRun.id == run_id).first() + if not row: + raise HTTPException(status_code=404, detail="run not found") + run_payload = json.loads(row.payload_json or "{}") + snapshot = run_payload.get("workflow_snapshot") or {} + definition = snapshot.get("definition") + if not isinstance(definition, dict): + raise HTTPException(status_code=400, detail="run snapshot missing workflow definition") + default_headers = snapshot.get("default_headers") or {} + if not isinstance(default_headers, dict): + default_headers = {} + workflow = db.query(Workflow).filter(Workflow.id == row.workflow_id).first() + if not workflow: + raise HTTPException(status_code=404, detail="workflow not found") + _assert_owner_or_superadmin(actor, workflow) + + api_map, mock_map = _collect_runtime_maps(db, actor) + active_run_id = secrets.token_hex(12) + runtime_snapshot = _init_runtime_snapshot( + workflow, + actor, + RunWorkflowRequest(workflow_id=workflow.id, base_url=base_url, fail_fast=fail_fast), + definition, + ) + runtime_snapshot["active_run_id"] = active_run_id + _set_runtime_snapshot(active_run_id, runtime_snapshot) + on_node_start, on_node_finish = _build_runtime_node_callbacks(active_run_id) + started = time.perf_counter() + try: + run_result = await execute_workflow( + definition=definition, + api_map=api_map, + mock_map=mock_map, + base_url=base_url, + fail_fast=fail_fast, + default_headers=default_headers, + on_node_start=on_node_start, + on_node_finish=on_node_finish, + ) + finally: + duration_ms = int((time.perf_counter() - started) * 1000) + _clear_runtime_snapshot(active_run_id) + replay_row = _record_workflow_run( + db, + workflow, + run_type="full", + triggered_by=triggered_by, + status=run_result["status"], + duration_ms=duration_ms, + summary=_summarize_results(run_result.get("results", [])), + payload={ + "replayed_from_run_id": run_id, + "workflow_snapshot": { + "id": snapshot.get("id", workflow.id), + "name": snapshot.get("name", workflow.name), + "folder_path": snapshot.get("folder_path", workflow.folder_path or ""), + "default_headers": default_headers, + "definition": definition, + }, + "request": {"base_url": base_url, "fail_fast": fail_fast}, + "results": run_result.get("results", []), + "variables": run_result.get("variables", {}), + }, + ) + return RunWorkflowResponse( + status=run_result["status"], + variables=run_result["variables"], + results=run_result["results"], + run_id=replay_row.id, + workflow_id=workflow.id, + ).model_dump() + + +def _clone_runtime_snapshot(snapshot: dict[str, Any] | None) -> dict[str, Any]: + return json.loads(json.dumps(snapshot or {}, ensure_ascii=False)) + + +def _init_runtime_snapshot(workflow: Workflow, user: User, payload: RunWorkflowRequest, definition: dict[str, Any]) -> dict[str, Any]: + nodes = definition.get("nodes", []) + return { + "workflow_id": workflow.id, + "workflow_name": workflow.name or "", + "workflow_folder_path": workflow.folder_path or "", + "creator_id": getattr(workflow, "creator_id", 0), + "viewer_user_id": user.id, + "triggered_by": user.display_name or user.username, + "status": "running", + "started_at": time.time(), + "updated_at": time.time(), + "current_node_id": None, + "current_node_label": "", + "completed_nodes": 0, + "total_nodes": len(nodes), + "request": { + "base_url": payload.base_url, + "fail_fast": payload.fail_fast, + }, + "definition": _clone_runtime_snapshot(definition), + "node_statuses": { + str(node.get("id", "")): { + "node_id": str(node.get("id", "")), + "label": str((node.get("data") or {}).get("label") or node.get("id") or ""), + "type": str((node.get("data") or {}).get("type") or "http"), + "status": "pending", + "started_at": None, + "finished_at": None, + "error": None, + } + for node in nodes + if node.get("id") + }, + } + + +def _set_runtime_snapshot(snapshot_key: str, snapshot: dict[str, Any]) -> None: + with RUNNING_WORKFLOW_LOCK: + RUNNING_WORKFLOW_SNAPSHOTS[snapshot_key] = _clone_runtime_snapshot(snapshot) + + +def _get_runtime_snapshot(snapshot_key: str) -> dict[str, Any] | None: + with RUNNING_WORKFLOW_LOCK: + snapshot = RUNNING_WORKFLOW_SNAPSHOTS.get(snapshot_key) + return _clone_runtime_snapshot(snapshot) if snapshot else None + + +def _list_runtime_snapshots_for_user(user: User) -> list[dict[str, Any]]: + with RUNNING_WORKFLOW_LOCK: + items = [ + _clone_runtime_snapshot(snapshot) + for snapshot in RUNNING_WORKFLOW_SNAPSHOTS.values() + if _is_superadmin(user) or int(snapshot.get("creator_id") or 0) == user.id + ] + items.sort(key=lambda item: float(item.get("started_at") or 0), reverse=True) + return items + + +def _clear_runtime_snapshot(snapshot_key: str) -> None: + with RUNNING_WORKFLOW_LOCK: + RUNNING_WORKFLOW_SNAPSHOTS.pop(snapshot_key, None) + + +def _build_runtime_node_callbacks(snapshot_key: str) -> tuple[Callable[..., None], Callable[..., None]]: + def on_node_start(node: dict[str, Any], current_index: int, total_nodes: int) -> None: + node_id = str(node.get("id", "")) + node_data = node.get("data") or {} + now = time.time() + with RUNNING_WORKFLOW_LOCK: + snapshot = RUNNING_WORKFLOW_SNAPSHOTS.get(snapshot_key) + if not snapshot: + return + snapshot["updated_at"] = now + snapshot["current_node_id"] = node_id + snapshot["current_node_label"] = str(node_data.get("label") or node_id) + snapshot["completed_nodes"] = max(0, current_index - 1) + snapshot["total_nodes"] = total_nodes + node_status = snapshot["node_statuses"].setdefault( + node_id, + { + "node_id": node_id, + "label": str(node_data.get("label") or node_id), + "type": str(node_data.get("type") or "http"), + }, + ) + node_status["status"] = "running" + node_status["started_at"] = now + node_status["finished_at"] = None + node_status["error"] = None + + def on_node_finish(node: dict[str, Any], result: dict[str, Any], completed_nodes: int, total_nodes: int) -> None: + node_id = str(node.get("id", "")) + node_data = node.get("data") or {} + now = time.time() + with RUNNING_WORKFLOW_LOCK: + snapshot = RUNNING_WORKFLOW_SNAPSHOTS.get(snapshot_key) + if not snapshot: + return + snapshot["updated_at"] = now + snapshot["completed_nodes"] = completed_nodes + snapshot["total_nodes"] = total_nodes + node_status = snapshot["node_statuses"].setdefault( + node_id, + { + "node_id": node_id, + "label": str(node_data.get("label") or node_id), + "type": str(node_data.get("type") or "http"), + }, + ) + node_status["status"] = str(result.get("status") or "success") + node_status["finished_at"] = now + node_status["error"] = result.get("error") + if snapshot.get("current_node_id") == node_id: + snapshot["current_node_id"] = None + snapshot["current_node_label"] = "" + + return on_node_start, on_node_finish + + +def _analyze_run_payload(run_payload: dict[str, Any]) -> dict[str, Any]: + results = run_payload.get("results", []) + failed = [item for item in results if item.get("status") == "failed"] + skipped = [item for item in results if item.get("status") == "skipped"] + success = [item for item in results if item.get("status") == "success"] + return { + "status": run_payload.get("status", "unknown"), + "total_nodes": len(results), + "success_count": len(success), + "failed_count": len(failed), + "skipped_count": len(skipped), + "failed_nodes": [item.get("node_id") for item in failed], + "variables": run_payload.get("variables", {}), + } + + +def _runtime_snapshot_summary(snapshot: dict[str, Any]) -> dict[str, Any]: + node_statuses = snapshot.get("node_statuses") or {} + counts = {"pending": 0, "running": 0, "success": 0, "failed": 0, "skipped": 0} + for item in node_statuses.values(): + status = str(item.get("status") or "pending") + counts[status] = counts.get(status, 0) + 1 + return { + "active_run_id": snapshot.get("active_run_id", ""), + "workflow_id": snapshot.get("workflow_id", 0), + "workflow_name": snapshot.get("workflow_name", ""), + "workflow_folder_path": snapshot.get("workflow_folder_path", ""), + "creator_id": snapshot.get("creator_id", 0), + "triggered_by": snapshot.get("triggered_by", ""), + "status": snapshot.get("status", "running"), + "started_at": snapshot.get("started_at"), + "updated_at": snapshot.get("updated_at"), + "current_node_id": snapshot.get("current_node_id", ""), + "current_node_label": snapshot.get("current_node_label", ""), + "completed_nodes": snapshot.get("completed_nodes", 0), + "total_nodes": snapshot.get("total_nodes", 0), + "progress": { + "pending": counts["pending"], + "running": counts["running"], + "success": counts["success"], + "failed": counts["failed"], + "skipped": counts["skipped"], + }, + } + + +def _runtime_snapshot_detail(snapshot: dict[str, Any]) -> dict[str, Any]: + base = _runtime_snapshot_summary(snapshot) + base["request"] = _clone_runtime_snapshot(snapshot.get("request") or {}) + base["definition"] = _clone_runtime_snapshot(snapshot.get("definition") or {}) + base["node_states"] = _clone_runtime_snapshot(snapshot.get("node_statuses") or {}) + return base + + +@app.get("/mcp/tools") +def mcp_tools(request: Request, db: Session = Depends(get_db)): + if _mcp_global_require_api_key(): + _resolve_mcp_actor(request, db, require_credential=True) + return { + "tools": MCP_TOOL_SPECS, + "require_api_key": _mcp_global_require_api_key(), + "ai_guide": "docs/mcp_tools_for_ai.md", + "ai_guide_summary": ( + "Read docs/mcp_tools_for_ai.md for tool selection, auth, batch workflow, " + "and node types. Start with catalog_snapshot; batch: create→update→run." + ), + } + + +@app.post("/mcp/invoke", response_model=McpInvokeResponse) +async def mcp_invoke(payload: McpInvokeRequest, request: Request, db: Session = Depends(get_db)): + tool = payload.tool + args = payload.arguments or {} + actor = _resolve_mcp_actor( + request, + db, + api_key=payload.api_key, + require_credential=_mcp_tool_requires_credential(tool), + ) + try: + if tool == "folder_ensure": + target = str(args["target"]) + path = str(args.get("path", "")) + _ensure_folder_entry(db, target, path, actor) + return McpInvokeResponse( + ok=True, + tool=tool, + data={"target": target, "path": _normalize_folder_path(path)}, + ) + + if tool == "folder_list": + target = str(args.get("target", "workflows")) + data = list_folders(target=target, db=db, user=actor) + return McpInvokeResponse(ok=True, tool=tool, data=data) + + if tool == "api_upsert": + api_id = args.get("api_id") + request_model = ApiUpdate if api_id else ApiCreate + request_data = request_model(**args) + if api_id: + data = update_api(int(api_id), request_data, db, actor).model_dump() + else: + data = create_api(request_data, db, actor).model_dump() + return McpInvokeResponse(ok=True, tool=tool, data=data) + + if tool == "workflow_upsert": + workflow_id = args.get("workflow_id") + request_model = WorkflowUpdate if workflow_id else WorkflowCreate + request_data = request_model(**args) + if workflow_id: + data = update_workflow(int(workflow_id), request_data, db, actor).model_dump() + else: + data = create_workflow(request_data, db, actor).model_dump() + return McpInvokeResponse(ok=True, tool=tool, data=data) + + if tool == "workflow_patch_json": + workflow_id = int(args["workflow_id"]) + patch = args.get("patch", {}) + row = db.query(Workflow).filter(Workflow.id == workflow_id).first() + if not row: + raise HTTPException(status_code=404, detail="workflow not found") + _assert_owner_or_superadmin(actor, row) + current = json.loads(row.definition_json) + merged = _deep_merge_dict(current, patch) + row.definition_json = json.dumps(merged, ensure_ascii=False) + db.commit() + db.refresh(row) + data = model_to_workflow_out(row).model_dump() + return McpInvokeResponse(ok=True, tool=tool, data=data) + + if tool == "api_move_folder": + api_id = int(args["api_id"]) + folder_path = _normalize_folder_path(str(args.get("folder_path", ""))) + row = db.query(ApiDefinition).filter(ApiDefinition.id == api_id).first() + if not row: + raise HTTPException(status_code=404, detail="API not found") + _assert_owner_or_superadmin(actor, row) + row.folder_path = folder_path + db.commit() + db.refresh(row) + _ensure_folder_entry(db, "apis", folder_path, actor) + return McpInvokeResponse(ok=True, tool=tool, data=model_to_api_out(row).model_dump()) + + if tool == "workflow_move_folder": + workflow_id = int(args["workflow_id"]) + folder_path = _normalize_folder_path(str(args.get("folder_path", ""))) + row = db.query(Workflow).filter(Workflow.id == workflow_id).first() + if not row: + raise HTTPException(status_code=404, detail="workflow not found") + _assert_owner_or_superadmin(actor, row) + _assert_workflow_folder_registered(db, folder_path, actor) + row.folder_path = folder_path + db.commit() + db.refresh(row) + return McpInvokeResponse(ok=True, tool=tool, data=model_to_workflow_out(row).model_dump()) + + if tool == "workflow_get": + workflow_id = int(args["workflow_id"]) + row = db.query(Workflow).filter(Workflow.id == workflow_id).first() + if not row: + raise HTTPException(status_code=404, detail="workflow not found") + _assert_owner_or_superadmin(actor, row) + data = model_to_workflow_out(row).model_dump() + return McpInvokeResponse(ok=True, tool=tool, data=data) + + if tool == "workflow_node_status": + workflow_id = int(args["workflow_id"]) + target_node_id = str(args["node_id"]) + row = db.query(Workflow).filter(Workflow.id == workflow_id).first() + if not row: + raise HTTPException(status_code=404, detail="workflow not found") + _assert_owner_or_superadmin(actor, row) + definition = json.loads(row.definition_json) + target_node = next((n for n in definition.get("nodes", []) if n.get("id") == target_node_id), None) + if not target_node: + raise HTTPException(status_code=404, detail="node not found") + last_run = (target_node.get("data") or {}).get("last_run") or {} + return McpInvokeResponse( + ok=True, + tool=tool, + data={"node_id": target_node_id, "last_run": last_run}, + ) + + if tool == "workflow_run": + request_data = RunWorkflowRequest(**args) + data = (await _do_run_workflow(request_data, db, "mcp", actor)).model_dump() + return McpInvokeResponse(ok=True, tool=tool, data=data) + + if tool == "workflow_batch_create": + request_data = WorkflowBatchCreate(**args) + batch = _create_workflow_batch_row(request_data, db, actor, "mcp") + return McpInvokeResponse(ok=True, tool=tool, data=_model_to_batch_dict(batch)) + + if tool == "workflow_batch_update": + batch_id = int(args["batch_id"]) + batch = _get_workflow_batch_or_404(batch_id, db, actor) + request_data = WorkflowBatchUpdate(**{k: v for k, v in args.items() if k != "batch_id"}) + batch = _update_workflow_batch_row(batch, request_data, db) + return McpInvokeResponse(ok=True, tool=tool, data=_model_to_batch_dict(batch)) + + if tool == "workflow_batch_run": + batch_id = int(args["batch_id"]) + batch = _get_workflow_batch_or_404(batch_id, db, actor) + data = (await _execute_workflow_batch(batch, db, "mcp", actor)).model_dump() + return McpInvokeResponse(ok=True, tool=tool, data=data) + + if tool == "workflow_batch_get": + batch_id = int(args["batch_id"]) + batch = _get_workflow_batch_or_404(batch_id, db, actor) + runs = ( + db.query(WorkflowRun) + .filter(WorkflowRun.batch_id == batch_id) + .order_by(WorkflowRun.id.asc()) + .all() + ) + return McpInvokeResponse( + ok=True, + tool=tool, + data=_model_to_batch_dict(batch, [_model_to_run_dict(item) for item in runs]), + ) + + if tool == "workflow_batch_list": + limit = int(args.get("limit") or 30) + after_id = args.get("after_id") + status_filter = args.get("status") + query = db.query(WorkflowBatch) + if not _is_superadmin(actor): + query = query.filter(WorkflowBatch.creator_id == actor.id) + if status_filter: + query = query.filter(WorkflowBatch.status == str(status_filter).strip()) + if after_id is not None: + query = query.filter(WorkflowBatch.id < int(after_id)) + rows = query.order_by(WorkflowBatch.id.desc()).limit(max(1, min(limit, 200))).all() + return McpInvokeResponse( + ok=True, + tool=tool, + data={"items": [_model_to_batch_dict(row) for row in rows]}, + ) + + if tool == "workflow_run_node": + request_data = RunNodeRequest(**args) + data = (await _do_run_workflow_node(request_data, db, "mcp", actor)).model_dump() + return McpInvokeResponse(ok=True, tool=tool, data=data) + + if tool == "workflow_analyze_last_run": + workflow_id = int(args["workflow_id"]) + row = db.query(Workflow).filter(Workflow.id == workflow_id).first() + if not row: + raise HTTPException(status_code=404, detail="workflow not found") + _assert_owner_or_superadmin(actor, row) + last = json.loads(row.last_run_json) if row.last_run_json else {"status": "unknown", "results": []} + data = _analyze_run_payload(last) + return McpInvokeResponse(ok=True, tool=tool, data=data) + + if tool == "workflow_run_list": + items = _list_workflow_runs_for_actor( + db, + actor, + workflow_id=args.get("workflow_id"), + batch_id=args.get("batch_id"), + limit=int(args.get("limit") or 30), + after_id=args.get("after_id"), + ) + return McpInvokeResponse(ok=True, tool=tool, data={"items": items}) + + if tool == "workflow_run_get": + data = _get_workflow_run_for_actor(db, actor, int(args["run_id"])) + return McpInvokeResponse(ok=True, tool=tool, data=data) + + if tool == "workflow_run_replay": + replay_args = {k: v for k, v in args.items() if k != "run_id"} + request_data = ReplayRunRequest(**replay_args) + data = await _replay_workflow_run_for_actor( + db, + actor, + int(args["run_id"]), + base_url=request_data.base_url, + fail_fast=request_data.fail_fast, + triggered_by="mcp", + ) + return McpInvokeResponse(ok=True, tool=tool, data=data) + + if tool == "workflow_run_loki_link": + data = _workflow_run_loki_link_for_actor(db, actor, int(args["run_id"]), str(args["node_id"])) + return McpInvokeResponse(ok=True, tool=tool, data=data) + + if tool == "workflow_validate": + definition = args.get("definition") or {} + if not isinstance(definition, dict): + raise HTTPException(status_code=400, detail="definition must be an object") + data = _validate_workflow_definition(definition) + return McpInvokeResponse(ok=True, tool=tool, data=data) + + if tool == "mock_upsert": + request_data = MockDataCreate(**args) + data = create_mock(request_data, db, actor).model_dump() + return McpInvokeResponse(ok=True, tool=tool, data=data) + + if tool == "mcp_tool_upsert": + request_data = McpToolCreate(**args) + data = upsert_mcp_tool(request_data, db, actor).model_dump() + return McpInvokeResponse(ok=True, tool=tool, data=data) + + if tool == "catalog_snapshot": + workflows = [item.model_dump() for item in list_workflows(db=db, user=actor)] + apis = [item.model_dump() for item in list_apis(db=db, user=actor)] + mocks = [item.model_dump() for item in list_mocks(db, actor)] + configs = [item.model_dump() for item in list_mcp_tools(db, actor)] + ssh_tree = [item.model_dump() for item in _build_ssh_tree(db, actor)] + batch_query = db.query(WorkflowBatch) + if not _is_superadmin(actor): + batch_query = batch_query.filter(WorkflowBatch.creator_id == actor.id) + batch_rows = batch_query.order_by(WorkflowBatch.id.desc()).limit(50).all() + data = { + "apis": apis, + "workflows": workflows, + "mocks": mocks, + "workflow_batches": [_model_to_batch_dict(row) for row in batch_rows], + "mcp_tools": configs, + "ssh_tree": ssh_tree, + "folders": { + "apis": list_folders(target="apis", db=db, user=actor)["folders"], + "workflows": list_folders(target="workflows", db=db, user=actor)["folders"], + }, + } + return McpInvokeResponse(ok=True, tool=tool, data=data) + + if tool == "ssh_tree": + data = [item.model_dump() for item in _build_ssh_tree(db, actor)] + return McpInvokeResponse(ok=True, tool=tool, data={"tree": data}) + + if tool == "ssh_script_get": + script_id = int(args["script_id"]) + row = _get_ssh_script_row(db, script_id) + _assert_owner_or_superadmin(actor, row) + data = model_to_ssh_script_out(row).model_dump() + if row.source_type == "file" and not data.get("content"): + data["content"] = resolve_script_body(model_to_ssh_script_out(row)) + return McpInvokeResponse(ok=True, tool=tool, data=data) + + if tool == "ssh_script_upsert": + script_id = args.get("script_id") + content = str(args.get("content", "")).strip() + if not content: + raise HTTPException(status_code=400, detail="script content is required") + if script_id: + row = _get_ssh_script_row(db, int(script_id)) + _assert_owner_or_superadmin(actor, row) + if row.source_type == "file": + raise HTTPException(status_code=400, detail="file script cannot be edited inline, re-upload instead") + row.name = str(args["name"]).strip() + row.description = str(args.get("description", "")).strip() + row.content = content + db.commit() + db.refresh(row) + data = model_to_ssh_script_out(row).model_dump() + else: + profile_id = int(args.get("profile_id") or 0) + if not profile_id: + raise HTTPException(status_code=400, detail="profile_id is required when creating script") + profile = db.query(SshProfile).filter(SshProfile.id == profile_id).first() + if not profile: + raise HTTPException(status_code=404, detail="ssh profile not found") + _assert_owner_or_superadmin(actor, profile) + row = SshScript( + profile_id=profile_id, + name=str(args["name"]).strip(), + description=str(args.get("description", "")).strip(), + source_type="inline", + content=content, + **_creator_fields(actor), + ) + db.add(row) + db.commit() + db.refresh(row) + data = model_to_ssh_script_out(row).model_dump() + return McpInvokeResponse(ok=True, tool=tool, data=data) + + if tool == "ssh_script_run": + profile_id = int(args["profile_id"]) + script_id = int(args["script_id"]) + password = str(args.get("password") or "") + if not password.strip(): + raise HTTPException(status_code=400, detail="password is required") + profile_row = db.query(SshProfile).filter(SshProfile.id == profile_id).first() + if not profile_row: + raise HTTPException(status_code=404, detail="ssh profile not found") + script_row = db.query(SshScript).filter(SshScript.id == script_id, SshScript.profile_id == profile_id).first() + if not script_row: + raise HTTPException(status_code=404, detail="ssh script not found") + _assert_owner_or_superadmin(actor, profile_row) + _assert_owner_or_superadmin(actor, script_row) + profile = model_to_ssh_profile_out(profile_row) + script = model_to_ssh_script_out(script_row) + if profile.auth_type == "key" and profile.key_path and not os.path.exists(profile.key_path): + raise HTTPException(status_code=400, detail="configured key_path does not exist") + timeout_seconds = float(args.get("timeout_seconds") or 120.0) + data = await run_script_collect(profile, script, password, timeout_seconds=timeout_seconds) + return McpInvokeResponse(ok=bool(data.get("ok")), tool=tool, data=data) + + raise HTTPException(status_code=404, detail=f"unknown mcp tool: {tool}") + except HTTPException as exc: + return McpInvokeResponse(ok=False, tool=tool, error=str(exc.detail)) + except Exception as exc: + return McpInvokeResponse(ok=False, tool=tool, error=str(exc)) + + +@app.post("/mcp/invoke-batch") +async def mcp_invoke_batch(payload: McpInvokeBatchRequest, request: Request, db: Session = Depends(get_db)): + outputs: list[dict[str, Any]] = [] + for call in payload.calls: + result = await mcp_invoke(call, request, db) + outputs.append(result.model_dump()) + if payload.stop_on_error and not result.ok: + break + return {"results": outputs} + + +@app.get("/health") +def health(): + return {"ok": True} + + +@app.post("/api/auth/login", response_model=LoginResponse) +def login(payload: LoginRequest, db: Session = Depends(get_db)): + user = db.query(User).filter(User.username == payload.username.strip()).first() + if not user or not user.is_active or user.password_hash != _hash_password(payload.password): + raise HTTPException(status_code=401, detail="用户名或密码错误") + return LoginResponse(token=_issue_auth_token(user), user=_make_user_out(user)) + + +@app.get("/api/auth/me", response_model=CurrentUserOut) +def auth_me(user: User = Depends(_get_current_user)): + return _make_user_out(user) + + +@app.get("/api/me/api-keys", response_model=list[UserApiKeyOut]) +def list_my_api_keys(db: Session = Depends(get_db), user: User = Depends(_get_current_user)): + rows = ( + db.query(UserApiKey) + .filter(UserApiKey.user_id == user.id) + .order_by(UserApiKey.id.desc()) + .all() + ) + return [_model_to_user_api_key_out(row) for row in rows] + + +@app.post("/api/me/api-keys", response_model=UserApiKeyCreateResponse) +def create_my_api_key( + payload: UserApiKeyCreate, + db: Session = Depends(get_db), + user: User = Depends(_get_current_user), +): + expires_at = _parse_api_key_expires_at(payload.expires_at) + full_key, key_hash, key_prefix, key_hint = _generate_user_api_key() + row = UserApiKey( + user_id=user.id, + key_prefix=key_prefix, + key_hash=key_hash, + key_hint=key_hint, + expires_at=expires_at, + is_active=True, + ) + db.add(row) + db.commit() + db.refresh(row) + base = _model_to_user_api_key_out(row) + return UserApiKeyCreateResponse(**base.model_dump(), api_key=full_key) + + +@app.delete("/api/me/api-keys/{key_id}") +def revoke_my_api_key(key_id: int, db: Session = Depends(get_db), user: User = Depends(_get_current_user)): + row = db.query(UserApiKey).filter(UserApiKey.id == key_id, UserApiKey.user_id == user.id).first() + if not row: + raise HTTPException(status_code=404, detail="api key not found") + db.delete(row) + db.commit() + return {"deleted": True, "id": key_id} + + +@app.get("/api/users", response_model=list[UserOut]) +def list_users( + db: Session = Depends(get_db), + _: User = Depends(_require_superadmin), +): + rows = db.query(User).order_by(User.id.asc()).all() + return [ + UserOut( + id=row.id, + username=row.username, + display_name=row.display_name or row.username, + role="superadmin" if row.role == "superadmin" else "user", + is_active=bool(row.is_active), + ) + for row in rows + ] + + +@app.post("/api/users", response_model=UserOut) +def create_user( + payload: UserCreate, + db: Session = Depends(get_db), + _: User = Depends(_require_superadmin), +): + username = payload.username.strip() + if not username: + raise HTTPException(status_code=400, detail="username is required") + if db.query(User.id).filter(User.username == username).first(): + raise HTTPException(status_code=400, detail="username already exists") + row = User( + username=username, + password_hash=_hash_password(payload.password), + display_name=(payload.display_name or username).strip(), + role=payload.role, + is_active=True, + ) + db.add(row) + db.commit() + db.refresh(row) + return UserOut( + id=row.id, + username=row.username, + display_name=row.display_name or row.username, + role="superadmin" if row.role == "superadmin" else "user", + is_active=bool(row.is_active), + ) + + +@app.put("/api/users/{user_id}", response_model=UserOut) +def update_user( + user_id: int, + payload: UserUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(_require_superadmin), +): + row = db.query(User).filter(User.id == user_id).first() + if not row: + raise HTTPException(status_code=404, detail="user not found") + + next_role = "superadmin" if payload.role == "superadmin" else "user" + next_active = bool(payload.is_active) + active_superadmin_count = db.query(User.id).filter(User.role == "superadmin", User.is_active == True).count() + + if row.id == current_user.id and not next_active: + raise HTTPException(status_code=400, detail="cannot deactivate current user") + if row.role == "superadmin" and row.is_active and (not next_active or next_role != "superadmin") and active_superadmin_count <= 1: + raise HTTPException(status_code=400, detail="at least one active superadmin is required") + + row.display_name = (payload.display_name or row.username).strip() or row.username + row.role = next_role + row.is_active = next_active + if payload.password.strip(): + row.password_hash = _hash_password(payload.password) + db.commit() + db.refresh(row) + return UserOut( + id=row.id, + username=row.username, + display_name=row.display_name or row.username, + role="superadmin" if row.role == "superadmin" else "user", + is_active=bool(row.is_active), + ) + + +@app.get("/api/folders") +def list_folders(target: str = "workflows", db: Session = Depends(get_db), user: User = Depends(_get_current_user)): + if target not in {"apis", "workflows"}: + raise HTTPException(status_code=400, detail="target must be apis or workflows") + if target == "apis": + query = db.query(ApiDefinition.folder_path, func.count(ApiDefinition.id)) + query = _scope_query_by_user(query, ApiDefinition, user) + grouped_rows = query.group_by(ApiDefinition.folder_path).all() + else: + query = db.query(Workflow.folder_path, func.count(Workflow.id)) + query = _scope_query_by_user(query, Workflow, user) + grouped_rows = query.group_by(Workflow.folder_path).all() + declared_query = db.query(FolderEntry.path).filter(FolderEntry.target == target) + declared_query = _scope_query_by_user(declared_query, FolderEntry, user) + declared_rows = declared_query.all() + path_count_map: dict[str, int] = {} + for path_value, count_value in grouped_rows: + normalized = _normalize_folder_path(path_value) + path_count_map[normalized] = path_count_map.get(normalized, 0) + int(count_value) + for row in declared_rows: + normalized = _normalize_folder_path(row[0]) + if normalized not in path_count_map: + path_count_map[normalized] = 0 + tree = _build_folder_tree(path_count_map) + folders = sorted(path for path in path_count_map.keys() if path) + return { + "target": target, + "folders": folders, + "tree": tree["children"], + "total_count": tree["count"], + "root_count": int(path_count_map.get("", 0)), + } + + +@app.post("/api/folders/ensure") +def ensure_folder(payload: dict[str, Any], db: Session = Depends(get_db), user: User = Depends(_get_current_user)): + target = str(payload.get("target", "workflows")) + path = str(payload.get("path", "")) + _ensure_folder_entry(db, target, path, user) + return {"ok": True, "target": target, "path": _normalize_folder_path(path)} + + +@app.put("/api/folders/move") +def move_folder(payload: FolderMoveRequest, db: Session = Depends(get_db), user: User = Depends(_get_current_user)): + data = _move_folder_tree(db, payload.target, payload.from_path, payload.to_path, user) + return {"ok": True, **data} + + +def _api_request_signature(method: str | None, url: str | None) -> tuple[str, str]: + return (str(method or "GET").upper(), str(url or "").strip()) + + +def _derive_api_name_from_node(data: dict[str, Any], url: str) -> str: + label = str(data.get("label") or "").strip() + if label: + return label[:128] + path = url.split("?")[0].rstrip("/") + tail = path.split("/")[-1] if path else "" + if tail: + return tail[:128] + return url[:128] + + +def _sync_apis_from_workflows(db: Session, user: User) -> dict[str, int]: + workflows = _scope_query_by_user(db.query(Workflow), Workflow, user).all() + existing_rows = _scope_query_by_user(db.query(ApiDefinition), ApiDefinition, user).all() + existing = {_api_request_signature(row.method, row.url): row for row in existing_rows} + + created = 0 + skipped = 0 + pending: dict[tuple[str, str], dict[str, Any]] = {} + + for workflow in workflows: + folder_path = _normalize_folder_path(workflow.folder_path) + try: + definition = json.loads(workflow.definition_json) + except json.JSONDecodeError: + continue + for node in definition.get("nodes", []): + data = node.get("data") or {} + if str(data.get("type", "")).lower() != "http": + continue + url = str(data.get("url") or "").strip() + if not url: + continue + signature = _api_request_signature(data.get("method"), url) + if signature in existing or signature in pending: + if signature in existing: + skipped += 1 + continue + pending[signature] = { + "name": _derive_api_name_from_node(data, url), + "folder_path": folder_path, + "method": signature[0], + "url": signature[1], + "config": { + "headers": data.get("headers") or {}, + "body": data.get("body") or {}, + "query": data.get("query") or {}, + "path_params": data.get("path_params") or {}, + "timeout_seconds": float(data.get("timeout_seconds") or 10), + }, + } + + creator = _creator_fields(user) + for item in pending.values(): + row = ApiDefinition( + name=item["name"], + folder_path=item["folder_path"], + method=item["method"], + url=item["url"], + config_json=json.dumps(item["config"], ensure_ascii=False), + **creator, + ) + db.add(row) + if item["folder_path"]: + _ensure_folder_entry(db, "apis", item["folder_path"], user) + created += 1 + + if created: + db.commit() + return { + "created": created, + "skipped": skipped, + "total": len(existing) + created, + } + + +@app.get("/api/apis", response_model=list[ApiOut]) +def list_apis(folder_path: str | None = None, db: Session = Depends(get_db), user: User = Depends(_get_current_user)): + query = _scope_query_by_user(db.query(ApiDefinition), ApiDefinition, user) + if folder_path is not None: + query = query.filter(ApiDefinition.folder_path == _normalize_folder_path(folder_path)) + rows = query.order_by(ApiDefinition.id.desc()).all() + return [model_to_api_out(r) for r in rows] + + +@app.get("/api/apis/{api_id}", response_model=ApiOut) +def get_api(api_id: int, db: Session = Depends(get_db), user: User = Depends(_get_current_user)): + row = db.query(ApiDefinition).filter(ApiDefinition.id == api_id).first() + if not row: + raise HTTPException(status_code=404, detail="API not found") + _assert_owner_or_superadmin(user, row) + return model_to_api_out(row) + + +@app.post("/api/apis", response_model=ApiOut) +def create_api(payload: ApiCreate, db: Session = Depends(get_db), user: User = Depends(_get_current_user)): + cfg = { + "headers": payload.headers, + "body": payload.body, + "query": payload.query, + "path_params": payload.path_params, + "timeout_seconds": payload.timeout_seconds, + } + row = ApiDefinition( + name=payload.name, + folder_path=_normalize_folder_path(payload.folder_path), + method=payload.method.upper(), + url=payload.url, + creator_id=user.id, + creator_name=user.display_name or user.username, + config_json=json.dumps(cfg, ensure_ascii=False), + ) + db.add(row) + db.commit() + db.refresh(row) + _ensure_folder_entry(db, "apis", row.folder_path, user) + return model_to_api_out(row) + + +@app.post("/api/apis/sync-from-workflows") +def sync_apis_from_workflows(db: Session = Depends(get_db), user: User = Depends(_get_current_user)): + result = _sync_apis_from_workflows(db, user) + return {"ok": True, **result} + + +@app.put("/api/apis/{api_id}", response_model=ApiOut) +def update_api(api_id: int, payload: ApiUpdate, db: Session = Depends(get_db), user: User = Depends(_get_current_user)): + row = db.query(ApiDefinition).filter(ApiDefinition.id == api_id).first() + if not row: + raise HTTPException(status_code=404, detail="API not found") + _assert_owner_or_superadmin(user, row) + row.name = payload.name + row.folder_path = _normalize_folder_path(payload.folder_path) + row.method = payload.method.upper() + row.url = payload.url + row.config_json = json.dumps( + { + "headers": payload.headers, + "body": payload.body, + "query": payload.query, + "path_params": payload.path_params, + "timeout_seconds": payload.timeout_seconds, + }, + ensure_ascii=False, + ) + db.commit() + db.refresh(row) + _ensure_folder_entry(db, "apis", row.folder_path, user) + return model_to_api_out(row) + + +@app.delete("/api/apis/{api_id}") +def delete_api(api_id: int, db: Session = Depends(get_db), user: User = Depends(_get_current_user)): + row = db.query(ApiDefinition).filter(ApiDefinition.id == api_id).first() + if not row: + raise HTTPException(status_code=404, detail="API not found") + _assert_owner_or_superadmin(user, row) + db.delete(row) + db.commit() + return {"deleted": True} + + +@app.post("/api/apis/{api_id}/move-folder") +def move_api_folder(api_id: int, payload: dict[str, Any], db: Session = Depends(get_db), user: User = Depends(_get_current_user)): + row = db.query(ApiDefinition).filter(ApiDefinition.id == api_id).first() + if not row: + raise HTTPException(status_code=404, detail="API not found") + _assert_owner_or_superadmin(user, row) + folder_path = _normalize_folder_path(str(payload.get("folder_path", ""))) + row.folder_path = folder_path + db.commit() + db.refresh(row) + _ensure_folder_entry(db, "apis", folder_path, user) + return {"ok": True, "api": model_to_api_out(row).model_dump()} + + +@app.get("/api/workflows", response_model=list[WorkflowOut]) +def list_workflows( + folder_path: str | None = None, + db: Session = Depends(get_db), + user: User = Depends(_get_current_user), +): + query = _scope_query_by_user(db.query(Workflow), Workflow, user) + if folder_path is not None: + query = query.filter(Workflow.folder_path == _normalize_folder_path(folder_path)) + rows = query.order_by(Workflow.id.desc()).all() + return [model_to_workflow_out(r) for r in rows] + + +@app.post("/api/workflows", response_model=WorkflowOut) +def create_workflow(payload: WorkflowCreate, db: Session = Depends(get_db), user: User = Depends(_get_current_user)): + folder_path = _normalize_folder_path(payload.folder_path) + _assert_workflow_folder_registered(db, folder_path, user) + row = Workflow( + name=payload.name, + folder_path=folder_path, + creator_id=user.id, + creator_name=user.display_name or user.username, + definition_json=payload.definition.model_dump_json(), + default_headers_json=json.dumps(payload.default_headers or {}, ensure_ascii=False), + ) + db.add(row) + db.commit() + db.refresh(row) + return model_to_workflow_out(row) + + +@app.put("/api/workflows/{workflow_id}", response_model=WorkflowOut) +def update_workflow( + workflow_id: int, + payload: WorkflowUpdate, + db: Session = Depends(get_db), + user: User = Depends(_get_current_user), +): + row = db.query(Workflow).filter(Workflow.id == workflow_id).first() + if not row: + raise HTTPException(status_code=404, detail="workflow not found") + _assert_owner_or_superadmin(user, row) + new_folder = _normalize_folder_path(payload.folder_path) + old_folder = _normalize_folder_path(row.folder_path) + if new_folder != old_folder: + _assert_workflow_folder_registered(db, new_folder, user) + row.name = payload.name + row.folder_path = new_folder + row.definition_json = payload.definition.model_dump_json() + row.default_headers_json = json.dumps(payload.default_headers or {}, ensure_ascii=False) + db.commit() + db.refresh(row) + return model_to_workflow_out(row) + + +@app.get("/api/workflows/{workflow_id}", response_model=WorkflowOut) +def get_workflow(workflow_id: int, db: Session = Depends(get_db), user: User = Depends(_get_current_user)): + row = db.query(Workflow).filter(Workflow.id == workflow_id).first() + if not row: + raise HTTPException(status_code=404, detail="workflow not found") + _assert_owner_or_superadmin(user, row) + return model_to_workflow_out(row) + + +@app.post("/api/workflows/{workflow_id}/move-folder") +def move_workflow_folder( + workflow_id: int, + payload: dict[str, Any], + db: Session = Depends(get_db), + user: User = Depends(_get_current_user), +): + row = db.query(Workflow).filter(Workflow.id == workflow_id).first() + if not row: + raise HTTPException(status_code=404, detail="workflow not found") + _assert_owner_or_superadmin(user, row) + folder_path = _normalize_folder_path(str(payload.get("folder_path", ""))) + _assert_workflow_folder_registered(db, folder_path, user) + row.folder_path = folder_path + db.commit() + db.refresh(row) + return {"ok": True, "workflow": model_to_workflow_out(row).model_dump()} + + +@app.post("/api/workflows/validate") +def validate_workflow(definition: dict[str, Any], _: User = Depends(_get_current_user)): + if not isinstance(definition, dict): + raise HTTPException(status_code=400, detail="definition must be an object") + return _validate_workflow_definition(definition) + + +async def _do_run_workflow( + payload: RunWorkflowRequest, + db: Session, + triggered_by: str, + user: User, + batch_id: int | None = None, +) -> RunWorkflowResponse: + workflow = db.query(Workflow).filter(Workflow.id == payload.workflow_id).first() + if not workflow: + raise HTTPException(status_code=404, detail="workflow not found") + _assert_owner_or_superadmin(user, workflow) + + definition = json.loads(workflow.definition_json) + api_map, mock_map = _collect_runtime_maps(db, user) + active_run_id = secrets.token_hex(12) + runtime_snapshot = _init_runtime_snapshot(workflow, user, payload, definition) + runtime_snapshot["active_run_id"] = active_run_id + _set_runtime_snapshot(active_run_id, runtime_snapshot) + on_node_start, on_node_finish = _build_runtime_node_callbacks(active_run_id) + + started = time.perf_counter() + try: + run_result = await execute_workflow( + definition=definition, + api_map=api_map, + mock_map=mock_map, + base_url=payload.base_url, + fail_fast=payload.fail_fast, + default_headers=_workflow_default_headers(workflow), + on_node_start=on_node_start, + on_node_finish=on_node_finish, + ) + except Exception: + with RUNNING_WORKFLOW_LOCK: + snapshot = RUNNING_WORKFLOW_SNAPSHOTS.get(active_run_id) + if snapshot: + snapshot["status"] = "failed" + snapshot["updated_at"] = time.time() + raise + finally: + duration_ms = int((time.perf_counter() - started) * 1000) + _clear_runtime_snapshot(active_run_id) + + workflow.definition_json = json.dumps(definition, ensure_ascii=False) + workflow.last_run_json = json.dumps(run_result, ensure_ascii=False) + db.commit() + run_row = _record_workflow_run( + db, + workflow, + run_type="full", + triggered_by=triggered_by, + status=run_result["status"], + duration_ms=duration_ms, + summary=_summarize_results(run_result.get("results", [])), + payload={ + "workflow_snapshot": { + "id": workflow.id, + "name": workflow.name, + "folder_path": workflow.folder_path or "", + "default_headers": _workflow_default_headers(workflow), + "definition": definition, + }, + "request": {"base_url": payload.base_url, "fail_fast": payload.fail_fast}, + "results": run_result.get("results", []), + "variables": run_result.get("variables", {}), + }, + batch_id=batch_id, + ) + return RunWorkflowResponse( + status=run_result["status"], + variables=run_result["variables"], + results=run_result["results"], + run_id=run_row.id, + workflow_id=workflow.id, + batch_id=batch_id, + ) + + +@app.post("/api/workflows/run", response_model=RunWorkflowResponse) +async def run_workflow( + payload: RunWorkflowRequest, + request: Request, + db: Session = Depends(get_db), + user: User = Depends(_get_current_user), +): + return await _do_run_workflow(payload, db, _detect_trigger_source(request), user) + + +async def _do_run_workflow_node( + payload: RunNodeRequest, + db: Session, + triggered_by: str, + user: User, +) -> RunNodeResponse: + workflow = db.query(Workflow).filter(Workflow.id == payload.workflow_id).first() + if not workflow: + raise HTTPException(status_code=404, detail="workflow not found") + _assert_owner_or_superadmin(user, workflow) + + definition = json.loads(workflow.definition_json) + nodes = definition.get("nodes", []) + node = next((item for item in nodes if item.get("id") == payload.node_id), None) + if not node: + raise HTTPException(status_code=404, detail="node not found in workflow") + + api_map, mock_map = _collect_runtime_maps(db, user) + variables = dict(definition.get("variables", {})) + + started = time.perf_counter() + result = await execute_single_node( + node=node, + variables=variables, + api_map=api_map, + mock_map=mock_map, + base_url=payload.base_url, + default_headers=_workflow_default_headers(workflow), + ) + duration_ms = int((time.perf_counter() - started) * 1000) + node_data = node.setdefault("data", {}) + if isinstance(node_data, dict): + node_data["last_run"] = { + "status": result.get("status"), + "error": result.get("error"), + "output": result.get("output", {}), + } + workflow.definition_json = json.dumps(definition, ensure_ascii=False) + db.commit() + status = "failed" if result["status"] == "failed" else "success" + _record_workflow_run( + db, + workflow, + run_type="node", + node_id=payload.node_id, + triggered_by=triggered_by, + status=status, + duration_ms=duration_ms, + summary=_summarize_results([result]), + payload={ + "workflow_snapshot": { + "id": workflow.id, + "name": workflow.name, + "folder_path": workflow.folder_path or "", + "default_headers": _workflow_default_headers(workflow), + "definition": definition, + }, + "request": {"base_url": payload.base_url}, + "result": result, + "variables": variables, + }, + ) + return RunNodeResponse(status=status, variables=variables, result=result) + + +@app.post("/api/workflows/run-node", response_model=RunNodeResponse) +async def run_workflow_node( + payload: RunNodeRequest, + request: Request, + db: Session = Depends(get_db), + user: User = Depends(_get_current_user), +): + return await _do_run_workflow_node(payload, db, _detect_trigger_source(request), user) + + +def _model_to_batch_dict(row: WorkflowBatch, runs: list[dict[str, Any]] | None = None) -> dict[str, Any]: + try: + workflow_ids = json.loads(row.workflow_ids_json or "[]") + except Exception: + workflow_ids = [] + if not isinstance(workflow_ids, list): + workflow_ids = [] + return { + "id": row.id, + "name": row.name or "", + "status": row.status, + "triggered_by": row.triggered_by, + "creator_id": row.creator_id, + "creator_name": row.creator_name, + "base_url": row.base_url or "", + "fail_fast": bool(row.fail_fast), + "workflow_ids": [int(item) for item in workflow_ids if str(item).isdigit()], + "summary": json.loads(row.summary_json or "{}"), + "created_at": row.created_at.isoformat() if row.created_at else None, + "finished_at": row.finished_at.isoformat() if row.finished_at else None, + "runs": runs or [], + } + + +def _normalize_workflow_ids(raw_ids: list[Any] | None) -> list[int]: + workflow_ids: list[int] = [] + for raw_id in raw_ids or []: + try: + workflow_ids.append(int(raw_id)) + except (TypeError, ValueError): + continue + return list(dict.fromkeys(workflow_ids)) + + +def _get_workflow_batch_or_404(batch_id: int, db: Session, user: User) -> WorkflowBatch: + row = db.query(WorkflowBatch).filter(WorkflowBatch.id == batch_id).first() + if not row: + raise HTTPException(status_code=404, detail="batch not found") + if not _is_superadmin(user) and row.creator_id != user.id: + raise HTTPException(status_code=403, detail="no permission for this resource") + return row + + +def _create_workflow_batch_row( + payload: WorkflowBatchCreate, + db: Session, + user: User, + triggered_by: str, +) -> WorkflowBatch: + workflow_ids = _normalize_workflow_ids(payload.workflow_ids) + batch = WorkflowBatch( + name=(payload.name or "").strip() or f"批跑 {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M')}", + status="draft", + triggered_by=triggered_by, + creator_id=user.id, + creator_name=user.display_name or user.username, + base_url=payload.base_url or "", + fail_fast=bool(payload.fail_fast), + workflow_ids_json=json.dumps(workflow_ids, ensure_ascii=False), + summary_json="{}", + ) + db.add(batch) + db.commit() + db.refresh(batch) + return batch + + +def _update_workflow_batch_row( + batch: WorkflowBatch, + payload: WorkflowBatchUpdate, + db: Session, +) -> WorkflowBatch: + if batch.status != "draft": + raise HTTPException(status_code=400, detail="仅 draft 状态的批次可编辑") + + if payload.name is not None: + batch.name = str(payload.name).strip() or batch.name + if payload.base_url is not None: + batch.base_url = str(payload.base_url) + if payload.fail_fast is not None: + batch.fail_fast = bool(payload.fail_fast) + if payload.workflow_ids is not None: + batch.workflow_ids_json = json.dumps(_normalize_workflow_ids(payload.workflow_ids), ensure_ascii=False) + + db.commit() + db.refresh(batch) + return batch + + +def _finalize_batch_status(items: list[WorkflowBatchRunItem]) -> str: + if not items: + return "failed" + statuses = {str(item.status) for item in items} + if statuses <= {"success"}: + return "success" + if statuses <= {"failed"}: + return "failed" + if "success" in statuses and "failed" in statuses: + return "partial" + return "partial" + + +async def _execute_workflow_batch( + batch: WorkflowBatch, + db: Session, + triggered_by: str, + user: User, +) -> RunWorkflowBatchResponse: + if batch.status != "draft": + raise HTTPException(status_code=400, detail="仅 draft 状态的批次可执行") + + workflow_ids = _normalize_workflow_ids(json.loads(batch.workflow_ids_json or "[]")) + if not workflow_ids: + raise HTTPException(status_code=400, detail="请先为批次选择至少一个工作流") + + batch.status = "running" + batch.finished_at = None + db.commit() + + items: list[WorkflowBatchRunItem] = [] + success_count = 0 + failed_count = 0 + + for workflow_id in workflow_ids: + item = WorkflowBatchRunItem(workflow_id=workflow_id, status="running") + workflow = db.query(Workflow).filter(Workflow.id == workflow_id).first() + if not workflow: + item.status = "failed" + item.error = "workflow not found" + failed_count += 1 + items.append(item) + if batch.fail_fast: + break + continue + try: + _assert_owner_or_superadmin(user, workflow) + except HTTPException as exc: + item.status = "failed" + item.error = str(exc.detail) + item.workflow_name = workflow.name or "" + failed_count += 1 + items.append(item) + if batch.fail_fast: + break + continue + + item.workflow_name = workflow.name or "" + try: + result = await _do_run_workflow( + RunWorkflowRequest( + workflow_id=workflow_id, + base_url=batch.base_url or "", + fail_fast=bool(batch.fail_fast), + ), + db, + triggered_by, + user, + batch_id=batch.id, + ) + item.run_id = result.run_id + item.status = result.status + if result.status == "success": + success_count += 1 + else: + failed_count += 1 + if batch.fail_fast: + items.append(item) + break + except HTTPException as exc: + item.status = "failed" + item.error = str(exc.detail) + failed_count += 1 + if batch.fail_fast: + items.append(item) + break + except Exception as exc: + item.status = "failed" + item.error = str(exc) + failed_count += 1 + if batch.fail_fast: + items.append(item) + break + items.append(item) + + batch_status = _finalize_batch_status(items) + batch.status = batch_status + batch.summary_json = json.dumps( + { + "total": len(workflow_ids), + "executed": len(items), + "success": success_count, + "failed": failed_count, + "items": [item.model_dump() for item in items], + }, + ensure_ascii=False, + ) + batch.finished_at = datetime.now(timezone.utc) + db.commit() + + return RunWorkflowBatchResponse(batch_id=batch.id, status=batch_status, items=items) + + +@app.post("/api/workflow-batches", response_model=WorkflowBatchOut) +def create_workflow_batch( + payload: WorkflowBatchCreate, + request: Request, + db: Session = Depends(get_db), + user: User = Depends(_get_current_user), +): + batch = _create_workflow_batch_row(payload, db, user, _detect_trigger_source(request)) + return _model_to_batch_dict(batch) + + +@app.put("/api/workflow-batches/{batch_id}", response_model=WorkflowBatchOut) +def update_workflow_batch( + batch_id: int, + payload: WorkflowBatchUpdate, + db: Session = Depends(get_db), + user: User = Depends(_get_current_user), +): + batch = _get_workflow_batch_or_404(batch_id, db, user) + batch = _update_workflow_batch_row(batch, payload, db) + return _model_to_batch_dict(batch) + + +@app.post("/api/workflow-batches/{batch_id}/run", response_model=RunWorkflowBatchResponse) +async def run_workflow_batch_by_id( + batch_id: int, + request: Request, + db: Session = Depends(get_db), + user: User = Depends(_get_current_user), +): + batch = _get_workflow_batch_or_404(batch_id, db, user) + return await _execute_workflow_batch(batch, db, _detect_trigger_source(request), user) + + +@app.post("/api/workflow-batches/run", response_model=RunWorkflowBatchResponse) +async def run_workflow_batch_quick( + payload: RunWorkflowBatchRequest, + request: Request, + db: Session = Depends(get_db), + user: User = Depends(_get_current_user), +): + """快捷:创建批次并立即执行。""" + triggered_by = _detect_trigger_source(request) + batch = _create_workflow_batch_row( + WorkflowBatchCreate( + name=payload.name, + base_url=payload.base_url, + fail_fast=payload.fail_fast, + workflow_ids=payload.workflow_ids, + ), + db, + user, + triggered_by, + ) + return await _execute_workflow_batch(batch, db, triggered_by, user) + + +@app.get("/api/workflow-batches") +def list_workflow_batches( + limit: int = 30, + after_id: int | None = None, + status: str | None = None, + db: Session = Depends(get_db), + user: User = Depends(_get_current_user), +): + limit = max(1, min(int(limit or 30), 200)) + query = db.query(WorkflowBatch) + if not _is_superadmin(user): + query = query.filter(WorkflowBatch.creator_id == user.id) + if status: + query = query.filter(WorkflowBatch.status == str(status).strip()) + if after_id is not None: + query = query.filter(WorkflowBatch.id < int(after_id)) + rows = query.order_by(WorkflowBatch.id.desc()).limit(limit).all() + return {"items": [_model_to_batch_dict(row) for row in rows]} + + +@app.get("/api/workflow-batches/{batch_id}", response_model=WorkflowBatchOut) +def get_workflow_batch( + batch_id: int, + db: Session = Depends(get_db), + user: User = Depends(_get_current_user), +): + row = _get_workflow_batch_or_404(batch_id, db, user) + runs = ( + db.query(WorkflowRun) + .filter(WorkflowRun.batch_id == batch_id) + .order_by(WorkflowRun.id.asc()) + .all() + ) + return _model_to_batch_dict(row, [_model_to_run_dict(item) for item in runs]) + + +@app.get("/api/workflow-runs") +def list_workflow_runs( + workflow_id: int | None = None, + batch_id: int | None = None, + limit: int = 30, + after_id: int | None = None, + db: Session = Depends(get_db), + user: User = Depends(_get_current_user), +): + items = _list_workflow_runs_for_actor( + db, + user, + workflow_id=workflow_id, + batch_id=batch_id, + limit=limit, + after_id=after_id, + ) + return {"items": items} + + +@app.get("/api/workflow-runs/active") +def list_active_workflow_runs(user: User = Depends(_get_current_user)): + items = [_runtime_snapshot_summary(item) for item in _list_runtime_snapshots_for_user(user)] + return {"items": items} + + +@app.get("/api/workflow-runs/active/{active_run_id}") +def get_active_workflow_run(active_run_id: str, user: User = Depends(_get_current_user)): + snapshot = _get_runtime_snapshot(active_run_id) + if not snapshot: + raise HTTPException(status_code=404, detail="active workflow run not found") + if not _is_superadmin(user) and int(snapshot.get("creator_id") or 0) != user.id: + raise HTTPException(status_code=403, detail="no permission for this resource") + return _runtime_snapshot_detail(snapshot) + + +@app.get("/api/workflow-runs/{run_id}") +def get_workflow_run(run_id: int, db: Session = Depends(get_db), user: User = Depends(_get_current_user)): + return _get_workflow_run_for_actor(db, user, run_id) + + +@app.post("/api/workflow-runs/{run_id}/replay", response_model=RunWorkflowResponse) +async def replay_workflow_run( + run_id: int, + payload: ReplayRunRequest, + request: Request, + db: Session = Depends(get_db), + user: User = Depends(_get_current_user), +): + data = await _replay_workflow_run_for_actor( + db, + user, + run_id, + base_url=payload.base_url or "", + fail_fast=payload.fail_fast, + triggered_by=_detect_trigger_source(request), + ) + return RunWorkflowResponse(**data) + + +@app.get("/api/workflow-runs/{run_id}/loki-link", response_model=LokiLogLinkOut) +def get_workflow_run_loki_link( + run_id: int, + node_id: str, + db: Session = Depends(get_db), + user: User = Depends(_get_current_user), +): + link = _workflow_run_loki_link_for_actor(db, user, run_id, node_id) + return LokiLogLinkOut(**link) + + +@app.get("/api/mocks", response_model=list[MockDataOut]) +def list_mocks(db: Session = Depends(get_db), user: User = Depends(_get_current_user)): + rows = _scope_query_by_user(db.query(MockDataset), MockDataset, user).order_by(MockDataset.id.desc()).all() + return [model_to_mock_out(r) for r in rows] + + +@app.post("/api/mocks", response_model=MockDataOut) +def create_mock(payload: MockDataCreate, db: Session = Depends(get_db), user: User = Depends(_get_current_user)): + row = _scope_query_by_user(db.query(MockDataset), MockDataset, user).filter(MockDataset.name == payload.name).first() + if row: + _assert_owner_or_superadmin(user, row) + row.data_json = json.dumps(payload.data, ensure_ascii=False) + else: + row = MockDataset( + name=payload.name, + data_json=json.dumps(payload.data, ensure_ascii=False), + creator_id=user.id, + creator_name=user.display_name or user.username, + ) + db.add(row) + db.commit() + db.refresh(row) + return model_to_mock_out(row) + + +@app.get("/api/mcp-tools", response_model=list[McpToolOut]) +def list_mcp_tools(db: Session = Depends(get_db), user: User = Depends(_get_current_user)): + rows = _scope_query_by_user(db.query(McpToolConfig), McpToolConfig, user).order_by(McpToolConfig.id.desc()).all() + return [model_to_mcp_out(r) for r in rows] + + +@app.post("/api/mcp-tools", response_model=McpToolOut) +def upsert_mcp_tool(payload: McpToolCreate, db: Session = Depends(get_db), user: User = Depends(_get_current_user)): + row = _scope_query_by_user(db.query(McpToolConfig), McpToolConfig, user).filter(McpToolConfig.name == payload.name).first() + if row: + _assert_owner_or_superadmin(user, row) + row.enabled = payload.enabled + row.config_json = json.dumps(payload.config, ensure_ascii=False) + else: + row = McpToolConfig( + name=payload.name, + enabled=payload.enabled, + creator_id=user.id, + creator_name=user.display_name or user.username, + config_json=json.dumps(payload.config, ensure_ascii=False), + ) + db.add(row) + db.commit() + db.refresh(row) + return model_to_mcp_out(row) + + +@app.get("/api/ssh-profiles", response_model=list[SshProfileOut]) +def list_ssh_profiles(db: Session = Depends(get_db), user: User = Depends(_get_current_user)): + rows = _scope_query_by_user(db.query(SshProfile), SshProfile, user).order_by(SshProfile.id.desc()).all() + return [model_to_ssh_profile_out(row) for row in rows] + + +@app.post("/api/ssh-profiles", response_model=SshProfileOut) +def create_ssh_profile( + payload: SshProfileCreate, + db: Session = Depends(get_db), + user: User = Depends(_get_current_user), +): + row = SshProfile( + name=payload.name.strip(), + host=payload.host.strip(), + port=int(payload.port or 22), + username=payload.username.strip(), + creator_id=user.id, + creator_name=user.display_name or user.username, + config_json=_build_ssh_config_json(payload), + ) + db.add(row) + db.commit() + db.refresh(row) + return model_to_ssh_profile_out(row) + + +@app.put("/api/ssh-profiles/{profile_id}", response_model=SshProfileOut) +def update_ssh_profile( + profile_id: int, + payload: SshProfileUpdate, + db: Session = Depends(get_db), + user: User = Depends(_get_current_user), +): + row = db.query(SshProfile).filter(SshProfile.id == profile_id).first() + if not row: + raise HTTPException(status_code=404, detail="ssh profile not found") + _assert_owner_or_superadmin(user, row) + row.name = payload.name.strip() + row.host = payload.host.strip() + row.port = int(payload.port or 22) + row.username = payload.username.strip() + row.config_json = _build_ssh_config_json(payload) + db.commit() + db.refresh(row) + return model_to_ssh_profile_out(row) + + +@app.delete("/api/ssh-profiles/{profile_id}") +def delete_ssh_profile(profile_id: int, db: Session = Depends(get_db), user: User = Depends(_get_current_user)): + row = db.query(SshProfile).filter(SshProfile.id == profile_id).first() + if not row: + raise HTTPException(status_code=404, detail="ssh profile not found") + _assert_owner_or_superadmin(user, row) + script_rows = db.query(SshScript).filter(SshScript.profile_id == profile_id).all() + for script_row in script_rows: + _delete_ssh_script_files(script_row) + db.delete(script_row) + db.delete(row) + db.commit() + return {"deleted": True} + + +@app.get("/api/ssh-tree", response_model=list[SshTreeProfileNode]) +def list_ssh_tree(db: Session = Depends(get_db), user: User = Depends(_get_current_user)): + return _build_ssh_tree(db, user) + + +@app.get("/api/ssh-scripts/{script_id}", response_model=SshScriptOut) +def get_ssh_script(script_id: int, db: Session = Depends(get_db), user: User = Depends(_get_current_user)): + row = _get_ssh_script_row(db, script_id) + _assert_owner_or_superadmin(user, row) + return model_to_ssh_script_out(row) + + +@app.post("/api/ssh-scripts", response_model=SshScriptOut) +def create_ssh_script( + payload: SshScriptCreate, + db: Session = Depends(get_db), + user: User = Depends(_get_current_user), +): + profile = db.query(SshProfile).filter(SshProfile.id == payload.profile_id).first() + if not profile: + raise HTTPException(status_code=404, detail="ssh profile not found") + _assert_owner_or_superadmin(user, profile) + content = payload.content.strip() + if payload.source_type == "inline" and not content: + raise HTTPException(status_code=400, detail="inline script content is required") + row = SshScript( + profile_id=payload.profile_id, + name=payload.name.strip(), + description=payload.description.strip(), + source_type=payload.source_type, + content=content, + creator_id=user.id, + creator_name=user.display_name or user.username, + ) + db.add(row) + db.commit() + db.refresh(row) + return model_to_ssh_script_out(row) + + +@app.put("/api/ssh-scripts/{script_id}", response_model=SshScriptOut) +def update_ssh_script( + script_id: int, + payload: SshScriptUpdate, + db: Session = Depends(get_db), + user: User = Depends(_get_current_user), +): + row = _get_ssh_script_row(db, script_id) + _assert_owner_or_superadmin(user, row) + if row.source_type == "file": + raise HTTPException(status_code=400, detail="file script cannot be edited inline, re-upload instead") + content = payload.content.strip() + if not content: + raise HTTPException(status_code=400, detail="script content is required") + row.name = payload.name.strip() + row.description = payload.description.strip() + row.content = content + db.commit() + db.refresh(row) + return model_to_ssh_script_out(row) + + +@app.delete("/api/ssh-scripts/{script_id}") +def delete_ssh_script(script_id: int, db: Session = Depends(get_db), user: User = Depends(_get_current_user)): + row = _get_ssh_script_row(db, script_id) + _assert_owner_or_superadmin(user, row) + _delete_ssh_script_files(row) + db.delete(row) + db.commit() + return {"deleted": True} + + +@app.post("/api/ssh-scripts/upload", response_model=SshScriptOut) +async def upload_ssh_script( + profile_id: int = Form(...), + name: str = Form(""), + description: str = Form(""), + file: UploadFile = File(...), + db: Session = Depends(get_db), + user: User = Depends(_get_current_user), +): + profile = db.query(SshProfile).filter(SshProfile.id == profile_id).first() + if not profile: + raise HTTPException(status_code=404, detail="ssh profile not found") + _assert_owner_or_superadmin(user, profile) + + raw = await file.read() + if not raw: + raise HTTPException(status_code=400, detail="empty script file") + try: + body = raw.decode("utf-8") + except UnicodeDecodeError as exc: + raise HTTPException(status_code=400, detail="script file must be utf-8 text") from exc + + script_name = (name or file.filename or "script.sh").strip() + safe_filename = _sanitize_script_filename(file.filename or script_name) + storage_name = f"{uuid.uuid4().hex}_{safe_filename}" + scripts_dir = _ensure_ssh_scripts_dir() + target_path = scripts_dir / storage_name + target_path.write_text(body, encoding="utf-8") + + row = SshScript( + profile_id=profile_id, + name=script_name, + description=description.strip(), + source_type="file", + content="", + storage_path=storage_name, + creator_id=user.id, + creator_name=user.display_name or user.username, + ) + db.add(row) + db.commit() + db.refresh(row) + return model_to_ssh_script_out(row) + + +@app.post("/api/ssh-profiles/{profile_id}/execute", response_model=SshExecuteResponse) +def execute_ssh_command( + profile_id: int, + payload: SshExecuteRequest, + db: Session = Depends(get_db), + user: User = Depends(_get_current_user), +): + row = db.query(SshProfile).filter(SshProfile.id == profile_id).first() + if not row: + raise HTTPException(status_code=404, detail="ssh profile not found") + _assert_owner_or_superadmin(user, row) + profile = model_to_ssh_profile_out(row) + command = _resolve_ssh_command(profile, payload) + if profile.auth_type == "key" and profile.key_path and not os.path.exists(profile.key_path): + raise HTTPException(status_code=400, detail="configured key_path does not exist") + return _run_ssh_command(profile, command, payload.password or "", payload.timeout_seconds) + + +@app.get("/api/ssh-shortcuts", response_model=list[SshShortcutOut]) +def list_ssh_shortcuts(db: Session = Depends(get_db), user: User = Depends(_get_current_user)): + rows = _scope_query_by_user(db.query(SshShortcut), SshShortcut, user).order_by(SshShortcut.id.desc()).all() + return [model_to_ssh_shortcut_out(row) for row in rows] + + +@app.post("/api/ssh-shortcuts", response_model=SshShortcutOut) +def create_ssh_shortcut( + payload: SshShortcutCreate, + db: Session = Depends(get_db), + user: User = Depends(_get_current_user), +): + row = SshShortcut( + name=payload.name.strip(), + command=payload.command.strip(), + description=payload.description.strip(), + creator_id=user.id, + creator_name=user.display_name or user.username, + ) + db.add(row) + db.commit() + db.refresh(row) + return model_to_ssh_shortcut_out(row) + + +@app.put("/api/ssh-shortcuts/{shortcut_id}", response_model=SshShortcutOut) +def update_ssh_shortcut( + shortcut_id: int, + payload: SshShortcutUpdate, + db: Session = Depends(get_db), + user: User = Depends(_get_current_user), +): + row = db.query(SshShortcut).filter(SshShortcut.id == shortcut_id).first() + if not row: + raise HTTPException(status_code=404, detail="ssh shortcut not found") + _assert_owner_or_superadmin(user, row) + row.name = payload.name.strip() + row.command = payload.command.strip() + row.description = payload.description.strip() + db.commit() + db.refresh(row) + return model_to_ssh_shortcut_out(row) + + +@app.delete("/api/ssh-shortcuts/{shortcut_id}") +def delete_ssh_shortcut(shortcut_id: int, db: Session = Depends(get_db), user: User = Depends(_get_current_user)): + row = db.query(SshShortcut).filter(SshShortcut.id == shortcut_id).first() + if not row: + raise HTTPException(status_code=404, detail="ssh shortcut not found") + _assert_owner_or_superadmin(user, row) + db.delete(row) + db.commit() + return {"deleted": True} + + +@app.websocket("/ws/ssh-script-run") +async def ssh_script_run_socket(websocket: WebSocket): + try: + user = _get_websocket_user(websocket) + except HTTPException: + await websocket.close(code=4401) + return + + await websocket.accept() + try: + message = await websocket.receive_json() + except WebSocketDisconnect: + return + + msg_type = str(message.get("type", "")).strip() + if msg_type != "start": + await websocket.send_json({"type": "error", "detail": "first message must be start"}) + await websocket.close(code=1003) + return + + profile_id = int(message.get("profile_id") or 0) + script_id = int(message.get("script_id") or 0) + password = str(message.get("password") or "") + if not profile_id or not script_id: + await websocket.send_json({"type": "error", "detail": "profile_id and script_id are required"}) + await websocket.close(code=1003) + return + if not password.strip(): + await websocket.send_json({"type": "error", "detail": "执行前必须输入密码"}) + await websocket.close(code=1003) + return + + with SessionLocal() as db: + profile_row = db.query(SshProfile).filter(SshProfile.id == profile_id).first() + if not profile_row: + await websocket.send_json({"type": "error", "detail": "ssh profile not found"}) + await websocket.close(code=1008) + return + script_row = db.query(SshScript).filter(SshScript.id == script_id, SshScript.profile_id == profile_id).first() + if not script_row: + await websocket.send_json({"type": "error", "detail": "ssh script not found"}) + await websocket.close(code=1008) + return + try: + _assert_owner_or_superadmin(user, profile_row) + _assert_owner_or_superadmin(user, script_row) + except HTTPException: + await websocket.send_json({"type": "error", "detail": "forbidden"}) + await websocket.close(code=1008) + return + profile = model_to_ssh_profile_out(profile_row) + script = model_to_ssh_script_out(script_row) + + try: + resolve_script_body(script) + except ValueError as exc: + await websocket.send_json({"type": "error", "detail": str(exc)}) + await websocket.close(code=1011) + return + + await websocket.send_json( + { + "type": "started", + "profile": {"id": profile.id, "name": profile.name}, + "script": {"id": script.id, "name": script.name}, + } + ) + + cancel_event = asyncio.Event() + stream_error: Exception | None = None + + async def pump_stream() -> None: + nonlocal stream_error + try: + async for event in stream_script_run(profile, script, password, cancel_event): + await websocket.send_json(event) + except Exception as exc: + stream_error = exc + + pump_task = asyncio.create_task(pump_stream()) + try: + while not pump_task.done(): + try: + message = await asyncio.wait_for(websocket.receive_json(), timeout=0.2) + except asyncio.TimeoutError: + continue + except WebSocketDisconnect: + cancel_event.set() + break + msg_type = str(message.get("type", "")).strip() + if msg_type == "stop": + cancel_event.set() + break + if not pump_task.done(): + cancel_event.set() + await pump_task + elif stream_error is not None: + raise stream_error + except WebSocketDisconnect: + cancel_event.set() + if not pump_task.done(): + await pump_task + except Exception as exc: + cancel_event.set() + if not pump_task.done(): + pump_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await pump_task + with contextlib.suppress(Exception): + await websocket.send_json({"type": "error", "detail": str(exc)}) + finally: + cancel_event.set() + if not pump_task.done(): + pump_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await pump_task + with contextlib.suppress(Exception): + await websocket.close() + + +@app.get("/", include_in_schema=False) +@app.get("/{full_path:path}", include_in_schema=False) +def frontend_app(full_path: str = ""): + if full_path in {"api", "docs", "health", "mcp", "openapi.json", "redoc", "ui"} or full_path.startswith( + ("api/", "docs/", "mcp/", "redoc/", "ui/") + ): + raise HTTPException(status_code=404, detail="Not Found") + if full_path: + candidate = (FRONTEND_DIST_DIR / full_path).resolve() + frontend_root = FRONTEND_DIST_DIR.resolve() + if candidate != frontend_root and frontend_root not in candidate.parents: + raise HTTPException(status_code=404, detail="Not Found") + if candidate.is_file(): + return FileResponse(candidate) + return _frontend_index_response() diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..5e94894 --- /dev/null +++ b/app/models.py @@ -0,0 +1,219 @@ +from sqlalchemy import Boolean, Column, DateTime, Integer, String, Text, func + +from .database import Base + + +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True, index=True) + username = Column(String(64), nullable=False, unique=True, index=True) + password_hash = Column(String(128), nullable=False) + display_name = Column(String(64), nullable=False, default="") + role = Column(String(32), nullable=False, default="user", index=True) # superadmin | user + is_active = Column(Boolean, nullable=False, default=True) + created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) + updated_at = Column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + + +class UserApiKey(Base): + __tablename__ = "user_api_keys" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, nullable=False, index=True) + key_prefix = Column(String(16), nullable=False, index=True) + key_hash = Column(String(128), nullable=False, unique=True, index=True) + key_hint = Column(String(8), nullable=False, default="") + expires_at = Column(DateTime(timezone=True), nullable=True) + is_active = Column(Boolean, nullable=False, default=True) + created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) + updated_at = Column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + + +class ApiDefinition(Base): + __tablename__ = "api_definitions" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(128), nullable=False, index=True) + folder_path = Column(String(255), nullable=False, default="", index=True) + method = Column(String(16), nullable=False) + url = Column(String(512), nullable=False) + creator_id = Column(Integer, nullable=False, default=1, index=True) + creator_name = Column(String(64), nullable=False, default="admin") + config_json = Column(Text, nullable=False, default="{}") + created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) + updated_at = Column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + + +class Workflow(Base): + __tablename__ = "workflows" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(128), nullable=False, index=True) + folder_path = Column(String(255), nullable=False, default="", index=True) + creator_id = Column(Integer, nullable=False, default=1, index=True) + creator_name = Column(String(64), nullable=False, default="admin") + definition_json = Column(Text, nullable=False, default='{"nodes":[],"edges":[],"variables":{}}') + default_headers_json = Column(Text, nullable=False, default="{}") + last_run_json = Column(Text, nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) + updated_at = Column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + + +class MockDataset(Base): + __tablename__ = "mock_datasets" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(128), nullable=False, unique=True, index=True) + creator_id = Column(Integer, nullable=False, default=1, index=True) + creator_name = Column(String(64), nullable=False, default="admin") + data_json = Column(Text, nullable=False, default="{}") + created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) + updated_at = Column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + + +class McpToolConfig(Base): + __tablename__ = "mcp_tool_configs" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(128), nullable=False, unique=True, index=True) + enabled = Column(Boolean, nullable=False, default=True) + creator_id = Column(Integer, nullable=False, default=1, index=True) + creator_name = Column(String(64), nullable=False, default="admin") + config_json = Column(Text, nullable=False, default="{}") + created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) + updated_at = Column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + + +class SshProfile(Base): + __tablename__ = "ssh_profiles" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(128), nullable=False, index=True) + host = Column(String(255), nullable=False) + port = Column(Integer, nullable=False, default=22) + username = Column(String(128), nullable=False) + creator_id = Column(Integer, nullable=False, default=1, index=True) + creator_name = Column(String(64), nullable=False, default="admin") + config_json = Column(Text, nullable=False, default="{}") + created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) + updated_at = Column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + + +class SshScript(Base): + __tablename__ = "ssh_scripts" + + id = Column(Integer, primary_key=True, index=True) + profile_id = Column(Integer, nullable=False, index=True) + name = Column(String(128), nullable=False, index=True) + description = Column(String(255), nullable=False, default="") + source_type = Column(String(16), nullable=False, default="inline") # inline | file + content = Column(Text, nullable=False, default="") + storage_path = Column(String(512), nullable=False, default="") + creator_id = Column(Integer, nullable=False, default=1, index=True) + creator_name = Column(String(64), nullable=False, default="admin") + created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) + updated_at = Column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + + +class SshShortcut(Base): + __tablename__ = "ssh_shortcuts" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(128), nullable=False, index=True) + command = Column(Text, nullable=False) + description = Column(String(255), nullable=False, default="") + creator_id = Column(Integer, nullable=False, default=1, index=True) + creator_name = Column(String(64), nullable=False, default="admin") + created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) + updated_at = Column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + + +class FolderEntry(Base): + __tablename__ = "folder_entries" + + id = Column(Integer, primary_key=True, index=True) + target = Column(String(32), nullable=False, index=True) # apis | workflows + path = Column(String(255), nullable=False, index=True) + creator_id = Column(Integer, nullable=False, default=1, index=True) + creator_name = Column(String(64), nullable=False, default="admin") + created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) + + +class WorkflowBatch(Base): + __tablename__ = "workflow_batches" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(128), nullable=False, default="") + status = Column(String(16), nullable=False, default="draft") # draft | running | success | failed | partial + triggered_by = Column(String(32), nullable=False, default="ui") + creator_id = Column(Integer, nullable=False, default=1, index=True) + creator_name = Column(String(64), nullable=False, default="admin") + base_url = Column(String(512), nullable=False, default="") + fail_fast = Column(Boolean, nullable=False, default=True) + workflow_ids_json = Column(Text, nullable=False, default="[]") + summary_json = Column(Text, nullable=False, default="{}") + created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False, index=True) + finished_at = Column(DateTime(timezone=True), nullable=True) + + +class WorkflowRun(Base): + __tablename__ = "workflow_runs" + + id = Column(Integer, primary_key=True, index=True) + batch_id = Column(Integer, nullable=True, index=True) + workflow_id = Column(Integer, nullable=False, index=True) + workflow_name = Column(String(128), nullable=False, default="") + run_type = Column(String(16), nullable=False, default="full") # full | node + node_id = Column(String(64), nullable=True) + triggered_by = Column(String(32), nullable=False, default="ui") # ui | mcp | api + status = Column(String(16), nullable=False, default="success") # success | failed + duration_ms = Column(Integer, nullable=False, default=0) + summary_json = Column(Text, nullable=False, default="{}") + payload_json = Column(Text, nullable=False, default="{}") + created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False, index=True) diff --git a/app/schemas.py b/app/schemas.py new file mode 100644 index 0000000..947951e --- /dev/null +++ b/app/schemas.py @@ -0,0 +1,408 @@ +from typing import Any, Literal + +from pydantic import BaseModel, Field + + +class ApiBase(BaseModel): + name: str + folder_path: str = "" + method: str = "GET" + url: str + headers: dict[str, Any] = Field(default_factory=dict) + body: dict[str, Any] = Field(default_factory=dict) + query: dict[str, Any] = Field(default_factory=dict) + path_params: dict[str, Any] = Field(default_factory=dict) + timeout_seconds: float = 10.0 + + +class ApiCreate(ApiBase): + pass + + +class ApiUpdate(ApiBase): + pass + + +class ApiOut(ApiBase): + id: int + creator_id: int = 0 + creator_name: str = "" + + class Config: + from_attributes = True + + +class WorkflowPayload(BaseModel): + nodes: list[dict[str, Any]] = Field(default_factory=list) + edges: list[dict[str, Any]] = Field(default_factory=list) + variables: dict[str, Any] = Field(default_factory=dict) + + +class WorkflowBase(BaseModel): + name: str + folder_path: str = "" + definition: WorkflowPayload = Field(default_factory=WorkflowPayload) + default_headers: dict[str, Any] = Field(default_factory=dict) + + +class WorkflowCreate(WorkflowBase): + pass + + +class WorkflowUpdate(WorkflowBase): + pass + + +class WorkflowOut(WorkflowBase): + id: int + creator_id: int = 0 + creator_name: str = "" + last_run: dict[str, Any] | None = None + + class Config: + from_attributes = True + + +class ReplayRunRequest(BaseModel): + base_url: str = "" + fail_fast: bool = True + + +class MockDataBase(BaseModel): + name: str + data: dict[str, Any] = Field(default_factory=dict) + + +class MockDataCreate(MockDataBase): + pass + + +class MockDataOut(MockDataBase): + id: int + creator_id: int = 0 + creator_name: str = "" + + class Config: + from_attributes = True + + +class McpToolBase(BaseModel): + name: str + enabled: bool = True + config: dict[str, Any] = Field(default_factory=dict) + + +class McpToolCreate(McpToolBase): + pass + + +class McpToolOut(McpToolBase): + id: int + creator_id: int = 0 + creator_name: str = "" + + class Config: + from_attributes = True + + +class SshCommandPreset(BaseModel): + name: str + command: str + description: str = "" + + +class SshProfileBase(BaseModel): + name: str + host: str + port: int = 22 + username: str + auth_type: Literal["key", "password"] = "key" + key_path: str = "" + presets: list[SshCommandPreset] = Field(default_factory=list) + + +class SshProfileCreate(SshProfileBase): + pass + + +class SshProfileUpdate(SshProfileBase): + pass + + +class SshProfileOut(SshProfileBase): + id: int + creator_id: int = 0 + creator_name: str = "" + + class Config: + from_attributes = True + + +class SshExecuteRequest(BaseModel): + command: str = "" + preset_name: str = "" + password: str = "" + timeout_seconds: float = 20.0 + + +class SshExecuteResponse(BaseModel): + ok: bool + command: str + stdout: str = "" + stderr: str = "" + exit_status: int = 0 + + +class SshShortcutBase(BaseModel): + name: str + command: str + description: str = "" + + +class SshShortcutCreate(SshShortcutBase): + pass + + +class SshShortcutUpdate(SshShortcutBase): + pass + + +class SshShortcutOut(SshShortcutBase): + id: int + creator_id: int = 0 + creator_name: str = "" + + class Config: + from_attributes = True + + +class SshScriptBase(BaseModel): + profile_id: int + name: str + description: str = "" + source_type: Literal["inline", "file"] = "inline" + content: str = "" + + +class SshScriptCreate(SshScriptBase): + pass + + +class SshScriptUpdate(BaseModel): + name: str + description: str = "" + content: str = "" + + +class SshScriptOut(SshScriptBase): + id: int + storage_path: str = "" + creator_id: int = 0 + creator_name: str = "" + + class Config: + from_attributes = True + + +class SshTreeScriptNode(BaseModel): + id: int + name: str + description: str = "" + source_type: Literal["inline", "file"] = "inline" + storage_path: str = "" + + +class SshTreeProfileNode(BaseModel): + id: int + name: str + host: str + port: int = 22 + username: str + auth_type: Literal["key", "password"] = "key" + key_path: str = "" + scripts: list[SshTreeScriptNode] = Field(default_factory=list) + + +class LoginRequest(BaseModel): + username: str + password: str + + +class CurrentUserOut(BaseModel): + id: int + username: str + display_name: str + role: Literal["superadmin", "user"] + + +class LoginResponse(BaseModel): + token: str + user: CurrentUserOut + + +class UserCreate(BaseModel): + username: str + password: str + display_name: str = "" + role: Literal["superadmin", "user"] = "user" + + +class UserUpdate(BaseModel): + display_name: str = "" + role: Literal["superadmin", "user"] = "user" + is_active: bool = True + password: str = "" + + +class UserOut(BaseModel): + id: int + username: str + display_name: str + role: Literal["superadmin", "user"] + is_active: bool + + +class UserApiKeyCreate(BaseModel): + expires_at: str | None = None + + +class UserApiKeyOut(BaseModel): + id: int + key_prefix: str + key_hint: str + masked_key: str = "" + expires_at: str | None = None + is_active: bool = True + created_at: str = "" + + class Config: + from_attributes = True + + +class UserApiKeyCreateResponse(UserApiKeyOut): + api_key: str + + +class RunWorkflowRequest(BaseModel): + workflow_id: int + base_url: str = "" + fail_fast: bool = True + + +class NodeExecutionResult(BaseModel): + node_id: str + node_type: str + status: Literal["success", "failed", "skipped"] + output: dict[str, Any] = Field(default_factory=dict) + error: str | None = None + + +class RunWorkflowResponse(BaseModel): + status: Literal["success", "failed"] + variables: dict[str, Any] + results: list[NodeExecutionResult] + run_id: int | None = None + workflow_id: int | None = None + batch_id: int | None = None + + +class WorkflowBatchCreate(BaseModel): + name: str = "" + base_url: str = "" + fail_fast: bool = True + workflow_ids: list[int] = Field(default_factory=list) + + +class WorkflowBatchUpdate(BaseModel): + name: str | None = None + base_url: str | None = None + fail_fast: bool | None = None + workflow_ids: list[int] | None = None + + +class RunWorkflowBatchRequest(BaseModel): + """快捷:创建批次并立即执行(兼容旧调用)。""" + workflow_ids: list[int] = Field(default_factory=list) + name: str = "" + base_url: str = "" + fail_fast: bool = True + + +class WorkflowBatchRunItem(BaseModel): + workflow_id: int + workflow_name: str = "" + run_id: int | None = None + status: str = "pending" + error: str | None = None + + +class RunWorkflowBatchResponse(BaseModel): + batch_id: int + status: Literal["draft", "running", "success", "failed", "partial"] + items: list[WorkflowBatchRunItem] + + +class WorkflowBatchOut(BaseModel): + id: int + name: str = "" + status: Literal["draft", "running", "success", "failed", "partial"] + triggered_by: str = "" + creator_id: int = 0 + creator_name: str = "" + base_url: str = "" + fail_fast: bool = True + workflow_ids: list[int] = Field(default_factory=list) + summary: dict[str, Any] = Field(default_factory=dict) + created_at: str | None = None + finished_at: str | None = None + runs: list[dict[str, Any]] = Field(default_factory=list) + + +class LokiLogLinkOut(BaseModel): + enabled: bool = False + explore_url: str = "" + logql: str = "" + time_from: str = "" + time_to: str = "" + method: str = "" + url: str = "" + hint: str = "" + + +class RunNodeRequest(BaseModel): + workflow_id: int + node_id: str + base_url: str = "" + + +class RunNodeResponse(BaseModel): + status: Literal["success", "failed"] + variables: dict[str, Any] + result: NodeExecutionResult + + +class FolderMoveRequest(BaseModel): + target: Literal["apis", "workflows"] + from_path: str + to_path: str + + +class McpInvokeRequest(BaseModel): + tool: str + arguments: dict[str, Any] = Field(default_factory=dict) + api_key: str | None = Field( + default=None, + description="Optional sto- API key when Authorization header is unavailable", + ) + + +class McpInvokeBatchRequest(BaseModel): + calls: list[McpInvokeRequest] = Field(default_factory=list) + stop_on_error: bool = True + + +class McpInvokeResponse(BaseModel): + ok: bool + tool: str + data: dict[str, Any] = Field(default_factory=dict) + error: str | None = None diff --git a/app/services/engine.py b/app/services/engine.py new file mode 100644 index 0000000..fdeaa73 --- /dev/null +++ b/app/services/engine.py @@ -0,0 +1,953 @@ +import asyncio +import json +import re +import time +from collections import defaultdict, deque +from collections.abc import Callable +from typing import Any + +import httpx + + +def render_template_value(value: Any, variables: dict[str, Any]) -> Any: + if isinstance(value, str): + matches = re.findall(r"\{\{(.*?)\}\}", value) + rendered = value + for raw_key in matches: + key = raw_key.strip() + replacement = variables.get(key, "") + rendered = rendered.replace(f"{{{{{raw_key}}}}}", str(replacement)) + return rendered + if isinstance(value, dict): + return {k: render_template_value(v, variables) for k, v in value.items()} + if isinstance(value, list): + return [render_template_value(v, variables) for v in value] + return value + + +def build_execution_order(nodes: list[dict[str, Any]], edges: list[dict[str, Any]]) -> list[str]: + node_ids = [n["id"] for n in nodes] + indegree = {node_id: 0 for node_id in node_ids} + graph = defaultdict(list) + for edge in edges: + source = edge.get("source") + target = edge.get("target") + if source in indegree and target in indegree: + graph[source].append(target) + indegree[target] += 1 + + queue = deque([node_id for node_id in node_ids if indegree[node_id] == 0]) + order = [] + while queue: + cur = queue.popleft() + order.append(cur) + for nxt in graph[cur]: + indegree[nxt] -= 1 + if indegree[nxt] == 0: + queue.append(nxt) + + if len(order) != len(node_ids): + return node_ids + return order + + +LOOP_BODY_BRANCHES = {"", "body", "loop", "in", "next", "continue"} +LOOP_EXIT_BRANCHES = {"", "done", "break", "out", "exit", "default"} + + +def _safe_eval(left: Any, op: str, right: Any) -> bool: + if op == "==": + return left == right + if op == "!=": + return left != right + if op == ">": + return left > right + if op == "<": + return left < right + if op == ">=": + return left >= right + if op == "<=": + return left <= right + return False + + +async def execute_workflow( + definition: dict[str, Any], + api_map: dict[int, dict[str, Any]], + mock_map: dict[str, dict[str, Any]], + base_url: str, + fail_fast: bool = True, + default_headers: dict[str, Any] | None = None, + on_node_start: Callable[[dict[str, Any], int, int], Any] | None = None, + on_node_finish: Callable[[dict[str, Any], dict[str, Any], int, int], Any] | None = None, +) -> dict[str, Any]: + nodes = definition.get("nodes", []) + edges = definition.get("edges", []) + variables = dict(definition.get("variables", {})) + node_map = {node["id"]: node for node in nodes} + order = build_execution_order(nodes, edges) + order_index = {node_id: idx for idx, node_id in enumerate(order)} + results: list[dict[str, Any]] = [] + halted = False + graph = _build_edge_graph(edges) + pending = deque(_sorted_start_nodes(nodes, edges, order_index)) + queued = set(pending) + executed = set() + total_nodes = len(order) + default_headers = dict(default_headers or {}) + + async with httpx.AsyncClient(timeout=20.0) as client: + while pending: + node_id = pending.popleft() + queued.discard(node_id) + if node_id in executed or node_id not in node_map: + continue + node = node_map[node_id] + node_type = str(node.get("data", {}).get("type", "http")).lower() + await _invoke_callback(on_node_start, node, len(executed) + 1, total_nodes) + + if node_type == "loop": + loop_results, result = await _execute_loop_node( + node=node, + node_map=node_map, + graph=graph, + edges=edges, + order_index=order_index, + variables=variables, + api_map=api_map, + mock_map=mock_map, + base_url=base_url, + client=client, + default_headers=default_headers, + fail_fast=fail_fast, + on_node_start=on_node_start, + on_node_finish=on_node_finish, + executed_count=len(executed), + total_nodes=total_nodes, + ) + results.extend(loop_results) + else: + result = await execute_single_node( + node, variables, api_map, mock_map, base_url, client, default_headers=default_headers + ) + results.append(result) + + executed.add(node_id) + _persist_node_last_run(node, result) + await _invoke_callback(on_node_finish, node, result, len(executed), total_nodes) + + if result["status"] == "failed" and fail_fast: + halted = True + break + + allowed = _allowed_branches(result) + next_nodes = [] + for target, branch in graph.get(node_id, []): + if target in executed or target in queued: + continue + if node_type == "loop": + if branch not in LOOP_EXIT_BRANCHES: + continue + if branch in allowed: + next_nodes.append(target) + next_nodes.sort(key=lambda item: order_index.get(item, 10**9)) + for target in next_nodes: + pending.append(target) + queued.add(target) + + if halted: + for node_id in order: + if node_id in executed: + continue + node_type = node_map[node_id].get("data", {}).get("type", "http") + skipped_result = { + "node_id": node_id, + "node_type": node_type, + "status": "skipped", + "output": {}, + "error": "halted by previous failure", + } + results.append(skipped_result) + _persist_node_last_run(node_map[node_id], skipped_result) + await _invoke_callback(on_node_finish, node_map[node_id], skipped_result, len(executed), total_nodes) + + status = "failed" if any(item["status"] == "failed" for item in results) else "success" + return {"status": status, "variables": variables, "results": results, "raw": json.dumps(results)} + + +async def _invoke_callback(callback: Callable[..., Any] | None, *args: Any) -> None: + if callback is None: + return + result = callback(*args) + if asyncio.iscoroutine(result): + await result + + +def _persist_node_last_run(node: dict[str, Any], result: dict[str, Any]) -> None: + if not isinstance(node, dict): + return + data = node.setdefault("data", {}) + if not isinstance(data, dict): + return + data["last_run"] = { + "status": result.get("status"), + "error": result.get("error"), + "output": result.get("output", {}), + "ts": int(time.time()), + } + + +def _edge_branch(edge: dict[str, Any]) -> str: + data = edge.get("data") + if isinstance(data, dict): + value = str(data.get("branch", "")).strip().lower() + if value: + return value + label = str(edge.get("label", "")).strip().lower() + return label + + +def _build_edge_graph(edges: list[dict[str, Any]]) -> dict[str, list[tuple[str, str]]]: + graph: dict[str, list[tuple[str, str]]] = defaultdict(list) + for edge in edges: + source = edge.get("source") + target = edge.get("target") + if not source or not target: + continue + graph[source].append((target, _edge_branch(edge))) + return graph + + +def _sorted_start_nodes( + nodes: list[dict[str, Any]], edges: list[dict[str, Any]], order_index: dict[str, int] +) -> list[str]: + indegree = {node["id"]: 0 for node in nodes if "id" in node} + for edge in edges: + target = edge.get("target") + if target in indegree: + indegree[target] += 1 + starts = [node_id for node_id, degree in indegree.items() if degree == 0] + starts.sort(key=lambda item: order_index.get(item, 10**9)) + return starts + + +def _allowed_branches(result: dict[str, Any]) -> set[str]: + status = result.get("status") + node_type = result.get("node_type") + output = result.get("output", {}) + if status == "failed": + return {"", "always", "default", "fail", "failed", "failure", "on_fail"} + if node_type == "condition": + if bool(output.get("result")): + return {"", "always", "default", "true", "on_true", "success"} + return {"", "always", "default", "false", "on_false"} + if node_type == "loop": + return set(LOOP_EXIT_BRANCHES) | {"always", "success", "on_success"} + return {"", "always", "default", "success", "on_success"} + + +def _resolve_field_path(source: Any, field: str) -> Any: + extracted = source + for part in str(field or "").split("."): + if not part: + continue + if isinstance(extracted, dict): + extracted = extracted.get(part) + else: + return None + return extracted + + +def _resolve_condition_operand(node_data: dict[str, Any], side: str, variables: dict[str, Any]) -> Any: + mode_key = f"{side}_mode" + mode = str(node_data.get(mode_key) or node_data.get("mode", "template")).strip().lower() + if mode == "json_path": + source_var = str(node_data.get(f"{side}_source_var") or node_data.get("source_var") or "").strip() + field = str(node_data.get(f"{side}_field") or node_data.get("field") or "").strip() + if not source_var: + return None + return _resolve_field_path(variables.get(source_var), field) + + raw = node_data.get(side, "") + return render_template_value(raw, variables) + + +def _evaluate_condition(node_data: dict[str, Any], variables: dict[str, Any]) -> bool: + left = _resolve_condition_operand(node_data, "left", variables) + right = _resolve_condition_operand(node_data, "right", variables) + op = str(node_data.get("op", "==")) + return _safe_eval(left, op, right) + + +def _collect_loop_body_node_ids(loop_id: str, graph: dict[str, list[tuple[str, str]]]) -> set[str]: + entries = [target for target, branch in graph.get(loop_id, []) if branch in LOOP_BODY_BRANCHES] + if not entries: + return set() + + body: set[str] = set() + queue = deque(entries) + while queue: + current = queue.popleft() + if current == loop_id or current in body: + continue + body.add(current) + for target, _branch in graph.get(current, []): + if target != loop_id and target not in body: + queue.append(target) + return body + + +async def _execute_node_subgraph( + *, + node_ids: set[str], + entry_node_ids: list[str], + node_map: dict[str, dict[str, Any]], + graph: dict[str, list[tuple[str, str]]], + edges: list[dict[str, Any]], + variables: dict[str, Any], + api_map: dict[int, dict[str, Any]], + mock_map: dict[str, dict[str, Any]], + base_url: str, + client: httpx.AsyncClient, + default_headers: dict[str, Any], + fail_fast: bool, + iteration: int, + on_node_start: Callable[[dict[str, Any], int, int], Any] | None, + on_node_finish: Callable[[dict[str, Any], dict[str, Any], int, int], Any] | None, + executed_count: int, + total_nodes: int, +) -> tuple[list[dict[str, Any]], bool]: + if not node_ids: + return [], False + + subgraph_nodes = [node_map[node_id] for node_id in node_ids if node_id in node_map] + subgraph_edges = [ + edge + for edge in edges + if edge.get("source") in node_ids and edge.get("target") in node_ids + ] + order = build_execution_order(subgraph_nodes, subgraph_edges) + order_index = {node_id: idx for idx, node_id in enumerate(order)} + + indegree = {node_id: 0 for node_id in node_ids} + for edge in subgraph_edges: + target = edge.get("target") + if target in indegree: + indegree[target] += 1 + + starts = [node_id for node_id in entry_node_ids if node_id in node_ids] + if not starts: + starts = [node_id for node_id, degree in indegree.items() if degree == 0] + starts.sort(key=lambda item: order_index.get(item, 10**9)) + + pending = deque(starts) + queued = set(starts) + local_executed: set[str] = set() + results: list[dict[str, Any]] = [] + halted = False + + while pending: + node_id = pending.popleft() + queued.discard(node_id) + if node_id in local_executed or node_id not in node_map: + continue + + node = node_map[node_id] + await _invoke_callback(on_node_start, node, executed_count + len(local_executed) + 1, total_nodes) + result = await execute_single_node( + node, variables, api_map, mock_map, base_url, client, default_headers=default_headers + ) + if isinstance(result.get("output"), dict): + result["output"]["loop_iteration"] = iteration + results.append(result) + local_executed.add(node_id) + _persist_node_last_run(node, result) + await _invoke_callback(on_node_finish, node, result, executed_count + len(local_executed), total_nodes) + + if result["status"] == "failed" and fail_fast: + halted = True + break + + allowed = _allowed_branches(result) + next_nodes = [] + for target, branch in graph.get(node_id, []): + if target not in node_ids or target in local_executed or target in queued: + continue + if branch in allowed: + next_nodes.append(target) + next_nodes.sort(key=lambda item: order_index.get(item, 10**9)) + for target in next_nodes: + pending.append(target) + queued.add(target) + + return results, halted + + +async def _execute_loop_node( + *, + node: dict[str, Any], + node_map: dict[str, dict[str, Any]], + graph: dict[str, list[tuple[str, str]]], + edges: list[dict[str, Any]], + order_index: dict[str, int], + variables: dict[str, Any], + api_map: dict[int, dict[str, Any]], + mock_map: dict[str, dict[str, Any]], + base_url: str, + client: httpx.AsyncClient, + default_headers: dict[str, Any], + fail_fast: bool, + on_node_start: Callable[[dict[str, Any], int, int], Any] | None, + on_node_finish: Callable[[dict[str, Any], dict[str, Any], int, int], Any] | None, + executed_count: int, + total_nodes: int, +) -> tuple[list[dict[str, Any]], dict[str, Any]]: + node_id = str(node.get("id", "")) + node_data = node.get("data", {}) if isinstance(node.get("data"), dict) else {} + max_iterations = max(1, min(int(node_data.get("max_iterations", 10) or 10), 1000)) + iteration_var = str(node_data.get("iteration_var") or "loop_index").strip() or "loop_index" + + body_node_ids = _collect_loop_body_node_ids(node_id, graph) + entry_node_ids = [ + target for target, branch in graph.get(node_id, []) if branch in LOOP_BODY_BRANCHES and target in body_node_ids + ] + entry_node_ids.sort(key=lambda item: order_index.get(item, 10**9)) + + if not body_node_ids: + return [], { + "node_id": node_id, + "node_type": "loop", + "status": "failed", + "output": {"iterations": 0, "reason": "missing loop body branch"}, + "error": "循环节点需要连接 body 分支到循环体", + } + + all_results: list[dict[str, Any]] = [] + iterations_run = 0 + continue_loop = True + last_error: str | None = None + + while continue_loop and iterations_run < max_iterations: + variables[iteration_var] = iterations_run + body_results, halted = await _execute_node_subgraph( + node_ids=body_node_ids, + entry_node_ids=entry_node_ids, + node_map=node_map, + graph=graph, + edges=edges, + variables=variables, + api_map=api_map, + mock_map=mock_map, + base_url=base_url, + client=client, + default_headers=default_headers, + fail_fast=fail_fast, + iteration=iterations_run, + on_node_start=on_node_start, + on_node_finish=on_node_finish, + executed_count=executed_count + len(all_results), + total_nodes=total_nodes, + ) + all_results.extend(body_results) + iterations_run += 1 + + if halted or any(item.get("status") == "failed" for item in body_results): + last_error = next( + (str(item.get("error") or "loop body failed") for item in body_results if item.get("status") == "failed"), + "loop body failed", + ) + break + + while_cfg = { + "left_mode": node_data.get("while_left_mode") or node_data.get("left_mode"), + "left": node_data.get("while_left", ""), + "left_source_var": node_data.get("while_left_source_var") or node_data.get("left_source_var"), + "left_field": node_data.get("while_left_field") or node_data.get("left_field"), + "op": node_data.get("while_op", node_data.get("op", "==")), + "right_mode": node_data.get("while_right_mode") or node_data.get("right_mode"), + "right": node_data.get("while_right", ""), + "right_source_var": node_data.get("while_right_source_var"), + "right_field": node_data.get("while_right_field"), + } + has_while_rule = any( + str(while_cfg.get(key) or "").strip() + for key in ("left", "left_source_var", "left_field", "right", "right_source_var", "right_field") + ) + if not has_while_rule: + continue_loop = False + else: + continue_loop = _evaluate_condition(while_cfg, variables) + + save_as = str(node_data.get("save_as") or f"{node_id}_loop").strip() + summary = { + "iterations": iterations_run, + "max_iterations": max_iterations, + "continue_loop": continue_loop, + "body_nodes": sorted(body_node_ids), + } + variables[save_as] = summary + + status = "failed" if last_error else "success" + return all_results, { + "node_id": node_id, + "node_type": "loop", + "status": status, + "output": summary, + "error": last_error, + } + + +async def execute_single_node( + node: dict[str, Any], + variables: dict[str, Any], + api_map: dict[int, dict[str, Any]], + mock_map: dict[str, dict[str, Any]], + base_url: str, + client: httpx.AsyncClient | None = None, + default_headers: dict[str, Any] | None = None, +) -> dict[str, Any]: + node_id = node.get("id", "") + node_data = node.get("data", {}) + node_type = node_data.get("type", "http") + own_client = client is None + if own_client: + client = httpx.AsyncClient(timeout=20.0) + assert client is not None + try: + # Legacy compatibility: 旧的 mock 节点已废弃 → 视为 http + if node_type == "mock": + node_type = "http" + node_data["type"] = "http" + + if node_type == "http": + return await _execute_http_node( + node_id=node_id, + node_data=node_data, + variables=variables, + api_map=api_map, + mock_map=mock_map, + base_url=base_url, + client=client, + default_headers=default_headers or {}, + ) + + if node_type == "condition": + ok = _evaluate_condition(node_data, variables) + variables[node_data.get("save_as", f"{node_id}_result")] = ok + return { + "node_id": node_id, + "node_type": node_type, + "status": "success", + "output": { + "result": ok, + "left": _resolve_condition_operand(node_data, "left", variables), + "right": _resolve_condition_operand(node_data, "right", variables), + "op": node_data.get("op", "=="), + }, + "error": None, + } + + if node_type == "loop": + return { + "node_id": node_id, + "node_type": node_type, + "status": "failed", + "output": {}, + "error": "loop node must be executed by execute_workflow", + } + + if node_type in {"start", "end"}: + return { + "node_id": node_id, + "node_type": node_type, + "status": "success", + "output": {"pass": True}, + "error": None, + } + + if node_type == "extract": + source_key = node_data.get("source_var", "") + field = node_data.get("field", "") + save_as = node_data.get("save_as", "") + source = variables.get(source_key, {}) + extracted = source + for part in field.split("."): + if not part: + continue + if isinstance(extracted, dict): + extracted = extracted.get(part) + else: + extracted = None + break + variables[save_as] = extracted + return { + "node_id": node_id, + "node_type": node_type, + "status": "success", + "output": {"value": extracted}, + "error": None, + } + + if node_type == "transform": + template = node_data.get("template", {}) + transformed = render_template_value(template, variables) + save_as = node_data.get("save_as", f"{node_id}_output") + variables[save_as] = transformed + return { + "node_id": node_id, + "node_type": node_type, + "status": "success", + "output": {"value": transformed}, + "error": None, + } + + if node_type == "delay": + seconds = float(node_data.get("seconds", 1)) + await asyncio.sleep(seconds) + return { + "node_id": node_id, + "node_type": node_type, + "status": "success", + "output": {"waited": seconds}, + "error": None, + } + + if node_type == "ai": + prompt = node_data.get("prompt", "") + response = {"message": "AI node reserved", "prompt": prompt} + save_as = node_data.get("save_as", f"{node_id}_ai") + variables[save_as] = response + return { + "node_id": node_id, + "node_type": node_type, + "status": "success", + "output": response, + "error": None, + } + + raise ValueError(f"unsupported node type: {node_type}") + except Exception as exc: + return { + "node_id": node_id, + "node_type": node_type, + "status": "failed", + "output": {}, + "error": str(exc), + } + finally: + if own_client: + await client.aclose() + + +def _merge_str_dict(a: dict[str, Any] | None, b: dict[str, Any] | None) -> dict[str, Any]: + out = dict(a or {}) + for k, v in (b or {}).items(): + out[k] = v + return out + + +def _mock_payload_for_overlay( + node_data: dict[str, Any], mock_map: dict[str, dict[str, Any]], variables: dict[str, Any] +) -> dict[str, Any]: + raw: Any = node_data.get("mock_data") + if raw is None: + raw = node_data.get("mock") + if isinstance(raw, str) and raw in mock_map: + raw = mock_map.get(raw) or {} + name = node_data.get("mock_name") + if (not raw or raw == {}) and isinstance(name, str) and name.strip() and name in mock_map: + raw = mock_map.get(name) or {} + rendered = render_template_value(raw if isinstance(raw, dict) else {}, variables) + return rendered if isinstance(rendered, dict) else {} + + +def _looks_like_response_mock(m: dict[str, Any]) -> bool: + if not m: + return False + if "status_code" in m: + return True + if m.get("text") is not None or m.get("json") is not None: + if not any(k in m for k in ("method", "url", "query", "path_params")): + return True + return False + + +def _has_request_overlay_keys(m: dict[str, Any]) -> bool: + return any(k in m for k in ("method", "url", "headers", "query", "body", "path_params")) + + +def _apply_request_overlay( + method: str, + url: str, + headers: dict[str, Any], + params: dict[str, Any], + json_body: dict[str, Any], + path_params: dict[str, Any], + overlay: dict[str, Any], +) -> tuple[str, str, dict[str, Any], dict[str, Any], dict[str, Any], dict[str, Any]]: + if not overlay: + return method, url, headers, params, json_body, path_params + if overlay.get("method"): + method = str(overlay["method"]).upper() + if "url" in overlay and overlay["url"] is not None: + url = str(overlay["url"]) + if isinstance(overlay.get("headers"), dict): + headers = _merge_str_dict(headers, overlay["headers"]) + if isinstance(overlay.get("query"), dict): + params = dict(overlay["query"]) + if isinstance(overlay.get("body"), dict): + json_body = dict(overlay["body"]) + if isinstance(overlay.get("path_params"), dict): + path_params = dict(overlay["path_params"]) + return method, url, headers, params, json_body, path_params + + +async def _execute_http_node( + node_id: str, + node_data: dict[str, Any], + variables: dict[str, Any], + api_map: dict[int, dict[str, Any]], + mock_map: dict[str, dict[str, Any]], + base_url: str, + client: httpx.AsyncClient, + default_headers: dict[str, Any], +) -> dict[str, Any]: + request_cfg = _resolve_http_request(node_data, api_map) + mode = str(node_data.get("mode", "http")).lower() + overlay_cfg = _mock_payload_for_overlay(node_data, mock_map, variables) + + use_request_mock = bool(node_data.get("use_mock")) + legacy_mode_mock = mode == "mock" + # 旧 mode=mock:若 overlay 中含请求字段则作为请求 mock;仅有响应字段时不覆盖请求(仍发真实 HTTP) + if legacy_mode_mock: + use_request_mock = use_request_mock or _has_request_overlay_keys(overlay_cfg) + + method = str(request_cfg.get("method", "GET")).upper() + url = render_template_value(request_cfg.get("url", ""), variables) + + dh = render_template_value(dict(default_headers or {}), variables) or {} + base_h = render_template_value(request_cfg.get("headers", {}), variables) or {} + headers = _merge_str_dict(dh, base_h) + params = render_template_value(request_cfg.get("query", {}), variables) or {} + json_body = render_template_value(request_cfg.get("body", {}), variables) or {} + path_params = render_template_value(request_cfg.get("path_params", {}), variables) or {} + + mock_request_applied = False + if use_request_mock and overlay_cfg and _has_request_overlay_keys(overlay_cfg): + method, url, headers, params, json_body, path_params = _apply_request_overlay( + method, url, headers, params, json_body, path_params, overlay_cfg + ) + mock_request_applied = True + + if base_url and url and not url.startswith("http"): + url = f"{base_url.rstrip('/')}/{url.lstrip('/')}" + + for key, value in path_params.items(): + url = url.replace(f"{{{key}}}", str(value)) + + request_payload = { + "method": method, + "url": url, + "headers": headers, + "query": params, + "body": json_body if method in {"POST", "PUT", "PATCH", "DELETE"} else None, + "path_params": path_params, + "mock_request_applied": mock_request_applied, + } + + response_payload: dict[str, Any] = {} + error_message: str | None = None + mock_response_used = False + started = time.perf_counter() + + try: + response = await client.request( + method=method, + url=url, + headers=headers, + params=params, + json=json_body if method in {"POST", "PUT", "PATCH", "DELETE"} else None, + ) + response_payload = _build_response_payload(response) + except Exception as exc: + error_message = str(exc) + if mode == "auto" and overlay_cfg and _looks_like_response_mock(overlay_cfg): + response_payload = _normalize_mock_response(overlay_cfg) + response_payload["mock_fallback_reason"] = error_message + mock_response_used = True + error_message = None + + duration_ms = int((time.perf_counter() - started) * 1000) + assertions = _evaluate_http_assertions(node_data.get("expect"), response_payload, error_message) + status = "failed" if (error_message or any(item["passed"] is False for item in assertions)) else "success" + + output = { + "request": request_payload, + "response": response_payload, + "mock_used": mock_request_applied or mock_response_used, + "duration_ms": duration_ms, + "assertions": assertions, + } + + save_as = node_data.get("save_as") + if save_as: + variables[save_as] = response_payload + + if status == "failed" and error_message is None: + error_message = "; ".join(item.get("reason", "assertion failed") for item in assertions if item["passed"] is False) + + return { + "node_id": node_id, + "node_type": "http", + "status": status, + "output": output, + "error": error_message, + } + + +def _resolve_http_request(node_data: dict[str, Any], api_map: dict[int, dict[str, Any]]) -> dict[str, Any]: + """Merge api_map definition (if api_id given) with inline node fields. Inline overrides.""" + + base: dict[str, Any] = {} + api_id_value = node_data.get("api_id") + if api_id_value is not None: + try: + api_id = int(api_id_value) + except (TypeError, ValueError): + api_id = None + if api_id is not None and api_id in api_map: + api_def = api_map[api_id] + base = { + "method": api_def.get("method", "GET"), + "url": api_def.get("url", ""), + "headers": dict(api_def.get("headers", {}) or {}), + "query": dict(api_def.get("query", {}) or {}), + "body": dict(api_def.get("body", {}) or {}), + "path_params": dict(api_def.get("path_params", {}) or {}), + } + + for field in ("method", "url"): + if node_data.get(field): + base[field] = node_data[field] + for field in ("headers", "query", "body", "path_params"): + if isinstance(node_data.get(field), dict): + merged = dict(base.get(field, {}) or {}) + merged.update(node_data[field]) + base[field] = merged + + base.setdefault("method", "GET") + base.setdefault("url", "") + base.setdefault("headers", {}) + base.setdefault("query", {}) + base.setdefault("body", {}) + base.setdefault("path_params", {}) + return base + + +def _build_response_payload(response: httpx.Response) -> dict[str, Any]: + payload: dict[str, Any] = { + "status_code": response.status_code, + "headers": dict(response.headers), + "text": response.text, + } + try: + payload["json"] = response.json() + except Exception: + payload["json"] = None + return payload + + +def _normalize_mock_response(mock_data: dict[str, Any]) -> dict[str, Any]: + if not isinstance(mock_data, dict): + return {"status_code": 200, "headers": {}, "text": "", "json": None} + body = mock_data.get("json") + if body is None and "data" in mock_data: + body = mock_data.get("data") + text = mock_data.get("text") + if text is None and body is not None: + try: + text = json.dumps(body, ensure_ascii=False) + except Exception: + text = str(body) + return { + "status_code": int(mock_data.get("status_code", 200)), + "headers": dict(mock_data.get("headers", {}) or {}), + "text": text or "", + "json": body, + "is_mock": True, + } + + +def _evaluate_http_assertions( + expect: Any, response_payload: dict[str, Any], error_message: str | None +) -> list[dict[str, Any]]: + if not isinstance(expect, dict): + return [] + if error_message: + return [{"name": "request", "passed": False, "reason": error_message}] + + results: list[dict[str, Any]] = [] + status_in = expect.get("status_in") or expect.get("status_codes") + if isinstance(status_in, list) and status_in: + actual = response_payload.get("status_code") + passed = actual in status_in + results.append( + { + "name": "status_in", + "passed": passed, + "expected": status_in, + "actual": actual, + "reason": None if passed else f"status_code {actual} not in {status_in}", + } + ) + + json_contains = expect.get("json_contains") + if isinstance(json_contains, dict): + body = response_payload.get("json") or {} + passed, reason = _dict_contains(body, json_contains) + results.append( + { + "name": "json_contains", + "passed": passed, + "expected": json_contains, + "actual": body, + "reason": None if passed else reason, + } + ) + + text_includes = expect.get("text_includes") + if isinstance(text_includes, str): + text = response_payload.get("text") or "" + passed = text_includes in text + results.append( + { + "name": "text_includes", + "passed": passed, + "expected": text_includes, + "actual": text[:200], + "reason": None if passed else f"response text does not contain '{text_includes}'", + } + ) + + return results + + +def _dict_contains(actual: Any, expected: Any) -> tuple[bool, str | None]: + if isinstance(expected, dict): + if not isinstance(actual, dict): + return False, "expected object" + for key, value in expected.items(): + if key not in actual: + return False, f"missing key: {key}" + ok, reason = _dict_contains(actual[key], value) + if not ok: + return False, f"{key}: {reason}" + return True, None + if isinstance(expected, list): + if not isinstance(actual, list): + return False, "expected list" + for item in expected: + if item not in actual: + return False, f"missing list item: {item}" + return True, None + return (actual == expected, None if actual == expected else f"expected {expected}, got {actual}") diff --git a/app/services/loki.py b/app/services/loki.py new file mode 100644 index 0000000..d8b3f29 --- /dev/null +++ b/app/services/loki.py @@ -0,0 +1,126 @@ +"""Build General / Grafana Loki explore links from workflow run context.""" + +from __future__ import annotations + +import json +import os +from datetime import datetime, timedelta, timezone +from typing import Any +from urllib.parse import quote + + +def _parse_run_time(value: str | None) -> datetime: + if not value: + return datetime.now(timezone.utc) + text = str(value).strip() + if text.endswith("Z"): + text = text[:-1] + "+00:00" + try: + parsed = datetime.fromisoformat(text) + except ValueError: + return datetime.now(timezone.utc) + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + +def _url_path_hint(url: str) -> str: + text = str(url or "").strip() + if not text: + return "" + if "://" in text: + try: + from urllib.parse import urlparse + + parsed = urlparse(text) + return parsed.path or text + except Exception: + return text + return text.split("?")[0] + + +def _extract_http_request(node_result: dict[str, Any] | None) -> dict[str, str]: + if not isinstance(node_result, dict): + return {} + output = node_result.get("output") or {} + if not isinstance(output, dict): + return {} + request = output.get("request") or {} + if not isinstance(request, dict): + return {} + method = str(request.get("method") or "GET").upper() + url = str(request.get("url") or "") + return {"method": method, "url": url, "path": _url_path_hint(url)} + + +def _build_logql(method: str, url_path: str) -> str: + service_label = os.getenv("LOKI_LABEL_SELECTOR", "").strip() + if service_label: + base = service_label + else: + service_key = os.getenv("LOKI_SERVICE_LABEL", "app").strip() or "app" + service_value = os.getenv("LOKI_SERVICE_VALUE", "").strip() or "ai-auto-test" + base = f'{{{service_key}="{service_value}"}}' + + filters: list[str] = [base] + if url_path: + escaped = url_path.replace('"', '\\"') + filters.append(f'|= "{escaped}"') + if method and method != "GET": + filters.append(f'|= "{method}"') + return " ".join(filters) + + +def _build_grafana_explore_url(logql: str, time_from: datetime, time_to: datetime) -> str: + base = (os.getenv("GENERAL_LOKI_EXPLORE_URL") or os.getenv("LOKI_EXPLORE_URL") or "").strip().rstrip("/") + if not base: + return "" + + datasource = os.getenv("LOKI_DATASOURCE", "Loki").strip() or "Loki" + org_id = os.getenv("LOKI_ORG_ID", "1").strip() or "1" + from_ms = int(time_from.timestamp() * 1000) + to_ms = int(time_to.timestamp() * 1000) + left = json.dumps( + [str(from_ms), str(to_ms), datasource, {"expr": logql, "refId": "A"}], + ensure_ascii=False, + ) + return f"{base}?orgId={quote(org_id)}&left={quote(left)}" + + +def build_run_loki_link( + *, + run_created_at: str | None, + duration_ms: int = 0, + node_result: dict[str, Any] | None = None, + padding_seconds: int | None = None, +) -> dict[str, Any]: + """Return explore URL + LogQL for a single HTTP node execution.""" + + padding = int(padding_seconds or os.getenv("LOKI_TIME_PADDING_SECONDS", "120") or 120) + started = _parse_run_time(run_created_at) + ended = started + timedelta(milliseconds=max(duration_ms, 0)) + time_from = started - timedelta(seconds=padding) + time_to = ended + timedelta(seconds=padding) + + http = _extract_http_request(node_result) + method = http.get("method", "") + url = http.get("url", "") + path = http.get("path", "") + logql = _build_logql(method, path) + explore_url = _build_grafana_explore_url(logql, time_from, time_to) + enabled = bool(explore_url) + + hint = "已生成 General Loki 查询链接,将按执行时间窗口与接口路径过滤日志。" + if not enabled: + hint = "未配置 GENERAL_LOKI_EXPLORE_URL / LOKI_EXPLORE_URL,请在环境变量中设置 General 探索页地址。" + + return { + "enabled": enabled, + "explore_url": explore_url, + "logql": logql, + "time_from": time_from.isoformat(), + "time_to": time_to.isoformat(), + "method": method, + "url": url, + "hint": hint, + } diff --git a/app/services/ssh_runner.py b/app/services/ssh_runner.py new file mode 100644 index 0000000..8bef91b --- /dev/null +++ b/app/services/ssh_runner.py @@ -0,0 +1,220 @@ +import asyncio +import contextlib +import os +import posixpath +import uuid +from collections.abc import AsyncIterator +from pathlib import Path +from typing import Any + +import paramiko + +from ..schemas import SshProfileOut, SshScriptOut + +PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent +SSH_SCRIPTS_DIR = PROJECT_ROOT / "data" / "ssh-scripts" + + +def resolve_script_body(script: SshScriptOut) -> str: + if script.source_type == "file": + if not script.storage_path: + raise ValueError("脚本文件路径为空") + file_path = (SSH_SCRIPTS_DIR / script.storage_path).resolve() + scripts_root = SSH_SCRIPTS_DIR.resolve() + if scripts_root not in file_path.parents and file_path != scripts_root: + raise ValueError("脚本文件路径非法") + if not file_path.is_file(): + raise ValueError("脚本文件不存在") + return file_path.read_text(encoding="utf-8") + body = (script.content or "").strip() + if not body: + raise ValueError("脚本内容为空") + return body + + +def _build_connect_kwargs(profile: SshProfileOut, password: str) -> dict[str, Any]: + if not password.strip(): + raise ValueError("执行前必须输入密码") + + connect_kwargs: dict[str, Any] = { + "hostname": profile.host, + "port": int(profile.port or 22), + "username": profile.username, + "timeout": 15, + "banner_timeout": 15, + "auth_timeout": 15, + } + if profile.auth_type == "password": + connect_kwargs.update({"password": password, "look_for_keys": False, "allow_agent": False}) + elif profile.auth_type == "key" and profile.key_path: + connect_kwargs.update({"key_filename": profile.key_path, "look_for_keys": False, "allow_agent": False}) + else: + connect_kwargs.update({"look_for_keys": True, "allow_agent": True}) + return connect_kwargs + + +def _connect_ssh(profile: SshProfileOut, password: str) -> paramiko.SSHClient: + if profile.auth_type == "key" and profile.key_path and not os.path.exists(profile.key_path): + raise ValueError("配置的 key_path 不存在") + + client = paramiko.SSHClient() + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + client.connect(**_build_connect_kwargs(profile, password)) + return client + + +def _upload_and_build_command(client: paramiko.SSHClient, script_body: str, script_name: str) -> str: + run_id = uuid.uuid4().hex[:12] + remote_dir = posixpath.join("/tmp", f"qip-script-{run_id}") + safe_name = "".join(ch if ch.isalnum() or ch in "._-" else "_" for ch in script_name) or "script.sh" + if not safe_name.endswith(".sh"): + safe_name = f"{safe_name}.sh" + remote_path = posixpath.join(remote_dir, safe_name) + + stdin, stdout, stderr = client.exec_command(f"mkdir -p {remote_dir} && chmod 700 {remote_dir}") + exit_status = stdout.channel.recv_exit_status() + if exit_status != 0: + err = (stderr.read() or b"").decode("utf-8", errors="replace").strip() + raise RuntimeError(err or "无法在远端创建临时目录") + + with client.open_sftp() as sftp: + with sftp.file(remote_path, "w") as remote_file: + remote_file.write(script_body) + sftp.chmod(remote_path, 0o700) + + return f"bash {remote_path}" + + +def _cleanup_remote_session(stdout: Any, stderr: Any, client: paramiko.SSHClient | None) -> None: + for stream in (stdout, stderr): + channel = getattr(stream, "channel", None) + if channel is not None: + with contextlib.suppress(Exception): + channel.close() + if client is not None: + with contextlib.suppress(Exception): + client.close() + + +async def stream_script_run( + profile: SshProfileOut, + script: SshScriptOut, + password: str, + cancel_event: asyncio.Event | None = None, +) -> AsyncIterator[dict[str, Any]]: + cancel_event = cancel_event or asyncio.Event() + script_body = resolve_script_body(script) + client = await asyncio.to_thread(_connect_ssh, profile, password) + stdout = None + stderr = None + + try: + remote_command = await asyncio.to_thread(_upload_and_build_command, client, script_body, script.name) + yield {"type": "meta", "command": remote_command, "profile": profile.name, "script": script.name} + + stdin, stdout, stderr = await asyncio.to_thread(client.exec_command, remote_command) + if stdin: + with contextlib.suppress(Exception): + stdin.close() + + while True: + if cancel_event.is_set(): + yield {"type": "stopped", "detail": "用户手动停止", "ok": False, "exit_status": -1} + return + + emitted = False + if stdout.channel.recv_ready(): + chunk = await asyncio.to_thread(stdout.channel.recv, 4096) + if chunk: + emitted = True + yield {"type": "stdout", "data": chunk.decode("utf-8", errors="replace")} + if stderr.channel.recv_stderr_ready(): + chunk = await asyncio.to_thread(stderr.channel.recv_stderr, 4096) + if chunk: + emitted = True + yield {"type": "stderr", "data": chunk.decode("utf-8", errors="replace")} + if ( + stdout.channel.exit_status_ready() + and not stdout.channel.recv_ready() + and not stderr.channel.recv_stderr_ready() + ): + break + if not emitted: + await asyncio.sleep(0.05) + + if cancel_event.is_set(): + yield {"type": "stopped", "detail": "用户手动停止", "ok": False, "exit_status": -1} + return + + exit_status = await asyncio.to_thread(stdout.channel.recv_exit_status) + yield {"type": "done", "exit_status": int(exit_status), "ok": exit_status == 0} + finally: + _cleanup_remote_session(stdout, stderr, client) + + +async def run_script_collect( + profile: SshProfileOut, + script: SshScriptOut, + password: str, + timeout_seconds: float = 120.0, + max_output_chars: int = 200_000, +) -> dict[str, Any]: + timeout_value = max(5.0, min(float(timeout_seconds or 120.0), 600.0)) + cancel_event = asyncio.Event() + stdout_parts: list[str] = [] + stderr_parts: list[str] = [] + meta: dict[str, Any] = {} + result: dict[str, Any] = { + "ok": False, + "exit_status": -1, + "stdout": "", + "stderr": "", + "command": "", + "timed_out": False, + "truncated": False, + } + + async def consume() -> None: + nonlocal result + async for event in stream_script_run(profile, script, password, cancel_event): + event_type = str(event.get("type", "")) + if event_type == "meta": + meta.update(event) + result["command"] = str(event.get("command", "")) + continue + if event_type in {"stdout", "stderr"}: + chunk = str(event.get("data", "")) + bucket = stdout_parts if event_type == "stdout" else stderr_parts + bucket.append(chunk) + total_len = sum(len(item) for item in stdout_parts) + sum(len(item) for item in stderr_parts) + if total_len > max_output_chars: + result["truncated"] = True + cancel_event.set() + return + continue + if event_type == "done": + result["ok"] = bool(event.get("ok")) + result["exit_status"] = int(event.get("exit_status", 0)) + return + if event_type == "stopped": + result["ok"] = False + result["exit_status"] = int(event.get("exit_status", -1)) + result["stopped"] = True + return + if event_type == "error": + raise RuntimeError(str(event.get("detail", "ssh script run failed"))) + + try: + await asyncio.wait_for(consume(), timeout=timeout_value) + except asyncio.TimeoutError: + cancel_event.set() + result["timed_out"] = True + result["ok"] = False + result["exit_status"] = -1 + finally: + result["stdout"] = "".join(stdout_parts) + result["stderr"] = "".join(stderr_parts) + if meta: + result["profile"] = meta.get("profile") + result["script"] = meta.get("script") + return result diff --git a/data/ssh-scripts/.gitkeep b/data/ssh-scripts/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/data/ssh-scripts/.gitkeep @@ -0,0 +1 @@ + diff --git a/docs/mcp_install.md b/docs/mcp_install.md new file mode 100644 index 0000000..8e686cd --- /dev/null +++ b/docs/mcp_install.md @@ -0,0 +1,201 @@ +# 把本平台注册成真正的 MCP Server + +本仓库已附带 `mcp_bridge.py`,它是一个标准 stdio MCP Server, +对外暴露 MCP 协议(JSON-RPC over stdio),内部桥接到本平台的 HTTP 网关。 + +只要先启动平台后端,再让 Cursor / Claude Desktop / Codex CLI 启动这个桥接, +它就会被识别成一个真正的 MCP Server,并自动暴露所有平台工具(含跑批工作流工具)。 + +--- + +## 1. 启动平台后端 + +```bash +cd /Users/qihongkun/work/My_app/ai_auto_test +# 可选:生产/共享环境建议开启,所有 MCP 调用必须带 API Key +# export MCP_REQUIRE_API_KEY=true +uvicorn app.main:app --host 127.0.0.1 --port 8000 +``` + +确认可访问: + +- `http://127.0.0.1:8000/mcp/tools` + +--- + +## 2. 安装到 Cursor + +编辑 `~/.cursor/mcp.json`(不存在则新建),加入: + +```json +{ + "mcpServers": { + "quality-inspection-platform": { + "command": "python3", + "args": ["/Users/qihongkun/work/My_app/ai_auto_test/mcp_bridge.py"], + "env": { + "AI_TEST_BASE_URL": "http://127.0.0.1:8000", + "AI_TEST_API_KEY": "sto-你的API密钥" + } + } + } +} +``` + +- `AI_TEST_BASE_URL`:平台后端地址。 +- `AI_TEST_API_KEY`:**必须配置**。写操作与执行类工具(`workflow_run`、`workflow_batch_run`、`ssh_script_run` 等)无 Key 将返回 401;桥接会把 Key 放到 `Authorization: Bearer` 与 invoke body 的 `api_key` 字段。 +- 后端可选 `MCP_REQUIRE_API_KEY=true`:所有 MCP 工具(含只读、`GET /mcp/tools`)均要求 Key。 + +在平台 Web 端右上角「个人中心」生成 `sto-` 开头的 API Key。 + +重启 Cursor 后,在 MCP 面板里就能看到 `quality-inspection-platform`,里面会自动列出全部工具。 + +--- + +## 3. 安装到 Claude Desktop + +编辑 `~/Library/Application Support/Claude/claude_desktop_config.json`: + +```json +{ + "mcpServers": { + "quality-inspection-platform": { + "command": "python3", + "args": ["/Users/qihongkun/work/My_app/ai_auto_test/mcp_bridge.py"], + "env": { + "AI_TEST_BASE_URL": "http://127.0.0.1:8000", + "AI_TEST_API_KEY": "sto-你的API密钥" + } + } + } +} +``` + +--- + +## 4. 安装到 Codex CLI + +```bash +codex mcp add quality-inspection-platform \ + --command python3 \ + --args /Users/qihongkun/work/My_app/ai_auto_test/mcp_bridge.py \ + --env AI_TEST_BASE_URL=http://127.0.0.1:8000 \ + --env AI_TEST_API_KEY=sto-你的API密钥 +``` + +或在 `~/.codex/config.toml` 中: + +```toml +[mcp.servers.quality-inspection-platform] +command = "python3" +args = ["/Users/qihongkun/work/My_app/ai_auto_test/mcp_bridge.py"] + +[mcp.servers.quality-inspection-platform.env] +AI_TEST_BASE_URL = "http://127.0.0.1:8000" +AI_TEST_API_KEY = "sto-你的API密钥" +``` + +--- + +## 5. 自检(不连客户端,先确认桥接可用) + +```bash +printf '%s\n' \ + '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' \ + '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' \ + '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"catalog_snapshot","arguments":{}}}' \ + | AI_TEST_API_KEY=sto-你的API密钥 python3 mcp_bridge.py +``` + +预期: + +- `initialize` 返回 `serverInfo` +- `tools/list` 返回工具清单(应包含 `workflow_batch_*` 等) +- `tools/call` 返回 `content[0].text` 内含调用结果 + +--- + +## 6. 暴露的工具(自动转发自平台) + +桥接启动时从 `GET /mcp/tools` 拉取列表,**无需改 `mcp_bridge.py`** 即可随平台升级获得新工具。 + +### 资源管理 + +- `api_upsert` — 创建/更新接口 +- `mock_upsert` — 创建/更新 mock 数据 +- `mcp_tool_upsert` — 创建/更新 MCP 工具配置 +- `catalog_snapshot` — 全量资源快照(含 `workflow_batches`、目录树、SSH 树) + +### 工作流(单次) + +- `workflow_upsert` — 创建/更新工作流(支持 `loop`、`condition` + `json_path`) +- `workflow_get` — 读取工作流及各节点 `last_run` +- `workflow_node_status` — 单节点最近执行状态 +- `workflow_patch_json` — 增量修改工作流 JSON +- `workflow_validate` — 校验 definition JSON +- `workflow_run` / `workflow_run_node` — 执行(需 API Key) +- `workflow_analyze_last_run` — 分析最近一次执行 +- `workflow_run_list` / `workflow_run_get` — 执行历史 +- `workflow_run_replay` / `workflow_run_loki_link` — 重放与 Loki 链接 + +### 跑批工作流 + +- `workflow_batch_create` — 创建草稿批跑任务(需 API Key) +- `workflow_batch_update` — 更新 `workflow_ids` / `base_url` 等(需 API Key) +- `workflow_batch_run` — 按任务配置顺序执行多个工作流 +- `workflow_batch_get` — 批跑详情与关联 `workflow_runs` +- `workflow_batch_list` — 批跑任务列表 + +推荐顺序:`create` → `update`(绑定 `workflow_ids`)→ `run` → `get`。详见 `docs/mcp_quickstart.md` 示例 H。 + +### 目录 + +- `folder_ensure` / `folder_list` +- `api_move_folder` / `workflow_move_folder` + +### SSH 脚本 + +- `ssh_tree` / `ssh_script_get` +- `ssh_script_upsert` / `ssh_script_run` + +--- + + +## 7. 工作机制 + +``` +Cursor / Claude / Codex + | + | (MCP stdio JSON-RPC) + v +mcp_bridge.py + | + | HTTP (httpx) + Authorization / api_key + v +http://127.0.0.1:8000/mcp/invoke +``` + +- 桥接会在启动时调用一次 `/mcp/tools`,把工具映射成 MCP `tools/list` 返回值。 +- 每次 `tools/call`,桥接会以 `{tool, arguments}` POST 到 `/mcp/invoke`。 +- 后端返回 `{ok, tool, data, error}`,桥接将其作为 `content[0].text` 文本返回,并按 `ok` 设置 `isError`。 + +--- + +## 8. Loki 日志(可选) + +若需在管理端或 API 中打开 General/Grafana Loki 探索页,在后端进程环境中配置: + +- `GENERAL_LOKI_EXPLORE_URL` 或 `LOKI_EXPLORE_URL` +- 可选:`LOKI_DATASOURCE`、`LOKI_ORG_ID`、`LOKI_LABEL_SELECTOR`、`LOKI_TIME_PADDING_SECONDS` + +配置后,`GET /api/workflow-runs/{run_id}/loki-link` 可为指定 HTTP 节点生成带时间窗与路径过滤的 Explore URL。 + +--- + +## 9. 维护建议 + +- 平台新增工具时,无需改桥接,重启 MCP 客户端即可重新拉取 `tools/list`。 +- 团队成员:`git pull` + `pip install -r requirements.txt` + 配置上述 MCP 入口与 `AI_TEST_API_KEY`。 +- **AI 能力说明(首选)**:**`docs/mcp_tools_for_ai.md`** +- 人类 curl 示例:**`docs/mcp_quickstart.md`** +- Codex 自动加载:**项目根 `AGENTS.md`** diff --git a/docs/mcp_quickstart.md b/docs/mcp_quickstart.md new file mode 100644 index 0000000..599de56 --- /dev/null +++ b/docs/mcp_quickstart.md @@ -0,0 +1,484 @@ +# MCP 快速接入与 AI 调用说明书 + +> **AI Agent 请优先阅读**:[mcp_tools_for_ai.md](./mcp_tools_for_ai.md)(工具决策表、鉴权、Recipe、节点约定)。 +> 本文档侧重 curl 示例与人工接入;项目根 [AGENTS.md](../AGENTS.md) 供 Codex 自动加载。 + +本文档给 AI Agent 和开发者使用,目标是让 AI 可以直接通过本平台的 MCP 接口完成: + +- 接口创建/更新 +- Mock 数据创建/更新 +- Workflow JSON 创建/修改(含循环节点、条件分支) +- 单次执行工作流 / 单节点调试 +- **跑批工作流**(先建任务、再选流程、再执行) +- 执行结果分析 +- 批量编排调用 + +--- + +## 1. 基础信息 + +- 服务地址:`http://127.0.0.1:8000` +- 工具发现:`GET /mcp/tools` +- 单次调用:`POST /mcp/invoke` +- 批量调用:`POST /mcp/invoke-batch` +- MCP 鉴权:在平台右上角「个人中心」生成 `sto-` 开头的 API Key,配置到 MCP Bridge 环境变量 `AI_TEST_API_KEY`,或在请求头使用 `Authorization: Bearer ` / `X-API-Key: `。 +- **写操作**(创建/更新资源)与 **执行类操作**(跑工作流、批跑、单节点、SSH 执行、重放)**必须**带有效 Key,禁止无 Key 回落为 superadmin。 +- 可选 **`MCP_REQUIRE_API_KEY=true`**(后端环境变量):所有 MCP 工具(含只读)均要求 Key;`GET /mcp/tools` 同样校验。 + +**需要 API Key 的写操作**:`folder_ensure`、`api_upsert`、`workflow_upsert`、`workflow_patch_json`、`workflow_move_folder`、`api_move_folder`、`mock_upsert`、`mcp_tool_upsert`、`ssh_script_upsert`、`workflow_batch_create`、`workflow_batch_update` + +**需要 API Key 的执行操作**:`workflow_run`、`workflow_run_node`、`workflow_batch_run`、`workflow_run_replay`、`ssh_script_run` + +统一返回结构(`/mcp/invoke`): + +```json +{ + "ok": true, + "tool": "tool_name", + "data": {}, + "error": null +} +``` + +--- + +## 2. 当前可用 MCP 工具 + +### 资源与工作流 + +| 工具 | 用途 | 关键参数 | +|------|------|----------| +| `api_upsert` | 创建或更新接口 | `name`, `method`, `url`;可选 `api_id`, `headers`, `body`, `query`, `path_params`, `timeout_seconds`, `folder_path` | +| `mock_upsert` | 创建或更新 Mock | `name`, `data` | +| `workflow_upsert` | 创建或更新工作流 JSON | `name`, `definition`;可选 `workflow_id`, `folder_path` | +| `workflow_get` | 读取完整工作流(含各节点 `last_run`) | `workflow_id` | +| `workflow_node_status` | 读取单节点最近执行状态 | `workflow_id`, `node_id` | +| `workflow_patch_json` | 对工作流 definition 深度合并 patch | `workflow_id`, `patch` | +| `workflow_run` | 执行整个工作流 | `workflow_id`;可选 `base_url`, `fail_fast` | +| `workflow_run_node` | 执行单个节点 | `workflow_id`, `node_id`;可选 `base_url` | +| `workflow_analyze_last_run` | 分析最近一次执行摘要 | `workflow_id` | +| `workflow_run_list` | 执行历史列表 | 可选 `workflow_id`、`batch_id`、`limit`、`after_id` | +| `workflow_run_get` | 单次执行详情(含完整 `payload`) | `run_id` | +| `workflow_run_replay` | 按历史快照重放 | `run_id`;可选 `base_url`、`fail_fast` | +| `workflow_run_loki_link` | 生成 Loki Explore 链接 | `run_id`、`node_id` | +| `workflow_validate` | 校验 definition JSON | `definition` | + +### 跑批工作流(推荐顺序见 §5 示例 H) + +| 工具 | 用途 | 关键参数 | +|------|------|----------| +| `workflow_batch_create` | 创建批跑任务(`draft`) | 可选 `name`, `base_url`, `fail_fast`, `workflow_ids` | +| `workflow_batch_update` | 更新草稿批跑任务 | `batch_id`;可选 `name`, `base_url`, `fail_fast`, `workflow_ids` | +| `workflow_batch_run` | 执行批跑(按 `workflow_ids` 顺序跑多个工作流) | `batch_id` | +| `workflow_batch_get` | 批跑详情(含关联的 `workflow_runs`) | `batch_id` | +| `workflow_batch_list` | 列出当前用户的批跑任务 | 可选 `limit`, `after_id`, `status`(`draft`/`running`/`success`/`failed`/`partial`) | + +> **跑批 MCP 约定**:必须先 `workflow_batch_create`(或 create 时带上 `workflow_ids`),再 `workflow_batch_update` 绑定工作流,最后 `workflow_batch_run`。不要跳过草稿阶段直接「匿名批量跑」。 + +### 目录管理 + +| 工具 | 用途 | +|------|------| +| `folder_ensure` | 创建/登记目录(`target`: `apis` / `workflows`,`path`) | +| `folder_list` | 列出目录 | +| `api_move_folder` | 移动接口到目录(`api_id`, `folder_path`) | +| `workflow_move_folder` | 移动工作流到目录(`workflow_id`, `folder_path`) | + +### 全局与其它 + +| 工具 | 用途 | +|------|------| +| `catalog_snapshot` | 一次返回 APIs / Workflows / Mocks / **workflow_batches** / MCP 配置及 SSH 树 | +| `mcp_tool_upsert` | 创建或更新 MCP 工具配置 | + +### SSH 脚本管理 + +| 工具 | 用途 | +|------|------| +| `ssh_tree` | SSH 主机与脚本树 | +| `ssh_script_get` | 读取脚本详情 | +| `ssh_script_upsert` | 创建/更新内联 bash 脚本(`name`, `content`;创建需 `profile_id`,更新需 `script_id`) | +| `ssh_script_run` | 远端执行脚本(`profile_id`, `script_id`, `password`;可选 `timeout_seconds`) | + +--- + +## 3. 工作流节点与连线约定 + +`workflow_upsert` / `workflow_patch_json` 中的 `definition` 与前端 Drawflow 导出结构一致: + +```json +{ + "nodes": [{ "id": "n1", "position": {"x": 0, "y": 0}, "data": { "type": "http", ... } }], + "edges": [{ "id": "e1", "source": "n1", "target": "n2", "label": "success", "data": { "branch": "success" } }], + "variables": {} +} +``` + +### 节点类型 + +| `data.type` | 说明 | +|-------------|------| +| `start` / `end` | 透传,无 HTTP | +| `http` | 发请求;可内联 `method/url/headers/body/query/path_params`,或 `api_id`;支持 `mock`、`expect` | +| `extract` | 从变量提取字段写入新变量 | +| `condition` | 条件分支:连线 `data.branch` 为 `true` / `false`(或 label `IF`/`ELSE`) | +| `loop` | 循环体:从 loop 节点连出的 **BODY** 边进入子图,多轮执行后再走 **DONE** | + +### 条件节点(`condition`) + +- `left_mode` / `right_mode`:`template`(默认,变量替换后比较)或 `json_path`(从 `left_source_var` 对应响应里按 `left_field` 取 JSON 路径值)。 +- `op`:`==`、`!=`、`>`、`<`、`contains` 等。 +- 出边:`edge.data.branch` 为 `true` / `false`(引擎也识别 label 中的 IF/ELSE)。 + +### 循环节点(`loop`) + +- **BODY 边**:`edge.data.branch` 取 `body`、`loop`、`in`、`next`、`continue` 之一(或空字符串),目标节点构成循环体子图。 +- **DONE 边**:`branch` 为 `done`、`out`、`exit`、`end` 等,循环结束后继续主流程。 +- 节点字段示例: + - `max_iterations`:最大轮数(默认 10) + - `iteration_var`:每轮写入变量的下标名(默认 `loop_index`) + - `while_left_mode` / `while_left` / `while_left_source_var` / `while_left_field` / `while_op` / `while_right_*`:每轮 BODY 执行完后判断是否继续下一轮(语义同 condition,支持 `json_path`) + +### HTTP 出边分支 + +- 成功:`branch`: `success` +- 失败:`branch`: `failed` + +--- + +## 4. AI 调用协议(推荐) + +### 4.1 通用调用模板 + +```bash +curl -s -X POST http://127.0.0.1:8000/mcp/invoke \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{ + "tool": "tool_name", + "arguments": {} + }' +``` + +### 4.2 批量调用模板 + +```bash +curl -s -X POST http://127.0.0.1:8000/mcp/invoke-batch \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{ + "stop_on_error": true, + "calls": [ + {"tool": "tool_a", "arguments": {}}, + {"tool": "tool_b", "arguments": {}} + ] + }' +``` + +--- + +## 5. 常见 AI 任务示例 + +### 示例 A:AI 自动创建接口 + +```bash +curl -s -X POST http://127.0.0.1:8000/mcp/invoke \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{ + "tool": "api_upsert", + "arguments": { + "name": "登录接口", + "method": "POST", + "url": "/api/login", + "headers": {"token": "{{token}}"}, + "body": {"username": "admin", "password": "123456"}, + "query": {}, + "path_params": {}, + "timeout_seconds": 10 + } + }' +``` + +### 示例 B:AI 自动创建 Mock 数据 + +```bash +curl -s -X POST http://127.0.0.1:8000/mcp/invoke \ + -H "Content-Type: application/json" \ + -d '{ + "tool": "mock_upsert", + "arguments": { + "name": "demo_user", + "data": {"uid": 1001, "token": "abc"} + } + }' +``` + +### 示例 C:AI 自动创建 Workflow(含条件分支) + +```bash +curl -s -X POST http://127.0.0.1:8000/mcp/invoke \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{ + "tool": "workflow_upsert", + "arguments": { + "name": "登录流程", + "definition": { + "nodes": [ + {"id": "n1", "position": {"x": 120, "y": 120}, "data": {"type": "http", "api_id": 1, "save_as": "login_resp"}}, + {"id": "n2", "position": {"x": 380, "y": 120}, "data": {"type": "extract", "source_var": "login_resp", "field": "json.token", "save_as": "token"}}, + {"id": "n3", "position": {"x": 640, "y": 120}, "data": {"type": "condition", "left_mode": "json_path", "left_source_var": "login_resp", "left_field": "json.code", "op": "==", "right": "0"}} + ], + "edges": [ + {"id": "e1", "source": "n1", "target": "n2", "label": "success", "data": {"branch": "success"}}, + {"id": "e2", "source": "n2", "target": "n3"}, + {"id": "e3", "source": "n3", "target": "n4", "label": "IF", "data": {"branch": "true"}}, + {"id": "e4", "source": "n3", "target": "n5", "label": "ELSE", "data": {"branch": "false"}} + ], + "variables": {} + } + } + }' +``` + +### 示例 D:AI 自动 Patch Workflow JSON + +```bash +curl -s -X POST http://127.0.0.1:8000/mcp/invoke \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{ + "tool": "workflow_patch_json", + "arguments": { + "workflow_id": 1, + "patch": { + "variables": {"env": "test"} + } + } + }' +``` + +### 示例 E:AI 执行工作流并分析结果 + +```bash +curl -s -X POST http://127.0.0.1:8000/mcp/invoke \ + -H "Content-Type: application/json" \ + -d '{ + "tool": "workflow_run", + "arguments": { + "workflow_id": 1, + "base_url": "http://127.0.0.1:8080", + "fail_fast": true + } + }' +``` + +```bash +curl -s -X POST http://127.0.0.1:8000/mcp/invoke \ + -H "Content-Type: application/json" \ + -d '{ + "tool": "workflow_analyze_last_run", + "arguments": {"workflow_id": 1} + }' +``` + +### 示例 F:AI 批量编排(创建接口 → Mock → Patch → 执行) + +```bash +curl -s -X POST http://127.0.0.1:8000/mcp/invoke-batch \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{ + "stop_on_error": true, + "calls": [ + { + "tool": "api_upsert", + "arguments": { + "name": "用户详情接口", + "method": "GET", + "url": "/api/user/{uid}", + "headers": {"token": "{{token}}"}, + "query": {}, + "path_params": {"uid": "{{uid}}"} + } + }, + { + "tool": "mock_upsert", + "arguments": { + "name": "seed_user", + "data": {"uid": 1001, "token": "abc"} + } + }, + { + "tool": "workflow_patch_json", + "arguments": { + "workflow_id": 1, + "patch": {"variables": {"uid": 1001, "token": "abc"}} + } + }, + { + "tool": "workflow_run", + "arguments": {"workflow_id": 1, "base_url": "http://127.0.0.1:8080", "fail_fast": true} + } + ] + }' +``` + +### 示例 G:按目录分功能管理 + +```bash +curl -s -X POST http://127.0.0.1:8000/mcp/invoke-batch \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{ + "stop_on_error": true, + "calls": [ + {"tool": "folder_ensure", "arguments": {"target": "apis", "path": "auth/login"}}, + {"tool": "folder_ensure", "arguments": {"target": "workflows", "path": "auth/smoke"}}, + { + "tool": "api_upsert", + "arguments": { + "name": "登录接口", + "folder_path": "auth/login", + "method": "POST", + "url": "/api/login", + "body": {"username": "admin", "password": "123456"} + } + }, + { + "tool": "workflow_upsert", + "arguments": { + "name": "登录冒烟", + "folder_path": "auth/smoke", + "definition": {"nodes": [], "edges": [], "variables": {}} + } + } + ] + }' +``` + +### 示例 H:跑批工作流(MCP 三步) + +```bash +# 1) 创建草稿批跑任务 +curl -s -X POST http://127.0.0.1:8000/mcp/invoke \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{ + "tool": "workflow_batch_create", + "arguments": { + "name": "nightly-smoke", + "base_url": "http://127.0.0.1:8080", + "fail_fast": false + } + }' + +# 假设返回 data.id = 3 + +# 2) 绑定要执行的工作流 ID 列表 +curl -s -X POST http://127.0.0.1:8000/mcp/invoke \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{ + "tool": "workflow_batch_update", + "arguments": { + "batch_id": 3, + "workflow_ids": [1, 2, 5] + } + }' + +# 3) 执行批跑 +curl -s -X POST http://127.0.0.1:8000/mcp/invoke \ + -H "Content-Type: application/json" \ + -d '{ + "tool": "workflow_batch_run", + "arguments": {"batch_id": 3} + }' + +# 4) 查询结果(含每次 workflow_run 的 run_id) +curl -s -X POST http://127.0.0.1:8000/mcp/invoke \ + -H "Content-Type: application/json" \ + -d '{ + "tool": "workflow_batch_get", + "arguments": {"batch_id": 3} + }' +``` + +也可用 `invoke-batch` 将 create + update + run 串在一次请求里(`stop_on_error: true` 时任一步失败即中断)。 + +--- + +## 6. Loki 环境变量 + +**Loki 相关环境变量**(后端 `app/services/loki.py`): + +| 变量 | 说明 | +|------|------| +| `GENERAL_LOKI_EXPLORE_URL` 或 `LOKI_EXPLORE_URL` | Grafana Explore 页基础 URL(必填其一才生成链接) | +| `LOKI_DATASOURCE` | 数据源名,默认 `Loki` | +| `LOKI_ORG_ID` | 组织 ID,默认 `1` | +| `LOKI_LABEL_SELECTOR` | 完整 LogQL 标签选择器;未设则用 `LOKI_SERVICE_LABEL` / `LOKI_SERVICE_VALUE` | +| `LOKI_TIME_PADDING_SECONDS` | 执行时间窗口前后扩展秒数,默认 `120` | + +--- + +## 7. AI 调用策略建议 + +### 推荐执行顺序(单工作流调试) + +1. `catalog_snapshot` 读取现状 +2. `api_upsert` / `mock_upsert` 补全资源 +3. `workflow_upsert` 或 `workflow_patch_json` 组装流程 +4. `workflow_run` 或 `workflow_run_node` 执行 +5. `workflow_analyze_last_run` / `workflow_get` 分析 +6. 根据失败节点二次 patch + 重跑 + +### 推荐执行顺序(跑批) + +1. `catalog_snapshot` 确认 `workflow_id` 列表 +2. `workflow_batch_create`(草稿) +3. `workflow_batch_update`(写入 `workflow_ids`、`base_url`、`fail_fast`) +4. `workflow_batch_run` +5. `workflow_batch_get` 汇总;排错用 `workflow_run_get` / `workflow_run_replay` / `workflow_run_loki_link` + +### 失败处理策略 + +- `ok=false`:优先读取 `error`,只修最小必要字段后重试 +- 工具不存在:先调用 `GET /mcp/tools` 刷新工具列表 +- JSON 错误:确保 `arguments` 中对象字段是 JSON 对象(不是字符串) +- 运行失败:先看 `failed_nodes`,再做针对性 patch,不要全量重建 workflow +- 批跑 `partial`:用 `workflow_batch_get` 里每条 `run` 的 `status` 定位失败工作流 + +--- + +## 8. 给 AI 的最小 Prompt(可直接复用) + +```text +你是本平台的自动化编排 Agent。 +目标:最小改动下让 workflow / 批跑 执行成功。 + +单工作流: +1) catalog_snapshot +2) 缺什么补什么(api_upsert / mock_upsert / workflow_patch_json) +3) workflow_run +4) workflow_analyze_last_run +5) 失败则只修失败节点相关配置后重试 + +跑批: +1) workflow_batch_create +2) workflow_batch_update(workflow_ids) +3) workflow_batch_run +4) workflow_batch_get 核对每条 run + +约束:保持 JSON 可读、禁止无关改动、每步输出工具调用和结果摘要。 +写操作必须带 sto- API Key。 +``` + +--- + +## 9. 版本说明 + +- 文档对应:`app/main.py` 中 `MCP_TOOL_SPECS` 与 `/mcp/invoke` 实现 +- 跑批、循环节点、`json_path` 条件、Loki 链接为当前仓库已上线能力 +- 若后续新增工具,请同步更新 `/mcp/tools` 和本文档工具清单 diff --git a/docs/mcp_tools_for_ai.md b/docs/mcp_tools_for_ai.md new file mode 100644 index 0000000..490d48f --- /dev/null +++ b/docs/mcp_tools_for_ai.md @@ -0,0 +1,357 @@ +# MCP 工具能力说明(AI 专用) + +> **读者**:Cursor / Codex / Claude 等通过 `quality-inspection-platform` MCP 工作的 Agent。 +> **目的**:在调用 `tools/list` 之外,提供「何时用哪个工具、按什么顺序、参数怎么填」的固定上下文。 +> **维护**:工具以 `app/main.py` 中 `MCP_TOOL_SPECS` 和平台 `GET /mcp/tools` 为准;增删工具时请同步更新本文档。 +> **重要**:某些 MCP 客户端可能缓存、裁剪或延迟刷新工具列表;不要仅凭当前客户端可见工具反向删减本文档能力。 + +--- + +## 0. Agent 必读(30 秒) + +1. **先** `catalog_snapshot` 了解现有 apis / workflows / workflow_batches / mocks / folders / ssh_tree,避免重复创建。 +2. **写操作与执行**必须带 `sto-` API Key(`Authorization: Bearer` 或桥接环境变量 `AI_TEST_API_KEY`)。 +3. **接口**用 `api_upsert`;**流程**用 `workflow_upsert` 或 `workflow_patch_json`;**跑批**固定三步:`workflow_batch_create` → `workflow_batch_update` → `workflow_batch_run`。 +4. **排障**:`workflow_run_get` 看 request/response → `workflow_run_loki_link` 查日志。 +5. 如果当前客户端没有显示某个工具,先确认平台 `GET /mcp/tools` 是否包含该工具,再刷新或重启 MCP 客户端。 +6. 人类可读安装与 curl 示例见 `docs/mcp_quickstart.md`;桥接安装见 `docs/mcp_install.md`。 + +### 0.1 工具来源与客户端刷新 + +- 权威工具清单来自后端 `app/main.py` 的 `MCP_TOOL_SPECS`,平台通过 `GET /mcp/tools` 对外暴露。 +- `mcp_bridge.py` 启动后会从 `AI_TEST_BASE_URL/mcp/tools` 拉取工具并转换为 MCP `tools/list`。 +- Codex / Cursor / Claude 等客户端可能在会话启动时缓存工具列表;平台新增工具后,通常需要重启 MCP 客户端或重启会话。 +- 如果客户端只显示部分工具,不代表平台没有该能力。先用 `/mcp/tools` 或本文档核对,再决定是否降级。 +- Agent 更新本文档时,应以源码和 `/mcp/tools` 为准,不以单个客户端当前暴露的工具子集为准。 + +--- + +## 1. 平台在做什么 + +| 层级 | 含义 | MCP 主要工具 | +|------|------|----------------| +| 接口库 | 单个 HTTP API 定义(method、url、headers、body…) | `api_upsert`、`catalog_snapshot` | +| 工作流 | 多节点编排(HTTP / 条件 / 循环 / 提取) | `workflow_upsert`、`workflow_run` | +| 跑批 | 一次任务顺序执行多个工作流 | `workflow_batch_*` | +| 执行历史 | 每次运行的 request/response 快照 | `workflow_run_list`、`workflow_run_get` | +| Mock | 命名数据集,供节点 mock 使用 | `mock_upsert` | + +**数据约定**:`url` 只存**路径**(如 `/api/user/{id}`),环境域名放在执行时的 `base_url`。 + +--- + +## 2. 鉴权 + +### 2.1 平台地址 + +MCP Bridge 通过环境变量访问平台: + +| 环境变量 | 含义 | 示例 | +|----------|------|------| +| `AI_TEST_BASE_URL` | 质量检测平台后端地址 | `http://127.0.0.1:8000` | +| `AI_TEST_API_KEY` | `sto-` 开头的 API Key | `sto-...` | + +Agent 通过 MCP 工具访问平台,不需要在业务项目中启动平台服务;平台进程由质量检测平台项目本身或共享服务负责。 + +### 2.2 调用鉴权 + +| 类型 | 工具示例 | 无 Key 时 | +|------|----------|-----------| +| 只读 | `catalog_snapshot`、`workflow_get`、`workflow_run_list` | 以 superadmin 读全库(不推荐生产暴露) | +| 写入 | `api_upsert`、`workflow_upsert`、`folder_ensure`… | **401** | +| 执行 | `workflow_run`、`workflow_batch_run`、`ssh_script_run`、`workflow_run_replay` | **401** | + +环境变量 `MCP_REQUIRE_API_KEY=true` 时,**所有**工具(含 `GET /mcp/tools`)均需 Key。 + +写操作与执行类工具必须使用有效 Key。不要把 Key 写入文档、日志或对话输出;只允许放在 MCP 配置、环境变量或请求头中。 + +--- + +## 3. 按场景选工具(决策表) + +| 用户意图 | 推荐工具链 | +|----------|------------| +| 我不知道项目里有什么 | `catalog_snapshot` | +| 客户端看不到某个工具 | 查 `GET /mcp/tools` → 重启 MCP 客户端 / 会话 → 再调用 | +| 从 Apifox/文档批量建接口 | `folder_ensure` → 多次 `api_upsert`(或 `invoke-batch`) | +| 新建/改流程图 | `workflow_validate`(可选)→ `workflow_upsert` | +| 只改流程里某几个字段 | `workflow_patch_json` | +| 跑一条流程 | `workflow_run`(`base_url` + `workflow_id`) | +| 只调试一个节点 | `workflow_run_node` | +| 版本/回归一次跑多条流程 | `workflow_batch_create` → `workflow_batch_update` → `workflow_batch_run` → `workflow_batch_get` | +| 看某次执行详情 | `workflow_run_get`(`run_id`) | +| 看历史列表 | `workflow_run_list`(`workflow_id` / `batch_id`) | +| 失败后续跑 | `workflow_run_replay`(`run_id`) | +| 查服务端日志 | `workflow_run_loki_link`(`run_id` + `node_id`) | +| 看节点上次结果 | `workflow_node_status` 或 `workflow_get` | +| 快速看上次成败统计 | `workflow_analyze_last_run` | +| 准备 Mock 数据 | `mock_upsert` | +| 运维脚本 | `ssh_tree` → `ssh_script_upsert` → `ssh_script_run` | + +--- + +## 4. 工具清单(按分类) + +### 4.1 发现与目录 + +#### `catalog_snapshot` + +- **用途**:一次拉取 apis、workflows、mocks、**workflow_batches**、folders、ssh_tree。 +- **参数**:无。 +- **何时用**:任务开始、批量导入前、避免重复 `api_upsert`。 + +#### `folder_ensure` + +- **用途**:登记 apis 或 workflows 目录(空目录也可)。 +- **参数**:`target`:`apis` | `workflows`;`path`:如 `auth/login`(不要前导 `/`)。 +- **需 Key**:是。 + +#### `folder_list` + +- **用途**:列出已登记目录。 +- **参数**:可选 `target`。 + +#### `api_move_folder` / `workflow_move_folder` + +- **用途**:移动资源到目录;workflow 目标目录须已 `folder_ensure`。 + +--- + +### 4.2 接口(API) + +#### `api_upsert` + +- **用途**:创建或更新接口定义。 +- **必填**:`name`、`method`、`url`。 +- **常用可选**:`api_id`(更新)、`folder_path`、`headers`、`body`、`query`、`path_params`、`timeout_seconds`。 +- **需 Key**:是。 +- **示例**: + +```json +{ + "tool": "api_upsert", + "arguments": { + "name": "用户登录", + "folder_path": "auth", + "method": "POST", + "url": "/api/login", + "headers": { "Content-Type": "application/json" }, + "body": { "username": "admin", "password": "{{password}}" }, + "query": {}, + "path_params": {} + } +} +``` + +--- + +### 4.3 工作流(Workflow) + +#### `workflow_upsert` + +- **用途**:创建/更新完整 `definition`(Drawflow JSON)。 +- **必填**:`name`、`definition`(`nodes`、`edges`、`variables`)。 +- **需 Key**:是。 +- **注意**:非空 `folder_path` 须先 `folder_ensure`(workflows)。 + +#### `workflow_get` + +- **用途**:读取流程及各节点 `last_run`。 + +#### `workflow_patch_json` + +- **用途**:对 `definition` / `variables` 等深度合并 patch;小改动优先于全量 upsert。 + +#### `workflow_validate` + +- **用途**:检查节点 id、边是否引用合法;**upsert 前**建议调用。 + +#### `workflow_move_folder` + +- **用途**:移动工作流到已登记目录。 + +#### `workflow_node_status` + +- **用途**:单节点最近 `last_run`。 + +#### `workflow_analyze_last_run` + +- **用途**:上次执行汇总(成功/失败节点 id);细节不足时再 `workflow_get`。 + +--- + +### 4.4 执行 + +#### `workflow_run` + +- **用途**:执行整条工作流。 +- **必填**:`workflow_id`。 +- **可选**:`base_url`(环境根地址)、`fail_fast`(默认 true)。 +- **需 Key**:是。 +- **返回**:`status`、`variables`、`results[]`(含每节点 request/response)、`run_id`。 + +#### `workflow_run_node` + +- **用途**:只跑一个节点(调试参数)。 +- **必填**:`workflow_id`、`node_id`。 +- **需 Key**:是。 + +#### `workflow_run_replay` + +- **用途**:按历史 run 内保存的 definition 快照重放。 +- **必填**:`run_id`。 +- **需 Key**:是。 + +--- + +### 4.5 跑批(Batch) + +| 步骤 | 工具 | 说明 | +|------|------|------| +| 1 | `workflow_batch_create` | 建草稿,可带 `name`、`base_url`、`fail_fast` | +| 2 | `workflow_batch_update` | **必填** `batch_id`;设置 `workflow_ids: [1,2,3]` | +| 3 | `workflow_batch_run` | 执行,仅 `draft` 可跑 | +| 4 | `workflow_batch_get` | 查看结果及关联 `run_id` | + +辅助:`workflow_batch_list`(`status` 过滤:draft|running|success|failed|partial)。 + +**需 Key**:create/update/run 需 Key;get/list 只读。 + +--- + +### 4.6 执行历史与日志 + +#### `workflow_run_list` + +- **参数**:可选 `workflow_id`、`batch_id`、`limit`、`after_id`。 +- **返回**:`items[]` 摘要(无完整 body 时用 `workflow_run_get`)。 + +#### `workflow_run_get` + +- **参数**:`run_id`。 +- **返回**:含 `payload`(`results`、`variables`、`workflow_snapshot`)。 + +#### `workflow_run_loki_link` + +- **参数**:`run_id`、`node_id`(HTTP 节点)。 +- **返回**:`explore_url`、`logql`、时间窗;未配 `GENERAL_LOKI_EXPLORE_URL` 时 `enabled=false`。 + +--- + +### 4.7 Mock 与其它 + +#### `mock_upsert` + +- **用途**:按 `name` 创建/更新 Mock 数据集(`data` 对象)。 +- **需 Key**:是。 + +#### `mcp_tool_upsert` + +- **用途**:平台内「自定义 MCP 工具配置」表,**不是**本桥接工具列表本身。 + +#### SSH 系列 + +| 工具 | 用途 | +|------|------| +| `ssh_tree` | 主机与脚本树 | +| `ssh_script_get` | 读脚本内容 | +| `ssh_script_upsert` | 创建/更新内联 bash(`profile_id`+`name`+`content`) | +| `ssh_script_run` | 远端执行(**需 password**,需 Key) | + +--- + +## 5. 工作流 definition 约定(简版) + +```json +{ + "nodes": [ + { "id": "n1", "position": {"x": 0, "y": 0}, "data": { "type": "http", "api_id": 1, "save_as": "resp1" } }, + { "id": "n2", "data": { "type": "extract", "source_var": "resp1", "field": "json.token", "save_as": "token" } } + ], + "edges": [ + { "id": "e1", "source": "n1", "target": "n2", "data": { "branch": "success" } } + ], + "variables": {} +} +``` + +| `data.type` | 说明 | +|-------------|------| +| `start` / `end` | 透传 | +| `http` | 引用 `api_id` 或内联 method/url/headers/body/query/path_params;支持 mock、expect | +| `extract` | 从 `source_var` 取 `field` 写入 `save_as` | +| `condition` | 出边 `branch`: `true` / `false`;支持 `left_mode=json_path` | +| `loop` | BODY 边进入子图;`while_*` 控制下一轮;DONE 边退出 | + +变量替换:字符串中的 `{{token}}` 由 `variables` 注入。工作流级 `default_headers` 与节点 headers 合并,**节点覆盖同名 key**。 + +--- + +## 6. 调用协议 + +- **发现**:`GET /mcp/tools` → `{ tools, require_api_key, ai_guide }` +- **单次**:`POST /mcp/invoke` → `{ ok, tool, data, error }` +- **批量**:`POST /mcp/invoke-batch` → `{ results: [...] }`,`stop_on_error: true` 时任一步失败即停 + +`ok=false` 时读 `error`,修正参数后重试;不要臆造未在 `tools/list` 中出现的工具名。 + +--- + +## 7. 推荐 Recipe + +### R1:新建接口并跑通单接口流程 + +``` +catalog_snapshot → folder_ensure(apis) → api_upsert +→ workflow_upsert → workflow_run → workflow_analyze_last_run +``` + +### R2:Apifox 迁移一批接口 + +``` +catalog_snapshot → folder_ensure(apis, 模块路径) +→ invoke-batch: N × api_upsert → 核对 catalog +``` + +### R3:版本回归跑批 + +``` +workflow_batch_create → workflow_batch_update(workflow_ids) +→ workflow_batch_run → workflow_batch_get +→ 对失败项 workflow_run_get + workflow_run_loki_link +``` + +### R4:失败节点最小修复 + +``` +workflow_analyze_last_run → workflow_patch_json(只改变量/单节点 data) +→ workflow_run → 仍失败则 workflow_run_get 看 payload +``` + +--- + +## 8. 常见错误 + +| error / 现象 | 处理 | +|--------------|------| +| 401 MCP requires API Key | 配置 `AI_TEST_API_KEY` 或请求头 Bearer | +| workflow folder not registered | 先 `folder_ensure` workflows | +| 仅 draft 批次可执行 | 已对 running/success 的 batch 再 run | +| unknown mcp tool | `GET /mcp/tools` 刷新名称 | +| Loki enabled=false | 配置 `GENERAL_LOKI_EXPLORE_URL` | +| 重复接口 | 同 method+url 视为同一接口,更新时传 `api_id` | + +--- + +## 9. 文档索引 + +| 文件 | 受众 | +|------|------| +| **本文** `docs/mcp_tools_for_ai.md` | **AI Agent(首选)** | +| `docs/mcp_quickstart.md` | 人类 + curl 示例 | +| `docs/mcp_install.md` | MCP 桥接安装 | +| 项目根 `AGENTS.md` | Codex 自动加载的短指引 | + +--- + +*文档版本:与仓库 `MCP_TOOL_SPECS` 同步(含 workflow_run_* / workflow_validate / 执行类鉴权)。* diff --git a/frontend-admin/.env.example b/frontend-admin/.env.example new file mode 100644 index 0000000..6c9bbfd --- /dev/null +++ b/frontend-admin/.env.example @@ -0,0 +1 @@ +VITE_API_BASE_URL=http://127.0.0.1:8000 diff --git a/frontend-admin/index.html b/frontend-admin/index.html new file mode 100644 index 0000000..7dc6bf8 --- /dev/null +++ b/frontend-admin/index.html @@ -0,0 +1,12 @@ + + + + + + 质量检测平台 + + +
+ + + diff --git a/frontend-admin/package-lock.json b/frontend-admin/package-lock.json new file mode 100644 index 0000000..e7259f6 --- /dev/null +++ b/frontend-admin/package-lock.json @@ -0,0 +1,1974 @@ +{ + "name": "ai-auto-test-admin", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ai-auto-test-admin", + "version": "0.0.1", + "dependencies": { + "@codemirror/lang-json": "^6.0.2", + "@codemirror/lint": "^6.9.6", + "@element-plus/icons-vue": "^2.3.1", + "@vue-flow/background": "^1.3.2", + "@vue-flow/controls": "^1.1.3", + "@vue-flow/core": "^1.48.2", + "@vue-flow/node-toolbar": "^1.1.1", + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", + "codemirror": "^6.0.2", + "element-plus": "^2.11.5", + "vue": "^3.5.13", + "vue-router": "^4.5.1" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.4", + "vite": "^6.3.5" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@codemirror/autocomplete": { + "version": "6.20.2", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.2.tgz", + "integrity": "sha512-G5FPkgIiLjOgZMjqVjvuKQ1rGPtHogLldJr33eFJdVLtmwY+giGrlv/ewljLz6b9BSQLkjxuwBc6g6omDM+YxQ==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.10.3", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.3.tgz", + "integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.6.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/lang-json": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz", + "integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/json": "^1.0.0" + } + }, + "node_modules/@codemirror/language": { + "version": "6.12.3", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz", + "integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.9.6", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.6.tgz", + "integrity": "sha512-6Kp7r6XfCi/D/5sdXieMfg9pJU1bUEx96WITuLU6ESaKizCz0QHFMjY/TaFSbigDdEAIgi93itLBIUETP4oK+A==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.42.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/search": { + "version": "6.7.0", + "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.0.tgz", + "integrity": "sha512-ZvGm99wc/s2cITtMT15LFdn8aH/aS+V+DqyGq/N5ZlV5vWtH+nILvC2nw0zX7ByNoHHDZ2IxxdW38O0tc5nVHg==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.37.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz", + "integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.43.0", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.0.tgz", + "integrity": "sha512-V7ZCLQO3Jus9hzh2jVCCPW3mO4IBMr43O37PqSUYautJSnnJF41YlgLw21x0fLJTYvJ+Vkm6Gp+qKGH9pltgXA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.6.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, + "node_modules/@ctrl/tinycolor": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz", + "integrity": "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@element-plus/icons-vue": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz", + "integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==", + "license": "MIT", + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@lezer/common": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz", + "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==", + "license": "MIT" + }, + "node_modules/@lezer/highlight": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", + "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/json": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz", + "integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz", + "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", + "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", + "license": "MIT" + }, + "node_modules/@popperjs/core": { + "name": "@sxzz/popperjs-es", + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@sxzz/popperjs-es/-/popperjs-es-2.11.8.tgz", + "integrity": "sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.24", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.21", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", + "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue-flow/background": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@vue-flow/background/-/background-1.3.2.tgz", + "integrity": "sha512-eJPhDcLj1wEo45bBoqTXw1uhl0yK2RaQGnEINqvvBsAFKh/camHJd5NPmOdS1w+M9lggc9igUewxaEd3iCQX2w==", + "license": "MIT", + "peerDependencies": { + "@vue-flow/core": "^1.23.0", + "vue": "^3.3.0" + } + }, + "node_modules/@vue-flow/controls": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@vue-flow/controls/-/controls-1.1.3.tgz", + "integrity": "sha512-XCf+G+jCvaWURdFlZmOjifZGw3XMhN5hHlfMGkWh9xot+9nH9gdTZtn+ldIJKtarg3B21iyHU8JjKDhYcB6JMw==", + "license": "MIT", + "peerDependencies": { + "@vue-flow/core": "^1.23.0", + "vue": "^3.3.0" + } + }, + "node_modules/@vue-flow/core": { + "version": "1.48.2", + "resolved": "https://registry.npmjs.org/@vue-flow/core/-/core-1.48.2.tgz", + "integrity": "sha512-raxhgKWE+G/mcEvXJjGFUDYW9rAI3GOtiHR3ZkNpwBWuIaCC1EYiBmKGwJOoNzVFgwO7COgErnK7i08i287AFA==", + "license": "MIT", + "dependencies": { + "@vueuse/core": "^10.5.0", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + }, + "peerDependencies": { + "vue": "^3.3.0" + } + }, + "node_modules/@vue-flow/core/node_modules/@types/web-bluetooth": { + "version": "0.0.20", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz", + "integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==", + "license": "MIT" + }, + "node_modules/@vue-flow/core/node_modules/@vueuse/core": { + "version": "10.11.1", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-10.11.1.tgz", + "integrity": "sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.20", + "@vueuse/metadata": "10.11.1", + "@vueuse/shared": "10.11.1", + "vue-demi": ">=0.14.8" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vue-flow/core/node_modules/@vueuse/core/node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/@vue-flow/core/node_modules/@vueuse/metadata": { + "version": "10.11.1", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-10.11.1.tgz", + "integrity": "sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vue-flow/core/node_modules/@vueuse/shared": { + "version": "10.11.1", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-10.11.1.tgz", + "integrity": "sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==", + "license": "MIT", + "dependencies": { + "vue-demi": ">=0.14.8" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vue-flow/core/node_modules/@vueuse/shared/node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/@vue-flow/node-toolbar": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@vue-flow/node-toolbar/-/node-toolbar-1.1.1.tgz", + "integrity": "sha512-vgSnGMLd3FXstdpvC721qcBDzGkPsudmyA8urEP55/EMikCp59klgzPvVULTDxxsew5MdXtTBCumsqOi+PXJdg==", + "license": "MIT", + "peerDependencies": { + "@vue-flow/core": "^1.23.0", + "vue": "^3.3.0" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.34.tgz", + "integrity": "sha512-s9cLyK5mLcvZ4Agva5QgRsQyLKvts9WbU9DB6NqiZkkGEdwmcEiylj5Jbwkp680drF/NNCV8OlAJSe+yMLxaJw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@vue/shared": "3.5.34", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.34.tgz", + "integrity": "sha512-EbF/T++k0e2MMZlJsBhzK8Sgwt0HcIPOhzn1CTB/lv6sQcyk+OWf8YeiLxZp3ro7MbbLcAfAJ6sEvjFWuNgUCw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.34", + "@vue/shared": "3.5.34" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.34.tgz", + "integrity": "sha512-D/ihr6uZeIt6r+pVZf46RWT1fAsLFMbUP7k8G1VkiiWexriED9GrX3echHd4Abbt17zjlfiFJ8z7a3BxZOPNjg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@vue/compiler-core": "3.5.34", + "@vue/compiler-dom": "3.5.34", + "@vue/compiler-ssr": "3.5.34", + "@vue/shared": "3.5.34", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.14", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.34.tgz", + "integrity": "sha512-cDtTHKibkThKGHH1SP+WdccquNRYQDFH6rRjQCqT9G2ltFAfoR5pUftpab/z+aM5mW9HLLVQW7hfKKQe/1GBeQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.34", + "@vue/shared": "3.5.34" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/reactivity": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.34.tgz", + "integrity": "sha512-y9XDjCEuBp+98k+UL5dbYkh57AHU4o6cxZedOPXw3bmrZZYLQsVHguGurq7hVrPCSrQtrnz1f9dssyFr+dMXfQ==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.34" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.34.tgz", + "integrity": "sha512-mKeBYvu8tcMSLhypAHBmriUFfWXKTCF/23Z4jiCoYK3UtWepkliViNLuR90V9XOyD62mUxs9p1jsrpK3CCGIzw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.34", + "@vue/shared": "3.5.34" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.34.tgz", + "integrity": "sha512-e8kZzERmCwUnBRVsgSQlAfrfU2rGoy0FFKPBXSlfEjc/O3KfA7QP0t1/2ZylrbchjmIKB4dPTd07A6WPr0eOrg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.34", + "@vue/runtime-core": "3.5.34", + "@vue/shared": "3.5.34", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.34.tgz", + "integrity": "sha512-nHxmJoTrKsmrkbILRhkC9gY1G3moZbJTqCzDd7DOOzG5KH9oeJ0Unqrff5f9v0pW//jES05ZkJcNtfE8JjOIew==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.34", + "@vue/shared": "3.5.34" + }, + "peerDependencies": { + "vue": "3.5.34" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.34.tgz", + "integrity": "sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==", + "license": "MIT" + }, + "node_modules/@vueuse/core": { + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.3.0.tgz", + "integrity": "sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "14.3.0", + "@vueuse/shared": "14.3.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/@vueuse/metadata": { + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.3.0.tgz", + "integrity": "sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.3.0.tgz", + "integrity": "sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/@xterm/addon-fit": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", + "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", + "license": "MIT" + }, + "node_modules/@xterm/xterm": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", + "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==", + "license": "MIT", + "workspaces": [ + "addons/*" + ] + }, + "node_modules/async-validator": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz", + "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==", + "license": "MIT" + }, + "node_modules/codemirror": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz", + "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/commands": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + } + }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dayjs": { + "version": "1.11.20", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", + "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", + "license": "MIT" + }, + "node_modules/element-plus": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.14.0.tgz", + "integrity": "sha512-POgH+TtoreaEKWqYYAVQyE6i8rQMEFqAEublyF29dBA5yASWPLKY6EzfeqBTr2Uv26mPss4vSrMrNPyaK7LX5w==", + "license": "MIT", + "dependencies": { + "@ctrl/tinycolor": "^4.2.0", + "@element-plus/icons-vue": "^2.3.2", + "@floating-ui/dom": "^1.0.1", + "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.8", + "@types/lodash": "^4.17.24", + "@types/lodash-es": "^4.17.12", + "@vueuse/core": "14.3.0", + "async-validator": "^4.2.5", + "dayjs": "^1.11.20", + "lodash": "^4.18.1", + "lodash-es": "^4.18.1", + "lodash-unified": "^1.0.3", + "memoize-one": "^6.0.0", + "normalize-wheel-es": "^1.2.0", + "vue-component-type-helpers": "^3.2.8" + }, + "peerDependencies": { + "vue": "^3.3.7" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, + "node_modules/lodash-unified": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/lodash-unified/-/lodash-unified-1.0.3.tgz", + "integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==", + "license": "MIT", + "peerDependencies": { + "@types/lodash-es": "*", + "lodash": "*", + "lodash-es": "*" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/normalize-wheel-es": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz", + "integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==", + "license": "BSD-3-Clause" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/vite": { + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.34.tgz", + "integrity": "sha512-WdLBG9gm02OgJIG9axd5Hpx0TFLdzVgfG2evFFu8Rur5O/IoGc5cMjnjh3tPL6GnRGsYvUhBSKVPYVcxRKpMCA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.34", + "@vue/compiler-sfc": "3.5.34", + "@vue/runtime-dom": "3.5.34", + "@vue/server-renderer": "3.5.34", + "@vue/shared": "3.5.34" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-component-type-helpers": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-3.3.1.tgz", + "integrity": "sha512-pu58kqxmVyEH6VfNYW1UyEfR3XAnJ27ZXT3yzXxxpjLxVzAbyC35Zk/nm/RMs7ijWnJNSd9fWkeex2OhUsx3MA==", + "license": "MIT" + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + } + } +} diff --git a/frontend-admin/package.json b/frontend-admin/package.json new file mode 100644 index 0000000..84ed0d5 --- /dev/null +++ b/frontend-admin/package.json @@ -0,0 +1,30 @@ +{ + "name": "ai-auto-test-admin", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@codemirror/lang-json": "^6.0.2", + "@codemirror/lint": "^6.9.6", + "@element-plus/icons-vue": "^2.3.1", + "@vue-flow/background": "^1.3.2", + "@vue-flow/controls": "^1.1.3", + "@vue-flow/core": "^1.48.2", + "@vue-flow/node-toolbar": "^1.1.1", + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", + "codemirror": "^6.0.2", + "element-plus": "^2.11.5", + "vue": "^3.5.13", + "vue-router": "^4.5.1" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.4", + "vite": "^6.3.5" + } +} diff --git a/frontend-admin/src/App.vue b/frontend-admin/src/App.vue new file mode 100644 index 0000000..98240ae --- /dev/null +++ b/frontend-admin/src/App.vue @@ -0,0 +1,3 @@ + diff --git a/frontend-admin/src/api/http.js b/frontend-admin/src/api/http.js new file mode 100644 index 0000000..7a51e3c --- /dev/null +++ b/frontend-admin/src/api/http.js @@ -0,0 +1,154 @@ +import { clearSession, getAccessToken } from "../auth/session"; + +const explicitBaseUrl = import.meta.env.VITE_API_BASE_URL?.trim(); + +export function buildUrl(path) { + if (/^https?:\/\//.test(path)) { + return path; + } + const normalizedPath = path.startsWith("/") ? path : `/${path}`; + if (explicitBaseUrl) { + return `${explicitBaseUrl}${normalizedPath}`; + } + return normalizedPath; +} + +export function buildWebSocketUrl(path, params = {}) { + const httpUrl = buildUrl(path); + const url = new URL(httpUrl, window.location.origin); + url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== null && value !== "") { + url.searchParams.set(key, String(value)); + } + }); + return url.toString(); +} + +export async function apiGet(path, init = {}) { + return request(path, { + method: "GET", + headers: { + Accept: "application/json", + ...(init.headers || {}), + }, + ...init, + }); +} + +export async function apiPost(path, body, init = {}) { + return request(path, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + ...(init.headers || {}), + }, + body: JSON.stringify(body), + ...init, + }); +} + +export async function apiPut(path, body, init = {}) { + return request(path, { + method: "PUT", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + ...(init.headers || {}), + }, + body: JSON.stringify(body), + ...init, + }); +} + +export async function apiDelete(path, init = {}) { + return request(path, { + method: "DELETE", + headers: { + Accept: "application/json", + ...(init.headers || {}), + }, + ...init, + }); +} + +export async function apiUploadForm(path, formData, init = {}) { + const token = getAccessToken(); + const headers = { ...(init.headers || {}) }; + if (token) { + headers.Authorization = `Bearer ${token}`; + } + + const response = await fetch(buildUrl(path), { + method: "POST", + headers, + body: formData, + ...init, + }); + + if (!response.ok) { + let errorMessage = `Request failed: ${response.status}`; + try { + const rawText = await response.text(); + if (rawText) { + try { + const payload = JSON.parse(rawText); + errorMessage = payload.detail || JSON.stringify(payload); + } catch { + errorMessage = rawText; + } + } + } catch { + errorMessage = `Request failed: ${response.status}`; + } + if (response.status === 401) { + clearSession(); + if (window.location.pathname !== "/login") { + window.location.href = "/login"; + } + } + throw new Error(errorMessage); + } + + return response.json(); +} + +async function request(path, init = {}) { + const token = getAccessToken(); + const headers = { ...(init.headers || {}) }; + if (token) { + headers.Authorization = `Bearer ${token}`; + } + + const response = await fetch(buildUrl(path), { + ...init, + headers, + }); + + if (!response.ok) { + let errorMessage = `Request failed: ${response.status}`; + try { + const rawText = await response.text(); + if (rawText) { + try { + const payload = JSON.parse(rawText); + errorMessage = payload.detail || JSON.stringify(payload); + } catch { + errorMessage = rawText; + } + } + } catch { + errorMessage = `Request failed: ${response.status}`; + } + if (response.status === 401) { + clearSession(); + if (window.location.pathname !== "/login") { + window.location.href = "/login"; + } + } + throw new Error(errorMessage); + } + + return response.json(); +} diff --git a/frontend-admin/src/auth/session.js b/frontend-admin/src/auth/session.js new file mode 100644 index 0000000..933a0da --- /dev/null +++ b/frontend-admin/src/auth/session.js @@ -0,0 +1,42 @@ +import { reactive } from "vue"; + +const TOKEN_KEY = "ai-auto-test-token"; +const USER_KEY = "ai-auto-test-user"; + +function loadUser() { + const raw = window.localStorage.getItem(USER_KEY); + if (!raw) return null; + try { + return JSON.parse(raw); + } catch { + return null; + } +} + +export const authState = reactive({ + token: window.localStorage.getItem(TOKEN_KEY) || "", + user: loadUser(), +}); + +export function getAccessToken() { + return authState.token || ""; +} + +export function setSession(token, user) { + authState.token = token || ""; + authState.user = user || null; + if (token) { + window.localStorage.setItem(TOKEN_KEY, token); + } else { + window.localStorage.removeItem(TOKEN_KEY); + } + if (user) { + window.localStorage.setItem(USER_KEY, JSON.stringify(user)); + } else { + window.localStorage.removeItem(USER_KEY); + } +} + +export function clearSession() { + setSession("", null); +} diff --git a/frontend-admin/src/components/FolderCreateDialog.vue b/frontend-admin/src/components/FolderCreateDialog.vue new file mode 100644 index 0000000..c84c688 --- /dev/null +++ b/frontend-admin/src/components/FolderCreateDialog.vue @@ -0,0 +1,200 @@ + + + diff --git a/frontend-admin/src/components/JsonCodeEditor.vue b/frontend-admin/src/components/JsonCodeEditor.vue new file mode 100644 index 0000000..af1fcdf --- /dev/null +++ b/frontend-admin/src/components/JsonCodeEditor.vue @@ -0,0 +1,258 @@ + + + + + diff --git a/frontend-admin/src/components/JsonField.vue b/frontend-admin/src/components/JsonField.vue new file mode 100644 index 0000000..67ea12f --- /dev/null +++ b/frontend-admin/src/components/JsonField.vue @@ -0,0 +1,49 @@ + + + diff --git a/frontend-admin/src/components/KeyValueEditor.vue b/frontend-admin/src/components/KeyValueEditor.vue new file mode 100644 index 0000000..fe30df2 --- /dev/null +++ b/frontend-admin/src/components/KeyValueEditor.vue @@ -0,0 +1,74 @@ + + + diff --git a/frontend-admin/src/components/ProfileApiKeyPanel.vue b/frontend-admin/src/components/ProfileApiKeyPanel.vue new file mode 100644 index 0000000..0ff2c67 --- /dev/null +++ b/frontend-admin/src/components/ProfileApiKeyPanel.vue @@ -0,0 +1,179 @@ + + + diff --git a/frontend-admin/src/components/RequestParamsTabs.vue b/frontend-admin/src/components/RequestParamsTabs.vue new file mode 100644 index 0000000..c2145a2 --- /dev/null +++ b/frontend-admin/src/components/RequestParamsTabs.vue @@ -0,0 +1,127 @@ + + + + + diff --git a/frontend-admin/src/components/SshFloatingTerminalLayer.vue b/frontend-admin/src/components/SshFloatingTerminalLayer.vue new file mode 100644 index 0000000..ad132b6 --- /dev/null +++ b/frontend-admin/src/components/SshFloatingTerminalLayer.vue @@ -0,0 +1,14 @@ + + + diff --git a/frontend-admin/src/components/SshFloatingTerminalWindow.vue b/frontend-admin/src/components/SshFloatingTerminalWindow.vue new file mode 100644 index 0000000..05a1dfe --- /dev/null +++ b/frontend-admin/src/components/SshFloatingTerminalWindow.vue @@ -0,0 +1,319 @@ + + + diff --git a/frontend-admin/src/components/WorkflowCanvasNode.vue b/frontend-admin/src/components/WorkflowCanvasNode.vue new file mode 100644 index 0000000..2821d2f --- /dev/null +++ b/frontend-admin/src/components/WorkflowCanvasNode.vue @@ -0,0 +1,523 @@ + + + + + diff --git a/frontend-admin/src/components/WorkflowPreview.vue b/frontend-admin/src/components/WorkflowPreview.vue new file mode 100644 index 0000000..d5f5a25 --- /dev/null +++ b/frontend-admin/src/components/WorkflowPreview.vue @@ -0,0 +1,1345 @@ + + + + + diff --git a/frontend-admin/src/layout/AdminLayout.vue b/frontend-admin/src/layout/AdminLayout.vue new file mode 100644 index 0000000..ea2b188 --- /dev/null +++ b/frontend-admin/src/layout/AdminLayout.vue @@ -0,0 +1,201 @@ + + + diff --git a/frontend-admin/src/main.js b/frontend-admin/src/main.js new file mode 100644 index 0000000..13bb29c --- /dev/null +++ b/frontend-admin/src/main.js @@ -0,0 +1,8 @@ +import { createApp } from "vue"; +import ElementPlus from "element-plus"; +import "element-plus/dist/index.css"; +import App from "./App.vue"; +import router from "./router"; +import "./styles/index.css"; + +createApp(App).use(router).use(ElementPlus).mount("#app"); diff --git a/frontend-admin/src/router/index.js b/frontend-admin/src/router/index.js new file mode 100644 index 0000000..b642601 --- /dev/null +++ b/frontend-admin/src/router/index.js @@ -0,0 +1,114 @@ +import { createRouter, createWebHistory } from "vue-router"; +import { authState, getAccessToken } from "../auth/session"; +import AdminLayout from "../layout/AdminLayout.vue"; +import ApiEditorView from "../views/ApiEditorView.vue"; +import ApiManagementView from "../views/ApiManagementView.vue"; +import DashboardView from "../views/DashboardView.vue"; +import LoginView from "../views/LoginView.vue"; +import McpManagementView from "../views/McpManagementView.vue"; +import SshManagementView from "../views/SshManagementView.vue"; +import UserManagementView from "../views/UserManagementView.vue"; +import WorkflowBatchView from "../views/WorkflowBatchView.vue"; + +const routes = [ + { + path: "/login", + name: "login", + component: LoginView, + meta: { + public: true, + title: "登录", + }, + }, + { + path: "/", + component: AdminLayout, + children: [ + { + path: "", + name: "dashboard", + component: DashboardView, + meta: { + title: "工作台", + }, + }, + { + path: "apis", + name: "apis", + component: ApiManagementView, + meta: { + title: "接口管理", + }, + }, + { + path: "ssh", + name: "ssh", + component: SshManagementView, + meta: { + title: "SSH 管理", + }, + }, + { + path: "workflow-batches", + name: "workflow-batches", + component: WorkflowBatchView, + meta: { + title: "跑批工作流执行", + }, + }, + { + path: "workflow-runtime", + redirect: "/workflow-batches", + }, + { + path: "mcp-config", + name: "mcp", + component: McpManagementView, + meta: { + title: "MCP 配置", + }, + }, + { + path: "apis/:apiId/edit", + name: "api-edit", + component: ApiEditorView, + meta: { + title: "接口编辑", + }, + }, + { + path: "users", + name: "users", + component: UserManagementView, + meta: { + title: "用户管理", + requiresSuperadmin: true, + }, + }, + ], + }, +]; + +const router = createRouter({ + history: createWebHistory(), + routes, +}); + +router.beforeEach((to) => { + const token = getAccessToken(); + if (to.meta.public) { + if (token && to.path === "/login") { + return "/"; + } + return true; + } + if (!token) { + return "/login"; + } + if (to.meta.requiresSuperadmin && authState.user?.role !== "superadmin") { + return "/"; + } + return true; +}); + +export default router; diff --git a/frontend-admin/src/styles/index.css b/frontend-admin/src/styles/index.css new file mode 100644 index 0000000..808da4c --- /dev/null +++ b/frontend-admin/src/styles/index.css @@ -0,0 +1,1810 @@ +:root { + color-scheme: light; + font-family: "PingFang SC", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif; + background: #f2f5f9; + color: #1f2d3d; +} + +* { + box-sizing: border-box; +} + +html, +body, +#app { + margin: 0; + min-height: 100%; +} + +body { + background: + radial-gradient(circle at top left, rgba(64, 158, 255, 0.18), transparent 24%), + linear-gradient(180deg, #f6f8fb 0%, #eef3f8 100%); +} + +.admin-shell { + display: flex; + min-height: 100vh; +} + +.admin-sidebar { + width: 240px; + border-right: 1px solid #e4e7ed; + background: linear-gradient(180deg, #ffffff 0%, #f7f9fc 100%); + transition: width 0.2s ease; + overflow: hidden; +} + +.admin-sidebar.collapsed { + width: 64px; +} + +.admin-brand { + display: flex; + align-items: center; + gap: 12px; + padding: 18px 16px; + border-bottom: 1px solid #edf1f6; +} + +.admin-brand__logo { + width: 36px; + height: 36px; + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, #409eff 0%, #36cfc9 100%); + color: #fff; + font-weight: 700; +} + +.admin-brand__meta { + display: flex; + flex-direction: column; +} + +.admin-brand__meta strong { + font-size: 14px; + color: #1f2d3d; +} + +.admin-sidebar__scroll { + height: calc(100vh - 73px); +} + +.admin-menu { + border-right: 0; + background: transparent; +} + +.admin-main { + flex: 1; + min-width: 0; + padding: 10px 20px 20px; +} + +.admin-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + min-height: 72px; + padding: 10px 18px; + border: 1px solid #e7ecf2; + border-radius: 18px; + background: rgba(255, 255, 255, 0.92); + box-shadow: 0 14px 40px rgba(31, 45, 61, 0.08); +} + +.admin-header__left, +.admin-header__right { + display: flex; + align-items: center; + gap: 12px; +} + +.admin-user-trigger { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 12px; + border-radius: 14px; + background: #f7f9fc; + border: 1px solid transparent; + color: inherit; + cursor: pointer; + transition: + border-color 0.2s ease, + box-shadow 0.2s ease, + transform 0.2s ease; +} + +.admin-user-trigger:hover { + border-color: #dbe8f7; + box-shadow: 0 10px 24px rgba(44, 62, 80, 0.08); +} + +.admin-user-trigger:focus-visible { + outline: none; + border-color: #8ebcf7; + box-shadow: 0 0 0 3px rgba(64, 158, 255, 0.16); +} + +.admin-user-trigger__meta { + text-align: left; +} + +.admin-user-trigger__meta strong, +.admin-user-trigger__meta p { + margin: 0; +} + +.admin-user-trigger__meta p { + font-size: 12px; + color: #8492a6; +} + +.admin-user-trigger__avatar { + color: #fff; + font-weight: 700; + background: linear-gradient(135deg, #7ca8ff 0%, #9fc3ff 100%); +} + +.admin-user-trigger__arrow { + color: #a0aec0; + transition: transform 0.2s ease; +} + +.admin-user-trigger[aria-expanded="true"] .admin-user-trigger__arrow { + transform: rotate(180deg); +} + +.admin-header h1 { + margin: 0; + font-size: 20px; + color: #1f2d3d; +} + +.admin-content { + min-width: 0; + margin-top: 10px; +} + +.user-dropdown-popper.el-popper { + border: 0; + border-radius: 18px; + padding: 0; + box-shadow: 0 18px 42px rgba(15, 23, 42, 0.18); + overflow: visible; +} + +.user-dropdown-popper.el-popper .el-popper__arrow::before { + border-color: #fff; + background: #fff; +} + +.user-dropdown-card { + min-width: 220px; + padding: 12px; + background: #fff; +} + +.user-dropdown-card__summary { + display: flex; + gap: 12px; + align-items: flex-start; + padding: 4px 4px 12px; +} + +.user-dropdown-card__avatar { + width: 40px; + height: 40px; + flex: 0 0 40px; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + color: #fff; + background: linear-gradient(135deg, #7ca8ff 0%, #d9e6ff 100%); +} + +.user-dropdown-card__summary strong, +.user-dropdown-card__summary p, +.user-dropdown-card__summary span { + display: block; +} + +.user-dropdown-card__summary strong { + font-size: 16px; + color: #1f2d3d; +} + +.user-dropdown-card__summary p { + margin: 4px 0 0; + font-size: 13px; + color: #5b6b82; +} + +.user-dropdown-card__summary span { + margin-top: 4px; + font-size: 12px; + color: #9aa7b8; +} + +.user-dropdown-card .el-dropdown-menu { + padding: 8px 0 0; +} + +.user-dropdown-card .el-dropdown-menu__item { + gap: 10px; + height: 40px; + border-radius: 10px; + color: #334155; +} + +.user-dropdown-card .el-dropdown-menu__item:not(.is-disabled):hover { + background: #f5f8fc; + color: #1f2d3d; +} + +.user-dropdown-card .el-dropdown-menu__item--divided { + margin-top: 8px; +} + +.page-stack { + display: flex; + flex-direction: column; + gap: 18px; +} + +.dashboard-page { + display: flex; + flex-direction: column; + gap: 18px; +} + +.dashboard-grid { + margin-top: 0; +} + +.dashboard-summary { + display: flex; + flex-direction: column; + gap: 16px; +} + +.dashboard-summary__item { + display: flex; + align-items: flex-start; + gap: 14px; + padding: 18px; + border: 1px solid #e8edf3; + border-radius: 16px; + background: #f8fbff; +} + +.dashboard-summary__item .el-icon { + width: 40px; + height: 40px; + border-radius: 12px; + display: inline-flex; + align-items: center; + justify-content: center; + background: rgba(64, 158, 255, 0.12); + color: #409eff; +} + +.dashboard-summary__item strong { + display: block; + margin-bottom: 6px; + color: #1f2d3d; +} + +.dashboard-summary__item p { + margin: 0; + font-size: 13px; + line-height: 1.7; + color: #6b7785; +} + +.overview-card, +.panel-card { + border-radius: 20px; + border: 1px solid #e8edf3; +} + +.overview-card { + margin-bottom: 18px; +} + +.overview-card .el-card__body { + display: flex; + gap: 14px; + align-items: flex-start; +} + +.overview-card__icon { + width: 44px; + height: 44px; + border-radius: 14px; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, rgba(64, 158, 255, 0.14), rgba(54, 207, 201, 0.18)); + color: #409eff; +} + +.overview-card__body { + display: flex; + flex-direction: column; + gap: 6px; +} + +.overview-card__body span { + font-size: 13px; + color: #909399; +} + +.overview-card__body strong { + font-size: 18px; + color: #1f2d3d; +} + +.dashboard-alert { + margin-top: 16px; +} + +.overview-card__body p, +.panel-card__header p, +.hero-block p { + margin: 0; + line-height: 1.7; + color: #8492a6; + font-size: 13px; +} + +.panel-card__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.panel-card__header strong { + display: block; + font-size: 16px; + color: #1f2d3d; +} + +.toolbar-actions, +.row-actions, +.drawer-footer { + display: flex; + align-items: center; + gap: 10px; +} + +.toolbar-actions { + flex-wrap: wrap; +} + +.ssh-page { + display: grid; + grid-template-columns: minmax(330px, 390px) minmax(0, 1fr); + gap: 16px; + height: calc(100vh - 132px); + min-height: 620px; +} + +.ssh-page__sidebar, +.ssh-page__main { + display: flex; + flex-direction: column; + min-height: 0; + height: 100%; + overflow: hidden; + background: #fff; + box-shadow: 0 14px 34px rgba(31, 45, 61, 0.06); +} + +.ssh-page__sidebar-header { + flex-shrink: 0; + align-items: flex-start; + padding: 18px 18px 14px; + border-bottom: 1px solid #edf1f7; +} + +.ssh-page__search { + width: auto; + margin: 14px 18px 12px; + flex-shrink: 0; +} + +.ssh-page__tree-scroll { + flex: 1; + min-height: 0; + margin: 0 0 8px; +} + +.ssh-page .el-tree { + --el-tree-node-hover-bg-color: #f5f8fc; + background: transparent; + color: #1f2d3d; +} + +.ssh-page .el-tree-node__content { + height: auto; + min-height: 48px; + margin: 2px 8px; + padding: 7px 8px; + border-radius: 10px; +} + +.ssh-page .el-tree-node.is-current > .el-tree-node__content { + background: #ecf5ff; +} + +.ssh-tree-node { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + width: 100%; + padding-right: 8px; +} + +.ssh-tree-node__label { + display: inline-flex; + align-items: center; + gap: 9px; + min-width: 0; + flex: 1; +} + +.ssh-tree-node__icon { + width: 28px; + height: 28px; + flex: 0 0 28px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 8px; + color: #409eff; + background: #eef6ff; +} + +.ssh-tree-node__text { + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.ssh-tree-node__title, +.ssh-tree-node__meta { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ssh-tree-node__title { + color: #1f2d3d; + font-weight: 600; + line-height: 1.35; +} + +.ssh-tree-node__meta { + color: #8492a6; + font-size: 12px; + line-height: 1.35; +} + +.ssh-tree-node__actions { + display: inline-flex; + align-items: center; + gap: 2px; + flex-shrink: 0; + opacity: 0.78; +} + +.ssh-page .el-tree-node__content:hover .ssh-tree-node__actions, +.ssh-page .el-tree-node.is-current > .el-tree-node__content .ssh-tree-node__actions { + opacity: 1; +} + +.ssh-tree-node__actions .el-button { + height: 28px; + padding: 4px 6px; + margin-left: 0; +} + +.ssh-page__main { + gap: 0; +} + +.ssh-run-panel { + display: flex; + flex-direction: column; + min-height: 0; + height: 100%; + gap: 10px; +} + +.ssh-run-panel__toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-shrink: 0; + padding: 16px 18px; + border-bottom: 1px solid #edf1f7; + background: #fbfcff; +} + +.ssh-run-panel__title strong { + display: block; + color: #1f2d3d; + font-size: 16px; +} + +.ssh-run-panel__title p { + margin: 4px 0 0; + color: #8492a6; + font-size: 13px; + line-height: 1.5; +} + +.ssh-run-tabs { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} + +.ssh-run-tabs .el-tabs__content { + flex: 1; + min-height: 0; + padding: 14px 16px 16px; + background: #f8fafd; +} + +.ssh-run-tabs .el-tabs__header { + margin: 0; + padding: 10px 16px 0; + background: #fff; +} + +.ssh-run-tabs .el-tabs__nav { + border-radius: 8px 8px 0 0; +} + +.ssh-run-tabs .el-tab-pane { + height: 100%; +} + +.ssh-run-tab__label { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.ssh-run-empty { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + min-height: 0; + margin: 16px; + border: 1px dashed #d8e1ec; + border-radius: 14px; + background: #f8fafd; +} + +.dashboard-folder-card { + margin-top: 18px; +} + +.dashboard-folder-list { + display: flex; + flex-direction: column; + gap: 8px; + max-height: 280px; + overflow: auto; +} + +.dashboard-folder-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 12px; + border: 1px solid #e2e8f0; + border-radius: 10px; + background: #f8fafc; +} + +.dashboard-folder-item code { + flex: 1; + min-width: 0; + color: #334155; + font-size: 13px; + word-break: break-all; +} + +.dashboard-folder-item__actions { + display: inline-flex; + align-items: center; + gap: 2px; + flex-shrink: 0; +} + +.folder-form__hint { + margin: 0; + color: #6b7785; + font-size: 12px; + line-height: 1.6; +} + +.dashboard-pinned-section { + margin-top: 18px; +} + +.dashboard-pinned-section__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 14px; +} + +.dashboard-pinned-section__header strong { + display: block; + color: #1f2d3d; + font-size: 16px; +} + +.dashboard-pinned-section__header p { + margin: 4px 0 0; + color: #8492a6; + font-size: 13px; + line-height: 1.6; +} + +.dashboard-pinned-count { + --el-tag-bg-color: #f5f8fc; + --el-tag-border-color: #dfe8f3; + --el-tag-text-color: #3a4a5f; + color: #3a4a5f; + background: #f5f8fc; + border-color: #dfe8f3; +} + +.dashboard-pinned-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(min(100%, 300px), 320px)); + gap: 16px; + justify-content: start; +} + +.dashboard-pinned-item { + display: flex; + flex-direction: column; + gap: 14px; + min-height: 210px; + padding: 16px; + border: 1px solid #e2e8f0; + border-radius: 16px; + background: linear-gradient(180deg, #fbfdff 0%, #fff 44%); + box-shadow: 0 12px 30px rgba(31, 45, 61, 0.06); + transition: + border-color 0.2s ease, + box-shadow 0.2s ease, + transform 0.2s ease; +} + +.dashboard-pinned-item:hover { + border-color: #bfd7ff; + box-shadow: 0 16px 34px rgba(64, 158, 255, 0.12); + transform: translateY(-1px); +} + +.dashboard-pinned-item__head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.dashboard-pinned-item__title { + display: flex; + align-items: flex-start; + gap: 12px; + min-width: 0; +} + +.dashboard-pinned-item__icon { + width: 36px; + height: 36px; + flex: 0 0 36px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 12px; + color: #409eff; + background: #eef6ff; +} + +.dashboard-pinned-item__head strong { + display: block; + overflow: hidden; + color: #1f2d3d; + font-size: 15px; + line-height: 1.45; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dashboard-pinned-item__title > div { + min-width: 0; +} + +.dashboard-pinned-item__head p { + margin: 4px 0 0; + overflow: hidden; + font-size: 12px; + color: #6b7785; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dashboard-pinned-item__host { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 12px; + border-radius: 12px; + background: #f5f8fc; +} + +.dashboard-pinned-item__host span { + flex-shrink: 0; + font-size: 12px; + color: #8492a6; +} + +.dashboard-pinned-item__host code { + min-width: 0; + overflow: hidden; + color: #3a4a5f; + font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dashboard-pinned-item__star { + color: #f59e0b; + font-size: 18px; +} + +.dashboard-pinned-item__desc { + margin: 0; + flex: 1; + font-size: 13px; + line-height: 1.6; + color: #6b7785; + display: -webkit-box; + overflow: hidden; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +.dashboard-pinned-item__actions { + display: flex; + gap: 10px; + margin-top: auto; +} + +.dashboard-pinned-item__actions .el-button { + flex: 1; + margin-left: 0; +} + +.dashboard-pinned-empty { + min-height: 190px; + display: flex; + align-items: center; + justify-content: center; + border: 1px dashed #d8e1ec; + border-radius: 14px; + background: #f8fafd; +} + +.dashboard-pinned-empty-card { + width: min(100%, 360px); + background: #fff; + box-shadow: 0 12px 30px rgba(31, 45, 61, 0.06); +} + +.dashboard-pinned-empty .el-empty { + padding: 16px 0; +} + +.dashboard-pinned-empty .el-empty__image { + width: 104px; +} + +.dashboard-pinned-empty strong { + display: block; + margin-bottom: 6px; + color: #1f2d3d; +} + +.dashboard-pinned-empty p { + margin: 0; + color: #8492a6; + font-size: 13px; + line-height: 1.6; +} + +.profile-drawer { + display: flex; + flex-direction: column; + gap: 18px; +} + +.profile-drawer__summary { + display: flex; + align-items: center; + gap: 16px; + padding: 18px; +} + +.profile-drawer__avatar { + display: flex; + align-items: center; + justify-content: center; + width: 52px; + height: 52px; + border-radius: 16px; + background: linear-gradient(135deg, #dbeafe, #eff6ff); + color: #2563eb; + font-size: 20px; + font-weight: 700; +} + +.profile-drawer__summary strong { + display: block; + color: #1f2d3d; +} + +.profile-drawer__summary p { + margin: 4px 0 0; + font-size: 13px; + color: #6b7785; +} + +.profile-drawer__section { + display: flex; + flex-direction: column; + gap: 14px; +} + +.profile-api-key-panel { + display: flex; + flex-direction: column; + gap: 16px; +} + +.profile-api-key-panel__create, +.profile-api-key-panel__latest { + padding: 16px; + border: 1px solid #e2e8f0; + border-radius: 16px; + background: #f8fbff; +} + +.profile-api-key-panel__create-head, +.profile-api-key-panel__latest-head, +.profile-api-key-panel__create-form { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.profile-api-key-panel__create-head strong, +.profile-api-key-panel__latest-head strong { + display: block; + color: #1f2d3d; +} + +.profile-api-key-panel__create-head p { + margin: 4px 0 0; + font-size: 12px; + color: #6b7785; +} + +.profile-api-key-panel__create-form { + margin-top: 12px; + justify-content: flex-start; +} + +.profile-api-key-panel__expires { + min-width: 280px; +} + +.profile-api-key-panel__key-text, +.profile-api-key-panel__masked { + display: block; + margin-top: 10px; + padding: 12px 14px; + border-radius: 12px; + background: #0f172a; + color: #e2e8f0; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; + font-size: 13px; + line-height: 1.6; + word-break: break-all; +} + +.ssh-float-window { + position: fixed; + display: flex; + flex-direction: column; + min-width: 360px; + min-height: 200px; + border: 1px solid #cbd5e1; + border-radius: 14px; + background: #fff; + box-shadow: 0 24px 48px rgba(15, 23, 42, 0.22); + overflow: hidden; +} + +.ssh-float-window--minimized { + height: auto !important; + min-height: 0; + width: min(420px, calc(100vw - 32px)) !important; +} + +.ssh-float-window--dragging, +.ssh-float-window--resizing { + user-select: none; +} + +.ssh-float-window--dragging .ssh-float-window__header { + cursor: grabbing; +} + +.ssh-float-window__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 12px; + border-bottom: 1px solid #e2e8f0; + background: linear-gradient(180deg, #f8fbff, #f1f5f9); + cursor: grab; +} + +.ssh-float-window__title { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} + +.ssh-float-window__title strong { + display: block; + color: #1f2d3d; + font-size: 14px; +} + +.ssh-float-window__title p { + margin: 2px 0 0; + color: #6b7785; + font-size: 12px; +} + +.ssh-float-window__drag-icon { + color: #94a3b8; + flex-shrink: 0; +} + +.ssh-float-window__actions { + display: inline-flex; + align-items: center; + gap: 2px; + flex-shrink: 0; +} + +.ssh-float-window__body { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} + +.ssh-float-window__terminal { + flex: 1; + min-height: 0; + border: 0; + border-radius: 0; + box-shadow: none; +} + +.ssh-float-window__terminal-canvas { + flex: 1; + min-height: 0; + height: 100%; +} + +.ssh-float-window__resize { + position: absolute; + z-index: 2; +} + +.ssh-float-window__resize--n, +.ssh-float-window__resize--s { + left: 10px; + right: 10px; + height: 6px; + cursor: ns-resize; +} + +.ssh-float-window__resize--n { + top: 0; +} + +.ssh-float-window__resize--s { + bottom: 0; +} + +.ssh-float-window__resize--e, +.ssh-float-window__resize--w { + top: 10px; + bottom: 10px; + width: 6px; + cursor: ew-resize; +} + +.ssh-float-window__resize--e { + right: 0; +} + +.ssh-float-window__resize--w { + left: 0; +} + +.ssh-float-window__resize--ne, +.ssh-float-window__resize--nw, +.ssh-float-window__resize--se, +.ssh-float-window__resize--sw { + width: 14px; + height: 14px; +} + +.ssh-float-window__resize--ne { + top: 0; + right: 0; + cursor: nesw-resize; +} + +.ssh-float-window__resize--nw { + top: 0; + left: 0; + cursor: nwse-resize; +} + +.ssh-float-window__resize--se { + right: 0; + bottom: 0; + cursor: nwse-resize; +} + +.ssh-float-window__resize--se::after { + content: ""; + position: absolute; + right: 4px; + bottom: 4px; + width: 8px; + height: 8px; + border-right: 2px solid #94a3b8; + border-bottom: 2px solid #94a3b8; + border-radius: 0 0 2px 0; + pointer-events: none; +} + +.ssh-float-window__resize--sw { + left: 0; + bottom: 0; + cursor: nesw-resize; +} + +.ssh-page__terminal { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + box-shadow: 0 12px 28px rgba(15, 23, 42, 0.12); +} + +.ssh-page__terminal-canvas { + flex: 1; + min-height: 0; + height: 100%; + max-height: none; +} + +.ssh-toolbar, +.ssh-current-profile, +.ssh-form-grid, +.preset-section__header, +.ssh-drawer-grid, +.preset-editor__item-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.ssh-toolbar { + align-items: center; + margin-bottom: 16px; +} + +.ssh-toolbar .el-input { + max-width: 320px; +} + +.ssh-card-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 14px; +} + +.ssh-card { + padding: 16px; + border: 1px solid #e2e8f0; + border-radius: 16px; + background: #fff; + text-align: left; + cursor: pointer; + transition: + border-color 0.2s ease, + box-shadow 0.2s ease, + transform 0.2s ease; +} + +.ssh-card:hover { + border-color: #bfd7ff; + box-shadow: 0 14px 28px rgba(64, 158, 255, 0.12); + transform: translateY(-1px); +} + +.ssh-card--active { + border-color: #409eff; + box-shadow: 0 0 0 1px rgba(64, 158, 255, 0.16), 0 14px 28px rgba(64, 158, 255, 0.14); + background: #f8fbff; +} + +.ssh-card__head, +.ssh-card__meta, +.ssh-card__actions { + display: flex; + justify-content: space-between; + gap: 12px; +} + +.ssh-card__head { + align-items: flex-start; +} + +.ssh-card__head strong { + display: block; + margin-bottom: 4px; + color: #1f2d3d; +} + +.ssh-card__head p { + margin: 0; + font-size: 13px; + color: #6b7785; +} + +.ssh-card__meta { + flex-wrap: wrap; + margin-top: 14px; + font-size: 12px; + color: #7b8794; +} + +.ssh-card__actions { + align-items: center; + margin-top: 12px; +} + +.shortcut-card-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 14px; +} + +.shortcut-card { + display: flex; + flex-direction: column; + gap: 12px; + padding: 16px; + border: 1px solid #e2e8f0; + border-radius: 16px; + background: #fff; +} + +.shortcut-card__head, +.shortcut-card__footer { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.shortcut-card__head strong { + display: block; + margin-bottom: 4px; + color: #1f2d3d; +} + +.shortcut-card__head p { + margin: 0; + font-size: 12px; + line-height: 1.6; + color: #6b7785; +} + +.shortcut-card pre { + margin: 0; + min-height: 72px; + max-height: 120px; + overflow: auto; + padding: 12px; + border-radius: 12px; + background: #0f172a; + color: #dbeafe; + white-space: pre-wrap; + word-break: break-word; + font-size: 12px; + line-height: 1.6; +} + +.shortcut-card__footer { + align-items: center; +} + +.shortcut-card__select { + min-width: 220px; + flex: 1; +} + +.ssh-target-cell { + display: flex; + flex-direction: column; + gap: 4px; +} + +.ssh-target-cell strong { + font-size: 13px; + color: #1f2d3d; +} + +.ssh-target-cell span, +.preset-section__header span, +.ssh-result__meta { + font-size: 12px; + color: #7b8794; +} + +.ssh-exec { + display: flex; + flex-direction: column; + gap: 16px; +} + +.ssh-terminal-shell { + overflow: hidden; + border: 1px solid #d7e0ea; + border-radius: 16px; + background: #0f172a; +} + +.ssh-terminal-shell__meta { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 10px 14px; + border-bottom: 1px solid rgba(148, 163, 184, 0.18); + font-size: 12px; + color: #cbd5e1; + background: rgba(15, 23, 42, 0.92); +} + +.ssh-terminal-canvas { + min-height: 420px; + padding: 12px; +} + +.ssh-page .ssh-terminal-canvas { + min-height: 0; +} + +.ssh-terminal-canvas .xterm { + height: 100%; +} + +.ssh-session-password { + max-width: 360px; +} + +.ssh-current-profile { + align-items: center; + padding: 16px 18px; + border: 1px solid #e8edf3; + border-radius: 16px; + background: #f8fbff; +} + +.ssh-current-profile strong, +.preset-section__header strong, +.preset-editor__item-head strong { + display: block; + color: #1f2d3d; +} + +.ssh-current-profile p { + margin: 4px 0 0; + font-size: 13px; + color: #6b7785; +} + +.ssh-form-grid { + align-items: stretch; +} + +.ssh-form-grid > .el-textarea { + flex: 1; +} + +.ssh-form-side { + width: 220px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.preset-section { + display: flex; + flex-direction: column; + gap: 12px; +} + +.preset-list { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 12px; +} + +.preset-item { + padding: 14px 16px; + border: 1px solid #e2e8f0; + border-radius: 14px; + background: #fff; + text-align: left; + cursor: pointer; + transition: + border-color 0.2s ease, + box-shadow 0.2s ease, + transform 0.2s ease; +} + +.preset-item:hover { + border-color: #bfd7ff; + box-shadow: 0 14px 28px rgba(64, 158, 255, 0.12); + transform: translateY(-1px); +} + +.preset-item strong { + display: block; + margin-bottom: 6px; + color: #1f2d3d; +} + +.preset-item span { + display: block; + font-size: 12px; + line-height: 1.6; + color: #6b7785; +} + +.ssh-result { + display: flex; + flex-direction: column; + gap: 12px; +} + +.ssh-result__panes { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} + +.ssh-result__panes section { + min-width: 0; + padding: 14px; + border-radius: 14px; + background: #0f172a; +} + +.ssh-result__panes span { + display: block; + margin-bottom: 8px; + font-size: 12px; + color: #94a3b8; +} + +.ssh-result__panes pre { + margin: 0; + min-height: 120px; + max-height: 320px; + overflow: auto; + white-space: pre-wrap; + word-break: break-word; + font-size: 12px; + line-height: 1.6; + color: #e2e8f0; +} + +.ssh-drawer-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.preset-editor { + display: flex; + flex-direction: column; + gap: 14px; +} + +.preset-editor__list { + display: flex; + flex-direction: column; + gap: 12px; +} + +.preset-editor__item { + padding: 16px; + border: 1px solid #e8edf3; + border-radius: 16px; + background: #fafcff; +} + +.preset-editor__item-head { + align-items: center; + margin-bottom: 12px; +} + +.api-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 16px; +} + +.api-toolbar__search { + max-width: 360px; +} + +.api-table .el-button + .el-button { + margin-left: 0; +} + +@media (max-width: 992px) { + .ssh-page { + grid-template-columns: 1fr; + height: auto; + min-height: 0; + } + + .ssh-page__sidebar, + .ssh-page__main { + height: auto; + min-height: 520px; + } + + .ssh-run-panel__toolbar, + .ssh-page__sidebar-header { + flex-direction: column; + align-items: stretch; + } + + .ssh-form-grid, + .ssh-current-profile, + .preset-section__header, + .ssh-toolbar { + flex-direction: column; + align-items: stretch; + } + + .ssh-form-side, + .ssh-toolbar .el-input { + width: 100%; + max-width: none; + } + + .ssh-result__panes, + .ssh-drawer-grid { + grid-template-columns: 1fr; + } + + .dashboard-pinned-section__header { + flex-direction: column; + align-items: stretch; + } + + .dashboard-pinned-grid { + grid-template-columns: 1fr; + } + + .dashboard-pinned-empty-card { + width: 100%; + } +} + +.api-form { + padding-bottom: 12px; +} + +.tree-title { + display: inline-flex; + align-items: center; + gap: 8px; + min-height: 30px; + vertical-align: middle; +} + +.tree-title--folder { + color: #1f2d3d; + padding: 4px 0; +} + +.tree-title strong, +.user-cell strong { + display: block; + font-size: 14px; + color: #1f2d3d; +} + +.user-cell p { + margin: 4px 0 0; + font-size: 12px; + color: #909399; +} + +.tree-title__api-icon, +.user-cell__avatar { + width: 30px; + height: 30px; + border-radius: 10px; + display: inline-flex; + align-items: center; + justify-content: center; + background: #eef5ff; + color: #409eff; +} + +.table-url { + font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; + color: #3a4a5f; +} + +.api-table :deep(.table-row-folder) { + --el-table-tr-bg-color: #f8fbff; +} + +.api-table :deep(.table-row-folder td) { + border-bottom-color: #edf3fb; +} + +.user-cell { + display: flex; + align-items: center; + gap: 12px; +} + +.full-width { + width: 100%; +} + +.code-textarea :deep(textarea), +.code-textarea textarea { + font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; +} + +.json-field { + display: flex; + flex-direction: column; + gap: 8px; + width: 100%; +} + +.json-field__label { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + color: var(--el-text-color-regular); + line-height: 1.4; +} + +.json-field__badge { + font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; + letter-spacing: 0.04em; +} + +.json-field__hint { + margin: 0; + font-size: 12px; + color: #8a9ab1; + line-height: 1.6; +} + +.kv-editor { + display: flex; + flex-direction: column; + gap: 10px; + width: 100%; +} + +.kv-editor__head { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1.3fr) auto; + gap: 10px; + padding: 0 2px; + font-size: 12px; + font-weight: 600; + color: #6e7f96; +} + +.kv-editor__list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.kv-editor__row { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1.3fr) auto; + gap: 10px; + align-items: center; +} + +.kv-editor__add { + align-self: flex-start; + padding-left: 0; +} + +.api-editor-settings-card :deep(.el-tabs__header) { + margin-bottom: 14px; +} + +.api-editor-settings-card :deep(.el-tabs__content) { + overflow: visible; +} + +@media (max-width: 1280px) { + .kv-editor__head, + .kv-editor__row { + grid-template-columns: 1fr; + } + + .kv-editor__head { + display: none; + } + + .kv-editor__action { + display: none; + } +} + +.login-page { + position: relative; + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; +} + +.login-page__backdrop { + position: absolute; + inset: 0; + background: + radial-gradient(circle at 20% 20%, rgba(64, 158, 255, 0.22), transparent 24%), + radial-gradient(circle at 80% 0%, rgba(54, 207, 201, 0.18), transparent 28%), + linear-gradient(135deg, #eef4fb 0%, #f8fbff 48%, #eef3f8 100%); +} + +.login-card { + position: relative; + z-index: 1; + width: min(440px, 100%); + border-radius: 24px; + border: 1px solid rgba(228, 233, 241, 0.92); + box-shadow: 0 28px 70px rgba(31, 45, 61, 0.12); +} + +.login-card__meta h1 { + margin: 6px 0 12px; + font-size: 28px; +} + +.login-card__meta p, +.login-card__hint { + color: #8492a6; + line-height: 1.7; +} + +.login-card__eyebrow { + display: inline-flex; + padding: 4px 10px; + border-radius: 999px; + background: #edf5ff; + color: #409eff; + font-size: 12px; + letter-spacing: 0.08em; +} + +.login-card__submit { + width: 100%; + margin-top: 8px; +} + +.login-card__hint { + margin-top: 18px; + display: flex; + flex-direction: column; + gap: 4px; + font-size: 12px; +} + +@media (max-width: 960px) { + .admin-shell { + flex-direction: column; + } + + .admin-sidebar, + .admin-sidebar.collapsed { + width: 100%; + } + + .admin-sidebar__scroll { + height: auto; + } + + .admin-header, + .panel-card__header, + .api-toolbar { + flex-direction: column; + align-items: flex-start; + } + + .hero-block { + grid-template-columns: 1fr; + } + + .api-toolbar__search { + max-width: none; + width: 100%; + } +} diff --git a/frontend-admin/src/utils/folderTree.js b/frontend-admin/src/utils/folderTree.js new file mode 100644 index 0000000..cc59b78 --- /dev/null +++ b/frontend-admin/src/utils/folderTree.js @@ -0,0 +1,94 @@ +export function normalizeFolderPath(value) { + const text = String(value || "") + .trim() + .replace(/\\/g, "/"); + if (!text || text === "/") return ""; + return text + .split("/") + .map((part) => part.trim()) + .filter(Boolean) + .join("/"); +} + +export function collectFolderPaths(rows, extraFolderPaths = []) { + const paths = new Set(); + rows.forEach((row) => { + const path = normalizeFolderPath(row.folder_path); + if (path) paths.add(path); + }); + extraFolderPaths.forEach((path) => { + const normalized = normalizeFolderPath(path); + if (normalized) paths.add(normalized); + }); + return [...paths]; +} + +export function buildFolderTreeRows(rows, extraFolderPaths = []) { + const folderMap = new Map(); + const rootNodes = []; + + function ensureFolderNode(path) { + if (!path) return null; + if (folderMap.has(path)) return folderMap.get(path); + + const parts = path.split("/").filter(Boolean); + const name = parts[parts.length - 1]; + const parentPath = parts.slice(0, -1).join("/"); + const node = { + rowKey: `folder:${path}`, + nodeType: "folder", + name, + folder_path: path, + creator_name: "", + method: "", + url: "", + timeout_seconds: "", + children: [], + isEmptyFolder: true, + }; + folderMap.set(path, node); + const parent = parentPath ? ensureFolderNode(parentPath) : null; + if (parent) { + parent.children.push(node); + } else { + rootNodes.push(node); + } + return node; + } + + collectFolderPaths(rows, extraFolderPaths).forEach((path) => ensureFolderNode(path)); + + rows.forEach((row) => { + const folderNode = ensureFolderNode(normalizeFolderPath(row.folder_path || "")); + const apiNode = { + ...row, + rowKey: `api:${row.id}`, + nodeType: "api", + children: [], + isEmptyFolder: false, + }; + if (folderNode) { + folderNode.isEmptyFolder = false; + folderNode.children.push(apiNode); + } else { + rootNodes.push(apiNode); + } + }); + + function sortNodes(nodes) { + nodes.sort((left, right) => { + if (left.nodeType !== right.nodeType) { + return left.nodeType === "folder" ? -1 : 1; + } + return String(left.name || "").localeCompare(String(right.name || ""), "zh-CN"); + }); + nodes.forEach((node) => { + if (node.children?.length) { + sortNodes(node.children); + } + }); + } + + sortNodes(rootNodes); + return rootNodes; +} diff --git a/frontend-admin/src/utils/keyValue.js b/frontend-admin/src/utils/keyValue.js new file mode 100644 index 0000000..1cacf8e --- /dev/null +++ b/frontend-admin/src/utils/keyValue.js @@ -0,0 +1,29 @@ +export function normalizeKeyValueRows(source) { + if (!source || typeof source !== "object" || Array.isArray(source)) { + return [{ key: "", value: "" }]; + } + + const rows = Object.entries(source).map(([key, value]) => ({ + key: String(key), + value: typeof value === "string" ? value : JSON.stringify(value ?? ""), + })); + + return rows.length ? rows : [{ key: "", value: "" }]; +} + +export function collectKeyValueObject(rows, duplicateLabel = "Key") { + const output = {}; + const seen = new Set(); + + rows.forEach((row) => { + const key = String(row.key || "").trim(); + if (!key) return; + if (seen.has(key)) { + throw new Error(`${duplicateLabel} 存在重复 Key:${key}`); + } + seen.add(key); + output[key] = String(row.value ?? ""); + }); + + return output; +} diff --git a/frontend-admin/src/utils/sshPinnedScripts.js b/frontend-admin/src/utils/sshPinnedScripts.js new file mode 100644 index 0000000..f4ab3ab --- /dev/null +++ b/frontend-admin/src/utils/sshPinnedScripts.js @@ -0,0 +1,47 @@ +const STORAGE_KEY = "qip_ssh_pinned_scripts"; + +export function loadPinnedScripts() { + try { + const raw = localStorage.getItem(STORAGE_KEY); + const parsed = raw ? JSON.parse(raw) : []; + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +export function savePinnedScripts(items) { + localStorage.setItem(STORAGE_KEY, JSON.stringify(items)); +} + +export function isScriptPinned(scriptId) { + return loadPinnedScripts().some((item) => Number(item.scriptId) === Number(scriptId)); +} + +export function togglePinScript(entry) { + const scriptId = Number(entry.scriptId); + const profileId = Number(entry.profileId); + const items = loadPinnedScripts(); + const index = items.findIndex((item) => Number(item.scriptId) === scriptId); + if (index >= 0) { + items.splice(index, 1); + savePinnedScripts(items); + return false; + } + items.unshift({ + scriptId, + profileId, + scriptName: entry.scriptName || "", + profileName: entry.profileName || "", + profileHost: entry.profileHost || "", + description: entry.description || "", + pinnedAt: Date.now(), + }); + savePinnedScripts(items); + return true; +} + +export function removePinnedScript(scriptId) { + const items = loadPinnedScripts().filter((item) => Number(item.scriptId) !== Number(scriptId)); + savePinnedScripts(items); +} diff --git a/frontend-admin/src/utils/sshScriptRunner.js b/frontend-admin/src/utils/sshScriptRunner.js new file mode 100644 index 0000000..19ea770 --- /dev/null +++ b/frontend-admin/src/utils/sshScriptRunner.js @@ -0,0 +1,428 @@ +import { ElMessage, ElMessageBox } from "element-plus"; +import { nextTick, ref } from "vue"; +import { FitAddon } from "@xterm/addon-fit"; +import { Terminal } from "@xterm/xterm"; +import { buildWebSocketUrl } from "../api/http"; +import { getAccessToken } from "../auth/session"; +import "@xterm/xterm/css/xterm.css"; + +let nextRunSeq = 1; +let nextZIndex = 3200; + +export const sessions = ref([]); +export const tabSessionIds = ref([]); +export const activeTabSessionId = ref(""); +export const floatingWindows = ref([]); + +export const FLOAT_WINDOW_DEFAULT_SIZE = { width: 760, height: 420 }; +export const FLOAT_WINDOW_MIN_SIZE = { width: 360, height: 200 }; + +const terminalContainers = new Map(); + +function createTerminalTheme() { + return { + background: "#0f172a", + foreground: "#e2e8f0", + cursor: "#64748b", + black: "#0f172a", + red: "#f87171", + green: "#4ade80", + yellow: "#facc15", + blue: "#60a5fa", + magenta: "#c084fc", + cyan: "#22d3ee", + white: "#e2e8f0", + brightBlack: "#334155", + brightRed: "#fca5a5", + brightGreen: "#86efac", + brightYellow: "#fde047", + brightBlue: "#93c5fd", + brightMagenta: "#d8b4fe", + brightCyan: "#67e8f9", + brightWhite: "#f8fafc", + }; +} + +export function getSession(sessionId) { + return sessions.value.find((item) => item.id === sessionId) || null; +} + +export function registerTerminalContainer(sessionId, element) { + if (element) { + terminalContainers.set(sessionId, element); + const session = getSession(sessionId); + if (session && !session.terminal) { + nextTick(() => initSessionTerminal(session)); + } + return; + } + terminalContainers.delete(sessionId); +} + +export function initSessionTerminal(session) { + const container = terminalContainers.get(session.id); + if (!container || session.terminal) return; + + const terminal = new Terminal({ + disableStdin: true, + cursorBlink: false, + convertEol: true, + scrollback: 8000, + fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace', + fontSize: 13, + lineHeight: 1.3, + theme: createTerminalTheme(), + }); + const fitAddon = new FitAddon(); + terminal.loadAddon(fitAddon); + terminal.open(container); + fitAddon.fit(); + terminal.writeln("\x1b[36mSSH 脚本执行日志\x1b[0m\r\n"); + + session.terminal = terminal; + session.fitAddon = fitAddon; + fitSessionTerminal(session.id); +} + +export function disposeSessionTerminal(session) { + if (!session) return; + session.terminal?.dispose(); + session.terminal = null; + session.fitAddon = null; + terminalContainers.delete(session.id); +} + +export function fitSessionTerminal(sessionId) { + const session = getSession(sessionId); + if (!session?.terminal || !session.fitAddon) return; + session.fitAddon.fit(); + session.terminal.scrollToBottom(); +} + +function scrollSessionTerminal(session) { + session.terminal?.scrollToBottom(); +} + +function writeSessionLine(session, text, color = "") { + if (!session.terminal) return; + if (color) { + session.terminal.writeln(`${color}${text}\x1b[0m`); + } else { + session.terminal.writeln(text); + } + scrollSessionTerminal(session); +} + +function appendSessionOutput(session, text, stream = "stdout") { + if (!session.terminal || !text) return; + if (stream === "stderr") { + session.terminal.write(`\x1b[31m${text}\x1b[0m`); + } else { + session.terminal.write(text); + } + scrollSessionTerminal(session); +} + +function finishSession(session, status, statusText, tailLine = "") { + session.status = status; + session.statusText = statusText; + if (tailLine) { + writeSessionLine(session, tailLine, status === "success" ? "\x1b[32m" : "\x1b[33m"); + } +} + +function closeSessionSocket(session) { + if (session.socket && session.socket.readyState === WebSocket.OPEN) { + session.socket.close(); + } + session.socket = null; +} + +export function stopSession(session, options = {}) { + const { silent = false } = options; + if (!session) return; + if (session.status === "running" || session.status === "connecting") { + if (session.socket?.readyState === WebSocket.OPEN) { + session.socket.send(JSON.stringify({ type: "stop" })); + } + closeSessionSocket(session); + finishSession(session, "stopped", "已手动停止", ">>> 用户手动停止执行"); + if (!silent) { + ElMessage.success(`已停止:${session.scriptName}`); + } + } +} + +function buildProfileHost(profile) { + return `${profile.username}@${profile.host}:${profile.port || 22}`; +} + +function createSession(script, profile) { + return { + id: `run-${Date.now()}-${nextRunSeq++}`, + scriptId: script.id, + scriptName: script.name, + profileId: profile.id, + profileName: profile.name, + profileHost: buildProfileHost(profile), + status: "connecting", + statusText: "正在连接并执行脚本...", + socket: null, + terminal: null, + fitAddon: null, + }; +} + +export async function executeScript(script, profile, password, target = "tab") { + const session = createSession(script, profile); + sessions.value.push(session); + + if (target === "float") { + const offset = floatingWindows.value.length * 28; + const maxWidth = + typeof window !== "undefined" ? Math.max(FLOAT_WINDOW_MIN_SIZE.width, window.innerWidth - 32) : 760; + floatingWindows.value.push({ + sessionId: session.id, + minimized: false, + position: { x: 72 + offset, y: 72 + offset }, + size: { + width: Math.min(FLOAT_WINDOW_DEFAULT_SIZE.width, maxWidth), + height: FLOAT_WINDOW_DEFAULT_SIZE.height, + }, + zIndex: ++nextZIndex, + }); + } else { + tabSessionIds.value.push(session.id); + activeTabSessionId.value = session.id; + } + + await nextTick(); + initSessionTerminal(session); + if (!session.terminal) { + await nextTick(); + initSessionTerminal(session); + } + + writeSessionLine( + session, + `>>> 执行脚本: ${script.name} @ ${buildProfileHost(profile)}`, + "\x1b[36m" + ); + + const token = getAccessToken(); + const socket = new WebSocket(buildWebSocketUrl("/ws/ssh-script-run", { token })); + session.socket = socket; + + socket.onopen = () => { + socket.send( + JSON.stringify({ + type: "start", + profile_id: profile.id, + script_id: script.id, + password, + }) + ); + }; + + socket.onmessage = (event) => { + try { + const payload = JSON.parse(event.data); + if (payload.type === "started") { + session.status = "running"; + session.statusText = "脚本执行中,日志实时输出..."; + return; + } + if (payload.type === "meta") { + writeSessionLine(session, `$ ${payload.command || ""}`, "\x1b[90m"); + return; + } + if (payload.type === "stdout" || payload.type === "stderr") { + appendSessionOutput(session, payload.data || "", payload.type); + return; + } + if (payload.type === "done") { + const exitCode = payload.exit_status ?? 0; + const ok = Boolean(payload.ok); + finishSession( + session, + ok ? "success" : "failed", + ok ? `执行成功,exit code ${exitCode}` : `执行失败,exit code ${exitCode}`, + `>>> 执行结束,exit code ${exitCode}` + ); + closeSessionSocket(session); + return; + } + if (payload.type === "stopped") { + finishSession(session, "stopped", "已手动停止", ">>> 用户手动停止执行"); + closeSessionSocket(session); + return; + } + if (payload.type === "error") { + finishSession(session, "failed", payload.detail || "执行失败", `\x1b[31m>>> ${payload.detail || "执行失败"}`); + closeSessionSocket(session); + } + } catch { + appendSessionOutput(session, String(event.data || "")); + } + }; + + socket.onerror = () => { + finishSession(session, "failed", "脚本执行连接异常", "\x1b[31m>>> WebSocket connection error"); + closeSessionSocket(session); + }; + + socket.onclose = () => { + if (session.status === "running" || session.status === "connecting") { + finishSession(session, "stopped", "脚本执行连接已关闭"); + } + session.socket = null; + }; + + return session; +} + +export async function promptExecutePassword(scriptName) { + try { + const result = await ElMessageBox.prompt("请输入执行密码,仅用于本次执行,不会保存。", `执行脚本:${scriptName}`, { + confirmButtonText: "执行", + cancelButtonText: "取消", + inputType: "password", + inputPlaceholder: "执行密码(必填)", + inputValidator: (value) => { + if (!value || !String(value).trim()) { + return "执行前必须输入密码"; + } + return true; + }, + }); + return String(result.value || "").trim(); + } catch (error) { + if (error !== "cancel") { + ElMessage.error(error instanceof Error ? error.message : "已取消执行"); + } + return null; + } +} + +export async function promptAndExecuteScript({ script, profile, target = "tab" }) { + const password = await promptExecutePassword(script.name); + if (!password) return null; + return executeScript(script, profile, password, target); +} + +export function parseProfileHost(profileHost) { + const raw = String(profileHost || "").trim(); + const withPort = raw.match(/^([^@]+)@([^:]+):(\d+)$/); + if (withPort) { + return { + username: withPort[1], + host: withPort[2], + port: Number(withPort[3]), + }; + } + const withoutPort = raw.match(/^([^@]+)@(.+)$/); + if (withoutPort) { + return { + username: withoutPort[1], + host: withoutPort[2], + port: 22, + }; + } + return { username: "", host: "", port: 22 }; +} + +export async function promptAndExecutePinnedScript(item) { + const parsed = parseProfileHost(item.profileHost); + const script = { id: Number(item.scriptId), name: item.scriptName || "脚本" }; + const profile = { + id: Number(item.profileId), + name: item.profileName || "主机", + username: parsed.username, + host: parsed.host, + port: parsed.port, + }; + return promptAndExecuteScript({ script, profile, target: "float" }); +} + +export function removeTabSession(tabId) { + const session = getSession(tabId); + if (!session) return; + stopSession(session, { silent: true }); + disposeSessionTerminal(session); + sessions.value = sessions.value.filter((item) => item.id !== tabId); + tabSessionIds.value = tabSessionIds.value.filter((id) => id !== tabId); + if (activeTabSessionId.value === tabId) { + activeTabSessionId.value = tabSessionIds.value[tabSessionIds.value.length - 1] || ""; + } + nextTick(() => fitSessionTerminal(activeTabSessionId.value)); +} + +export function closeFloatingWindow(sessionId) { + const session = getSession(sessionId); + if (session) { + stopSession(session, { silent: true }); + disposeSessionTerminal(session); + sessions.value = sessions.value.filter((item) => item.id !== sessionId); + } + floatingWindows.value = floatingWindows.value.filter((item) => item.sessionId !== sessionId); + tabSessionIds.value = tabSessionIds.value.filter((id) => id !== sessionId); +} + +export function toggleFloatingMinimize(sessionId) { + const target = floatingWindows.value.find((item) => item.sessionId === sessionId); + if (!target) return; + target.minimized = !target.minimized; + if (!target.minimized) { + nextTick(() => fitSessionTerminal(sessionId)); + } +} + +export function bringFloatingToFront(sessionId) { + const target = floatingWindows.value.find((item) => item.sessionId === sessionId); + if (!target) return; + target.zIndex = ++nextZIndex; +} + +export function updateFloatingPosition(sessionId, position) { + const target = floatingWindows.value.find((item) => item.sessionId === sessionId); + if (!target) return; + target.position = position; +} + +export function updateFloatingSize(sessionId, size) { + const target = floatingWindows.value.find((item) => item.sessionId === sessionId); + if (!target) return; + const maxWidth = + typeof window !== "undefined" ? Math.max(FLOAT_WINDOW_MIN_SIZE.width, window.innerWidth - 16) : 2000; + const maxHeight = + typeof window !== "undefined" ? Math.max(FLOAT_WINDOW_MIN_SIZE.height, window.innerHeight - 16) : 1200; + target.size = { + width: Math.min(Math.max(FLOAT_WINDOW_MIN_SIZE.width, size.width), maxWidth), + height: Math.min(Math.max(FLOAT_WINDOW_MIN_SIZE.height, size.height), maxHeight), + }; +} + +export function cleanupTabSessions() { + const ids = [...tabSessionIds.value]; + ids.forEach((id) => { + const session = getSession(id); + if (session) { + stopSession(session, { silent: true }); + disposeSessionTerminal(session); + } + }); + sessions.value = sessions.value.filter((item) => !ids.includes(item.id)); + tabSessionIds.value = []; + activeTabSessionId.value = ""; +} + +export function cleanupAllSessions() { + sessions.value.forEach((session) => { + stopSession(session, { silent: true }); + disposeSessionTerminal(session); + }); + sessions.value = []; + tabSessionIds.value = []; + activeTabSessionId.value = ""; + floatingWindows.value = []; +} diff --git a/frontend-admin/src/views/ApiEditorView.vue b/frontend-admin/src/views/ApiEditorView.vue new file mode 100644 index 0000000..7fb5144 --- /dev/null +++ b/frontend-admin/src/views/ApiEditorView.vue @@ -0,0 +1,1229 @@ + + + + + diff --git a/frontend-admin/src/views/ApiManagementView.vue b/frontend-admin/src/views/ApiManagementView.vue new file mode 100644 index 0000000..bd5f8d0 --- /dev/null +++ b/frontend-admin/src/views/ApiManagementView.vue @@ -0,0 +1,391 @@ + + + diff --git a/frontend-admin/src/views/DashboardView.vue b/frontend-admin/src/views/DashboardView.vue new file mode 100644 index 0000000..093089d --- /dev/null +++ b/frontend-admin/src/views/DashboardView.vue @@ -0,0 +1,248 @@ + + + diff --git a/frontend-admin/src/views/LoginView.vue b/frontend-admin/src/views/LoginView.vue new file mode 100644 index 0000000..3a00d82 --- /dev/null +++ b/frontend-admin/src/views/LoginView.vue @@ -0,0 +1,74 @@ + + + diff --git a/frontend-admin/src/views/McpManagementView.vue b/frontend-admin/src/views/McpManagementView.vue new file mode 100644 index 0000000..0b98423 --- /dev/null +++ b/frontend-admin/src/views/McpManagementView.vue @@ -0,0 +1,369 @@ + + + + + diff --git a/frontend-admin/src/views/SshManagementView.vue b/frontend-admin/src/views/SshManagementView.vue new file mode 100644 index 0000000..b393855 --- /dev/null +++ b/frontend-admin/src/views/SshManagementView.vue @@ -0,0 +1,764 @@ + + + diff --git a/frontend-admin/src/views/UserManagementView.vue b/frontend-admin/src/views/UserManagementView.vue new file mode 100644 index 0000000..fe6c075 --- /dev/null +++ b/frontend-admin/src/views/UserManagementView.vue @@ -0,0 +1,241 @@ + + + diff --git a/frontend-admin/src/views/WorkflowBatchView.vue b/frontend-admin/src/views/WorkflowBatchView.vue new file mode 100644 index 0000000..abeb0e9 --- /dev/null +++ b/frontend-admin/src/views/WorkflowBatchView.vue @@ -0,0 +1,718 @@ + + + + + diff --git a/frontend-admin/vite.config.js b/frontend-admin/vite.config.js new file mode 100644 index 0000000..77f8e41 --- /dev/null +++ b/frontend-admin/vite.config.js @@ -0,0 +1,33 @@ +import { defineConfig } from "vite"; +import vue from "@vitejs/plugin-vue"; + +export default defineConfig({ + plugins: [vue()], + server: { + host: "0.0.0.0", + port: 5173, + proxy: { + "^/api(?:/|$)": { + target: "http://127.0.0.1:8000", + changeOrigin: true, + }, + "^/docs(?:/|$)": { + target: "http://127.0.0.1:8000", + changeOrigin: true, + }, + "^/openapi\\.json$": { + target: "http://127.0.0.1:8000", + changeOrigin: true, + }, + "^/mcp(?:/|$)": { + target: "http://127.0.0.1:8000", + changeOrigin: true, + }, + "^/ws(?:/|$)": { + target: "ws://127.0.0.1:8000", + ws: true, + changeOrigin: true, + }, + }, + }, +}); diff --git a/mcp_bridge.py b/mcp_bridge.py new file mode 100644 index 0000000..b3c41fd --- /dev/null +++ b/mcp_bridge.py @@ -0,0 +1,181 @@ +"""Stdio MCP server that bridges to the local HTTP MCP gateway. + +It speaks the Model Context Protocol over stdin/stdout (newline-delimited +JSON-RPC 2.0) and forwards every tool call to: + + GET http://127.0.0.1:8000/mcp/tools + POST http://127.0.0.1:8000/mcp/invoke + +Run it with Cursor / Codex / Claude Desktop by configuring stdio MCP entry: + + { + "command": "python3", + "args": ["/abs/path/to/mcp_bridge.py"], + "env": {"AI_TEST_BASE_URL": "http://127.0.0.1:8000"} + } +""" + +from __future__ import annotations + +import json +import os +import sys +from typing import Any + +import httpx + +BASE_URL = os.environ.get("AI_TEST_BASE_URL", "http://127.0.0.1:8000").rstrip("/") +API_KEY = os.environ.get("AI_TEST_API_KEY", "").strip() +SERVER_NAME = "quality-inspection-platform" +SERVER_VERSION = "0.1.0" +PROTOCOL_VERSION = "2024-11-05" + + +def log(msg: str) -> None: + sys.stderr.write(f"[mcp_bridge] {msg}\n") + sys.stderr.flush() + + +def write_message(message: dict[str, Any]) -> None: + sys.stdout.write(json.dumps(message, ensure_ascii=False) + "\n") + sys.stdout.flush() + + +def fetch_tools() -> list[dict[str, Any]]: + try: + response = httpx.get(f"{BASE_URL}/mcp/tools", headers=_request_headers(), timeout=10.0) + response.raise_for_status() + payload = response.json() or {} + tools = payload.get("tools", []) + except Exception as exc: + log(f"fetch tools failed: {exc}") + return [] + + converted = [] + for tool in tools: + converted.append( + { + "name": tool.get("name", ""), + "description": tool.get("description", ""), + "inputSchema": tool.get("input_schema") or {"type": "object"}, + } + ) + return converted + + +def _request_headers() -> dict[str, str]: + headers = {"Content-Type": "application/json"} + if API_KEY: + headers["Authorization"] = f"Bearer {API_KEY}" + return headers + + +def call_tool(name: str, arguments: dict[str, Any]) -> dict[str, Any]: + payload: dict[str, Any] = {"tool": name, "arguments": arguments or {}} + if API_KEY: + payload["api_key"] = API_KEY + try: + response = httpx.post( + f"{BASE_URL}/mcp/invoke", + json=payload, + headers=_request_headers(), + timeout=60.0, + ) + response.raise_for_status() + return response.json() or {} + except Exception as exc: + return {"ok": False, "tool": name, "data": {}, "error": str(exc)} + + +def make_response(req_id: Any, result: Any) -> dict[str, Any]: + return {"jsonrpc": "2.0", "id": req_id, "result": result} + + +def make_error(req_id: Any, code: int, message: str) -> dict[str, Any]: + return {"jsonrpc": "2.0", "id": req_id, "error": {"code": code, "message": message}} + + +def handle_initialize(req_id: Any, _: dict[str, Any]) -> dict[str, Any]: + return make_response( + req_id, + { + "protocolVersion": PROTOCOL_VERSION, + "capabilities": {"tools": {"listChanged": False}}, + "serverInfo": {"name": SERVER_NAME, "version": SERVER_VERSION}, + "instructions": ( + "AI Auto API Test platform. Before complex tasks, read project file " + "docs/mcp_tools_for_ai.md (tool picker, auth, recipes). " + "Typical start: catalog_snapshot. " + "Batch runs: workflow_batch_create → workflow_batch_update → workflow_batch_run. " + "Writes and execution require sto- API Key (AI_TEST_API_KEY)." + ), + }, + ) + + +def handle_tools_list(req_id: Any, _: dict[str, Any]) -> dict[str, Any]: + return make_response(req_id, {"tools": fetch_tools()}) + + +def handle_tools_call(req_id: Any, params: dict[str, Any]) -> dict[str, Any]: + name = str(params.get("name", "")) + arguments = params.get("arguments") or {} + if not name: + return make_error(req_id, -32602, "missing tool name") + + payload = call_tool(name, arguments) + text = json.dumps(payload, ensure_ascii=False, indent=2) + is_error = not bool(payload.get("ok", True)) + return make_response( + req_id, + { + "content": [{"type": "text", "text": text}], + "isError": is_error, + }, + ) + + +HANDLERS = { + "initialize": handle_initialize, + "tools/list": handle_tools_list, + "tools/call": handle_tools_call, +} + + +def main() -> None: + log(f"started, base_url={BASE_URL}") + for raw_line in sys.stdin: + line = raw_line.strip() + if not line: + continue + try: + message = json.loads(line) + except json.JSONDecodeError as exc: + log(f"invalid json: {exc}") + continue + + method = message.get("method") + req_id = message.get("id") + params = message.get("params") or {} + + if method is None: + continue + if "id" not in message: + # JSON-RPC notification, no response required. + continue + + handler = HANDLERS.get(method) + if handler is None: + write_message(make_error(req_id, -32601, f"method not found: {method}")) + continue + + try: + response = handler(req_id, params) + except Exception as exc: + log(f"handler error: {exc}") + response = make_error(req_id, -32000, str(exc)) + write_message(response) + + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..417aa96 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +fastapi +uvicorn[standard] +sqlalchemy +pydantic +httpx +jinja2 +python-multipart +paramiko diff --git a/start_project.command b/start_project.command new file mode 100755 index 0000000..3aba22c --- /dev/null +++ b/start_project.command @@ -0,0 +1,115 @@ +#!/bin/zsh + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")" && pwd)" +BACKEND_PORT=8000 +FRONTEND_PORT=5173 + +require_file() { + local path="$1" + local message="$2" + if [[ ! -e "$path" ]]; then + echo "$message" + read -r -k 1 "?按任意键关闭..." + exit 1 + fi +} + +require_command() { + local name="$1" + if ! command -v "$name" >/dev/null 2>&1; then + echo "缺少命令: $name" + read -r -k 1 "?按任意键关闭..." + exit 1 + fi +} + +is_port_listening() { + local port="$1" + lsof -iTCP:"$port" -sTCP:LISTEN -t >/dev/null 2>&1 +} + +wait_for_http() { + local url="$1" + local max_attempts="$2" + local attempt=1 + + while (( attempt <= max_attempts )); do + if curl -fsS "$url" >/dev/null 2>&1; then + return 0 + fi + sleep 1 + (( attempt++ )) + done + + return 1 +} + +open_backend_terminal() { + osascript - "$ROOT_DIR" <<'APPLESCRIPT' +on run argv + set rootDir to item 1 of argv + tell application "Terminal" + activate + do script "cd " & quoted form of rootDir & " && exec ./.venv/bin/python -m uvicorn app.main:app --host 127.0.0.1 --port 8000" + end tell +end run +APPLESCRIPT +} + +open_frontend_terminal() { + osascript - "$ROOT_DIR" <<'APPLESCRIPT' +on run argv + set rootDir to item 1 of argv + tell application "Terminal" + activate + do script "cd " & quoted form of (rootDir & "/frontend-admin") & " && exec npm run dev -- --host 0.0.0.0 --port 5173" + end tell +end run +APPLESCRIPT +} + +require_file "$ROOT_DIR/.venv/bin/python" "未找到 Python 虚拟环境: .venv/bin/python" +require_file "$ROOT_DIR/frontend-admin/package.json" "未找到前端工程: frontend-admin/package.json" +require_command npm +require_command curl +require_command lsof +require_command osascript + +if ! is_port_listening "$BACKEND_PORT"; then + echo "启动后端..." + open_backend_terminal +else + echo "后端已在 $BACKEND_PORT 端口运行" +fi + +if ! is_port_listening "$FRONTEND_PORT"; then + echo "启动前端..." + open_frontend_terminal +else + echo "前端已在 $FRONTEND_PORT 端口运行" +fi + +echo "等待服务就绪..." + +if ! wait_for_http "http://127.0.0.1:$BACKEND_PORT/health" 30; then + echo "后端启动失败,请检查新打开的 Terminal 窗口" + read -r -k 1 "?按任意键关闭..." + exit 1 +fi + +if ! wait_for_http "http://127.0.0.1:$FRONTEND_PORT/" 30; then + echo "前端启动失败,请检查新打开的 Terminal 窗口" + read -r -k 1 "?按任意键关闭..." + exit 1 +fi + +open "http://127.0.0.1:$FRONTEND_PORT/" + +echo +echo "启动成功" +echo "前端: http://127.0.0.1:$FRONTEND_PORT/" +echo "后端: http://127.0.0.1:$BACKEND_PORT/" +echo +read -r -k 1 "?按任意键关闭..."