diff --git a/.vscode/settings.json b/.vscode/settings.json
index d9a9765293..3f80f03404 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -66,7 +66,7 @@
}
],
"editor.codeActionsOnSave": {
- "source.fixAll.eslint": true
+ "source.fixAll.eslint": "explicit"
},
- "typescript.tsdk": "node_modules/typescript/lib"
+ "typescript.tsdk": "node_modules/typescript/lib",
}
diff --git a/docker/nodes.json b/docker/nodes.json
index adf934ec01..3fdf455c7b 100644
--- a/docker/nodes.json
+++ b/docker/nodes.json
@@ -55,6 +55,10 @@
"0.3.3-alpha": "0.16.0-beta",
"0.3.2-alpha": "0.16.0-beta"
}
+ },
+ "simln": {
+ "latest": "0.2.0",
+ "versions": ["0.2.0"]
}
}
}
diff --git a/src/components/designer/ActivityGenerator.tsx b/src/components/designer/ActivityGenerator.tsx
index 184170f0aa..9f81fb6513 100644
--- a/src/components/designer/ActivityGenerator.tsx
+++ b/src/components/designer/ActivityGenerator.tsx
@@ -1,10 +1,11 @@
-import React, { useState } from 'react';
+import React from 'react';
import styled from '@emotion/styled';
import { Alert, Button, Col, Form, InputNumber, Row, Select, Slider } from 'antd';
import { usePrefixedTranslation } from 'hooks';
import { CLightningNode, LightningNode, LndNode } from 'shared/types';
import { useStoreActions, useStoreState } from 'store';
import { ActivityInfo, Network, SimulationActivityNode } from 'types';
+import { AddActivityInvalidState } from './default/cards/ActivityDesignerCard';
const Styled = {
ActivityGen: styled.div`
@@ -89,19 +90,19 @@ interface Props {
activities: any;
activityInfo: ActivityInfo;
network: Network;
+ addActivityInvalidState: AddActivityInvalidState | null;
+ setAddActivityInvalidState: (state: AddActivityInvalidState | null) => void;
toggle: () => void;
updater: AvtivityUpdater;
reset: () => void;
}
-interface AddActivityInvalidState {
- state: 'warning' | 'error';
- message: string;
-}
const ActivityGenerator: React.FC = ({
visible,
network,
activityInfo,
+ addActivityInvalidState,
+ setAddActivityInvalidState,
toggle,
reset,
updater,
@@ -109,8 +110,6 @@ const ActivityGenerator: React.FC = ({
if (!visible) return null;
const editActivityId = activityInfo.id;
- const [addActivityInvalidState, setAddActivityInvalidState] =
- useState(null);
const { sourceNode, targetNode, frequency, amount } = activityInfo;
const { l } = usePrefixedTranslation('cmps.designer.ActivityGenerator');
@@ -170,6 +169,7 @@ const ActivityGenerator: React.FC = ({
setAddActivityInvalidState({
state: 'error',
message: '',
+ action: 'save',
});
return;
}
@@ -290,7 +290,7 @@ const ActivityGenerator: React.FC = ({
- {addActivityInvalidState?.state && (
+ {addActivityInvalidState?.state && addActivityInvalidState.action === 'save' && (
setAddActivityInvalidState(null)}
diff --git a/src/components/designer/default/cards/ActivityDesignerCard.tsx b/src/components/designer/default/cards/ActivityDesignerCard.tsx
index 63b15420e6..ac890c86f9 100644
--- a/src/components/designer/default/cards/ActivityDesignerCard.tsx
+++ b/src/components/designer/default/cards/ActivityDesignerCard.tsx
@@ -1,19 +1,21 @@
-import React, { useState } from 'react';
+import React, { useEffect, useState } from 'react';
import {
ArrowDownOutlined,
ArrowRightOutlined,
ArrowUpOutlined,
- DeleteOutlined,
CopyOutlined,
+ DeleteOutlined,
} from '@ant-design/icons';
import styled from '@emotion/styled';
-import { Button, Tooltip } from 'antd';
+import { Alert, Button, Tooltip } from 'antd';
import { usePrefixedTranslation } from 'hooks';
import { useTheme } from 'hooks/useTheme';
+import { Status } from 'shared/types';
+import { getDocker } from 'lib/docker/dockerService';
+import { useStoreActions } from 'store';
import { ThemeColors } from 'theme/colors';
import { ActivityInfo, Network, SimulationActivity } from 'types';
import ActivityGenerator from '../../ActivityGenerator';
-import { useStoreActions } from 'store';
const Styled = {
AddNodes: styled.div`
@@ -135,6 +137,12 @@ interface Props {
visible: boolean;
}
+export interface AddActivityInvalidState {
+ state: 'warning' | 'error';
+ action: 'start' | 'save';
+ message: string;
+}
+
const defaultActivityInfo: ActivityInfo = {
id: undefined,
sourceNode: undefined,
@@ -146,18 +154,94 @@ const defaultActivityInfo: ActivityInfo = {
const ActivityDesignerCard: React.FC = ({ visible, network }) => {
const [isSimulationActive, setIsStartSimulationActive] = React.useState(false);
const [isAddActivityActive, setIsAddActivityActive] = React.useState(false);
-
- const { addSimulationActivity } = useStoreActions(s => s.network);
- const { lightning } = network.nodes;
-
+ const [addActivityInvalidState, setAddActivityInvalidState] =
+ useState(null);
const [activityInfo, setActivityInfo] = useState(defaultActivityInfo);
const theme = useTheme();
const { l } = usePrefixedTranslation(
'cmps.designer.default.cards.ActivityDesignerCard',
);
- const numberOfActivities = network.simulationActivities.length;
- const { removeSimulationActivity } = useStoreActions(s => s.network);
+ const {
+ addSimulationActivity,
+ removeSimulationActivity,
+ startSimulation,
+ stopSimulation,
+ } = useStoreActions(s => s.network);
+ const { lightning } = network.nodes;
+
+ const activities = network.simulationActivities ?? [];
+ const numberOfActivities = activities.length;
+
+ const isSimulationContainerRunning = async () => {
+ const docker = await getDocker();
+ const containers = await docker.listContainers();
+ const simContainer = containers.find(c => {
+ // remove the leading '/' from the container name
+ const name = c.Names[0].substring(1);
+ return name === `polar-n${network.id}-simln`;
+ });
+ return simContainer?.State === 'restarting' || simContainer?.State === 'running';
+ };
+
+ useEffect(() => {
+ isSimulationContainerRunning().then(isRunning => {
+ setIsStartSimulationActive(isRunning);
+ });
+ }, []);
+
+ const startSimulationActivity = () => {
+ if (network.status !== Status.Started) {
+ setAddActivityInvalidState({
+ state: 'warning',
+ message: l('startWarning'),
+ action: 'start',
+ });
+ setIsStartSimulationActive(false);
+ return;
+ }
+ if (numberOfActivities === 0) {
+ setIsAddActivityActive(true);
+ setAddActivityInvalidState({
+ state: 'warning',
+ message: l('NoActivityAddedWarning'),
+ action: 'start',
+ });
+ setIsStartSimulationActive(false);
+ return;
+ }
+ const allNotStartedNodesSet = new Set();
+ const nodes = network.simulationActivities.flatMap(activity => {
+ const activityNodes = new Set([activity.source.label, activity.destination.label]);
+ return lightning
+ .filter(node => node.status !== Status.Started && activityNodes.has(node.name))
+ .filter(node => {
+ const notStarted = !allNotStartedNodesSet.has(node.name);
+ if (notStarted) {
+ allNotStartedNodesSet.add(node.name);
+ }
+ return notStarted;
+ });
+ });
+ if (nodes.length > 0) {
+ setIsAddActivityActive(true);
+ setAddActivityInvalidState({
+ state: 'warning',
+ message: l('startWarning'),
+ action: 'start',
+ });
+ setIsStartSimulationActive(false);
+ return;
+ }
+ setAddActivityInvalidState(null);
+ if (isSimulationActive) {
+ setIsStartSimulationActive(false);
+ stopSimulation({ id: network.id });
+ return;
+ }
+ setIsStartSimulationActive(true);
+ startSimulation({ id: network.id });
+ };
const toggleAddActivity = () => {
setIsAddActivityActive(prev => !prev);
@@ -229,9 +313,11 @@ const ActivityDesignerCard: React.FC = ({ visible, network }) => {
{l('addActivitiesDesc')}
= ({ visible, network }) => {
{` (${numberOfActivities})`}
- {network.simulationActivities.map(activity => (
+ {activities.map(activity => (
= ({ visible, network }) => {
))}
+ {addActivityInvalidState?.state && addActivityInvalidState.action === 'start' && (
+ setAddActivityInvalidState(null)}
+ type="warning"
+ message={addActivityInvalidState?.message || l('startWarning')}
+ closable={true}
+ showIcon
+ style={{ marginTop: 20 }}
+ />
+ )}
setIsStartSimulationActive(!isSimulationActive)}
+ onClick={startSimulationActivity}
>
{isSimulationActive ? l('stop') : l('start')}
diff --git a/src/i18n/locales/en-US.json b/src/i18n/locales/en-US.json
index 067587504e..f419635fbf 100644
--- a/src/i18n/locales/en-US.json
+++ b/src/i18n/locales/en-US.json
@@ -103,6 +103,8 @@
"cmps.designer.default.cards.ActivityDesignerCard.duplicateBtnTip": "Duplicate",
"cmps.designer.default.cards.ActivityDesignerCard.start": "Start",
"cmps.designer.default.cards.ActivityDesignerCard.stop": "Stop",
+ "cmps.designer.default.cards.ActivityDesignerCard.startWarning": "The network and activity nodes must be started to run simulations",
+ "cmps.designer.default.cards.ActivityDesignerCard.NoActivityAddedWarning": "Please add at least one activity to run simulations",
"cmps.designer.default.cards.NetworkDesignerCard.showVersions": "Show All Versions",
"cmps.designer.default.cards.NetworkDesignerCard.checkUpdates": "Check for new Node Versions",
"cmps.designer.default.ImageUpdatesModal.title": "Check for new Node Versions",
diff --git a/src/lib/docker/composeFile.ts b/src/lib/docker/composeFile.ts
index 398e798437..e2bed5c6a6 100644
--- a/src/lib/docker/composeFile.ts
+++ b/src/lib/docker/composeFile.ts
@@ -8,7 +8,7 @@ import {
} from 'shared/types';
import { bitcoinCredentials, dockerConfigs, eclairCredentials } from 'utils/constants';
import { getContainerName } from 'utils/network';
-import { bitcoind, clightning, eclair, lnd, tapd } from './nodeTemplates';
+import { bitcoind, clightning, eclair, lnd, simln, tapd } from './nodeTemplates';
export interface ComposeService {
image: string;
@@ -47,12 +47,24 @@ class ComposeFile {
environment: {
USERID: '${USERID:-1000}',
GROUPID: '${GROUPID:-1000}',
+ ...service.environment,
},
stop_grace_period: '2m',
...service,
};
}
+ addSimLn(networkId: number) {
+ const svc: ComposeService = simln(
+ dockerConfigs['simln'].name,
+ `polar-n${networkId}-simln`,
+ dockerConfigs['simln'].imageName,
+ dockerConfigs['simln'].command,
+ { ...dockerConfigs['simln'].env },
+ );
+ this.addService(svc);
+ }
+
addBitcoind(node: BitcoinNode) {
const { name, version, ports } = node;
const { rpc, p2p, zmqBlock, zmqTx } = ports;
diff --git a/src/lib/docker/dockerService.ts b/src/lib/docker/dockerService.ts
index aa7df81675..c8a45d8b36 100644
--- a/src/lib/docker/dockerService.ts
+++ b/src/lib/docker/dockerService.ts
@@ -24,6 +24,13 @@ import { migrateNetworksFile } from 'utils/migrations';
import { isLinux, isMac } from 'utils/system';
import ComposeFile from './composeFile';
+type SimulationActivity = {
+ source: string;
+ destination: string;
+ interval_secs: number;
+ amount_msat: number;
+};
+
let dockerInst: Dockerode | undefined;
/**
* Creates a new Dockerode instance by detecting the docker socket
@@ -124,6 +131,7 @@ class DockerService implements DockerLibrary {
async saveComposeFile(network: Network) {
const file = new ComposeFile(network.id);
const { bitcoin, lightning, tap } = network.nodes;
+ const simulationActivityExists = network.simulationActivities.length > 0;
bitcoin.forEach(node => file.addBitcoind(node));
lightning.forEach(node => {
@@ -152,6 +160,9 @@ class DockerService implements DockerLibrary {
file.addTapd(tapd, lndBackend as LndNode);
}
});
+ if (simulationActivityExists) {
+ file.addSimLn(network.id);
+ }
const yml = yaml.dump(file.content);
const path = join(network.path, 'docker-compose.yml');
@@ -159,6 +170,74 @@ class DockerService implements DockerLibrary {
info(`saved compose file for '${network.name}' at '${path}'`);
}
+ /**
+ * Constructs the contents of sim.json file for the simulation activity
+ * @param network the network to start
+ */
+ async constructSimJson(network: Network) {
+ const nodes = new Set();
+ const activities = new Set();
+ network.simulationActivities.map(activity => {
+ nodes.add({
+ id: activity.source.id,
+ address: activity.source.address,
+ macaroon: activity.source.macaroon,
+ cert: activity.source.clientCert ?? activity.source.clientKey,
+ });
+ nodes.add({
+ id: activity.destination.id,
+ address: activity.destination.address,
+ macaroon: activity.destination.macaroon,
+ cert: activity.destination.clientCert ?? activity.destination.clientKey,
+ });
+
+ activities.add({
+ source: activity.source.id,
+ destination: activity.destination.id,
+ interval_secs: activity.intervalSecs,
+ amount_msat: activity.amountMsat,
+ });
+ });
+ return {
+ nodes: Array.from(nodes),
+ activities: Array.from(activities) as SimulationActivity[],
+ };
+ }
+
+ /**
+ * Start a simulation activity in the network using docker compose
+ * @param network the network containing the simulation activity
+ */
+ async startSimulationActivity(network: Network) {
+ const simJson = await this.constructSimJson(network);
+ console.log('simJson', simJson);
+ info(`simJson: ${simJson}`);
+ await this.ensureDirs(network, [
+ ...network.nodes.bitcoin,
+ ...network.nodes.lightning,
+ ...network.nodes.tap,
+ ]);
+ const simjsonPath = nodePath(network, 'simln', 'sim.json');
+ await write(simjsonPath, JSON.stringify(simJson));
+ const result = await this.execute(compose.upOne, 'simln', this.getArgs(network));
+ info(`Simulation activity started:\n ${result.out || result.err}`);
+ }
+
+ /**
+ * Stop a simulation activity in the network using docker compose
+ * @param network the network containing the simulation activity
+ */
+ async stopSimulationActivity(network: Network) {
+ info(`Stopping simulation activity for ${network.name}`);
+ info(` - path: ${network.path}`);
+ const result = await this.execute(compose.stopOne, 'simln', this.getArgs(network));
+ info(`Simulation activity stopped:\n ${result.out || result.err}`);
+
+ // remove container to avoid conflicts when starting the network again
+ await this.execute(compose.rm as any, this.getArgs(network), 'simln');
+ info(`Removed simln container`);
+ }
+
/**
* Start a network using docker compose
* @param network the network to start
@@ -169,8 +248,19 @@ class DockerService implements DockerLibrary {
info(`Starting docker containers for ${network.name}`);
info(` - path: ${network.path}`);
- const result = await this.execute(compose.upAll, this.getArgs(network));
- info(`Network started:\n ${result.out || result.err}`);
+
+ // we don't want to start the simln service when starting the network
+ // because it depends on the running lightning nodes and the simulation
+ // activity should be started separately based on user preference
+ const servicesToStart = this.getServicesToStart(
+ [...bitcoin, ...lightning, ...tap],
+ ['simln'],
+ );
+
+ for (const service of servicesToStart) {
+ const result = await this.execute(compose.upOne, service, this.getArgs(network));
+ info(`Network started: ${service}\n ${result.out || result.err}`);
+ }
}
/**
@@ -283,6 +373,24 @@ class DockerService implements DockerLibrary {
}
}
+ /**
+ * Filter out services based on exclude list and return a list of service names to start
+ * @param nodes Array of all nodes
+ * @param exclude Array of container names to exclude
+ */
+ private getServicesToStart(
+ nodes:
+ | CommonNode[]
+ | {
+ name: 'simln';
+ }[],
+ exclude: string[],
+ ): string[] {
+ return nodes
+ .map(node => node.name)
+ .filter(serviceName => !exclude.includes(serviceName));
+ }
+
/**
* Helper method to trap and format exceptions thrown and
* @param cmd the compose function to call
diff --git a/src/lib/docker/nodeTemplates.ts b/src/lib/docker/nodeTemplates.ts
index d26360702b..6fb39de0fe 100644
--- a/src/lib/docker/nodeTemplates.ts
+++ b/src/lib/docker/nodeTemplates.ts
@@ -5,6 +5,24 @@ import { ComposeService } from './composeFile';
// simple function to remove all line-breaks and extra white-space inside of a string
const trimInside = (text: string): string => text.replace(/\s+/g, ' ').trim();
+export const simln = (
+ name: string,
+ container: string,
+ image: string,
+ command: string,
+ environment: Record,
+): ComposeService => ({
+ image,
+ container_name: container,
+ hostname: name,
+ command: trimInside(command),
+ environment,
+ restart: 'always',
+ volumes: [`./volumes/${name}:/home/simln/.simln`],
+ expose: [],
+ ports: [],
+});
+
export const bitcoind = (
name: string,
container: string,
diff --git a/src/store/models/network.ts b/src/store/models/network.ts
index 3142b651c3..754028c672 100644
--- a/src/store/models/network.ts
+++ b/src/store/models/network.ts
@@ -1,4 +1,4 @@
-import { ipcRenderer, remote, SaveDialogOptions } from 'electron';
+import { remote, SaveDialogOptions } from 'electron';
import { info } from 'electron-log';
import { join } from 'path';
import { push } from 'connected-react-router';
@@ -142,6 +142,20 @@ export interface NetworkModel {
NetworkModel,
{ id: number; status: Status; only?: string; all?: boolean; error?: Error }
>;
+ startSimulation: Thunk<
+ NetworkModel,
+ { id: number },
+ StoreInjections,
+ RootModel,
+ Promise
+ >;
+ stopSimulation: Thunk<
+ NetworkModel,
+ { id: number },
+ StoreInjections,
+ RootModel,
+ Promise
+ >;
start: Thunk>;
stop: Thunk>;
stopAll: Thunk>;
@@ -712,26 +726,46 @@ const networkModel: NetworkModel = {
throw e;
}
}),
- stopAll: thunk(async (actions, _, { getState }) => {
- let networks = getState().networks.filter(
- n => n.status === Status.Started || n.status === Status.Stopping,
- );
- if (networks.length === 0) {
- ipcRenderer.send('docker-shut-down');
- }
- networks.forEach(async network => {
- await actions.stop(network.id);
- });
- setInterval(async () => {
- networks = getState().networks.filter(
- n => n.status === Status.Started || n.status === Status.Stopping,
- );
- if (networks.length === 0) {
- await actions.save();
- ipcRenderer.send('docker-shut-down');
+ startSimulation: thunk(
+ async (actions, { id }, { getState, injections, getStoreActions }) => {
+ const network = getState().networks.find(n => n.id === id);
+ if (!network) throw new Error(l('networkByIdErr', { networkId: id }));
+ try {
+ const nodes = [
+ ...network.nodes.lightning,
+ ...network.nodes.bitcoin,
+ ...network.nodes.tap,
+ ];
+ nodes.forEach(n => {
+ if (n.status !== Status.Started) {
+ throw new Error(l('nodeNotStarted', { name: n.name }));
+ }
+ });
+ await injections.dockerService.saveComposeFile(network);
+ await injections.dockerService.startSimulationActivity(network);
+ info(`Simulation started for network '${network.name}'`);
+ await getStoreActions().app.getDockerImages();
+ } catch (e: any) {
+ info(`unable to start simulation for network '${network.name}'`, e.message);
+ throw e;
}
- }, 2000);
- }),
+ },
+ ),
+ stopSimulation: thunk(
+ async (actions, { id }, { getState, injections, getStoreActions }) => {
+ const network = getState().networks.find(n => n.id === id);
+ if (!network) throw new Error(l('networkByIdErr', { networkId: id }));
+ try {
+ await injections.dockerService.stopSimulationActivity(network);
+ console.log('Simulation stopped');
+ info(`Simulation stopped for network '${network.name}'`);
+ await getStoreActions().app.getDockerImages();
+ } catch (e: any) {
+ info(`unable to stop simulation for network '${network.name}'`, e.message);
+ throw e;
+ }
+ },
+ ),
toggle: thunk(async (actions, networkId, { getState }) => {
const network = getState().networks.find(n => n.id === networkId);
if (!network) throw new Error(l('networkByIdErr', { networkId }));
@@ -933,13 +967,14 @@ const networkModel: NetworkModel = {
// Create a shallow copy of the network to update the object reference to cause a rerender on setNetworks
const network = { ...networks[networkIndex] };
- const nextId = Math.max(0, ...network.simulationActivities.map(n => n.id)) + 1;
+ const activities = network.simulationActivities ?? [];
+ const nextId = Math.max(0, ...activities.map(n => n.id)) + 1;
const activity = { ...rest, networkId, id: nextId };
const updatedNetworks = [...networks];
updatedNetworks[networkIndex] = {
...network,
- simulationActivities: [...network.simulationActivities, activity],
+ simulationActivities: [...activities, activity],
};
actions.setNetworks(updatedNetworks);
diff --git a/src/types/index.ts b/src/types/index.ts
index 678793911f..477d82357f 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -83,6 +83,7 @@ export interface DockerConfig {
variables: string[];
dataDir?: string;
apiDir?: string;
+ env?: Record;
}
export interface DockerRepoImage {
@@ -113,6 +114,8 @@ export interface DockerLibrary {
saveComposeFile: (network: Network) => Promise;
start: (network: Network) => Promise;
stop: (network: Network) => Promise;
+ startSimulationActivity: (network: Network) => Promise;
+ stopSimulationActivity: (network: Network) => Promise;
startNode: (network: Network, node: CommonNode) => Promise;
stopNode: (network: Network, node: CommonNode) => Promise;
removeNode: (network: Network, node: CommonNode) => Promise;
diff --git a/src/utils/config.ts b/src/utils/config.ts
index cdadf02810..152bfdc800 100644
--- a/src/utils/config.ts
+++ b/src/utils/config.ts
@@ -32,7 +32,7 @@ export const networksPath = join(dataPath, 'networks');
*/
export const nodePath = (
network: Network,
- implementation: NodeImplementation,
+ implementation: NodeImplementation | 'simln',
name: string,
): string =>
join(network.path, 'volumes', dockerConfigs[implementation].volumeDirName, name);
diff --git a/src/utils/constants.ts b/src/utils/constants.ts
index e29e957ea8..10aef81652 100644
--- a/src/utils/constants.ts
+++ b/src/utils/constants.ts
@@ -83,7 +83,7 @@ export const eclairCredentials = {
pass: 'eclairpw',
};
-export const dockerConfigs: Record = {
+export const dockerConfigs: Record = {
LND: {
name: 'LND',
imageName: 'polarlightning/lnd',
@@ -239,6 +239,20 @@ export const dockerConfigs: Record = {
// if vars are modified, also update composeFile.ts & the i18n strings for cmps.nodes.CommandVariables
variables: ['name', 'containerName', 'lndName'],
},
+ simln: {
+ name: 'simln',
+ imageName: 'bitcoindevproject/simln:0.2.0',
+ logo: '',
+ platforms: ['mac', 'linux', 'windows'],
+ volumeDirName: 'simln',
+ env: {
+ SIMFILE_PATH: '/home/simln/.simln/sim.json',
+ DATA_DIR: '/home/simln/.simln',
+ LOG_LEVEL: 'info',
+ },
+ command: '',
+ variables: ['DEFAULT_SIMFILE_PATH', 'LOG_LEVEL', 'DATA_DIR'],
+ },
};
/**
diff --git a/src/utils/tests/renderWithProviders.tsx b/src/utils/tests/renderWithProviders.tsx
index 0fdc9d5ccb..4161292434 100644
--- a/src/utils/tests/renderWithProviders.tsx
+++ b/src/utils/tests/renderWithProviders.tsx
@@ -51,6 +51,8 @@ export const injections: StoreInjections = {
removeNode: jest.fn(),
saveNetworks: jest.fn(),
loadNetworks: jest.fn(),
+ startSimulationActivity: jest.fn(),
+ stopSimulationActivity: jest.fn(),
},
repoService: {
load: jest.fn(),