From a86231c8f59fa533659e5142805ae9b8874baeed Mon Sep 17 00:00:00 2001 From: Nicole Rappe Date: Wed, 15 Oct 2025 04:15:21 -0600 Subject: [PATCH 1/4] Fix screenshot role config handling --- Data/Agent/Roles/role_Screenshot.py | 194 ++++++++++++++++++++++++---- 1 file changed, 166 insertions(+), 28 deletions(-) diff --git a/Data/Agent/Roles/role_Screenshot.py b/Data/Agent/Roles/role_Screenshot.py index 65962ae..45af8d8 100644 --- a/Data/Agent/Roles/role_Screenshot.py +++ b/Data/Agent/Roles/role_Screenshot.py @@ -44,12 +44,56 @@ extra_top_padding = overlay_green_thickness * 2 + 4 overlay_widgets = {} +def _coerce_int(value, default, minimum=None, maximum=None): + try: + if value is None: + raise ValueError + if isinstance(value, bool): + raise ValueError + if isinstance(value, (int, float)): + ivalue = int(value) + else: + text = str(value).strip() + if not text: + raise ValueError + ivalue = int(float(text)) + if minimum is not None and ivalue < minimum: + return minimum + if maximum is not None and ivalue > maximum: + return maximum + return ivalue + except Exception: + return default + + +def _coerce_bool(value, default=True): + if isinstance(value, bool): + return value + if isinstance(value, str): + text = value.strip().lower() + if text in {'true', '1', 'yes', 'on'}: + return True + if text in {'false', '0', 'no', 'off'}: + return False + return default + + +def _coerce_text(value): + if value is None: + return '' + try: + return str(value) + except Exception: + return '' + + class ScreenshotRegion(QtWidgets.QWidget): def __init__(self, ctx, node_id, x=100, y=100, w=300, h=200, alias=None): super().__init__() self.ctx = ctx self.node_id = node_id - self.alias = alias + self.alias = _coerce_text(alias) + self._visible = True self.setGeometry( x - handle_size, y - handle_size - extra_top_padding, @@ -99,6 +143,19 @@ class ScreenshotRegion(QtWidgets.QWidget): p.setBrush(QtGui.QColor(0, 191, 255)) p.drawRect(bar_x, bar_y - bar_height - 10, bar_width, bar_height * 4) + if self.alias: + p.setPen(QtGui.QPen(QtGui.QColor(255, 255, 255))) + font = QtGui.QFont() + font.setPointSize(10) + p.setFont(font) + text_rect = QtCore.QRect( + overlay_green_thickness * 2, + extra_top_padding + overlay_green_thickness * 2, + w - overlay_green_thickness * 4, + overlay_green_thickness * 10, + ) + p.drawText(text_rect, QtCore.Qt.AlignLeft | QtCore.Qt.AlignTop, self.alias) + def get_geometry(self): g = self.geometry() return ( @@ -108,6 +165,29 @@ class ScreenshotRegion(QtWidgets.QWidget): g.height() - handle_size * 2 - extra_top_padding, ) + def set_region(self, x, y, w, h): + self.setGeometry( + x - handle_size, + y - handle_size - extra_top_padding, + w + handle_size * 2, + h + handle_size * 2 + extra_top_padding, + ) + + def set_alias(self, alias): + self.alias = _coerce_text(alias) + self.update() + + def apply_visibility(self, visible: bool): + self._visible = bool(visible) + if self._visible: + self.show() + try: + self.raise_() + except Exception: + pass + else: + self.hide() + def mousePressEvent(self, e): if e.button() == QtCore.Qt.LeftButton: pos = e.pos() @@ -184,6 +264,7 @@ class Role: def __init__(self, ctx): self.ctx = ctx self.tasks = {} + self.running_configs = {} def register_events(self): sio = self.ctx.sio @@ -214,6 +295,7 @@ class Role: except Exception: pass self.tasks.clear() + self.running_configs.clear() # Close all widgets for nid in list(overlay_widgets.keys()): self._close_overlay(nid) @@ -222,10 +304,16 @@ class Role: # Filter only screenshot roles screenshot_roles = [r for r in roles_cfg if (r.get('role') == 'screenshot')] + sanitized_roles = [] + for rcfg in screenshot_roles: + sanitized = self._normalize_config(rcfg) + if sanitized: + sanitized_roles.append(sanitized) + # Optional: forward interval to SYSTEM helper via hook try: - if screenshot_roles and 'send_service_control' in self.ctx.hooks: - interval_ms = int(screenshot_roles[0].get('interval', 1000)) + if sanitized_roles and 'send_service_control' in self.ctx.hooks: + interval_ms = sanitized_roles[0]['interval'] try: self.ctx.hooks['send_service_control']({'type': 'screenshot_config', 'interval_ms': interval_ms}) except Exception: @@ -234,7 +322,7 @@ class Role: pass # Cancel tasks that are no longer present - new_ids = {r.get('node_id') for r in screenshot_roles if r.get('node_id')} + new_ids = {r.get('node_id') for r in sanitized_roles if r.get('node_id')} old_ids = set(self.tasks.keys()) removed = old_ids - new_ids for rid in removed: @@ -250,6 +338,7 @@ class Role: self._close_overlay(rid) except Exception: pass + self.running_configs.pop(rid, None) if removed: try: self.ctx.config._write() @@ -257,39 +346,80 @@ class Role: pass # Start tasks for all screenshot roles in config - for rcfg in screenshot_roles: + for rcfg in sanitized_roles: nid = rcfg.get('node_id') if not nid: continue if nid in self.tasks: - continue + if self.running_configs.get(nid) == rcfg: + continue + prev = self.tasks.pop(nid) + try: + prev.cancel() + except Exception: + pass task = asyncio.create_task(self._screenshot_task(rcfg)) self.tasks[nid] = task + self.running_configs[nid] = rcfg + + def _normalize_config(self, cfg): + try: + nid = cfg.get('node_id') + if not nid: + return None + norm = { + 'node_id': nid, + 'interval': _coerce_int(cfg.get('interval'), 1000, minimum=100), + 'x': _coerce_int(cfg.get('x'), 100), + 'y': _coerce_int(cfg.get('y'), 100), + 'w': _coerce_int(cfg.get('w'), 300, minimum=1), + 'h': _coerce_int(cfg.get('h'), 200, minimum=1), + 'visible': _coerce_bool(cfg.get('visible'), True), + 'alias': _coerce_text(cfg.get('alias')), + } + return norm + except Exception: + return None async def _screenshot_task(self, cfg): + cfg = self._normalize_config(cfg) or {} nid = cfg.get('node_id') - alias = cfg.get('alias', '') - reg = self.ctx.config.data.setdefault('regions', {}) - r = reg.get(nid) - if r: - region = (r['x'], r['y'], r['w'], r['h']) - else: - region = ( - cfg.get('x', 100), - cfg.get('y', 100), - cfg.get('w', 300), - cfg.get('h', 200), - ) - reg[nid] = {'x': region[0], 'y': region[1], 'w': region[2], 'h': region[3]} - try: - self.ctx.config._write() - except Exception: - pass + if not nid: + return - if nid not in overlay_widgets: + alias = cfg.get('alias', '') + visible = cfg.get('visible', True) + reg = self.ctx.config.data.setdefault('regions', {}) + stored = reg.get(nid) if isinstance(reg.get(nid), dict) else None + base_region = ( + cfg.get('x', 100), + cfg.get('y', 100), + cfg.get('w', 300), + cfg.get('h', 200), + ) + if stored: + region = ( + _coerce_int(stored.get('x'), base_region[0]), + _coerce_int(stored.get('y'), base_region[1]), + _coerce_int(stored.get('w'), base_region[2], minimum=1), + _coerce_int(stored.get('h'), base_region[3], minimum=1), + ) + else: + region = base_region + reg[nid] = {'x': region[0], 'y': region[1], 'w': region[2], 'h': region[3]} + try: + self.ctx.config._write() + except Exception: + pass + + widget = overlay_widgets.get(nid) + if widget is None: widget = ScreenshotRegion(self.ctx, nid, *region, alias=alias) overlay_widgets[nid] = widget - widget.show() + else: + widget.set_region(*region) + widget.set_alias(alias) + widget.apply_visibility(visible) await self.ctx.sio.emit('agent_screenshot_task', { 'agent_id': self.ctx.agent_id, @@ -298,14 +428,17 @@ class Role: 'x': region[0], 'y': region[1], 'w': region[2], 'h': region[3] }) - interval = cfg.get('interval', 1000) / 1000.0 + interval = max(cfg.get('interval', 1000), 50) / 1000.0 loop = asyncio.get_event_loop() # Maximum number of screenshot roles you can assign to an agent. (8 already feels overkill) executor = concurrent.futures.ThreadPoolExecutor(max_workers=8) - + try: while True: - x, y, w, h = overlay_widgets[nid].get_geometry() + widget = overlay_widgets.get(nid) + if widget is None: + break + x, y, w, h = widget.get_geometry() grab = partial(ImageGrab.grab, bbox=(x, y, x + w, y + h)) img = await loop.run_in_executor(executor, grab) buf = BytesIO(); img.save(buf, format='PNG') @@ -321,3 +454,8 @@ class Role: pass except Exception: traceback.print_exc() + finally: + try: + executor.shutdown(wait=False) + except Exception: + pass From 41c52590f4173a545ef7d82e4381db54b8e5405a Mon Sep 17 00:00:00 2001 From: Nicole Rappe Date: Wed, 15 Oct 2025 04:40:21 -0600 Subject: [PATCH 2/4] Support selecting agent context for screenshots --- Data/Agent/Roles/role_Screenshot.py | 16 ++ Data/Agent/agent.py | 21 +- Data/Agent/role_manager.py | 4 + .../Node_Agent_Role_Screenshot.jsx | 71 +++++-- .../WebUI/src/nodes/Agent/Node_Agent.jsx | 186 +++++++++++++++--- Data/Server/server.py | 49 ++++- 6 files changed, 291 insertions(+), 56 deletions(-) diff --git a/Data/Agent/Roles/role_Screenshot.py b/Data/Agent/Roles/role_Screenshot.py index 45af8d8..7b0709d 100644 --- a/Data/Agent/Roles/role_Screenshot.py +++ b/Data/Agent/Roles/role_Screenshot.py @@ -87,6 +87,15 @@ def _coerce_text(value): return '' +def _normalize_mode(value): + text = _coerce_text(value).strip().lower() + if text in {'interactive', 'currentuser', 'user'}: + return 'currentuser' + if text in {'system', 'svc', 'service'}: + return 'system' + return '' + + class ScreenshotRegion(QtWidgets.QWidget): def __init__(self, ctx, node_id, x=100, y=100, w=300, h=200, alias=None): super().__init__() @@ -376,6 +385,8 @@ class Role: 'h': _coerce_int(cfg.get('h'), 200, minimum=1), 'visible': _coerce_bool(cfg.get('visible'), True), 'alias': _coerce_text(cfg.get('alias')), + 'target_agent_mode': _normalize_mode(cfg.get('target_agent_mode')), + 'target_agent_host': _coerce_text(cfg.get('target_agent_host')), } return norm except Exception: @@ -387,6 +398,11 @@ class Role: if not nid: return + target_mode = cfg.get('target_agent_mode') or '' + current_mode = getattr(self.ctx, 'service_mode', '') or '' + if target_mode and current_mode and target_mode != current_mode: + return + alias = cfg.get('alias', '') visible = cfg.get('visible', True) reg = self.ctx.config.data.setdefault('regions', {}) diff --git a/Data/Agent/agent.py b/Data/Agent/agent.py index 9140ad9..53afe21 100644 --- a/Data/Agent/agent.py +++ b/Data/Agent/agent.py @@ -72,6 +72,7 @@ def _bootstrap_log(msg: str): # Headless/service mode flag (skip Qt and interactive UI) SYSTEM_SERVICE_MODE = ('--system-service' in sys.argv) or (os.environ.get('BOREALIS_AGENT_MODE') == 'system') +SERVICE_MODE = 'system' if SYSTEM_SERVICE_MODE else 'currentuser' _bootstrap_log(f'agent.py loaded; SYSTEM_SERVICE_MODE={SYSTEM_SERVICE_MODE}; argv={sys.argv!r}') def _argv_get(flag: str, default: str = None): try: @@ -859,7 +860,8 @@ async def send_heartbeat(): "agent_id": AGENT_ID, "hostname": socket.gethostname(), "agent_operating_system": detect_agent_os(), - "last_seen": int(time.time()) + "last_seen": int(time.time()), + "service_mode": SERVICE_MODE, } await sio.emit("agent_heartbeat", payload) # Also report collector status alive ping. @@ -872,6 +874,7 @@ async def send_heartbeat(): 'agent_id': AGENT_ID, 'hostname': socket.gethostname(), 'active': True, + 'service_mode': SERVICE_MODE, 'last_user': f"{os.environ.get('USERDOMAIN') or socket.gethostname()}\\{getpass.getuser()}" }) else: @@ -879,6 +882,7 @@ async def send_heartbeat(): 'agent_id': AGENT_ID, 'hostname': socket.gethostname(), 'active': True, + 'service_mode': SERVICE_MODE, }) except Exception: pass @@ -1203,7 +1207,7 @@ async def send_agent_details_once(): async def connect(): print(f"[INFO] Successfully Connected to Borealis Server!") _log_agent('Connected to server.') - await sio.emit('connect_agent', {"agent_id": AGENT_ID}) + await sio.emit('connect_agent', {"agent_id": AGENT_ID, "service_mode": SERVICE_MODE}) # Send an immediate heartbeat so the UI can populate instantly. try: @@ -1211,7 +1215,8 @@ async def connect(): "agent_id": AGENT_ID, "hostname": socket.gethostname(), "agent_operating_system": detect_agent_os(), - "last_seen": int(time.time()) + "last_seen": int(time.time()), + "service_mode": SERVICE_MODE, }) except Exception as e: print(f"[WARN] initial heartbeat failed: {e}") @@ -1225,6 +1230,7 @@ async def connect(): 'agent_id': AGENT_ID, 'hostname': socket.gethostname(), 'active': True, + 'service_mode': SERVICE_MODE, 'last_user': f"{os.environ.get('USERDOMAIN') or socket.gethostname()}\\{getpass.getuser()}" }) else: @@ -1232,6 +1238,7 @@ async def connect(): 'agent_id': AGENT_ID, 'hostname': socket.gethostname(), 'active': True, + 'service_mode': SERVICE_MODE, }) except Exception: pass @@ -1542,9 +1549,10 @@ if __name__=='__main__': # Initialize roles context for role tasks # Initialize role manager and hot-load roles from Roles/ try: - hooks = {'send_service_control': send_service_control, 'get_server_url': get_server_url} + base_hooks = {'send_service_control': send_service_control, 'get_server_url': get_server_url} if not SYSTEM_SERVICE_MODE: # Load interactive-context roles (tray/UI, current-user execution, screenshot, etc.) + hooks_interactive = {**base_hooks, 'service_mode': 'currentuser'} ROLE_MANAGER = RoleManager( base_dir=os.path.dirname(__file__), context='interactive', @@ -1552,12 +1560,13 @@ if __name__=='__main__': agent_id=AGENT_ID, config=CONFIG, loop=loop, - hooks=hooks, + hooks=hooks_interactive, ) ROLE_MANAGER.load() # Load system roles only when running in SYSTEM service mode ROLE_MANAGER_SYS = None if SYSTEM_SERVICE_MODE: + hooks_system = {**base_hooks, 'service_mode': 'system'} ROLE_MANAGER_SYS = RoleManager( base_dir=os.path.dirname(__file__), context='system', @@ -1565,7 +1574,7 @@ if __name__=='__main__': agent_id=AGENT_ID, config=CONFIG, loop=loop, - hooks=hooks, + hooks=hooks_system, ) ROLE_MANAGER_SYS.load() except Exception as e: diff --git a/Data/Agent/role_manager.py b/Data/Agent/role_manager.py index 2f90af0..1731ed2 100644 --- a/Data/Agent/role_manager.py +++ b/Data/Agent/role_manager.py @@ -24,6 +24,10 @@ class RoleManager: self.config = config self.loop = loop self.hooks = hooks or {} + try: + self.service_mode = (self.hooks.get('service_mode') or '').strip().lower() + except Exception: + self.service_mode = '' def __init__(self, base_dir: str, context: str, sio, agent_id: str, config, loop, hooks: Optional[dict] = None): self.base_dir = base_dir diff --git a/Data/Server/WebUI/src/nodes/Agent Roles/Node_Agent_Role_Screenshot.jsx b/Data/Server/WebUI/src/nodes/Agent Roles/Node_Agent_Role_Screenshot.jsx index 50a87f7..fa18478 100644 --- a/Data/Server/WebUI/src/nodes/Agent Roles/Node_Agent_Role_Screenshot.jsx +++ b/Data/Server/WebUI/src/nodes/Agent Roles/Node_Agent_Role_Screenshot.jsx @@ -1,5 +1,5 @@ ////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: /Data/WebUI/src/nodes/Agent/Node_Agent_Role_Screenshot.jsx -import React, { useEffect, useRef, useState } from "react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; import { Handle, Position, useReactFlow, useStore } from "reactflow"; import ShareIcon from "@mui/icons-material/Share"; import IconButton from "@mui/material/IconButton"; @@ -21,6 +21,17 @@ const AgentScreenshotNode = ({ id, data }) => { const { setNodes, getNodes } = useReactFlow(); const edges = useStore(state => state.edges); + const resolveAgentData = useCallback(() => { + try { + const agentEdge = edges.find(e => e.target === id && e.sourceHandle === "provisioner"); + const agentNode = getNodes().find(n => n.id === agentEdge?.source); + return agentNode?.data || null; + } catch (err) { + return null; + } + }, [edges, getNodes, id]); + + // Core config values pulled from sidebar config (with defaults) const interval = parseInt(data?.interval || 1000, 10) || 1000; const region = { @@ -32,6 +43,11 @@ const AgentScreenshotNode = ({ id, data }) => { const visible = (data?.visible ?? "true") === "true"; const alias = data?.alias || ""; const [imageBase64, setImageBase64] = useState(data?.value || ""); + const agentData = resolveAgentData(); + const targetModeLabel = ((agentData?.agent_mode || "").toString().toLowerCase() === "system") + ? "SYSTEM Agent" + : "CURRENTUSER Agent"; + const targetHostLabel = (agentData?.agent_host || "").toString(); // Always push current imageBase64 into BorealisValueBus at the global update rate useEffect(() => { @@ -56,14 +72,9 @@ const AgentScreenshotNode = ({ id, data }) => { const handleScreenshot = (payload) => { if (payload?.node_id !== id) return; // Additionally ensure payload is from the agent connected upstream of this node - try { - const agentEdge = edges.find(e => e.target === id && e.sourceHandle === "provisioner"); - const agentNode = getNodes().find(n => n.id === agentEdge?.source); - const selectedAgentId = agentNode?.data?.agent_id; - if (!selectedAgentId || payload?.agent_id !== selectedAgentId) return; - } catch (err) { - return; // fail-closed if we cannot resolve upstream agent - } + const agentData = resolveAgentData(); + const selectedAgentId = agentData?.agent_id; + if (!selectedAgentId || payload?.agent_id !== selectedAgentId) return; if (payload.image_base64) { setImageBase64(payload.image_base64); @@ -86,24 +97,30 @@ const AgentScreenshotNode = ({ id, data }) => { socket.on("agent_screenshot_task", handleScreenshot); return () => socket.off("agent_screenshot_task", handleScreenshot); - }, [id, setNodes, edges, getNodes]); + }, [id, setNodes, resolveAgentData]); // Register this node for the agent provisioning sync window.__BorealisInstructionNodes = window.__BorealisInstructionNodes || {}; - window.__BorealisInstructionNodes[id] = () => ({ - node_id: id, - role: "screenshot", - interval, - visible, - alias, - ...region - }); + window.__BorealisInstructionNodes[id] = () => { + const agentData = resolveAgentData() || {}; + const modeRaw = (agentData.agent_mode || "").toString().toLowerCase(); + const targetMode = modeRaw === "system" ? "system" : "currentuser"; + return { + node_id: id, + role: "screenshot", + interval, + visible, + alias, + target_agent_mode: targetMode, + target_agent_host: agentData.agent_host || "", + ...region + }; + }; // Manual live view copy button const handleCopyLiveViewLink = () => { - const agentEdge = edges.find(e => e.target === id && e.sourceHandle === "provisioner"); - const agentNode = getNodes().find(n => n.id === agentEdge?.source); - const selectedAgentId = agentNode?.data?.agent_id; + const agentData = resolveAgentData(); + const selectedAgentId = agentData?.agent_id; if (!selectedAgentId) { alert("No valid agent connection found."); @@ -132,6 +149,17 @@ const AgentScreenshotNode = ({ id, data }) => {
Interval: {interval} ms
+
+ Agent Context: {targetModeLabel} +
+
+ Target Host:{" "} + {targetHostLabel ? ( + targetHostLabel + ) : ( + unknown + )} +
Overlay: {visible ? "Yes" : "No"}
@@ -165,6 +193,7 @@ Capture a live screenshot of a defined region from a remote Borealis Agent. - Optionally show a visual overlay with a label - Pushes base64 PNG stream to downstream nodes - Use copy button to share live view URL +- Targets the CURRENTUSER or SYSTEM agent context selected upstream `.trim(), content: "Capture screenshot region via agent", component: AgentScreenshotNode, diff --git a/Data/Server/WebUI/src/nodes/Agent/Node_Agent.jsx b/Data/Server/WebUI/src/nodes/Agent/Node_Agent.jsx index 2dd39db..063149b 100644 --- a/Data/Server/WebUI/src/nodes/Agent/Node_Agent.jsx +++ b/Data/Server/WebUI/src/nodes/Agent/Node_Agent.jsx @@ -8,22 +8,61 @@ const BorealisAgentNode = ({ id, data }) => { const edges = useStore((state) => state.edges); const [agents, setAgents] = useState({}); const [selectedAgent, setSelectedAgent] = useState(data.agent_id || ""); + const [selectedHost, setSelectedHost] = useState(data.agent_host || ""); + const initialMode = (data.agent_mode || "currentuser").toLowerCase(); + const [selectedMode, setSelectedMode] = useState( + initialMode === "system" ? "system" : "currentuser" + ); const [isConnected, setIsConnected] = useState(false); const prevRolesRef = useRef([]); - // Agent List Sorted (Online First) - const agentList = useMemo(() => { - if (!agents || typeof agents !== "object") return []; - return Object.entries(agents) - .map(([aid, info]) => ({ - id: aid, - status: info?.status || "offline", - last_seen: info?.last_seen || 0 - })) - .filter(({ status }) => status !== "offline") - .sort((a, b) => b.last_seen - a.last_seen); + // Group agents by hostname and execution context + const agentsByHostname = useMemo(() => { + if (!agents || typeof agents !== "object") return {}; + const grouped = {}; + Object.entries(agents).forEach(([aid, info]) => { + if (!info || typeof info !== "object") return; + const status = (info.status || "").toString().toLowerCase(); + if (status === "offline") return; + const host = (info.hostname || info.agent_hostname || "").trim() || "unknown"; + const modeRaw = (info.service_mode || "").toString().toLowerCase(); + const mode = modeRaw === "system" ? "system" : "currentuser"; + if (!grouped[host]) { + grouped[host] = { currentuser: null, system: null }; + } + grouped[host][mode] = { + agent_id: aid, + status: info.status || "offline", + last_seen: info.last_seen || 0, + info, + }; + }); + return grouped; }, [agents]); + const hostOptions = useMemo(() => { + const entries = Object.entries(agentsByHostname) + .map(([host, contexts]) => { + const candidates = [contexts.currentuser, contexts.system].filter(Boolean); + if (!candidates.length) return null; + const badge = (record) => { + if (!record) return "✕"; + const st = (record.status || "").toString().toLowerCase(); + if (st === "provisioned") return "✓"; + return "•"; + }; + const label = `${host} (CURRENTUSER ${badge(contexts.currentuser)}, SYSTEM ${badge(contexts.system)})`; + const latest = Math.max(...candidates.map((r) => r.last_seen || 0)); + return { host, label, contexts, latest }; + }) + .filter(Boolean) + .sort((a, b) => { + if (b.latest !== a.latest) return b.latest - a.latest; + return a.host.localeCompare(b.host); + }); + return entries; + }, [agentsByHostname]); + // Fetch Agents Periodically useEffect(() => { const fetchAgents = () => { @@ -37,15 +76,79 @@ const BorealisAgentNode = ({ id, data }) => { return () => clearInterval(interval); }, []); + // Ensure host selection stays aligned with available agents + useEffect(() => { + const hostExists = hostOptions.some((opt) => opt.host === selectedHost); + if (hostExists) return; + + if (selectedAgent && agents[selectedAgent]) { + const info = agents[selectedAgent]; + const inferredHost = (info?.hostname || info?.agent_hostname || "").trim() || "unknown"; + if (inferredHost && inferredHost !== selectedHost) { + setSelectedHost(inferredHost); + return; + } + } + + const fallbackHost = hostOptions[0]?.host || ""; + if (fallbackHost !== selectedHost) { + setSelectedHost(fallbackHost); + } + if (!fallbackHost && selectedAgent) { + setSelectedAgent(""); + } + }, [hostOptions, selectedHost, selectedAgent, agents]); + + // Align agent selection with host/mode choice + useEffect(() => { + if (!selectedHost) { + if (selectedMode !== "currentuser") setSelectedMode("currentuser"); + if (selectedAgent) setSelectedAgent(""); + return; + } + const contexts = agentsByHostname[selectedHost]; + if (!contexts) { + if (selectedMode !== "currentuser") setSelectedMode("currentuser"); + if (selectedAgent) setSelectedAgent(""); + return; + } + if (!contexts[selectedMode]) { + const fallbackMode = contexts.currentuser + ? "currentuser" + : contexts.system + ? "system" + : selectedMode; + if (fallbackMode !== selectedMode) { + setSelectedMode(fallbackMode); + return; + } + } + const activeContext = contexts[selectedMode]; + const targetAgentId = activeContext?.agent_id || ""; + if (targetAgentId !== selectedAgent) { + setSelectedAgent(targetAgentId); + } + }, [selectedHost, selectedMode, agentsByHostname, selectedAgent]); + // Sync node data with sidebar changes useEffect(() => { setNodes((nds) => nds.map((n) => - n.id === id ? { ...n, data: { ...n.data, agent_id: selectedAgent } } : n + n.id === id + ? { + ...n, + data: { + ...n.data, + agent_id: selectedAgent, + agent_host: selectedHost, + agent_mode: selectedMode, + }, + } + : n ) ); setIsConnected(false); - }, [selectedAgent, setNodes, id]); + }, [selectedAgent, selectedHost, selectedMode, setNodes, id]); // Attached Roles logic const attachedRoleIds = useMemo( @@ -109,11 +212,19 @@ const BorealisAgentNode = ({ id, data }) => { // Status Label const selectedAgentStatus = useMemo(() => { - if (!selectedAgent) return "Unassigned"; - const agent = agents[selectedAgent]; - if (!agent) return "Reconnecting..."; - return agent.status === "provisioned" ? "Connected" : "Available"; - }, [agents, selectedAgent]); + if (!selectedHost) return "Unassigned"; + const contexts = agentsByHostname[selectedHost]; + if (!contexts) return "Offline"; + const activeContext = contexts[selectedMode]; + if (!selectedAgent || !activeContext) return "Unavailable"; + const status = (activeContext.status || "").toString().toLowerCase(); + if (status === "provisioned") return "Connected"; + if (status === "orphaned") return "Available"; + if (!status) return "Available"; + return status.charAt(0).toUpperCase() + status.slice(1); + }, [agentsByHostname, selectedHost, selectedMode, selectedAgent]); + + const activeHostContexts = selectedHost ? agentsByHostname[selectedHost] : null; // Render (Sidebar handles config) return ( @@ -128,20 +239,44 @@ const BorealisAgentNode = ({ id, data }) => {
Borealis Agent
- + + + + +
+ Target Agent ID:{" "} + {selectedAgent ? ( + {selectedAgent} + ) : ( + none + )} +
+ {isConnected ? ( )} - -
- -
- Status: {selectedAgentStatus} -
- Attach Agent Role Nodes to define live behavior. -
); @@ -309,7 +299,7 @@ const BorealisAgentNode = ({ id, data }) => { // Node Registration Object with sidebar config and docs export default { type: "Borealis_Agent", - label: "Borealis Agent", + label: "Device Agent", description: ` Select and connect to a remote Borealis Agent. - Assign roles to agent dynamically by connecting "Agent Role" nodes.