From f8d6886f2f0f62aab8d40937ee8eeca43a24c9cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bir=C3=B3=2C=20Csaba=20Attila?= Date: Sat, 14 Feb 2026 21:11:00 +0100 Subject: [PATCH 01/22] feat(frontend): add online monitoring to editor Integrate PLC online mode directly into the editor canvas: - Add toolbar with Go Online/Offline controls and connection status indicator - Show live variable values on node ports and edges - Node execution state overlay (active/idle/error glow) - Active edges glow green with drop-shadow - Watch panel with fixed-column table, pushes canvas up instead of overlapping minimap - Node headers clip to rounded corners (overflow:hidden) - Remove standalone monitoring module (replaced) - Add demo page with mock PLC cycle for testing - Hide React Flow attribution badge Co-Authored-By: Claude Opus 4.6 --- src/frontend/src/App.css | 397 +++++++++++++++++- src/frontend/src/App.tsx | 49 +-- src/frontend/src/api/types.ts | 16 + .../src/features/editor/EditorPage.tsx | 74 +++- .../src/features/editor/EditorPageDemo.tsx | 155 +++++++ .../editor/components/EditorToolbar.tsx | 64 +++ .../features/editor/components/FlowCanvas.tsx | 144 ++++++- .../editor/components/NodeOnlineOverlay.tsx | 27 ++ .../features/editor/components/OnlineEdge.tsx | 72 ++++ .../features/editor/components/WatchPanel.tsx | 109 +++++ .../features/editor/hooks/useOnlineMode.ts | 126 ++++++ .../features/editor/nodes/ComparisonNode.tsx | 57 ++- .../src/features/editor/nodes/CounterNode.tsx | 80 +++- .../src/features/editor/nodes/InputNode.tsx | 31 +- .../src/features/editor/nodes/OutputNode.tsx | 31 +- .../src/features/editor/nodes/TimerNode.tsx | 67 ++- .../src/features/editor/nodes/nodeRegistry.ts | 7 +- .../features/editor/stores/useOnlineStore.ts | 83 ++++ .../src/features/editor/types/flow.types.ts | 9 +- .../editor/utils/nodeVariableMapping.ts | 44 ++ .../src/features/monitoring/MonitorPage.tsx | 6 - .../monitoring/components/ValueDisplay.tsx | 6 - .../monitoring/components/VariableList.tsx | 6 - .../components/VariableSubscriber.tsx | 6 - .../monitoring/hooks/useMonitorSession.ts | 6 - .../features/monitoring/hooks/usePlcData.ts | 6 - src/frontend/src/shared/hooks/useSignalR.ts | 89 +++- 27 files changed, 1663 insertions(+), 104 deletions(-) create mode 100644 src/frontend/src/features/editor/EditorPageDemo.tsx create mode 100644 src/frontend/src/features/editor/components/EditorToolbar.tsx create mode 100644 src/frontend/src/features/editor/components/NodeOnlineOverlay.tsx create mode 100644 src/frontend/src/features/editor/components/OnlineEdge.tsx create mode 100644 src/frontend/src/features/editor/components/WatchPanel.tsx create mode 100644 src/frontend/src/features/editor/hooks/useOnlineMode.ts create mode 100644 src/frontend/src/features/editor/stores/useOnlineStore.ts create mode 100644 src/frontend/src/features/editor/utils/nodeVariableMapping.ts delete mode 100644 src/frontend/src/features/monitoring/MonitorPage.tsx delete mode 100644 src/frontend/src/features/monitoring/components/ValueDisplay.tsx delete mode 100644 src/frontend/src/features/monitoring/components/VariableList.tsx delete mode 100644 src/frontend/src/features/monitoring/components/VariableSubscriber.tsx delete mode 100644 src/frontend/src/features/monitoring/hooks/useMonitorSession.ts delete mode 100644 src/frontend/src/features/monitoring/hooks/usePlcData.ts diff --git a/src/frontend/src/App.css b/src/frontend/src/App.css index 1e32d6f..0651e21 100644 --- a/src/frontend/src/App.css +++ b/src/frontend/src/App.css @@ -1,9 +1,398 @@ -.app { +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + background: #1a1a2e; + color: #e0e0e0; +} + +/* ── Editor page layout ────────────────────────────────────────────── */ + +.ff-editor-page { width: 100vw; height: 100vh; + display: flex; + flex-direction: column; + overflow: hidden; } -body { - margin: 0; - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; +.ff-editor-content { + flex: 1; + display: flex; + position: relative; + overflow: hidden; + min-height: 0; +} + +.ff-flow-canvas { + flex: 1; + height: 100%; +} + +/* ── Toolbar ───────────────────────────────────────────────────────── */ + +.ff-editor-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 16px; + background: #16213e; + border-bottom: 1px solid #0f3460; + min-height: 40px; + gap: 12px; +} + +.ff-toolbar-left { + display: flex; + align-items: center; + gap: 12px; +} + +.ff-toolbar-right { + display: flex; + align-items: center; +} + +.ff-toolbar-target { + font-size: 13px; + color: #94a3b8; +} + +.ff-toolbar-error { + font-size: 13px; + color: #ef4444; +} + +.ff-status-dot { + width: 10px; + height: 10px; + border-radius: 50%; + display: inline-block; + flex-shrink: 0; +} + +/* ── Buttons ───────────────────────────────────────────────────────── */ + +.ff-btn { + border: none; + border-radius: 6px; + padding: 6px 16px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: background 0.15s, opacity 0.15s; +} + +.ff-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.ff-btn-primary { + background: #3b82f6; + color: #fff; +} + +.ff-btn-primary:hover:not(:disabled) { + background: #2563eb; +} + +.ff-btn-danger { + background: #ef4444; + color: #fff; +} + +.ff-btn-danger:hover:not(:disabled) { + background: #dc2626; +} + +.ff-btn-sm { + padding: 3px 10px; + font-size: 12px; +} + +/* ── Node base styles ──────────────────────────────────────────────── */ + +.ff-node { + background: #1e293b; + border: 2px solid #334155; + border-radius: 8px; + min-width: 140px; + font-size: 12px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); + transition: border-color 0.2s, box-shadow 0.2s; + overflow: hidden; +} + +.ff-node-header { + padding: 6px 12px; + font-weight: 700; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.5px; + border-bottom: 1px solid #334155; + text-align: center; + border-radius: 6px 6px 0 0; +} + +.ff-node-input .ff-node-header { background: #1e3a5f; color: #60a5fa; } +.ff-node-output .ff-node-header { background: #3b1f2b; color: #f87171; } +.ff-node-timer .ff-node-header { background: #2d2b1e; color: #fbbf24; } +.ff-node-counter .ff-node-header { background: #1e2d3b; color: #38bdf8; } +.ff-node-comparison .ff-node-header { background: #2d1e3b; color: #c084fc; } + +.ff-node-body { + padding: 8px 12px; + display: flex; + flex-direction: column; + gap: 4px; +} + +/* ── Ports ─────────────────────────────────────────────────────────── */ + +.ff-port { + display: flex; + align-items: center; + gap: 6px; + min-height: 22px; + position: relative; +} + +.ff-port-input { + justify-content: flex-start; +} + +.ff-port-output { + justify-content: flex-end; +} + +.ff-port-label { + color: #94a3b8; + font-size: 11px; + font-weight: 500; +} + +.ff-port-value { + font-family: "JetBrains Mono", "Fira Code", monospace; + font-size: 11px; + color: #22c55e; + background: rgba(34, 197, 94, 0.1); + padding: 1px 5px; + border-radius: 3px; + font-weight: 600; +} + +/* ── Execution state styling ───────────────────────────────────────── */ + +.ff-exec-idle { + /* default — no change */ +} + +.ff-exec-active { + border-color: #22c55e !important; + box-shadow: 0 0 12px rgba(34, 197, 94, 0.35), 0 2px 8px rgba(0, 0, 0, 0.3) !important; +} + +.ff-exec-error { + border-color: #ef4444 !important; + box-shadow: 0 0 12px rgba(239, 68, 68, 0.35), 0 2px 8px rgba(0, 0, 0, 0.3) !important; +} + +/* ── Online overlay ────────────────────────────────────────────────── */ + +.ff-online-overlay { + border-radius: 8px; + transition: box-shadow 0.2s, outline-color 0.2s; +} + +.ff-online-active { + outline: 2px solid #22c55e; + outline-offset: 2px; +} + +.ff-online-error { + outline: 2px solid #ef4444; + outline-offset: 2px; +} + +/* ── Edge online styling ───────────────────────────────────────────── */ + +.ff-edge-online { + stroke: #475569; + stroke-width: 2; +} + +.ff-edge-active { + stroke: #22c55e !important; + stroke-width: 2.5 !important; + filter: drop-shadow(0 0 4px rgba(34, 197, 94, 0.5)) drop-shadow(0 0 8px rgba(34, 197, 94, 0.25)); +} + +.ff-edge-offline { + stroke: #475569; +} + +.ff-edge-value { + background: #0f172a; + color: #22c55e; + font-family: "JetBrains Mono", "Fira Code", monospace; + font-size: 10px; + font-weight: 600; + padding: 2px 6px; + border-radius: 4px; + border: 1px solid #334155; + white-space: nowrap; +} + +/* ── Watch panel ───────────────────────────────────────────────────── */ + +.ff-watch-panel { + background: #0f172a; + border-top: 1px solid #334155; + display: flex; + flex-direction: column; + flex-shrink: 0; + max-height: 40vh; +} + +.ff-watch-toggle { + display: block; + width: 100%; + background: #1e293b; + color: #94a3b8; + border: none; + padding: 6px 16px; + font-size: 12px; + font-weight: 600; + cursor: pointer; + text-align: left; + border-top: 1px solid #334155; +} + +.ff-watch-toggle:hover { + background: #263548; +} + +.ff-watch-content { + overflow-y: auto; + padding: 8px 12px; + flex: 1; +} + +.ff-watch-add { + display: flex; + gap: 8px; + margin-bottom: 8px; +} + +.ff-watch-input { + flex: 1; + padding: 4px 8px; + font-size: 12px; + font-family: "JetBrains Mono", "Fira Code", monospace; + background: #1e293b; + color: #e0e0e0; + border: 1px solid #334155; + border-radius: 4px; + outline: none; +} + +.ff-watch-input:focus { + border-color: #3b82f6; +} + +.ff-watch-table { + width: 100%; + border-collapse: collapse; + font-size: 12px; + table-layout: fixed; +} + +.ff-watch-table th { + text-align: left; + padding: 4px 8px; + color: #64748b; + font-weight: 600; + border-bottom: 1px solid #334155; +} + +.ff-watch-table th:nth-child(1) { width: 40%; } +.ff-watch-table th:nth-child(2) { width: 20%; } +.ff-watch-table th:nth-child(3) { width: 15%; } +.ff-watch-table th:nth-child(4) { width: 15%; } +.ff-watch-table th:nth-child(5) { width: 10%; } + +.ff-watch-table td { + padding: 4px 8px; + border-bottom: 1px solid #1e293b; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ff-watch-path { + font-family: "JetBrains Mono", "Fira Code", monospace; + color: #94a3b8; +} + +.ff-watch-value { + font-family: "JetBrains Mono", "Fira Code", monospace; + color: #22c55e; + font-weight: 600; +} + +.ff-watch-type { + color: #64748b; +} + +.ff-watch-time { + color: #64748b; + font-size: 11px; +} + +.ff-watch-empty { + color: #475569; + text-align: center; + padding: 12px !important; +} + +/* ── React Flow overrides ──────────────────────────────────────────── */ + +.react-flow__background { + background: #1a1a2e !important; +} + +.react-flow__controls button { + background: #1e293b; + color: #94a3b8; + border: 1px solid #334155; +} + +.react-flow__controls button:hover { + background: #334155; +} + +.react-flow__minimap { + background: #0f172a; +} + +.react-flow__edge-path { + stroke: #475569; + stroke-width: 2; +} + +.react-flow__handle { + width: 8px; + height: 8px; + background: #64748b; + border: 2px solid #1e293b; +} + +.react-flow__handle-left { left: -5px; } +.react-flow__handle-right { right: -5px; } + +.react-flow__node { + background: transparent !important; + border: none !important; + box-shadow: none !important; + padding: 0 !important; } diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index 135aa82..b28c321 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -1,52 +1,9 @@ // Copyright (c) 2026 Qubernetic (Biró, Csaba Attila) // SPDX-License-Identifier: AGPL-3.0-or-later -import { useCallback } from "react"; -import { - ReactFlow, - Background, - Controls, - MiniMap, - addEdge, - useNodesState, - useEdgesState, - type OnConnect, -} from "@xyflow/react"; -import "@xyflow/react/dist/style.css"; +import { EditorPageDemo } from "./features/editor/EditorPageDemo"; import "./App.css"; -const initialNodes = [ - { - id: "1", - type: "input", - data: { label: "Start" }, - position: { x: 250, y: 0 }, - }, -]; - export default function App() { - const [nodes, , onNodesChange] = useNodesState(initialNodes); - const [edges, setEdges, onEdgesChange] = useEdgesState([]); - - const onConnect: OnConnect = useCallback( - (params) => setEdges((eds) => addEdge(params, eds)), - [setEdges], - ); - - return ( -
- - - - - -
- ); -} \ No newline at end of file + return ; +} diff --git a/src/frontend/src/api/types.ts b/src/frontend/src/api/types.ts index 4a73666..ff3ab54 100644 --- a/src/frontend/src/api/types.ts +++ b/src/frontend/src/api/types.ts @@ -117,3 +117,19 @@ export interface PlcVariableValue { dataType: string; timestamp: string; } + +// Online monitoring + +export type NodeExecutionState = "idle" | "active" | "error"; + +export interface NodeOnlineData { + nodeId: string; + executionState: NodeExecutionState; + variables: PlcVariableValue[]; +} + +export type ConnectionStatus = + | "disconnected" + | "connecting" + | "connected" + | "error"; diff --git a/src/frontend/src/features/editor/EditorPage.tsx b/src/frontend/src/features/editor/EditorPage.tsx index d65c678..c798274 100644 --- a/src/frontend/src/features/editor/EditorPage.tsx +++ b/src/frontend/src/features/editor/EditorPage.tsx @@ -1,6 +1,76 @@ // Copyright (c) 2026 Qubernetic (Biró, Csaba Attila) // SPDX-License-Identifier: AGPL-3.0-or-later -export function EditorPage() { - return
EditorPage
; +import { useMemo } from "react"; +import { ReactFlowProvider } from "@xyflow/react"; +import { EditorToolbar } from "./components/EditorToolbar"; +import { FlowCanvas } from "./components/FlowCanvas"; +import { WatchPanel } from "./components/WatchPanel"; +import { useOnlineMode } from "./hooks/useOnlineMode"; +import type { FlowDocument } from "../../api/types"; + +interface EditorPageProps { + projectId?: string | null; + targetAmsNetId?: string | null; + targetName?: string | null; + flow?: FlowDocument | null; +} + +export function EditorPage({ + projectId = null, + targetAmsNetId = null, + targetName = null, + flow = null, +}: EditorPageProps) { + const flowNodes = useMemo(() => flow?.nodes ?? [], [flow]); + const flowConnections = useMemo(() => flow?.connections ?? [], [flow]); + + const { + isOnline, + connectionStatus, + error, + nodeStates, + variableValues, + watchList, + goOnline, + goOffline, + addToWatchList, + removeFromWatchList, + } = useOnlineMode(projectId ?? null, targetAmsNetId ?? null, flowNodes); + + const canGoOnline = !!projectId && !!targetAmsNetId; + + return ( +
+ + +
+ + + +
+ + +
+ ); } diff --git a/src/frontend/src/features/editor/EditorPageDemo.tsx b/src/frontend/src/features/editor/EditorPageDemo.tsx new file mode 100644 index 0000000..52bb7e2 --- /dev/null +++ b/src/frontend/src/features/editor/EditorPageDemo.tsx @@ -0,0 +1,155 @@ +// Copyright (c) 2026 Qubernetic (Biró, Csaba Attila) +// SPDX-License-Identifier: AGPL-3.0-or-later + +// Standalone demo page with mock data — no backend required. + +import { useState, useCallback } from "react"; +import { ReactFlowProvider } from "@xyflow/react"; +import { EditorToolbar } from "./components/EditorToolbar"; +import { FlowCanvas } from "./components/FlowCanvas"; +import { WatchPanel } from "./components/WatchPanel"; +import type { + FlowNode, + FlowConnection, + NodeExecutionState, + PlcVariableValue, + ConnectionStatus, +} from "../../api/types"; + +// ── Mock flow document ──────────────────────────────────────────────── + +const mockNodes: FlowNode[] = [ + { id: "input_1", type: "input", position: { x: 50, y: 100 }, parameters: { label: "Start Button" } }, + { id: "timer_1", type: "timer", position: { x: 350, y: 50 }, parameters: { label: "Delay T#2S" } }, + { id: "counter_1", type: "counter", position: { x: 350, y: 280 }, parameters: { label: "Part Counter" } }, + { id: "comparison_1", type: "comparison", position: { x: 650, y: 280 }, parameters: { label: ">=", operator: ">=" } }, + { id: "output_1", type: "output", position: { x: 900, y: 100 }, parameters: { label: "Motor ON" } }, + { id: "output_2", type: "output", position: { x: 900, y: 350 }, parameters: { label: "Batch Done" } }, +]; + +const mockConnections: FlowConnection[] = [ + { from: { nodeId: "input_1", portName: "OUT" }, to: { nodeId: "timer_1", portName: "IN" } }, + { from: { nodeId: "input_1", portName: "OUT" }, to: { nodeId: "counter_1", portName: "CU" } }, + { from: { nodeId: "timer_1", portName: "Q" }, to: { nodeId: "output_1", portName: "IN" } }, + { from: { nodeId: "counter_1", portName: "CV" }, to: { nodeId: "comparison_1", portName: "A" } }, + { from: { nodeId: "comparison_1", portName: "OUT" }, to: { nodeId: "output_2", portName: "IN" } }, +]; + +// ── Mock online variable snapshots ──────────────────────────────────── + +function makeMockValues(tick: number): PlcVariableValue[] { + const ts = new Date().toISOString(); + const running = tick % 6 < 4; // on 4 cycles, off 2 + const counterVal = tick % 20; + return [ + { path: "MAIN.input_1.OUT", value: running, dataType: "BOOL", timestamp: ts }, + { path: "MAIN.timer_1.IN", value: running, dataType: "BOOL", timestamp: ts }, + { path: "MAIN.timer_1.PT", value: "T#2S", dataType: "TIME", timestamp: ts }, + { path: "MAIN.timer_1.Q", value: running && tick % 6 > 1, dataType: "BOOL", timestamp: ts }, + { path: "MAIN.timer_1.ET", value: running ? `T#${(tick % 3) * 700}MS` : "T#0MS", dataType: "TIME", timestamp: ts }, + { path: "MAIN.counter_1.CU", value: running, dataType: "BOOL", timestamp: ts }, + { path: "MAIN.counter_1.RESET", value: false, dataType: "BOOL", timestamp: ts }, + { path: "MAIN.counter_1.PV", value: 10, dataType: "INT", timestamp: ts }, + { path: "MAIN.counter_1.Q", value: counterVal >= 10, dataType: "BOOL", timestamp: ts }, + { path: "MAIN.counter_1.CV", value: counterVal, dataType: "INT", timestamp: ts }, + { path: "MAIN.comparison_1.A", value: counterVal, dataType: "INT", timestamp: ts }, + { path: "MAIN.comparison_1.B", value: 10, dataType: "INT", timestamp: ts }, + { path: "MAIN.comparison_1.OUT", value: counterVal >= 10, dataType: "BOOL", timestamp: ts }, + { path: "MAIN.output_1.IN", value: running && tick % 6 > 1, dataType: "BOOL", timestamp: ts }, + { path: "MAIN.output_2.IN", value: counterVal >= 10, dataType: "BOOL", timestamp: ts }, + ]; +} + +// ── Demo component ──────────────────────────────────────────────────── + +export function EditorPageDemo() { + const [isOnline, setIsOnline] = useState(false); + const [connectionStatus, setConnectionStatus] = useState("disconnected"); + const [variableValues, setVariableValues] = useState>(new Map()); + const [nodeStates, setNodeStates] = useState>(new Map()); + const [watchList, setWatchList] = useState(["MAIN.counter_1.CV", "MAIN.timer_1.ET"]); + const [tickRef] = useState({ current: 0, interval: 0 as unknown as ReturnType }); + + const goOnline = useCallback(() => { + setIsOnline(true); + setConnectionStatus("connecting"); + + // Simulate connection delay + setTimeout(() => { + setConnectionStatus("connected"); + + // Start simulated PLC cycle + tickRef.current = 0; + tickRef.interval = setInterval(() => { + tickRef.current++; + const values = makeMockValues(tickRef.current); + const valMap = new Map(); + const stateMap = new Map(); + + for (const v of values) { + valMap.set(v.path, v); + } + + // Derive node states + for (const node of mockNodes) { + const outPaths = values.filter((v) => v.path.startsWith(`MAIN.${node.id}.`)); + const hasActive = outPaths.some((v) => v.value === true); + stateMap.set(node.id, hasActive ? "active" : "idle"); + } + + setVariableValues(valMap); + setNodeStates(stateMap); + }, 800); + }, 600); + }, [tickRef]); + + const goOffline = useCallback(() => { + clearInterval(tickRef.interval); + setIsOnline(false); + setConnectionStatus("disconnected"); + setVariableValues(new Map()); + setNodeStates(new Map()); + }, [tickRef]); + + const addToWatchList = useCallback((path: string) => { + setWatchList((prev) => (prev.includes(path) ? prev : [...prev, path])); + }, []); + + const removeFromWatchList = useCallback((path: string) => { + setWatchList((prev) => prev.filter((p) => p !== path)); + }, []); + + return ( +
+ + +
+ + + +
+ + +
+ ); +} diff --git a/src/frontend/src/features/editor/components/EditorToolbar.tsx b/src/frontend/src/features/editor/components/EditorToolbar.tsx new file mode 100644 index 0000000..26c5427 --- /dev/null +++ b/src/frontend/src/features/editor/components/EditorToolbar.tsx @@ -0,0 +1,64 @@ +// Copyright (c) 2026 Qubernetic (Biró, Csaba Attila) +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type { ConnectionStatus } from "../../../api/types"; + +interface EditorToolbarProps { + isOnline: boolean; + connectionStatus: ConnectionStatus; + targetName: string | null; + error: string | null; + canGoOnline: boolean; + onGoOnline: () => void; + onGoOffline: () => void; +} + +const statusColors: Record = { + disconnected: "#9ca3af", + connecting: "#f59e0b", + connected: "#22c55e", + error: "#ef4444", +}; + +export function EditorToolbar({ + isOnline, + connectionStatus, + targetName, + error, + canGoOnline, + onGoOnline, + onGoOffline, +}: EditorToolbarProps) { + return ( +
+
+ + + {isOnline && ( + <> + + {targetName && ( + {targetName} + )} + + )} +
+ + {error && ( +
+ {error} +
+ )} +
+ ); +} diff --git a/src/frontend/src/features/editor/components/FlowCanvas.tsx b/src/frontend/src/features/editor/components/FlowCanvas.tsx index 9ce8b7f..f6ff088 100644 --- a/src/frontend/src/features/editor/components/FlowCanvas.tsx +++ b/src/frontend/src/features/editor/components/FlowCanvas.tsx @@ -1,6 +1,146 @@ // Copyright (c) 2026 Qubernetic (Biró, Csaba Attila) // SPDX-License-Identifier: AGPL-3.0-or-later -export function FlowCanvas() { - return
FlowCanvas
; +import { useCallback, useEffect, useMemo } from "react"; +import { + ReactFlow, + Controls, + MiniMap, + Background, + BackgroundVariant, + useNodesState, + useEdgesState, + addEdge, + type OnConnect, + type Node, + type Edge, +} from "@xyflow/react"; +import "@xyflow/react/dist/style.css"; + +import { nodeTypes, edgeTypes } from "../nodes/nodeRegistry"; +import type { + FlowNode, + FlowConnection, + NodeExecutionState, + PlcVariableValue, + NodeOnlineData, +} from "../../../api/types"; +import { getVariablePathsForNode } from "../utils/nodeVariableMapping"; + +interface FlowCanvasProps { + flowNodes: FlowNode[]; + flowConnections: FlowConnection[]; + isOnline: boolean; + nodeStates: Map; + variableValues: Map; +} + +function toReactFlowNodes( + flowNodes: FlowNode[], + isOnline: boolean, + nodeStates: Map, + variableValues: Map, +): Node[] { + return flowNodes.map((fn) => { + let onlineData: NodeOnlineData | undefined; + if (isOnline) { + const paths = getVariablePathsForNode(fn); + const variables = paths + .map((p) => variableValues.get(p)) + .filter((v): v is PlcVariableValue => v !== undefined); + onlineData = { + nodeId: fn.id, + executionState: nodeStates.get(fn.id) ?? "idle", + variables, + }; + } + + return { + id: fn.id, + type: fn.type, + position: fn.position, + data: { + ...fn.parameters, + label: (fn.parameters.label as string) ?? fn.type, + onlineData, + }, + }; + }); +} + +function toReactFlowEdges( + connections: FlowConnection[], + isOnline: boolean, + variableValues: Map, +): Edge[] { + return connections.map((conn) => { + const id = `e-${conn.from.nodeId}-${conn.from.portName}-${conn.to.nodeId}-${conn.to.portName}`; + const sourcePath = `MAIN.${conn.from.nodeId}.${conn.from.portName}`; + const value = variableValues.get(sourcePath)?.value; + + return { + id, + source: conn.from.nodeId, + sourceHandle: conn.from.portName, + target: conn.to.nodeId, + targetHandle: conn.to.portName, + type: isOnline ? "online" : "default", + data: isOnline ? { isOnline: true, value } : undefined, + }; + }); +} + +export function FlowCanvas({ + flowNodes, + flowConnections, + isOnline, + nodeStates, + variableValues, +}: FlowCanvasProps) { + const initialNodes = useMemo( + () => toReactFlowNodes(flowNodes, isOnline, nodeStates, variableValues), + [flowNodes, isOnline, nodeStates, variableValues], + ); + + const initialEdges = useMemo( + () => toReactFlowEdges(flowConnections, isOnline, variableValues), + [flowConnections, isOnline, variableValues], + ); + + const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); + const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); + + // Sync nodes/edges when online data changes + useEffect(() => { + setNodes(toReactFlowNodes(flowNodes, isOnline, nodeStates, variableValues)); + }, [flowNodes, isOnline, nodeStates, variableValues, setNodes]); + + useEffect(() => { + setEdges(toReactFlowEdges(flowConnections, isOnline, variableValues)); + }, [flowConnections, isOnline, variableValues, setEdges]); + + const onConnect: OnConnect = useCallback( + (params) => setEdges((eds) => addEdge(params, eds)), + [setEdges], + ); + + return ( +
+ + + + + +
+ ); } diff --git a/src/frontend/src/features/editor/components/NodeOnlineOverlay.tsx b/src/frontend/src/features/editor/components/NodeOnlineOverlay.tsx new file mode 100644 index 0000000..b3bab12 --- /dev/null +++ b/src/frontend/src/features/editor/components/NodeOnlineOverlay.tsx @@ -0,0 +1,27 @@ +// Copyright (c) 2026 Qubernetic (Biró, Csaba Attila) +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type { ReactNode } from "react"; +import type { NodeExecutionState } from "../../../api/types"; + +interface NodeOnlineOverlayProps { + executionState: NodeExecutionState; + isOnline: boolean; + children: ReactNode; +} + +export function NodeOnlineOverlay({ + executionState, + isOnline, + children, +}: NodeOnlineOverlayProps) { + if (!isOnline) { + return <>{children}; + } + + return ( +
+ {children} +
+ ); +} diff --git a/src/frontend/src/features/editor/components/OnlineEdge.tsx b/src/frontend/src/features/editor/components/OnlineEdge.tsx new file mode 100644 index 0000000..2d2fdaa --- /dev/null +++ b/src/frontend/src/features/editor/components/OnlineEdge.tsx @@ -0,0 +1,72 @@ +// Copyright (c) 2026 Qubernetic (Biró, Csaba Attila) +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { + BaseEdge, + getBezierPath, + EdgeLabelRenderer, + type EdgeProps, +} from "@xyflow/react"; + +interface OnlineEdgeData { + isOnline?: boolean; + value?: unknown; + [key: string]: unknown; +} + +export function OnlineEdge({ + id, + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + data, + markerEnd, +}: EdgeProps) { + const [edgePath, labelX, labelY] = getBezierPath({ + sourceX, + sourceY, + sourcePosition, + targetX, + targetY, + targetPosition, + }); + + const edgeData = data as OnlineEdgeData | undefined; + const isOnline = edgeData?.isOnline ?? false; + const value = edgeData?.value; + const isActive = isOnline && Boolean(value); + + const edgeClass = !isOnline + ? "ff-edge-offline" + : isActive + ? "ff-edge-active" + : "ff-edge-online"; + + return ( + <> + + {isOnline && value !== undefined && ( + +
+ {String(value)} +
+
+ )} + + ); +} diff --git a/src/frontend/src/features/editor/components/WatchPanel.tsx b/src/frontend/src/features/editor/components/WatchPanel.tsx new file mode 100644 index 0000000..6ed99e4 --- /dev/null +++ b/src/frontend/src/features/editor/components/WatchPanel.tsx @@ -0,0 +1,109 @@ +// Copyright (c) 2026 Qubernetic (Biró, Csaba Attila) +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { useState } from "react"; +import type { PlcVariableValue } from "../../../api/types"; + +interface WatchPanelProps { + isOnline: boolean; + watchList: string[]; + variableValues: Map; + onAdd: (path: string) => void; + onRemove: (path: string) => void; +} + +export function WatchPanel({ + isOnline, + watchList, + variableValues, + onAdd, + onRemove, +}: WatchPanelProps) { + const [isOpen, setIsOpen] = useState(false); + const [newPath, setNewPath] = useState(""); + + if (!isOnline) return null; + + const handleAdd = () => { + const trimmed = newPath.trim(); + if (trimmed) { + onAdd(trimmed); + setNewPath(""); + } + }; + + return ( +
+ + + {isOpen && ( +
+
+ setNewPath(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleAdd()} + /> + +
+ + + + + + + + + + + + {watchList.map((path) => { + const v = variableValues.get(path); + return ( + + + + + + + + ); + })} + {watchList.length === 0 && ( + + + + )} + +
VariableValueTypeTime +
{path} + {v ? String(v.value) : "\u2014"} + {v?.dataType ?? "\u2014"} + {v?.timestamp + ? new Date(v.timestamp).toLocaleTimeString() + : "\u2014"} + + +
+ No variables in watch list +
+
+ )} +
+ ); +} diff --git a/src/frontend/src/features/editor/hooks/useOnlineMode.ts b/src/frontend/src/features/editor/hooks/useOnlineMode.ts new file mode 100644 index 0000000..fbb7a74 --- /dev/null +++ b/src/frontend/src/features/editor/hooks/useOnlineMode.ts @@ -0,0 +1,126 @@ +// Copyright (c) 2026 Qubernetic (Biró, Csaba Attila) +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { useCallback, useEffect, useMemo } from "react"; +import { useSignalR } from "../../../shared/hooks/useSignalR"; +import { useOnlineStore } from "../stores/useOnlineStore"; +import { apiFetch } from "../../../api/client"; +import { endpoints } from "../../../api/endpoints"; +import { getAllVariablePaths, getNodeIdFromPath } from "../utils/nodeVariableMapping"; +import type { FlowNode, MonitorSession, PlcVariableValue } from "../../../api/types"; + +export function useOnlineMode( + projectId: string | null, + targetAmsNetId: string | null, + flowNodes: FlowNode[], +) { + const store = useOnlineStore(); + + const handlers = useMemo( + () => ({ + ReceiveVariableValues: (values: PlcVariableValue[]) => { + store.updateVariableValues(values); + + // Derive node execution states from variable values + for (const v of values) { + const nodeId = getNodeIdFromPath(v.path); + if (nodeId) { + // A node is "active" if any of its output variables are truthy + const state = v.value ? "active" as const : "idle" as const; + store.updateNodeState(nodeId, state); + } + } + }, + ReceiveConnectionStatus: (status: string) => { + if ( + status === "disconnected" || + status === "connecting" || + status === "connected" || + status === "error" + ) { + store.updateConnectionStatus(status); + } + }, + ReceiveError: (message: string) => { + store.setError(message); + }, + }), + // Store methods are stable (zustand), safe to reference + // eslint-disable-next-line react-hooks/exhaustive-deps + [], + ); + + const { connectionStatus: signalRStatus, invoke } = useSignalR( + store.session?.signalREndpoint ?? null, + store.session?.authToken ?? null, + handlers, + ); + + // Sync SignalR connection status to store + useEffect(() => { + store.updateConnectionStatus(signalRStatus); + }, [signalRStatus, store]); + + // Auto-subscribe to variables when online and flow changes + useEffect(() => { + if (!store.isOnline || signalRStatus !== "connected") return; + const paths = getAllVariablePaths(flowNodes); + if (paths.length > 0) { + invoke("Subscribe", paths).catch(() => {}); + } + }, [store.isOnline, signalRStatus, flowNodes, invoke]); + + // Subscribe watch list variables when they change + useEffect(() => { + if (!store.isOnline || signalRStatus !== "connected") return; + if (store.watchList.length > 0) { + invoke("Subscribe", store.watchList).catch(() => {}); + } + }, [store.isOnline, signalRStatus, store.watchList, invoke]); + + const goOnline = useCallback(async () => { + if (!projectId || !targetAmsNetId) return; + store.setError(null); + store.updateConnectionStatus("connecting"); + + try { + const session = await apiFetch(endpoints.monitor.start, { + method: "POST", + body: JSON.stringify({ projectId, targetAmsNetId }), + }); + store.setSession(session); + store.setOnline(true); + } catch (err) { + store.setError( + err instanceof Error ? err.message : "Failed to start monitor session", + ); + store.updateConnectionStatus("error"); + } + }, [projectId, targetAmsNetId, store]); + + const goOffline = useCallback(async () => { + const sessionId = store.session?.sessionId; + store.reset(); + + if (sessionId) { + try { + await apiFetch(endpoints.monitor.stop(sessionId), { method: "POST" }); + } catch { + // Best-effort cleanup + } + } + }, [store]); + + return { + isOnline: store.isOnline, + connectionStatus: store.connectionStatus, + error: store.error, + nodeStates: store.nodeStates, + variableValues: store.variableValues, + watchList: store.watchList, + goOnline, + goOffline, + addToWatchList: store.addToWatchList, + removeFromWatchList: store.removeFromWatchList, + }; +} diff --git a/src/frontend/src/features/editor/nodes/ComparisonNode.tsx b/src/frontend/src/features/editor/nodes/ComparisonNode.tsx index d5bba16..1edf6bd 100644 --- a/src/frontend/src/features/editor/nodes/ComparisonNode.tsx +++ b/src/frontend/src/features/editor/nodes/ComparisonNode.tsx @@ -1,6 +1,59 @@ // Copyright (c) 2026 Qubernetic (Biró, Csaba Attila) // SPDX-License-Identifier: AGPL-3.0-or-later -export function ComparisonNode() { - return
ComparisonNode
; +import { Handle, Position } from "@xyflow/react"; +import type { NodeOnlineData } from "../../../api/types"; + +interface ComparisonNodeData { + label?: string; + operator?: string; + onlineData?: NodeOnlineData; + [key: string]: unknown; +} + +function findVar(data: NodeOnlineData | undefined, suffix: string) { + return data?.variables.find((v) => v.path.endsWith(suffix)); +} + +export function ComparisonNode({ data }: { data: ComparisonNodeData }) { + const execState = data.onlineData?.executionState ?? "idle"; + const aVar = findVar(data.onlineData, ".A"); + const bVar = findVar(data.onlineData, ".B"); + const outVar = findVar(data.onlineData, ".OUT"); + + return ( +
+
+ {data.label ?? data.operator ?? "Compare"} +
+
+
+ + A + {aVar && ( + {String(aVar.value)} + )} +
+
+ + B + {bVar && ( + {String(bVar.value)} + )} +
+
+ OUT + {outVar && ( + {String(outVar.value)} + )} + +
+
+
+ ); } diff --git a/src/frontend/src/features/editor/nodes/CounterNode.tsx b/src/frontend/src/features/editor/nodes/CounterNode.tsx index 98f13d3..e7b1022 100644 --- a/src/frontend/src/features/editor/nodes/CounterNode.tsx +++ b/src/frontend/src/features/editor/nodes/CounterNode.tsx @@ -1,6 +1,82 @@ // Copyright (c) 2026 Qubernetic (Biró, Csaba Attila) // SPDX-License-Identifier: AGPL-3.0-or-later -export function CounterNode() { - return
CounterNode
; +import { Handle, Position } from "@xyflow/react"; +import type { NodeOnlineData } from "../../../api/types"; + +interface CounterNodeData { + label?: string; + onlineData?: NodeOnlineData; + [key: string]: unknown; +} + +function findVar(data: NodeOnlineData | undefined, suffix: string) { + return data?.variables.find((v) => v.path.endsWith(suffix)); +} + +export function CounterNode({ data }: { data: CounterNodeData }) { + const execState = data.onlineData?.executionState ?? "idle"; + const cuVar = findVar(data.onlineData, ".CU"); + const resetVar = findVar(data.onlineData, ".RESET"); + const pvVar = findVar(data.onlineData, ".PV"); + const qVar = findVar(data.onlineData, ".Q"); + const cvVar = findVar(data.onlineData, ".CV"); + + return ( +
+
{data.label ?? "Counter"}
+
+
+ + CU + {cuVar && ( + {String(cuVar.value)} + )} +
+
+ + RESET + {resetVar && ( + {String(resetVar.value)} + )} +
+
+ + PV + {pvVar && ( + {String(pvVar.value)} + )} +
+
+ Q + {qVar && ( + {String(qVar.value)} + )} + +
+
+ CV + {cvVar && ( + {String(cvVar.value)} + )} + +
+
+
+ ); } diff --git a/src/frontend/src/features/editor/nodes/InputNode.tsx b/src/frontend/src/features/editor/nodes/InputNode.tsx index 416b28e..d8e7867 100644 --- a/src/frontend/src/features/editor/nodes/InputNode.tsx +++ b/src/frontend/src/features/editor/nodes/InputNode.tsx @@ -1,6 +1,33 @@ // Copyright (c) 2026 Qubernetic (Biró, Csaba Attila) // SPDX-License-Identifier: AGPL-3.0-or-later -export function InputNode() { - return
InputNode
; +import { Handle, Position } from "@xyflow/react"; +import type { NodeOnlineData } from "../../../api/types"; + +interface InputNodeData { + label?: string; + onlineData?: NodeOnlineData; + [key: string]: unknown; +} + +export function InputNode({ data }: { data: InputNodeData }) { + const execState = data.onlineData?.executionState ?? "idle"; + const outVar = data.onlineData?.variables.find((v) => + v.path.endsWith(".OUT"), + ); + + return ( +
+
{data.label ?? "Input"}
+
+
+ OUT + {outVar && ( + {String(outVar.value)} + )} + +
+
+
+ ); } diff --git a/src/frontend/src/features/editor/nodes/OutputNode.tsx b/src/frontend/src/features/editor/nodes/OutputNode.tsx index 412b27f..83d39e0 100644 --- a/src/frontend/src/features/editor/nodes/OutputNode.tsx +++ b/src/frontend/src/features/editor/nodes/OutputNode.tsx @@ -1,6 +1,33 @@ // Copyright (c) 2026 Qubernetic (Biró, Csaba Attila) // SPDX-License-Identifier: AGPL-3.0-or-later -export function OutputNode() { - return
OutputNode
; +import { Handle, Position } from "@xyflow/react"; +import type { NodeOnlineData } from "../../../api/types"; + +interface OutputNodeData { + label?: string; + onlineData?: NodeOnlineData; + [key: string]: unknown; +} + +export function OutputNode({ data }: { data: OutputNodeData }) { + const execState = data.onlineData?.executionState ?? "idle"; + const inVar = data.onlineData?.variables.find((v) => + v.path.endsWith(".IN"), + ); + + return ( +
+
{data.label ?? "Output"}
+
+
+ + IN + {inVar && ( + {String(inVar.value)} + )} +
+
+
+ ); } diff --git a/src/frontend/src/features/editor/nodes/TimerNode.tsx b/src/frontend/src/features/editor/nodes/TimerNode.tsx index 5fe51b7..2f2152f 100644 --- a/src/frontend/src/features/editor/nodes/TimerNode.tsx +++ b/src/frontend/src/features/editor/nodes/TimerNode.tsx @@ -1,6 +1,69 @@ // Copyright (c) 2026 Qubernetic (Biró, Csaba Attila) // SPDX-License-Identifier: AGPL-3.0-or-later -export function TimerNode() { - return
TimerNode
; +import { Handle, Position } from "@xyflow/react"; +import type { NodeOnlineData } from "../../../api/types"; + +interface TimerNodeData { + label?: string; + onlineData?: NodeOnlineData; + [key: string]: unknown; +} + +function findVar(data: NodeOnlineData | undefined, suffix: string) { + return data?.variables.find((v) => v.path.endsWith(suffix)); +} + +export function TimerNode({ data }: { data: TimerNodeData }) { + const execState = data.onlineData?.executionState ?? "idle"; + const inVar = findVar(data.onlineData, ".IN"); + const ptVar = findVar(data.onlineData, ".PT"); + const qVar = findVar(data.onlineData, ".Q"); + const etVar = findVar(data.onlineData, ".ET"); + + return ( +
+
{data.label ?? "Timer"}
+
+
+ + IN + {inVar && ( + {String(inVar.value)} + )} +
+
+ + PT + {ptVar && ( + {String(ptVar.value)} + )} +
+
+ Q + {qVar && ( + {String(qVar.value)} + )} + +
+
+ ET + {etVar && ( + {String(etVar.value)} + )} + +
+
+
+ ); } diff --git a/src/frontend/src/features/editor/nodes/nodeRegistry.ts b/src/frontend/src/features/editor/nodes/nodeRegistry.ts index 531b37b..2be0664 100644 --- a/src/frontend/src/features/editor/nodes/nodeRegistry.ts +++ b/src/frontend/src/features/editor/nodes/nodeRegistry.ts @@ -6,11 +6,16 @@ import { OutputNode } from './OutputNode'; import { TimerNode } from './TimerNode'; import { CounterNode } from './CounterNode'; import { ComparisonNode } from './ComparisonNode'; +import { OnlineEdge } from '../components/OnlineEdge'; -export const nodeTypes: Record = { +export const nodeTypes = { input: InputNode, output: OutputNode, timer: TimerNode, counter: CounterNode, comparison: ComparisonNode, }; + +export const edgeTypes = { + online: OnlineEdge, +}; diff --git a/src/frontend/src/features/editor/stores/useOnlineStore.ts b/src/frontend/src/features/editor/stores/useOnlineStore.ts new file mode 100644 index 0000000..9fb5519 --- /dev/null +++ b/src/frontend/src/features/editor/stores/useOnlineStore.ts @@ -0,0 +1,83 @@ +// Copyright (c) 2026 Qubernetic (Biró, Csaba Attila) +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { create } from "zustand"; +import type { + MonitorSession, + PlcVariableValue, + NodeExecutionState, + ConnectionStatus, +} from "../../../api/types"; + +interface OnlineState { + isOnline: boolean; + session: MonitorSession | null; + nodeStates: Map; + variableValues: Map; + watchList: string[]; + connectionStatus: ConnectionStatus; + error: string | null; +} + +interface OnlineActions { + setSession: (session: MonitorSession | null) => void; + setOnline: (online: boolean) => void; + updateVariableValues: (values: PlcVariableValue[]) => void; + updateNodeState: (nodeId: string, state: NodeExecutionState) => void; + updateConnectionStatus: (status: ConnectionStatus) => void; + setError: (error: string | null) => void; + addToWatchList: (path: string) => void; + removeFromWatchList: (path: string) => void; + reset: () => void; +} + +const initialState: OnlineState = { + isOnline: false, + session: null, + nodeStates: new Map(), + variableValues: new Map(), + watchList: [], + connectionStatus: "disconnected", + error: null, +}; + +export const useOnlineStore = create((set) => ({ + ...initialState, + + setSession: (session) => set({ session }), + + setOnline: (isOnline) => set({ isOnline }), + + updateVariableValues: (values) => + set((state) => { + const next = new Map(state.variableValues); + for (const v of values) { + next.set(v.path, v); + } + return { variableValues: next }; + }), + + updateNodeState: (nodeId, executionState) => + set((state) => { + const next = new Map(state.nodeStates); + next.set(nodeId, executionState); + return { nodeStates: next }; + }), + + updateConnectionStatus: (connectionStatus) => set({ connectionStatus }), + + setError: (error) => set({ error }), + + addToWatchList: (path) => + set((state) => { + if (state.watchList.includes(path)) return state; + return { watchList: [...state.watchList, path] }; + }), + + removeFromWatchList: (path) => + set((state) => ({ + watchList: state.watchList.filter((p) => p !== path), + })), + + reset: () => set(initialState), +})); diff --git a/src/frontend/src/features/editor/types/flow.types.ts b/src/frontend/src/features/editor/types/flow.types.ts index 2909f3f..aa86f4f 100644 --- a/src/frontend/src/features/editor/types/flow.types.ts +++ b/src/frontend/src/features/editor/types/flow.types.ts @@ -1,4 +1,11 @@ // Copyright (c) 2026 Qubernetic (Biró, Csaba Attila) // SPDX-License-Identifier: AGPL-3.0-or-later -export type { FlowDocument, FlowNode, FlowConnection } from '../../../api/types'; +export type { + FlowDocument, + FlowNode, + FlowConnection, + NodeExecutionState, + NodeOnlineData, + ConnectionStatus, +} from '../../../api/types'; diff --git a/src/frontend/src/features/editor/utils/nodeVariableMapping.ts b/src/frontend/src/features/editor/utils/nodeVariableMapping.ts new file mode 100644 index 0000000..627d279 --- /dev/null +++ b/src/frontend/src/features/editor/utils/nodeVariableMapping.ts @@ -0,0 +1,44 @@ +// Copyright (c) 2026 Qubernetic (Biró, Csaba Attila) +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type { FlowNode } from "../../../api/types"; + +/** + * Maps node types to their PLC variable port names. + * Must match the build server's code generation naming conventions. + */ +const nodePortMap: Record = { + input: ["OUT"], + output: ["IN"], + timer: ["IN", "PT", "Q", "ET"], + counter: ["CU", "RESET", "PV", "Q", "CV"], + comparison: ["A", "B", "OUT"], +}; + +/** + * Returns PLC variable paths for a given flow node. + * Path format: MAIN.. + */ +export function getVariablePathsForNode(node: FlowNode): string[] { + const ports = nodePortMap[node.type]; + if (!ports) return []; + return ports.map((port) => `MAIN.${node.id}.${port}`); +} + +/** + * Returns all variable paths for an entire flow document's nodes. + */ +export function getAllVariablePaths(nodes: FlowNode[]): string[] { + return nodes.flatMap(getVariablePathsForNode); +} + +/** + * Extracts the nodeId from a variable path (MAIN..). + */ +export function getNodeIdFromPath(path: string): string | null { + const parts = path.split("."); + if (parts.length >= 3 && parts[0] === "MAIN") { + return parts[1] ?? null; + } + return null; +} diff --git a/src/frontend/src/features/monitoring/MonitorPage.tsx b/src/frontend/src/features/monitoring/MonitorPage.tsx deleted file mode 100644 index 86cc217..0000000 --- a/src/frontend/src/features/monitoring/MonitorPage.tsx +++ /dev/null @@ -1,6 +0,0 @@ -// Copyright (c) 2026 Qubernetic (Biró, Csaba Attila) -// SPDX-License-Identifier: AGPL-3.0-or-later - -export function MonitorPage() { - return
MonitorPage
; -} diff --git a/src/frontend/src/features/monitoring/components/ValueDisplay.tsx b/src/frontend/src/features/monitoring/components/ValueDisplay.tsx deleted file mode 100644 index 11ea7e4..0000000 --- a/src/frontend/src/features/monitoring/components/ValueDisplay.tsx +++ /dev/null @@ -1,6 +0,0 @@ -// Copyright (c) 2026 Qubernetic (Biró, Csaba Attila) -// SPDX-License-Identifier: AGPL-3.0-or-later - -export function ValueDisplay() { - return
ValueDisplay
; -} diff --git a/src/frontend/src/features/monitoring/components/VariableList.tsx b/src/frontend/src/features/monitoring/components/VariableList.tsx deleted file mode 100644 index e8d62a6..0000000 --- a/src/frontend/src/features/monitoring/components/VariableList.tsx +++ /dev/null @@ -1,6 +0,0 @@ -// Copyright (c) 2026 Qubernetic (Biró, Csaba Attila) -// SPDX-License-Identifier: AGPL-3.0-or-later - -export function VariableList() { - return
VariableList
; -} diff --git a/src/frontend/src/features/monitoring/components/VariableSubscriber.tsx b/src/frontend/src/features/monitoring/components/VariableSubscriber.tsx deleted file mode 100644 index aab81ed..0000000 --- a/src/frontend/src/features/monitoring/components/VariableSubscriber.tsx +++ /dev/null @@ -1,6 +0,0 @@ -// Copyright (c) 2026 Qubernetic (Biró, Csaba Attila) -// SPDX-License-Identifier: AGPL-3.0-or-later - -export function VariableSubscriber() { - return
VariableSubscriber
; -} diff --git a/src/frontend/src/features/monitoring/hooks/useMonitorSession.ts b/src/frontend/src/features/monitoring/hooks/useMonitorSession.ts deleted file mode 100644 index 538c392..0000000 --- a/src/frontend/src/features/monitoring/hooks/useMonitorSession.ts +++ /dev/null @@ -1,6 +0,0 @@ -// Copyright (c) 2026 Qubernetic (Biró, Csaba Attila) -// SPDX-License-Identifier: AGPL-3.0-or-later - -export function useMonitorSession() { - return null; -} diff --git a/src/frontend/src/features/monitoring/hooks/usePlcData.ts b/src/frontend/src/features/monitoring/hooks/usePlcData.ts deleted file mode 100644 index b8ed0d2..0000000 --- a/src/frontend/src/features/monitoring/hooks/usePlcData.ts +++ /dev/null @@ -1,6 +0,0 @@ -// Copyright (c) 2026 Qubernetic (Biró, Csaba Attila) -// SPDX-License-Identifier: AGPL-3.0-or-later - -export function usePlcData() { - return {}; -} diff --git a/src/frontend/src/shared/hooks/useSignalR.ts b/src/frontend/src/shared/hooks/useSignalR.ts index f622d4b..9cfb570 100644 --- a/src/frontend/src/shared/hooks/useSignalR.ts +++ b/src/frontend/src/shared/hooks/useSignalR.ts @@ -1,6 +1,91 @@ // Copyright (c) 2026 Qubernetic (Biró, Csaba Attila) // SPDX-License-Identifier: AGPL-3.0-or-later -export function useSignalR() { - return null; +import { useEffect, useRef, useState, useCallback } from "react"; +import { + HubConnectionBuilder, + HubConnection, + HubConnectionState, + LogLevel, +} from "@microsoft/signalr"; +import type { ConnectionStatus } from "../../api/types"; + +export interface SignalRHandlers { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [eventName: string]: (...args: any[]) => void; +} + +export function useSignalR( + endpoint: string | null, + authToken: string | null, + handlers: SignalRHandlers, +) { + const [connectionStatus, setConnectionStatus] = + useState("disconnected"); + const connectionRef = useRef(null); + const handlersRef = useRef(handlers); + handlersRef.current = handlers; + + const disconnect = useCallback(async () => { + const conn = connectionRef.current; + if (conn) { + connectionRef.current = null; + try { + await conn.stop(); + } catch { + // ignore stop errors during cleanup + } + } + setConnectionStatus("disconnected"); + }, []); + + useEffect(() => { + if (!endpoint || !authToken) { + return; + } + + const connection = new HubConnectionBuilder() + .withUrl(endpoint, { accessTokenFactory: () => authToken }) + .withAutomaticReconnect([0, 2000, 5000, 10000, 30000]) + .configureLogging(LogLevel.Warning) + .build(); + + connectionRef.current = connection; + + // Register event handlers + for (const [event] of Object.entries(handlersRef.current)) { + connection.on(event, (...args: unknown[]) => { + handlersRef.current[event]?.(...args); + }); + } + + connection.onreconnecting(() => setConnectionStatus("connecting")); + connection.onreconnected(() => setConnectionStatus("connected")); + connection.onclose(() => setConnectionStatus("disconnected")); + + setConnectionStatus("connecting"); + connection + .start() + .then(() => setConnectionStatus("connected")) + .catch(() => setConnectionStatus("error")); + + return () => { + connection.stop().catch(() => {}); + }; + }, [endpoint, authToken]); + + return { + connection: connectionRef.current, + connectionStatus, + disconnect, + invoke: useCallback( + async (method: string, ...args: unknown[]) => { + const conn = connectionRef.current; + if (conn?.state === HubConnectionState.Connected) { + return conn.invoke(method, ...args); + } + }, + [], + ), + }; } From f053dc844d563af3d971fc2fdadb6ee90f42374d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bir=C3=B3=2C=20Csaba=20Attila?= Date: Sat, 14 Feb 2026 21:29:06 +0100 Subject: [PATCH 02/22] style(frontend): restyle editor to Unity Visual Scripting theme Dark charcoal nodes with muted colored headers, data-type colored handles (BOOL=green, INT=blue, TIME=cyan) and edges colored by source port type. Neutral dark grey background without blue tint. Co-Authored-By: Claude Opus 4.6 --- src/frontend/src/App.css | 99 ++++++++----------- .../features/editor/components/FlowCanvas.tsx | 28 +++++- .../features/editor/components/OnlineEdge.tsx | 18 ++-- .../features/editor/nodes/ComparisonNode.tsx | 7 +- .../src/features/editor/nodes/CounterNode.tsx | 11 ++- .../src/features/editor/nodes/InputNode.tsx | 3 +- .../src/features/editor/nodes/OutputNode.tsx | 3 +- .../src/features/editor/nodes/TimerNode.tsx | 9 +- .../src/features/editor/utils/portColors.ts | 14 +++ 9 files changed, 111 insertions(+), 81 deletions(-) create mode 100644 src/frontend/src/features/editor/utils/portColors.ts diff --git a/src/frontend/src/App.css b/src/frontend/src/App.css index 0651e21..b6120fb 100644 --- a/src/frontend/src/App.css +++ b/src/frontend/src/App.css @@ -1,7 +1,7 @@ body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; - background: #1a1a2e; + background: #1a1a1a; color: #e0e0e0; } @@ -35,8 +35,8 @@ body { align-items: center; justify-content: space-between; padding: 8px 16px; - background: #16213e; - border-bottom: 1px solid #0f3460; + background: #222; + border-bottom: 1px solid #333; min-height: 40px; gap: 12px; } @@ -113,9 +113,9 @@ body { /* ── Node base styles ──────────────────────────────────────────────── */ .ff-node { - background: #1e293b; - border: 2px solid #334155; - border-radius: 8px; + background: #2d2d2d; + border: 1px solid #1a1a1a; + border-radius: 6px; min-width: 140px; font-size: 12px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); @@ -124,27 +124,27 @@ body { } .ff-node-header { - padding: 6px 12px; - font-weight: 700; - font-size: 12px; - text-transform: uppercase; - letter-spacing: 0.5px; - border-bottom: 1px solid #334155; + padding: 4px 12px; + font-weight: 600; + font-size: 11px; + text-transform: none; + letter-spacing: 0; text-align: center; - border-radius: 6px 6px 0 0; + border-radius: 5px 5px 0 0; } -.ff-node-input .ff-node-header { background: #1e3a5f; color: #60a5fa; } -.ff-node-output .ff-node-header { background: #3b1f2b; color: #f87171; } -.ff-node-timer .ff-node-header { background: #2d2b1e; color: #fbbf24; } -.ff-node-counter .ff-node-header { background: #1e2d3b; color: #38bdf8; } -.ff-node-comparison .ff-node-header { background: #2d1e3b; color: #c084fc; } +.ff-node-input .ff-node-header { background: #2a4a5a; color: #7ab5cc; } +.ff-node-output .ff-node-header { background: #5a2a3a; color: #cc7a9b; } +.ff-node-timer .ff-node-header { background: #4a4a2a; color: #cccc7a; } +.ff-node-counter .ff-node-header { background: #2a3a4a; color: #7a9bcc; } +.ff-node-comparison .ff-node-header { background: #4a2a5a; color: #b57acc; } .ff-node-body { padding: 8px 12px; display: flex; flex-direction: column; gap: 4px; + border-top: 1px solid #3a3a3a; } /* ── Ports ─────────────────────────────────────────────────────────── */ @@ -174,8 +174,8 @@ body { .ff-port-value { font-family: "JetBrains Mono", "Fira Code", monospace; font-size: 11px; - color: #22c55e; - background: rgba(34, 197, 94, 0.1); + color: #4ec970; + background: rgba(78, 201, 112, 0.1); padding: 1px 5px; border-radius: 3px; font-weight: 600; @@ -200,7 +200,7 @@ body { /* ── Online overlay ────────────────────────────────────────────────── */ .ff-online-overlay { - border-radius: 8px; + border-radius: 6px; transition: box-shadow 0.2s, outline-color 0.2s; } @@ -214,40 +214,25 @@ body { outline-offset: 2px; } -/* ── Edge online styling ───────────────────────────────────────────── */ - -.ff-edge-online { - stroke: #475569; - stroke-width: 2; -} - -.ff-edge-active { - stroke: #22c55e !important; - stroke-width: 2.5 !important; - filter: drop-shadow(0 0 4px rgba(34, 197, 94, 0.5)) drop-shadow(0 0 8px rgba(34, 197, 94, 0.25)); -} - -.ff-edge-offline { - stroke: #475569; -} +/* ── Edge value label ─────────────────────────────────────────────── */ .ff-edge-value { - background: #0f172a; - color: #22c55e; + background: #1a1a1a; + color: #4ec970; font-family: "JetBrains Mono", "Fira Code", monospace; font-size: 10px; font-weight: 600; padding: 2px 6px; border-radius: 4px; - border: 1px solid #334155; + border: 1px solid #3a3a3a; white-space: nowrap; } /* ── Watch panel ───────────────────────────────────────────────────── */ .ff-watch-panel { - background: #0f172a; - border-top: 1px solid #334155; + background: #1e1e1e; + border-top: 1px solid #3a3a3a; display: flex; flex-direction: column; flex-shrink: 0; @@ -257,7 +242,7 @@ body { .ff-watch-toggle { display: block; width: 100%; - background: #1e293b; + background: #2d2d2d; color: #94a3b8; border: none; padding: 6px 16px; @@ -265,11 +250,11 @@ body { font-weight: 600; cursor: pointer; text-align: left; - border-top: 1px solid #334155; + border-top: 1px solid #3a3a3a; } .ff-watch-toggle:hover { - background: #263548; + background: #383838; } .ff-watch-content { @@ -289,9 +274,9 @@ body { padding: 4px 8px; font-size: 12px; font-family: "JetBrains Mono", "Fira Code", monospace; - background: #1e293b; + background: #2d2d2d; color: #e0e0e0; - border: 1px solid #334155; + border: 1px solid #3a3a3a; border-radius: 4px; outline: none; } @@ -312,7 +297,7 @@ body { padding: 4px 8px; color: #64748b; font-weight: 600; - border-bottom: 1px solid #334155; + border-bottom: 1px solid #3a3a3a; } .ff-watch-table th:nth-child(1) { width: 40%; } @@ -323,7 +308,7 @@ body { .ff-watch-table td { padding: 4px 8px; - border-bottom: 1px solid #1e293b; + border-bottom: 1px solid #2d2d2d; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -336,7 +321,7 @@ body { .ff-watch-value { font-family: "JetBrains Mono", "Fira Code", monospace; - color: #22c55e; + color: #4ec970; font-weight: 600; } @@ -358,25 +343,25 @@ body { /* ── React Flow overrides ──────────────────────────────────────────── */ .react-flow__background { - background: #1a1a2e !important; + background: #1a1a1a !important; } .react-flow__controls button { - background: #1e293b; + background: #2d2d2d; color: #94a3b8; - border: 1px solid #334155; + border: 1px solid #3a3a3a; } .react-flow__controls button:hover { - background: #334155; + background: #3a3a3a; } .react-flow__minimap { - background: #0f172a; + background: #1e1e1e; } .react-flow__edge-path { - stroke: #475569; + stroke: #8b95a3; stroke-width: 2; } @@ -384,7 +369,7 @@ body { width: 8px; height: 8px; background: #64748b; - border: 2px solid #1e293b; + border: 1.5px solid #1a1a1a; } .react-flow__handle-left { left: -5px; } diff --git a/src/frontend/src/features/editor/components/FlowCanvas.tsx b/src/frontend/src/features/editor/components/FlowCanvas.tsx index f6ff088..dbcb742 100644 --- a/src/frontend/src/features/editor/components/FlowCanvas.tsx +++ b/src/frontend/src/features/editor/components/FlowCanvas.tsx @@ -27,6 +27,14 @@ import type { } from "../../../api/types"; import { getVariablePathsForNode } from "../utils/nodeVariableMapping"; +const PORT_DATA_TYPES: Record> = { + input: { OUT: "BOOL" }, + output: { IN: "BOOL" }, + timer: { IN: "BOOL", PT: "TIME", Q: "BOOL", ET: "TIME" }, + counter: { CU: "BOOL", RESET: "BOOL", PV: "INT", Q: "BOOL", CV: "INT" }, + comparison: { A: "INT", B: "INT", OUT: "BOOL" }, +}; + interface FlowCanvasProps { flowNodes: FlowNode[]; flowConnections: FlowConnection[]; @@ -68,15 +76,27 @@ function toReactFlowNodes( }); } +function resolvePortType( + nodeId: string, + portName: string, + flowNodes: FlowNode[], +): string { + const node = flowNodes.find((n) => n.id === nodeId); + if (!node) return "DEFAULT"; + return PORT_DATA_TYPES[node.type]?.[portName] ?? "DEFAULT"; +} + function toReactFlowEdges( connections: FlowConnection[], isOnline: boolean, variableValues: Map, + flowNodes: FlowNode[], ): Edge[] { return connections.map((conn) => { const id = `e-${conn.from.nodeId}-${conn.from.portName}-${conn.to.nodeId}-${conn.to.portName}`; const sourcePath = `MAIN.${conn.from.nodeId}.${conn.from.portName}`; const value = variableValues.get(sourcePath)?.value; + const portType = resolvePortType(conn.from.nodeId, conn.from.portName, flowNodes); return { id, @@ -84,8 +104,8 @@ function toReactFlowEdges( sourceHandle: conn.from.portName, target: conn.to.nodeId, targetHandle: conn.to.portName, - type: isOnline ? "online" : "default", - data: isOnline ? { isOnline: true, value } : undefined, + type: "online", + data: { isOnline, value: isOnline ? value : undefined, portType }, }; }); } @@ -103,7 +123,7 @@ export function FlowCanvas({ ); const initialEdges = useMemo( - () => toReactFlowEdges(flowConnections, isOnline, variableValues), + () => toReactFlowEdges(flowConnections, isOnline, variableValues, flowNodes), [flowConnections, isOnline, variableValues], ); @@ -116,7 +136,7 @@ export function FlowCanvas({ }, [flowNodes, isOnline, nodeStates, variableValues, setNodes]); useEffect(() => { - setEdges(toReactFlowEdges(flowConnections, isOnline, variableValues)); + setEdges(toReactFlowEdges(flowConnections, isOnline, variableValues, flowNodes)); }, [flowConnections, isOnline, variableValues, setEdges]); const onConnect: OnConnect = useCallback( diff --git a/src/frontend/src/features/editor/components/OnlineEdge.tsx b/src/frontend/src/features/editor/components/OnlineEdge.tsx index 2d2fdaa..bda24b0 100644 --- a/src/frontend/src/features/editor/components/OnlineEdge.tsx +++ b/src/frontend/src/features/editor/components/OnlineEdge.tsx @@ -7,10 +7,12 @@ import { EdgeLabelRenderer, type EdgeProps, } from "@xyflow/react"; +import { getPortColor } from "../utils/portColors"; interface OnlineEdgeData { isOnline?: boolean; value?: unknown; + portType?: string; [key: string]: unknown; } @@ -37,13 +39,17 @@ export function OnlineEdge({ const edgeData = data as OnlineEdgeData | undefined; const isOnline = edgeData?.isOnline ?? false; const value = edgeData?.value; + const portType = edgeData?.portType ?? "DEFAULT"; const isActive = isOnline && Boolean(value); - const edgeClass = !isOnline - ? "ff-edge-offline" - : isActive - ? "ff-edge-active" - : "ff-edge-online"; + const strokeColor = getPortColor(portType); + const edgeStyle: React.CSSProperties = { + stroke: strokeColor, + strokeWidth: isActive ? 2.5 : 2, + filter: isActive + ? `drop-shadow(0 0 4px ${strokeColor}80) drop-shadow(0 0 8px ${strokeColor}40)` + : undefined, + }; return ( <> @@ -51,7 +57,7 @@ export function OnlineEdge({ id={id} path={edgePath} markerEnd={markerEnd} - className={edgeClass} + style={edgeStyle} /> {isOnline && value !== undefined && ( diff --git a/src/frontend/src/features/editor/nodes/ComparisonNode.tsx b/src/frontend/src/features/editor/nodes/ComparisonNode.tsx index 1edf6bd..f89a4da 100644 --- a/src/frontend/src/features/editor/nodes/ComparisonNode.tsx +++ b/src/frontend/src/features/editor/nodes/ComparisonNode.tsx @@ -3,6 +3,7 @@ import { Handle, Position } from "@xyflow/react"; import type { NodeOnlineData } from "../../../api/types"; +import { getPortColor } from "../utils/portColors"; interface ComparisonNodeData { label?: string; @@ -28,7 +29,7 @@ export function ComparisonNode({ data }: { data: ComparisonNodeData }) {
- + A {aVar && ( {String(aVar.value)} @@ -39,7 +40,7 @@ export function ComparisonNode({ data }: { data: ComparisonNodeData }) { type="target" position={Position.Left} id="B" - style={{ top: "60%" }} + style={{ top: "60%", background: getPortColor("INT") }} /> B {bVar && ( @@ -51,7 +52,7 @@ export function ComparisonNode({ data }: { data: ComparisonNodeData }) { {outVar && ( {String(outVar.value)} )} - +
diff --git a/src/frontend/src/features/editor/nodes/CounterNode.tsx b/src/frontend/src/features/editor/nodes/CounterNode.tsx index e7b1022..12ed1b5 100644 --- a/src/frontend/src/features/editor/nodes/CounterNode.tsx +++ b/src/frontend/src/features/editor/nodes/CounterNode.tsx @@ -3,6 +3,7 @@ import { Handle, Position } from "@xyflow/react"; import type { NodeOnlineData } from "../../../api/types"; +import { getPortColor } from "../utils/portColors"; interface CounterNodeData { label?: string; @@ -27,7 +28,7 @@ export function CounterNode({ data }: { data: CounterNodeData }) {
{data.label ?? "Counter"}
- + CU {cuVar && ( {String(cuVar.value)} @@ -38,7 +39,7 @@ export function CounterNode({ data }: { data: CounterNodeData }) { type="target" position={Position.Left} id="RESET" - style={{ top: "50%" }} + style={{ top: "50%", background: getPortColor("BOOL") }} /> RESET {resetVar && ( @@ -50,7 +51,7 @@ export function CounterNode({ data }: { data: CounterNodeData }) { type="target" position={Position.Left} id="PV" - style={{ top: "70%" }} + style={{ top: "70%", background: getPortColor("INT") }} /> PV {pvVar && ( @@ -62,7 +63,7 @@ export function CounterNode({ data }: { data: CounterNodeData }) { {qVar && ( {String(qVar.value)} )} - +
CV @@ -73,7 +74,7 @@ export function CounterNode({ data }: { data: CounterNodeData }) { type="source" position={Position.Right} id="CV" - style={{ top: "60%" }} + style={{ top: "60%", background: getPortColor("INT") }} />
diff --git a/src/frontend/src/features/editor/nodes/InputNode.tsx b/src/frontend/src/features/editor/nodes/InputNode.tsx index d8e7867..083d73e 100644 --- a/src/frontend/src/features/editor/nodes/InputNode.tsx +++ b/src/frontend/src/features/editor/nodes/InputNode.tsx @@ -3,6 +3,7 @@ import { Handle, Position } from "@xyflow/react"; import type { NodeOnlineData } from "../../../api/types"; +import { getPortColor } from "../utils/portColors"; interface InputNodeData { label?: string; @@ -25,7 +26,7 @@ export function InputNode({ data }: { data: InputNodeData }) { {outVar && ( {String(outVar.value)} )} - + diff --git a/src/frontend/src/features/editor/nodes/OutputNode.tsx b/src/frontend/src/features/editor/nodes/OutputNode.tsx index 83d39e0..f524a70 100644 --- a/src/frontend/src/features/editor/nodes/OutputNode.tsx +++ b/src/frontend/src/features/editor/nodes/OutputNode.tsx @@ -3,6 +3,7 @@ import { Handle, Position } from "@xyflow/react"; import type { NodeOnlineData } from "../../../api/types"; +import { getPortColor } from "../utils/portColors"; interface OutputNodeData { label?: string; @@ -21,7 +22,7 @@ export function OutputNode({ data }: { data: OutputNodeData }) {
{data.label ?? "Output"}
- + IN {inVar && ( {String(inVar.value)} diff --git a/src/frontend/src/features/editor/nodes/TimerNode.tsx b/src/frontend/src/features/editor/nodes/TimerNode.tsx index 2f2152f..0cac5ec 100644 --- a/src/frontend/src/features/editor/nodes/TimerNode.tsx +++ b/src/frontend/src/features/editor/nodes/TimerNode.tsx @@ -3,6 +3,7 @@ import { Handle, Position } from "@xyflow/react"; import type { NodeOnlineData } from "../../../api/types"; +import { getPortColor } from "../utils/portColors"; interface TimerNodeData { label?: string; @@ -26,7 +27,7 @@ export function TimerNode({ data }: { data: TimerNodeData }) {
{data.label ?? "Timer"}
- + IN {inVar && ( {String(inVar.value)} @@ -37,7 +38,7 @@ export function TimerNode({ data }: { data: TimerNodeData }) { type="target" position={Position.Left} id="PT" - style={{ top: "60%" }} + style={{ top: "60%", background: getPortColor("TIME") }} /> PT {ptVar && ( @@ -49,7 +50,7 @@ export function TimerNode({ data }: { data: TimerNodeData }) { {qVar && ( {String(qVar.value)} )} - +
ET @@ -60,7 +61,7 @@ export function TimerNode({ data }: { data: TimerNodeData }) { type="source" position={Position.Right} id="ET" - style={{ top: "60%" }} + style={{ top: "60%", background: getPortColor("TIME") }} />
diff --git a/src/frontend/src/features/editor/utils/portColors.ts b/src/frontend/src/features/editor/utils/portColors.ts new file mode 100644 index 0000000..b11177c --- /dev/null +++ b/src/frontend/src/features/editor/utils/portColors.ts @@ -0,0 +1,14 @@ +// Copyright (c) 2026 Qubernetic (Biró, Csaba Attila) +// SPDX-License-Identifier: AGPL-3.0-or-later + +const PORT_COLORS: Record = { + BOOL: "#4ec970", + INT: "#5b8def", + TIME: "#4ac1cc", +}; + +const DEFAULT_COLOR = "#8b95a3"; + +export function getPortColor(dataType: string): string { + return PORT_COLORS[dataType.toUpperCase()] ?? DEFAULT_COLOR; +} From 39723a2eed21388f53893e5b23c10847bfab6121 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bir=C3=B3=2C=20Csaba=20Attila?= Date: Sun, 15 Feb 2026 23:01:29 +0100 Subject: [PATCH 03/22] feat(frontend): add Entry, IF, FOR control flow node types Add EntryNode (PRG/method entry point with ENO exec output), IfNode (conditional with TRUE branch), ForNode (loop with DO exec output), and execution order computation utility. Co-Authored-By: Claude Opus 4.6 --- .../src/features/editor/nodes/EntryNode.tsx | 42 ++++++++++ .../src/features/editor/nodes/ForNode.tsx | 79 +++++++++++++++++++ .../src/features/editor/nodes/IfNode.tsx | 65 +++++++++++++++ .../features/editor/utils/executionOrder.ts | 60 ++++++++++++++ 4 files changed, 246 insertions(+) create mode 100644 src/frontend/src/features/editor/nodes/EntryNode.tsx create mode 100644 src/frontend/src/features/editor/nodes/ForNode.tsx create mode 100644 src/frontend/src/features/editor/nodes/IfNode.tsx create mode 100644 src/frontend/src/features/editor/utils/executionOrder.ts diff --git a/src/frontend/src/features/editor/nodes/EntryNode.tsx b/src/frontend/src/features/editor/nodes/EntryNode.tsx new file mode 100644 index 0000000..1455038 --- /dev/null +++ b/src/frontend/src/features/editor/nodes/EntryNode.tsx @@ -0,0 +1,42 @@ +// Copyright (c) 2026 Qubernetic (Biró, Csaba Attila) +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { Handle, Position } from "@xyflow/react"; +import type { NodeOnlineData } from "../../../api/types"; +import { getPortColor } from "../utils/portColors"; + +interface EntryNodeData { + label?: string; + typePath?: string; + executionOrder?: number; + onlineData?: NodeOnlineData; + [key: string]: unknown; +} + +export function EntryNode({ data }: { data: EntryNodeData }) { + const execState = data.onlineData?.executionState ?? "idle"; + return ( +
+
+
+ {data.label ?? "Execution Entry"} + {data.typePath ?? "PRG · MAIN"} +
+ {data.executionOrder != null && ( + #{data.executionOrder} + )} +
+
+
+ ENO + +
+
+
+ ); +} diff --git a/src/frontend/src/features/editor/nodes/ForNode.tsx b/src/frontend/src/features/editor/nodes/ForNode.tsx new file mode 100644 index 0000000..87ba877 --- /dev/null +++ b/src/frontend/src/features/editor/nodes/ForNode.tsx @@ -0,0 +1,79 @@ +// Copyright (c) 2026 Qubernetic (Biró, Csaba Attila) +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { Handle, Position } from "@xyflow/react"; +import type { NodeOnlineData } from "../../../api/types"; +import { getPortColor } from "../utils/portColors"; + +interface ForNodeData { + label?: string; + executionOrder?: number; + onlineData?: NodeOnlineData; + [key: string]: unknown; +} + +function findVar(data: NodeOnlineData | undefined, suffix: string) { + return data?.variables.find((v) => v.path.endsWith(suffix)); +} + +function valueClass(dataType: string, value: unknown): string { + const base = `ff-port-value ff-port-value-${dataType.toLowerCase()}`; + if (dataType === "BOOL" && value === false) return `${base} ff-port-value-false`; + return base; +} + +export function ForNode({ data }: { data: ForNodeData }) { + const execState = data.onlineData?.executionState ?? "idle"; + const iVar = findVar(data.onlineData, ".i"); + const fromVar = findVar(data.onlineData, ".FROM"); + const toVar = findVar(data.onlineData, ".TO"); + + return ( +
+
+
+ {data.label ?? "FOR"} + CTRL · FOR +
+ {data.executionOrder != null && ( + #{data.executionOrder} + )} +
+
+
+
+ + EN +
+
+ ENO + +
+
+
+
+ + FROM + {fromVar && {String(fromVar.value)}} +
+
+ DO + +
+
+
+
+ + TO + {toVar && {String(toVar.value)}} +
+
+ {iVar && {String(iVar.value)}} + i + +
+
+
+
+ ); +} diff --git a/src/frontend/src/features/editor/nodes/IfNode.tsx b/src/frontend/src/features/editor/nodes/IfNode.tsx new file mode 100644 index 0000000..505a0dc --- /dev/null +++ b/src/frontend/src/features/editor/nodes/IfNode.tsx @@ -0,0 +1,65 @@ +// Copyright (c) 2026 Qubernetic (Biró, Csaba Attila) +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { Handle, Position } from "@xyflow/react"; +import type { NodeOnlineData } from "../../../api/types"; +import { getPortColor } from "../utils/portColors"; + +interface IfNodeData { + label?: string; + executionOrder?: number; + onlineData?: NodeOnlineData; + [key: string]: unknown; +} + +function findVar(data: NodeOnlineData | undefined, suffix: string) { + return data?.variables.find((v) => v.path.endsWith(suffix)); +} + +function valueClass(dataType: string, value: unknown): string { + const base = `ff-port-value ff-port-value-${dataType.toLowerCase()}`; + if (dataType === "BOOL" && value === false) return `${base} ff-port-value-false`; + return base; +} + +export function IfNode({ data }: { data: IfNodeData }) { + const execState = data.onlineData?.executionState ?? "idle"; + const condVar = findVar(data.onlineData, ".COND"); + + return ( +
+
+
+ {data.label ?? "IF"} + CTRL · IF +
+ {data.executionOrder != null && ( + #{data.executionOrder} + )} +
+
+
+
+ + EN +
+
+ ENO + +
+
+
+
+ + COND + {condVar && {String(condVar.value)}} +
+
+ TRUE + +
+
+
+
+ ); +} diff --git a/src/frontend/src/features/editor/utils/executionOrder.ts b/src/frontend/src/features/editor/utils/executionOrder.ts new file mode 100644 index 0000000..f2e85d8 --- /dev/null +++ b/src/frontend/src/features/editor/utils/executionOrder.ts @@ -0,0 +1,60 @@ +// Copyright (c) 2026 Qubernetic (Biró, Csaba Attila) +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type { FlowNode, FlowConnection } from "../../../api/types"; + +/** + * Walk EXEC connection chains starting from all entry-type nodes. + * Each chain is numbered independently (1-based). + * Also follows branching exec ports (TRUE, DO) with continued numbering. + * Input/Output nodes are not part of the execution chain. + */ +export function computeExecutionOrder( + nodes: FlowNode[], + connections: FlowConnection[], +): Map { + const orderMap = new Map(); + + // Find all entry nodes (PRG entries, method entries) + const entryNodes = nodes.filter((n) => n.type === "entry"); + if (entryNodes.length === 0) return orderMap; + + // Build lookup: "sourceNodeId:portName" → targetNodeId for all exec connections + const execTargets = new Map(); + for (const conn of connections) { + const isExecPort = conn.to.portName === "EN"; + if (isExecPort) { + execTargets.set(`${conn.from.nodeId}:${conn.from.portName}`, conn.to.nodeId); + } + } + + // Walk each chain independently + for (const entry of entryNodes) { + let order = 1; + const queue: string[] = [entry.id]; + + while (queue.length > 0) { + const currentId = queue.shift()!; + if (orderMap.has(currentId)) continue; + + orderMap.set(currentId, order); + order++; + + // Follow ENO (main continuation) + const enoTarget = execTargets.get(`${currentId}:ENO`); + if (enoTarget && !orderMap.has(enoTarget)) { + queue.push(enoTarget); + } + + // Follow branch ports (TRUE, DO, etc.) + for (const port of ["TRUE", "DO"]) { + const branchTarget = execTargets.get(`${currentId}:${port}`); + if (branchTarget && !orderMap.has(branchTarget)) { + queue.push(branchTarget); + } + } + } + } + + return orderMap; +} From 7509e4a31dedeb3c2bb7e1b8a43358626fabbed6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bir=C3=B3=2C=20Csaba=20Attila?= Date: Sun, 15 Feb 2026 23:01:38 +0100 Subject: [PATCH 04/22] feat(frontend): add design tokens and port color utilities Add centralized design tokens (colors, fonts, shadows) with category color/label maps and port color resolver. Update port color utility with EXEC and expanded type support. Co-Authored-By: Claude Opus 4.6 --- .../src/features/editor/utils/designTokens.ts | 96 +++++++++++++++++++ .../src/features/editor/utils/portColors.ts | 10 +- 2 files changed, 102 insertions(+), 4 deletions(-) create mode 100644 src/frontend/src/features/editor/utils/designTokens.ts diff --git a/src/frontend/src/features/editor/utils/designTokens.ts b/src/frontend/src/features/editor/utils/designTokens.ts new file mode 100644 index 0000000..685b4e8 --- /dev/null +++ b/src/frontend/src/features/editor/utils/designTokens.ts @@ -0,0 +1,96 @@ +// Copyright (c) 2026 Qubernetic (Biró, Csaba Attila) +// SPDX-License-Identifier: AGPL-3.0-or-later + +export const T = { + canvasBg: "#1a1a1a", + gridLine: "#222", + panelBg: "#252525", + toolbarBg: "#333333", + nodeBg: "#262626", + nodeBorder: "#333", + panelBorder: "#444", + inputBg: "#171717", + inputBorder: "#404040", + textPrimary: "#e0e0e0", + textSecondary: "#999", + textLabel: "#a3a3a3", + textMuted: "#666", + accentBlue: "#3b82f6", + accentGreen: "#22c55e", + accentRed: "#ef4444", + catPrg: "#C0392B", + catIO: "#3C9D7C", + catFB: "#4A90C9", + catFun: "#B05DBF", + catMethod: "#D4883A", + catProperty: "#C89B5B", + catInterface: "#2BABB4", + catCtrl: "#D4883A", + catAction: "#D4A83A", + catTransition: "#D45A5A", + portBool: "#84cc16", + portInt: "#60a5fa", + portTime: "#2dd4bf", + portReal: "#f97316", + fontUI: "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif", + fontMono: "'JetBrains Mono', 'Fira Code', monospace", + nodeRadius: 4, + nodeShadow: "0 4px 15px rgba(0,0,0,0.5)", + selectedGlow: "0 0 0 1px #3b82f6, 0 4px 15px rgba(0,0,0,0.5)", + activeGlow: "0 0 4px rgba(255,255,255,0.5), 0 0 8px rgba(255,255,255,0.25), 0 4px 15px rgba(0,0,0,0.5)", + activeSelectedGlow: "0 0 0 1px #3b82f6, 0 0 4px rgba(255,255,255,0.5), 0 0 8px rgba(255,255,255,0.25), 0 4px 15px rgba(0,0,0,0.5)", +} as const; + +const CATEGORY_COLORS: Record = { + entry: T.catPrg, + input: T.catIO, + output: T.catIO, + timer: T.catFB, + counter: T.catFB, + comparison: T.catFun, + if: T.catCtrl, + for: T.catCtrl, + methodCall: T.catMethod, + methodEntry: T.catPrg, + method: T.catMethod, + property: T.catProperty, + interface: T.catInterface, + action: T.catAction, + transition: T.catTransition, +}; + +const CATEGORY_LABELS: Record = { + entry: "PRG", + input: "I/O INPUT", + output: "I/O OUTPUT", + timer: "FB", + counter: "FB", + comparison: "FUN", + if: "CTRL", + for: "CTRL", + methodCall: "METHOD", + methodEntry: "METHOD", + method: "METHOD", + property: "PROPERTY", + interface: "INTERFACE", + action: "ACTION", + transition: "TRANSITION", +}; + +export function categoryColor(nodeType: string): string { + return CATEGORY_COLORS[nodeType] ?? T.textMuted; +} + +export function categoryLabel(nodeType: string): string { + return CATEGORY_LABELS[nodeType] ?? nodeType.toUpperCase(); +} + +export function portColor(dataType: string): string { + switch (dataType.toUpperCase()) { + case "BOOL": return T.portBool; + case "INT": return T.portInt; + case "TIME": return T.portTime; + case "REAL": return T.portReal; + default: return T.textMuted; + } +} diff --git a/src/frontend/src/features/editor/utils/portColors.ts b/src/frontend/src/features/editor/utils/portColors.ts index b11177c..db949fe 100644 --- a/src/frontend/src/features/editor/utils/portColors.ts +++ b/src/frontend/src/features/editor/utils/portColors.ts @@ -2,12 +2,14 @@ // SPDX-License-Identifier: AGPL-3.0-or-later const PORT_COLORS: Record = { - BOOL: "#4ec970", - INT: "#5b8def", - TIME: "#4ac1cc", + BOOL: "#84cc16", + INT: "#60a5fa", + TIME: "#2dd4bf", + REAL: "#f97316", + EXEC: "#999", }; -const DEFAULT_COLOR = "#8b95a3"; +const DEFAULT_COLOR = "#666"; export function getPortColor(dataType: string): string { return PORT_COLORS[dataType.toUpperCase()] ?? DEFAULT_COLOR; From 58fadc2ebe1a3f8d36e8d3a1212cc2e47b3b2d77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bir=C3=B3=2C=20Csaba=20Attila?= Date: Sun, 15 Feb 2026 23:01:49 +0100 Subject: [PATCH 05/22] feat(frontend): add MethodCall and MethodEntry node types MethodCallNode represents calling a method with VAR_INPUT params (Cycles, Temp) and RET return value. MethodEntryNode is the method body entry point with parameter outputs for downstream processing. Add variable mappings for both types. Co-Authored-By: Claude Opus 4.6 --- .../features/editor/nodes/MethodCallNode.tsx | 77 +++++++++++++++++++ .../features/editor/nodes/MethodEntryNode.tsx | 69 +++++++++++++++++ .../editor/utils/nodeVariableMapping.ts | 5 ++ 3 files changed, 151 insertions(+) create mode 100644 src/frontend/src/features/editor/nodes/MethodCallNode.tsx create mode 100644 src/frontend/src/features/editor/nodes/MethodEntryNode.tsx diff --git a/src/frontend/src/features/editor/nodes/MethodCallNode.tsx b/src/frontend/src/features/editor/nodes/MethodCallNode.tsx new file mode 100644 index 0000000..946d626 --- /dev/null +++ b/src/frontend/src/features/editor/nodes/MethodCallNode.tsx @@ -0,0 +1,77 @@ +// Copyright (c) 2026 Qubernetic (Biró, Csaba Attila) +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { Handle, Position } from "@xyflow/react"; +import type { NodeOnlineData } from "../../../api/types"; +import { getPortColor } from "../utils/portColors"; + +interface MethodCallNodeData { + label?: string; + executionOrder?: number; + onlineData?: NodeOnlineData; + [key: string]: unknown; +} + +function findVar(data: NodeOnlineData | undefined, suffix: string) { + return data?.variables.find((v) => v.path.endsWith(suffix)); +} + +function valueClass(dataType: string, value: unknown): string { + const base = `ff-port-value ff-port-value-${dataType.toLowerCase()}`; + if (dataType === "BOOL" && value === false) return `${base} ff-port-value-false`; + return base; +} + +export function MethodCallNode({ data }: { data: MethodCallNodeData }) { + const execState = data.onlineData?.executionState ?? "idle"; + const cyclesVar = findVar(data.onlineData, ".Cycles"); + const tempVar = findVar(data.onlineData, ".Temp"); + const retVar = findVar(data.onlineData, ".RET"); + const label = data.label ?? "MethodCall"; + + return ( +
+
+
+ {label} + METHOD · MAIN.{label} +
+ {data.executionOrder != null && ( + #{data.executionOrder} + )} +
+
+
+
+ + EN +
+
+ ENO + +
+
+
+
+ + Cycles + {cyclesVar && {String(cyclesVar.value)}} +
+
+ {retVar && {String(retVar.value)}} + RET + +
+
+
+
+ + Temp + {tempVar && {String(tempVar.value)}} +
+
+
+
+
+ ); +} diff --git a/src/frontend/src/features/editor/nodes/MethodEntryNode.tsx b/src/frontend/src/features/editor/nodes/MethodEntryNode.tsx new file mode 100644 index 0000000..ceaf598 --- /dev/null +++ b/src/frontend/src/features/editor/nodes/MethodEntryNode.tsx @@ -0,0 +1,69 @@ +// Copyright (c) 2026 Qubernetic (Biró, Csaba Attila) +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { Handle, Position } from "@xyflow/react"; +import type { NodeOnlineData } from "../../../api/types"; +import { getPortColor } from "../utils/portColors"; + +interface MethodEntryNodeData { + label?: string; + executionOrder?: number; + onlineData?: NodeOnlineData; + [key: string]: unknown; +} + +function findVar(data: NodeOnlineData | undefined, suffix: string) { + return data?.variables.find((v) => v.path.endsWith(suffix)); +} + +function valueClass(dataType: string, value: unknown): string { + const base = `ff-port-value ff-port-value-${dataType.toLowerCase()}`; + if (dataType === "BOOL" && value === false) return `${base} ff-port-value-false`; + return base; +} + +export function MethodEntryNode({ data }: { data: MethodEntryNodeData }) { + const execState = data.onlineData?.executionState ?? "idle"; + const cyclesVar = findVar(data.onlineData, ".Cycles"); + const tempVar = findVar(data.onlineData, ".Temp"); + const label = data.label ?? "MethodEntry"; + + return ( +
+
+
+ {label} + METHOD · {label} +
+ {data.executionOrder != null && ( + #{data.executionOrder} + )} +
+
+
+
+
+ ENO + +
+
+
+
+
+ {cyclesVar && {String(cyclesVar.value)}} + Cycles + +
+
+
+
+
+ {tempVar && {String(tempVar.value)}} + Temp + +
+
+
+
+ ); +} diff --git a/src/frontend/src/features/editor/utils/nodeVariableMapping.ts b/src/frontend/src/features/editor/utils/nodeVariableMapping.ts index 627d279..a97e0bd 100644 --- a/src/frontend/src/features/editor/utils/nodeVariableMapping.ts +++ b/src/frontend/src/features/editor/utils/nodeVariableMapping.ts @@ -8,11 +8,16 @@ import type { FlowNode } from "../../../api/types"; * Must match the build server's code generation naming conventions. */ const nodePortMap: Record = { + entry: [], input: ["OUT"], output: ["IN"], timer: ["IN", "PT", "Q", "ET"], counter: ["CU", "RESET", "PV", "Q", "CV"], comparison: ["A", "B", "OUT"], + if: ["COND"], + for: ["FROM", "TO", "i"], + methodCall: ["Cycles", "Temp", "RET"], + methodEntry: ["Cycles", "Temp"], }; /** From 3216ae1a777037a4c1ae3d1333501d38dd1531d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bir=C3=B3=2C=20Csaba=20Attila?= Date: Sun, 15 Feb 2026 23:01:58 +0100 Subject: [PATCH 06/22] feat(frontend): add visual group node with drag and resize Draggable comment-box style group node for visual organization. Header acts as drag handle, bottom-right corner for resize. Body area blocks node drag and selection propagation. Co-Authored-By: Claude Opus 4.6 --- .../src/features/editor/nodes/GroupNode.tsx | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 src/frontend/src/features/editor/nodes/GroupNode.tsx diff --git a/src/frontend/src/features/editor/nodes/GroupNode.tsx b/src/frontend/src/features/editor/nodes/GroupNode.tsx new file mode 100644 index 0000000..da84a9a --- /dev/null +++ b/src/frontend/src/features/editor/nodes/GroupNode.tsx @@ -0,0 +1,128 @@ +// Copyright (c) 2026 Qubernetic (Biró, Csaba Attila) +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { useCallback, useRef } from "react"; + +interface GroupNodeData { + label?: string; + color?: string; + width?: number; + height?: number; + onResize?: (id: string, width: number, height: number) => void; + nodeId?: string; + [key: string]: unknown; +} + +export function GroupNode({ data }: { data: GroupNodeData }) { + const color = data.color ?? "#666"; + const width = data.width ?? 400; + const height = data.height ?? 300; + const ref = useRef(null); + + const onResizeStart = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + const startX = e.clientX; + const startY = e.clientY; + const startW = width; + const startH = height; + + const onMove = (ev: MouseEvent) => { + const newW = Math.max(120, startW + (ev.clientX - startX)); + const newH = Math.max(80, startH + (ev.clientY - startY)); + if (ref.current) { + ref.current.style.width = `${newW}px`; + ref.current.style.height = `${newH}px`; + } + }; + + const onUp = (ev: MouseEvent) => { + window.removeEventListener("mousemove", onMove); + window.removeEventListener("mouseup", onUp); + const finalW = Math.max(120, startW + (ev.clientX - startX)); + const finalH = Math.max(80, startH + (ev.clientY - startY)); + data.onResize?.(data.nodeId ?? "", finalW, finalH); + }; + + window.addEventListener("mousemove", onMove); + window.addEventListener("mouseup", onUp); + }, + [width, height, data], + ); + + return ( +
+ {/* Header — drag handle */} +
+ + {data.label} + +
+ + {/* Body — blocks node drag and selection */} +
e.stopPropagation()} + onPointerDown={(e) => e.stopPropagation()} + style={{ + position: "absolute", + top: 30, + left: 0, + right: 0, + bottom: 0, + pointerEvents: "all", + }} + /> + + {/* Resize handle bottom-right */} +
+ + + + +
+
+ ); +} From a161756c49ad6b0e3b7080c757b540ac4476eba8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bir=C3=B3=2C=20Csaba=20Attila?= Date: Sun, 15 Feb 2026 23:02:07 +0100 Subject: [PATCH 07/22] feat(frontend): register new nodes and update editor components Register all new node types in nodeRegistry. Update FlowCanvas with group node support, port data types, and execution state on edges. Update NodeInspector with method node ports and labels. Add method items to NodePalette. Pass sourceExecState to edges so EXEC wires reflect execution state. Co-Authored-By: Claude Opus 4.6 --- .../features/editor/components/FlowCanvas.tsx | 125 +++++++- .../editor/components/NodeInspector.tsx | 297 +++++++++++++++++- .../editor/components/NodePalette.tsx | 130 +++++++- .../features/editor/components/OnlineEdge.tsx | 9 +- .../src/features/editor/nodes/nodeRegistry.ts | 12 + 5 files changed, 553 insertions(+), 20 deletions(-) diff --git a/src/frontend/src/features/editor/components/FlowCanvas.tsx b/src/frontend/src/features/editor/components/FlowCanvas.tsx index dbcb742..89ecd45 100644 --- a/src/frontend/src/features/editor/components/FlowCanvas.tsx +++ b/src/frontend/src/features/editor/components/FlowCanvas.tsx @@ -12,6 +12,7 @@ import { useEdgesState, addEdge, type OnConnect, + type OnSelectionChangeFunc, type Node, type Edge, } from "@xyflow/react"; @@ -26,29 +27,49 @@ import type { NodeOnlineData, } from "../../../api/types"; import { getVariablePathsForNode } from "../utils/nodeVariableMapping"; +import { computeExecutionOrder } from "../utils/executionOrder"; const PORT_DATA_TYPES: Record> = { + entry: { ENO: "EXEC" }, input: { OUT: "BOOL" }, output: { IN: "BOOL" }, - timer: { IN: "BOOL", PT: "TIME", Q: "BOOL", ET: "TIME" }, - counter: { CU: "BOOL", RESET: "BOOL", PV: "INT", Q: "BOOL", CV: "INT" }, - comparison: { A: "INT", B: "INT", OUT: "BOOL" }, + timer: { EN: "EXEC", ENO: "EXEC", IN: "BOOL", PT: "TIME", Q: "BOOL", ET: "TIME" }, + counter: { EN: "EXEC", ENO: "EXEC", CU: "BOOL", RESET: "BOOL", PV: "INT", Q: "BOOL", CV: "INT" }, + comparison: { EN: "EXEC", ENO: "EXEC", A: "INT", B: "INT", OUT: "BOOL" }, + if: { EN: "EXEC", ENO: "EXEC", COND: "BOOL", TRUE: "EXEC" }, + for: { EN: "EXEC", ENO: "EXEC", DO: "EXEC", FROM: "INT", TO: "INT", i: "INT" }, + methodCall: { EN: "EXEC", ENO: "EXEC", Cycles: "INT", Temp: "INT", RET: "BOOL" }, + methodEntry: { ENO: "EXEC", Cycles: "INT", Temp: "INT" }, }; +export interface FlowGroup { + id: string; + label: string; + position: { x: number; y: number }; + width: number; + height: number; + color: string; +} + interface FlowCanvasProps { flowNodes: FlowNode[]; flowConnections: FlowConnection[]; + groups?: FlowGroup[]; isOnline: boolean; nodeStates: Map; variableValues: Map; + onNodeSelect?: (nodeId: string | null) => void; } function toReactFlowNodes( flowNodes: FlowNode[], + flowConnections: FlowConnection[], isOnline: boolean, nodeStates: Map, variableValues: Map, ): Node[] { + const execOrder = computeExecutionOrder(flowNodes, flowConnections); + return flowNodes.map((fn) => { let onlineData: NodeOnlineData | undefined; if (isOnline) { @@ -70,6 +91,7 @@ function toReactFlowNodes( data: { ...fn.parameters, label: (fn.parameters.label as string) ?? fn.type, + executionOrder: execOrder.get(fn.id), onlineData, }, }; @@ -91,12 +113,14 @@ function toReactFlowEdges( isOnline: boolean, variableValues: Map, flowNodes: FlowNode[], + nodeStates: Map, ): Edge[] { return connections.map((conn) => { const id = `e-${conn.from.nodeId}-${conn.from.portName}-${conn.to.nodeId}-${conn.to.portName}`; const sourcePath = `MAIN.${conn.from.nodeId}.${conn.from.portName}`; const value = variableValues.get(sourcePath)?.value; const portType = resolvePortType(conn.from.nodeId, conn.from.portName, flowNodes); + const sourceExecState = nodeStates.get(conn.from.nodeId) ?? "idle"; return { id, @@ -105,45 +129,117 @@ function toReactFlowEdges( target: conn.to.nodeId, targetHandle: conn.to.portName, type: "online", - data: { isOnline, value: isOnline ? value : undefined, portType }, + data: { isOnline, value: isOnline ? value : undefined, portType, sourceExecState }, }; }); } +function toGroupReactFlowNodes( + groups: FlowGroup[], + onResize: (id: string, w: number, h: number) => void, +): Node[] { + return groups.map((g) => ({ + id: g.id, + type: "flowGroup", + position: g.position, + data: { label: g.label, color: g.color, width: g.width, height: g.height, nodeId: g.id, onResize }, + dragHandle: ".ff-group-header", + selectable: false, + focusable: false, + zIndex: -1, + })); +} + export function FlowCanvas({ flowNodes, flowConnections, + groups = [], isOnline, nodeStates, variableValues, + onNodeSelect, }: FlowCanvasProps) { - const initialNodes = useMemo( - () => toReactFlowNodes(flowNodes, isOnline, nodeStates, variableValues), - [flowNodes, isOnline, nodeStates, variableValues], + const handleGroupResize = useCallback( + (id: string, width: number, height: number) => { + setNodes((nds) => + nds.map((n) => + n.id === id ? { ...n, data: { ...n.data, width, height } } : n, + ), + ); + }, + [], ); + const initialNodes = useMemo(() => { + const flowRfNodes = toReactFlowNodes(flowNodes, flowConnections, isOnline, nodeStates, variableValues); + const groupRfNodes = toGroupReactFlowNodes(groups, handleGroupResize); + return [...groupRfNodes, ...flowRfNodes]; + }, [flowNodes, flowConnections, isOnline, nodeStates, variableValues, groups, handleGroupResize]); + const initialEdges = useMemo( - () => toReactFlowEdges(flowConnections, isOnline, variableValues, flowNodes), - [flowConnections, isOnline, variableValues], + () => toReactFlowEdges(flowConnections, isOnline, variableValues, flowNodes, nodeStates), + [flowConnections, isOnline, variableValues, nodeStates], ); const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); - // Sync nodes/edges when online data changes + // Sync node data when online values change, but preserve user-dragged positions and selection useEffect(() => { - setNodes(toReactFlowNodes(flowNodes, isOnline, nodeStates, variableValues)); + const freshFlowNodes = toReactFlowNodes(flowNodes, flowConnections, isOnline, nodeStates, variableValues); + setNodes((currentNodes) => { + // Keep group nodes as-is, update flow nodes + const groupNodes = currentNodes.filter((n) => n.type === "flowGroup"); + const currentFlowNodes = currentNodes.filter((n) => n.type !== "flowGroup"); + if (currentFlowNodes.length === 0) return [...groupNodes, ...freshFlowNodes]; + const stateMap = new Map(currentFlowNodes.map((n) => [n.id, n])); + const updatedFlow = freshFlowNodes.map((n) => { + const prev = stateMap.get(n.id); + return { + ...n, + position: prev?.position ?? n.position, + selected: prev?.selected, + }; + }); + return [...groupNodes, ...updatedFlow]; + }); }, [flowNodes, isOnline, nodeStates, variableValues, setNodes]); useEffect(() => { - setEdges(toReactFlowEdges(flowConnections, isOnline, variableValues, flowNodes)); - }, [flowConnections, isOnline, variableValues, setEdges]); + setEdges(toReactFlowEdges(flowConnections, isOnline, variableValues, flowNodes, nodeStates)); + }, [flowConnections, isOnline, variableValues, nodeStates, setEdges]); const onConnect: OnConnect = useCallback( (params) => setEdges((eds) => addEdge(params, eds)), [setEdges], ); + // DEV helper: press Ctrl+Shift+P to dump current node positions to console + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.ctrlKey && e.shiftKey && e.key === "P") { + e.preventDefault(); + const positions = nodes.filter((n) => n.type !== "flowGroup").map((n) => ({ + id: n.id, + x: Math.round(n.position.x), + y: Math.round(n.position.y), + })); + console.log("Node positions:", JSON.stringify(positions, null, 2)); + } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [nodes]); + + const onSelectionChange: OnSelectionChangeFunc = useCallback( + ({ nodes: selectedNodes }) => { + if (onNodeSelect) { + onNodeSelect(selectedNodes.length === 1 ? selectedNodes[0].id : null); + } + }, + [onNodeSelect], + ); + return (
- +
); diff --git a/src/frontend/src/features/editor/components/NodeInspector.tsx b/src/frontend/src/features/editor/components/NodeInspector.tsx index 784ffd5..fc81ec2 100644 --- a/src/frontend/src/features/editor/components/NodeInspector.tsx +++ b/src/frontend/src/features/editor/components/NodeInspector.tsx @@ -1,6 +1,299 @@ // Copyright (c) 2026 Qubernetic (Biró, Csaba Attila) // SPDX-License-Identifier: AGPL-3.0-or-later -export function NodeInspector() { - return
NodeInspector
; +import type { + FlowNode, + FlowConnection, + NodeExecutionState, + PlcVariableValue, +} from "../../../api/types"; +import { categoryColor, categoryLabel, portColor } from "../utils/designTokens"; +import { getPortColor } from "../utils/portColors"; + +interface NodeInspectorProps { + isOnline: boolean; + selectedNode: FlowNode | null; + nodeStates: Map; + variableValues: Map; + connections: FlowConnection[]; + executionOrder?: Map; + totalExecNodes?: number; + style?: React.CSSProperties; +} + +const PORT_DATA_TYPES: Record> = { + entry: {}, + input: { OUT: "BOOL" }, + output: { IN: "BOOL" }, + timer: { IN: "BOOL", PT: "TIME", Q: "BOOL", ET: "TIME" }, + counter: { CU: "BOOL", RESET: "BOOL", PV: "INT", Q: "BOOL", CV: "INT" }, + comparison: { A: "INT", B: "INT", OUT: "BOOL" }, + if: { COND: "BOOL" }, + for: { FROM: "INT", TO: "INT", i: "INT" }, + methodCall: { Cycles: "INT", Temp: "INT", RET: "BOOL" }, + methodEntry: { Cycles: "INT", Temp: "INT" }, +}; + +const NODE_TYPE_LABELS: Record = { + entry: "PRG (Program)", + input: "I/O Input Mapping", + output: "I/O Output Mapping", + timer: "FB — TON (On-Delay Timer)", + counter: "FB — CTU (Up Counter)", + comparison: "FUN — GE (Compare >=)", + if: "CTRL — IF (Conditional)", + for: "CTRL — FOR (Loop)", + methodCall: "METHOD — CleanupCycle", + methodEntry: "METHOD — CleanupCycle (Body)", +}; + +const EXEC_NODE_TYPES = new Set(["entry", "timer", "counter", "comparison", "if", "for", "methodCall", "methodEntry"]); + +function ValuePill({ value, type }: { value: string; type: string }) { + const isBoolFalse = type === "BOOL" && value === "FALSE"; + const color = isBoolFalse ? "#666" : portColor(type); + const bg = isBoolFalse + ? "rgba(100,100,100,0.1)" + : `${getPortColor(type)}1F`; + + return ( + + {value} + + ); +} + +export function NodeInspector({ + isOnline, + selectedNode, + nodeStates, + variableValues, + connections, + executionOrder, + totalExecNodes, + style, +}: NodeInspectorProps) { + if (!selectedNode) { + return ( + + ); + } + + const node = selectedNode; + const label = (node.parameters.label as string) ?? node.type; + const catColor = categoryColor(node.type); + const catLabel = categoryLabel(node.type); + const typeLabel = NODE_TYPE_LABELS[node.type] ?? node.type; + const ports = PORT_DATA_TYPES[node.type] ?? {}; + const inputPorts = Object.entries(ports).filter(([, dt]) => + node.type === "input" ? false : true + ).filter(([name]) => { + if (node.type === "input") return false; + if (node.type === "output") return name === "IN"; + if (node.type === "timer") return name === "IN" || name === "PT"; + if (node.type === "counter") return name === "CU" || name === "RESET" || name === "PV"; + if (node.type === "comparison") return name === "A" || name === "B"; + if (node.type === "methodCall") return name === "Cycles" || name === "Temp"; + if (node.type === "methodEntry") return false; + return false; + }); + const outputPorts = Object.entries(ports).filter(([name]) => { + if (node.type === "input") return name === "OUT"; + if (node.type === "output") return false; + if (node.type === "timer") return name === "Q" || name === "ET"; + if (node.type === "counter") return name === "Q" || name === "CV"; + if (node.type === "comparison") return name === "OUT"; + if (node.type === "methodCall") return name === "RET"; + if (node.type === "methodEntry") return name === "Cycles" || name === "Temp"; + return false; + }); + + function findConnection(portName: string, direction: "input" | "output") { + if (direction === "input") { + return connections.find( + (c) => c.to.nodeId === node.id && c.to.portName === portName, + ); + } + return connections.find( + (c) => c.from.nodeId === node.id && c.from.portName === portName, + ); + } + + return ( +