import React, { useState, useEffect, useCallback } from 'react';
import {
getAdminNodes, adminCreateNode, adminUpdateNode,
adminGrantNodeAccess, adminRevokeNodeAccess,
adminDownloadNodeBundle, getUserAccessibleNodes,
getAdminGroups, getNodeStreamUrl
} from '../services/apiService';
const NodesPage = ({ user }) => {
const [activeTab, setActiveTab] = useState(user?.role === 'admin' ? 'manage' : 'status');
const [nodes, setNodes] = useState([]);
const [groups, setGroups] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [showCreateModal, setShowCreateModal] = useState(false);
const [newNode, setNewNode] = useState({ node_id: '', display_name: '', description: '', skill_config: { shell: { enabled: true }, browser: { enabled: true }, sync: { enabled: true } } });
// WebSocket state for live updates
const [meshStatus, setMeshStatus] = useState({}); // node_id -> { status, stats }
const [recentEvents, setRecentEvents] = useState([]); // Array of event objects
const isAdmin = user?.role === 'admin';
const fetchData = useCallback(async () => {
setLoading(true);
try {
if (isAdmin) {
const [nodesData, groupsData] = await Promise.all([getAdminNodes(), getAdminGroups()]);
setNodes(nodesData);
setGroups(groupsData);
} else {
const nodesData = await getUserAccessibleNodes();
setNodes(nodesData);
}
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}, [isAdmin]);
useEffect(() => {
fetchData();
}, [fetchData]);
// WebSocket Connection for Live Mesh Status
useEffect(() => {
const wsUrl = getNodeStreamUrl();
const ws = new WebSocket(wsUrl);
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.event === 'initial_snapshot') {
const statusMap = {};
msg.data.nodes.forEach(n => {
statusMap[n.node_id] = { status: n.status, stats: n.stats };
});
setMeshStatus(statusMap);
} else if (msg.event === 'mesh_heartbeat') {
const statusMap = { ...meshStatus };
msg.data.nodes.forEach(n => {
statusMap[n.node_id] = { status: n.status, stats: n.stats };
});
setMeshStatus(statusMap);
} else if (['task_start', 'task_complete', 'task_error', 'info'].includes(msg.event)) {
// Add to recent events log
setRecentEvents(prev => [msg, ...prev].slice(0, 50));
}
};
return () => ws.close();
}, [userId]); // Wait, I don't have userId here, I'll use user.id
const handleCreateNode = async (e) => {
e.preventDefault();
try {
await adminCreateNode(newNode);
setShowCreateModal(false);
fetchData();
} catch (err) {
alert(err.message);
}
};
const toggleNodeActive = async (node) => {
try {
await adminUpdateNode(node.node_id, { is_active: !node.is_active });
fetchData();
} catch (err) {
alert(err.message);
}
};
return (
<div className="flex flex-col h-full bg-gray-50 dark:bg-gray-900 overflow-hidden">
{/* Header */}
<header className="bg-white dark:bg-gray-800 border-b dark:border-gray-700 px-8 py-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
<span className="mr-2">🚀</span> Agent Node Mesh
</h1>
<p className="text-gray-500 dark:text-gray-400 text-sm mt-1">
Manage distributed execution nodes and monitor live health.
</p>
</div>
<div className="flex space-x-2">
<button
onClick={fetchData}
className="p-2 text-gray-500 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
title="Refresh List"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
{isAdmin && (
<button
onClick={() => setShowCreateModal(true)}
className="bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded-lg text-sm font-semibold transition-all flex items-center"
>
<svg className="w-4 h-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Register Node
</button>
)}
</div>
</div>
{/* Tabs */}
<div className="flex space-x-8 mt-8 border-b dark:border-gray-700">
{isAdmin && (
<button
onClick={() => setActiveTab('manage')}
className={`pb-4 px-1 text-sm font-medium transition-colors border-b-2 ${activeTab === 'manage' ? 'border-indigo-500 text-indigo-600 dark:text-indigo-400' : 'border-transparent text-gray-500 hover:text-gray-700 dark:hover:text-gray-300'}`}
>
Management
</button>
)}
<button
onClick={() => setActiveTab('status')}
className={`pb-4 px-1 text-sm font-medium transition-colors border-b-2 ${activeTab === 'status' ? 'border-indigo-500 text-indigo-600 dark:text-indigo-400' : 'border-transparent text-gray-500 hover:text-gray-700 dark:hover:text-gray-300'}`}
>
Live Monitor
</button>
</div>
</header>
{/* Main Content */}
<main className="flex-1 overflow-auto p-8">
{loading ? (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-500"></div>
</div>
) : error ? (
<div className="bg-red-50 dark:bg-red-900/20 text-red-600 p-4 rounded-xl border border-red-200 dark:border-red-800">
Error: {error}
</div>
) : activeTab === 'manage' ? (
/* ADMIN MANAGEMENT VIEW */
<div className="space-y-6">
<div className="grid grid-cols-1 gap-6">
{nodes.map(node => (
<div key={node.node_id} className="bg-white dark:bg-gray-800 rounded-2xl p-6 shadow-sm border dark:border-gray-700 flex flex-col md:flex-row md:items-center">
<div className="flex-1">
<div className="flex items-center space-x-3">
<h3 className="text-lg font-bold text-gray-900 dark:text-white">{node.display_name}</h3>
<span className={`px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider ${node.is_active ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600'}`}>
{node.is_active ? 'Active' : 'Disabled'}
</span>
<span className="text-sm font-mono text-gray-400">ID: {node.node_id}</span>
</div>
<p className="text-gray-500 dark:text-gray-400 text-sm mt-1">{node.description || 'No description provided.'}</p>
<div className="flex flex-wrap gap-2 mt-4">
{Object.entries(node.skill_config || {}).map(([skill, cfg]) => (
<div key={skill} className={`flex items-center space-x-1 px-3 py-1 rounded-full text-xs font-semibold ${cfg?.enabled ? 'bg-indigo-50 text-indigo-600 dark:bg-indigo-900/30' : 'bg-gray-100 text-gray-400 dark:bg-gray-700'}`}>
<span>{skill}</span>
{cfg?.enabled ? (
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20"><path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" /></svg>
) : (
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20"><path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" /></svg>
)}
</div>
))}
</div>
</div>
<div className="mt-6 md:mt-0 flex flex-wrap gap-2">
<button
onClick={() => adminDownloadNodeBundle(node.node_id)}
className="bg-white dark:bg-gray-700 border dark:border-gray-600 text-gray-700 dark:text-gray-200 px-3 py-2 rounded-lg text-sm font-medium hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors flex items-center"
>
<svg className="w-4 h-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
Bundle (M5)
</button>
<button
onClick={() => toggleNodeActive(node)}
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors ${node.is_active ? 'bg-yellow-50 text-yellow-600 hover:bg-yellow-100' : 'bg-green-50 text-green-600 hover:bg-green-100'}`}
>
{node.is_active ? 'Disable' : 'Enable'}
</button>
{/* Access control button could open another modal */}
<button className="bg-white dark:bg-gray-700 border dark:border-gray-600 text-gray-700 dark:text-gray-200 px-3 py-2 rounded-lg text-sm font-medium hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors">
Access Control
</button>
</div>
</div>
))}
</div>
</div>
) : (
/* LIVE MONITOR VIEW (M6) */
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Status Cards */}
<div className="lg:col-span-2 grid grid-cols-1 md:grid-cols-2 gap-6 content-start">
{nodes.map(node => {
const live = meshStatus[node.node_id];
const isOnline = live?.status === 'online' || live?.status === 'idle' || live?.status === 'busy';
return (
<div key={node.node_id} className="bg-white dark:bg-gray-800 rounded-2xl p-6 shadow-sm border dark:border-gray-700 relative overflow-hidden group">
<div className={`absolute top-0 left-0 w-1 h-full ${isOnline ? 'bg-green-500' : 'bg-gray-300'}`}></div>
<div className="flex justify-between items-start">
<div>
<h4 className="font-bold text-gray-900 dark:text-white uppercase tracking-tight">{node.display_name}</h4>
<p className="text-[10px] text-gray-400 font-mono">{node.node_id}</p>
</div>
<div className="flex items-center">
<span className={`w-2 h-2 rounded-full mr-2 ${isOnline ? 'bg-green-500 animate-pulse' : 'bg-gray-400'}`}></span>
<span className={`text-[10px] font-bold uppercase tracking-wider ${isOnline ? 'text-green-600' : 'text-gray-500'}`}>
{live?.status || (node.last_status || 'offline')}
</span>
</div>
</div>
<div className="mt-6 flex justify-between items-end">
<div className="space-y-1">
<div className="text-[10px] text-gray-500 font-bold uppercase">Tasks Running</div>
<div className="text-2xl font-black text-gray-900 dark:text-white">{live?.stats?.active_tasks || 0}</div>
</div>
<div className="space-y-1 text-right">
<div className="text-[10px] text-gray-500 font-bold uppercase">Uptime Score</div>
<div className="text-xl font-bold text-indigo-500">{(live?.stats?.success_rate ? (live.stats.success_rate * 100).toFixed(1) : "100")}%</div>
</div>
</div>
<div className="mt-4 pt-4 border-t dark:border-gray-700 grid grid-cols-2 gap-4">
<div className="text-[10px] text-gray-400">
<span className="block font-bold mb-0.5">CPU</span>
<div className="w-full bg-gray-100 dark:bg-gray-700 rounded-full h-1 overflow-hidden">
<div className="bg-indigo-500 h-full" style={{ width: `${live?.stats?.cpu_usage || 0}%` }}></div>
</div>
</div>
<div className="text-[10px] text-gray-400">
<span className="block font-bold mb-0.5">MEM</span>
<div className="w-full bg-gray-100 dark:bg-gray-700 rounded-full h-1 overflow-hidden">
<div className="bg-pink-500 h-full" style={{ width: `${live?.stats?.mem_usage || 0}%` }}></div>
</div>
</div>
</div>
</div>
);
})}
</div>
{/* Event Timeline */}
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-sm border dark:border-gray-700 overflow-hidden flex flex-col h-[500px]">
<div className="p-4 border-b dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
<h4 className="text-xs font-black uppercase tracking-widest text-gray-500 flex items-center">
<span className="w-2 h-2 rounded-full bg-indigo-500 mr-2"></span>
Execution Live Bus
</h4>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-3 font-mono text-[11px]">
{recentEvents.length === 0 && (
<div className="text-gray-400 text-center py-8 italic">Listening for mesh events...</div>
)}
{recentEvents.map((evt, i) => (
<div key={i} className="flex space-x-2 animate-in slide-in-from-left duration-300">
<span className="text-indigo-400 flex-shrink-0">[{evt.timestamp?.split('T')[1].split('.')[0]}]</span>
<span className="text-gray-500 flex-shrink-0">{evt.node_id.slice(0, 8)}</span>
<span className={`flex-grow ${evt.event === 'task_error' ? 'text-red-500' : 'text-gray-700 dark:text-gray-300'}`}>
{evt.label || evt.event}: {JSON.stringify(evt.data)}
</span>
</div>
))}
</div>
</div>
</div>
)}
</main>
{/* CREATE NODE MODAL */}
{showCreateModal && (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
<div className="bg-white dark:bg-gray-800 rounded-2xl w-full max-w-lg shadow-2xl overflow-hidden animate-in zoom-in-95 duration-200">
<div className="px-6 py-4 border-b dark:border-gray-700 flex justify-between items-center">
<h3 className="font-bold text-gray-900 dark:text-white">Register Agent Node</h3>
<button onClick={() => setShowCreateModal(false)} className="text-gray-400 hover:text-gray-600 transition-colors">
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
<form onSubmit={handleCreateNode} className="p-6 space-y-4">
<div>
<label className="block text-xs font-bold text-gray-500 uppercase tracking-widest mb-1">Node identifier (Slug)</label>
<input
required
className="w-full bg-gray-50 dark:bg-gray-700 border-none rounded-xl px-4 py-3 text-sm focus:ring-2 focus:ring-indigo-500 transition-all dark:text-white"
placeholder="e.g. macbook-m3-local"
value={newNode.node_id}
onChange={e => setNewNode({ ...newNode, node_id: e.target.value })}
/>
</div>
<div>
<label className="block text-xs font-bold text-gray-500 uppercase tracking-widest mb-1">Display Name</label>
<input
required
className="w-full bg-gray-50 dark:bg-gray-700 border-none rounded-xl px-4 py-3 text-sm focus:ring-2 focus:ring-indigo-500 transition-all dark:text-white"
placeholder="e.g. My Primary MacBook"
value={newNode.display_name}
onChange={e => setNewNode({ ...newNode, display_name: e.target.value })}
/>
</div>
<div>
<label className="block text-xs font-bold text-gray-500 uppercase tracking-widest mb-1">Description</label>
<textarea
className="w-full bg-gray-50 dark:bg-gray-700 border-none rounded-xl px-4 py-3 text-sm focus:ring-2 focus:ring-indigo-500 transition-all dark:text-white h-24"
placeholder="What is this node used for?"
value={newNode.description}
onChange={e => setNewNode({ ...newNode, description: e.target.value })}
/>
</div>
<div className="flex justify-end space-x-2 pt-4">
<button type="button" onClick={() => setShowCreateModal(false)} className="px-5 py-2 text-sm font-semibold text-gray-500 hover:text-gray-700">Cancel</button>
<button type="submit" className="bg-indigo-600 hover:bg-indigo-700 text-white px-6 py-2 rounded-xl text-sm font-bold shadow-lg shadow-indigo-600/20 active:scale-95 transition-all">
Register & Close
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
};
export default NodesPage;