diff --git a/.vscode/settings.json b/.vscode/settings.json index 2cc3cb1..0d40480 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -21,7 +21,7 @@ "importSorter.sortConfiguration.removeUnusedImports": true, "importSorter.sortConfiguration.removeUnusedDefaultImports": true, "importSorter.importStringConfiguration.tabSize": 2, - "importSorter.importStringConfiguration.maximumNumberOfImportExpressionsPerLine.count": 80, + "importSorter.importStringConfiguration.maximumNumberOfImportExpressionsPerLine.count": 90, "importSorter.importStringConfiguration.maximumNumberOfImportExpressionsPerLine.type": "newLineEachExpressionAfterCountLimit", "importSorter.sortConfiguration.customOrderingRules.defaultNumberOfEmptyLinesAfterGroup": 0, "importSorter.sortConfiguration.customOrderingRules.defaultOrderLevel": 50, diff --git a/TODO.md b/TODO.md index aeae489..cf9095c 100644 --- a/TODO.md +++ b/TODO.md @@ -3,6 +3,7 @@ Small Stuff - build images if they don't exist +- replace sidebar with app header bar - generate alice/bob/carol names for lnd nodes - mock docker for e2e tests - update app icon diff --git a/src/components/designer/CustomNodeInner.tsx b/src/components/designer/CustomNodeInner.tsx new file mode 100644 index 0000000..97a3d08 --- /dev/null +++ b/src/components/designer/CustomNodeInner.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { INodeInnerDefaultProps } from '@mrblenny/react-flow-chart'; +import { StatusBadge } from 'components/common'; + +const CustomNodeInner: React.FC = ({ node }) => { + return ( +
+ + + + +
+ ); +}; + +export default CustomNodeInner; diff --git a/src/components/designer/NetworkDesigner.tsx b/src/components/designer/NetworkDesigner.tsx index 2c6f847..bddd8c4 100644 --- a/src/components/designer/NetworkDesigner.tsx +++ b/src/components/designer/NetworkDesigner.tsx @@ -1,50 +1,70 @@ -import React, { useEffect } from 'react'; -import { FlowChart, INodeInnerDefaultProps } from '@mrblenny/react-flow-chart'; -import { useStoreActions, useStoreState } from 'store'; +import React, { useEffect, useState } from 'react'; +import { FlowChart } from '@mrblenny/react-flow-chart'; +import useDebounce from 'hooks/useDebounce'; +import { useStoreActions } from 'store'; import { Network } from 'types'; -import { StatusBadge } from 'components/common'; +import { initChartFromNetwork } from 'utils/chart'; +import * as actions from './chartActions'; +import CustomNodeInner from './CustomNodeInner'; interface Props { network: Network; } -const NodeInnerCustom = ({ node }: INodeInnerDefaultProps) => { - return ( -
- - - - -
- ); -}; - const NetworkDesigner: React.FC = ({ network }) => { - const { chart } = useStoreState(s => s.designer); - const { initialize, setChart, ...callbacks } = useStoreActions(s => s.designer); + const initialChart = network.design || initChartFromNetwork(network); + const [chart, setChart] = useState(initialChart); + const { setNetworkDesign, save } = useStoreActions(s => s.network); + + // update chart in state when the network status changes useEffect(() => { - if (network.design) { - setChart(network.design); - } else { - initialize(network); - } - }, [network]); - - return ( + setChart(c => { + Object.keys(c.nodes).forEach(n => { + c.nodes[n].properties.status = network.status; + }); + return { ...c }; + }); + }, [network.status]); + + // prevent updating redux state with the new chart on every callback + // which can be many, ex: onDragNode, onLinkMouseEnter + const debouncedChart = useDebounce(chart, 3000); + useEffect(() => { + // store the updated chart in the redux store + setNetworkDesign({ id: network.id, chart: debouncedChart }); + // eslint-disable-next-line + }, [debouncedChart]); // missing deps are intentional + + // save network with chart to disk if this component is unmounted + useEffect(() => { + const saveAsync = async () => await save(); + return () => { + setNetworkDesign({ id: network.id, chart }); + saveAsync(); + }; + // eslint-disable-next-line + }, []); // this effect should only fun the cleanup func once when unmounted + + // wacky code to intercept the callbacks and store the resulting chart in state + const callbacks = Object.entries(actions).reduce( + (allActions: { [key: string]: any }, [key, action]: [string, any]) => { + allActions[key] = (...args: any) => { + // call the action with the args from FlowChart and the current chart object + const newChart = action(...args)(chart); + // need to pass a new object to the hook to trigger a rerender + setChart({ ...newChart }); + }; + return allActions; + }, + {}, + ) as typeof actions; + + return !chart ? null : (
diff --git a/src/components/designer/chartActions.ts b/src/components/designer/chartActions.ts new file mode 100644 index 0000000..a745c4c --- /dev/null +++ b/src/components/designer/chartActions.ts @@ -0,0 +1,240 @@ +import { + IChart, + IOnCanvasClick, + IOnCanvasDrop, + IOnDeleteKey, + IOnDragCanvas, + IOnDragNode, + IOnLinkCancel, + IOnLinkComplete, + IOnLinkMouseEnter, + IOnLinkMouseLeave, + IOnLinkMove, + IOnLinkStart, + IOnNodeClick, + IOnNodeSizeChange, + IOnPortPositionChange, + IPosition, +} from '@mrblenny/react-flow-chart'; + +const v4 = () => new Date().getTime().toString(); +// center = rotation center +// current = current position +// x, y = rotated positions +// angle = angle of rotation +export const rotate = ( + center: IPosition, + current: IPosition, + angle: number, +): IPosition => { + const radians = (Math.PI / 180) * angle; + const cos = Math.cos(radians); + const sin = Math.sin(radians); + const x = cos * (current.x - center.x) + sin * (current.y - center.y) + center.x; + const y = cos * (current.y - center.y) - sin * (current.x - center.x) + center.y; + return { x, y }; +}; + +/** + * This file contains actions for updating state after each of the required callbacks + */ + +export const onDragNode: IOnDragNode = ({ config, data, id }) => (chart: IChart) => { + const nodechart = chart.nodes[id]; + + if (nodechart) { + chart.nodes[id] = { + ...nodechart, + position: + config && config.snapToGrid + ? { x: Math.round(data.x / 20) * 20, y: Math.round(data.y / 20) * 20 } + : data, + }; + } + + return chart; +}; + +export const onDragCanvas: IOnDragCanvas = ({ config, data }) => ( + chart: IChart, +): IChart => { + chart.offset = + config && config.snapToGrid + ? { x: Math.round(data.x / 20) * 20, y: Math.round(data.y / 20) * 20 } + : data; + return chart; +}; + +export const onLinkStart: IOnLinkStart = ({ linkId, fromNodeId, fromPortId }) => ( + chart: IChart, +): IChart => { + chart.links[linkId] = { + id: linkId, + from: { + nodeId: fromNodeId, + portId: fromPortId, + }, + to: {}, + }; + return chart; +}; + +export const onLinkMove: IOnLinkMove = ({ linkId, toPosition }) => ( + chart: IChart, +): IChart => { + const link = chart.links[linkId]; + link.to.position = toPosition; + chart.links[linkId] = { ...link }; + return chart; +}; + +export const onLinkComplete: IOnLinkComplete = props => { + const { linkId, fromNodeId, fromPortId, toNodeId, toPortId, config = {} } = props; + return (chart: IChart): IChart => { + if ( + (config.validateLink ? config.validateLink({ ...props, chart }) : true) && + [fromNodeId, fromPortId].join() !== [toNodeId, toPortId].join() + ) { + chart.links[linkId].to = { + nodeId: toNodeId, + portId: toPortId, + }; + } else { + delete chart.links[linkId]; + } + return chart; + }; +}; + +export const onLinkCancel: IOnLinkCancel = ({ linkId }) => (chart: IChart) => { + delete chart.links[linkId]; + return chart; +}; + +export const onLinkMouseEnter: IOnLinkMouseEnter = ({ linkId }) => (chart: IChart) => { + // Set the link to hover + const link = chart.links[linkId]; + // Set the connected ports to hover + if (link.to.nodeId && link.to.portId) { + if (chart.hovered.type !== 'link' || chart.hovered.id !== linkId) { + chart.hovered = { + type: 'link', + id: linkId, + }; + } + } + return chart; +}; + +export const onLinkMouseLeave: IOnLinkMouseLeave = ({ linkId }) => (chart: IChart) => { + const link = chart.links[linkId]; + // Set the connected ports to hover + if (link.to.nodeId && link.to.portId) { + chart.hovered = {}; + } + return chart; +}; + +export const onLinkClick: IOnLinkMouseLeave = ({ linkId }) => (chart: IChart) => { + if (chart.selected.id !== linkId || chart.selected.type !== 'link') { + chart.selected = { + type: 'link', + id: linkId, + }; + } + return chart; +}; + +export const onCanvasClick: IOnCanvasClick = () => (chart: IChart) => { + if (chart.selected.id) { + chart.selected = {}; + } + return chart; +}; + +export const onDeleteKey: IOnDeleteKey = () => (chart: IChart) => { + if (chart.selected.type === 'node' && chart.selected.id) { + const node = chart.nodes[chart.selected.id]; + // Delete the connected links + Object.keys(chart.links).forEach(linkId => { + const link = chart.links[linkId]; + if (link.from.nodeId === node.id || link.to.nodeId === node.id) { + delete chart.links[link.id]; + } + }); + // Delete the node + delete chart.nodes[chart.selected.id]; + } else if (chart.selected.type === 'link' && chart.selected.id) { + delete chart.links[chart.selected.id]; + } + if (chart.selected) { + chart.selected = {}; + } + return chart; +}; + +export const onNodeClick: IOnNodeClick = ({ nodeId }) => (chart: IChart) => { + if (chart.selected.id !== nodeId || chart.selected.type !== 'node') { + chart.selected = { + type: 'node', + id: nodeId, + }; + } + return chart; +}; + +export const onNodeSizeChange: IOnNodeSizeChange = ({ nodeId, size }) => ( + chart: IChart, +) => { + chart.nodes[nodeId] = { + ...chart.nodes[nodeId], + size, + }; + return chart; +}; + +export const onPortPositionChange: IOnPortPositionChange = ({ + node: nodeToUpdate, + port, + el, + nodesEl, +}) => (chart: IChart): IChart => { + if (nodeToUpdate.size) { + // rotate the port's position based on the node's orientation prop (angle) + const center = { x: nodeToUpdate.size.width / 2, y: nodeToUpdate.size.height / 2 }; + const current = { + x: el.offsetLeft + nodesEl.offsetLeft + el.offsetWidth / 2, + y: el.offsetTop + nodesEl.offsetTop + el.offsetHeight / 2, + }; + const angle = nodeToUpdate.orientation || 0; + const position = rotate(center, current, angle); + + const node = chart.nodes[nodeToUpdate.id]; + node.ports[port.id].position = { + x: position.x, + y: position.y, + }; + + chart.nodes[nodeToUpdate.id] = { ...node }; + } + + return chart; +}; + +export const onCanvasDrop: IOnCanvasDrop = ({ config, data, position }) => ( + chart: IChart, +): IChart => { + const id = v4(); + chart.nodes[id] = { + id, + position: + config && config.snapToGrid + ? { x: Math.round(position.x / 20) * 20, y: Math.round(position.y / 20) * 20 } + : position, + orientation: data.orientation || 0, + type: data.type, + ports: data.ports, + properties: data.properties, + }; + return chart; +}; diff --git a/src/components/network/NetworkView.tsx b/src/components/network/NetworkView.tsx index f7988f1..4023db4 100644 --- a/src/components/network/NetworkView.tsx +++ b/src/components/network/NetworkView.tsx @@ -17,6 +17,10 @@ interface MatchParams { id?: string; } +interface Props { + network: Network; +} + const btcDetails = [ { label: 'Block Height', value: '432' }, { label: 'Wallet Balance', value: '54.00000000' }, @@ -33,23 +37,26 @@ const lndDetails = [ { label: 'Version', value: 'v0.7.1' }, ]; -const NetworkView: React.FC> = ({ match }) => { +const NetworkViewWrap: React.FC> = ({ match }) => { + const { networks } = useStoreState(s => s.network); + if (match.params.id) { + const networkId = parseInt(match.params.id); + const network = networks.find(n => n.id === networkId); + if (network) { + // set the key to force React to mount a new instance when the route changes + return ; + } + } + return null; +}; + +const NetworkView: React.FC = ({ network }) => { useEffect(() => info('Rendering NetworkView component'), []); const { t } = useTranslation(); - const { networkById } = useStoreState(s => s.network); - const { toggle, setActive } = useStoreActions(s => s.network); - - let network: Network; + const { toggle } = useStoreActions(s => s.network); const toggleAsync = useAsyncCallback(async () => toggle(network.id)); - try { - network = networkById(match.params.id); - setActive(network); - } catch { - return null; - } - const { lightning, bitcoin } = network.nodes; return ( @@ -83,4 +90,4 @@ const NetworkView: React.FC> = ({ match }) => { ); }; -export default NetworkView; +export default NetworkViewWrap; diff --git a/src/hooks/useDebounce.ts b/src/hooks/useDebounce.ts new file mode 100644 index 0000000..5977e31 --- /dev/null +++ b/src/hooks/useDebounce.ts @@ -0,0 +1,20 @@ +import { useEffect, useState } from 'react'; + +// Our hook +export default function useDebounce(value: any, delay: number) { + // State and setters for debounced value + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + // Set debouncedValue to value (passed in) after the specified delay + const handler = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + clearTimeout(handler); + }; + }, [value, delay]); + + return debouncedValue; +} diff --git a/src/store/models/designer.ts b/src/store/models/designer.ts deleted file mode 100644 index d781006..0000000 --- a/src/store/models/designer.ts +++ /dev/null @@ -1,275 +0,0 @@ -import { - IChart, - IConfig, - IOnCanvasClick, - IOnCanvasDrop, - IOnDeleteKey, - IOnDragCanvas, - IOnDragNode, - IOnLinkCancel, - IOnLinkClick, - IOnLinkComplete, - IOnLinkMouseEnter, - IOnLinkMouseLeave, - IOnLinkMove, - IOnLinkStart, - IOnNodeClick, - IOnNodeSizeChange, - IOnPortPositionChange, - IPosition, -} from '@mrblenny/react-flow-chart'; -import { Action, action } from 'easy-peasy'; -import { Network } from 'types'; -import btclogo from 'resources/bitcoin.svg'; -import lndlogo from 'resources/lnd.png'; - -export interface DesignerModel { - activeNetworkId: number; - chart: IChart; - initialize: Action; - setChart: Action; - onDragNode: Action[0]>; - onDragCanvas: Action[0]>; - onCanvasDrop: Action[0]>; - onCanvasClick: Action[0]>; - onLinkStart: Action[0]>; - onLinkMove: Action[0]>; - onLinkClick: Action[0]>; - onLinkComplete: Action[0]>; - onLinkCancel: Action[0]>; - onLinkMouseEnter: Action[0]>; - onLinkMouseLeave: Action[0]>; - onPortPositionChange: Action[0]>; - onDeleteKey: Action[0]>; - onNodeClick: Action[0]>; - onNodeSizeChange: Action[0]>; -} - -const v4 = () => new Date().getTime().toString(); -const snap = (position: IPosition, config?: IConfig): IPosition => - config && config.snapToGrid - ? { x: Math.round(position.x / 20) * 20, y: Math.round(position.y / 20) * 20 } - : position; -// center = rotation center -// current = current position -// x, y = rotated positions -// angle = angle of rotation -export const rotate = ( - center: IPosition, - current: IPosition, - angle: number, -): IPosition => { - const radians = (Math.PI / 180) * angle; - const cos = Math.cos(radians); - const sin = Math.sin(radians); - const x = cos * (current.x - center.x) + sin * (current.y - center.y) + center.x; - const y = cos * (current.y - center.y) - sin * (current.x - center.x) + center.y; - return { x, y }; -}; - -const EMPTY_CHART: IChart = { - offset: { x: 0, y: 0 }, - nodes: {}, - links: {}, - selected: {}, - hovered: {}, -}; - -const appModel: DesignerModel = { - activeNetworkId: 0, - chart: { ...EMPTY_CHART }, - initialize: action((state, network) => { - state.activeNetworkId = network.id; - state.chart.offset = EMPTY_CHART.offset; - state.chart.selected = {}; - state.chart.hovered = {}; - Object.keys(state.chart.nodes).forEach(k => delete state.chart.nodes[k]); - Object.keys(state.chart.links).forEach(k => delete state.chart.links[k]); - - network.nodes.bitcoin.forEach(n => { - state.chart.nodes[n.name] = { - id: n.name, - type: 'output-only', - position: { x: n.id * 250 + 200, y: 400 }, - ports: { - backend: { id: 'backend', type: 'input' }, - }, - properties: { - status: n.status, - icon: btclogo, - }, - }; - }); - - network.nodes.lightning.forEach(n => { - state.chart.nodes[n.name] = { - id: n.name, - type: 'input-output', - position: { x: n.id * 250 + 50, y: n.id % 2 === 0 ? 100 : 200 }, - ports: { - port1: { id: 'port1', type: 'left' }, - port2: { id: 'port2', type: 'right' }, - backend: { id: 'backend', type: 'output' }, - }, - properties: { - status: n.status, - icon: lndlogo, - }, - }; - - const linkName = `${n.name}-${n.backendName}`; - state.chart.links[linkName] = { - id: linkName, - from: { nodeId: n.name, portId: 'backend' }, - to: { nodeId: n.backendName, portId: 'backend' }, - }; - }); - }), - setChart: action((state, chart) => { - state.chart = chart; - }), - onDragNode: action(({ chart }, { config, data, id }) => { - const nodechart = chart.nodes[id]; - - if (nodechart) { - chart.nodes[id] = { - ...nodechart, - position: snap(data, config), - }; - } - }), - onDragCanvas: action(({ chart }, { config, data }) => { - chart.offset = snap(data, config); - }), - onCanvasDrop: action(({ chart }, { config, data, position }) => { - const id = v4(); - chart.nodes[id] = { - id, - position: snap(position, config), - orientation: data.orientation || 0, - type: data.type, - ports: data.ports, - properties: data.properties, - }; - }), - onCanvasClick: action(({ chart }) => { - if (chart.selected.id) { - chart.selected = {}; - } - }), - onLinkStart: action(({ chart }, { linkId, fromNodeId, fromPortId }) => { - chart.links[linkId] = { - id: linkId, - from: { - nodeId: fromNodeId, - portId: fromPortId, - }, - to: {}, - }; - }), - onLinkMove: action(({ chart }, { linkId, toPosition }) => { - const link = chart.links[linkId]; - link.to.position = toPosition; - chart.links[linkId] = { ...link }; - }), - onLinkClick: action(({ chart }, { linkId }) => { - if (chart.selected.id !== linkId || chart.selected.type !== 'link') { - chart.selected = { - type: 'link', - id: linkId, - }; - } - }), - onLinkComplete: action(({ chart }, payload) => { - const { linkId, fromNodeId, fromPortId, toNodeId, toPortId, config = {} } = payload; - if ( - (config.validateLink ? config.validateLink({ ...payload, chart: chart }) : true) && - [fromNodeId, fromPortId].join() !== [toNodeId, toPortId].join() - ) { - chart.links[linkId].to = { - nodeId: toNodeId, - portId: toPortId, - }; - } else { - delete chart.links[linkId]; - } - }), - onLinkCancel: action(({ chart }, { linkId }) => { - delete chart.links[linkId]; - }), - onLinkMouseEnter: action(({ chart }, { linkId }) => { - // Set the link to hover - const link = chart.links[linkId]; - // Set the connected ports to hover - if (link.to.nodeId && link.to.portId) { - if (chart.hovered.type !== 'link' || chart.hovered.id !== linkId) { - chart.hovered = { - type: 'link', - id: linkId, - }; - } - } - }), - onLinkMouseLeave: action(({ chart }, { linkId }) => { - const link = chart.links[linkId]; - // Set the connected ports to hover - if (link.to.nodeId && link.to.portId) { - chart.hovered = {}; - } - }), - onPortPositionChange: action(({ chart }, { node: nodeToUpdate, port, el, nodesEl }) => { - if (nodeToUpdate.size) { - // rotate the port's position based on the node's orientation prop (angle) - const center = { x: nodeToUpdate.size.width / 2, y: nodeToUpdate.size.height / 2 }; - const current = { - x: el.offsetLeft + nodesEl.offsetLeft + el.offsetWidth / 2, - y: el.offsetTop + nodesEl.offsetTop + el.offsetHeight / 2, - }; - const angle = nodeToUpdate.orientation || 0; - const position = rotate(center, current, angle); - - const node = chart.nodes[nodeToUpdate.id]; - node.ports[port.id].position = { - x: position.x, - y: position.y, - }; - - chart.nodes[nodeToUpdate.id] = { ...node }; - } - }), - onDeleteKey: action(({ chart }) => { - if (chart.selected.type === 'node' && chart.selected.id) { - const node = chart.nodes[chart.selected.id]; - // Delete the connected links - Object.keys(chart.links).forEach(linkId => { - const link = chart.links[linkId]; - if (link.from.nodeId === node.id || link.to.nodeId === node.id) { - delete chart.links[link.id]; - } - }); - // Delete the node - delete chart.nodes[chart.selected.id]; - } else if (chart.selected.type === 'link' && chart.selected.id) { - delete chart.links[chart.selected.id]; - } - if (chart.selected) { - chart.selected = {}; - } - }), - onNodeClick: action(({ chart }, { nodeId }) => { - if (chart.selected.id !== nodeId || chart.selected.type !== 'node') { - chart.selected = { - type: 'node', - id: nodeId, - }; - } - }), - onNodeSizeChange: action(({ chart }, { nodeId, size }) => { - chart.nodes[nodeId] = { - ...chart.nodes[nodeId], - size, - }; - }), -}; - -export default appModel; diff --git a/src/store/models/index.ts b/src/store/models/index.ts index f4cacb4..fd4ca1b 100644 --- a/src/store/models/index.ts +++ b/src/store/models/index.ts @@ -3,14 +3,12 @@ import { reducer, Reducer } from 'easy-peasy'; import { History } from 'history'; import { AnyAction } from 'redux'; import appModel, { AppModel } from './app'; -import designerModel, { DesignerModel } from './designer'; import networkModel, { NetworkModel } from './network'; export interface RootModel { router: Reducer; app: AppModel; network: NetworkModel; - designer: DesignerModel; } export const createModel = (history: History): RootModel => { @@ -18,7 +16,6 @@ export const createModel = (history: History): RootModel => { router: reducer(connectRouter(history) as any), app: appModel, network: networkModel, - designer: designerModel, }; return rootModel; }; diff --git a/src/store/models/network.ts b/src/store/models/network.ts index 1721e4e..61bf553 100644 --- a/src/store/models/network.ts +++ b/src/store/models/network.ts @@ -2,21 +2,11 @@ import { info } from 'electron-log'; import { join } from 'path'; import { IChart } from '@mrblenny/react-flow-chart'; import { push } from 'connected-react-router'; -import { - Action, - action, - Computed, - computed, - Thunk, - thunk, - ThunkOn, - thunkOn, -} from 'easy-peasy'; +import { Action, action, Computed, computed, Thunk, thunk } from 'easy-peasy'; import { Network, Status, StoreInjections } from 'types'; import { networksPath } from 'utils/config'; import { range } from 'utils/numbers'; import { NETWORK_VIEW } from 'components/routing'; -import { RootModel } from './'; interface AddNetworkArgs { name: string; @@ -26,11 +16,10 @@ interface AddNetworkArgs { export interface NetworkModel { networks: Network[]; - activeNetworkId: number; networkById: Computed Network>; setNetworks: Action; - setActive: Action; load: Thunk>; + save: Thunk>; add: Action; addNetwork: Thunk>; setNetworkStatus: Action; @@ -38,12 +27,10 @@ export interface NetworkModel { stop: Thunk>; toggle: Thunk>; setNetworkDesign: Action; - onDesignChanged: ThunkOn; } const networkModel: NetworkModel = { // state properties networks: [], - activeNetworkId: 0, // computed properties/functions networkById: computed(state => (id?: string | number) => { const networkId = typeof id === 'number' ? id : parseInt(id || ''); @@ -57,15 +44,15 @@ const networkModel: NetworkModel = { setNetworks: action((state, networks) => { state.networks = networks; }), - setActive: action((state, network) => { - state.activeNetworkId = network.id; - }), load: thunk(async (actions, payload, { injections }) => { const networks = await injections.dockerService.load(); if (networks && networks.length) { actions.setNetworks(networks); } }), + save: thunk(async (actions, payload, { getState, injections }) => { + await injections.dockerService.save(getState().networks); + }), add: action((state, { name, lndNodes, bitcoindNodes }) => { const nextId = Math.max(0, ...state.networks.map(n => n.id)) + 1; const network: Network = { @@ -152,38 +139,8 @@ const networkModel: NetworkModel = { setNetworkDesign: action((state, { id, chart }) => { const network = state.networks.find(n => n.id === id); if (!network) throw new Error(`Network with the id '${id}' was not found.`); - network.design = chart; + network.design = { ...chart }; }), - // listen for actions being fired on the designer model - onDesignChanged: thunkOn( - (actions, storeActions) => [ - storeActions.designer.onDragNode, - storeActions.designer.onDragCanvas, - storeActions.designer.onCanvasDrop, - storeActions.designer.onCanvasClick, - storeActions.designer.onLinkStart, - storeActions.designer.onLinkMove, - storeActions.designer.onLinkClick, - storeActions.designer.onLinkComplete, - storeActions.designer.onLinkCancel, - storeActions.designer.onLinkMouseEnter, - storeActions.designer.onLinkMouseLeave, - storeActions.designer.onPortPositionChange, - storeActions.designer.onDeleteKey, - storeActions.designer.onNodeClick, - storeActions.designer.onNodeSizeChange, - ], - async (actions, payload, { getStoreState, injections }) => { - const { - network: { activeNetworkId }, - designer: { chart }, - } = getStoreState(); - if (!activeNetworkId) return; - actions.setNetworkDesign({ id: activeNetworkId, chart }); - const { networks } = getStoreState().network; - await injections.dockerService.save(networks); - }, - ), }; export default networkModel; diff --git a/src/utils/chart.ts b/src/utils/chart.ts new file mode 100644 index 0000000..4bffdd4 --- /dev/null +++ b/src/utils/chart.ts @@ -0,0 +1,55 @@ +import { IChart } from '@mrblenny/react-flow-chart'; +import { Network } from 'types'; +import btclogo from 'resources/bitcoin.svg'; +import lndlogo from 'resources/lnd.png'; + +export const initChartFromNetwork = (network: Network): IChart => { + const chart: IChart = { + offset: { x: 0, y: 0 }, + nodes: {}, + links: {}, + selected: {}, + hovered: {}, + }; + + network.nodes.bitcoin.forEach(n => { + chart.nodes[n.name] = { + id: n.name, + type: 'output-only', + position: { x: n.id * 250 + 200, y: 400 }, + ports: { + backend: { id: 'backend', type: 'input' }, + }, + properties: { + status: n.status, + icon: btclogo, + }, + }; + }); + + network.nodes.lightning.forEach(n => { + chart.nodes[n.name] = { + id: n.name, + type: 'input-output', + position: { x: n.id * 250 + 50, y: n.id % 2 === 0 ? 100 : 200 }, + ports: { + port1: { id: 'port1', type: 'left' }, + port2: { id: 'port2', type: 'right' }, + backend: { id: 'backend', type: 'output' }, + }, + properties: { + status: n.status, + icon: lndlogo, + }, + }; + + const linkName = `${n.name}-${n.backendName}`; + chart.links[linkName] = { + id: linkName, + from: { nodeId: n.name, portId: 'backend' }, + to: { nodeId: n.backendName, portId: 'backend' }, + }; + }); + + return chart; +};