diff --git a/backend/app/main.py b/backend/app/main.py index 3cba4a7..a56285d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -15,7 +15,7 @@ import os from app.config import settings 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 # ─── Logging ─────────────────────────────────────────────────────────────────── @@ -64,6 +64,7 @@ app.include_router(agents.router) app.include_router(logs.router) app.include_router(workflows.router) app.include_router(config.router) +app.include_router(openclaw.router) # ─── Static Files ───────────────────────────────────────────────────────────── diff --git a/backend/app/routers/openclaw.py b/backend/app/routers/openclaw.py new file mode 100644 index 0000000..7f37aec --- /dev/null +++ b/backend/app/routers/openclaw.py @@ -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} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 69d7e33..3c7c701 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,12 +4,14 @@ import Projects from './pages/Projects'; import Agents from './pages/Agents'; import Logs from './pages/Logs'; import Settings from './pages/Settings'; +import OpenClaw from './pages/OpenClaw'; const NAV_ITEMS = [ { path: '/', label: 'Dashboard', icon: '📊' }, { path: '/projects', label: 'Projets', icon: '📋' }, { path: '/agents', label: 'Agents', icon: '🤖' }, { path: '/logs', label: 'Logs', icon: '📜' }, + { path: '/openclaw', label: 'OpenClaw', icon: '🐙' }, { path: '/settings', label: 'Config', icon: '⚙️' }, ]; @@ -60,6 +62,7 @@ export default function App() { } /> } /> } /> + } /> } /> diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index f2c05a2..75ecf64 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -148,6 +148,67 @@ export interface AppConfig { 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; + 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[]; + providers: Record[]; + raw_keys?: string[]; + error?: string; +} + +export interface FileTreeNode { + name: string; + path: string; + type: 'file' | 'directory'; + size?: number; + children?: FileTreeNode[]; +} + // ─── HTTP Helpers ─────────────────────────────────────────────────────────── async function request(path: string, options?: RequestInit): Promise { @@ -227,4 +288,18 @@ export const api = { // Health health: () => request<{ status: string; version: string }>('/api/health'), + + // OpenClaw + openclawStatus: () => request('/api/openclaw/status'), + openclawConfig: () => request<{ config: Record | 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(`/api/openclaw/logs${qs}`); + }, + openclawModels: () => request('/api/openclaw/models'), + openclawFilesystem: () => request<{ root: string; tree: FileTreeNode[] }>('/api/openclaw/filesystem'), }; diff --git a/frontend/src/pages/OpenClaw.tsx b/frontend/src/pages/OpenClaw.tsx new file mode 100644 index 0000000..1680c47 --- /dev/null +++ b/frontend/src/pages/OpenClaw.tsx @@ -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('status'); + const [status, setStatus] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + api.openclawStatus() + .then(s => { setStatus(s); setLoading(false); }) + .catch(() => setLoading(false)); + }, []); + + if (loading) { + return ( +
+
+
🐙
+
Connexion à OpenClaw…
+
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+
+ 🐙 +
+
+

OpenClaw

+
+ + {status?.gateway_online ? 'Gateway opérationnel' : 'Gateway hors ligne'} + + {status?.openclaw_type} +
+
+
+
+ + {/* Tab Bar */} +
+ {TABS.map(tab => ( + + ))} +
+ + {/* Tab Content */} +
+ {activeTab === 'status' && } + {activeTab === 'config' && } + {activeTab === 'agents' && } + {activeTab === 'skills' && } + {activeTab === 'logs' && } + {activeTab === 'models' && } +
+
+ ); +} + + +// ═════════════════════════════════════════════════════════════════════════════════ +// Tab 1 — Status +// ═════════════════════════════════════════════════════════════════════════════════ + +function StatusTab({ status }: { status: OpenClawStatus | null }) { + const [tree, setTree] = useState([]); + const [treeLoading, setTreeLoading] = useState(true); + + useEffect(() => { + api.openclawFilesystem() + .then(d => { setTree(d.tree); setTreeLoading(false); }) + .catch(() => setTreeLoading(false)); + }, []); + + if (!status) return ; + + 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 ( +
+ {/* Hero health card */} +
+
+
+
+ {status.gateway_online ? '✅' : '❌'} +
+
+

+ {status.gateway_online ? 'Gateway Opérationnel' : 'Gateway Hors Ligne'} +

+

+ {status.foxy_home} — Port {status.gateway_port} +

+
+ + {status.gateway_mode} + + + {status.openclaw_type} + + {status.config_exists && ( + config ✓ + )} +
+
+
+
+ + {/* Metrics grid */} +
+ {metrics.map(m => ( + + ))} +
+ + {/* Filesystem tree */} +
+

+ 📂 Arborescence FOXY_HOME +

+ {treeLoading ? ( +
Chargement de l'arborescence…
+ ) : tree.length === 0 ? ( +

Aucun fichier trouvé

+ ) : ( +
+ {tree.map(node => ( + + ))} +
+ )} +
+
+ ); +} + + +function MetricCard({ label, value, accent, icon }: { label: string; value: string; accent: string; icon: string }) { + const colorMap: Record = { + 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 ( +
+
{icon}
+
{label}
+
{value}
+
+ ); +} + + +function TreeNode({ node, depth }: { node: FileTreeNode; depth: number }) { + const [open, setOpen] = useState(depth < 1); + const isDir = node.type === 'directory'; + const indent = depth * 20; + + return ( +
+
isDir && setOpen(!open)} + > + {isDir ? ( + {open ? '📂' : '📁'} + ) : ( + 📄 + )} + {node.name} + {node.size != null && !isDir && ( + {formatSize(node.size)} + )} +
+ {isDir && open && node.children?.map(child => ( + + ))} +
+ ); +} + + +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 | null>(null); + const [loading, setLoading] = useState(true); + const [expandedSections, setExpandedSections] = useState>(new Set(['gateway', 'mcpServers'])); + + useEffect(() => { + api.openclawConfig() + .then(d => { setConfig(d.config); setLoading(false); }) + .catch(() => setLoading(false)); + }, []); + + if (loading) return ; + if (!config) return ; + + 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 ( +
+
+ 📋 + Lecture de + openclaw.json + — {sections.length} sections +
+ + {sections.map(([key, value]) => { + const isObject = typeof value === 'object' && value !== null; + const isExpanded = expandedSections.has(key); + + return ( +
+ + {isObject && isExpanded && ( +
+
+                  {JSON.stringify(value, null, 2)}
+                
+
+ )} +
+ ); + })} +
+ ); +} + + +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([]); + const [loading, setLoading] = useState(true); + const [expandedAgent, setExpandedAgent] = useState(null); + + useEffect(() => { + api.openclawAgents() + .then(d => { setAgents(d.agents); setLoading(false); }) + .catch(() => setLoading(false)); + }, []); + + if (loading) return ; + if (agents.length === 0) return ; + + return ( +
+ {/* Agent count header */} +
+ 🤖 + {agents.length} agents + détectés dans le répertoire OpenClaw +
+ + {/* Agent grid */} +
+ {agents.map(agent => ( +
+
setExpandedAgent(expandedAgent === agent.name ? null : agent.name)} + > +
+
+
+ 🤖 +
+
+

{agent.name}

+
+ {agent.type} + {agent.model && ( + {agent.model} + )} + {agent.file_count != null && ( + {agent.file_count} fichiers + )} +
+
+
+ {expandedAgent === agent.name ? '▲' : '▼'} +
+ + {/* Config files */} + {agent.config_files && agent.config_files.length > 0 && ( +
+ {agent.config_files.map(f => ( + {f} + ))} +
+ )} +
+ + {/* Expanded details */} + {expandedAgent === agent.name && ( +
+ {agent.system_prompt && ( +
+

System Prompt

+
+ {agent.system_prompt} +
+
+ )} + {agent.config && ( +
+

Configuration complète

+
+                      {JSON.stringify(agent.config, null, 2)}
+                    
+
+ )} +
+ )} +
+ ))} +
+
+ ); +} + + +// ═════════════════════════════════════════════════════════════════════════════════ +// Tab 4 — Skills +// ═════════════════════════════════════════════════════════════════════════════════ + +function SkillsTab() { + const [skills, setSkills] = useState([]); + const [loading, setLoading] = useState(true); + const [expandedSkill, setExpandedSkill] = useState(null); + + useEffect(() => { + api.openclawSkills() + .then(d => { setSkills(d.skills); setLoading(false); }) + .catch(() => setLoading(false)); + }, []); + + if (loading) return ; + if (skills.length === 0) return ; + + return ( +
+
+ 🧩 + {skills.length} skills + disponibles +
+ +
+ {skills.map(skill => ( +
+
setExpandedSkill(expandedSkill === skill.name ? null : skill.name)} + > +
+
+
+ 🧩 +
+
+

{skill.name}

+
+ {skill.type} + {skill.source} +
+
+
+
+ + {skill.description_short && ( +

{skill.description_short}

+ )} + + {skill.subdirs && skill.subdirs.length > 0 && ( +
+ {skill.subdirs.map(d => ( + 📁 {d} + ))} +
+ )} + +
+ {skill.file_count != null && {skill.file_count} fichiers} + {skill.size != null && {formatSize(skill.size)}} +
+
+ + {expandedSkill === skill.name && skill.description && ( +
+

Description

+
+                  {skill.description}
+                
+
+ )} +
+ ))} +
+
+ ); +} + + +// ═════════════════════════════════════════════════════════════════════════════════ +// Tab 5 — Logs (Terminal style) +// ═════════════════════════════════════════════════════════════════════════════════ + +function LogsTab() { + const [logs, setLogs] = useState(null); + const [loading, setLoading] = useState(true); + const [levelFilter, setLevelFilter] = useState(''); + const [lineCount, setLineCount] = useState(200); + const [autoRefresh, setAutoRefresh] = useState(false); + const logEndRef = useRef(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 ( +
+ {/* Toolbar */} +
+ 📟 + Gateway Logs + +
+ + {/* Level filter */} +
+ {['', 'INFO', 'WARNING', 'ERROR'].map(lvl => ( + + ))} +
+ + {/* Line count */} + + + {/* Auto-refresh toggle */} + + + {/* Refresh button */} + +
+ + {/* Terminal */} + {loading ? : ( +
+ {/* Terminal header */} +
+
+ + + +
+ + {logs?.log_path || 'gateway.log'} + + {logs && ( + + {logs.showing} / {logs.total_lines} lignes + + )} +
+ + {/* Terminal content */} +
+ {logs?.error && ( +
{logs.error}
+ )} + {(!logs?.lines || logs.lines.length === 0) ? ( +
Aucune ligne de log
+ ) : ( + logs.lines.map((line, i) => ( +
+ {i + 1} + {line} +
+ )) + )} +
+
+
+ )} +
+ ); +} + + +// ═════════════════════════════════════════════════════════════════════════════════ +// Tab 6 — Models & Providers +// ═════════════════════════════════════════════════════════════════════════════════ + +function ModelsTab() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + api.openclawModels() + .then(d => { setData(d); setLoading(false); }) + .catch(() => setLoading(false)); + }, []); + + if (loading) return ; + if (!data || data.error) return ; + + return ( +
+ {/* Providers */} + {data.providers.length > 0 && ( +
+

+ 🔌 Providers / MCP Servers +

+
+ {data.providers.map((p, i) => ( +
+
+
+ 🔌 +
+
+

{String(p.name || `Provider ${i + 1}`)}

+ {p.type ? {String(p.type)} : null} +
+ {p.enabled !== undefined && ( + + {p.enabled ? 'actif' : 'inactif'} + + )} +
+ {p.model ? ( +
+ Modèle : {String(p.model)} +
+ ) : null} + {p.endpoint ? ( +
+ {String(p.endpoint)} +
+ ) : null} +
+ ))} +
+
+ )} + + {/* Models */} + {data.models.length > 0 && ( +
+

+ 🧠 Modèles configurés +

+
+ + + + + + + + + {data.models.map((m, i) => ( + + + + + ))} + +
NomDétails
+ {String(m.name || `Model ${i + 1}`)} + +
+                        {JSON.stringify(m, null, 2)}
+                      
+
+
+
+ )} + + {/* Raw config keys */} + {data.raw_keys && data.raw_keys.length > 0 && ( +
+

+ 🔑 Clés de configuration détectées +

+
+ {data.raw_keys.map(k => ( + {k} + ))} +
+
+ )} + + {data.providers.length === 0 && data.models.length === 0 && ( + + )} +
+ ); +} + + +// ═════════════════════════════════════════════════════════════════════════════════ +// Shared Components +// ═════════════════════════════════════════════════════════════════════════════════ + +function LoadingSpinner() { + return ( +
+
+
🐙
+
Chargement…
+
+
+ ); +} + +function EmptyState({ message }: { message: string }) { + return ( +
+
🐙
+

{message}

+
+ ); +}