diff --git a/frontend/src/App.js b/frontend/src/App.js index d3243f7..5bbcb88 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -7,7 +7,7 @@ import LoginPage from "./pages/LoginPage"; import SettingsPage from "./pages/SettingsPage"; import ProfilePage from "./pages/ProfilePage"; -import NodesPage from "./pages/NodesPage"; +import { NodesPage } from "./features/nodes"; import SkillsPage from "./pages/SkillsPage"; import { getUserStatus, logout, getUserProfile } from "./services/apiService"; diff --git a/frontend/src/components/NodeTerminal.js b/frontend/src/components/NodeTerminal.js deleted file mode 100644 index 2ecf8c7..0000000 --- a/frontend/src/components/NodeTerminal.js +++ /dev/null @@ -1,229 +0,0 @@ -import React, { useState, useEffect, useRef, useMemo } from 'react'; -import { getNodeStreamUrl } from '../services/apiService'; -import { Terminal } from '@xterm/xterm'; -import { FitAddon } from '@xterm/addon-fit'; -import '@xterm/xterm/css/xterm.css'; - -const NodeTerminal = ({ nodeId }) => { - const [isZoomed, setIsZoomed] = useState(false); - const [debugMode, setDebugMode] = useState(false); - const [latency, setLatency] = useState(null); - - const terminalRef = useRef(null); - const wsRef = useRef(null); - const xtermRef = useRef(null); - const fitAddonRef = useRef(null); - - const sessionId = useMemo(() => `terminal-${nodeId}-${Math.random().toString(36).substr(2, 9)}`, [nodeId]); - - // 1. Core Terminal Engine Initialization - useEffect(() => { - const xterm = new Terminal({ - cursorBlink: true, - theme: { - background: '#030712', // Matches Tailwind bg-gray-950 - foreground: '#10b981', // Matches text-emerald-500 loosely - cursor: '#10b981', - cursorAccent: '#030712', - }, - fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace', - fontSize: 13, - rightClickSelectsWord: true, - scrollback: 1000, - convertEol: true, - scrollOnOutput: true, - }); - - const fitAddon = new FitAddon(); - xterm.loadAddon(fitAddon); - - xterm.open(terminalRef.current); - fitAddon.fit(); - - xtermRef.current = xterm; - fitAddonRef.current = fitAddon; - - // Auto-refit terminal rows/cols when user resizes Chrome window - const observer = new ResizeObserver(() => fitAddon.fit()); - if (terminalRef.current) { - observer.observe(terminalRef.current); - } - - return () => { - observer.disconnect(); - xterm.dispose(); - }; - }, []); - - // 2. Headless WS Comm Link & Keystroke Forwarding - useEffect(() => { - const ws = new WebSocket(getNodeStreamUrl(nodeId)); - wsRef.current = ws; - - ws.onopen = () => { - xtermRef.current?.writeln('\x1b[32m\x1b[1m[CORTEX MESH] Stream Established — Persistent PTY Active\x1b[0m\r'); - // Force initial resize sync - fitAddonRef.current?.fit(); - }; - - ws.onmessage = (event) => { - try { - const msg = JSON.parse(event.data); - - // --- 1. Periodic Latency Monitor (Pong Response) --- - if (msg.event === 'pong' && msg.client_ts) { - const rtt = Date.now() - msg.client_ts; - setLatency(rtt); - } - - // --- 2. Skill Events (Terminal stdout) --- - else if (msg.event === 'skill_event' && msg.data?.type === 'output') { - // Crucial: Just blindly pipe pure PTY ANSI stdout to Xterm directly! No formatting! - xtermRef.current?.write(msg.data.data); - } - else if (msg.event === 'task_error') { - const errStr = msg.data?.stderr || JSON.stringify(msg.data); - xtermRef.current?.writeln(`\r\n\x1b[31m[ERROR] ${errStr}\x1b[0m\r`); - } - else if (debugMode) { - // Debug events visible natively inline in the terminal text - if (msg.event === 'task_start') { - xtermRef.current?.writeln(`\r\n\x1b[33m[DEBUG] task_start id=${msg.task_id || '?'}\x1b[0m\r`); - } else if (msg.event === 'task_complete') { - xtermRef.current?.writeln(`\r\n\x1b[33m[DEBUG] task_complete status=${msg.data?.status ?? '?'} id=${msg.task_id || '?'}\x1b[0m\r`); - } else if (msg.event === 'snapshot') { - xtermRef.current?.writeln(`\x1b[33m[DEBUG] snapshot status=${msg.data?.status || '?'}\x1b[0m\r`); - } - } - } catch (e) { - // Ignore raw failures - } - }; - - ws.onerror = () => xtermRef.current?.writeln('\r\n\x1b[31m[WebSocket connection error]\x1b[0m\r'); - ws.onclose = () => xtermRef.current?.writeln('\r\n\x1b[31m[WebSocket disconnected]\x1b[0m\r'); - - // BIND XTERM DATA TO SOCKET - const disposableData = xtermRef.current?.onData((data) => { - if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { - wsRef.current.send(JSON.stringify({ - action: "dispatch", - command: JSON.stringify({ tty: data }), - session_id: sessionId - })); - } - }); - - const disposableResize = xtermRef.current?.onResize(({ cols, rows }) => { - if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { - wsRef.current.send(JSON.stringify({ - action: "dispatch", - command: JSON.stringify({ action: "resize", cols, rows }), - session_id: sessionId - })); - } - }); - - // --- PERIODIC LATENCY PING (Pulse) --- - const pingInterval = setInterval(() => { - if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { - wsRef.current.send(JSON.stringify({ - action: "ping", - ts: Date.now() - })); - } - }, 3000); - - return () => { - disposableData?.dispose(); - disposableResize?.dispose(); - clearInterval(pingInterval); - ws.close(); - }; - }, [nodeId, sessionId, debugMode]); - - // Force XTerm internal Grid fit on zoom toggle - useEffect(() => { - const timeoutId = setTimeout(() => { - fitAddonRef.current?.fit(); - }, 150); - return () => clearTimeout(timeoutId); - }, [isZoomed]); - - const handleClear = () => { - xtermRef.current?.clear(); - }; - - return ( -
- {isZoomed &&
setIsZoomed(false)}>
} - - {/* Title Bar */} -
- - - Interactive Console - — {nodeId} - {latency !== null && ( - - {latency}ms - - )} - -
- {/* Debug Mode Toggle */} - - - {/* Clear */} - - - {/* Zoom */} - -
-
- - {/* XTerm Host Area */} - {/* Note: 'h-auto' combined with 'flex-1' guarantees XTerm occupies exactly the rest of the flex parent container */} -
-
- ); -}; - -export default NodeTerminal; diff --git a/frontend/src/features/nodes/components/NodeTerminal.js b/frontend/src/features/nodes/components/NodeTerminal.js new file mode 100644 index 0000000..0d5d6b9 --- /dev/null +++ b/frontend/src/features/nodes/components/NodeTerminal.js @@ -0,0 +1,229 @@ +import React, { useState, useEffect, useRef, useMemo } from 'react'; +import { getNodeStreamUrl } from '../../../services/apiService'; +import { Terminal } from '@xterm/xterm'; +import { FitAddon } from '@xterm/addon-fit'; +import '@xterm/xterm/css/xterm.css'; + +const NodeTerminal = ({ nodeId }) => { + const [isZoomed, setIsZoomed] = useState(false); + const [debugMode, setDebugMode] = useState(false); + const [latency, setLatency] = useState(null); + + const terminalRef = useRef(null); + const wsRef = useRef(null); + const xtermRef = useRef(null); + const fitAddonRef = useRef(null); + + const sessionId = useMemo(() => `terminal-${nodeId}-${Math.random().toString(36).substr(2, 9)}`, [nodeId]); + + // 1. Core Terminal Engine Initialization + useEffect(() => { + const xterm = new Terminal({ + cursorBlink: true, + theme: { + background: '#030712', // Matches Tailwind bg-gray-950 + foreground: '#10b981', // Matches text-emerald-500 loosely + cursor: '#10b981', + cursorAccent: '#030712', + }, + fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace', + fontSize: 13, + rightClickSelectsWord: true, + scrollback: 1000, + convertEol: true, + scrollOnOutput: true, + }); + + const fitAddon = new FitAddon(); + xterm.loadAddon(fitAddon); + + xterm.open(terminalRef.current); + fitAddon.fit(); + + xtermRef.current = xterm; + fitAddonRef.current = fitAddon; + + // Auto-refit terminal rows/cols when user resizes Chrome window + const observer = new ResizeObserver(() => fitAddon.fit()); + if (terminalRef.current) { + observer.observe(terminalRef.current); + } + + return () => { + observer.disconnect(); + xterm.dispose(); + }; + }, []); + + // 2. Headless WS Comm Link & Keystroke Forwarding + useEffect(() => { + const ws = new WebSocket(getNodeStreamUrl(nodeId)); + wsRef.current = ws; + + ws.onopen = () => { + xtermRef.current?.writeln('\x1b[32m\x1b[1m[CORTEX MESH] Stream Established — Persistent PTY Active\x1b[0m\r'); + // Force initial resize sync + fitAddonRef.current?.fit(); + }; + + ws.onmessage = (event) => { + try { + const msg = JSON.parse(event.data); + + // --- 1. Periodic Latency Monitor (Pong Response) --- + if (msg.event === 'pong' && msg.client_ts) { + const rtt = Date.now() - msg.client_ts; + setLatency(rtt); + } + + // --- 2. Skill Events (Terminal stdout) --- + else if (msg.event === 'skill_event' && msg.data?.type === 'output') { + // Crucial: Just blindly pipe pure PTY ANSI stdout to Xterm directly! No formatting! + xtermRef.current?.write(msg.data.data); + } + else if (msg.event === 'task_error') { + const errStr = msg.data?.stderr || JSON.stringify(msg.data); + xtermRef.current?.writeln(`\r\n\x1b[31m[ERROR] ${errStr}\x1b[0m\r`); + } + else if (debugMode) { + // Debug events visible natively inline in the terminal text + if (msg.event === 'task_start') { + xtermRef.current?.writeln(`\r\n\x1b[33m[DEBUG] task_start id=${msg.task_id || '?'}\x1b[0m\r`); + } else if (msg.event === 'task_complete') { + xtermRef.current?.writeln(`\r\n\x1b[33m[DEBUG] task_complete status=${msg.data?.status ?? '?'} id=${msg.task_id || '?'}\x1b[0m\r`); + } else if (msg.event === 'snapshot') { + xtermRef.current?.writeln(`\x1b[33m[DEBUG] snapshot status=${msg.data?.status || '?'}\x1b[0m\r`); + } + } + } catch (e) { + // Ignore raw failures + } + }; + + ws.onerror = () => xtermRef.current?.writeln('\r\n\x1b[31m[WebSocket connection error]\x1b[0m\r'); + ws.onclose = () => xtermRef.current?.writeln('\r\n\x1b[31m[WebSocket disconnected]\x1b[0m\r'); + + // BIND XTERM DATA TO SOCKET + const disposableData = xtermRef.current?.onData((data) => { + if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify({ + action: "dispatch", + command: JSON.stringify({ tty: data }), + session_id: sessionId + })); + } + }); + + const disposableResize = xtermRef.current?.onResize(({ cols, rows }) => { + if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify({ + action: "dispatch", + command: JSON.stringify({ action: "resize", cols, rows }), + session_id: sessionId + })); + } + }); + + // --- PERIODIC LATENCY PING (Pulse) --- + const pingInterval = setInterval(() => { + if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify({ + action: "ping", + ts: Date.now() + })); + } + }, 3000); + + return () => { + disposableData?.dispose(); + disposableResize?.dispose(); + clearInterval(pingInterval); + ws.close(); + }; + }, [nodeId, sessionId, debugMode]); + + // Force XTerm internal Grid fit on zoom toggle + useEffect(() => { + const timeoutId = setTimeout(() => { + fitAddonRef.current?.fit(); + }, 150); + return () => clearTimeout(timeoutId); + }, [isZoomed]); + + const handleClear = () => { + xtermRef.current?.clear(); + }; + + return ( +
+ {isZoomed &&
setIsZoomed(false)}>
} + + {/* Title Bar */} +
+ + + Interactive Console + — {nodeId} + {latency !== null && ( + + {latency}ms + + )} + +
+ {/* Debug Mode Toggle */} + + + {/* Clear */} + + + {/* Zoom */} + +
+
+ + {/* XTerm Host Area */} + {/* Note: 'h-auto' combined with 'flex-1' guarantees XTerm occupies exactly the rest of the flex parent container */} +
+
+ ); +}; + +export default NodeTerminal; diff --git a/frontend/src/features/nodes/index.js b/frontend/src/features/nodes/index.js new file mode 100644 index 0000000..a1f3746 --- /dev/null +++ b/frontend/src/features/nodes/index.js @@ -0,0 +1,4 @@ +// Feature entry point for the Nodes feature. +// Exposes the main page (and any future nodes-related exports) via a single import. + +export { default as NodesPage } from "./pages/NodesPage"; diff --git a/frontend/src/features/nodes/pages/NodesPage.js b/frontend/src/features/nodes/pages/NodesPage.js new file mode 100644 index 0000000..d7df934 --- /dev/null +++ b/frontend/src/features/nodes/pages/NodesPage.js @@ -0,0 +1,894 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { + getAdminNodes, adminCreateNode, adminUpdateNode, adminDeleteNode, + adminDownloadNodeBundle, getUserAccessibleNodes, + getAdminGroups, getNodeStreamUrl +} from '../../../services/apiService'; +import NodeTerminal from "../components/NodeTerminal"; +import FileSystemNavigator from "../../../components/FileSystemNavigator"; + +const NodesPage = ({ user }) => { + const [nodes, setNodes] = useState([]); + const [groups, setGroups] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [showCreateModal, setShowCreateModal] = useState(false); + const [nodeToDelete, setNodeToDelete] = useState(null); + const [newNode, setNewNode] = useState({ node_id: '', display_name: '', description: '', skill_config: { shell: { enabled: true }, sync: { enabled: true } } }); + const [expandedTerminals, setExpandedTerminals] = useState({}); // node_id -> boolean + const [expandedNodes, setExpandedNodes] = useState({}); // node_id -> boolean + const [expandedFiles, setExpandedFiles] = useState({}); // node_id -> boolean + const [editingNodeId, setEditingNodeId] = useState(null); + const [editForm, setEditForm] = useState({ + display_name: '', + description: '', + skill_config: { + shell: { + enabled: true, + sandbox: { mode: 'STRICT', allowed_commands: [], denied_commands: [], sensitive_commands: [] } + } + } + }); + + // 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(() => { + if (!user?.id) return; + + // CRITICAL FIX: getNodeStreamUrl() without args uses the global user-based stream. + // Passing user.id was incorrectly interpreted as a nodeId. + const wsUrl = getNodeStreamUrl(); + const ws = new WebSocket(wsUrl); + + ws.onopen = () => console.log("[📡] Mesh Monitor Connected"); + ws.onmessage = (e) => { + const msg = JSON.parse(e.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') { + setMeshStatus(prev => { + const statusMap = { ...prev }; + msg.data.nodes.forEach(n => { + statusMap[n.node_id] = { status: n.status, stats: n.stats }; + }); + return statusMap; + }); + } else if (msg.event === 'heartbeat') { + // Individual node heartbeat + setMeshStatus(prev => ({ + ...prev, + [msg.node_id]: { ...prev[msg.node_id], stats: msg.data, status: 'online' } + })); + } else { + setRecentEvents(prev => [msg, ...prev].slice(0, 50)); + } + }; + + ws.onclose = () => console.log("[📡] Mesh Monitor Disconnected"); + ws.onerror = (err) => console.error("[📡] Mesh Monitor Error:", err); + + return () => ws.close(); + }, [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); + } + }; + + const confirmDeleteNode = async () => { + if (!nodeToDelete) return; + try { + await adminDeleteNode(nodeToDelete); + setNodeToDelete(null); + fetchData(); + } catch (err) { + alert(err.message); + } + }; + + const startEditing = (node) => { + setEditingNodeId(node.node_id); + setEditForm({ + display_name: node.display_name, + description: node.description, + skill_config: node.skill_config || {} + }); + }; + + const handleUpdateNode = async (nodeId) => { + try { + await adminUpdateNode(nodeId, editForm); + setEditingNodeId(null); + fetchData(); + } catch (err) { + alert(err.message); + } + }; + + // ─── Node Name Hover Tooltip ───────────────────────────────────────────── + const NodeNameWithTooltip = ({ node }) => { + const liveCaps = meshStatus[node.node_id]?.caps || {}; + const caps = { ...(node.capabilities || {}), ...liveCaps }; + + const gpu = caps.gpu; + const arch = caps.arch === 'aarch64' ? 'arm64' : caps.arch; + const os = caps.os; + const osRelease = caps.os_release; + const hasCaps = caps && Object.keys(caps).length > 0; + + const gpuColor = !gpu || gpu === 'none' + ? 'bg-gray-100 dark:bg-gray-700 text-gray-500' + : gpu === 'apple-silicon' + ? 'bg-purple-100 dark:bg-purple-900/50 text-purple-600 dark:text-purple-300' + : 'bg-green-100 dark:bg-green-900/50 text-green-700 dark:text-green-300'; + const gpuIcon = (!gpu || gpu === 'none') ? '—' : gpu === 'apple-silicon' ? '🍎' : '🟢'; + const gpuText = !gpu || gpu === 'none' ? 'No GPU' : gpu === 'apple-silicon' ? 'Apple GPU (Metal)' : gpu.split(',')[0].trim(); + + const osIcon = os === 'linux' ? '🐧' : os === 'darwin' ? '🍏' : os === 'windows' ? '🪟' : '💻'; + const archIsArm = arch && (arch.startsWith('arm') || arch === 'arm64'); + + return ( +
+ {/* Node Name — the hover trigger */} +

+ {node.display_name} +

+ + {/* Tooltip — appears below the name */} +
+ {/* Arrow */} +
+ + {/* Header */} +
+
{node.display_name}
+
{node.node_id}
+ {node.description && ( +
{node.description}
+ )} +
+ + {hasCaps ? ( +
+ {/* GPU Row */} +
+ GPU + + {gpuIcon} {gpuText} + +
+ {/* OS Row */} + {os && ( +
+ OS + + {osIcon} {os === 'darwin' ? 'macOS' : os === 'linux' ? `Linux ${osRelease || ''}`.trim() : os} + +
+ )} + {/* Arch Row */} + {arch && ( +
+ Arch + + {archIsArm ? '🔩' : '🔲'} {arch} + +
+ )} + {/* Registered Owner */} +
+ Owner + {node.registered_by || 'system'} +
+
+ ) : ( +
Capabilities available when node connects
+ )} +
+
+ ); + }; + // ───────────────────────────────────────────────────────────────────────── + + const NodeHealthMetrics = ({ node, compact = false }) => { + const live = meshStatus[node.node_id]; + const stats = live?.stats || node.stats; + + // Use live status if available, fallback to initial node status + const status = live?.status || node.last_status; + const isOnline = status === 'online' || status === 'idle' || status === 'busy' || (stats?.cpu_usage_percent > 0); + + // If we are definitely offline in compact mode, show the spacer + if (!isOnline && compact) return
; + + const cpu = stats?.cpu_usage_percent || 0; + const mem = stats?.memory_usage_percent || 0; + const cpuCount = stats?.cpu_count || 0; + const memUsed = (stats?.memory_used_gb || 0).toFixed(1); + const memTotal = (stats?.memory_total_gb || 0).toFixed(1); + + // Rich Metrics (M6 additions) + const cpuFreq = stats?.cpu_freq_mhz || 0; + const loadAvg = stats?.load_avg || [0, 0, 0]; + const perCore = stats?.cpu_usage_per_core || []; + const memAvail = (stats?.memory_available_gb || 0).toFixed(1); + + return ( +
+ {/* CPU Metric */} +
+
+ CPU +
+ {cpu.toFixed(0)}% + {compact && cpuCount > 0 && ( + ({cpuCount}c) + )} +
+
+
+
+
+ + {/* Rich Tooltip (CPU) - Popping DOWN to avoid header clipping */} +
+ {/* Tooltip Arrow (Top) */} +
+
+ CPU Details + {cpu.toFixed(1)}% +
+
+
+ Cores: + {cpuCount} Threads +
+ {cpuFreq > 0 && ( +
+ Frequency: + {(cpuFreq / 1000).toFixed(2)} GHz +
+ )} +
+ Load (1/5/15): + {loadAvg.map(l => l.toFixed(2)).join(' ')} +
+ + {perCore.length > 0 && ( +
+
Per-Core Distribution
+
+ {perCore.map((c, i) => ( +
+
+ {i} +
+ ))} +
+
+ )} +
+
+
+ + {/* RAM Metric */} +
+
+ RAM +
+ {mem.toFixed(0)}% + {compact && memTotal > 0 && ( + ({memUsed}G) + )} +
+
+
+
+
+ + {/* Rich Tooltip (RAM) - Popping DOWN to avoid header clipping */} +
+ {/* Tooltip Arrow (Top) */} +
+
+ Memory Hub + {mem.toFixed(1)}% +
+
+
+ Used: + {memUsed} GB +
+
+ Available: + {memAvail} GB +
+
+ Total Physical: + {memTotal} GB +
+ +
+
+
+
+
+
+ Used + {100 - mem > 10 ? 'Free' : ''} +
+
+
+
+
+
+ ); + }; + + return ( +
+ {/* Header */} +
+
+
+

+ 🚀 Agent Node Mesh +

+

+ {isAdmin + ? "Manage distributed execution nodes and monitor live health." + : "Monitor the health and availability of your accessible agent nodes."} +

+
+
+ + {isAdmin && ( + + )} +
+
+
+ + {/* Main Content */} +
+ {loading ? ( +
+
+
+ ) : error ? ( +
+ Error: {error} +
+ ) : ( +
+
+ {nodes.map(node => ( +
+ {/* ── Top Row (Mobile-friendly) ─────────────────────────── */} +
+ + {/* Row 1: Status pill + Name + (desktop) CPU/RAM */} +
+ {/* Status dot */} +
+
+ + {meshStatus[node.node_id]?.status || node.last_status || 'offline'} + +
+ + {/* Name (tooltip) — takes remaining width */} +
+ +
ID: {node.node_id}
+
+ + {/* Desktop-only CPU/RAM inline */} +
+ +
+
+ + {/* Mobile-only: CPU/RAM row below name */} +
+ +
+ + {/* Row 2: Action toolbar */} +
+ {/* Active/Disabled pill — slim on mobile */} + + + {/* Icon action buttons */} +
+ + + + {isAdmin && ( + <> +
+ + + )} +
+
+
+ + {/* Expanded Panels */} + {(expandedNodes[node.node_id] || expandedTerminals[node.node_id] || expandedFiles[node.node_id]) && ( +
+ {expandedNodes[node.node_id] && ( +
+
+
+

General Configuration

+ {isAdmin && ( + editingNodeId !== node.node_id ? ( + + ) : ( +
+ + +
+ ) + )} +
+
+
+ + {editingNodeId === node.node_id ? ( + setEditForm({ ...editForm, display_name: e.target.value })} + /> + ) : ( +
+ {node.display_name} +
+ )} +
+
+ + {editingNodeId === node.node_id ? ( +