feat: Add OpenClaw integration with new UI, API client, and backend routes.

This commit is contained in:
Bruno Charest 2026-03-13 20:34:04 -04:00
parent da3020b4f1
commit 69642a17ea
5 changed files with 1288 additions and 1 deletions

View File

@ -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 ─────────────────────────────────────────────────────────────

View 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}

View File

@ -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() {
<Route path="/projects" element={<Projects />} />
<Route path="/agents" element={<Agents />} />
<Route path="/logs" element={<Logs />} />
<Route path="/openclaw" element={<OpenClaw />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</main>

View File

@ -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<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 ───────────────────────────────────────────────────────────
async function request<T>(path: string, options?: RequestInit): Promise<T> {
@ -227,4 +288,18 @@ export const 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'),
};

View 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>
);
}