Browse Source

feat(designer): move network designer data to a redux store

feat/auto-update
jamaljsr 5 years ago
parent
commit
5f9f07396a
  1. 95
      src/components/designer/NetworkDesigner.tsx
  2. 10
      src/lib/docker/dockerService.ts
  3. 218
      src/store/models/designer.ts
  4. 3
      src/store/models/index.ts
  5. 42
      src/store/models/network.ts
  6. 10
      src/types/index.ts

95
src/components/designer/NetworkDesigner.tsx

@ -1,11 +1,10 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect } from 'react';
import styled from '@emotion/styled';
import { FlowChart } from '@mrblenny/react-flow-chart';
import * as chartCallbacks from '@mrblenny/react-flow-chart/src/container/actions';
import useDebounce from 'hooks/useDebounce';
import { useStoreActions } from 'store';
import { Network, Status } from 'types';
import { initChartFromNetwork } from 'utils/chart';
import { useStoreActions, useStoreState } from 'store';
import { Network } from 'types';
import { Loader } from 'components/common';
import CustomNodeInner from './CustomNodeInner';
import Sidebar from './Sidebar';
@ -18,88 +17,30 @@ const Styled = {
height: 100%;
`,
};
interface Props {
network: Network;
updateStateDelay?: number;
}
const NetworkDesigner: React.FC<Props> = ({ network, updateStateDelay = 3000 }) => {
const [chart, setChart] = useState(
// use function to avoid calling init on every rerender
() => network.design || initChartFromNetwork(network),
);
// keep array of each node's status in state in order to detect changes
const [nodeStates, setNodeStates] = useState();
const statuses = [network.status]
.concat(
network.nodes.bitcoin.map(n => n.status),
network.nodes.lightning.map(n => n.status),
)
.toString();
// if any node status changed then update the local state
if (statuses !== nodeStates) {
setNodeStates(statuses);
}
// update chart in state when the network status changes
const { lightning, bitcoin } = network.nodes;
const NetworkDesigner: React.FC<Props> = ({ network, updateStateDelay = 3000 }) => {
const { setActiveId, ...callbacks } = useStoreActions(s => s.designer);
// update the redux store with the current network's chart
useEffect(() => {
setChart(c => {
Object.keys(c.nodes).forEach(n => {
// create a mapping of node name to its status
const nodes: Record<string, Status> = [...lightning, ...bitcoin].reduce(
(result, node) => ({
...result,
[node.name]: node.status,
}),
{},
);
// update the node status in the chart
c.nodes[n].properties.status = nodes[n];
});
return { ...c };
});
}, [network.status, nodeStates, lightning, bitcoin]);
setActiveId(network.id);
}, [network.id, setActiveId]);
const { setNetworkDesign, save } = useStoreActions(s => s.network);
// prevent updating redux state with the new chart on every callback
// which can be many, ex: onDragNode, onLinkMouseEnter
const { save } = useStoreActions(s => s.network);
const chart = useStoreState(s => s.designer.activeChart);
// prevent saving the new chart on every callback
// which can be many, ex: onDragNode, onDragCanvas, etc
const debouncedChart = useDebounce(chart, updateStateDelay);
useEffect(() => {
// TODO: save changes here instead of on unmount
// 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
// save to disk when the design is changed (debounced)
save();
}, [debouncedChart, save]);
// use custom callbacks to update the chart based on user interactions.
// this wacky code intercepts the callbacks, giving them the current chart
// from component state then storing the returned chart back in state
const callbacks = Object.entries(chartCallbacks).reduce(
(allActions: Record<string, any>, entry: [string, any]) => {
// key is the property name of the chartCallbacks object
// func is the callback function
const [key, func] = entry;
allActions[key] = (...args: any) => {
// invoke the curried function with the args from the FlowChart
// component and the current chart object from state
const newChart = func(...args)(chart);
// need to pass a new object to the hook to trigger a rerender
setChart({ ...newChart });
};
return allActions;
},
{},
) as typeof chartCallbacks;
if (!chart) return <Loader />;
return (
<Styled.Designer>

10
src/lib/docker/dockerService.ts

@ -2,7 +2,7 @@ import { info } from 'electron-log';
import { join } from 'path';
import * as compose from 'docker-compose';
import yaml from 'js-yaml';
import { DockerLibrary, LndNode, Network } from 'types';
import { DockerLibrary, LndNode, Network, NetworksFile } from 'types';
import { networksPath } from 'utils/config';
import { exists, read, write } from 'utils/files';
import ComposeFile from './composeFile';
@ -78,8 +78,8 @@ class DockerService implements DockerLibrary {
* Saves the given networks to disk
* @param networks the list of networks to save
*/
async save(networks: Network[]) {
const json = JSON.stringify(networks, null, 2);
async save(data: NetworksFile) {
const json = JSON.stringify(data, null, 2);
const path = join(networksPath, 'networks.json');
await write(path, json);
info(`saved networks to '${path}'`);
@ -88,7 +88,7 @@ class DockerService implements DockerLibrary {
/**
* Loads a list of networks from the file system
*/
async load(): Promise<Network[]> {
async load(): Promise<NetworksFile> {
const path = join(networksPath, 'networks.json');
if (await exists(path)) {
const json = await read(path);
@ -97,7 +97,7 @@ class DockerService implements DockerLibrary {
return networks;
} else {
info(`skipped loading networks because the file '${path}' doesn't exist`);
return [];
return { networks: [], charts: {} };
}
}
}

218
src/store/models/designer.ts

@ -0,0 +1,218 @@
import RFC, { IChart, IConfig, IPosition } from '@mrblenny/react-flow-chart';
import { Action, action, Computed, computed } from 'easy-peasy';
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 snap = (position: IPosition, config?: IConfig) =>
config && config.snapToGrid
? { x: Math.round(position.x / 20) * 20, y: Math.round(position.y / 20) * 20 }
: position;
export interface DesignerModel {
activeId: number;
allCharts: Record<number, IChart>;
activeChart: Computed<DesignerModel, IChart>;
setActiveId: Action<DesignerModel, number>;
setAllCharts: Action<DesignerModel, Record<number, IChart>>;
addChart: Action<DesignerModel, { id: number; chart: IChart }>;
onDragNode: Action<DesignerModel, Parameters<RFC.IOnDragNode>[0]>;
onDragCanvas: Action<DesignerModel, Parameters<RFC.IOnDragCanvas>[0]>;
onLinkStart: Action<DesignerModel, Parameters<RFC.IOnLinkStart>[0]>;
onLinkMove: Action<DesignerModel, Parameters<RFC.IOnLinkMove>[0]>;
onLinkComplete: Action<DesignerModel, Parameters<RFC.IOnLinkComplete>[0]>;
onLinkCancel: Action<DesignerModel, Parameters<RFC.IOnLinkCancel>[0]>;
onLinkMouseEnter: Action<DesignerModel, Parameters<RFC.IOnLinkMouseEnter>[0]>;
onLinkMouseLeave: Action<DesignerModel, Parameters<RFC.IOnLinkMouseLeave>[0]>;
onLinkClick: Action<DesignerModel, Parameters<RFC.IOnLinkMouseLeave>[0]>;
onCanvasClick: Action<DesignerModel, Parameters<RFC.IOnCanvasClick>[0]>;
onDeleteKey: Action<DesignerModel, Parameters<RFC.IOnDeleteKey>[0]>;
onNodeClick: Action<DesignerModel, Parameters<RFC.IOnNodeClick>[0]>;
onNodeSizeChange: Action<DesignerModel, Parameters<RFC.IOnNodeSizeChange>[0]>;
onPortPositionChange: Action<DesignerModel, Parameters<RFC.IOnPortPositionChange>[0]>;
onCanvasDrop: Action<DesignerModel, Parameters<RFC.IOnCanvasDrop>[0]>;
}
const designerModel: DesignerModel = {
activeId: 0,
allCharts: {},
activeChart: computed(state => state.allCharts[state.activeId]),
setActiveId: action((state, networkId) => {
if (!state.allCharts[networkId])
throw new Error(`Chart not found for network with id ${networkId}`);
state.activeId = networkId;
}),
setAllCharts: action((state, charts) => {
state.allCharts = charts;
}),
addChart: action((state, { id, chart }) => {
state.allCharts[id] = chart;
}),
onDragNode: action((state, { config, data, id }) => {
const chart = state.allCharts[state.activeId];
if (chart.nodes[id]) {
chart.nodes[id] = {
...chart.nodes[id],
position: snap(data, config),
};
}
}),
onDragCanvas: action((state, { config, data }) => {
const chart = state.allCharts[state.activeId];
chart.offset = snap(data, config);
}),
onLinkStart: action((state, { linkId, fromNodeId, fromPortId }) => {
const chart = state.allCharts[state.activeId];
chart.links[linkId] = {
id: linkId,
from: {
nodeId: fromNodeId,
portId: fromPortId,
},
to: {},
};
}),
onLinkMove: action((state, { linkId, toPosition }) => {
const chart = state.allCharts[state.activeId];
const link = chart.links[linkId];
link.to.position = toPosition;
chart.links[linkId] = { ...link };
}),
onLinkComplete: action((state, args) => {
const chart = state.allCharts[state.activeId];
const { linkId, fromNodeId, fromPortId, toNodeId, toPortId, config = {} } = args;
if (
(config.validateLink ? config.validateLink({ ...args, chart }) : true) &&
[fromNodeId, fromPortId].join() !== [toNodeId, toPortId].join()
) {
chart.links[linkId].to = {
nodeId: toNodeId,
portId: toPortId,
};
} else {
delete chart.links[linkId];
}
}),
onLinkCancel: action((state, { linkId }) => {
const chart = state.allCharts[state.activeId];
delete chart.links[linkId];
}),
onLinkMouseEnter: action((state, { linkId }) => {
const chart = state.allCharts[state.activeId];
// 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((state, { linkId }) => {
const chart = state.allCharts[state.activeId];
const link = chart.links[linkId];
// Set the connected ports to hover
if (link.to.nodeId && link.to.portId) {
chart.hovered = {};
}
}),
onLinkClick: action((state, { linkId }) => {
const chart = state.allCharts[state.activeId];
if (chart.selected.id !== linkId || chart.selected.type !== 'link') {
chart.selected = {
type: 'link',
id: linkId,
};
}
}),
onCanvasClick: action(state => {
const chart = state.allCharts[state.activeId];
if (chart.selected.id) {
chart.selected = {};
}
}),
onDeleteKey: action(state => {
const chart = state.allCharts[state.activeId];
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((state, { nodeId }) => {
const chart = state.allCharts[state.activeId];
if (chart.selected.id !== nodeId || chart.selected.type !== 'node') {
chart.selected = {
type: 'node',
id: nodeId,
};
}
}),
onNodeSizeChange: action((state, { nodeId, size }) => {
const chart = state.allCharts[state.activeId];
chart.nodes[nodeId] = {
...chart.nodes[nodeId],
size,
};
}),
onPortPositionChange: action((state, { node: nodeToUpdate, port, el, nodesEl }) => {
const chart = state.allCharts[state.activeId];
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 };
}
}),
onCanvasDrop: action((state, { config, data, position }) => {
const chart = state.allCharts[state.activeId];
const id = Date.now().toString(); // TODO: v4();
chart.nodes[id] = {
id,
position: snap(position, config),
orientation: data.orientation || 0,
type: data.type,
ports: data.ports,
properties: data.properties,
};
}),
};
export default designerModel;

3
src/store/models/index.ts

@ -4,6 +4,7 @@ import { History } from 'history';
import { AnyAction } from 'redux';
import appModel, { AppModel } from './app';
import bitcoindModel, { BitcoindModel } from './bitcoind';
import designerModel, { DesignerModel } from './designer';
import lndModel, { LndModel } from './lnd';
import networkModel, { NetworkModel } from './network';
@ -13,6 +14,7 @@ export interface RootModel {
network: NetworkModel;
bitcoind: BitcoindModel;
lnd: LndModel;
designer: DesignerModel;
}
export const createModel = (history: History<any>): RootModel => {
@ -22,6 +24,7 @@ export const createModel = (history: History<any>): RootModel => {
network: networkModel,
bitcoind: bitcoindModel,
lnd: lndModel,
designer: designerModel,
};
return rootModel;
};

42
src/store/models/network.ts

@ -1,8 +1,9 @@
import { info } from 'electron-log';
import { IChart } from '@mrblenny/react-flow-chart';
import { push } from 'connected-react-router';
import { Action, action, Computed, computed, Thunk, thunk } from 'easy-peasy';
import { CommonNode, Network, Status, StoreInjections } from 'types';
import { initChartFromNetwork } from 'utils/chart';
import { createNetwork } from 'utils/network';
import { NETWORK_VIEW } from 'components/routing';
import { RootModel } from './';
@ -36,7 +37,6 @@ export interface NetworkModel {
start: Thunk<NetworkModel, number, StoreInjections, RootModel, Promise<void>>;
stop: Thunk<NetworkModel, number, StoreInjections, RootModel, Promise<void>>;
toggle: Thunk<NetworkModel, number, StoreInjections, RootModel, Promise<void>>;
setNetworkDesign: Action<NetworkModel, { id: number; chart: IChart }>;
}
const networkModel: NetworkModel = {
// state properties
@ -58,15 +58,20 @@ const networkModel: NetworkModel = {
setLoaded: action((state, loaded) => {
state.loaded = loaded;
}),
load: thunk(async (actions, payload, { injections }) => {
const networks = await injections.dockerService.load();
load: thunk(async (actions, payload, { injections, getStoreActions }) => {
const { networks, charts } = await injections.dockerService.load();
if (networks && networks.length) {
actions.setNetworks(networks);
}
getStoreActions().designer.setAllCharts(charts);
actions.setLoaded(true);
}),
save: thunk(async (actions, payload, { getState, injections }) => {
await injections.dockerService.save(getState().networks);
save: thunk(async (actions, payload, { getState, injections, getStoreState }) => {
const data = {
networks: getState().networks,
charts: getStoreState().designer.allCharts,
};
await injections.dockerService.save(data);
}),
add: action((state, { name, lndNodes, bitcoindNodes }) => {
const nextId = Math.max(0, ...state.networks.map(n => n.id)) + 1;
@ -74,14 +79,18 @@ const networkModel: NetworkModel = {
state.networks.push(network);
info(`Added new network '${network.name}' to redux state`);
}),
addNetwork: thunk(async (actions, payload, { dispatch, getState, injections }) => {
actions.add(payload);
const { networks } = getState();
const newNetwork = networks[networks.length - 1];
await injections.dockerService.create(newNetwork);
await injections.dockerService.save(networks);
dispatch(push(NETWORK_VIEW(newNetwork.id)));
}),
addNetwork: thunk(
async (actions, payload, { dispatch, getState, injections, getStoreActions }) => {
actions.add(payload);
const { networks } = getState();
const newNetwork = networks[networks.length - 1];
await injections.dockerService.create(newNetwork);
const chart = initChartFromNetwork(newNetwork);
getStoreActions().designer.addChart({ id: newNetwork.id, chart });
await actions.save();
dispatch(push(NETWORK_VIEW(newNetwork.id)));
},
),
setStatus: action((state, { id, status, only, all = true }) => {
const network = state.networks.find(n => n.id === id);
if (!network) throw new Error(`Network with the id '${id}' was not found.`);
@ -159,11 +168,6 @@ const networkModel: NetworkModel = {
await actions.stop(network.id);
}
}),
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 };
}),
};
export default networkModel;

10
src/types/index.ts

@ -54,7 +54,6 @@ export interface Network {
name: string;
status: Status;
path: string;
design?: IChart;
nodes: {
bitcoin: BitcoinNode[];
lightning: LndNode[];
@ -65,8 +64,8 @@ export interface DockerLibrary {
create: (network: Network) => Promise<void>;
start: (network: Network) => Promise<void>;
stop: (network: Network) => Promise<void>;
save: (networks: Network[]) => Promise<void>;
load: () => Promise<Network[]>;
save: (networks: NetworksFile) => Promise<void>;
load: () => Promise<NetworksFile>;
}
export interface BitcoindLibrary {
@ -86,3 +85,8 @@ export interface StoreInjections {
bitcoindService: BitcoindLibrary;
lndService: LndLibrary;
}
export interface NetworksFile {
networks: Network[];
charts: Record<number, IChart>;
}

Loading…
Cancel
Save