Browse Source

feat(network): implement node removal

feat/auto-update
jamaljsr 5 years ago
parent
commit
fcb0749bff
  1. 1
      TODO.md
  2. 6
      src/components/designer/lnd/actions/RemoveNode.tsx
  3. 2
      src/components/network/NewNetwork.tsx
  4. 3
      src/i18n/locales/en-US.json
  5. 8
      src/i18n/locales/es.json
  6. 37
      src/lib/docker/dockerService.ts
  7. 5
      src/lib/lnd/lndService.ts
  8. 14
      src/store/models/designer.ts
  9. 6
      src/store/models/lnd.ts
  10. 18
      src/store/models/network.ts
  11. 5
      src/types/index.ts
  12. 11
      src/utils/chart.ts
  13. 13
      src/utils/network.ts
  14. 1
      src/utils/tests.tsx

1
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

6
src/components/designer/lnd/actions/RemoveNode.tsx

@ -18,17 +18,19 @@ interface Props {
const RemoveNode: React.FC<Props> = ({ 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 });

2
src/components/network/NewNetwork.tsx

@ -53,7 +53,7 @@ const NewNetwork: React.SFC<FormComponentProps> = ({ form }) => {
<Form.Item label={l('lndNodesLabel')}>
{form.getFieldDecorator('lndNodes', {
rules: [{ required: true, message: l('cmps.forms.required') }],
initialValue: 2,
initialValue: 3,
})(<InputNumber min={1} max={10} />)}
</Form.Item>
</Col>

3
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",

8
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",

37
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<A>(
cmd: (args: A) => Promise<compose.IDockerComposeResult>,
args: A,
private async execute<A, B>(
cmd: (arg1: A, arg2?: B) => Promise<compose.IDockerComposeResult>,
arg1: A,
arg2?: B,
): Promise<compose.IDockerComposeResult> {
try {
const result = await cmd(args);
const result = await cmd(arg1, arg2);
result.out = stripAnsi(result.out);
result.err = stripAnsi(result.err);
return result;

5
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<void> {
const nodes = network.nodes.lightning.filter(n => n.implementation === 'LND');
async onNodesDeleted(nodes: LndNode[]): Promise<void> {
return await proxy.onNodesDeleted(nodes);
}

14
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];

6
src/store/models/lnd.ts

@ -37,6 +37,7 @@ export interface OpenChannelPayload {
export interface LndModel {
nodes: LndNodeMapping;
removeNode: Action<LndModel, string>;
setInfo: Action<LndModel, { node: LndNode; info: LND.GetInfoResponse }>;
getInfo: Thunk<LndModel, LndNode, StoreInjections, RootModel>;
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;

18
src/store/models/network.ts

@ -45,6 +45,7 @@ export interface NetworkModel {
RootModel,
Promise<LndNode>
>;
removeNode: Thunk<NetworkModel, { node: LndNode }, StoreInjections, RootModel>;
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);
}),
};

5
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<void>;
start: (network: Network) => Promise<void>;
stop: (network: Network) => Promise<void>;
removeNode: (network: Network, node: CommonNode) => Promise<void>;
saveNetworks: (networks: NetworksFile) => Promise<void>;
loadNetworks: () => Promise<NetworksFile>;
}
@ -54,7 +55,7 @@ export interface LndLibrary {
closeChannel: (node: LndNode, channelPoint: string) => Promise<any>;
listChannels: (node: LndNode) => Promise<LND.ListChannelsResponse>;
pendingChannels: (node: LndNode) => Promise<LND.PendingChannelsResponse>;
onNodesDeleted: (network: Network) => Promise<void>;
onNodesDeleted: (nodes: LndNode[]) => Promise<void>;
}
export interface StoreInjections {

11
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,

13
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,
},
};
};

1
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(),
},

Loading…
Cancel
Save