diff --git a/Data/Server/WebUI/src/Flow_Editor/Node_Configuration_Sidebar.jsx b/Data/Server/WebUI/src/Flow_Editor/Node_Configuration_Sidebar.jsx index 9185230..a2a1c65 100644 --- a/Data/Server/WebUI/src/Flow_Editor/Node_Configuration_Sidebar.jsx +++ b/Data/Server/WebUI/src/Flow_Editor/Node_Configuration_Sidebar.jsx @@ -40,23 +40,55 @@ export default function NodeConfigurationSidebar({ drawerOpen, setDrawerOpen, ti const config = nodeData?.config || []; const nodeId = nodeData?.nodeId; + const normalizeOptions = (opts = []) => + opts.map((opt) => { + if (typeof opt === "string") { + return { value: opt, label: opt, disabled: false }; + } + if (opt && typeof opt === "object") { + const val = + opt.value ?? + opt.id ?? + opt.handle ?? + (typeof opt.label === "string" ? opt.label : ""); + const label = + opt.label ?? + opt.name ?? + opt.title ?? + (typeof val !== "undefined" ? String(val) : ""); + return { + value: typeof val === "undefined" ? "" : String(val), + label: typeof label === "undefined" ? "" : String(label), + disabled: Boolean(opt.disabled) + }; + } + return { value: String(opt ?? ""), label: String(opt ?? ""), disabled: false }; + }); + return config.map((field, index) => { - const value = nodeData?.[field.key] || ""; + const value = nodeData?.[field.key] ?? ""; + const isReadOnly = Boolean(field.readOnly); // ---- DYNAMIC DROPDOWN SUPPORT ---- if (field.type === "select") { let options = field.options || []; - // Handle dynamic options for things like Target Window - if (field.dynamicOptions && nodeData?.windowList && Array.isArray(nodeData.windowList)) { + if (field.optionsKey && Array.isArray(nodeData?.[field.optionsKey])) { + options = nodeData[field.optionsKey]; + } else if (field.dynamicOptions && nodeData?.windowList && Array.isArray(nodeData.windowList)) { options = nodeData.windowList - .map(win => ({ + .map((win) => ({ value: String(win.handle), label: `${win.title} (${win.handle})` })) .sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: "base" })); - } else { - options = options.map(opt => ({ value: opt, label: opt })); + } + + options = normalizeOptions(options); + + // Handle dynamic options for things like Target Window + if (field.dynamicOptions && (!nodeData?.windowList || !Array.isArray(nodeData.windowList))) { + options = []; } return ( @@ -70,6 +102,7 @@ export default function NodeConfigurationSidebar({ drawerOpen, setDrawerOpen, ti size="small" value={value} onChange={(e) => { + if (isReadOnly) return; const newValue = e.target.value; if (!nodeId) return; effectiveSetNodes((nds) => @@ -134,7 +167,7 @@ export default function NodeConfigurationSidebar({ drawerOpen, setDrawerOpen, ti ) : ( options.map((opt, idx) => ( - + {opt.label} )) @@ -155,7 +188,13 @@ export default function NodeConfigurationSidebar({ drawerOpen, setDrawerOpen, ti size="small" fullWidth value={value} + disabled={isReadOnly} + InputProps={{ + readOnly: isReadOnly, + sx: { color: "#ccc" } + }} onChange={(e) => { + if (isReadOnly) return; const newValue = e.target.value; if (!nodeId) return; effectiveSetNodes((nds) => diff --git a/Data/Server/WebUI/src/nodes/Agent/Node_Agent.jsx b/Data/Server/WebUI/src/nodes/Agent/Node_Agent.jsx index d3e31fd..b892ace 100644 --- a/Data/Server/WebUI/src/nodes/Agent/Node_Agent.jsx +++ b/Data/Server/WebUI/src/nodes/Agent/Node_Agent.jsx @@ -8,18 +8,18 @@ const BorealisAgentNode = ({ id, data }) => { const edges = useStore((state) => state.edges); const [agents, setAgents] = useState({}); const [sites, setSites] = useState([]); - const [selectedAgent, setSelectedAgent] = useState(data.agent_id || ""); - const [selectedHost, setSelectedHost] = useState(data.agent_host || ""); - const [selectedSiteId, setSelectedSiteId] = useState( - data.agent_site_id ? String(data.agent_site_id) : "" - ); - const initialMode = (data.agent_mode || "currentuser").toLowerCase(); - const [selectedMode, setSelectedMode] = useState( - initialMode === "system" ? "system" : "currentuser" - ); const [isConnected, setIsConnected] = useState(false); const [siteMapping, setSiteMapping] = useState({}); const prevRolesRef = useRef([]); + const selectionRef = useRef({ host: "", mode: "", agentId: "", siteId: "" }); + + const selectedSiteId = data?.agent_site_id ? String(data.agent_site_id) : ""; + const selectedHost = data?.agent_host || ""; + const selectedMode = + (data?.agent_mode || "currentuser").toString().toLowerCase() === "system" + ? "system" + : "currentuser"; + const selectedAgent = data?.agent_id || ""; // Group agents by hostname and execution context const agentsByHostname = useMemo(() => { @@ -127,18 +127,31 @@ const hostOptions = useMemo(() => { }); }, [hostOptions, selectedSiteId, siteMapping]); - const hasSiteSelection = Boolean(selectedSiteId); - // Align selected site with known host mapping when available useEffect(() => { if (selectedSiteId || !selectedHost) return; const mapping = siteMapping[selectedHost]; if (!mapping || typeof mapping.site_id === "undefined" || mapping.site_id === null) return; - setSelectedSiteId(String(mapping.site_id)); - }, [selectedHost, selectedSiteId, siteMapping]); + const mappedId = String(mapping.site_id); + setNodes((nds) => + nds.map((n) => + n.id === id + ? { + ...n, + data: { + ...n.data, + agent_site_id: mappedId, + }, + } + : n + ) + ); + }, [selectedHost, selectedSiteId, siteMapping, id, setNodes]); // Ensure host selection stays aligned with available agents useEffect(() => { + if (!selectedHost) return; + const hostExists = filteredHostOptions.some((opt) => opt.host === selectedHost); if (hostExists) return; @@ -147,52 +160,78 @@ const hostOptions = useMemo(() => { const inferredHost = (info?.hostname || info?.agent_hostname || "").trim() || "unknown"; const allowed = filteredHostOptions.some((opt) => opt.host === inferredHost); if (allowed && inferredHost && inferredHost !== selectedHost) { - setSelectedHost(inferredHost); + setNodes((nds) => + nds.map((n) => + n.id === id + ? { + ...n, + data: { + ...n.data, + agent_host: inferredHost, + }, + } + : n + ) + ); return; } } - const fallbackHost = filteredHostOptions[0]?.host || ""; - if (fallbackHost !== selectedHost) { - setSelectedHost(fallbackHost); - } - if (!fallbackHost && selectedAgent) { - setSelectedAgent(""); - } - }, [filteredHostOptions, selectedHost, selectedAgent, agents]); + setNodes((nds) => + nds.map((n) => + n.id === id + ? { + ...n, + data: { + ...n.data, + agent_host: "", + agent_id: "", + agent_mode: "currentuser", + }, + } + : n + ) + ); + }, [filteredHostOptions, selectedHost, selectedAgent, agents, id, setNodes]); - // 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]); + const siteSelectOptions = useMemo(() => { + const entries = Array.isArray(sites) ? [...sites] : []; + entries.sort((a, b) => + (a?.name || "").localeCompare(b?.name || "", undefined, { sensitivity: "base" }) + ); + const mapped = entries.map((site) => ({ + value: String(site.id), + label: site.name || `Site ${site.id}`, + })); + return [{ value: "", label: "All Sites" }, ...mapped]; + }, [sites]); + + const hostSelectOptions = useMemo(() => { + const mapped = filteredHostOptions.map(({ host, label }) => ({ + value: host, + label, + })); + return [{ value: "", label: "-- Select --" }, ...mapped]; + }, [filteredHostOptions]); + + const activeHostContexts = selectedHost ? agentsByHostname[selectedHost] : null; + + const modeSelectOptions = useMemo( + () => [ + { + value: "currentuser", + label: "CURRENTUSER (Screen Capture / Macros)", + disabled: !activeHostContexts?.currentuser, + }, + { + value: "system", + label: "SYSTEM (Scripts)", + disabled: !activeHostContexts?.system, + }, + ], + [activeHostContexts] + ); - // Sync node data with sidebar changes useEffect(() => { setNodes((nds) => nds.map((n) => @@ -201,17 +240,126 @@ const hostOptions = useMemo(() => { ...n, data: { ...n.data, - agent_id: selectedAgent, - agent_host: selectedHost, - agent_mode: selectedMode, - agent_site_id: selectedSiteId || "", + siteOptions: siteSelectOptions, + hostOptions: hostSelectOptions, + modeOptions: modeSelectOptions, }, } : n ) ); - setIsConnected(false); - }, [selectedAgent, selectedHost, selectedMode, selectedSiteId, setNodes, id]); + }, [id, setNodes, siteSelectOptions, hostSelectOptions, modeSelectOptions]); + + useEffect(() => { + if (!selectedHost) { + if (selectedAgent || selectedMode !== "currentuser") { + setNodes((nds) => + nds.map((n) => + n.id === id + ? { + ...n, + data: { + ...n.data, + agent_id: "", + agent_mode: "currentuser", + }, + } + : n + ) + ); + } + return; + } + + const contexts = agentsByHostname[selectedHost]; + if (!contexts) { + if (selectedAgent || selectedMode !== "currentuser") { + setNodes((nds) => + nds.map((n) => + n.id === id + ? { + ...n, + data: { + ...n.data, + agent_id: "", + agent_mode: "currentuser", + }, + } + : n + ) + ); + } + return; + } + + if (!contexts[selectedMode]) { + const fallbackMode = contexts.currentuser + ? "currentuser" + : contexts.system + ? "system" + : "currentuser"; + const fallbackAgentId = contexts[fallbackMode]?.agent_id || ""; + if (fallbackMode !== selectedMode || fallbackAgentId !== selectedAgent) { + setNodes((nds) => + nds.map((n) => + n.id === id + ? { + ...n, + data: { + ...n.data, + agent_mode: fallbackMode, + agent_id: fallbackAgentId, + }, + } + : n + ) + ); + } + return; + } + + const targetAgentId = contexts[selectedMode]?.agent_id || ""; + if (targetAgentId !== selectedAgent) { + setNodes((nds) => + nds.map((n) => + n.id === id + ? { + ...n, + data: { + ...n.data, + agent_id: targetAgentId, + }, + } + : n + ) + ); + } + }, [selectedHost, selectedMode, agentsByHostname, selectedAgent, id, setNodes]); + + useEffect(() => { + const prev = selectionRef.current; + const changed = + prev.host !== selectedHost || + prev.mode !== selectedMode || + prev.agentId !== selectedAgent || + prev.siteId !== selectedSiteId; + if (!changed) return; + + const selectionChangedAgent = + prev.agentId && + (prev.agentId !== selectedAgent || prev.host !== selectedHost || prev.mode !== selectedMode); + if (selectionChangedAgent) { + setIsConnected(false); + prevRolesRef.current = []; + } + + selectionRef.current = { + host: selectedHost, + mode: selectedMode, + agentId: selectedAgent, + siteId: selectedSiteId, + }; + }, [selectedHost, selectedMode, selectedAgent, selectedSiteId]); // Attached Roles logic const attachedRoleIds = useMemo( @@ -287,8 +435,6 @@ const hostOptions = useMemo(() => { return status.charAt(0).toUpperCase() + status.slice(1); }, [agentsByHostname, selectedHost, selectedMode, selectedAgent]); - const activeHostContexts = selectedHost ? agentsByHostname[selectedHost] : null; - // Render (Sidebar handles config) return (
@@ -301,76 +447,40 @@ const hostOptions = useMemo(() => { />
Device Agent
-
- - - - - - - - - -
- Agent ID:{" "} - {selectedAgent ? ( - {selectedAgent} - ) : ( - No Agent Selected - )} + {isConnected ? "Disconnect" : "Connect to Device"} + +
+ {selectedHost ? `${selectedHost} ยท ${selectedMode.toUpperCase()}` : "No device selected"}
- - {isConnected ? ( - - ) : ( - - )}
); @@ -390,37 +500,54 @@ Select and connect to a remote Borealis Agent. content: "Select and manage an Agent with dynamic roles", component: BorealisAgentNode, config: [ + { + key: "agent_site_id", + label: "Site", + type: "select", + optionsKey: "siteOptions", + defaultValue: "" + }, + { + key: "agent_host", + label: "Device", + type: "select", + optionsKey: "hostOptions", + defaultValue: "" + }, + { + key: "agent_mode", + label: "Agent Context", + type: "select", + optionsKey: "modeOptions", + defaultValue: "currentuser" + }, { key: "agent_id", - label: "Agent", - type: "text", // NOTE: UI populates via agent fetch, but config drives default for sidebar. + label: "Agent ID", + type: "text", + readOnly: true, defaultValue: "" } ], usage_documentation: ` ### Borealis Agent Node -This node represents an available Borealis Agent (Python client) you can control from your workflow. +This node allows you to establish a connection with a device running a Borealis "Agent", so you can instruct the agent to do things from your workflow. #### Features -- **Select** a device and agent context (CURRENTUSER vs SYSTEM). +- **Select** a site, then a device, then finally an agent context (CURRENTUSER vs SYSTEM). - **Connect/Disconnect** from the agent at any time. - **Attach roles** (by connecting "Agent Role" nodes to this node's output handle) to assign behaviors dynamically. -- **Live status** shows if the agent is available, connected, or offline. #### How to Use -1. **Drag in a Borealis Agent node.** -2. **Pick an agent** from the dropdown list (auto-populates from backend). -3. **Click "Connect to Agent"** to provision it for the workflow. +1. **Drag and drop in a Borealis Agent node.** +2. **Pick an agent** from the dropdown list (auto-populates from API backend). +3. **Click "Connect to Agent"**. 4. **Attach Agent Role Nodes** (e.g., Screenshot, Macro Keypress) to the "provisioner" output handle to define what the agent should do. 5. Agent will automatically update its roles as you change connected Role Nodes. -#### Output Handle -- "provisioner" (bottom): Connect Agent Role nodes here. - #### Good to Know - If an agent disconnects or goes offline, its status will show "Reconnecting..." until it returns. -- Node config can be edited in the right sidebar. - **Roles update LIVE**: Any time you change attached roles, the agent gets updated instantly. `.trim()