diff --git a/package-lock.json b/package-lock.json index 4683eb4..9d9d29b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@fortawesome/fontawesome-free": "^6.5.2", "@projectstorm/react-canvas-core": "^7.0.3", "@projectstorm/react-diagrams": "^7.0.4", - "@reduxjs/toolkit": "^2.2.3", + "@reduxjs/toolkit": "^2.5.0", "dompurify": "^3.1.0", "easymde": "^2.18.0", "i18next": "^23.11.2", @@ -3527,17 +3527,17 @@ "integrity": "sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA==" }, "node_modules/@reduxjs/toolkit": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.2.3.tgz", - "integrity": "sha512-76dll9EnJXg4EVcI5YNxZA/9hSAmZsFqzMmNRHvIlzw2WS/twfcVX3ysYrWGJMClwEmChQFC4yRq74tn6fdzRA==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.5.0.tgz", + "integrity": "sha512-awNe2oTodsZ6LmRqmkFhtb/KH03hUhxOamEQy411m3Njj3BbFvoBovxo4Q1cBWnV1ErprVj9MlF0UPXkng0eyg==", "dependencies": { "immer": "^10.0.3", "redux": "^5.0.1", "redux-thunk": "^3.1.0", - "reselect": "^5.0.1" + "reselect": "^5.1.0" }, "peerDependencies": { - "react": "^16.9.0 || ^17.0.0 || ^18", + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "peerDependenciesMeta": { diff --git a/package.json b/package.json index b6688a0..1b24423 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "@fortawesome/fontawesome-free": "^6.5.2", "@projectstorm/react-canvas-core": "^7.0.3", "@projectstorm/react-diagrams": "^7.0.4", - "@reduxjs/toolkit": "^2.2.3", + "@reduxjs/toolkit": "^2.5.0", "dompurify": "^3.1.0", "easymde": "^2.18.0", "i18next": "^23.11.2", diff --git a/packages/flow-client/src/app/redux/modules/api/flow.api.ts b/packages/flow-client/src/app/redux/modules/api/flow.api.ts new file mode 100644 index 0000000..0156a25 --- /dev/null +++ b/packages/flow-client/src/app/redux/modules/api/flow.api.ts @@ -0,0 +1,97 @@ +import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'; +import environment from '../../../../environment'; + +// Base type for common properties +export interface NodeRedBase { + id: string; + type: string; + info: string; + env: { name: string; type: string; value: string }[]; +} + +// Type for regular Node-RED flows +export interface NodeRedFlow extends NodeRedBase { + type: 'tab'; + label: string; + disabled: boolean; +} + +// Type for Node-RED subflows +export interface NodeRedSubflow extends NodeRedBase { + type: 'subflow'; + name: string; + category: string; + color: string; + icon: string; + in: NodeRedEndpoint[]; + out: NodeRedEndpoint[]; +} + +// Type for nodes within flows or subflows +export interface NodeRedNode extends NodeRedBase { + name: string; + x: number; + y: number; + z: string; + wires: string[][]; + inputs?: number; + outputs?: number; + inputLabels?: string[]; + outputLabels?: string[]; + icon?: string; +} + +// Type for endpoints used in subflows (inputs and outputs) +export interface NodeRedEndpoint { + x: number; + y: number; + wires: { id: string; port?: number }[]; +} + +// Composite type for all Node-RED objects +export interface NodeRedFlows { + rev?: string; + flows: NodeRedBase[]; +} + +// Define a service using a base URL and expected endpoints for flows +export const flowApi = createApi({ + reducerPath: 'flowApi', + baseQuery: fetchBaseQuery({ + baseUrl: environment.NODE_RED_API_ROOT, + responseHandler: 'content-type', + prepareHeaders: headers => { + headers.set('Node-RED-API-Version', 'v2'); + headers.set('Node-RED-Deployment-Type', 'nodes'); + return headers; + }, + }), + tagTypes: ['Flow'], // For automatic cache invalidation and refetching + endpoints: builder => ({ + // Endpoint to fetch all flows + getFlows: builder.query({ + query: () => ({ + url: 'flows', + headers: { + Accept: 'application/json', + }, + }), + providesTags: ['Flow'], + }), + // Endpoint to update all flows + updateFlows: builder.mutation({ + query: flows => ({ + url: 'flows', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: flows, + }), + invalidatesTags: ['Flow'], + }), + }), +}); + +// Export hooks for usage in components +export const { useGetFlowsQuery, useUpdateFlowsMutation } = flowApi; diff --git a/packages/flow-client/src/app/redux/modules/flow/flow.logic.ts b/packages/flow-client/src/app/redux/modules/flow/flow.logic.ts index c7337fe..e2bdf61 100644 --- a/packages/flow-client/src/app/redux/modules/flow/flow.logic.ts +++ b/packages/flow-client/src/app/redux/modules/flow/flow.logic.ts @@ -12,6 +12,7 @@ import { } from './flow.slice'; import { GraphLogic } from './graph.logic'; import { NodeLogic } from './node.logic'; +import { RedLogic } from './red.logic'; import { TreeLogic } from './tree.logic'; // checks if a given property has changed @@ -37,11 +38,13 @@ export class FlowLogic { public readonly graph: GraphLogic; public readonly node: NodeLogic; public readonly tree: TreeLogic; + public readonly red: RedLogic; constructor() { this.node = new NodeLogic(); this.graph = new GraphLogic(this.node); this.tree = new TreeLogic(); + this.red = new RedLogic(); } public createNewFlow( diff --git a/packages/flow-client/src/app/redux/modules/flow/red.listener.ts b/packages/flow-client/src/app/redux/modules/flow/red.listener.ts new file mode 100644 index 0000000..8f624ee --- /dev/null +++ b/packages/flow-client/src/app/redux/modules/flow/red.listener.ts @@ -0,0 +1,62 @@ +import { createListenerMiddleware, isAnyOf } from '@reduxjs/toolkit'; +import type { AppLogic } from '../../logic'; +import type { AppDispatch, RootState } from '../../store'; +import { flowApi } from '../api/flow.api'; +import { flowActions } from './flow.slice'; + +// Create the Node-RED sync middleware instance +export const createRedListener = (logic: AppLogic) => { + const nodeRedListener = createListenerMiddleware({ + extra: logic, + }); + + return nodeRedListener; +}; + +export const startRedListener = ( + listener: ReturnType +) => { + // Add a listener that responds to any flow state changes to sync with Node-RED + listener.startListening.withTypes()({ + matcher: isAnyOf( + // Flow entity actions + flowActions.addFlowEntity, + flowActions.updateFlowEntity, + flowActions.removeFlowEntity, + flowActions.addFlowEntities, + flowActions.updateFlowEntities, + flowActions.removeFlowEntities, + // Flow node actions + flowActions.addFlowNode, + flowActions.updateFlowNode, + flowActions.removeFlowNode, + flowActions.addFlowNodes, + flowActions.updateFlowNodes, + flowActions.removeFlowNodes + ), + effect: async (action, listenerApi) => { + // debounce pattern + listenerApi.cancelActiveListeners(); + await listenerApi.delay(1000); + + try { + const state = listenerApi.getState(); + const logic = listenerApi.extra; + + // Convert our state to Node-RED format + const nodeRedFlows = logic.flow.red.toNodeRed(state); + + // Update flows in Node-RED + await listenerApi.dispatch( + flowApi.endpoints.updateFlows.initiate(nodeRedFlows) + ); + } catch (error) { + // Log any errors but don't crash the app + console.error('Error updating Node-RED flows:', error); + + // Could also dispatch an error action if needed: + // listenerApi.dispatch(flowActions.setError('Failed to update Node-RED flows')); + } + }, + }); +}; diff --git a/packages/flow-client/src/app/redux/modules/flow/red.logic.ts b/packages/flow-client/src/app/redux/modules/flow/red.logic.ts new file mode 100644 index 0000000..56ba88a --- /dev/null +++ b/packages/flow-client/src/app/redux/modules/flow/red.logic.ts @@ -0,0 +1,231 @@ +import { RootState } from '../../store'; +import { + NodeRedBase, + NodeRedFlow, + NodeRedFlows, + NodeRedNode, + NodeRedSubflow, +} from '../api/flow.api'; +import { + FlowEntity, + FlowNodeEntity, + SubflowEntity, + selectAllFlowEntities, + selectFlowNodesByFlowId, +} from './flow.slice'; + +// Define a more specific type for flowMeta +interface FlowMeta { + id: string; + extraData: Record; + nodes: Array<{ id: string; extraData: Record }>; +} + +interface NodeMeta { + id: string; + extraData: Record; +} + +export class RedLogic { + private convertNodeToNodeRed(node: FlowNodeEntity): NodeRedNode { + // Convert our node format to Node-RED format + const nodeRedNode: NodeRedNode = { + id: node.id, + type: node.type, + name: node.name, + x: node.x, + y: node.y, + z: node.z, + wires: node.wires ?? [], + inputs: node.inputs, + outputs: node.outputs, + inputLabels: node.inputLabels, + outputLabels: node.outputLabels, + icon: node.icon, + info: node.info ?? '', + env: [], + }; + + return nodeRedNode; + } + + private convertFlowToNodeRed( + flow: FlowEntity | SubflowEntity + ): NodeRedBase { + const baseFlow = { + id: flow.id, + type: 'tab', + label: flow.name, + disabled: false, + info: flow.info, + }; + + if (flow.type === 'subflow') { + return { + ...baseFlow, + name: flow.name, + category: flow.category, + color: flow.color, + icon: flow.icon, + in: + flow.in?.map(endpoint => ({ + id: endpoint, + x: 0, + y: 0, + wires: [], + })) ?? [], + out: + flow.out?.map(endpoint => ({ + id: endpoint, + x: 0, + y: 0, + wires: [], + })) ?? [], + env: flow.env, + meta: {}, + } as NodeRedSubflow; + } + + return { + ...baseFlow, + env: flow.env, + }; + } + + private createMetadataFlow(state: RootState): NodeRedFlow { + const flows = selectAllFlowEntities(state); + const metadata = flows.map(flow => ({ + id: flow.id, + extraData: flow, + nodes: selectFlowNodesByFlowId(state, flow.id).map(node => ({ + id: node.id, + extraData: node, + })), + })); + return { + id: 'aed83478cb340859', + type: 'tab', + label: 'FLOW-CLIENT:::METADATA::ROOT', + disabled: true, + info: JSON.stringify(metadata), + env: [], + }; + } + + public toNodeRed(state: RootState): NodeRedFlows { + // Get all flows and their nodes + const flows = selectAllFlowEntities(state); + + // Convert each flow and its nodes to Node-RED format + const nodeRedFlows = flows + .map(flow => { + const flowNodes = selectFlowNodesByFlowId(state, flow.id); + + return [ + this.convertFlowToNodeRed(flow), + ...flowNodes.map(node => this.convertNodeToNodeRed(node)), + ]; + }) + .flat() + .concat(this.createMetadataFlow(state)); + + return { + flows: nodeRedFlows, + // TODO: Implement versioning + // rev: 'b03654ee1803134c42f82d4530f0ebf8', + }; + } + + private extractMetadata(nodeRedFlows: NodeRedFlows) { + const metadataFlow = nodeRedFlows.flows.find( + flow => + flow.type === 'tab' && + (flow as NodeRedFlow).label === 'FLOW-CLIENT:::METADATA::ROOT' + ) as NodeRedFlow; + if (!metadataFlow?.info) { + return {}; + } + const metadata = JSON.parse(metadataFlow.info as string); + return metadata.reduce( + (acc: Record, flowMeta: FlowMeta) => { + acc[flowMeta.id] = { + flow: flowMeta.extraData, + nodes: flowMeta.nodes.reduce( + ( + nodeAcc: Record, + nodeMeta: NodeMeta + ) => { + nodeAcc[nodeMeta.id] = nodeMeta.extraData; + return nodeAcc; + }, + {} + ), + }; + return acc; + }, + {} + ); + } + + public fromNodeRed(nodeRedFlows: NodeRedFlows): { + flows: Array; + nodes: FlowNodeEntity[]; + } { + const metadata = this.extractMetadata(nodeRedFlows); + const flows: Array = []; + const nodes: FlowNodeEntity[] = []; + + nodeRedFlows.flows.forEach(nodeRedObj => { + const flowMetadata = metadata[nodeRedObj.id as string]?.flow || {}; + + // Handle subflows + if (nodeRedObj.type === 'subflow') { + const nodeRedFlow = nodeRedObj as NodeRedSubflow; + flows.push({ + ...flowMetadata, + id: nodeRedFlow.id, + type: 'subflow', + name: nodeRedFlow.name || '', + info: nodeRedFlow.info || '', + category: nodeRedFlow.category || '', + color: nodeRedFlow.color || '', + icon: nodeRedFlow.icon || '', + in: nodeRedFlow.in || [], + out: nodeRedFlow.out || [], + env: nodeRedFlow.env || [], + } as SubflowEntity); + } + // Handle regular flows + else if (nodeRedObj.type === 'tab') { + const nodeRedFlow = nodeRedObj as NodeRedFlow; + flows.push({ + ...flowMetadata, + id: nodeRedFlow.id, + type: 'flow', + name: nodeRedFlow.label || '', + disabled: nodeRedFlow.disabled || false, + info: nodeRedFlow.info || '', + env: nodeRedFlow.env || [], + } as FlowEntity); + } else { + const nodeRedNode = nodeRedObj as NodeRedNode; + const nodeMetadata = + metadata[nodeRedNode.id]?.nodes?.[nodeRedNode.id] || {}; + nodes.push({ + ...nodeMetadata, + id: nodeRedNode.id, + type: nodeRedNode.type, + x: nodeRedNode.x, + y: nodeRedNode.y, + z: nodeRedNode.z, + wires: nodeRedNode.wires || [], + } as FlowNodeEntity); + } + }); + + return { + flows, + nodes, + }; + } +} diff --git a/packages/flow-client/src/app/redux/store.ts b/packages/flow-client/src/app/redux/store.ts index cc644bc..f024769 100644 --- a/packages/flow-client/src/app/redux/store.ts +++ b/packages/flow-client/src/app/redux/store.ts @@ -10,8 +10,8 @@ import { REHYDRATE, } from 'redux-persist'; import storage from 'redux-persist/lib/storage'; - import type { AppLogic } from './logic'; +import { flowApi } from './modules/api/flow.api'; import { iconApi } from './modules/api/icon.api'; import { nodeApi } from './modules/api/node.api'; // Import the nodeApi import { @@ -24,14 +24,21 @@ import { flowReducer, FlowState, } from './modules/flow/flow.slice'; +import { + createRedListener, + startRedListener, +} from './modules/flow/red.listener'; import { PALETTE_NODE_FEATURE_KEY, paletteNodeReducer, } from './modules/palette/node.slice'; export const createStore = (logic: AppLogic) => { + const redListener = createRedListener(logic); + const store = configureStore({ reducer: { + [flowApi.reducerPath]: flowApi.reducer, [nodeApi.reducerPath]: nodeApi.reducer, [iconApi.reducerPath]: iconApi.reducer, [PALETTE_NODE_FEATURE_KEY]: paletteNodeReducer, @@ -66,11 +73,17 @@ export const createStore = (logic: AppLogic) => { thunk: { extraArgument: logic, }, - }).concat(nodeApi.middleware, iconApi.middleware), + }).concat( + nodeApi.middleware, + iconApi.middleware, + flowApi.middleware, + redListener.middleware + ), devTools: process.env.NODE_ENV !== 'production', }); setupListeners(store.dispatch); + startRedListener(redListener); return store; };