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)
This commit is contained in:
1 parent
909c013e2d
commit
9b3e15ace6
9 files changed
+1550
-288
No files matched your search
@@ -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"}
|
||||
@@ -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"])
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
+70
-87
@@ -1,72 +1,55 @@
|
||||
import React, { useState, useEffect, Suspense, lazy } from 'react';
|
||||
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 - eagerly loaded (needed immediately)
|
||||
// Layouts
|
||||
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'));
|
||||
// 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 - 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 = () => (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: '60px 20px', color: 'var(--color-text-tertiary)', fontSize: '14px',
|
||||
gap: '12px',
|
||||
}}>
|
||||
<div className="audit-spinner" />
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
);
|
||||
// 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<User | null>(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 {
|
||||
try {
|
||||
setUser(JSON.parse(cachedUser));
|
||||
} catch {
|
||||
localStorage.removeItem('aiflow_token');
|
||||
localStorage.removeItem('aiflow_user');
|
||||
}
|
||||
@@ -74,26 +57,35 @@ const App: React.FC = () => {
|
||||
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 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 (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
height: '100vh', color: '#6C63FF', flexDirection: 'column', gap: 16,
|
||||
}}>
|
||||
<div className="audit-spinner" style={{ width: 40, height: 40 }} />
|
||||
<span style={{ fontSize: 14, color: '#9CA3AF' }}>加载中...</span>
|
||||
</div>
|
||||
);
|
||||
if (loading) {
|
||||
return <div className="loading-screen">加载中...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, login, logout, isAdmin }}>
|
||||
<Suspense fallback={<PageLoading />}>
|
||||
<Routes>
|
||||
<Route path="/login" element={user ? (isAdmin ? <Navigate to="/admin" /> : <Navigate to="/chat" />) : <LoginPage />} />
|
||||
<Route path="/" element={user ? (isAdmin ? <Navigate to="/admin" /> : <Navigate to="/chat" />) : <LandingPage />} />
|
||||
{/* Login */}
|
||||
<Route path="/login" element={
|
||||
user ? (isAdmin ? <Navigate to="/admin" /> : <Navigate to="/chat" />) : <LoginPage />
|
||||
} />
|
||||
|
||||
{/* Root - Landing for guests, redirect for authenticated users */}
|
||||
<Route path="/" element={
|
||||
user ? (isAdmin ? <Navigate to="/admin" /> : <Navigate to="/chat" />) : <LandingPage />
|
||||
} />
|
||||
|
||||
{/* Front Routes (authenticated only) */}
|
||||
<Route path="/" element={<FrontLayout />}>
|
||||
<Route path="chat" element={<ChatPage />} />
|
||||
<Route path="chat/:id" element={<ChatPage />} />
|
||||
@@ -108,36 +100,27 @@ const App: React.FC = () => {
|
||||
<Route path="assets/novel/:id" element={<NovelPreviewPage />} />
|
||||
<Route path="evolution" element={<EvolutionDashboard />} />
|
||||
</Route>
|
||||
|
||||
{/* Admin Routes */}
|
||||
<Route path="/admin" element={<AdminLayout />}>
|
||||
<Route index element={<AdminDashboard />} />
|
||||
{/* 用户体系 */}
|
||||
<Route path="users" element={<AdminUsers />} />
|
||||
<Route path="membership" element={<AdminMembership />} />
|
||||
{/* AI能力 */}
|
||||
<Route path="models" element={<AdminModels />} />
|
||||
<Route path="agents" element={<AdminAgents />} />
|
||||
<Route path="workflow" element={<AdminWorkflow />} />
|
||||
<Route path="skills" element={<AdminSkills />} />
|
||||
<Route path="knowledge" element={<AdminKnowledge />} />
|
||||
<Route path="mcp" element={<AdminMcp />} />
|
||||
{/* 业务运营 */}
|
||||
<Route path="market" element={<AdminMarket />} />
|
||||
<Route path="services" element={<AdminServices />} />
|
||||
<Route path="payment" element={<AdminPayment />} />
|
||||
<Route path="apps" element={<AdminApps />} />
|
||||
<Route path="ads" element={<AdminAds />} />
|
||||
{/* 系统设置 */}
|
||||
<Route path="settings" element={<AdminSiteSettings />} />
|
||||
<Route path="payment" element={<AdminPayment />} />
|
||||
<Route path="services" element={<AdminServices />} />
|
||||
<Route path="membership" element={<AdminMembership />} />
|
||||
<Route path="ads" element={<AdminAds />} />
|
||||
<Route path="market" element={<AdminMarket />} />
|
||||
<Route path="logs" element={<AdminLogs />} />
|
||||
<Route path="storage" element={<AdminStorage />} />
|
||||
<Route path="asset-center" element={<AdminAssetCenter />} />
|
||||
<Route path="automation" element={<AdminAutomation />} />
|
||||
<Route path="developer" element={<AdminDeveloper />} />
|
||||
<Route path="developer-tools" element={<AdminDeveloperTools />} />
|
||||
|
||||
<Route path="skills" element={<AdminSkills />} />
|
||||
<Route path="agents" element={<AdminAgents />} />
|
||||
<Route path="code-exec" element={<AdminCodeExec />} />
|
||||
</Route>
|
||||
|
||||
<Route path="*" element={<Navigate to="/" />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -650,6 +650,19 @@ export const adminAPI = {
|
||||
seedMcpTools: () =>
|
||||
post<any>('/admin/mcp-market/seed'),
|
||||
|
||||
// Code Execution Sandbox (Phase G2)
|
||||
getCodeExecStats: () => get<any>('/admin/code-exec/stats'),
|
||||
getCodeExecRuntimes: () => get<any>('/admin/code-exec/runtimes'),
|
||||
executeCode: (data: { language: string; code: string; stdin?: string; timeout?: number }) =>
|
||||
post<any>('/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<any>(`/admin/code-exec/tasks${sp.toString() ? '?' + sp.toString() : ''}`);
|
||||
},
|
||||
getCodeExecTask: (id: string) => get<any>(`/admin/code-exec/tasks/${id}`),
|
||||
deleteCodeExecTask: (id: string) => del<any>(`/admin/code-exec/tasks/${id}`),
|
||||
|
||||
};
|
||||
// ===== AI自进化API =====
|
||||
export const evolutionAPI = {
|
||||
|
||||
@@ -1,68 +1,23 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { Outlet, NavLink, useNavigate } 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: [
|
||||
const adminNavItems = [
|
||||
{ path: '/admin', icon: 'dashboard', label: '平台驾驶舱', exact: true },
|
||||
{ path: '/admin/users', icon: 'users', label: '用户管理' },
|
||||
{ path: '/admin/membership', icon: 'membership', label: '会员体系' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'AI能力',
|
||||
items: [
|
||||
{ path: '/admin/models', icon: 'models', 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/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: "🔧" },
|
||||
],
|
||||
},
|
||||
];
|
||||
{ path: '/admin/code-exec', icon: 'code', label: '代码执行沙箱' },];
|
||||
|
||||
const adminIconPaths: Record<string, JSX.Element> = {
|
||||
dashboard: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>,
|
||||
@@ -74,76 +29,21 @@ const adminIconPaths: Record<string, JSX.Element> = {
|
||||
membership: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></svg>,
|
||||
ads: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>,
|
||||
market: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="7" height="7" rx="1.2"/><rect x="14" y="3" width="7" height="7" rx="1.2"/><rect x="3" y="14" width="7" height="7" rx="1.2"/><rect x="14" y="14" width="7" height="7" rx="1.2"/></svg>,
|
||||
logs: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>,
|
||||
agents: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="10" r="3"/><path d="M12 2a8 8 0 0 0-8 8c0 5.4 8 12 8 12s8-6.6 8-12a8 8 0 0 0-8-8z"/></svg>,
|
||||
workflow: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="6" height="6" rx="1"/><rect x="15" y="15" width="6" height="6" rx="1"/><path d="M9 6h6a3 3 0 0 1 3 3v6"/></svg>,
|
||||
logs: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>,
|
||||
|
||||
skills: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>,
|
||||
knowledge: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/><line x1="8" y1="7" x2="16" y2="7"/><line x1="8" y1="11" x2="14" y2="11"/></svg>,
|
||||
// New icons for orphan pages
|
||||
mcp: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>,
|
||||
apps: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="2" width="6" height="6" rx="1"/><rect x="9" y="2" width="6" height="6" rx="1"/><rect x="16" y="2" width="6" height="6" rx="1"/><rect x="2" y="9" width="6" height="6" rx="1"/><rect x="9" y="9" width="6" height="6" rx="1"/><rect x="16" y="9" width="6" height="6" rx="1"/><rect x="2" y="16" width="6" height="6" rx="1"/><rect x="9" y="16" width="6" height="6" rx="1"/><rect x="16" y="16" width="6" height="6" rx="1"/></svg>,
|
||||
storage: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>,
|
||||
automation: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>,
|
||||
developer: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>,
|
||||
};
|
||||
|
||||
// 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: '管理后台' }];
|
||||
}
|
||||
code: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>,};
|
||||
|
||||
const AdminLayout: React.FC = () => {
|
||||
const { user, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(
|
||||
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 (
|
||||
<div className="admin-layout">
|
||||
{/* Admin Sidebar */}
|
||||
@@ -151,68 +51,21 @@ const AdminLayout: React.FC = () => {
|
||||
<div className="admin-sidebar-header">
|
||||
<div className="sidebar-logo">
|
||||
<svg width="28" height="28" viewBox="0 0 32 32" fill="none">
|
||||
<rect width="32" height="32" rx="8" fill="url(#logoGrad)"/>
|
||||
<defs>
|
||||
<linearGradient id="logoGrad" x1="0" y1="0" x2="32" y2="32">
|
||||
<stop stopColor="#6C63FF"/>
|
||||
<stop offset="1" stopColor="#A78BFA"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d="M8 20V12l4 4 4-4v8" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none"/>
|
||||
<path d="M6 22h20" stroke="#fff" strokeWidth="1.5" strokeLinecap="round" opacity="0.5"/>
|
||||
<rect width="32" height="32" rx="8" fill="#1A1A2E"/>
|
||||
<path d="M8 20V12l4 4 4-4v8" stroke="#6C63FF" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none"/>
|
||||
<path d="M6 22h20" stroke="#6C63FF" strokeWidth="1.5" strokeLinecap="round" opacity="0.5"/>
|
||||
</svg>
|
||||
{!collapsed && <span className="sidebar-brand admin-brand">Qihu-AIFlow</span>}
|
||||
{!collapsed && <span className="sidebar-brand admin-brand">Qihu-AIFlow 管理后台</span>}
|
||||
</div>
|
||||
<button className="collapse-btn" onClick={() => setCollapsed(!collapsed)} title={collapsed ? '展开菜单' : '折叠菜单'}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
{collapsed
|
||||
? <><polyline points="13 17 18 12 13 7"/><polyline points="6 17 11 12 6 7"/></>
|
||||
: <><polyline points="11 17 6 12 11 7"/><polyline points="18 17 13 12 18 7"/></>
|
||||
}
|
||||
<button className="collapse-btn" onClick={() => setCollapsed(!collapsed)}>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="18" x2="21" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav className="admin-nav">
|
||||
{navGroups.map(group => {
|
||||
if (collapsed) {
|
||||
// Collapsed mode: show just icons
|
||||
return (
|
||||
<div key={group.label} className="nav-group">
|
||||
{group.items.map(item => (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
end={item.exact}
|
||||
className={({ isActive }) => `admin-nav-item ${isActive ? 'active' : ''}`}
|
||||
title={item.label}
|
||||
>
|
||||
<span className="nav-icon">{adminIconPaths[item.icon]}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={group.label} className={`nav-group ${isGroupActive(group) ? 'active-group' : ''}`}>
|
||||
<div
|
||||
className="nav-group-header"
|
||||
onClick={() => toggleGroup(group.label)}
|
||||
>
|
||||
<span className="nav-group-label">{group.label}</span>
|
||||
<svg
|
||||
width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
|
||||
className={`nav-group-chevron ${expandedGroups.has(group.label) ? 'open' : ''}`}
|
||||
>
|
||||
<polyline points="9 18 15 12 9 6"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
className={`nav-group-items ${expandedGroups.has(group.label) ? '' : 'collapsed'}`}
|
||||
>
|
||||
{group.items.map(item => (
|
||||
{adminNavItems.map(item => (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
@@ -220,22 +73,15 @@ const AdminLayout: React.FC = () => {
|
||||
className={({ isActive }) => `admin-nav-item ${isActive ? 'active' : ''}`}
|
||||
>
|
||||
<span className="nav-icon">{adminIconPaths[item.icon]}</span>
|
||||
<span className="nav-label">{item.label}</span>
|
||||
{isItemActive(item) && <span className="nav-active-dot" />}
|
||||
{!collapsed && <span className="nav-label">{item.label}</span>}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Sidebar bottom area */}
|
||||
<div className="admin-sidebar-footer">
|
||||
{/* Links row */}
|
||||
<NavLink to="/" className="admin-nav-item">
|
||||
<NavLink to="/chat" className="admin-nav-item">
|
||||
<span className="nav-icon">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
||||
</span>
|
||||
{!collapsed && <span className="nav-label">返回前台</span>}
|
||||
</NavLink>
|
||||
@@ -246,7 +92,6 @@ const AdminLayout: React.FC = () => {
|
||||
{!collapsed && <span className="nav-label">退出登录</span>}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!collapsed && (
|
||||
<div className="admin-version-info">
|
||||
Qihu-AIFlow v2.0.0
|
||||
@@ -258,26 +103,9 @@ const AdminLayout: React.FC = () => {
|
||||
<main className="admin-main">
|
||||
<header className="admin-topbar">
|
||||
<div className="admin-topbar-left">
|
||||
{/* Page title */}
|
||||
<h1 className="admin-page-title">{pageTitle}</h1>
|
||||
{/* Breadcrumb */}
|
||||
<div className="admin-breadcrumb">
|
||||
{breadcrumbs.map((crumb, i) => (
|
||||
<span key={crumb.path}>
|
||||
{i > 0 && <span className="breadcrumb-sep">/</span>}
|
||||
<span
|
||||
className={`breadcrumb-item ${i === breadcrumbs.length - 1 ? 'current' : ''}`}
|
||||
onClick={() => i < breadcrumbs.length - 1 && navigate(crumb.path)}
|
||||
style={{ cursor: i < breadcrumbs.length - 1 ? 'pointer' : 'default' }}
|
||||
>
|
||||
{crumb.label}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<span className="admin-welcome">欢迎,{user.username}</span>
|
||||
</div>
|
||||
<div className="admin-topbar-right">
|
||||
<span className="admin-welcome">欢迎,{user.username}</span>
|
||||
<a href="#/chat" className="admin-topbar-action">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
||||
<span>返回前台</span>
|
||||
|
||||
@@ -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<string, number>;
|
||||
}
|
||||
|
||||
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<string, { label: string; color: string; bg: string; placeholder: string }> = {
|
||||
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<string, { label: string; color: string; bg: string }> = {
|
||||
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<ExecStats | null>(null);
|
||||
const [runtimes, setRuntimes] = useState<RuntimeInfo | null>(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<ExecResult | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<'stdout' | 'stderr'>('stdout');
|
||||
|
||||
// Task History
|
||||
const [tasks, setTasks] = useState<TaskItem[]>([]);
|
||||
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<TaskItem | null>(null);
|
||||
const [detailResult, setDetailResult] = useState<ExecResult | null>(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<HTMLDivElement>(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 (
|
||||
<div className="admin-page">
|
||||
<div className="admin-page-header">
|
||||
<h2>代码执行沙箱</h2>
|
||||
<span style={{ fontSize: '13px', color: '#6B7280', marginLeft: '12px' }}>
|
||||
安全隔离的 Python / JavaScript 代码执行环境
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
{stats && (
|
||||
<div className="stats-grid" style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: '12px', marginBottom: '20px' }}>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{stats.today}</div>
|
||||
<div className="stat-label">今日执行</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value" style={{ color: '#10B981' }}>{stats.success}</div>
|
||||
<div className="stat-label">成功</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value" style={{ color: '#EF4444' }}>{stats.failed + stats.timeout}</div>
|
||||
<div className="stat-label">失败/超时</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value" style={{ color: '#6C63FF' }}>{stats.avg_execution_time_ms.toFixed(0)}ms</div>
|
||||
<div className="stat-label">平均耗时</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{stats.total}</div>
|
||||
<div className="stat-label">总执行次数</div>
|
||||
</div>
|
||||
{/* Language breakdown */}
|
||||
<div className="stat-card" style={{ gridColumn: 'span 2' }}>
|
||||
<div style={{ display: 'flex', gap: '12px', flexWrap: 'wrap', marginTop: '4px' }}>
|
||||
{Object.entries(stats.by_language || {}).map(([lang, count]) => (
|
||||
<span key={lang} style={{
|
||||
padding: '2px 12px', borderRadius: '12px', fontSize: '12px',
|
||||
color: (LANG_CONFIG[lang] || {}).color || '#666',
|
||||
background: (LANG_CONFIG[lang] || {}).bg || '#f0f0f0',
|
||||
}}>
|
||||
{(LANG_CONFIG[lang] || {}).label || lang}: {count}
|
||||
</span>
|
||||
))}
|
||||
{Object.keys(stats.by_language || {}).length === 0 && (
|
||||
<span style={{ fontSize: '12px', color: '#9CA3AF' }}>暂无数据</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="stat-label">语言分布</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main grid: Editor + Output */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '20px', marginBottom: '24px' }}>
|
||||
{/* Left: Code Editor */}
|
||||
<div style={{
|
||||
background: '#1E1E2E', borderRadius: '12px', border: '1px solid #313244',
|
||||
overflow: 'hidden', display: 'flex', flexDirection: 'column',
|
||||
}}>
|
||||
{/* Editor header */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '10px 16px', borderBottom: '1px solid #313244',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<div style={{ display: 'flex', gap: '6px' }}>
|
||||
<div style={{ width: '10px', height: '10px', borderRadius: '50%', background: '#FF5F57' }} />
|
||||
<div style={{ width: '10px', height: '10px', borderRadius: '50%', background: '#FFBD2E' }} />
|
||||
<div style={{ width: '10px', height: '10px', borderRadius: '50%', background: '#27CA40' }} />
|
||||
</div>
|
||||
<select
|
||||
value={language}
|
||||
onChange={(e) => {
|
||||
setLanguage(e.target.value);
|
||||
setCode(LANG_CONFIG[e.target.value]?.placeholder || '');
|
||||
setResult(null);
|
||||
}}
|
||||
style={{
|
||||
padding: '4px 12px', borderRadius: '6px', border: '1px solid #45475A',
|
||||
background: '#313244', color: '#CDD6F4', fontSize: '13px', cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<option value="python">Python</option>
|
||||
<option value="javascript">JavaScript</option>
|
||||
</select>
|
||||
<span style={{ fontSize: '12px', color: '#6C7086' }}>
|
||||
{runtimes && (language === 'python' ? runtimes.python : runtimes.nodejs)}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{ fontSize: '12px', color: '#6C7086' }}>超时:</span>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={120}
|
||||
value={timeout}
|
||||
onChange={(e) => setTimeout_(parseInt(e.target.value))}
|
||||
style={{ width: '80px', accentColor: '#6C63FF' }}
|
||||
/>
|
||||
<span style={{ fontSize: '12px', color: '#CDD6F4', minWidth: '28px' }}>{timeout}s</span>
|
||||
<button
|
||||
onClick={handleExecute}
|
||||
disabled={executing}
|
||||
style={{
|
||||
padding: '6px 16px', borderRadius: '6px', border: 'none',
|
||||
background: executing ? '#45475A' : '#6C63FF',
|
||||
color: '#fff', cursor: executing ? 'not-allowed' : 'pointer',
|
||||
fontSize: '13px', fontWeight: 600, whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{executing ? '执行中...' : '▶ 运行'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Code textarea */}
|
||||
<textarea
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={LANG_CONFIG[language]?.placeholder || '输入代码...'}
|
||||
spellCheck={false}
|
||||
style={{
|
||||
flex: 1, minHeight: '320px', padding: '16px', border: 'none',
|
||||
background: '#1E1E2E', color: '#CDD6F4', fontFamily: "'Fira Code', 'Cascadia Code', 'JetBrains Mono', 'Consolas', monospace",
|
||||
fontSize: '13px', lineHeight: 1.6, resize: 'vertical',
|
||||
outline: 'none', tabSize: 4,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Footer hint */}
|
||||
<div style={{
|
||||
padding: '6px 16px', borderTop: '1px solid #313244',
|
||||
fontSize: '11px', color: '#585B70',
|
||||
}}>
|
||||
Ctrl+Enter 运行 · {language === 'python' ? 'Python 3.11' : 'Node.js'} · 最大 {runtimes?.max_code_length ? (runtimes.max_code_length / 1024).toFixed(0) + 'KB' : '50KB'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Output */}
|
||||
<div style={{
|
||||
background: '#fff', borderRadius: '12px', border: '1px solid #E5E7EB',
|
||||
overflow: 'hidden', display: 'flex', flexDirection: 'column',
|
||||
}}>
|
||||
{/* Output header */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '10px 16px', borderBottom: '1px solid #E5E7EB',
|
||||
background: '#F9FAFB',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{ fontSize: '14px', fontWeight: 600, color: '#374151' }}>执行结果</span>
|
||||
{result && (
|
||||
<span style={{
|
||||
padding: '2px 8px', borderRadius: '4px', fontSize: '11px',
|
||||
color: result.success ? '#fff' : '#fff',
|
||||
background: result.success ? '#10B981' : (result.error ? '#EF4444' : '#F59E0B'),
|
||||
}}>
|
||||
{result.success ? '成功' : (result.error?.includes('timeout') ? '超时' : '失败')}
|
||||
</span>
|
||||
)}
|
||||
{result && !result.success && result.exit_code !== 0 && (
|
||||
<span style={{ fontSize: '12px', color: '#6B7280' }}>
|
||||
exit_code={result.exit_code}
|
||||
</span>
|
||||
)}
|
||||
{result && (
|
||||
<span style={{ fontSize: '12px', color: '#6B7280' }}>
|
||||
{result.execution_time_ms.toFixed(0)}ms
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{result && (
|
||||
<div style={{ display: 'flex', gap: '4px' }}>
|
||||
<button
|
||||
onClick={() => setActiveTab('stdout')}
|
||||
style={{
|
||||
padding: '4px 10px', borderRadius: '4px', border: 'none',
|
||||
background: activeTab === 'stdout' ? '#6C63FF' : '#F3F4F6',
|
||||
color: activeTab === 'stdout' ? '#fff' : '#6B7280',
|
||||
cursor: 'pointer', fontSize: '12px',
|
||||
}}
|
||||
>
|
||||
stdout
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('stderr')}
|
||||
style={{
|
||||
padding: '4px 10px', borderRadius: '4px', border: 'none',
|
||||
background: activeTab === 'stderr' ? '#EF4444' : '#F3F4F6',
|
||||
color: activeTab === 'stderr' ? '#fff' : '#6B7280',
|
||||
cursor: 'pointer', fontSize: '12px',
|
||||
}}
|
||||
>
|
||||
stderr{result.stderr ? ` (${result.stderr.length})` : ''}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Output content */}
|
||||
<div ref={outputRef} style={{
|
||||
flex: 1, minHeight: '320px', padding: '16px', overflow: 'auto',
|
||||
background: '#1E1E2E',
|
||||
}}>
|
||||
{executing ? (
|
||||
<div style={{ color: '#6C7086', fontSize: '13px', fontFamily: 'monospace' }}>
|
||||
<span style={{ animation: 'blink 1s infinite' }}>⏳</span> 正在执行...
|
||||
</div>
|
||||
) : !result ? (
|
||||
<div style={{ color: '#585B70', fontSize: '13px', fontFamily: 'monospace', textAlign: 'center', paddingTop: '60px' }}>
|
||||
<div style={{ fontSize: '36px', marginBottom: '12px' }}>{'</>'}</div>
|
||||
<div>点击「运行」按钮执行代码</div>
|
||||
<div style={{ marginTop: '8px', fontSize: '11px' }}>或按 Ctrl+Enter 快捷运行</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{result.error && (
|
||||
<div style={{
|
||||
padding: '8px 12px', marginBottom: '12px', borderRadius: '6px',
|
||||
background: '#FEE2E2', color: '#DC2626', fontSize: '12px',
|
||||
fontFamily: "'Fira Code', monospace",
|
||||
}}>
|
||||
{result.error}
|
||||
</div>
|
||||
)}
|
||||
{result.truncated && (
|
||||
<div style={{
|
||||
padding: '4px 12px', marginBottom: '8px', borderRadius: '4px',
|
||||
background: '#FEF3C7', color: '#92400E', fontSize: '11px', fontFamily: 'monospace',
|
||||
}}>
|
||||
输出已截断 (超过 500KB)
|
||||
</div>
|
||||
)}
|
||||
<pre style={{
|
||||
margin: 0, padding: 0, background: 'transparent', color: '#CDD6F4',
|
||||
fontSize: '13px', fontFamily: "'Fira Code', 'Cascadia Code', 'JetBrains Mono', 'Consolas', monospace",
|
||||
lineHeight: 1.6, whiteSpace: 'pre-wrap', wordBreak: 'break-all',
|
||||
minHeight: activeTab === 'stdout' && !result.stdout.trim() ? 'auto' : undefined,
|
||||
}}>
|
||||
{activeTab === 'stdout'
|
||||
? (result.stdout.trim() || <span style={{ color: '#585B70' }}>(无输出)</span>)
|
||||
: (result.stderr.trim() || <span style={{ color: '#585B70' }}>(无输出)</span>)
|
||||
}
|
||||
</pre>
|
||||
{result.task_id && (
|
||||
<div style={{ marginTop: '12px', padding: '6px 0', borderTop: '1px solid #313244', fontSize: '11px', color: '#585B70', fontFamily: 'monospace' }}>
|
||||
Task ID: {result.task_id}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Task History */}
|
||||
<div style={{ marginTop: '8px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '12px' }}>
|
||||
<h3 style={{ margin: 0, fontSize: '16px', color: '#374151' }}>执行历史</h3>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<select
|
||||
value={filterLang}
|
||||
onChange={(e) => { setFilterLang(e.target.value); setPage(1); }}
|
||||
style={{ padding: '6px 10px', borderRadius: '6px', border: '1px solid #D1D5DB', fontSize: '13px' }}
|
||||
>
|
||||
<option value="">全部语言</option>
|
||||
<option value="python">Python</option>
|
||||
<option value="javascript">JavaScript</option>
|
||||
</select>
|
||||
<select
|
||||
value={filterStatus}
|
||||
onChange={(e) => { setFilterStatus(e.target.value); setPage(1); }}
|
||||
style={{ padding: '6px 10px', borderRadius: '6px', border: '1px solid #D1D5DB', fontSize: '13px' }}
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
<option value="completed">成功</option>
|
||||
<option value="error">错误</option>
|
||||
<option value="failed">失败</option>
|
||||
<option value="running">运行中</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={loadTasks}
|
||||
style={{
|
||||
padding: '6px 12px', borderRadius: '6px', border: '1px solid #D1D5DB',
|
||||
background: '#fff', cursor: 'pointer', fontSize: '13px', color: '#374151',
|
||||
}}
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="table-container" style={{ background: '#fff', borderRadius: '12px', border: '1px solid #E5E7EB', overflow: 'hidden' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ background: '#F9FAFB', borderBottom: '1px solid #E5E7EB' }}>
|
||||
<th style={{ padding: '10px 16px', textAlign: 'left', fontSize: '13px', color: '#6B7280', fontWeight: 600, width: '100px' }}>任务ID</th>
|
||||
<th style={{ padding: '10px 16px', textAlign: 'left', fontSize: '13px', color: '#6B7280', fontWeight: 600, width: '80px' }}>语言</th>
|
||||
<th style={{ padding: '10px 16px', textAlign: 'left', fontSize: '13px', color: '#6B7280', fontWeight: 600 }}>代码预览</th>
|
||||
<th style={{ padding: '10px 16px', textAlign: 'center', fontSize: '13px', color: '#6B7280', fontWeight: 600, width: '80px' }}>状态</th>
|
||||
<th style={{ padding: '10px 16px', textAlign: 'center', fontSize: '13px', color: '#6B7280', fontWeight: 600, width: '80px' }}>耗时</th>
|
||||
<th style={{ padding: '10px 16px', textAlign: 'left', fontSize: '13px', color: '#6B7280', fontWeight: 600, width: '160px' }}>创建时间</th>
|
||||
<th style={{ padding: '10px 16px', textAlign: 'right', fontSize: '13px', color: '#6B7280', fontWeight: 600, width: '120px' }}>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr><td colSpan={7} style={{ padding: '40px', textAlign: 'center', color: '#9CA3AF' }}>加载中...</td></tr>
|
||||
) : tasks.length === 0 ? (
|
||||
<tr><td colSpan={7} style={{ padding: '40px', textAlign: 'center', color: '#9CA3AF' }}>暂无执行记录</td></tr>
|
||||
) : (
|
||||
tasks.map(task => (
|
||||
<tr key={task.id} style={{ borderBottom: '1px solid #F3F4F6' }}>
|
||||
<td style={{ padding: '10px 16px', fontFamily: 'monospace', fontSize: '12px', color: '#6C63FF' }}>
|
||||
{task.id}
|
||||
</td>
|
||||
<td style={{ padding: '10px 16px' }}>
|
||||
<span style={{
|
||||
padding: '2px 8px', borderRadius: '12px', fontSize: '11px',
|
||||
color: (LANG_CONFIG[task.language] || {}).color || '#666',
|
||||
background: (LANG_CONFIG[task.language] || {}).bg || '#f0f0f0',
|
||||
}}>
|
||||
{(LANG_CONFIG[task.language] || {}).label || task.language}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '10px 16px', fontSize: '12px', color: '#6B7280', fontFamily: 'monospace', maxWidth: '280px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{task.code_preview || '-'}
|
||||
</td>
|
||||
<td style={{ padding: '10px 16px', textAlign: 'center' }}>
|
||||
<span style={{
|
||||
padding: '2px 8px', borderRadius: '12px', fontSize: '11px',
|
||||
color: (STATUS_CONFIG[task.status] || {}).color || '#666',
|
||||
background: (STATUS_CONFIG[task.status] || {}).bg || '#f0f0f0',
|
||||
}}>
|
||||
{(STATUS_CONFIG[task.status] || {}).label || task.status}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '10px 16px', textAlign: 'center', fontSize: '12px', color: '#6B7280', fontFamily: 'monospace' }}>
|
||||
{task.execution_time_ms != null ? `${task.execution_time_ms}ms` : '-'}
|
||||
</td>
|
||||
<td style={{ padding: '10px 16px', fontSize: '12px', color: '#6B7280' }}>
|
||||
{task.created_at || '-'}
|
||||
</td>
|
||||
<td style={{ padding: '10px 16px', textAlign: 'right' }}>
|
||||
<div style={{ display: 'flex', gap: '4px', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={() => viewTaskDetail(task)}
|
||||
style={{
|
||||
padding: '4px 10px', borderRadius: '4px', border: '1px solid #D1D5DB',
|
||||
background: '#fff', cursor: 'pointer', fontSize: '11px', color: '#374151',
|
||||
}}
|
||||
>
|
||||
详情
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteTask(task)}
|
||||
style={{
|
||||
padding: '4px 10px', borderRadius: '4px', border: '1px solid #FEE2E2',
|
||||
background: '#FEF2F2', cursor: 'pointer', fontSize: '11px', color: '#EF4444',
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{total > PAGE_SIZE && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: '8px', marginTop: '16px' }}>
|
||||
<button
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage(p => p - 1)}
|
||||
style={{
|
||||
padding: '6px 16px', borderRadius: '6px', border: '1px solid #D1D5DB',
|
||||
background: '#fff', cursor: page <= 1 ? 'not-allowed' : 'pointer',
|
||||
opacity: page <= 1 ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<span style={{ padding: '6px 12px', color: '#6B7280', fontSize: '14px' }}>
|
||||
第 {page} / {Math.ceil(total / PAGE_SIZE)} 页 (共 {total} 条)
|
||||
</span>
|
||||
<button
|
||||
disabled={page >= Math.ceil(total / PAGE_SIZE)}
|
||||
onClick={() => setPage(p => p + 1)}
|
||||
style={{
|
||||
padding: '6px 16px', borderRadius: '6px', border: '1px solid #D1D5DB',
|
||||
background: '#fff', cursor: page >= Math.ceil(total / PAGE_SIZE) ? 'not-allowed' : 'pointer',
|
||||
opacity: page >= Math.ceil(total / PAGE_SIZE) ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Task Detail Modal */}
|
||||
{detailTask && (
|
||||
<div className="modal-overlay" style={{
|
||||
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||||
background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center',
|
||||
justifyContent: 'center', zIndex: 1000,
|
||||
}} onClick={() => { setDetailTask(null); setDetailResult(null); }}>
|
||||
<div className="modal-content" style={{
|
||||
background: '#fff', borderRadius: '12px', padding: '24px',
|
||||
width: '700px', maxHeight: '85vh', overflow: 'auto',
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.2)',
|
||||
}} onClick={e => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
|
||||
<h3 style={{ margin: 0, fontSize: '16px' }}>任务详情 - {detailTask.id}</h3>
|
||||
<button
|
||||
onClick={() => { setDetailTask(null); setDetailResult(null); }}
|
||||
style={{ padding: '4px 8px', borderRadius: '4px', border: 'none', background: '#F3F4F6', cursor: 'pointer', fontSize: '16px', color: '#6B7280' }}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Task info bar */}
|
||||
<div style={{ display: 'flex', gap: '12px', marginBottom: '16px', flexWrap: 'wrap' }}>
|
||||
<span style={{
|
||||
padding: '2px 10px', borderRadius: '12px', fontSize: '12px',
|
||||
color: (LANG_CONFIG[detailTask.language] || {}).color || '#666',
|
||||
background: (LANG_CONFIG[detailTask.language] || {}).bg || '#f0f0f0',
|
||||
}}>
|
||||
{(LANG_CONFIG[detailTask.language] || {}).label || detailTask.language}
|
||||
</span>
|
||||
<span style={{
|
||||
padding: '2px 10px', borderRadius: '12px', fontSize: '12px',
|
||||
color: (STATUS_CONFIG[detailTask.status] || {}).color || '#666',
|
||||
background: (STATUS_CONFIG[detailTask.status] || {}).bg || '#f0f0f0',
|
||||
}}>
|
||||
{(STATUS_CONFIG[detailTask.status] || {}).label || detailTask.status}
|
||||
</span>
|
||||
{detailTask.execution_time_ms != null && (
|
||||
<span style={{ fontSize: '12px', color: '#6B7280', fontFamily: 'monospace' }}>
|
||||
耗时: {detailTask.execution_time_ms}ms
|
||||
</span>
|
||||
)}
|
||||
{detailTask.exit_code != null && (
|
||||
<span style={{ fontSize: '12px', color: '#6B7280', fontFamily: 'monospace' }}>
|
||||
exit_code: {detailTask.exit_code}
|
||||
</span>
|
||||
)}
|
||||
<span style={{ fontSize: '12px', color: '#9CA3AF' }}>
|
||||
{detailTask.created_at}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Code preview */}
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '6px', fontSize: '13px', color: '#374151', fontWeight: 500 }}>代码预览</label>
|
||||
<pre style={{
|
||||
margin: 0, padding: '12px', borderRadius: '6px',
|
||||
background: '#1E1E2E', color: '#CDD6F4', fontSize: '12px',
|
||||
fontFamily: 'monospace', lineHeight: 1.5, maxHeight: '150px', overflow: 'auto',
|
||||
}}>
|
||||
{detailTask.code_preview || '(无)'}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Full output */}
|
||||
{detailLoading ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px', color: '#9CA3AF' }}>加载中...</div>
|
||||
) : detailResult ? (
|
||||
<>
|
||||
{detailResult.error && (
|
||||
<div style={{
|
||||
padding: '8px 12px', marginBottom: '12px', borderRadius: '6px',
|
||||
background: '#FEE2E2', color: '#DC2626', fontSize: '12px', fontFamily: 'monospace',
|
||||
}}>
|
||||
{detailResult.error}
|
||||
</div>
|
||||
)}
|
||||
{detailResult.stdout && (
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '6px', fontSize: '13px', color: '#374151', fontWeight: 500 }}>stdout</label>
|
||||
<pre style={{
|
||||
margin: 0, padding: '12px', borderRadius: '6px',
|
||||
background: '#1E1E2E', color: '#CDD6F4', fontSize: '12px',
|
||||
fontFamily: 'monospace', lineHeight: 1.5, maxHeight: '300px', overflow: 'auto',
|
||||
whiteSpace: 'pre-wrap', wordBreak: 'break-all',
|
||||
}}>
|
||||
{detailResult.stdout}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{detailResult.stderr && (
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '6px', fontSize: '13px', color: '#EF4444', fontWeight: 500 }}>stderr</label>
|
||||
<pre style={{
|
||||
margin: 0, padding: '12px', borderRadius: '6px',
|
||||
background: '#1E1E2E', color: '#F38BA8', fontSize: '12px',
|
||||
fontFamily: 'monospace', lineHeight: 1.5, maxHeight: '300px', overflow: 'auto',
|
||||
whiteSpace: 'pre-wrap', wordBreak: 'break-all',
|
||||
}}>
|
||||
{detailResult.stderr}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{!detailResult.stdout.trim() && !detailResult.stderr.trim() && !detailResult.error && (
|
||||
<div style={{ textAlign: 'center', padding: '20px', color: '#9CA3AF', fontSize: '13px' }}>(无输出)</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div style={{ textAlign: 'center', padding: '20px', color: '#9CA3AF', fontSize: '13px' }}>无法获取详细信息</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Toast */}
|
||||
{toast && (
|
||||
<div style={{
|
||||
position: 'fixed', top: '20px', right: '20px', zIndex: 2000,
|
||||
padding: '12px 24px', borderRadius: '8px', color: '#fff',
|
||||
background: toast.type === 'success' ? '#10B981' : '#EF4444',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)', fontSize: '14px',
|
||||
}}>
|
||||
{toast.msg}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CodeExec;
|
||||
@@ -0,0 +1,23 @@
|
||||
-- G2: Code Execution Worker - Database Migration
|
||||
-- Creates code_exec_tasks table for tracking code execution tasks
|
||||
|
||||
CREATE TABLE IF NOT EXISTS code_exec_tasks (
|
||||
id VARCHAR(36) NOT NULL PRIMARY KEY COMMENT 'Task ID (UUID short)',
|
||||
language VARCHAR(20) NOT NULL DEFAULT 'python' COMMENT 'Programming language',
|
||||
code_preview VARCHAR(500) DEFAULT '' COMMENT 'First 200 chars of code for preview',
|
||||
status ENUM('running','completed','error','failed','cancelled') NOT NULL DEFAULT 'running' COMMENT 'Execution status',
|
||||
timeout_sec INT NOT NULL DEFAULT 30 COMMENT 'Timeout in seconds',
|
||||
exit_code INT DEFAULT NULL COMMENT 'Process exit code',
|
||||
execution_time_ms FLOAT DEFAULT 0 COMMENT 'Execution time in milliseconds',
|
||||
stdout MEDIUMTEXT COMMENT 'Standard output (max 50KB)',
|
||||
stderr MEDIUMTEXT COMMENT 'Standard error (max 50KB)',
|
||||
error_msg VARCHAR(2048) DEFAULT NULL COMMENT 'Error message if failed',
|
||||
truncated TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Whether output was truncated',
|
||||
created_at DATETIME NOT NULL COMMENT 'Task creation time',
|
||||
completed_at DATETIME DEFAULT NULL COMMENT 'Task completion time',
|
||||
INDEX idx_language (language),
|
||||
INDEX idx_status (status),
|
||||
INDEX idx_created_at (created_at),
|
||||
INDEX idx_lang_status (language, status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
COMMENT='Code execution task records';
|
||||
Reference in new issue
Block a user