2026-05-08 19:53:21 -04:00
|
|
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
2026-05-07 20:15:30 -04:00
|
|
|
import { listAgents } from '../services/api'
|
2026-05-08 19:53:21 -04:00
|
|
|
import { Activity, AlertTriangle, RefreshCw, Bot, Zap, Coffee, AlertCircle } from 'lucide-react'
|
|
|
|
|
import type { Agent } from '../types'
|
|
|
|
|
|
|
|
|
|
function statusStats(agents: Agent[]) {
|
|
|
|
|
const counts = { total: agents.length, active: 0, idle: 0, thinking: 0, error: 0 }
|
|
|
|
|
for (const a of agents) {
|
|
|
|
|
if (a.status in counts) counts[a.status as keyof typeof counts]++
|
|
|
|
|
}
|
|
|
|
|
return counts
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const STATUS_COLORS: Record<string, string> = {
|
|
|
|
|
active: 'bg-green-500',
|
|
|
|
|
idle: 'bg-yellow-500',
|
|
|
|
|
thinking: 'bg-blue-500',
|
|
|
|
|
error: 'bg-red-500',
|
|
|
|
|
}
|
2026-05-07 20:15:30 -04:00
|
|
|
|
|
|
|
|
export default function HubPage() {
|
2026-05-08 19:53:21 -04:00
|
|
|
const queryClient = useQueryClient()
|
|
|
|
|
const { data, isLoading, error, refetch, isRefetching } = useQuery({
|
2026-05-07 20:15:30 -04:00
|
|
|
queryKey: ['agents'],
|
|
|
|
|
queryFn: listAgents,
|
|
|
|
|
})
|
|
|
|
|
|
2026-05-08 19:53:21 -04:00
|
|
|
const agents = data?.data ?? []
|
|
|
|
|
const stats = statusStats(agents)
|
|
|
|
|
|
2026-05-07 20:15:30 -04:00
|
|
|
if (isLoading) {
|
2026-05-08 19:53:21 -04:00
|
|
|
return <HubSkeleton />
|
2026-05-07 20:15:30 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (error) {
|
|
|
|
|
return (
|
2026-05-08 19:53:21 -04:00
|
|
|
<div className="flex flex-col items-center justify-center h-96 gap-4">
|
|
|
|
|
<AlertCircle size={48} className="text-danger" />
|
|
|
|
|
<p className="text-danger text-lg">Failed to load agents</p>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => queryClient.invalidateQueries({ queryKey: ['agents'] })}
|
|
|
|
|
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary/10 text-primary hover:bg-primary/20 transition-colors"
|
|
|
|
|
>
|
|
|
|
|
<RefreshCw size={16} /> Retry
|
|
|
|
|
</button>
|
2026-05-07 20:15:30 -04:00
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-6">
|
2026-05-08 19:53:21 -04:00
|
|
|
{/* Header */}
|
|
|
|
|
<header className="flex items-center justify-between flex-wrap gap-2">
|
|
|
|
|
<div>
|
|
|
|
|
<h1 className="text-2xl font-bold">Command Hub</h1>
|
|
|
|
|
<p className="text-on-surface-variant">Agent fleet overview</p>
|
|
|
|
|
</div>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => refetch()}
|
|
|
|
|
disabled={isRefetching}
|
|
|
|
|
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary/10 text-primary hover:bg-primary/20 transition-colors disabled:opacity-50"
|
|
|
|
|
>
|
|
|
|
|
<RefreshCw size={16} className={isRefetching ? 'animate-spin' : ''} />
|
|
|
|
|
Refresh
|
|
|
|
|
</button>
|
2026-05-07 20:15:30 -04:00
|
|
|
</header>
|
|
|
|
|
|
2026-05-08 19:53:21 -04:00
|
|
|
{/* Summary stats */}
|
|
|
|
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
|
|
|
|
<StatCard icon={Bot} label="Total" value={stats.total} color="text-on-surface" />
|
|
|
|
|
<StatCard icon={Zap} label="Active" value={stats.active} color="text-green-500" />
|
|
|
|
|
<StatCard icon={Coffee} label="Idle" value={stats.idle} color="text-yellow-500" />
|
|
|
|
|
<StatCard icon={AlertTriangle} label="Errors" value={stats.error} color="text-red-500" />
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Agent grid */}
|
|
|
|
|
{agents.length === 0 ? (
|
|
|
|
|
<div className="flex flex-col items-center justify-center h-48 gap-3 border border-dashed border-surface-light rounded-xl">
|
|
|
|
|
<Bot size={40} className="text-on-surface-muted" />
|
|
|
|
|
<p className="text-on-surface-muted text-lg">No agents registered</p>
|
|
|
|
|
<p className="text-on-surface-muted text-sm">Agents will appear here once connected.</p>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
|
|
|
|
{agents.map((agent) => (
|
|
|
|
|
<div
|
|
|
|
|
key={agent.id}
|
|
|
|
|
className="p-4 rounded-xl border border-surface-light bg-surface-dark hover:border-surface-lighter transition-colors"
|
|
|
|
|
>
|
|
|
|
|
{/* Agent identity */}
|
|
|
|
|
<div className="flex items-start justify-between mb-3">
|
|
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
<div className="w-10 h-10 rounded-full bg-surface-light flex items-center justify-center text-lg font-bold shrink-0">
|
|
|
|
|
{agent.displayName.charAt(0)}
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<h3 className="font-semibold text-sm">{agent.displayName}</h3>
|
|
|
|
|
<p className="text-xs text-on-surface-variant">{agent.role}</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<StatusBadge status={agent.status} />
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Current task */}
|
|
|
|
|
{agent.currentTask && (
|
|
|
|
|
<div className="mb-2 text-sm text-on-surface-variant truncate">
|
|
|
|
|
{agent.currentTask}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Progress bar */}
|
|
|
|
|
{agent.taskProgress !== undefined && agent.taskProgress > 0 && (
|
|
|
|
|
<div className="w-full h-2 bg-surface-light rounded-full overflow-hidden mb-2">
|
|
|
|
|
<div
|
|
|
|
|
className="h-full bg-primary rounded-full transition-all duration-500"
|
|
|
|
|
style={{ width: `${Math.min(agent.taskProgress, 100)}%` }}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Footer info */}
|
|
|
|
|
<div className="mt-3 flex items-center gap-2 text-xs text-on-surface-muted">
|
|
|
|
|
<Activity size={12} />
|
|
|
|
|
<span>{agent.channel}</span>
|
|
|
|
|
<span>·</span>
|
|
|
|
|
<span>{agent.lastActivity}</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function StatCard({ icon: Icon, label, value, color }: { icon: React.ElementType; label: string; value: number; color: string }) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="p-4 rounded-xl border border-surface-light bg-surface-dark flex items-center gap-3">
|
|
|
|
|
<div className={`p-2 rounded-lg bg-surface-light ${color}`}>
|
|
|
|
|
<Icon size={20} />
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<p className="text-xs text-on-surface-variant">{label}</p>
|
|
|
|
|
<p className="text-xl font-bold">{value}</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function StatusBadge({ status }: { status: string }) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex items-center gap-1.5 px-2 py-1 rounded-full bg-surface-light/50">
|
|
|
|
|
<div className={`w-2 h-2 rounded-full ${STATUS_COLORS[status] ?? 'bg-gray-500'}`} />
|
|
|
|
|
<span className="text-xs capitalize text-on-surface-variant">{status}</span>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function HubSkeleton() {
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-6 animate-pulse">
|
|
|
|
|
<div>
|
|
|
|
|
<div className="h-8 w-48 skeleton mb-2" />
|
|
|
|
|
<div className="h-4 w-36 skeleton" />
|
|
|
|
|
</div>
|
|
|
|
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
|
|
|
|
{[...Array(4)].map((_, i) => (
|
|
|
|
|
<div key={i} className="p-4 rounded-xl border border-surface-light bg-surface-dark">
|
|
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
<div className="h-10 w-10 rounded-lg skeleton" />
|
|
|
|
|
<div className="flex-1">
|
|
|
|
|
<div className="h-3 w-12 skeleton mb-2" />
|
|
|
|
|
<div className="h-6 w-8 skeleton" />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
2026-05-07 20:15:30 -04:00
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
2026-05-08 19:53:21 -04:00
|
|
|
{[...Array(6)].map((_, i) => (
|
|
|
|
|
<div key={i} className="p-4 rounded-xl border border-surface-light bg-surface-dark">
|
|
|
|
|
<div className="flex items-start gap-3 mb-3">
|
|
|
|
|
<div className="h-10 w-10 rounded-full skeleton shrink-0" />
|
|
|
|
|
<div className="flex-1">
|
|
|
|
|
<div className="h-4 w-24 skeleton mb-1" />
|
|
|
|
|
<div className="h-3 w-16 skeleton" />
|
2026-05-07 20:15:30 -04:00
|
|
|
</div>
|
2026-05-08 19:53:21 -04:00
|
|
|
<div className="h-6 w-16 rounded-full skeleton" />
|
2026-05-07 20:15:30 -04:00
|
|
|
</div>
|
2026-05-08 19:53:21 -04:00
|
|
|
<div className="h-4 w-full skeleton mb-2" />
|
|
|
|
|
<div className="h-2 w-full skeleton rounded-full" />
|
|
|
|
|
<div className="mt-3 h-3 w-32 skeleton" />
|
2026-05-07 20:15:30 -04:00
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|