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

This commit is contained in:
2026-03-13 20:34:04 -04:00
parent da3020b4f1
commit 69642a17ea
5 changed files with 1288 additions and 1 deletions
+3
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>
+75
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'),
};
+817
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>
);
}