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 8f08661fe1..60c65d1069 100644 --- a/docker/nodes.json +++ b/docker/nodes.json @@ -46,6 +46,10 @@ "compatibility": { "0.3.0-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 f3cfd8f39b..de3d2f15ec 100644 --- a/src/i18n/locales/en-US.json +++ b/src/i18n/locales/en-US.json @@ -102,6 +102,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 095716278a..eb8186d1a4 100644 --- a/src/store/models/network.ts +++ b/src/store/models/network.ts @@ -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>; toggle: Thunk>; @@ -711,6 +725,46 @@ const networkModel: NetworkModel = { throw e; } }), + 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; + } + }, + ), + 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 })); @@ -912,13 +966,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 1e1d7732d0..407b81f096 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -24,7 +24,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 4bd8a585d9..3def9f7210 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(),