diff --git a/TODO.md b/TODO.md index bcda886..550ed7b 100644 --- a/TODO.md +++ b/TODO.md @@ -2,6 +2,7 @@ Small Stuff +- dont allow open channel if both nodes aren't Started - implement real-time channel updates from LND via GRPC streams - implement option to auto-mine every X minutes - switch renovatebot to dependabot and use automatic security fixes diff --git a/src/components/designer/lnd/actions/RemoveNode.tsx b/src/components/designer/lnd/actions/RemoveNode.tsx index 80b17be..fd1c6cb 100644 --- a/src/components/designer/lnd/actions/RemoveNode.tsx +++ b/src/components/designer/lnd/actions/RemoveNode.tsx @@ -18,17 +18,19 @@ interface Props { const RemoveNode: React.FC = ({ node }) => { const { l } = usePrefixedTranslation('cmps.designer.lnd.actions.RemoveNode'); const { notify } = useStoreActions(s => s.app); + const { removeNode } = useStoreActions(s => s.network); const showRemoveModal = () => { const { name } = node; Modal.confirm({ - title: l('confirmText', { name }), + title: l('confirmTitle', { name }), + content: l('confirmText'), okText: l('confirmBtn'), okType: 'danger', cancelText: l('cancelBtn'), onOk: async () => { try { - // await closeChannel({ node: from as LndNode, channelPoint }); + await removeNode({ node }); notify({ message: l('success', { name }) }); } catch (error) { notify({ message: l('error'), error }); diff --git a/src/components/network/NewNetwork.tsx b/src/components/network/NewNetwork.tsx index c908261..3c1ba28 100644 --- a/src/components/network/NewNetwork.tsx +++ b/src/components/network/NewNetwork.tsx @@ -53,7 +53,7 @@ const NewNetwork: React.SFC = ({ form }) => { {form.getFieldDecorator('lndNodes', { rules: [{ required: true, message: l('cmps.forms.required') }], - initialValue: 2, + initialValue: 3, })()} diff --git a/src/i18n/locales/en-US.json b/src/i18n/locales/en-US.json index 54ed2c1..f06fdb7 100644 --- a/src/i18n/locales/en-US.json +++ b/src/i18n/locales/en-US.json @@ -74,7 +74,8 @@ "cmps.designer.lnd.actions.OpenChannelModal.submitError": "Unable to open the channel", "cmps.designer.lnd.actions.RemoveNode.title": "Remove Node From Network", "cmps.designer.lnd.actions.RemoveNode.btnText": "Remove", - "cmps.designer.lnd.actions.RemoveNode.confirmText": "Are you sure you want to remove {{name}} from the network?", + "cmps.designer.lnd.actions.RemoveNode.confirmTitle": "Are you sure you want to remove {{name}} from the network?", + "cmps.designer.lnd.actions.RemoveNode.confirmText": "Any open channels with this node will be inoperable and not displayed in the designer.", "cmps.designer.lnd.actions.RemoveNode.confirmBtn": "Yes", "cmps.designer.lnd.actions.RemoveNode.cancelBtn": "Cancel", "cmps.designer.lnd.actions.RemoveNode.success": "The node {{name}} have been removed from the network", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index c172a4d..bed0855 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -72,6 +72,14 @@ "cmps.designer.lnd.actions.OpenChannelModal.okBtn": "Canal abierto", "cmps.designer.lnd.actions.OpenChannelModal.balancesError": "No es posible recuperar saldos de nodos", "cmps.designer.lnd.actions.OpenChannelModal.submitError": "No se puede abrir el canal.", + "cmps.designer.lnd.actions.RemoveNode.title": "Eliminar nodo de la red", + "cmps.designer.lnd.actions.RemoveNode.btnText": "Eliminar", + "cmps.designer.lnd.actions.RemoveNode.confirmTitle": "¿Estás seguro de que deseas eliminar {{name}} de la red?", + "cmps.designer.lnd.actions.RemoveNode.confirmText": "Cualquier canal abierto con este nodo será inoperable y no se mostrará en el diseñador.", + "cmps.designer.lnd.actions.RemoveNode.confirmBtn": "Si", + "cmps.designer.lnd.actions.RemoveNode.cancelBtn": "Cancelar", + "cmps.designer.lnd.actions.RemoveNode.success": "El nodo {{name}} se ha eliminado de la red", + "cmps.designer.lnd.actions.RemoveNode.error": "No se puede eliminar el nodo", "cmps.designer.lnd.connect.FilePaths.tlsCert": "Certificado TLS", "cmps.designer.lnd.connect.FilePaths.adminMacaroon": "Admin Macarrón", "cmps.designer.lnd.connect.FilePaths.readOnlyMacaroon": "Macarrón de solo lectura", diff --git a/src/lib/docker/dockerService.ts b/src/lib/docker/dockerService.ts index 6b27949..f16eacc 100644 --- a/src/lib/docker/dockerService.ts +++ b/src/lib/docker/dockerService.ts @@ -4,7 +4,7 @@ import { join } from 'path'; import * as compose from 'docker-compose'; import Dockerode from 'dockerode'; import yaml from 'js-yaml'; -import { LndNode } from 'shared/types'; +import { CommonNode, LndNode } from 'shared/types'; import stripAnsi from 'strip-ansi'; import { DockerLibrary, DockerVersions, Network, NetworksFile } from 'types'; import { networksPath } from 'utils/config'; @@ -84,7 +84,7 @@ class DockerService implements DockerLibrary { const yml = yaml.dump(file.content); const path = join(network.path, 'docker-compose.yml'); await write(path, yml); - info(`created compose file for '${network.name}' at '${path}'`); + info(`saved compose file for '${network.name}' at '${path}'`); } /** @@ -109,6 +109,24 @@ class DockerService implements DockerLibrary { info(`Network stopped:\n ${result.out || result.err}`); } + /** + * Removes a single service from the network using docker-compose + * @param network the network containing the node + * @param node the node to remove + */ + async removeNode(network: Network, node: CommonNode) { + info(`Stopping docker container for ${node.name}`); + info(` - path: ${network.path}`); + let result = await this.execute(compose.stopOne, node.name, this.getArgs(network)); + info(`Container stopped:\n ${result.out || result.err}`); + + info(`Removing stopped docker containers`); + result = await this.execute(compose.rm, this.getArgs(network)); + info(`Removed:\n ${result.out || result.err}`); + + await this.saveComposeFile(network); + } + /** * Saves the given networks to disk * @param networks the list of networks to save @@ -127,9 +145,9 @@ class DockerService implements DockerLibrary { const path = join(networksPath, 'networks.json'); if (await exists(path)) { const json = await read(path); - const networks = JSON.parse(json); - info(`loaded ${networks.length} networks from '${path}'`); - return networks; + const data = JSON.parse(json); + info(`loaded ${data.networks.length} networks from '${path}'`); + return data; } else { info(`skipped loading networks because the file '${path}' doesn't exist`); return { networks: [], charts: {} }; @@ -141,12 +159,13 @@ class DockerService implements DockerLibrary { * @param cmd the compose function to call * @param args the arguments to the compose function */ - private async execute( - cmd: (args: A) => Promise, - args: A, + private async execute( + cmd: (arg1: A, arg2?: B) => Promise, + arg1: A, + arg2?: B, ): Promise { try { - const result = await cmd(args); + const result = await cmd(arg1, arg2); result.out = stripAnsi(result.out); result.err = stripAnsi(result.err); return result; diff --git a/src/lib/lnd/lndService.ts b/src/lib/lnd/lndService.ts index 84446b6..3935123 100644 --- a/src/lib/lnd/lndService.ts +++ b/src/lib/lnd/lndService.ts @@ -1,6 +1,6 @@ import * as LND from '@radar/lnrpc'; import { LndNode } from 'shared/types'; -import { LndLibrary, Network } from 'types'; +import { LndLibrary } from 'types'; import { waitFor } from 'utils/async'; import { getContainerName } from 'utils/network'; import { lndProxyClient as proxy } from './'; @@ -68,8 +68,7 @@ class LndService implements LndLibrary { return await proxy.pendingChannels(node); } - async onNodesDeleted(network: Network): Promise { - const nodes = network.nodes.lightning.filter(n => n.implementation === 'LND'); + async onNodesDeleted(nodes: LndNode[]): Promise { return await proxy.onNodesDeleted(nodes); } diff --git a/src/store/models/designer.ts b/src/store/models/designer.ts index 41dde83..de905eb 100644 --- a/src/store/models/designer.ts +++ b/src/store/models/designer.ts @@ -133,10 +133,16 @@ const designerModel: DesignerModel = { delete state.allCharts[state.activeId].links[linkId]; }), removeNode: action((state, nodeId) => { - // this action is used when a node is dropped onto the canvas. - // remove the node created in the chart once the async loading - // has been completed - delete state.allCharts[state.activeId].nodes[nodeId]; + const chart = state.allCharts[state.activeId]; + if (chart.selected && chart.selected.id === nodeId) { + chart.selected = {}; + } + delete chart.nodes[nodeId]; + Object.values(chart.links).forEach(link => { + if ([link.to.nodeId, link.from.nodeId].includes(nodeId)) { + delete chart.links[link.id]; + } + }); }), addLndNode: action((state, { lndNode, position }) => { const chart = state.allCharts[state.activeId]; diff --git a/src/store/models/lnd.ts b/src/store/models/lnd.ts index cfe4077..4250afc 100644 --- a/src/store/models/lnd.ts +++ b/src/store/models/lnd.ts @@ -37,6 +37,7 @@ export interface OpenChannelPayload { export interface LndModel { nodes: LndNodeMapping; + removeNode: Action; setInfo: Action; getInfo: Thunk; setWalletBalance: Action< @@ -61,6 +62,11 @@ const lndModel: LndModel = { // state properties nodes: {}, // reducer actions (mutations allowed thx to immer) + removeNode: action((state, name) => { + if (state.nodes[name]) { + delete state.nodes[name]; + } + }), setInfo: action((state, { node, info }) => { if (!state.nodes[node.name]) state.nodes[node.name] = {}; state.nodes[node.name].info = info; diff --git a/src/store/models/network.ts b/src/store/models/network.ts index 9893cce..f7f9d5e 100644 --- a/src/store/models/network.ts +++ b/src/store/models/network.ts @@ -45,6 +45,7 @@ export interface NetworkModel { RootModel, Promise >; + removeNode: Thunk; setStatus: Action< NetworkModel, { id: number; status: Status; only?: string; all?: boolean; error?: Error } @@ -130,6 +131,21 @@ const networkModel: NetworkModel = { await injections.dockerService.saveComposeFile(network); return node; }), + removeNode: thunk( + async (actions, { node }, { getState, injections, getStoreActions }) => { + const networks = getState().networks; + const network = networks.find(n => n.id === node.networkId); + if (!network) throw new Error(l('networkByIdErr', { networkId: node.networkId })); + network.nodes.lightning = network.nodes.lightning.filter(n => n !== node); + getStoreActions().lnd.removeNode(node.name); + await injections.dockerService.removeNode(network, node); + actions.setNetworks([...networks]); + await actions.save(); + getStoreActions().designer.removeNode(node.name); + getStoreActions().designer.syncChart(network); + await injections.lndService.onNodesDeleted([node, ...network.nodes.lightning]); + }, + ), setStatus: action((state, { id, status, only, all = true, error }) => { const network = state.networks.find(n => n.id === id); if (!network) throw new Error(l('networkByIdErr', { networkId: id })); @@ -246,7 +262,7 @@ const networkModel: NetworkModel = { actions.setNetworks(newNetworks); getStoreActions().designer.removeChart(networkId); await actions.save(); - await injections.lndService.onNodesDeleted(network); + await injections.lndService.onNodesDeleted(network.nodes.lightning); }), }; diff --git a/src/types/index.ts b/src/types/index.ts index 71a1ef7..3a06461 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,7 +1,7 @@ import { IChart } from '@mrblenny/react-flow-chart'; import * as LND from '@radar/lnrpc'; import { ChainInfo, WalletInfo } from 'bitcoin-core'; -import { BitcoinNode, LndNode, Status } from 'shared/types'; +import { BitcoinNode, CommonNode, LndNode, Status } from 'shared/types'; import { IpcSender } from 'lib/ipc/ipcService'; export interface LocaleConfig { @@ -33,6 +33,7 @@ export interface DockerLibrary { saveComposeFile: (network: Network) => Promise; start: (network: Network) => Promise; stop: (network: Network) => Promise; + removeNode: (network: Network, node: CommonNode) => Promise; saveNetworks: (networks: NetworksFile) => Promise; loadNetworks: () => Promise; } @@ -54,7 +55,7 @@ export interface LndLibrary { closeChannel: (node: LndNode, channelPoint: string) => Promise; listChannels: (node: LndNode) => Promise; pendingChannels: (node: LndNode) => Promise; - onNodesDeleted: (network: Network) => Promise; + onNodesDeleted: (nodes: LndNode[]) => Promise; } export interface StoreInjections { diff --git a/src/utils/chart.ts b/src/utils/chart.ts index a69724f..470647a 100644 --- a/src/utils/chart.ts +++ b/src/utils/chart.ts @@ -222,10 +222,13 @@ export const updateChartFromLnd = (chart: IChart, lndData: LndNodeMapping): ICha ...waitingClose.map(pluckChan).map(mapPendingChannel('Waiting to Close')), ]; - allChannels.forEach(channel => { - updateLinksAndPorts(channel, pubkeys, nodes, fromNode, links); - createdLinkIds.push(channel.uniqueId); - }); + allChannels + // ignore channels to nodes that no longer exist in the network + .filter(c => !!pubkeys[c.pubkey]) + .forEach(channel => { + updateLinksAndPorts(channel, pubkeys, nodes, fromNode, links); + createdLinkIds.push(channel.uniqueId); + }); nodes[fromName] = { ...fromNode, diff --git a/src/utils/network.ts b/src/utils/network.ts index 2958ffc..174b098 100644 --- a/src/utils/network.ts +++ b/src/utils/network.ts @@ -32,21 +32,22 @@ export const createLndNetworkNode = ( version: LndVersion, status: Status, ): LndNode => { - const index = network.nodes.lightning.length; - const name = `lnd-${index + 1}`; + const { bitcoin, lightning } = network.nodes; + const id = lightning.length ? Math.max(...lightning.map(n => n.id)) + 1 : 0; + const name = `lnd-${id + 1}`; return { - id: index, + id, networkId: network.id, name: name, type: 'lightning', implementation: 'LND', version, status, - backendName: network.nodes.bitcoin[0].name, + backendName: bitcoin[0].name, paths: getFilePaths(name, network), ports: { - rest: 8081 + index, - grpc: 10001 + index, + rest: 8081 + id, + grpc: 10001 + id, }, }; }; diff --git a/src/utils/tests.tsx b/src/utils/tests.tsx index 708e915..30eed2a 100644 --- a/src/utils/tests.tsx +++ b/src/utils/tests.tsx @@ -34,6 +34,7 @@ export const injections: StoreInjections = { saveComposeFile: jest.fn(), start: jest.fn(), stop: jest.fn(), + removeNode: jest.fn(), saveNetworks: jest.fn(), loadNetworks: jest.fn(), },