Newer
Older
cortex-hub / ui / client-app / src / components / MultiNodeConsole.js
import React, { useEffect, useState, useRef } from 'react';
import { getNodeStreamUrl } from '../services/apiService';
import { Terminal } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
import '@xterm/xterm/css/xterm.css';

// Sub-component for each terminal window to encapsulate Xterm.js logic
const TerminalNodeItem = ({ nodeId, stats, onMount, onUnmount, nodeConfig, isAIProcessing }) => {
    const terminalRef = useRef(null);
    const xtermRef = useRef(null);
    const fitAddonRef = useRef(null);

    const sandbox = nodeConfig?.skill_config?.shell?.sandbox || {};
    const mode = sandbox.mode || 'PASSIVE';
    const isStrict = mode === 'STRICT';

    // UI Feedback: boundary color & pulse logic
    // AI Activity pulsing gives life to the interface when things are happening.
    const borderClass = isStrict
        ? (isAIProcessing ? 'border-red-500/80 ring-1 ring-red-500/40 animate-[pulse-red_2s_infinite]' : 'border-red-900/50')
        : (isAIProcessing ? 'border-blue-500/80 ring-1 ring-blue-500/40 animate-[pulse-blue_2s_infinite]' : 'border-blue-900/50');

    const statusDotClass = isStrict ? 'bg-rose-500' : 'bg-blue-500';

    useEffect(() => {
        // Initialize Xterm
        const xterm = new Terminal({
            theme: {
                background: '#030712', // Slightly deeper than 0d1117 for contrast
                foreground: '#e6edf3',
                cursor: isAIProcessing ? '#388bfd' : '#22c55e',
                selectionBackground: '#388bfd',
            },
            fontSize: 12, // Slightly tighter for multi-view
            fontFamily: 'Menlo, Monaco, "Courier New", monospace',
            cursorBlink: isAIProcessing,
            cursorStyle: 'block',
            convertEol: true,
            scrollback: 1000,
            disableStdin: true,
            scrollOnOutput: true,
        });

        const fitAddon = new FitAddon();
        xterm.loadAddon(fitAddon);
        xterm.open(terminalRef.current);

        setTimeout(() => fitAddon.fit(), 10);

        xtermRef.current = xterm;
        fitAddonRef.current = fitAddon;

        onMount(nodeId, xterm);

        const observer = new ResizeObserver(() => fitAddon.fit());
        if (terminalRef.current) observer.observe(terminalRef.current);

        return () => {
            observer.disconnect();
            onUnmount(nodeId);
            xterm.dispose();
        };
    }, [nodeId]);

    return (
        <div className={`flex-1 flex flex-col min-w-0 border-r last:border-r-0 transition-all duration-700 ${borderClass}`}>
            {/* compact Node Header */}
            <div className={`bg-gray-900/80 px-3 py-1.5 border-b border-gray-800 flex justify-between items-center text-[9px] font-bold group relative overflow-hidden`}>
                {/* Mode Indicator Overlay Gradient */}
                <div className={`absolute inset-0 opacity-5 pointer-events-none bg-gradient-to-r ${isStrict ? 'from-rose-500' : 'from-blue-500'} to-transparent`}></div>

                <div className="flex items-center gap-2 relative z-10">
                    <span className={`w-1.5 h-1.5 rounded-full ${statusDotClass} ${isAIProcessing ? 'animate-pulse' : ''}`}></span>
                    <span className="text-gray-200 tracking-wider truncate max-w-[80px]">{nodeId}</span>
                    <span className={`text-[8px] uppercase tracking-tighter px-1.5 py-0.5 rounded ${isStrict ? 'bg-rose-950/50 text-rose-400 border border-rose-900/50' : 'bg-blue-950/50 text-blue-400 border border-blue-900/50'}`}>
                        {mode}
                    </span>
                </div>
                <div className="flex space-x-3 text-gray-400 font-mono relative z-10">
                    <span title="CPU Usage">C: <span className="text-emerald-400">{stats?.cpu_usage_percent?.toFixed(1) || '0.0'}%</span></span>
                    <span title="Memory Usage">M: <span className="text-emerald-400">{stats?.memory_usage_percent?.toFixed(1) || '0.0'}%</span></span>
                </div>
            </div>
            {/* Terminal Host */}
            <div className="flex-1 overflow-hidden bg-[#030712] relative p-1">
                <div ref={terminalRef} className="w-full h-full" />
                {isAIProcessing && (
                    <div className="absolute top-1 right-2 pointer-events-none opacity-40">
                        <span className="flex h-2 w-2">
                            <span className={`${isStrict ? 'bg-rose-400' : 'bg-blue-400'} animate-ping absolute inline-flex h-full w-full rounded-full opacity-75`}></span>
                            <span className={`relative inline-flex rounded-full h-2 w-2 ${isStrict ? 'bg-rose-500' : 'bg-blue-500'}`}></span>
                        </span>
                    </div>
                )}
            </div>

            <style dangerouslySetInnerHTML={{
                __html: `
                @keyframes pulse-red {
                    0%, 100% { border-color: rgba(244, 63, 94, 0.4); box-shadow: 0 0 5px rgba(244, 63, 94, 0.1); }
                    50% { border-color: rgba(244, 63, 94, 0.8); box-shadow: 0 0 15px rgba(244, 63, 94, 0.3); }
                }
                @keyframes pulse-blue {
                    0%, 100% { border-color: rgba(59, 130, 246, 0.4); box-shadow: 0 0 5px rgba(59, 130, 246, 0.1); }
                    50% { border-color: rgba(59, 130, 246, 0.8); box-shadow: 0 0 15px rgba(59, 130, 246, 0.3); }
                }
            `}} />
        </div>
    );
};

const MultiNodeConsole = ({ attachedNodeIds, nodes, isAIProcessing }) => {
    const [nodeStats, setNodeStats] = useState({}); // node_id -> stats object
    const [connected, setConnected] = useState(false);
    const wsRef = useRef(null);
    const xtermsRef = useRef({}); // nodeId -> Terminal instance

    useEffect(() => {
        if (!attachedNodeIds || attachedNodeIds.length === 0) return;

        let reconnectTimer;
        let isClosing = false; // Flag to prevent reconnect on intentional close

        const connect = () => {
            if (isClosing) return;
            console.log("[📶] Connecting to Agent Mesh Stream (Multiplexed)...");
            const ws = new WebSocket(getNodeStreamUrl());
            wsRef.current = ws;

            ws.onopen = () => {
                console.log("[📶] Mesh Stream Connected");
                setConnected(true);
            };

            ws.onmessage = (event) => {
                if (isClosing) return;
                try {
                    const msg = JSON.parse(event.data);
                    // Filter logic...
                    if (msg.node_id && !attachedNodeIds.includes(msg.node_id)) return;

                    // Update stats if present
                    if ((msg.event === 'mesh_heartbeat' || msg.event === 'heartbeat')) {
                        if (msg.data?.stats) {
                            setNodeStats(prev => ({ ...prev, [msg.node_id]: msg.data.stats }));
                        }
                        if (msg.data?.nodes) {
                            msg.data.nodes.forEach(n => {
                                setNodeStats(prev => ({ ...prev, [n.node_id]: n.stats }));
                            });
                        }
                        return;
                    }
                    const xterm = xtermsRef.current[msg.node_id];
                    if (xterm) {
                        switch (msg.event) {
                            case 'task_assigned':
                                if (msg.data.command) xterm.write(`\x1b[38;5;33m\x1b[1m$ ${msg.data.command}\x1b[0m\r\n`);
                                break;
                            case 'task_stdout': xterm.write(msg.data); break;
                            case 'skill_event': if (msg.data?.type === 'output') xterm.write(msg.data.data); break;
                            case 'browser_event': xterm.write(`\x1b[90m${msg.data.type === 'console' ? '🖥️' : '🌐'} ${msg.data.text || msg.data.url}\x1b[0m\r\n`); break;
                        }
                    }
                } catch (e) { }
            };

            ws.onclose = () => {
                if (!isClosing) {
                    console.warn("[📶] Mesh Stream Disconnected. Reconnecting in 3s...");
                    setConnected(false);
                    reconnectTimer = setTimeout(connect, 3000);
                } else {
                    console.log("[📶] Mesh Stream Closed Intentionally.");
                }
            };
        };

        connect();

        return () => {
            isClosing = true;
            if (wsRef.current) wsRef.current.close();
            clearTimeout(reconnectTimer);
        };
    }, [JSON.stringify(attachedNodeIds)]); // Static dependency check

    const handleMount = (nodeId, xterm) => {
        xtermsRef.current[nodeId] = xterm;
    };

    const handleUnmount = (nodeId) => {
        delete xtermsRef.current[nodeId];
    };

    if (!attachedNodeIds || attachedNodeIds.length === 0) return null;

    return (
        <div className={`flex flex-col h-full bg-gray-950 text-gray-300 font-mono border-t border-gray-800 transition-colors duration-500`}>
            {/* Premium AI Observation Notice Banner */}
            <div className={`bg-amber-500/10 border-b border-amber-500/20 px-4 py-1.5 flex justify-between items-center overflow-hidden shrink-0 z-10`} id="ai-terminal-notice">
                <span className="text-[10px] font-black uppercase tracking-tighter text-amber-500 flex items-center gap-2">
                    <svg className={`w-3.5 h-3.5 shrink-0 ${isAIProcessing ? 'animate-bounce' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
                        <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
                    </svg>
                    {isAIProcessing ? 'AI Agent actively controlling node terminals...' : 'Terminal Surfaced to AI :: Context Synchronized'}
                </span>
                <span className="text-[9px] text-amber-600 font-bold uppercase tracking-widest opacity-60 hidden md:inline font-mono">
                    OBSERVATION_MODE
                </span>
            </div>

            {/* Swarm Status Bar */}
            <div className="flex bg-gray-950 border-b border-gray-800 px-4 py-1.5 justify-between items-center shrink-0">
                <span className="flex items-center text-[10px] font-black tracking-[0.2em] text-gray-500">
                    <span className={`w-1.5 h-1.5 rounded-full mr-2.5 ${connected ? 'bg-emerald-500' : 'bg-red-500'} ${isAIProcessing && connected ? 'animate-ping shadow-[0_0_8px_#10b981]' : ''}`}></span>
                    NODE_EXECUTION_SWARM
                </span>
                <div className="flex gap-4 items-center">
                    <span className="text-gray-600 uppercase tracking-widest text-[8px] font-bold">
                        Attached: {attachedNodeIds.length}
                    </span>
                    <span className="text-gray-500 uppercase tracking-widest text-[9px] font-bold py-0.5 px-2 rounded-full border border-gray-800 bg-gray-950/50">
                        Live Stream
                    </span>
                </div>
            </div>

            {/* Multi-Terminal Display */}
            <div className="flex flex-1 overflow-hidden divide-x divide-gray-800">
                {attachedNodeIds.map(nodeId => (
                    <TerminalNodeItem
                        key={nodeId}
                        nodeId={nodeId}
                        stats={nodeStats[nodeId]}
                        onMount={handleMount}
                        onUnmount={handleUnmount}
                        nodeConfig={nodes?.find(n => n.node_id === nodeId)}
                        isAIProcessing={isAIProcessing}
                    />
                ))}
            </div>
        </div>
    );
};

export default MultiNodeConsole;