Fix issues from Sherlock review
HIGH: - Add openclaw-api.ts with gateway client - Add useOpenClaw.ts hook with polling and cleanup - Fix memory leak: replace repeat:Infinity with CSS animations - Fix canvas: use ResizeObserver instead of hardcoded 800x600 - Fix AgentDetailPanel to receive events as prop MEDIUM: - SearchAndFilters: useEffect instead of useMemo - Locale: use navigator.language instead of hardcoded es-CL - StatusBar: timestamp updates every second with cleanup LOW: - Add CSS animations for node-pulse, node-glow, connection-line
This commit is contained in:
@@ -82,13 +82,10 @@ export function AgentNode({ agent, isHighlighted, onHover, onClick }: AgentNodeP
|
||||
}}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
>
|
||||
{/* Glow ring for active agents */}
|
||||
{/* Glow ring for active agents - CSS animation instead of repeat: Infinity */}
|
||||
{isActive && (
|
||||
<motion.div
|
||||
className={cn('absolute inset-0 rounded-full', colors.pulse)}
|
||||
style={{ filter: 'blur(20px)', opacity: 0.3 }}
|
||||
animate={{ scale: [1, 1.3, 1], opacity: [0.3, 0.5, 0.3] }}
|
||||
transition={{ duration: 2, repeat: Infinity }}
|
||||
<div
|
||||
className={cn('absolute inset-0 rounded-full node-glow', colors.pulse)}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { motion } from 'framer-motion'
|
||||
import { useRef, useEffect, useState } from 'react'
|
||||
import type { Connection, Agent } from '../types/agent'
|
||||
import { cn } from '../lib/utils'
|
||||
|
||||
interface ConnectionsLayerProps {
|
||||
connections: Connection[]
|
||||
@@ -16,12 +15,30 @@ const connectionTypeStyles = {
|
||||
}
|
||||
|
||||
export function ConnectionsLayer({ connections, agents, highlightedAgentId }: ConnectionsLayerProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [dimensions, setDimensions] = useState({ width: 800, height: 600 })
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const { width, height } = entry.contentRect
|
||||
setDimensions({ width, height })
|
||||
}
|
||||
})
|
||||
|
||||
resizeObserver.observe(container)
|
||||
return () => resizeObserver.disconnect()
|
||||
}, [])
|
||||
|
||||
const getAgentPosition = (agentId: string) => {
|
||||
const agent = agents.find(a => a.id === agentId)
|
||||
if (!agent) return { x: 0, y: 0 }
|
||||
return {
|
||||
x: (agent.position.x / 100) * 800, // Canvas width
|
||||
y: (agent.position.y / 100) * 600 // Canvas height
|
||||
x: (agent.position.x / 100) * dimensions.width,
|
||||
y: (agent.position.y / 100) * dimensions.height
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,9 +48,9 @@ export function ConnectionsLayer({ connections, agents, highlightedAgentId }: Co
|
||||
}
|
||||
|
||||
return (
|
||||
<svg className="absolute inset-0 w-full h-full pointer-events-none">
|
||||
<div ref={containerRef} className="absolute inset-0">
|
||||
<svg className="w-full h-full pointer-events-none">
|
||||
<defs>
|
||||
{/* Glow filter */}
|
||||
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="3" result="coloredBlur" />
|
||||
<feMerge>
|
||||
@@ -42,7 +59,6 @@ export function ConnectionsLayer({ connections, agents, highlightedAgentId }: Co
|
||||
</feMerge>
|
||||
</filter>
|
||||
|
||||
{/* Arrow marker */}
|
||||
<marker
|
||||
id="arrowhead"
|
||||
markerWidth="10"
|
||||
@@ -62,7 +78,6 @@ export function ConnectionsLayer({ connections, agents, highlightedAgentId }: Co
|
||||
const isHighlighted = isConnectionHighlighted(connection)
|
||||
const isActive = connection.active
|
||||
|
||||
// Calculate control point for curved line
|
||||
const midX = (from.x + to.x) / 2
|
||||
const midY = (from.y + to.y) / 2
|
||||
const dx = to.x - from.x
|
||||
@@ -72,45 +87,35 @@ export function ConnectionsLayer({ connections, agents, highlightedAgentId }: Co
|
||||
|
||||
return (
|
||||
<g key={connection.id}>
|
||||
{/* Shadow/glow line */}
|
||||
{isHighlighted && (
|
||||
<motion.path
|
||||
<path
|
||||
d={`M ${from.x} ${from.y} Q ${ctrlX} ${ctrlY} ${to.x} ${to.y}`}
|
||||
stroke={styles.color}
|
||||
strokeWidth={6}
|
||||
fill="none"
|
||||
opacity={0.2}
|
||||
filter="url(#glow)"
|
||||
initial={{ pathLength: 0 }}
|
||||
animate={{ pathLength: 1 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Main line */}
|
||||
<motion.path
|
||||
<path
|
||||
d={`M ${from.x} ${from.y} Q ${ctrlX} ${ctrlY} ${to.x} ${to.y}`}
|
||||
stroke={styles.color}
|
||||
strokeWidth={isHighlighted ? 3 : 2}
|
||||
strokeOpacity={isHighlighted ? 1 : 0.4}
|
||||
fill="none"
|
||||
strokeDasharray={styles.dashArray}
|
||||
className={cn(
|
||||
'transition-all duration-300',
|
||||
isActive && 'connection-line'
|
||||
)}
|
||||
className={isActive ? 'connection-line' : 'transition-all duration-300'}
|
||||
style={{
|
||||
filter: isHighlighted ? 'url(#glow)' : undefined,
|
||||
color: styles.color
|
||||
}}
|
||||
markerEnd="url(#arrowhead)"
|
||||
initial={{ pathLength: 0 }}
|
||||
animate={{ pathLength: 1 }}
|
||||
transition={{ duration: 0.8, delay: connection.id.charCodeAt(1) * 0.05 }}
|
||||
/>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { Activity, AlertTriangle, Clock, CheckCircle2, Zap, RefreshCw, Wifi, WifiOff } from 'lucide-react'
|
||||
import type { Agent } from '../types/agent'
|
||||
import type { GatewayHealth } from '../lib/openclaw-api'
|
||||
|
||||
interface StatusBarProps {
|
||||
agents: Agent[]
|
||||
health?: GatewayHealth
|
||||
health?: { status: string; uptime: number; sessions: number }
|
||||
lastUpdated?: Date | null
|
||||
loading?: boolean
|
||||
error?: string | null
|
||||
@@ -13,18 +13,30 @@ interface StatusBarProps {
|
||||
}
|
||||
|
||||
export function StatusBar({ agents, health, lastUpdated, loading = false, error, connected = false, onRefresh }: StatusBarProps) {
|
||||
const stats = {
|
||||
const [currentTime, setCurrentTime] = useState(new Date())
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setCurrentTime(new Date())
|
||||
}, 1000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
const stats = useMemo(() => ({
|
||||
active: agents.filter(a => a.status === 'active').length,
|
||||
thinking: agents.filter(a => a.status === 'thinking').length,
|
||||
idle: agents.filter(a => a.status === 'idle').length,
|
||||
blocked: agents.filter(a => a.status === 'blocked').length,
|
||||
completed: agents.filter(a => a.status === 'completed').length,
|
||||
}
|
||||
}), [agents])
|
||||
|
||||
const totalTasks = agents.reduce((acc, a) => acc + a.stats.tasksCompleted + a.stats.tasksInProgress, 0)
|
||||
const completedTasks = agents.reduce((acc, a) => acc + a.stats.tasksCompleted, 0)
|
||||
const taskStats = useMemo(() => {
|
||||
const totalTasks = agents.reduce((acc, a) => acc + (a.stats?.tasksCompleted || 0) + (a.stats?.tasksInProgress || 0), 0)
|
||||
const completedTasks = agents.reduce((acc, a) => acc + (a.stats?.tasksCompleted || 0), 0)
|
||||
return { totalTasks, completedTasks }
|
||||
}, [agents])
|
||||
|
||||
const progress = totalTasks > 0 ? (completedTasks / totalTasks) * 100 : 0
|
||||
const progress = taskStats.totalTasks > 0 ? (taskStats.completedTasks / taskStats.totalTasks) * 100 : 0
|
||||
|
||||
const formatUptime = (ms: number) => {
|
||||
if (!ms) return '—'
|
||||
@@ -36,9 +48,10 @@ export function StatusBar({ agents, health, lastUpdated, loading = false, error,
|
||||
return `${s}s`
|
||||
}
|
||||
|
||||
const locale = typeof navigator !== 'undefined' ? navigator.language : 'es-CL'
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between p-4 border-b border-glass-border">
|
||||
{/* Logo / Title */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-blue-500/20 to-purple-500/20 border border-blue-500/30 flex items-center justify-center">
|
||||
<Zap className="w-5 h-5 text-blue-400" />
|
||||
@@ -49,9 +62,7 @@ export function StatusBar({ agents, health, lastUpdated, loading = false, error,
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex items-center gap-6">
|
||||
{/* Gateway Status */}
|
||||
<div className="flex items-center gap-2">
|
||||
{connected ? (
|
||||
<Wifi className="w-3.5 h-3.5 text-agent-active" />
|
||||
@@ -68,7 +79,6 @@ export function StatusBar({ agents, health, lastUpdated, loading = false, error,
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sessions */}
|
||||
{health && health.sessions > 0 && (
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 rounded-lg bg-background-lighter border border-glass-border">
|
||||
<span className="text-xs text-gray-400">
|
||||
@@ -77,14 +87,13 @@ export function StatusBar({ agents, health, lastUpdated, loading = false, error,
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Task Progress */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-right">
|
||||
<p className="text-xs text-gray-400">Tareas</p>
|
||||
<p className="text-sm font-semibold">
|
||||
<span className="text-agent-completed">{completedTasks}</span>
|
||||
<span className="text-agent-completed">{taskStats.completedTasks}</span>
|
||||
<span className="text-gray-500">/</span>
|
||||
<span className="text-gray-300">{totalTasks}</span>
|
||||
<span className="text-gray-300">{taskStats.totalTasks}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-16 h-1.5 rounded-full bg-background-lighter overflow-hidden">
|
||||
@@ -95,7 +104,6 @@ export function StatusBar({ agents, health, lastUpdated, loading = false, error,
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Agent Status Pills */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 rounded-lg bg-agent-active/10 border border-agent-active/20">
|
||||
<Activity className="w-3 h-3 text-agent-active" />
|
||||
@@ -124,7 +132,6 @@ export function StatusBar({ agents, health, lastUpdated, loading = false, error,
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Time / Refresh */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
@@ -140,8 +147,8 @@ export function StatusBar({ agents, health, lastUpdated, loading = false, error,
|
||||
</p>
|
||||
<p className="text-sm font-mono text-gray-300">
|
||||
{lastUpdated
|
||||
? lastUpdated.toLocaleTimeString('es-CL', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||
: '—'}
|
||||
? lastUpdated.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||
: currentTime.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,117 +1,80 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { getOpenClawClient, resetOpenClawClient, type OpenClawState } from '../lib/openclaw-api'
|
||||
import type { Agent } from '../types/agent'
|
||||
import { agents as staticAgents } from '../data/agents'
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { openclawClient } from '../lib/openclaw-api';
|
||||
import type { Agent, TimelineEvent } from '../types/agent';
|
||||
|
||||
// Layout positions para cada agente (fijas en el canvas)
|
||||
const AGENT_POSITIONS: Record<string, { x: number; y: number }> = {
|
||||
main: { x: 50, y: 50 },
|
||||
architect: { x: 25, y: 30 },
|
||||
coder: { x: 15, y: 55 },
|
||||
designer: { x: 35, y: 70 },
|
||||
assistant: { x: 75, y: 60 },
|
||||
reviewer: { x: 65, y: 35 },
|
||||
interface UseOpenClawReturn {
|
||||
agents: Agent[];
|
||||
events: TimelineEvent[];
|
||||
health: { status: string; uptime: number; sessions: number } | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
lastUpdate: Date | null;
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
// Avatar y role por defecto desde los datos estáticos
|
||||
const AGENT_META: Record<string, { avatar: string; role: Agent['role'] }> = {}
|
||||
for (const a of staticAgents) {
|
||||
AGENT_META[a.id] = { avatar: a.avatar ?? '🤖', role: a.role }
|
||||
}
|
||||
const POLLING_INTERVAL = 30000;
|
||||
|
||||
export interface UseOpenClawOptions {
|
||||
pollInterval?: number
|
||||
gatewayUrl?: string
|
||||
token?: string
|
||||
}
|
||||
export function useOpenClaw(): UseOpenClawReturn {
|
||||
const [agents, setAgents] = useState<Agent[]>([]);
|
||||
const [events, setEvents] = useState<TimelineEvent[]>([]);
|
||||
const [health, setHealth] = useState<{ status: string; uptime: number; sessions: number } | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lastUpdate, setLastUpdate] = useState<Date | null>(null);
|
||||
|
||||
export interface UseOpenClawResult {
|
||||
agents: Agent[]
|
||||
connections: import('../types/agent').Connection[]
|
||||
events: import('../types/agent').TimelineEvent[]
|
||||
health: import('../lib/openclaw-api').GatewayHealth
|
||||
lastUpdated: Date | null
|
||||
loading: boolean
|
||||
error: string | null
|
||||
connected: boolean
|
||||
refetch: () => void
|
||||
}
|
||||
const intervalRef = useRef<number | null>(null);
|
||||
const isMountedRef = useRef(true);
|
||||
|
||||
function openClawAgentToAgent(ocAgent: import('../lib/openclaw-api').OpenClawAgent): Agent {
|
||||
const pos = AGENT_POSITIONS[ocAgent.id] ?? { x: 50, y: 50 }
|
||||
const meta = AGENT_META[ocAgent.id] ?? { avatar: '🤖', role: 'assistant' }
|
||||
const incomingConnections = ['main', 'architect', 'coder', 'designer', 'assistant', 'reviewer'].filter(
|
||||
id => id !== ocAgent.id
|
||||
)
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
const [agentsData, eventsData, healthData] = await Promise.all([
|
||||
openclawClient.fetchAgents(),
|
||||
openclawClient.fetchEvents(),
|
||||
openclawClient.fetchHealth(),
|
||||
]);
|
||||
|
||||
return {
|
||||
id: ocAgent.id,
|
||||
name: ocAgent.name,
|
||||
role: meta.role,
|
||||
status: ocAgent.status,
|
||||
position: pos,
|
||||
avatar: meta.avatar,
|
||||
currentTask: ocAgent.currentTask,
|
||||
connections: incomingConnections,
|
||||
stats: ocAgent.stats,
|
||||
if (isMountedRef.current) {
|
||||
setAgents(agentsData);
|
||||
setEvents(eventsData);
|
||||
setHealth(healthData);
|
||||
setError(null);
|
||||
setLastUpdate(new Date());
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (isMountedRef.current) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to connect to gateway');
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
export function useOpenClaw(opts: UseOpenClawOptions = {}): UseOpenClawResult {
|
||||
const { pollInterval = 30_000, gatewayUrl, token } = opts
|
||||
|
||||
const [state, setState] = useState<OpenClawState | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [connected, setConnected] = useState(false)
|
||||
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
const startPolling = useCallback(() => {
|
||||
if (pollTimerRef.current) clearInterval(pollTimerRef.current)
|
||||
pollTimerRef.current = setInterval(() => {
|
||||
getOpenClawClient(gatewayUrl, token).poll()
|
||||
}, pollInterval)
|
||||
}, [pollInterval, gatewayUrl, token])
|
||||
const refresh = useCallback(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
useEffect(() => {
|
||||
const client = getOpenClawClient(gatewayUrl, token)
|
||||
client.setPollInterval(pollInterval)
|
||||
isMountedRef.current = true;
|
||||
fetchData();
|
||||
|
||||
client.onUpdate((newState) => {
|
||||
setState(newState)
|
||||
setLoading(false)
|
||||
setError(null)
|
||||
setConnected(client.isHealthy())
|
||||
})
|
||||
|
||||
// Initial poll
|
||||
client.poll().catch((err: Error) => {
|
||||
setError(err.message ?? 'Error conectando al gateway')
|
||||
setLoading(false)
|
||||
})
|
||||
|
||||
startPolling()
|
||||
intervalRef.current = window.setInterval(fetchData, POLLING_INTERVAL);
|
||||
|
||||
return () => {
|
||||
if (pollTimerRef.current) clearInterval(pollTimerRef.current)
|
||||
resetOpenClawClient()
|
||||
isMountedRef.current = false;
|
||||
if (intervalRef.current !== null) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
}, [pollInterval, gatewayUrl, token, startPolling])
|
||||
|
||||
// Convertir estado de OpenClaw → agentes del dashboard
|
||||
const agents: Agent[] = state
|
||||
? state.agents.map(openClawAgentToAgent)
|
||||
: staticAgents.map(a => ({ ...a, status: 'idle' as const }))
|
||||
};
|
||||
}, [fetchData]);
|
||||
|
||||
return {
|
||||
agents,
|
||||
connections: state?.connections ?? [],
|
||||
events: state?.events ?? [],
|
||||
health: state?.health ?? { healthy: false, uptime: 0, agents: 0, sessions: 0 },
|
||||
lastUpdated: state?.lastUpdated ?? null,
|
||||
loading,
|
||||
events,
|
||||
health,
|
||||
isLoading,
|
||||
error,
|
||||
connected,
|
||||
refetch: () => getOpenClawClient(gatewayUrl, token).poll(),
|
||||
}
|
||||
lastUpdate,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -112,3 +112,23 @@ body {
|
||||
outline: 2px solid rgba(59, 130, 246, 0.5);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Node pulse animation - replaces repeat: Infinity */
|
||||
@keyframes node-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.node-pulse {
|
||||
animation: node-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Glow effect for active nodes */
|
||||
@keyframes glow-pulse {
|
||||
0%, 100% { transform: scale(1); opacity: 0.3; filter: blur(20px); }
|
||||
50% { transform: scale(1.3); opacity: 0.5; filter: blur(25px); }
|
||||
}
|
||||
|
||||
.node-glow {
|
||||
animation: glow-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@@ -1,313 +1,119 @@
|
||||
/**
|
||||
* OpenClaw Gateway API Client
|
||||
*
|
||||
* Provee métodos para obtener estado de agentes, sesiones y eventos
|
||||
* desde el gateway de OpenClaw via HTTP polling.
|
||||
*
|
||||
* Para uso en browser, el gateway debe estar expuesto via reverse proxy.
|
||||
* Configurar VITE_OPENCLAW_GATEWAY_URL y VITE_OPENCLAW_GATEWAY_TOKEN en .env
|
||||
*/
|
||||
import type { Agent, Connection, TimelineEvent } from '../types/agent';
|
||||
|
||||
import type { Agent, Connection, TimelineEvent } from '../types/agent'
|
||||
const GATEWAY_URL = import.meta.env.VITE_OPENCLAW_GATEWAY_URL || 'http://127.0.0.1:18789';
|
||||
const GATEWAY_TOKEN = import.meta.env.VITE_OPENCLAW_GATEWAY_TOKEN || '';
|
||||
|
||||
export interface GatewayHealth {
|
||||
healthy: boolean
|
||||
uptime: number
|
||||
agents: number
|
||||
sessions: number
|
||||
interface GatewayHealth {
|
||||
status: string;
|
||||
uptime: number;
|
||||
sessions: number;
|
||||
agents: number;
|
||||
}
|
||||
|
||||
export interface OpenClawAgent {
|
||||
id: string
|
||||
name: string
|
||||
role: string
|
||||
status: Agent['status']
|
||||
workspace: string
|
||||
model: string
|
||||
sessionCount: number
|
||||
lastActive: number
|
||||
currentTask?: string
|
||||
stats: {
|
||||
tasksCompleted: number
|
||||
tasksInProgress: number
|
||||
tasksBlocked: number
|
||||
}
|
||||
interface GatewayAgentsResponse {
|
||||
agents: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
model: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface OpenClawState {
|
||||
agents: OpenClawAgent[]
|
||||
connections: Connection[]
|
||||
events: TimelineEvent[]
|
||||
health: GatewayHealth
|
||||
lastUpdated: Date
|
||||
}
|
||||
|
||||
// Mapeo de IDs de agente OpenClaw → datos del dashboard
|
||||
const AGENT_META: Record<string, { name: string; avatar: string; role: Agent['role'] }> = {
|
||||
main: { name: 'Erwin', avatar: '🎯', role: 'orchestrator' },
|
||||
architect: { name: 'Bulma', avatar: '🏗️', role: 'architect' },
|
||||
coder: { name: 'Rocket', avatar: '🚀', role: 'developer' },
|
||||
designer: { name: 'Hiro', avatar: '🎨', role: 'designer' },
|
||||
assistant: { name: 'Claudia', avatar: '📝', role: 'assistant' },
|
||||
reviewer: { name: 'Sherlock', avatar: '🔍', role: 'reviewer' },
|
||||
}
|
||||
|
||||
// Relaciones estáticas entre agentes (reporting lines)
|
||||
const AGENT_CONNECTIONS: Connection[] = [
|
||||
{ id: 'c1', from: 'main', to: 'architect', type: 'delegates', active: true },
|
||||
{ id: 'c2', from: 'main', to: 'coder', type: 'delegates', active: true },
|
||||
{ id: 'c3', from: 'main', to: 'designer', type: 'delegates', active: false },
|
||||
{ id: 'c4', from: 'main', to: 'reviewer', type: 'requests', active: false },
|
||||
{ id: 'c5', from: 'main', to: 'assistant', type: 'requests', active: false },
|
||||
{ id: 'c6', from: 'architect', to: 'main', type: 'reports', active: true },
|
||||
{ id: 'c7', from: 'architect', to: 'coder', type: 'transfers', active: true },
|
||||
{ id: 'c8', from: 'architect', to: 'designer', type: 'transfers', active: false },
|
||||
{ id: 'c9', from: 'coder', to: 'main', type: 'reports', active: true },
|
||||
{ id: 'c10', from: 'coder', to: 'reviewer', type: 'requests', active: true },
|
||||
{ id: 'c11', from: 'designer', to: 'main', type: 'reports', active: false },
|
||||
{ id: 'c12', from: 'reviewer', to: 'main', type: 'reports', active: true },
|
||||
{ id: 'c13', from: 'reviewer', to: 'assistant', type: 'requests', active: false },
|
||||
{ id: 'c14', from: 'assistant', to: 'main', type: 'reports', active: true },
|
||||
]
|
||||
|
||||
function nextEventId(): string {
|
||||
return `evt-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`
|
||||
}
|
||||
|
||||
function guessStatus(sessionCount: number, lastActive: number): Agent['status'] {
|
||||
if (sessionCount === 0) return 'idle'
|
||||
const since = Date.now() - lastActive
|
||||
if (since > 5 * 60 * 1000) return 'idle'
|
||||
if (sessionCount > 0) return 'active'
|
||||
return 'thinking'
|
||||
}
|
||||
|
||||
function buildOpenClawAgent(
|
||||
id: string,
|
||||
sessionCount: number,
|
||||
lastActive: number,
|
||||
currentTask?: string
|
||||
): OpenClawAgent {
|
||||
const meta = AGENT_META[id] ?? { name: id, avatar: '🤖', role: 'assistant' as const }
|
||||
const status = guessStatus(sessionCount, lastActive)
|
||||
return {
|
||||
id,
|
||||
name: meta.name,
|
||||
role: meta.role,
|
||||
status,
|
||||
workspace: '',
|
||||
model: '',
|
||||
sessionCount,
|
||||
lastActive,
|
||||
currentTask,
|
||||
stats: {
|
||||
tasksCompleted: Math.floor(Math.random() * 20) + (status === 'completed' ? 5 : 0),
|
||||
tasksInProgress: status === 'active' || status === 'thinking' ? Math.floor(Math.random() * 5) : 0,
|
||||
tasksBlocked: status === 'blocked' ? Math.floor(Math.random() * 3) : 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function buildConnections(agentIds: string[]): Connection[] {
|
||||
const validIds = new Set(agentIds)
|
||||
return AGENT_CONNECTIONS.filter(c => validIds.has(c.from) && validIds.has(c.to))
|
||||
}
|
||||
|
||||
// ─── HTTP API Client ─────────────────────────────────────────────────────────
|
||||
|
||||
export class OpenClawGatewayClient {
|
||||
private baseUrl: string
|
||||
private token: string
|
||||
private pollInterval = 30_000
|
||||
private pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
private onStateChange: ((state: OpenClawState) => void) | null = null
|
||||
private lastHealth: GatewayHealth = { healthy: false, uptime: 0, agents: 6, sessions: 0 }
|
||||
private knownAgentIds = ['main', 'architect', 'coder', 'designer', 'assistant', 'reviewer']
|
||||
private baseUrl: string;
|
||||
private token: string;
|
||||
|
||||
constructor(baseUrl: string, token: string) {
|
||||
// Normalize: ensure http/https prefix
|
||||
this.baseUrl = baseUrl.replace(/^ws(s)?:\/\//, 'http$1://')
|
||||
this.token = token
|
||||
constructor(baseUrl: string = GATEWAY_URL, token: string = GATEWAY_TOKEN) {
|
||||
this.baseUrl = baseUrl;
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
private async fetch<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const url = `${this.baseUrl}${path}`
|
||||
const resp = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
private async fetch<T>(path: string): Promise<T> {
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
...(this.token ? { Authorization: `Bearer ${this.token}` } : {}),
|
||||
...options?.headers,
|
||||
},
|
||||
})
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Gateway ${resp.status}: ${resp.statusText}`)
|
||||
}
|
||||
return resp.json() as Promise<T>
|
||||
};
|
||||
|
||||
if (this.token) {
|
||||
headers['Authorization'] = `Bearer ${this.token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.baseUrl}${path}`, { headers });
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Gateway error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene estado de salud del gateway y sesiones activas.
|
||||
* Endpoint: GET /healthz (o similar endpoint interno)
|
||||
*/
|
||||
async fetchHealth(): Promise<GatewayHealth> {
|
||||
try {
|
||||
// Tratamos de obtener /v1/models como health check
|
||||
const data = await this.fetch<{ data?: unknown[] }>('/v1/models')
|
||||
void data // just to verify gateway is reachable
|
||||
return {
|
||||
healthy: true,
|
||||
uptime: this.lastHealth.uptime,
|
||||
agents: this.knownAgentIds.length,
|
||||
sessions: this.lastHealth.sessions,
|
||||
return this.fetch<GatewayHealth>('/api/health');
|
||||
}
|
||||
|
||||
async fetchAgents(): Promise<Agent[]> {
|
||||
try {
|
||||
const data = await this.fetch<GatewayAgentsResponse>('/api/agents');
|
||||
return data.agents.map((agent, index) => ({
|
||||
id: agent.id,
|
||||
name: agent.name,
|
||||
role: this.getRoleFromAgentId(agent.id),
|
||||
status: agent.status === 'active' ? 'active' : agent.status === 'busy' ? 'busy' : 'idle',
|
||||
queue: 0,
|
||||
icon: this.getIconFromIndex(index),
|
||||
x: this.getAgentX(index),
|
||||
y: this.getAgentY(index),
|
||||
}));
|
||||
} catch {
|
||||
// Gateway no reachable desde el browser (CORS o no expuesto)
|
||||
// Devolvemos último estado conocido con healthy=false
|
||||
return { ...this.lastHealth, healthy: false }
|
||||
return this.getDefaultAgents();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene lista de sesiones activas del gateway.
|
||||
*/
|
||||
async fetchSessions(): Promise<{ sessions: number; agents: Record<string, number> }> {
|
||||
async fetchEvents(): Promise<TimelineEvent[]> {
|
||||
try {
|
||||
// GET /sessions o similar - si no existe, usamos estimate
|
||||
// Como no hay endpoint REST público, usamos el último valor conocido
|
||||
return {
|
||||
sessions: this.lastHealth.sessions || 11,
|
||||
agents: Object.fromEntries(this.knownAgentIds.map(id => [id, 1])),
|
||||
}
|
||||
const data = await this.fetch<{ events: TimelineEvent[] }>('/api/events');
|
||||
return data.events || [];
|
||||
} catch {
|
||||
return {
|
||||
sessions: this.lastHealth.sessions || 11,
|
||||
agents: Object.fromEntries(this.knownAgentIds.map(id => [id, 1])),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene agentes con estado estimado basado en sesiones.
|
||||
*/
|
||||
async fetchAgents(): Promise<OpenClawAgent[]> {
|
||||
const sessions = await this.fetchSessions()
|
||||
const perAgent = Math.max(1, Math.floor(sessions.sessions / this.knownAgentIds.length))
|
||||
|
||||
return this.knownAgentIds.map((id) =>
|
||||
buildOpenClawAgent(id, perAgent, Date.now() - Math.random() * 600_000)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene eventos recientes del timeline.
|
||||
*/
|
||||
async fetchEvents(agents: OpenClawAgent[]): Promise<TimelineEvent[]> {
|
||||
const events: TimelineEvent[] = []
|
||||
for (const agent of agents) {
|
||||
if (agent.status === 'active' || agent.status === 'thinking') {
|
||||
events.push({
|
||||
id: nextEventId(),
|
||||
agentId: agent.id,
|
||||
timestamp: new Date(Date.now() - Math.random() * 300_000),
|
||||
type: 'task_start',
|
||||
description: `${agent.name} está ${agent.status === 'thinking' ? 'analizando' : 'ejecutando tarea'}`,
|
||||
})
|
||||
}
|
||||
if (agent.stats.tasksCompleted > 0) {
|
||||
events.push({
|
||||
id: nextEventId(),
|
||||
agentId: agent.id,
|
||||
timestamp: new Date(Date.now() - Math.random() * 600_000),
|
||||
type: 'task_complete',
|
||||
description: `${agent.name} completó ${agent.stats.tasksCompleted} tareas`,
|
||||
})
|
||||
}
|
||||
}
|
||||
return events.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()).slice(0, 20)
|
||||
}
|
||||
|
||||
/**
|
||||
* Polling principal: obtiene todo el estado del gateway.
|
||||
*/
|
||||
async poll(): Promise<void> {
|
||||
try {
|
||||
const [agents, health] = await Promise.allSettled([
|
||||
this.fetchAgents(),
|
||||
this.fetchHealth(),
|
||||
])
|
||||
|
||||
const openClawAgents = agents.status === 'fulfilled' ? agents.value : []
|
||||
const gwHealth = health.status === 'fulfilled' ? health.value : this.lastHealth
|
||||
|
||||
// Actualizar health con sessions count si está disponible
|
||||
if (gwHealth.sessions > 0) {
|
||||
this.lastHealth = gwHealth
|
||||
} else {
|
||||
// Estimar sesiones desde el health
|
||||
this.lastHealth = { ...this.lastHealth, healthy: gwHealth.healthy }
|
||||
}
|
||||
|
||||
const events = await this.fetchEvents(openClawAgents)
|
||||
|
||||
const state: OpenClawState = {
|
||||
agents: openClawAgents,
|
||||
connections: buildConnections(openClawAgents.map(a => a.id)),
|
||||
events,
|
||||
health: this.lastHealth,
|
||||
lastUpdated: new Date(),
|
||||
}
|
||||
|
||||
this.onStateChange?.(state)
|
||||
} catch (err) {
|
||||
console.error('[OpenClawGateway] poll error:', err)
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
setPollInterval(ms: number): void {
|
||||
this.pollInterval = ms
|
||||
private getRoleFromAgentId(id: string): string {
|
||||
const roles: Record<string, string> = {
|
||||
orchestrator: 'Coordina flujo y delegación',
|
||||
architect: 'Define solución y tareas',
|
||||
coder: 'Implementa cambios',
|
||||
reviewer: 'Revisa calidad y riesgos',
|
||||
docs: 'Documenta decisiones y cambios',
|
||||
assistant: 'Asistente general',
|
||||
};
|
||||
return roles[id] || 'Agente';
|
||||
}
|
||||
|
||||
onUpdate(cb: (state: OpenClawState) => void): void {
|
||||
this.onStateChange = cb
|
||||
private getIconFromIndex(index: number) {
|
||||
const icons = ['Workflow', 'Layers3', 'Bot', 'ShieldCheck', 'FileText', 'Activity'];
|
||||
return icons[index % icons.length];
|
||||
}
|
||||
|
||||
startPolling(): void {
|
||||
this.poll()
|
||||
this.pollTimer = setInterval(() => this.poll(), this.pollInterval)
|
||||
private getAgentX(index: number): number {
|
||||
const positions = [10, 36, 36, 66, 66, 66];
|
||||
return positions[index] || 50;
|
||||
}
|
||||
|
||||
stopPolling(): void {
|
||||
if (this.pollTimer) {
|
||||
clearInterval(this.pollTimer)
|
||||
this.pollTimer = null
|
||||
}
|
||||
private getAgentY(index: number): number {
|
||||
const positions = [38, 12, 64, 24, 64, 50];
|
||||
return positions[index] || 50;
|
||||
}
|
||||
|
||||
isHealthy(): boolean {
|
||||
return this.lastHealth.healthy
|
||||
}
|
||||
|
||||
getLastHealth(): GatewayHealth {
|
||||
return this.lastHealth
|
||||
private getDefaultAgents(): Agent[] {
|
||||
return [
|
||||
{ id: 'erwin', name: 'Erwin', role: 'Orquestador', status: 'active', queue: 0, icon: 'Workflow', x: 10, y: 38 },
|
||||
{ id: 'bulma', name: 'Bulma', role: 'Arquitecto', status: 'active', queue: 0, icon: 'Layers3', x: 36, y: 12 },
|
||||
{ id: 'rocket', name: 'Rocket', role: 'Desarrollador', status: 'busy', queue: 0, icon: 'Bot', x: 36, y: 64 },
|
||||
{ id: 'sherlock', name: 'Sherlock', role: 'Revisor', status: 'idle', queue: 0, icon: 'ShieldCheck', x: 66, y: 24 },
|
||||
{ id: 'hiro', name: 'Hiro', role: 'Diseñador', status: 'idle', queue: 0, icon: 'FileText', x: 66, y: 64 },
|
||||
{ id: 'claudia', name: 'Claudia', role: 'Asistente', status: 'idle', queue: 0, icon: 'Activity', x: 66, y: 50 },
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Singleton instance ───────────────────────────────────────────────────────
|
||||
|
||||
let _client: OpenClawGatewayClient | null = null
|
||||
|
||||
export function getOpenClawClient(gatewayUrl?: string, token?: string): OpenClawGatewayClient {
|
||||
if (!_client) {
|
||||
const url = gatewayUrl
|
||||
?? (typeof window !== 'undefined' ? window.location.origin : 'http://127.0.0.1:18789')
|
||||
const tk = token ?? ''
|
||||
_client = new OpenClawGatewayClient(url, tk)
|
||||
}
|
||||
return _client
|
||||
}
|
||||
|
||||
export function resetOpenClawClient(): void {
|
||||
_client?.stopPolling()
|
||||
_client = null
|
||||
}
|
||||
export const openclawClient = new OpenClawGatewayClient();
|
||||
|
||||
Reference in New Issue
Block a user