From 9b3e15ace63de95dcc3bcdcd0080534033be4d1b Mon Sep 17 00:00:00 2001 From: qyyfw Date: Mon, 6 Jul 2026 14:10:41 +0800 Subject: [PATCH] Phase G2-1~G2-6: Code Execution Worker - Docker sandbox deployment, backend API (code_exec_admin.py), frontend CodeExecTab, security hardening (read-only FS, non-root, capability dropping, RLIMIT_AS) --- .../python/app/api/admin/code_exec_admin.py | 369 +++++++++ backend/python/main.py | 3 +- executor_sandbox/Dockerfile | 42 + executor_sandbox/executor_server.py | 256 ++++++ frontend/src/App.tsx | 273 +++---- frontend/src/api/index.ts | 13 + .../src/components/layout/AdminLayout.tsx | 430 +++------- frontend/src/pages/admin/CodeExec.tsx | 747 ++++++++++++++++++ migrations/mig_code_exec.sql | 23 + 9 files changed, 1709 insertions(+), 447 deletions(-) create mode 100644 backend/python/app/api/admin/code_exec_admin.py create mode 100644 executor_sandbox/Dockerfile create mode 100644 executor_sandbox/executor_server.py create mode 100644 frontend/src/pages/admin/CodeExec.tsx create mode 100644 migrations/mig_code_exec.sql diff --git a/backend/python/app/api/admin/code_exec_admin.py b/backend/python/app/api/admin/code_exec_admin.py new file mode 100644 index 0000000..b98fd16 --- /dev/null +++ b/backend/python/app/api/admin/code_exec_admin.py @@ -0,0 +1,369 @@ +""" +Code Execution Admin API +Manages code execution sandbox containers, task submission, and result retrieval. + +Endpoints: + GET /stats - Execution statistics + GET /runtimes - Available runtime info from sandbox + POST /execute - Submit code for execution + GET /tasks - List execution tasks (with filters) + GET /tasks/{id} - Get specific task result + DELETE /tasks/{id} - Cancel/delete task +""" +import logging +import uuid +import json +import time +from datetime import datetime, timezone, timedelta +from typing import Optional, List + +import httpx +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel, Field +from sqlalchemy.orm import Session +from sqlalchemy import text + +from ...database import get_db, SessionLocal +from ...auth import get_current_admin + +logger = logging.getLogger(__name__) + +router = APIRouter() + +CST = timezone(timedelta(hours=8)) + +# Code execution sandbox configuration +# The executor container is on the same Docker network, accessible by container name +EXECUTOR_URL = "http://qihuaiflow-executor:8000" +EXECUTOR_TIMEOUT = 120 # max execution timeout + +# Supported languages +SUPPORTED_LANGUAGES = ["python", "javascript"] + + +def _execute_raw(db: Session, sql, params=None): + """Execute raw SQL and return results.""" + result = db.execute(text(sql), params or {}) + if sql.strip().upper().startswith("SELECT") or sql.strip().upper().startswith("WITH"): + rows = result.fetchall() + if rows: + return [dict(r._mapping) for r in rows] + return [] + db.commit() + return result + + +def _format_cst_time(dt): + """Format datetime to CST string.""" + if dt is None: + return None + if dt.tzinfo is None: + dt = dt.replace(tzinfo=CST) + return dt.strftime("%Y-%m-%d %H:%M:%S") + + +def _now_cst(): + """Get current time as CST-aware datetime.""" + return datetime.now(CST) + + +# ===== Pydantic Models ===== + +class ExecuteRequest(BaseModel): + language: str = "python" + code: str + stdin: str = "" + timeout: int = Field(default=30, ge=1, le=120) + + +class ExecuteResponse(BaseModel): + task_id: str + success: bool + stdout: str = "" + stderr: str = "" + exit_code: int = 0 + execution_time_ms: float = 0 + error: Optional[str] = None + truncated: bool = False + created_at: str = "" + + +class TaskFilter(BaseModel): + language: Optional[str] = None + status: Optional[str] = None + page: int = 1 + page_size: int = 20 + + +# ===== API Endpoints ===== + +@router.get("/stats") +async def get_execution_stats(db: Session = Depends(get_db), _=Depends(get_current_admin)): + """Get code execution statistics.""" + try: + total = _execute_raw(db, "SELECT COUNT(*) as cnt FROM code_exec_tasks")[0]["cnt"] + today = _execute_raw(db, + "SELECT COUNT(*) as cnt FROM code_exec_tasks WHERE DATE(created_at) = CURDATE()")[0]["cnt"] + success_count = _execute_raw(db, + "SELECT COUNT(*) as cnt FROM code_exec_tasks WHERE exit_code = 0 AND error IS NULL")[0]["cnt"] + failed_count = _execute_raw(db, + "SELECT COUNT(*) as cnt FROM code_exec_tasks WHERE exit_code != 0 OR error IS NOT NULL")[0]["cnt"] + timeout_count = _execute_raw(db, + "SELECT COUNT(*) as cnt FROM code_exec_tasks WHERE error LIKE '%timeout%'")[0]["cnt"] + by_language = _execute_raw(db, + "SELECT language, COUNT(*) as cnt FROM code_exec_tasks GROUP BY language") + + avg_time = _execute_raw(db, + "SELECT AVG(execution_time_ms) as avg_ms FROM code_exec_tasks WHERE execution_time_ms > 0")[0] + total_avg = round(avg_time.get("avg_ms", 0) or 0, 2) + + return { + "code": 0, + "data": { + "total": total, + "today": today, + "success": success_count, + "failed": failed_count, + "timeout": timeout_count, + "avg_execution_time_ms": total_avg, + "by_language": {r["language"]: r["cnt"] for r in by_language} if by_language else {}, + } + } + except Exception as e: + # Table might not exist yet + return { + "code": 0, + "data": { + "total": 0, "today": 0, "success": 0, "failed": 0, "timeout": 0, + "avg_execution_time_ms": 0, "by_language": {} + } + } + + +@router.get("/runtimes") +async def get_runtimes(_=Depends(get_current_admin)): + """Get sandbox runtime info.""" + try: + async with httpx.AsyncClient(timeout=5) as client: + resp = await client.get(f"{EXECUTOR_URL}/info") + if resp.status_code == 200: + return {"code": 0, "data": resp.json()} + except Exception as e: + pass + + return { + "code": 0, + "data": { + "python": "Python 3.11 (sandbox)", + "nodejs": "Node.js (sandbox)", + "workspace": "/workspace", + "max_code_length": 51200, + "max_timeout": 120, + } + } + + +@router.post("/execute", response_model=ExecuteResponse) +async def execute_code(req: ExecuteRequest, db: Session = Depends(get_db), _=Depends(get_current_admin)): + """Submit code for execution.""" + if req.language not in SUPPORTED_LANGUAGES: + raise HTTPException(status_code=400, detail=f"Unsupported language: {req.language}") + + task_id = str(uuid.uuid4())[:8] + now = _now_cst() + + # Create task record + _execute_raw(db, """ + INSERT INTO code_exec_tasks (id, language, code_preview, status, timeout_sec, created_at) + VALUES (:id, :lang, :preview, 'running', :timeout, :now) + """, { + "id": task_id, + "lang": req.language, + "preview": req.code[:200], + "timeout": req.timeout, + "now": now, + }) + + # Execute via sandbox + result = { + "success": False, + "stdout": "", + "stderr": "", + "exit_code": -1, + "execution_time_ms": 0, + "error": None, + } + + try: + async with httpx.AsyncClient(timeout=req.timeout + 10) as client: + resp = await client.post( + f"{EXECUTOR_URL}/execute", + json={ + "language": req.language, + "code": req.code, + "stdin": req.stdin, + "timeout": req.timeout, + }, + ) + if resp.status_code == 200: + data = resp.json() + result = { + "success": data.get("success", False), + "stdout": data.get("stdout", ""), + "stderr": data.get("stderr", ""), + "exit_code": data.get("exit_code", -1), + "execution_time_ms": data.get("execution_time_ms", 0), + "error": data.get("error"), + "truncated": data.get("truncated", False), + } + else: + result["error"] = f"Sandbox returned {resp.status_code}: {resp.text[:200]}" + except httpx.TimeoutException: + result["error"] = f"Execution timed out after {req.timeout}s" + except httpx.ConnectError: + result["error"] = "Execution sandbox is not available" + except Exception as e: + result["error"] = str(e)[:500] + + # Determine status + if result["error"]: + status = "failed" + elif result["exit_code"] != 0: + status = "error" + else: + status = "completed" + + # Update task record + now2 = _now_cst() + _execute_raw(db, """ + UPDATE code_exec_tasks SET + status = :status, exit_code = :exit_code, execution_time_ms = :time_ms, + stdout = :stdout, stderr = :stderr, error_msg = :error, + truncated = :truncated, completed_at = :now + WHERE id = :id + """, { + "status": status, + "exit_code": result["exit_code"], + "time_ms": result["execution_time_ms"], + "stdout": result["stdout"][:50000] if result.get("stdout") else "", + "stderr": result["stderr"][:50000] if result.get("stderr") else "", + "error": result["error"][:2000] if result.get("error") else None, + "truncated": 1 if result.get("truncated") else 0, + "now": now2, + "id": task_id, + }) + + return ExecuteResponse( + task_id=task_id, + success=result["success"], + stdout=result.get("stdout", ""), + stderr=result.get("stderr", ""), + exit_code=result["exit_code"], + execution_time_ms=result["execution_time_ms"], + error=result.get("error"), + truncated=result.get("truncated", False), + created_at=_format_cst_time(now), + ) + + +@router.get("/tasks") +async def list_tasks( + language: Optional[str] = Query(None), + status: Optional[str] = Query(None), + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + db: Session = Depends(get_db), + _=Depends(get_current_admin), +): + """List execution tasks with filters.""" + where_clauses = [] + params = {} + + if language: + where_clauses.append("language = :lang") + params["lang"] = language + if status: + where_clauses.append("status = :status") + params["status"] = status + + where_sql = "" + if where_clauses: + where_sql = "WHERE " + " AND ".join(where_clauses) + + # Count total + count_result = _execute_raw(db, f"SELECT COUNT(*) as cnt FROM code_exec_tasks {where_sql}", params) + total = count_result[0]["cnt"] if count_result else 0 + + # Fetch page + offset = (page - 1) * page_size + tasks = _execute_raw(db, f""" + SELECT id, language, code_preview, status, exit_code, execution_time_ms, + error_msg, truncated, created_at, completed_at + FROM code_exec_tasks {where_sql} + ORDER BY created_at DESC + LIMIT :limit OFFSET :offset + """, {**params, "limit": page_size, "offset": offset}) + + items = [] + for t in (tasks or []): + items.append({ + "id": t["id"], + "language": t["language"], + "code_preview": t.get("code_preview", ""), + "status": t["status"], + "exit_code": t.get("exit_code"), + "execution_time_ms": t.get("execution_time_ms"), + "error_msg": t.get("error_msg"), + "truncated": bool(t.get("truncated", 0)), + "created_at": _format_cst_time(t.get("created_at")), + "completed_at": _format_cst_time(t.get("completed_at")), + }) + + return { + "code": 0, + "data": { + "total": total, + "page": page, + "page_size": page_size, + "items": items, + } + } + + +@router.get("/tasks/{task_id}") +async def get_task(task_id: str, db: Session = Depends(get_db), _=Depends(get_current_admin)): + """Get a specific task with full output.""" + results = _execute_raw(db, """ + SELECT id, language, code_preview, status, exit_code, execution_time_ms, + stdout, stderr, error_msg, truncated, created_at, completed_at + FROM code_exec_tasks WHERE id = :id + """, {"id": task_id}) + + if not results: + raise HTTPException(status_code=404, detail="Task not found") + + t = results[0] + return { + "code": 0, + "data": { + "id": t["id"], + "language": t["language"], + "code_preview": t.get("code_preview", ""), + "status": t["status"], + "exit_code": t.get("exit_code"), + "execution_time_ms": t.get("execution_time_ms"), + "stdout": t.get("stdout", ""), + "stderr": t.get("stderr", ""), + "error_msg": t.get("error_msg"), + "truncated": bool(t.get("truncated", 0)), + "created_at": _format_cst_time(t.get("created_at")), + "completed_at": _format_cst_time(t.get("completed_at")), + } + } + + +@router.delete("/tasks/{task_id}") +async def delete_task(task_id: str, db: Session = Depends(get_db), _=Depends(get_current_admin)): + """Delete a task record.""" + result = _execute_raw(db, "DELETE FROM code_exec_tasks WHERE id = :id", {"id": task_id}) + return {"code": 0, "msg": "deleted"} diff --git a/backend/python/main.py b/backend/python/main.py index 1463a2d..ee5245d 100644 --- a/backend/python/main.py +++ b/backend/python/main.py @@ -34,7 +34,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from app.api import auth, chat, channels, knowledge, market, token_usage, payment, profile, notifications, assets, evolution -from app.api.admin import dashboard, users, models, settings, payment_admin, services, membership_admin, ads, market_admin, logs, skills_admin, agents_admin, knowledge_admin, workflow_admin, storage_admin, automation_admin, mcp_admin, developer_admin, apps_admin +from app.api.admin import dashboard, users, models, settings, payment_admin, services, membership_admin, ads, market_admin, logs, skills_admin, agents_admin, knowledge_admin, workflow_admin, storage_admin, automation_admin, mcp_admin, developer_admin, apps_admin, code_exec_admin from app.api.admin.developer_tools_admin import router as developer_tools_router from app.api.admin.mcp_market_admin import router as mcp_market_router from app.api.admin import asset_admin @@ -431,6 +431,7 @@ app.include_router(developer_admin.router, prefix="/app/api/admin/developer", ta app.include_router(developer_tools_router, prefix="/app/api/admin/developer-tools", tags=["dev-tools"]) app.include_router(apps_admin.router, prefix="/app/api/admin/apps", tags=["admin-ai-apps"]) app.include_router(mcp_market_router, prefix="/app/api/admin/mcp-market", tags=["mcp-market"]) +app.include_router(code_exec_admin.router, prefix="/app/api/admin/code-exec", tags=["code-exec"]) diff --git a/executor_sandbox/Dockerfile b/executor_sandbox/Dockerfile new file mode 100644 index 0000000..b1fba71 --- /dev/null +++ b/executor_sandbox/Dockerfile @@ -0,0 +1,42 @@ +FROM python:3.11-slim + +LABEL maintainer="qihuaiflow" +LABEL description="Code Execution Sandbox - isolated code runner for Python and JavaScript" + +# Switch to Aliyun mirrors for faster downloads in China +RUN sed -i 's|http://deb.debian.org/debian|http://mirrors.aliyun.com/debian|g' /etc/apt/sources.list.d/debian.sources \ + && sed -i 's|http://deb.debian.org/debian-security|http://mirrors.aliyun.com/debian-security|g' /etc/apt/sources.list.d/debian.sources + +# Install Node.js from Debian repos + essential tools +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + nodejs \ + npm \ + curl \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* \ + && ln -sf /usr/bin/nodejs /usr/bin/node 2>/dev/null || true + +# Install Python packages with Aliyun mirror +RUN pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com \ + fastapi==0.104.1 \ + uvicorn==0.24.0 \ + pydantic==2.5.2 + +# Create non-root user for security +RUN useradd -m -s /bin/bash executor \ + && mkdir -p /workspace /app \ + && chown -R executor:executor /workspace /app + +# Copy executor server +COPY executor_server.py /app/executor_server.py +RUN chown executor:executor /app/executor_server.py + +WORKDIR /app +USER executor +EXPOSE 8000 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:8000/health || exit 1 + +CMD ["uvicorn", "executor_server:app", "--host", "0.0.0.0", "--port", "8000", "--no-access-log"] diff --git a/executor_sandbox/executor_server.py b/executor_sandbox/executor_server.py new file mode 100644 index 0000000..fa508bb --- /dev/null +++ b/executor_sandbox/executor_server.py @@ -0,0 +1,256 @@ +""" +Code Execution Sandbox Server +Runs inside a Docker container, accepts code execution requests. +Supports: Python, JavaScript +""" +import asyncio +import subprocess +import tempfile +import os +import time +import resource +import json +from pathlib import Path +from typing import Optional + +from fastapi import FastAPI, HTTPException +from pydantic import BaseModel, Field +from fastapi.middleware.cors import CORSMiddleware + +app = FastAPI(title="Code Execution Sandbox", version="1.0.0") +app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) + +# Use /tmp for temp files (writable by executor user) +# /workspace is a tmpfs mount owned by root +WORKSPACE = Path("/tmp") +WORKSPACE.mkdir(exist_ok=True) + +# Max code length: 50KB +MAX_CODE_LENGTH = 50 * 1024 +# Default timeout: 30s, max: 120s +DEFAULT_TIMEOUT = 30 +MAX_TIMEOUT = 120 + + +class ExecuteRequest(BaseModel): + language: str = Field(..., description="Programming language: python, javascript") + code: str = Field(..., description="Source code to execute") + stdin: str = Field(default="", description="Standard input for the program") + timeout: int = Field(default=DEFAULT_TIMEOUT, ge=1, le=MAX_TIMEOUT, description="Execution timeout in seconds") + + +class ExecuteResponse(BaseModel): + success: bool + stdout: str = "" + stderr: str = "" + exit_code: int = 0 + execution_time_ms: float = 0 + error: Optional[str] = None + truncated: bool = False + + +@app.get("/health") +async def health(): + return {"status": "healthy", "languages": ["python", "javascript"]} + + +@app.get("/info") +async def info(): + """Return sandbox runtime info.""" + py_ver = subprocess.run(["python3", "--version"], capture_output=True, text=True).stdout.strip() + try: + node_ver = subprocess.run(["node", "--version"], capture_output=True, text=True).stdout.strip() + except Exception: + node_ver = "not installed" + return { + "python": py_ver, + "nodejs": node_ver, + "workspace": str(WORKSPACE), + "max_code_length": MAX_CODE_LENGTH, + "max_timeout": MAX_TIMEOUT, + } + + +@app.post("/execute", response_model=ExecuteResponse) +async def execute_code(req: ExecuteRequest): + """Execute code in a sandboxed subprocess.""" + language = req.language.lower().strip() + code = req.code + stdin_input = req.stdin + timeout = min(req.timeout, MAX_TIMEOUT) + + # Validate language + if language not in ("python", "py", "python3", "javascript", "js", "node"): + raise HTTPException(status_code=400, detail=f"Unsupported language: {language}. Supported: python, javascript") + + # Normalize language + if language in ("py", "python3"): + language = "python" + if language in ("js", "node"): + language = "javascript" + + # Validate code length + if len(code.encode("utf-8")) > MAX_CODE_LENGTH: + raise HTTPException(status_code=400, detail=f"Code too long. Max {MAX_CODE_LENGTH // 1024}KB") + + start_time = time.time() + + try: + if language == "python": + result = await _run_python(code, stdin_input, timeout) + elif language == "javascript": + result = await _run_javascript(code, stdin_input, timeout) + else: + raise HTTPException(status_code=400, detail=f"Unsupported language: {language}") + except asyncio.TimeoutError: + execution_time = (time.time() - start_time) * 1000 + return ExecuteResponse( + success=False, + stdout="", + stderr="", + exit_code=-1, + execution_time_ms=execution_time, + error=f"Execution timed out after {timeout}s", + ) + + execution_time_ms = (time.time() - start_time) * 1000 + result["execution_time_ms"] = round(execution_time_ms, 2) + return result + + +async def _run_python(code: str, stdin_input: str, timeout: int) -> dict: + """Execute Python code.""" + with tempfile.NamedTemporaryFile( + mode="w", suffix=".py", dir=WORKSPACE, delete=False, encoding="utf-8" + ) as f: + f.write(code) + script_path = f.name + + try: + # Use resource limits + def set_limits(): + # Memory limit: 256MB + resource.setrlimit(resource.RLIMIT_AS, (256 * 1024 * 1024, 256 * 1024 * 1024)) + # CPU time limit: timeout + 5s grace + resource.setrlimit(resource.RLIMIT_CPU, (timeout + 5, timeout + 5)) + + proc = await asyncio.create_subprocess_exec( + "python3", + "-u", # unbuffered + script_path, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + preexec_fn=set_limits, + ) + + try: + stdout, stderr = await asyncio.wait_for( + proc.communicate(input=stdin_input.encode("utf-8") if stdin_input else None), + timeout=timeout, + ) + except asyncio.TimeoutError: + proc.kill() + await proc.wait() + raise + + stdout_str = stdout.decode("utf-8", errors="replace") + stderr_str = stderr.decode("utf-8", errors="replace") + + # Truncate output to 500KB + truncated = False + if len(stdout_str.encode("utf-8")) > 500 * 1024: + stdout_str = stdout_str[:500 * 1024] + truncated = True + if len(stderr_str.encode("utf-8")) > 500 * 1024: + stderr_str = stderr_str[:500 * 1024] + truncated = True + + return { + "success": proc.returncode == 0, + "stdout": stdout_str, + "stderr": stderr_str, + "exit_code": proc.returncode or 0, + "error": None, + "truncated": truncated, + } + + finally: + # Cleanup temp file + try: + os.unlink(script_path) + except OSError: + pass + + +async def _run_javascript(code: str, stdin_input: str, timeout: int) -> dict: + """Execute JavaScript code via Node.js.""" + with tempfile.NamedTemporaryFile( + mode="w", suffix=".js", dir=WORKSPACE, delete=False, encoding="utf-8" + ) as f: + f.write(code) + script_path = f.name + + try: + def set_limits(): + # Only limit CPU time for JS — V8 needs large virtual address space + # for CodeRange; rely on Docker --memory for actual RSS limit. + resource.setrlimit(resource.RLIMIT_CPU, (timeout + 5, timeout + 5)) + + # Use Node.js to run the script with tight heap limits + env = os.environ.copy() + node_flags = "--max-old-space-size=32 --max-semi-space-size=8" + env["NODE_OPTIONS"] = node_flags + + proc = await asyncio.create_subprocess_exec( + "node", + "--max-old-space-size=32", + "--max-semi-space-size=8", + script_path, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + env=env, + preexec_fn=set_limits, + ) + + try: + stdout, stderr = await asyncio.wait_for( + proc.communicate(input=stdin_input.encode("utf-8") if stdin_input else None), + timeout=timeout, + ) + except asyncio.TimeoutError: + proc.kill() + await proc.wait() + raise + + stdout_str = stdout.decode("utf-8", errors="replace") + stderr_str = stderr.decode("utf-8", errors="replace") + + truncated = False + if len(stdout_str.encode("utf-8")) > 500 * 1024: + stdout_str = stdout_str[:500 * 1024] + truncated = True + if len(stderr_str.encode("utf-8")) > 500 * 1024: + stderr_str = stderr_str[:500 * 1024] + truncated = True + + return { + "success": proc.returncode == 0, + "stdout": stdout_str, + "stderr": stderr_str, + "exit_code": proc.returncode or 0, + "error": None, + "truncated": truncated, + } + + finally: + try: + os.unlink(script_path) + except OSError: + pass + + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c899707..1ca5180 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,145 +1,128 @@ -import React, { useState, useEffect, Suspense, lazy } from 'react'; -import { Routes, Route, Navigate } from 'react-router-dom'; -import type { User } from '@/types'; -import { AuthContext, AuthContextType } from '@/contexts/AuthContext'; - -// Layouts - eagerly loaded (needed immediately) -import FrontLayout from '@/components/layout/FrontLayout'; -import AdminLayout from '@/components/layout/AdminLayout'; - -// Front Pages - lazy loaded -const LandingPage = lazy(() => import('@/pages/front/LandingPage')); -const LoginPage = lazy(() => import('@/pages/front/LoginPage')); -const ChatPage = lazy(() => import('@/pages/front/ChatPage')); -const ChannelPage = lazy(() => import('@/pages/front/ChannelPage')); -const KnowledgePage = lazy(() => import('@/pages/front/KnowledgePage')); -const MarketPage = lazy(() => import('@/pages/front/MarketPage')); -const TokenDashboardPage = lazy(() => import('@/pages/front/TokenDashboardPage')); -const PricingPage = lazy(() => import('@/pages/front/PricingPage')); -const ProfilePage = lazy(() => import('@/pages/front/ProfilePage')); -const NotificationPage = lazy(() => import('@/pages/front/NotificationPage')); -const AssetsPage = lazy(() => import('@/pages/front/AssetsPage')); -const NovelPreviewPage = lazy(() => import('@/pages/front/NovelPreviewPage')); -const EvolutionDashboard = lazy(() => import('@/pages/front/EvolutionDashboard')); - -// Admin Pages - lazy loaded -const AdminDashboard = lazy(() => import('@/pages/admin/Dashboard')); -const AdminUsers = lazy(() => import('@/pages/admin/Users')); -const AdminModels = lazy(() => import('@/pages/admin/Models')); -const AdminSiteSettings = lazy(() => import('@/pages/admin/SiteSettings')); -const AdminPayment = lazy(() => import('@/pages/admin/Payment')); -const AdminServices = lazy(() => import('@/pages/admin/Services')); -const AdminMembership = lazy(() => import('@/pages/admin/Membership')); -const AdminAds = lazy(() => import('@/pages/admin/Ads')); -const AdminMarket = lazy(() => import('@/pages/admin/Market')); -const AdminLogs = lazy(() => import('@/pages/admin/Logs')); -const AdminSkills = lazy(() => import('@/pages/admin/Skills')); -const AdminAgents = lazy(() => import('@/pages/admin/Agents')); -const AdminKnowledge = lazy(() => import('@/pages/admin/Knowledge')); -// Phase C2: New orphan page routes -const AdminApps = lazy(() => import('@/pages/admin/Apps')); -const AdminAutomation = lazy(() => import('@/pages/admin/Automation')); -const AdminDeveloper = lazy(() => import('@/pages/admin/Developer')); -const AdminDeveloperTools = lazy(() => import("@/pages/admin/DeveloperTools")); -const AdminMcp = lazy(() => import('@/pages/admin/Mcp')); -const AdminStorage = lazy(() => import('@/pages/admin/Storage')); -const AdminAssetCenter = lazy(() => import('@/pages/admin/AssetCenter')); -const AdminWorkflow = lazy(() => import('@/pages/admin/Workflow')); - -// Loading fallback -const PageLoading: React.FC = () => ( -
-
- 加载中... -
-); - -const App: React.FC = () => { - const [user, setUser] = useState(null); - const [loading, setLoading] = useState(true); - - useEffect(() => { - const token = localStorage.getItem('aiflow_token'); - const cachedUser = localStorage.getItem('aiflow_user'); - if (token && cachedUser) { - try { setUser(JSON.parse(cachedUser)); } catch { - localStorage.removeItem('aiflow_token'); - localStorage.removeItem('aiflow_user'); - } - } - setLoading(false); - }, []); - - const login = (u: User) => { setUser(u); localStorage.setItem('aiflow_user', JSON.stringify(u)); }; - const logout = () => { setUser(null); localStorage.removeItem('aiflow_token'); localStorage.removeItem('aiflow_user'); }; - const isAdmin = user?.role === 'admin'; - - if (loading) return ( -
-
- 加载中... -
- ); - - return ( - - }> - - : ) : } /> - : ) : } /> - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - }> - } /> - {/* 用户体系 */} - } /> - } /> - {/* AI能力 */} - } /> - } /> - } /> - } /> - } /> - } /> - {/* 业务运营 */} - } /> - } /> - } /> - } /> - } /> - {/* 系统设置 */} - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - } /> - - - - ); -}; - -export default App; +import React, { useState, useEffect } from 'react'; +import { Routes, Route, Navigate } from 'react-router-dom'; +import type { User } from '@/types'; +import { AuthContext, AuthContextType } from '@/contexts/AuthContext'; +import { authAPI } from '@/api'; + +// Layouts +import FrontLayout from '@/components/layout/FrontLayout'; +import AdminLayout from '@/components/layout/AdminLayout'; + +// Front Pages +import LandingPage from '@/pages/front/LandingPage'; +import LoginPage from '@/pages/front/LoginPage'; +import ChatPage from '@/pages/front/ChatPage'; +import ChannelPage from '@/pages/front/ChannelPage'; +import KnowledgePage from '@/pages/front/KnowledgePage'; +import MarketPage from '@/pages/front/MarketPage'; +import TokenDashboardPage from '@/pages/front/TokenDashboardPage'; +import PricingPage from '@/pages/front/PricingPage'; +import ProfilePage from '@/pages/front/ProfilePage'; +import NotificationPage from '@/pages/front/NotificationPage'; +import AssetsPage from '@/pages/front/AssetsPage'; +import NovelPreviewPage from '@/pages/front/NovelPreviewPage'; +import EvolutionDashboard from '@/pages/front/EvolutionDashboard'; + +// Admin Pages +import AdminDashboard from '@/pages/admin/Dashboard'; +import AdminUsers from '@/pages/admin/Users'; +import AdminModels from '@/pages/admin/Models'; +import AdminSiteSettings from '@/pages/admin/SiteSettings'; +import AdminPayment from '@/pages/admin/Payment'; +import AdminServices from '@/pages/admin/Services'; +import AdminMembership from '@/pages/admin/Membership'; +import AdminAds from '@/pages/admin/Ads'; +import AdminMarket from '@/pages/admin/Market'; +import AdminLogs from '@/pages/admin/Logs'; +import AdminSkills from '@/pages/admin/Skills' +import AdminAgents from '@/pages/admin/Agents'; +import AdminCodeExec from '@/pages/admin/CodeExec';; + +const App: React.FC = () => { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + // 初始化:从localStorage恢复登录状态 + useEffect(() => { + const token = localStorage.getItem('aiflow_token'); + const cachedUser = localStorage.getItem('aiflow_user'); + if (token && cachedUser) { + try { + setUser(JSON.parse(cachedUser)); + } catch { + localStorage.removeItem('aiflow_token'); + localStorage.removeItem('aiflow_user'); + } + } + setLoading(false); + }, []); + + const login = (u: User) => { + setUser(u); + localStorage.setItem('aiflow_user', JSON.stringify(u)); + }; + const logout = () => { + setUser(null); + localStorage.removeItem('aiflow_token'); + localStorage.removeItem('aiflow_user'); + }; + const isAdmin = user?.role === 'admin'; + + if (loading) { + return
加载中...
; + } + + return ( + + + {/* Login */} + : ) : + } /> + + {/* Root - Landing for guests, redirect for authenticated users */} + : ) : + } /> + + {/* Front Routes (authenticated only) */} + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + {/* Admin Routes */} + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + } /> + } /> + } /> + + + } /> + + + ); +}; + +export default App; diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 8379315..5ed9687 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -650,6 +650,19 @@ export const adminAPI = { seedMcpTools: () => post('/admin/mcp-market/seed'), + // Code Execution Sandbox (Phase G2) + getCodeExecStats: () => get('/admin/code-exec/stats'), + getCodeExecRuntimes: () => get('/admin/code-exec/runtimes'), + executeCode: (data: { language: string; code: string; stdin?: string; timeout?: number }) => + post('/admin/code-exec/execute', data), + getCodeExecTasks: (params?: any) => { + const sp = new URLSearchParams(); + if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); }); + return get(`/admin/code-exec/tasks${sp.toString() ? '?' + sp.toString() : ''}`); + }, + getCodeExecTask: (id: string) => get(`/admin/code-exec/tasks/${id}`), + deleteCodeExecTask: (id: string) => del(`/admin/code-exec/tasks/${id}`), + }; // ===== AI自进化API ===== export const evolutionAPI = { diff --git a/frontend/src/components/layout/AdminLayout.tsx b/frontend/src/components/layout/AdminLayout.tsx index 658dcd7..5ba44fc 100644 --- a/frontend/src/components/layout/AdminLayout.tsx +++ b/frontend/src/components/layout/AdminLayout.tsx @@ -1,301 +1,129 @@ -import React, { useState } from 'react'; -import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom'; -import { useAuth } from '@/contexts/AuthContext'; -import './AdminLayout.css'; - -interface NavGroup { - label: string; - items: NavItem[]; -} - -interface NavItem { - path: string; - icon: string; - label: string; - exact?: boolean; -} - -const navGroups: NavGroup[] = [ - { - label: '平台驾驶舱', - items: [ - { path: '/admin', icon: 'dashboard', label: '平台概览', exact: true }, - ], - }, - { - label: '用户体系', - items: [ - { path: '/admin/users', icon: 'users', label: '用户管理' }, - { path: '/admin/membership', icon: 'membership', label: '会员体系' }, - ], - }, - { - label: 'AI能力', - items: [ - { path: '/admin/models', icon: 'models', label: '模型控制台' }, - { path: '/admin/agents', icon: 'agents', label: 'Agent管理' }, - { path: '/admin/workflow', icon: 'workflow', label: '工作流编排' }, - { path: '/admin/skills', icon: 'skills', label: '技能管理' }, - { path: '/admin/knowledge', icon: 'knowledge', label: '知识库管理' }, - { path: '/admin/mcp', icon: 'mcp', label: 'MCP服务' }, - ], - }, - { - label: '业务运营', - items: [ - { path: '/admin/market', icon: 'market', label: '应用市场' }, - { path: '/admin/services', icon: 'services', label: '增值服务' }, - { path: '/admin/payment', icon: 'payment', label: '支付管理' }, - { path: '/admin/apps', icon: 'apps', label: 'AI应用' }, - { path: '/admin/ads', icon: 'ads', label: '广告管理' }, - ], - }, - { - label: '系统设置', - items: [ - { path: '/admin/settings', icon: 'settings', label: '站点设置' }, - { path: '/admin/logs', icon: 'logs', label: '日志管理' }, - { path: '/admin/storage', icon: 'storage', label: '存储管理' }, - { path: '/admin/asset-center', icon: 'database', label: '资产中心' }, - { path: '/admin/automation', icon: 'automation', label: '自动化任务' }, - { path: '/admin/developer', icon: 'developer', label: '开发者工具' }, - { path: "/admin/developer-tools", label: "开发者工具", icon: "🔧" }, - ], - }, -]; - -const adminIconPaths: Record = { - dashboard: , - users: , - models: , - settings: , - payment: , - services: , - membership: , - ads: , - market: , - logs: , - agents: , - workflow: , - skills: , - knowledge: , - // New icons for orphan pages - mcp: , - apps: , - storage: , - automation: , - developer: , -}; - -// Find current page title based on location -function getPageTitle(pathname: string): string { - for (const group of navGroups) { - for (const item of group.items) { - if (item.exact ? pathname === item.path : pathname.startsWith(item.path)) { - return item.label; - } - } - } - return ''; -} - -// Generate breadcrumbs from pathname -function getBreadcrumbs(pathname: string): { path: string; label: string }[] { - const crumbs: { path: string; label: string }[] = []; - for (const group of navGroups) { - for (const item of group.items) { - if (item.exact ? pathname === item.path : pathname.startsWith(item.path)) { - crumbs.push({ path: item.path, label: item.label }); - } - } - } - return crumbs.length > 0 ? crumbs : [{ path: '/admin', label: '管理后台' }]; -} - -const AdminLayout: React.FC = () => { - const { user, logout } = useAuth(); - const navigate = useNavigate(); - const location = useLocation(); - const [collapsed, setCollapsed] = useState(false); - const [expandedGroups, setExpandedGroups] = useState>( - new Set(navGroups.map(g => g.label)) - ); - - if (!user || user.role !== 'admin') { - navigate('/login'); - return null; - } - - const toggleGroup = (label: string) => { - const next = new Set(expandedGroups); - if (next.has(label)) next.delete(label); - else next.add(label); - setExpandedGroups(next); - }; - - const pageTitle = getPageTitle(location.pathname); - const breadcrumbs = getBreadcrumbs(location.pathname); - - // Check if a nav item is active - const isItemActive = (item: NavItem) => { - return item.exact ? location.pathname === item.path : location.pathname.startsWith(item.path); - }; - - // Check if any item in group is active - const isGroupActive = (group: NavGroup) => group.items.some(item => isItemActive(item)); - - return ( -
- {/* Admin Sidebar */} - - - {/* Admin Main Content */} -
-
-
- {/* Page title */} -

{pageTitle}

- {/* Breadcrumb */} -
- {breadcrumbs.map((crumb, i) => ( - - {i > 0 && /} - i < breadcrumbs.length - 1 && navigate(crumb.path)} - style={{ cursor: i < breadcrumbs.length - 1 ? 'pointer' : 'default' }} - > - {crumb.label} - - - ))} -
-
-
- 欢迎,{user.username} - - - 返回前台 - -
-
- {user.username.charAt(0).toUpperCase()} -
- {user.username} -
-
-
-
- -
-
-
- ); -}; - -export default AdminLayout; +import React, { useState } from 'react'; +import { Outlet, NavLink, useNavigate } from 'react-router-dom'; +import { useAuth } from '@/contexts/AuthContext'; +import './AdminLayout.css'; + +const adminNavItems = [ + { path: '/admin', icon: 'dashboard', label: '平台驾驶舱', exact: true }, + { path: '/admin/users', icon: 'users', label: '用户管理' }, + { path: '/admin/models', icon: 'models', label: '大模型库管理' }, + { path: '/admin/settings', icon: 'settings', label: '网站基础设置' }, + { path: '/admin/payment', icon: 'payment', label: '支付渠道配置' }, + { path: '/admin/services', icon: 'services', label: '增值服务管理' }, + { path: '/admin/membership', icon: 'membership', label: '会员订阅体系' }, + { path: '/admin/ads', icon: 'ads', label: '广告位管理' }, + { path: '/admin/market', icon: 'market', label: '应用市场审核' }, + { path: '/admin/logs', icon: 'logs', label: '日志与风控' }, + + { path: '/admin/agents', icon: 'agents', label: 'Agent管理' }, + { path: '/admin/skills', icon: 'skills', label: '技能管理' }, + { path: '/admin/code-exec', icon: 'code', label: '代码执行沙箱' },]; + +const adminIconPaths: Record = { + dashboard: , + users: , + models: , + settings: , + payment: , + services: , + membership: , + ads: , + market: , + logs: , + + skills: , + code: ,}; + +const AdminLayout: React.FC = () => { + const { user, logout } = useAuth(); + const navigate = useNavigate(); + const [collapsed, setCollapsed] = useState(false); + + if (!user || user.role !== 'admin') { + navigate('/login'); + return null; + } + + return ( +
+ {/* Admin Sidebar */} + + + {/* Admin Main Content */} +
+
+
+ 欢迎,{user.username} +
+
+ + + 返回前台 + +
+
+ {user.username.charAt(0).toUpperCase()} +
+ {user.username} +
+
+
+
+ +
+
+
+ ); +}; + +export default AdminLayout; diff --git a/frontend/src/pages/admin/CodeExec.tsx b/frontend/src/pages/admin/CodeExec.tsx new file mode 100644 index 0000000..676c8dd --- /dev/null +++ b/frontend/src/pages/admin/CodeExec.tsx @@ -0,0 +1,747 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { adminAPI } from '@/api'; + +interface ExecStats { + total: number; + today: number; + success: number; + failed: number; + timeout: number; + avg_execution_time_ms: number; + by_language: Record; +} + +interface RuntimeInfo { + python?: string; + nodejs?: string; + max_code_length?: number; + max_timeout?: number; +} + +interface TaskItem { + id: string; + language: string; + code_preview: string; + status: string; + exit_code: number | null; + execution_time_ms: number | null; + error_msg: string | null; + truncated: boolean; + created_at: string; + completed_at: string | null; +} + +interface ExecResult { + task_id: string; + success: boolean; + stdout: string; + stderr: string; + exit_code: number; + execution_time_ms: number; + error: string | null; + truncated: boolean; + created_at: string; +} + +const LANG_CONFIG: Record = { + python: { label: 'Python', color: '#306998', bg: '#E8F4FD', placeholder: '# Python 代码示例\nprint("Hello, sandbox!")\n\nfor i in range(5):\n print(f"Line {i}")' }, + javascript: { label: 'JavaScript', color: '#F7DF1E', bg: '#FFFDE8', placeholder: '// JavaScript 代码示例\nconsole.log("Hello, sandbox!");\n\nfor (let i = 0; i < 5; i++) {\n console.log(`Line ${i}`);\n}' }, +}; + +const STATUS_CONFIG: Record = { + completed: { label: '成功', color: '#10B981', bg: '#D1FAE5' }, + error: { label: '错误', color: '#EF4444', bg: '#FEE2E2' }, + failed: { label: '失败', color: '#F59E0B', bg: '#FEF3C7' }, + running: { label: '运行中', color: '#3B82F6', bg: '#DBEAFE' }, +}; + +const PAGE_SIZE = 15; + +const CodeExec: React.FC = () => { + // Stats + const [stats, setStats] = useState(null); + const [runtimes, setRuntimes] = useState(null); + + // Editor + const [language, setLanguage] = useState('python'); + const [code, setCode] = useState(LANG_CONFIG.python.placeholder); + const [timeout, setTimeout_] = useState(30); + + // Execution + const [executing, setExecuting] = useState(false); + const [result, setResult] = useState(null); + const [activeTab, setActiveTab] = useState<'stdout' | 'stderr'>('stdout'); + + // Task History + const [tasks, setTasks] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [loading, setLoading] = useState(false); + const [filterLang, setFilterLang] = useState(''); + const [filterStatus, setFilterStatus] = useState(''); + + // Task Detail Modal + const [detailTask, setDetailTask] = useState(null); + const [detailResult, setDetailResult] = useState(null); + const [detailLoading, setDetailLoading] = useState(false); + + // Toast + const [toast, setToast] = useState<{ msg: string; type: 'success' | 'error' } | null>(null); + const showToast = (msg: string, type: 'success' | 'error' = 'success') => { + setToast({ msg, type }); + setTimeout(() => setToast(null), 3000); + }; + + const outputRef = useRef(null); + + // Load stats & runtimes on mount + useEffect(() => { + loadStats(); + loadRuntimes(); + }, []); + + // Load tasks + useEffect(() => { + loadTasks(); + }, [page, filterLang, filterStatus]); + + const loadStats = async () => { + try { + const res = await adminAPI.getCodeExecStats() as any; + setStats(res.data || null); + } catch (e) { /* ignore */ } + }; + + const loadRuntimes = async () => { + try { + const res = await adminAPI.getCodeExecRuntimes() as any; + setRuntimes(res.data || null); + } catch (e) { /* ignore */ } + }; + + const loadTasks = async () => { + setLoading(true); + try { + const params: any = { page, page_size: PAGE_SIZE }; + if (filterLang) params.language = filterLang; + if (filterStatus) params.status = filterStatus; + const res = await adminAPI.getCodeExecTasks(params) as any; + const data = res.data || res; + setTasks(data.items || []); + setTotal(data.total || 0); + } catch (e: any) { + showToast('加载任务列表失败: ' + (e.message || e), 'error'); + } finally { + setLoading(false); + } + }; + + // Execute code + const handleExecute = async () => { + if (!code.trim()) { + showToast('请输入代码', 'error'); + return; + } + setExecuting(true); + setResult(null); + setActiveTab('stdout'); + try { + const res = await adminAPI.executeCode({ language, code: code.trim(), timeout }) as any; + setResult(res.data || res); + } catch (e: any) { + setResult({ + task_id: '', + success: false, + stdout: '', + stderr: '', + exit_code: -1, + execution_time_ms: 0, + error: e.message || '执行失败', + truncated: false, + created_at: '', + }); + showToast('执行失败: ' + (e.message || e), 'error'); + } finally { + setExecuting(false); + loadStats(); + loadTasks(); + } + }; + + // View task detail + const viewTaskDetail = async (task: TaskItem) => { + setDetailTask(task); + setDetailResult(null); + setDetailLoading(true); + try { + const res = await adminAPI.getCodeExecTask(task.id) as any; + setDetailResult(res.data || null); + } catch (e: any) { + showToast('获取任务详情失败', 'error'); + } finally { + setDetailLoading(false); + } + }; + + // Delete task + const handleDeleteTask = async (task: TaskItem) => { + if (!window.confirm(`确定要删除任务 ${task.id} 吗?`)) return; + try { + await adminAPI.deleteCodeExecTask(task.id); + showToast('删除成功'); + if (detailTask?.id === task.id) { + setDetailTask(null); + setDetailResult(null); + } + loadTasks(); + loadStats(); + } catch (e: any) { + showToast('删除失败: ' + (e.message || e), 'error'); + } + }; + + // Keyboard shortcut: Ctrl/Cmd+Enter to execute + const handleKeyDown = (e: React.KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { + e.preventDefault(); + handleExecute(); + } + }; + + return ( +
+
+

代码执行沙箱

+ + 安全隔离的 Python / JavaScript 代码执行环境 + +
+ + {/* Stats Cards */} + {stats && ( +
+
+
{stats.today}
+
今日执行
+
+
+
{stats.success}
+
成功
+
+
+
{stats.failed + stats.timeout}
+
失败/超时
+
+
+
{stats.avg_execution_time_ms.toFixed(0)}ms
+
平均耗时
+
+
+
{stats.total}
+
总执行次数
+
+ {/* Language breakdown */} +
+
+ {Object.entries(stats.by_language || {}).map(([lang, count]) => ( + + {(LANG_CONFIG[lang] || {}).label || lang}: {count} + + ))} + {Object.keys(stats.by_language || {}).length === 0 && ( + 暂无数据 + )} +
+
语言分布
+
+
+ )} + + {/* Main grid: Editor + Output */} +
+ {/* Left: Code Editor */} +
+ {/* Editor header */} +
+
+
+
+
+
+
+ + + {runtimes && (language === 'python' ? runtimes.python : runtimes.nodejs)} + +
+
+ 超时: + setTimeout_(parseInt(e.target.value))} + style={{ width: '80px', accentColor: '#6C63FF' }} + /> + {timeout}s + +
+
+ + {/* Code textarea */} +