feat: Add OpenClaw integration with new UI, API client, and backend routes.
This commit is contained in:
parent
da3020b4f1
commit
69642a17ea
@ -15,7 +15,7 @@ import os
|
|||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.database import init_db
|
from app.database import init_db
|
||||||
from app.routers import projects, agents, logs, workflows, config
|
from app.routers import projects, agents, logs, workflows, config, openclaw
|
||||||
from app.routers.ws import manager
|
from app.routers.ws import manager
|
||||||
|
|
||||||
# ─── Logging ───────────────────────────────────────────────────────────────────
|
# ─── Logging ───────────────────────────────────────────────────────────────────
|
||||||
@ -64,6 +64,7 @@ app.include_router(agents.router)
|
|||||||
app.include_router(logs.router)
|
app.include_router(logs.router)
|
||||||
app.include_router(workflows.router)
|
app.include_router(workflows.router)
|
||||||
app.include_router(config.router)
|
app.include_router(config.router)
|
||||||
|
app.include_router(openclaw.router)
|
||||||
|
|
||||||
# ─── Static Files ─────────────────────────────────────────────────────────────
|
# ─── Static Files ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
391
backend/app/routers/openclaw.py
Normal file
391
backend/app/routers/openclaw.py
Normal file
@ -0,0 +1,391 @@
|
|||||||
|
"""
|
||||||
|
🐙 OpenClaw Service — Status, Configuration, Agents, Skills, Logs & Models.
|
||||||
|
|
||||||
|
Reads data from the OpenClaw home directory (FOXY_HOME) and exposes it
|
||||||
|
through a set of REST endpoints for the dashboard frontend.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import socket
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Query
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
log = logging.getLogger("foxy.api.openclaw")
|
||||||
|
router = APIRouter(prefix="/api/openclaw", tags=["openclaw"])
|
||||||
|
|
||||||
|
# Secrets / tokens to mask in config output
|
||||||
|
_SECRET_PATTERNS = re.compile(
|
||||||
|
r"(token|key|secret|password|apiKey|api_key)", re.IGNORECASE
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _home() -> Path:
|
||||||
|
"""Resolve the OpenClaw home directory."""
|
||||||
|
return Path(settings.FOXY_HOME)
|
||||||
|
|
||||||
|
|
||||||
|
def _mask_secrets(obj: Any, depth: int = 0) -> Any:
|
||||||
|
"""Recursively mask values whose keys look like secrets."""
|
||||||
|
if depth > 20:
|
||||||
|
return obj
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
masked = {}
|
||||||
|
for k, v in obj.items():
|
||||||
|
if isinstance(v, str) and _SECRET_PATTERNS.search(k) and v:
|
||||||
|
masked[k] = "••••••••"
|
||||||
|
else:
|
||||||
|
masked[k] = _mask_secrets(v, depth + 1)
|
||||||
|
return masked
|
||||||
|
if isinstance(obj, list):
|
||||||
|
return [_mask_secrets(item, depth + 1) for item in obj]
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
def _port_open(port: int, host: str = "127.0.0.1", timeout: float = 1.0) -> bool:
|
||||||
|
"""Check if a TCP port is open."""
|
||||||
|
try:
|
||||||
|
with socket.create_connection((host, port), timeout=timeout):
|
||||||
|
return True
|
||||||
|
except (OSError, ConnectionRefusedError, TimeoutError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _read_json(path: Path) -> Optional[dict]:
|
||||||
|
"""Read and parse a JSON file, return None on failure."""
|
||||||
|
try:
|
||||||
|
return json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _dir_tree(root: Path, max_depth: int = 3, _depth: int = 0) -> list[dict]:
|
||||||
|
"""Build a lightweight directory tree."""
|
||||||
|
entries: list[dict] = []
|
||||||
|
if not root.is_dir() or _depth > max_depth:
|
||||||
|
return entries
|
||||||
|
try:
|
||||||
|
for child in sorted(root.iterdir()):
|
||||||
|
name = child.name
|
||||||
|
if name.startswith(".") and name not in (".openclaw",):
|
||||||
|
continue
|
||||||
|
if name in ("node_modules", "__pycache__", ".git"):
|
||||||
|
continue
|
||||||
|
entry: dict = {"name": name, "path": str(child.relative_to(_home()))}
|
||||||
|
if child.is_dir():
|
||||||
|
entry["type"] = "directory"
|
||||||
|
entry["children"] = _dir_tree(child, max_depth, _depth + 1)
|
||||||
|
else:
|
||||||
|
entry["type"] = "file"
|
||||||
|
try:
|
||||||
|
entry["size"] = child.stat().st_size
|
||||||
|
except OSError:
|
||||||
|
entry["size"] = 0
|
||||||
|
entries.append(entry)
|
||||||
|
except PermissionError:
|
||||||
|
pass
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
# ═════════════════════════════════════════════════════════════════════════════════
|
||||||
|
# 1. Status
|
||||||
|
# ═════════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status")
|
||||||
|
async def get_status():
|
||||||
|
"""Gateway health, process info, mode."""
|
||||||
|
home = _home()
|
||||||
|
openclaw_type = os.environ.get("OPENCLAW_TYPE", "shared")
|
||||||
|
|
||||||
|
# Check gateway port
|
||||||
|
gateway_up = await asyncio.to_thread(_port_open, 18789)
|
||||||
|
|
||||||
|
# Try reading config for extra info
|
||||||
|
config_data = await asyncio.to_thread(_read_json, home / "openclaw.json")
|
||||||
|
gateway_cfg = {}
|
||||||
|
if config_data:
|
||||||
|
gateway_cfg = config_data.get("gateway", {})
|
||||||
|
|
||||||
|
# Count agents, skills
|
||||||
|
agents_dir = home / "agents"
|
||||||
|
skills_dir = home / "skills"
|
||||||
|
agent_count = len(list(agents_dir.iterdir())) if agents_dir.is_dir() else 0
|
||||||
|
skill_count = len(list(skills_dir.iterdir())) if skills_dir.is_dir() else 0
|
||||||
|
|
||||||
|
# Gateway log existence
|
||||||
|
log_file = home / "logs" / "gateway.log"
|
||||||
|
log_size = 0
|
||||||
|
if log_file.is_file():
|
||||||
|
try:
|
||||||
|
log_size = log_file.stat().st_size
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {
|
||||||
|
"gateway_online": gateway_up,
|
||||||
|
"openclaw_type": openclaw_type,
|
||||||
|
"foxy_home": str(home),
|
||||||
|
"gateway_bind": gateway_cfg.get("bind", "unknown"),
|
||||||
|
"gateway_mode": gateway_cfg.get("mode", "unknown"),
|
||||||
|
"gateway_port": 18789,
|
||||||
|
"agent_count": agent_count,
|
||||||
|
"skill_count": skill_count,
|
||||||
|
"log_file_size": log_size,
|
||||||
|
"config_exists": (home / "openclaw.json").is_file(),
|
||||||
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ═════════════════════════════════════════════════════════════════════════════════
|
||||||
|
# 2. Configuration
|
||||||
|
# ═════════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/config")
|
||||||
|
async def get_openclaw_config():
|
||||||
|
"""Read openclaw.json with secrets masked."""
|
||||||
|
config_path = _home() / "openclaw.json"
|
||||||
|
data = await asyncio.to_thread(_read_json, config_path)
|
||||||
|
if data is None:
|
||||||
|
return {"error": "openclaw.json not found or unreadable", "config": None}
|
||||||
|
return {"config": _mask_secrets(data)}
|
||||||
|
|
||||||
|
|
||||||
|
# ═════════════════════════════════════════════════════════════════════════════════
|
||||||
|
# 3. Agents
|
||||||
|
# ═════════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/agents")
|
||||||
|
async def list_openclaw_agents():
|
||||||
|
"""List agent definitions from the agents/ directory."""
|
||||||
|
agents_dir = _home() / "agents"
|
||||||
|
if not agents_dir.is_dir():
|
||||||
|
return {"agents": [], "error": "agents/ directory not found"}
|
||||||
|
|
||||||
|
agents = []
|
||||||
|
for entry in sorted(agents_dir.iterdir()):
|
||||||
|
agent: dict[str, Any] = {"name": entry.name}
|
||||||
|
|
||||||
|
if entry.is_dir():
|
||||||
|
# Directory-based agent — look for config files
|
||||||
|
agent["type"] = "directory"
|
||||||
|
config_files = list(entry.glob("*.json")) + list(entry.glob("*.yaml")) + list(entry.glob("*.yml"))
|
||||||
|
agent["config_files"] = [f.name for f in config_files]
|
||||||
|
|
||||||
|
# Try to read agent config
|
||||||
|
for cfg_file in config_files:
|
||||||
|
cfg = _read_json(cfg_file) if cfg_file.suffix == ".json" else None
|
||||||
|
if cfg:
|
||||||
|
agent["config"] = _mask_secrets(cfg)
|
||||||
|
agent["model"] = cfg.get("model", cfg.get("modelId", None))
|
||||||
|
agent["system_prompt"] = cfg.get("systemPrompt", cfg.get("system_prompt", cfg.get("instructions", None)))
|
||||||
|
if agent["system_prompt"] and len(agent["system_prompt"]) > 300:
|
||||||
|
agent["system_prompt_preview"] = agent["system_prompt"][:300] + "…"
|
||||||
|
break
|
||||||
|
|
||||||
|
# Count files
|
||||||
|
try:
|
||||||
|
all_files = list(entry.rglob("*"))
|
||||||
|
agent["file_count"] = len([f for f in all_files if f.is_file()])
|
||||||
|
except Exception:
|
||||||
|
agent["file_count"] = 0
|
||||||
|
|
||||||
|
elif entry.is_file() and entry.suffix == ".json":
|
||||||
|
# File-based agent definition
|
||||||
|
agent["type"] = "file"
|
||||||
|
cfg = _read_json(entry)
|
||||||
|
if cfg:
|
||||||
|
agent["config"] = _mask_secrets(cfg)
|
||||||
|
agent["model"] = cfg.get("model", cfg.get("modelId", None))
|
||||||
|
|
||||||
|
agents.append(agent)
|
||||||
|
|
||||||
|
return {"agents": agents, "count": len(agents)}
|
||||||
|
|
||||||
|
|
||||||
|
# ═════════════════════════════════════════════════════════════════════════════════
|
||||||
|
# 4. Skills
|
||||||
|
# ═════════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/skills")
|
||||||
|
async def list_openclaw_skills():
|
||||||
|
"""List available skills from the skills/ directory."""
|
||||||
|
# Skills can be in multiple locations
|
||||||
|
possible_dirs = [
|
||||||
|
_home() / "skills",
|
||||||
|
_home() / "workspace" / "skills",
|
||||||
|
]
|
||||||
|
|
||||||
|
skills = []
|
||||||
|
for skills_dir in possible_dirs:
|
||||||
|
if not skills_dir.is_dir():
|
||||||
|
continue
|
||||||
|
for entry in sorted(skills_dir.iterdir()):
|
||||||
|
skill: dict[str, Any] = {
|
||||||
|
"name": entry.name,
|
||||||
|
"source": str(skills_dir.relative_to(_home())),
|
||||||
|
}
|
||||||
|
|
||||||
|
if entry.is_dir():
|
||||||
|
skill["type"] = "directory"
|
||||||
|
# Look for SKILL.md or README.md
|
||||||
|
for doc_name in ("SKILL.md", "README.md", "skill.md", "readme.md"):
|
||||||
|
doc = entry / doc_name
|
||||||
|
if doc.is_file():
|
||||||
|
try:
|
||||||
|
content = doc.read_text(encoding="utf-8", errors="replace")
|
||||||
|
# Extract description from YAML frontmatter or first lines
|
||||||
|
skill["description"] = content[:500]
|
||||||
|
# Try extracting YAML description
|
||||||
|
if content.startswith("---"):
|
||||||
|
end = content.find("---", 3)
|
||||||
|
if end > 0:
|
||||||
|
frontmatter = content[3:end].strip()
|
||||||
|
for line in frontmatter.split("\n"):
|
||||||
|
if line.startswith("description:"):
|
||||||
|
skill["description_short"] = line.split(":", 1)[1].strip().strip("'\"")
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
break
|
||||||
|
|
||||||
|
# Count files
|
||||||
|
try:
|
||||||
|
all_files = list(entry.rglob("*"))
|
||||||
|
skill["file_count"] = len([f for f in all_files if f.is_file()])
|
||||||
|
skill["subdirs"] = [d.name for d in entry.iterdir() if d.is_dir()]
|
||||||
|
except Exception:
|
||||||
|
skill["file_count"] = 0
|
||||||
|
|
||||||
|
elif entry.is_file():
|
||||||
|
skill["type"] = "file"
|
||||||
|
try:
|
||||||
|
skill["size"] = entry.stat().st_size
|
||||||
|
except OSError:
|
||||||
|
skill["size"] = 0
|
||||||
|
|
||||||
|
skills.append(skill)
|
||||||
|
|
||||||
|
return {"skills": skills, "count": len(skills)}
|
||||||
|
|
||||||
|
|
||||||
|
# ═════════════════════════════════════════════════════════════════════════════════
|
||||||
|
# 5. Logs
|
||||||
|
# ═════════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/logs")
|
||||||
|
async def get_gateway_logs(
|
||||||
|
lines: int = Query(200, ge=10, le=2000),
|
||||||
|
level: Optional[str] = Query(None, description="Filter by log level: INFO, WARNING, ERROR"),
|
||||||
|
):
|
||||||
|
"""Read the last N lines of the gateway log."""
|
||||||
|
log_file = _home() / "logs" / "gateway.log"
|
||||||
|
if not log_file.is_file():
|
||||||
|
# Try alternate locations
|
||||||
|
alt = _home() / "gateway.log"
|
||||||
|
if alt.is_file():
|
||||||
|
log_file = alt
|
||||||
|
else:
|
||||||
|
return {"lines": [], "error": "Gateway log file not found", "log_path": str(log_file)}
|
||||||
|
|
||||||
|
try:
|
||||||
|
content = await asyncio.to_thread(
|
||||||
|
log_file.read_text, encoding="utf-8", errors="replace"
|
||||||
|
)
|
||||||
|
all_lines = content.strip().split("\n")
|
||||||
|
|
||||||
|
# Take last N lines
|
||||||
|
result_lines = all_lines[-lines:]
|
||||||
|
|
||||||
|
# Filter by level if requested
|
||||||
|
if level:
|
||||||
|
level_upper = level.upper()
|
||||||
|
result_lines = [l for l in result_lines if level_upper in l.upper()]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"lines": result_lines,
|
||||||
|
"total_lines": len(all_lines),
|
||||||
|
"showing": len(result_lines),
|
||||||
|
"log_path": str(log_file),
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {"lines": [], "error": str(e), "log_path": str(log_file)}
|
||||||
|
|
||||||
|
|
||||||
|
# ═════════════════════════════════════════════════════════════════════════════════
|
||||||
|
# 6. Models & Providers
|
||||||
|
# ═════════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/models")
|
||||||
|
async def list_models():
|
||||||
|
"""Extract configured models and providers from openclaw.json."""
|
||||||
|
config_path = _home() / "openclaw.json"
|
||||||
|
data = await asyncio.to_thread(_read_json, config_path)
|
||||||
|
if data is None:
|
||||||
|
return {"models": [], "providers": [], "error": "openclaw.json not found"}
|
||||||
|
|
||||||
|
# Extract providers
|
||||||
|
providers = []
|
||||||
|
providers_cfg = data.get("mcpServers", data.get("providers", data.get("llm", {})))
|
||||||
|
if isinstance(providers_cfg, dict):
|
||||||
|
for name, cfg in providers_cfg.items():
|
||||||
|
provider: dict[str, Any] = {
|
||||||
|
"name": name,
|
||||||
|
"type": cfg.get("type", cfg.get("provider", "unknown")) if isinstance(cfg, dict) else "unknown",
|
||||||
|
}
|
||||||
|
if isinstance(cfg, dict):
|
||||||
|
provider["enabled"] = cfg.get("enabled", True)
|
||||||
|
provider["model"] = cfg.get("model", cfg.get("modelId", None))
|
||||||
|
provider["endpoint"] = cfg.get("endpoint", cfg.get("baseUrl", cfg.get("url", None)))
|
||||||
|
providers.append(provider)
|
||||||
|
|
||||||
|
# Extract models list if present
|
||||||
|
models = []
|
||||||
|
models_cfg = data.get("models", [])
|
||||||
|
if isinstance(models_cfg, list):
|
||||||
|
for m in models_cfg:
|
||||||
|
if isinstance(m, dict):
|
||||||
|
models.append(_mask_secrets(m))
|
||||||
|
elif isinstance(m, str):
|
||||||
|
models.append({"name": m})
|
||||||
|
elif isinstance(models_cfg, dict):
|
||||||
|
for name, cfg in models_cfg.items():
|
||||||
|
model_entry = {"name": name}
|
||||||
|
if isinstance(cfg, dict):
|
||||||
|
model_entry.update(_mask_secrets(cfg))
|
||||||
|
models.append(model_entry)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"models": models,
|
||||||
|
"providers": _mask_secrets(providers),
|
||||||
|
"raw_keys": list(data.keys()),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ═════════════════════════════════════════════════════════════════════════════════
|
||||||
|
# 7. Filesystem Tree
|
||||||
|
# ═════════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/filesystem")
|
||||||
|
async def get_filesystem():
|
||||||
|
"""Get a directory tree of the OpenClaw home directory."""
|
||||||
|
home = _home()
|
||||||
|
if not home.is_dir():
|
||||||
|
return {"tree": [], "error": f"FOXY_HOME ({home}) does not exist"}
|
||||||
|
|
||||||
|
tree = await asyncio.to_thread(_dir_tree, home)
|
||||||
|
return {"root": str(home), "tree": tree}
|
||||||
@ -4,12 +4,14 @@ import Projects from './pages/Projects';
|
|||||||
import Agents from './pages/Agents';
|
import Agents from './pages/Agents';
|
||||||
import Logs from './pages/Logs';
|
import Logs from './pages/Logs';
|
||||||
import Settings from './pages/Settings';
|
import Settings from './pages/Settings';
|
||||||
|
import OpenClaw from './pages/OpenClaw';
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
{ path: '/', label: 'Dashboard', icon: '📊' },
|
{ path: '/', label: 'Dashboard', icon: '📊' },
|
||||||
{ path: '/projects', label: 'Projets', icon: '📋' },
|
{ path: '/projects', label: 'Projets', icon: '📋' },
|
||||||
{ path: '/agents', label: 'Agents', icon: '🤖' },
|
{ path: '/agents', label: 'Agents', icon: '🤖' },
|
||||||
{ path: '/logs', label: 'Logs', icon: '📜' },
|
{ path: '/logs', label: 'Logs', icon: '📜' },
|
||||||
|
{ path: '/openclaw', label: 'OpenClaw', icon: '🐙' },
|
||||||
{ path: '/settings', label: 'Config', icon: '⚙️' },
|
{ path: '/settings', label: 'Config', icon: '⚙️' },
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -60,6 +62,7 @@ export default function App() {
|
|||||||
<Route path="/projects" element={<Projects />} />
|
<Route path="/projects" element={<Projects />} />
|
||||||
<Route path="/agents" element={<Agents />} />
|
<Route path="/agents" element={<Agents />} />
|
||||||
<Route path="/logs" element={<Logs />} />
|
<Route path="/logs" element={<Logs />} />
|
||||||
|
<Route path="/openclaw" element={<OpenClaw />} />
|
||||||
<Route path="/settings" element={<Settings />} />
|
<Route path="/settings" element={<Settings />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@ -148,6 +148,67 @@ export interface AppConfig {
|
|||||||
TELEGRAM_BOT_TOKEN: string;
|
TELEGRAM_BOT_TOKEN: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── OpenClaw Types ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface OpenClawStatus {
|
||||||
|
gateway_online: boolean;
|
||||||
|
openclaw_type: string;
|
||||||
|
foxy_home: string;
|
||||||
|
gateway_bind: string;
|
||||||
|
gateway_mode: string;
|
||||||
|
gateway_port: number;
|
||||||
|
agent_count: number;
|
||||||
|
skill_count: number;
|
||||||
|
log_file_size: number;
|
||||||
|
config_exists: boolean;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpenClawAgent {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
config_files?: string[];
|
||||||
|
config?: Record<string, unknown>;
|
||||||
|
model?: string;
|
||||||
|
system_prompt?: string;
|
||||||
|
system_prompt_preview?: string;
|
||||||
|
file_count?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpenClawSkill {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
source: string;
|
||||||
|
description?: string;
|
||||||
|
description_short?: string;
|
||||||
|
file_count?: number;
|
||||||
|
subdirs?: string[];
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpenClawLogs {
|
||||||
|
lines: string[];
|
||||||
|
total_lines: number;
|
||||||
|
showing: number;
|
||||||
|
log_path: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpenClawModels {
|
||||||
|
models: Record<string, unknown>[];
|
||||||
|
providers: Record<string, unknown>[];
|
||||||
|
raw_keys?: string[];
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileTreeNode {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
type: 'file' | 'directory';
|
||||||
|
size?: number;
|
||||||
|
children?: FileTreeNode[];
|
||||||
|
}
|
||||||
|
|
||||||
// ─── HTTP Helpers ───────────────────────────────────────────────────────────
|
// ─── HTTP Helpers ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||||
@ -227,4 +288,18 @@ export const api = {
|
|||||||
|
|
||||||
// Health
|
// Health
|
||||||
health: () => request<{ status: string; version: string }>('/api/health'),
|
health: () => request<{ status: string; version: string }>('/api/health'),
|
||||||
|
|
||||||
|
// OpenClaw
|
||||||
|
openclawStatus: () => request<OpenClawStatus>('/api/openclaw/status'),
|
||||||
|
openclawConfig: () => request<{ config: Record<string, unknown> | null; error?: string }>('/api/openclaw/config'),
|
||||||
|
openclawAgents: () => request<{ agents: OpenClawAgent[]; count: number }>('/api/openclaw/agents'),
|
||||||
|
openclawSkills: () => request<{ skills: OpenClawSkill[]; count: number }>('/api/openclaw/skills'),
|
||||||
|
openclawLogs: (params?: { lines?: number; level?: string }) => {
|
||||||
|
const qs = params ? '?' + new URLSearchParams(
|
||||||
|
Object.fromEntries(Object.entries(params).filter(([, v]) => v != null).map(([k, v]) => [k, String(v)]))
|
||||||
|
).toString() : '';
|
||||||
|
return request<OpenClawLogs>(`/api/openclaw/logs${qs}`);
|
||||||
|
},
|
||||||
|
openclawModels: () => request<OpenClawModels>('/api/openclaw/models'),
|
||||||
|
openclawFilesystem: () => request<{ root: string; tree: FileTreeNode[] }>('/api/openclaw/filesystem'),
|
||||||
};
|
};
|
||||||
|
|||||||
817
frontend/src/pages/OpenClaw.tsx
Normal file
817
frontend/src/pages/OpenClaw.tsx
Normal file
@ -0,0 +1,817 @@
|
|||||||
|
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||||
|
import type {
|
||||||
|
OpenClawStatus, OpenClawAgent, OpenClawSkill, OpenClawLogs,
|
||||||
|
OpenClawModels, FileTreeNode,
|
||||||
|
} from '../api/client';
|
||||||
|
import { api } from '../api/client';
|
||||||
|
|
||||||
|
// ═════════════════════════════════════════════════════════════════════════════════
|
||||||
|
// Tab definitions
|
||||||
|
// ═════════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
const TABS = [
|
||||||
|
{ id: 'status', label: 'Status', icon: '🔋' },
|
||||||
|
{ id: 'config', label: 'Configuration', icon: '⚙️' },
|
||||||
|
{ id: 'agents', label: 'Agents', icon: '🤖' },
|
||||||
|
{ id: 'skills', label: 'Skills', icon: '🧩' },
|
||||||
|
{ id: 'logs', label: 'Logs', icon: '📟' },
|
||||||
|
{ id: 'models', label: 'Modèles', icon: '🧠' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type TabId = (typeof TABS)[number]['id'];
|
||||||
|
|
||||||
|
// ═════════════════════════════════════════════════════════════════════════════════
|
||||||
|
// Main Page
|
||||||
|
// ═════════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
export default function OpenClawPage() {
|
||||||
|
const [activeTab, setActiveTab] = useState<TabId>('status');
|
||||||
|
const [status, setStatus] = useState<OpenClawStatus | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.openclawStatus()
|
||||||
|
.then(s => { setStatus(s); setLoading(false); })
|
||||||
|
.catch(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-5xl mb-3 animate-spin-slow">🐙</div>
|
||||||
|
<div className="text-gray-500 text-sm">Connexion à OpenClaw…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-fade-in">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-purple-600/30 to-indigo-600/30 border border-purple-500/20 flex items-center justify-center text-2xl">
|
||||||
|
🐙
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-white tracking-tight">OpenClaw</h1>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-gray-500">
|
||||||
|
<span className={`w-2 h-2 rounded-full ${status?.gateway_online ? 'bg-green-400 shadow-[0_0_8px_rgba(74,222,128,0.5)]' : 'bg-red-400 shadow-[0_0_8px_rgba(248,113,113,0.5)]'}`}></span>
|
||||||
|
{status?.gateway_online ? 'Gateway opérationnel' : 'Gateway hors ligne'}
|
||||||
|
<span className="text-gray-700">•</span>
|
||||||
|
<span className="uppercase tracking-wider">{status?.openclaw_type}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab Bar */}
|
||||||
|
<div className="flex gap-1 p-1 rounded-2xl bg-surface-800/60 border border-glass-border overflow-x-auto">
|
||||||
|
{TABS.map(tab => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => setActiveTab(tab.id)}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2.5 rounded-xl text-sm font-medium transition-all duration-200 whitespace-nowrap ${
|
||||||
|
activeTab === tab.id
|
||||||
|
? 'bg-gradient-to-r from-purple-600/20 to-indigo-600/20 text-white border border-purple-500/20 shadow-[0_0_12px_rgba(147,51,234,0.1)]'
|
||||||
|
: 'text-gray-500 hover:text-gray-300 hover:bg-glass'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-base">{tab.icon}</span>
|
||||||
|
<span>{tab.label}</span>
|
||||||
|
{tab.id === 'agents' && status && (
|
||||||
|
<span className="ml-1 px-1.5 py-0.5 rounded-full bg-purple-500/20 text-purple-300 text-[10px] font-bold">{status.agent_count}</span>
|
||||||
|
)}
|
||||||
|
{tab.id === 'skills' && status && (
|
||||||
|
<span className="ml-1 px-1.5 py-0.5 rounded-full bg-indigo-500/20 text-indigo-300 text-[10px] font-bold">{status.skill_count}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab Content */}
|
||||||
|
<div className="animate-fade-in">
|
||||||
|
{activeTab === 'status' && <StatusTab status={status} />}
|
||||||
|
{activeTab === 'config' && <ConfigTab />}
|
||||||
|
{activeTab === 'agents' && <AgentsTab />}
|
||||||
|
{activeTab === 'skills' && <SkillsTab />}
|
||||||
|
{activeTab === 'logs' && <LogsTab />}
|
||||||
|
{activeTab === 'models' && <ModelsTab />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ═════════════════════════════════════════════════════════════════════════════════
|
||||||
|
// Tab 1 — Status
|
||||||
|
// ═════════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function StatusTab({ status }: { status: OpenClawStatus | null }) {
|
||||||
|
const [tree, setTree] = useState<FileTreeNode[]>([]);
|
||||||
|
const [treeLoading, setTreeLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.openclawFilesystem()
|
||||||
|
.then(d => { setTree(d.tree); setTreeLoading(false); })
|
||||||
|
.catch(() => setTreeLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!status) return <EmptyState message="Impossible de charger le status" />;
|
||||||
|
|
||||||
|
const metrics = [
|
||||||
|
{ label: 'Gateway', value: status.gateway_online ? 'En ligne' : 'Hors ligne', accent: status.gateway_online ? 'green' : 'red', icon: '🔌' },
|
||||||
|
{ label: 'Mode', value: status.openclaw_type, accent: 'purple', icon: '⚡' },
|
||||||
|
{ label: 'Bind', value: status.gateway_bind, accent: 'blue', icon: '🌐' },
|
||||||
|
{ label: 'Port', value: String(status.gateway_port), accent: 'indigo', icon: '🔗' },
|
||||||
|
{ label: 'Agents', value: String(status.agent_count), accent: 'amber', icon: '🤖' },
|
||||||
|
{ label: 'Skills', value: String(status.skill_count), accent: 'teal', icon: '🧩' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Hero health card */}
|
||||||
|
<div className={`glass-card p-6 relative overflow-hidden ${status.gateway_online ? 'border-green-500/20' : 'border-red-500/20'}`}>
|
||||||
|
<div className="absolute -top-20 -right-20 w-60 h-60 rounded-full blur-3xl opacity-10"
|
||||||
|
style={{ background: status.gateway_online ? 'radial-gradient(circle, #22C55E, transparent)' : 'radial-gradient(circle, #EF4444, transparent)' }}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-6 relative z-10">
|
||||||
|
<div className={`w-20 h-20 rounded-3xl flex items-center justify-center text-4xl ${
|
||||||
|
status.gateway_online
|
||||||
|
? 'bg-gradient-to-br from-green-500/20 to-emerald-600/10 border border-green-500/30'
|
||||||
|
: 'bg-gradient-to-br from-red-500/20 to-rose-600/10 border border-red-500/30'
|
||||||
|
}`} style={status.gateway_online ? { animation: 'pulse-glow 3s ease-in-out infinite' } : undefined}>
|
||||||
|
{status.gateway_online ? '✅' : '❌'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-white">
|
||||||
|
{status.gateway_online ? 'Gateway Opérationnel' : 'Gateway Hors Ligne'}
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-400 text-sm mt-1">
|
||||||
|
{status.foxy_home} — Port {status.gateway_port}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-3 mt-2">
|
||||||
|
<span className={`badge ${status.gateway_online ? 'badge-completed' : 'badge-failed'}`}>
|
||||||
|
{status.gateway_mode}
|
||||||
|
</span>
|
||||||
|
<span className="badge bg-purple-500/15 text-purple-300">
|
||||||
|
{status.openclaw_type}
|
||||||
|
</span>
|
||||||
|
{status.config_exists && (
|
||||||
|
<span className="badge bg-blue-500/15 text-blue-300">config ✓</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Metrics grid */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
|
||||||
|
{metrics.map(m => (
|
||||||
|
<MetricCard key={m.label} {...m} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filesystem tree */}
|
||||||
|
<div className="glass-card p-6">
|
||||||
|
<h2 className="text-lg font-bold text-white mb-4 flex items-center gap-2">
|
||||||
|
<span>📂</span> Arborescence FOXY_HOME
|
||||||
|
</h2>
|
||||||
|
{treeLoading ? (
|
||||||
|
<div className="text-gray-500 text-sm animate-pulse">Chargement de l'arborescence…</div>
|
||||||
|
) : tree.length === 0 ? (
|
||||||
|
<p className="text-gray-500 text-sm">Aucun fichier trouvé</p>
|
||||||
|
) : (
|
||||||
|
<div className="font-mono text-xs max-h-80 overflow-y-auto space-y-0.5">
|
||||||
|
{tree.map(node => (
|
||||||
|
<TreeNode key={node.path} node={node} depth={0} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function MetricCard({ label, value, accent, icon }: { label: string; value: string; accent: string; icon: string }) {
|
||||||
|
const colorMap: Record<string, { bg: string; text: string }> = {
|
||||||
|
green: { bg: 'from-green-500/10 to-green-600/5', text: 'text-green-400' },
|
||||||
|
red: { bg: 'from-red-500/10 to-red-600/5', text: 'text-red-400' },
|
||||||
|
purple: { bg: 'from-purple-500/10 to-purple-600/5', text: 'text-purple-400' },
|
||||||
|
blue: { bg: 'from-blue-500/10 to-blue-600/5', text: 'text-blue-400' },
|
||||||
|
indigo: { bg: 'from-indigo-500/10 to-indigo-600/5', text: 'text-indigo-400' },
|
||||||
|
amber: { bg: 'from-amber-500/10 to-amber-600/5', text: 'text-amber-400' },
|
||||||
|
teal: { bg: 'from-teal-500/10 to-teal-600/5', text: 'text-teal-400' },
|
||||||
|
};
|
||||||
|
const c = colorMap[accent] || colorMap.purple;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`glass-card p-4 bg-gradient-to-br ${c.bg}`}>
|
||||||
|
<div className="text-lg mb-1">{icon}</div>
|
||||||
|
<div className="text-[10px] text-gray-500 uppercase tracking-wider">{label}</div>
|
||||||
|
<div className={`text-lg font-extrabold ${c.text} mt-0.5`}>{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function TreeNode({ node, depth }: { node: FileTreeNode; depth: number }) {
|
||||||
|
const [open, setOpen] = useState(depth < 1);
|
||||||
|
const isDir = node.type === 'directory';
|
||||||
|
const indent = depth * 20;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-2 py-1 px-2 rounded-lg transition-colors ${
|
||||||
|
isDir ? 'hover:bg-surface-700/50 cursor-pointer' : 'text-gray-400'
|
||||||
|
}`}
|
||||||
|
style={{ paddingLeft: `${indent + 8}px` }}
|
||||||
|
onClick={() => isDir && setOpen(!open)}
|
||||||
|
>
|
||||||
|
{isDir ? (
|
||||||
|
<span className="text-yellow-400 w-4 text-center">{open ? '📂' : '📁'}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-500 w-4 text-center">📄</span>
|
||||||
|
)}
|
||||||
|
<span className={isDir ? 'text-white font-medium' : 'text-gray-400'}>{node.name}</span>
|
||||||
|
{node.size != null && !isDir && (
|
||||||
|
<span className="text-gray-600 ml-auto text-[10px]">{formatSize(node.size)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isDir && open && node.children?.map(child => (
|
||||||
|
<TreeNode key={child.path} node={child} depth={depth + 1} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function formatSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ═════════════════════════════════════════════════════════════════════════════════
|
||||||
|
// Tab 2 — Configuration
|
||||||
|
// ═════════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function ConfigTab() {
|
||||||
|
const [config, setConfig] = useState<Record<string, unknown> | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set(['gateway', 'mcpServers']));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.openclawConfig()
|
||||||
|
.then(d => { setConfig(d.config); setLoading(false); })
|
||||||
|
.catch(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) return <LoadingSpinner />;
|
||||||
|
if (!config) return <EmptyState message="openclaw.json introuvable ou illisible" />;
|
||||||
|
|
||||||
|
const sections = Object.entries(config);
|
||||||
|
|
||||||
|
const toggleSection = (key: string) => {
|
||||||
|
setExpandedSections(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.has(key) ? next.delete(key) : next.add(key);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="glass-card p-4 flex items-center gap-3 text-sm">
|
||||||
|
<span className="text-lg">📋</span>
|
||||||
|
<span className="text-gray-400">Lecture de</span>
|
||||||
|
<code className="px-2 py-1 rounded-lg bg-surface-800 text-purple-300 font-mono text-xs">openclaw.json</code>
|
||||||
|
<span className="text-gray-500">— {sections.length} sections</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sections.map(([key, value]) => {
|
||||||
|
const isObject = typeof value === 'object' && value !== null;
|
||||||
|
const isExpanded = expandedSections.has(key);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={key} className="glass-card overflow-hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => isObject && toggleSection(key)}
|
||||||
|
className={`w-full flex items-center justify-between p-4 text-left transition-colors ${
|
||||||
|
isObject ? 'hover:bg-glass-hover cursor-pointer' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-base">
|
||||||
|
{isObject ? (isExpanded ? '▼' : '▶') : '•'}
|
||||||
|
</span>
|
||||||
|
<span className="text-white font-semibold text-sm">{key}</span>
|
||||||
|
{isObject && (
|
||||||
|
<span className="text-[10px] text-gray-600 uppercase tracking-wider">
|
||||||
|
{Array.isArray(value)
|
||||||
|
? `${(value as unknown[]).length} items`
|
||||||
|
: `${Object.keys(value as object).length} clés`
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!isObject && (
|
||||||
|
<code className="text-sm text-purple-300 font-mono">{renderValue(value)}</code>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{isObject && isExpanded && (
|
||||||
|
<div className="border-t border-glass-border p-4 bg-surface-900/40">
|
||||||
|
<pre className="text-xs font-mono text-gray-300 whitespace-pre-wrap overflow-x-auto max-h-96 leading-relaxed">
|
||||||
|
{JSON.stringify(value, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function renderValue(v: unknown): string {
|
||||||
|
if (v === null) return 'null';
|
||||||
|
if (typeof v === 'boolean') return v ? 'true' : 'false';
|
||||||
|
if (typeof v === 'number') return String(v);
|
||||||
|
if (typeof v === 'string') return v.length > 80 ? v.slice(0, 77) + '…' : v;
|
||||||
|
return String(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ═════════════════════════════════════════════════════════════════════════════════
|
||||||
|
// Tab 3 — Agents
|
||||||
|
// ═════════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function AgentsTab() {
|
||||||
|
const [agents, setAgents] = useState<OpenClawAgent[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [expandedAgent, setExpandedAgent] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.openclawAgents()
|
||||||
|
.then(d => { setAgents(d.agents); setLoading(false); })
|
||||||
|
.catch(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) return <LoadingSpinner />;
|
||||||
|
if (agents.length === 0) return <EmptyState message="Aucun agent trouvé dans le répertoire agents/" />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Agent count header */}
|
||||||
|
<div className="glass-card p-4 flex items-center gap-3">
|
||||||
|
<span className="text-lg">🤖</span>
|
||||||
|
<span className="text-white font-semibold">{agents.length} agents</span>
|
||||||
|
<span className="text-gray-500 text-sm">détectés dans le répertoire OpenClaw</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Agent grid */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
{agents.map(agent => (
|
||||||
|
<div
|
||||||
|
key={agent.name}
|
||||||
|
className={`glass-card overflow-hidden transition-all duration-300 ${
|
||||||
|
expandedAgent === agent.name ? 'ring-1 ring-purple-500/30' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="p-5 cursor-pointer hover:bg-glass-hover transition-colors"
|
||||||
|
onClick={() => setExpandedAgent(expandedAgent === agent.name ? null : agent.name)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-amber-500/20 to-orange-600/10 border border-amber-500/20 flex items-center justify-center text-xl">
|
||||||
|
🤖
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-white font-semibold text-sm">{agent.name}</h3>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<span className="badge bg-surface-600 text-gray-300 text-[10px]">{agent.type}</span>
|
||||||
|
{agent.model && (
|
||||||
|
<span className="badge bg-purple-500/15 text-purple-300 text-[10px]">{agent.model}</span>
|
||||||
|
)}
|
||||||
|
{agent.file_count != null && (
|
||||||
|
<span className="text-[10px] text-gray-600">{agent.file_count} fichiers</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-gray-600 text-sm">{expandedAgent === agent.name ? '▲' : '▼'}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Config files */}
|
||||||
|
{agent.config_files && agent.config_files.length > 0 && (
|
||||||
|
<div className="flex gap-2 mt-3 flex-wrap">
|
||||||
|
{agent.config_files.map(f => (
|
||||||
|
<span key={f} className="px-2 py-0.5 rounded-md bg-surface-700 text-gray-400 text-[10px] font-mono">{f}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expanded details */}
|
||||||
|
{expandedAgent === agent.name && (
|
||||||
|
<div className="border-t border-glass-border p-5 bg-surface-900/40 space-y-4">
|
||||||
|
{agent.system_prompt && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-xs text-gray-500 uppercase tracking-wider mb-2">System Prompt</h4>
|
||||||
|
<div className="p-3 rounded-xl bg-surface-800 text-xs text-gray-300 font-mono whitespace-pre-wrap max-h-48 overflow-y-auto leading-relaxed">
|
||||||
|
{agent.system_prompt}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{agent.config && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-xs text-gray-500 uppercase tracking-wider mb-2">Configuration complète</h4>
|
||||||
|
<pre className="p-3 rounded-xl bg-surface-800 text-xs text-gray-300 font-mono whitespace-pre-wrap max-h-64 overflow-y-auto leading-relaxed">
|
||||||
|
{JSON.stringify(agent.config, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ═════════════════════════════════════════════════════════════════════════════════
|
||||||
|
// Tab 4 — Skills
|
||||||
|
// ═════════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function SkillsTab() {
|
||||||
|
const [skills, setSkills] = useState<OpenClawSkill[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [expandedSkill, setExpandedSkill] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.openclawSkills()
|
||||||
|
.then(d => { setSkills(d.skills); setLoading(false); })
|
||||||
|
.catch(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) return <LoadingSpinner />;
|
||||||
|
if (skills.length === 0) return <EmptyState message="Aucun skill trouvé" />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="glass-card p-4 flex items-center gap-3">
|
||||||
|
<span className="text-lg">🧩</span>
|
||||||
|
<span className="text-white font-semibold">{skills.length} skills</span>
|
||||||
|
<span className="text-gray-500 text-sm">disponibles</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||||
|
{skills.map(skill => (
|
||||||
|
<div
|
||||||
|
key={`${skill.source}/${skill.name}`}
|
||||||
|
className="glass-card overflow-hidden"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="p-5 cursor-pointer hover:bg-glass-hover transition-colors"
|
||||||
|
onClick={() => setExpandedSkill(expandedSkill === skill.name ? null : skill.name)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-indigo-500/20 to-blue-600/10 border border-indigo-500/20 flex items-center justify-center text-xl">
|
||||||
|
🧩
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-white font-semibold text-sm">{skill.name}</h3>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<span className="badge bg-surface-600 text-gray-300 text-[10px]">{skill.type}</span>
|
||||||
|
<span className="text-[10px] text-gray-600 font-mono">{skill.source}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{skill.description_short && (
|
||||||
|
<p className="text-xs text-gray-400 mt-3 leading-relaxed">{skill.description_short}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{skill.subdirs && skill.subdirs.length > 0 && (
|
||||||
|
<div className="flex gap-1.5 mt-3 flex-wrap">
|
||||||
|
{skill.subdirs.map(d => (
|
||||||
|
<span key={d} className="px-2 py-0.5 rounded-md bg-surface-700 text-gray-400 text-[10px] font-mono">📁 {d}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 mt-3 text-[10px] text-gray-600">
|
||||||
|
{skill.file_count != null && <span>{skill.file_count} fichiers</span>}
|
||||||
|
{skill.size != null && <span>{formatSize(skill.size)}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expandedSkill === skill.name && skill.description && (
|
||||||
|
<div className="border-t border-glass-border p-4 bg-surface-900/40">
|
||||||
|
<h4 className="text-xs text-gray-500 uppercase tracking-wider mb-2">Description</h4>
|
||||||
|
<pre className="text-xs text-gray-300 font-mono whitespace-pre-wrap max-h-48 overflow-y-auto leading-relaxed">
|
||||||
|
{skill.description}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ═════════════════════════════════════════════════════════════════════════════════
|
||||||
|
// Tab 5 — Logs (Terminal style)
|
||||||
|
// ═════════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function LogsTab() {
|
||||||
|
const [logs, setLogs] = useState<OpenClawLogs | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [levelFilter, setLevelFilter] = useState<string>('');
|
||||||
|
const [lineCount, setLineCount] = useState(200);
|
||||||
|
const [autoRefresh, setAutoRefresh] = useState(false);
|
||||||
|
const logEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const fetchLogs = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const params: { lines: number; level?: string } = { lines: lineCount };
|
||||||
|
if (levelFilter) params.level = levelFilter;
|
||||||
|
const data = await api.openclawLogs(params);
|
||||||
|
setLogs(data);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
setLoading(false);
|
||||||
|
}, [lineCount, levelFilter]);
|
||||||
|
|
||||||
|
useEffect(() => { fetchLogs(); }, [fetchLogs]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!autoRefresh) return;
|
||||||
|
const interval = setInterval(fetchLogs, 5000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [autoRefresh, fetchLogs]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (logEndRef.current) {
|
||||||
|
logEndRef.current.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
}, [logs]);
|
||||||
|
|
||||||
|
function getLineStyle(line: string): string {
|
||||||
|
const upper = line.toUpperCase();
|
||||||
|
if (upper.includes('ERROR') || upper.includes('FATAL') || upper.includes('PANIC')) return 'text-red-400';
|
||||||
|
if (upper.includes('WARN')) return 'text-yellow-400';
|
||||||
|
if (upper.includes('DEBUG') || upper.includes('TRACE')) return 'text-gray-600';
|
||||||
|
if (upper.includes('INFO')) return 'text-green-300';
|
||||||
|
return 'text-gray-400';
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="glass-card p-4 flex items-center gap-3 flex-wrap">
|
||||||
|
<span className="text-lg">📟</span>
|
||||||
|
<span className="text-white font-semibold text-sm">Gateway Logs</span>
|
||||||
|
|
||||||
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
{/* Level filter */}
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{['', 'INFO', 'WARNING', 'ERROR'].map(lvl => (
|
||||||
|
<button
|
||||||
|
key={lvl}
|
||||||
|
onClick={() => setLevelFilter(lvl)}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
|
||||||
|
levelFilter === lvl
|
||||||
|
? 'bg-purple-500/20 text-purple-300 border border-purple-500/30'
|
||||||
|
: 'text-gray-500 hover:text-gray-300 bg-surface-700/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{lvl || 'Tous'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Line count */}
|
||||||
|
<select
|
||||||
|
value={lineCount}
|
||||||
|
onChange={e => setLineCount(Number(e.target.value))}
|
||||||
|
className="input !w-auto !py-1.5 text-xs"
|
||||||
|
>
|
||||||
|
<option value={50}>50 lignes</option>
|
||||||
|
<option value={200}>200 lignes</option>
|
||||||
|
<option value={500}>500 lignes</option>
|
||||||
|
<option value={1000}>1000 lignes</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Auto-refresh toggle */}
|
||||||
|
<button
|
||||||
|
onClick={() => setAutoRefresh(!autoRefresh)}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
|
||||||
|
autoRefresh
|
||||||
|
? 'bg-green-500/20 text-green-300 border border-green-500/30'
|
||||||
|
: 'text-gray-500 bg-surface-700/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{autoRefresh ? '⏸ Auto' : '▶ Auto'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Refresh button */}
|
||||||
|
<button onClick={fetchLogs} className="btn btn-ghost text-xs !py-1.5">
|
||||||
|
🔄 Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Terminal */}
|
||||||
|
{loading ? <LoadingSpinner /> : (
|
||||||
|
<div className="rounded-2xl overflow-hidden border border-glass-border">
|
||||||
|
{/* Terminal header */}
|
||||||
|
<div className="flex items-center gap-3 px-4 py-2.5 bg-surface-800 border-b border-glass-border">
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<span className="w-3 h-3 rounded-full bg-red-500/60"></span>
|
||||||
|
<span className="w-3 h-3 rounded-full bg-yellow-500/60"></span>
|
||||||
|
<span className="w-3 h-3 rounded-full bg-green-500/60"></span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-gray-500 font-mono flex-1">
|
||||||
|
{logs?.log_path || 'gateway.log'}
|
||||||
|
</span>
|
||||||
|
{logs && (
|
||||||
|
<span className="text-[10px] text-gray-600">
|
||||||
|
{logs.showing} / {logs.total_lines} lignes
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Terminal content */}
|
||||||
|
<div className="p-4 max-h-[600px] overflow-y-auto font-mono text-xs leading-5"
|
||||||
|
style={{ background: '#0a0e17' }}
|
||||||
|
>
|
||||||
|
{logs?.error && (
|
||||||
|
<div className="text-red-400 mb-3 p-2 rounded-lg bg-red-500/10">{logs.error}</div>
|
||||||
|
)}
|
||||||
|
{(!logs?.lines || logs.lines.length === 0) ? (
|
||||||
|
<div className="text-gray-600">Aucune ligne de log</div>
|
||||||
|
) : (
|
||||||
|
logs.lines.map((line, i) => (
|
||||||
|
<div key={i} className={`hover:bg-white/[0.02] px-1 rounded ${getLineStyle(line)}`}>
|
||||||
|
<span className="text-gray-700 select-none mr-3 inline-block w-10 text-right">{i + 1}</span>
|
||||||
|
{line}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
<div ref={logEndRef} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ═════════════════════════════════════════════════════════════════════════════════
|
||||||
|
// Tab 6 — Models & Providers
|
||||||
|
// ═════════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function ModelsTab() {
|
||||||
|
const [data, setData] = useState<OpenClawModels | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.openclawModels()
|
||||||
|
.then(d => { setData(d); setLoading(false); })
|
||||||
|
.catch(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) return <LoadingSpinner />;
|
||||||
|
if (!data || data.error) return <EmptyState message={data?.error || 'Impossible de charger les modèles'} />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Providers */}
|
||||||
|
{data.providers.length > 0 && (
|
||||||
|
<div className="glass-card p-6">
|
||||||
|
<h2 className="text-lg font-bold text-white mb-4 flex items-center gap-2">
|
||||||
|
<span>🔌</span> Providers / MCP Servers
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
|
{data.providers.map((p, i) => (
|
||||||
|
<div key={i} className="p-4 rounded-xl bg-surface-800/50 border border-glass-border hover:border-purple-500/20 transition-colors">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-purple-500/20 to-indigo-600/10 border border-purple-500/20 flex items-center justify-center text-sm">
|
||||||
|
🔌
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-white font-semibold text-sm">{String(p.name || `Provider ${i + 1}`)}</h3>
|
||||||
|
{p.type ? <span className="text-[10px] text-gray-500">{String(p.type)}</span> : null}
|
||||||
|
</div>
|
||||||
|
{p.enabled !== undefined && (
|
||||||
|
<span className={`badge ml-auto text-[10px] ${p.enabled ? 'badge-completed' : 'badge-paused'}`}>
|
||||||
|
{p.enabled ? 'actif' : 'inactif'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{p.model ? (
|
||||||
|
<div className="text-xs text-purple-300 font-mono mt-1">
|
||||||
|
Modèle : {String(p.model)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{p.endpoint ? (
|
||||||
|
<div className="text-[10px] text-gray-600 font-mono mt-1 truncate">
|
||||||
|
{String(p.endpoint)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Models */}
|
||||||
|
{data.models.length > 0 && (
|
||||||
|
<div className="glass-card p-6">
|
||||||
|
<h2 className="text-lg font-bold text-white mb-4 flex items-center gap-2">
|
||||||
|
<span>🧠</span> Modèles configurés
|
||||||
|
</h2>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-glass-border">
|
||||||
|
<th className="text-left text-xs text-gray-500 uppercase tracking-wider py-3 px-4">Nom</th>
|
||||||
|
<th className="text-left text-xs text-gray-500 uppercase tracking-wider py-3 px-4">Détails</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.models.map((m, i) => (
|
||||||
|
<tr key={i} className="border-b border-glass-border/50 hover:bg-glass-hover transition-colors">
|
||||||
|
<td className="py-3 px-4">
|
||||||
|
<span className="text-white font-medium">{String(m.name || `Model ${i + 1}`)}</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4">
|
||||||
|
<pre className="text-xs text-gray-400 font-mono whitespace-pre-wrap">
|
||||||
|
{JSON.stringify(m, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Raw config keys */}
|
||||||
|
{data.raw_keys && data.raw_keys.length > 0 && (
|
||||||
|
<div className="glass-card p-6">
|
||||||
|
<h2 className="text-sm font-bold text-gray-400 mb-3 flex items-center gap-2">
|
||||||
|
<span>🔑</span> Clés de configuration détectées
|
||||||
|
</h2>
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{data.raw_keys.map(k => (
|
||||||
|
<span key={k} className="px-3 py-1 rounded-lg bg-surface-700 text-gray-400 text-xs font-mono">{k}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data.providers.length === 0 && data.models.length === 0 && (
|
||||||
|
<EmptyState message="Aucun modèle ou provider détecté dans la configuration" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ═════════════════════════════════════════════════════════════════════════════════
|
||||||
|
// Shared Components
|
||||||
|
// ═════════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function LoadingSpinner() {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-48">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-3xl animate-spin-slow mb-2">🐙</div>
|
||||||
|
<div className="text-gray-600 text-xs">Chargement…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyState({ message }: { message: string }) {
|
||||||
|
return (
|
||||||
|
<div className="glass-card p-12 text-center">
|
||||||
|
<div className="text-4xl mb-3 opacity-40">🐙</div>
|
||||||
|
<p className="text-gray-500 text-sm">{message}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user