diff --git a/.vscode/settings.json b/.vscode/settings.json index 0b6af845a..3f80f0340 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -68,5 +68,5 @@ "editor.codeActionsOnSave": { "source.fixAll.eslint": "explicit" }, - "typescript.tsdk": "node_modules/typescript/lib" + "typescript.tsdk": "node_modules/typescript/lib", } diff --git a/src/components/designer/ActivityGenerator.tsx b/src/components/designer/ActivityGenerator.tsx new file mode 100644 index 000000000..9f81fb651 --- /dev/null +++ b/src/components/designer/ActivityGenerator.tsx @@ -0,0 +1,338 @@ +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` + display: flex; + flex-direction: column; + row-gap: 10px; + align-items: start; + width: 100%; + border-radius: 4px; + `, + Divider: styled.div` + height: 1px; + width: 100%; + margin: 15px 0; + background: #545353e6; + opacity: 0.5; + `, + DeleteButton: styled(Button)` + border: none; + height: 100%; + color: red; + opacity: 0.6; + + &:hover { + background: red; + color: #fff; + border: 1px solid #fff; + } + `, + AmountInput: styled(InputNumber)` + width: 100%; + border-radius: 4px; + margin: 0 0 10px 0; + padding: 5px 0; + `, + ActivityForm: styled(Form)` + display: flex; + flex-direction: column; + row-gap: 10px; + align-items: start; + width: 100%; + border-radius: 4px; + border: 1px solid #545353e6; + padding: 10px; + `, + Label: styled.p` + margin: 0; + padding: 0; + `, + NodeWrapper: styled.div` + display: flex; + align-items: center; + justify-content: start; + column-gap: 15px; + width: 100%; + `, + NodeSelect: styled(Select)` + width: 100%; + border-radius: 4px; + margin: 0 0 10px 0; + `, + Save: styled(Button)<{ canSave: boolean }>` + width: 100%; + opacity: ${props => (props.canSave ? '1' : '0.6')}; + cursor: ${props => (props.canSave ? 'pointer' : 'not-allowed')}; + + &:hover { + background: ${props => (props.canSave ? '#d46b08' : '')}; + } + `, + Cancel: styled(Button)` + width: 100%; + `, +}; + +interface AvtivityUpdater { + (params: { name: K; value: ActivityInfo[K] }): void; +} + +interface Props { + visible: boolean; + activities: any; + activityInfo: ActivityInfo; + network: Network; + addActivityInvalidState: AddActivityInvalidState | null; + setAddActivityInvalidState: (state: AddActivityInvalidState | null) => void; + toggle: () => void; + updater: AvtivityUpdater; + reset: () => void; +} + +const ActivityGenerator: React.FC = ({ + visible, + network, + activityInfo, + addActivityInvalidState, + setAddActivityInvalidState, + toggle, + reset, + updater, +}) => { + if (!visible) return null; + + const editActivityId = activityInfo.id; + const { sourceNode, targetNode, frequency, amount } = activityInfo; + + const { l } = usePrefixedTranslation('cmps.designer.ActivityGenerator'); + const nodeState = useStoreState(s => s.lightning); + const { lightning } = network.nodes; + + // get store actions for adding activities + const { addSimulationActivity, updateSimulationActivity } = useStoreActions( + s => s.network, + ); + + const getAuthDetails = (node: LightningNode) => { + const id = nodeState && nodeState.nodes[node.name]?.info?.pubkey; + + if (!id) return; + + switch (node.implementation) { + case 'LND': + const lnd = node as LndNode; + return { + id, + macaroon: lnd.paths.adminMacaroon, + tlsCert: lnd.paths.tlsCert, + clientCert: lnd.paths.tlsCert, + clientKey: '', + address: `https://host.docker.internal:${lnd.ports.grpc}`, + }; + case 'c-lightning': + const cln = node as CLightningNode; + return { + id, + macaroon: cln.paths.macaroon, + tlsCert: cln.paths.tlsCert, + clientCert: cln.paths.tlsClientCert, + clientKey: cln.paths.tlsClientKey, + address: `https://host.docker.internal:${cln.ports.grpc}`, + }; + default: + return { + id, + macaroon: '', + tlsCert: '', + clientCert: '', + clientKey: '', + address: '', + }; + } + }; + + const handleAddActivity = async () => { + setAddActivityInvalidState(null); + if (!sourceNode || !targetNode) return; + const sourceNodeDetails = getAuthDetails(sourceNode); + const targetNodeDetails = getAuthDetails(targetNode); + + if (!sourceNodeDetails || !targetNodeDetails) { + setAddActivityInvalidState({ + state: 'error', + message: '', + action: 'save', + }); + return; + } + + const sourceSimulationNode: SimulationActivityNode = { + id: sourceNodeDetails.id || '', + label: sourceNode.name, + type: sourceNode.implementation, + address: sourceNodeDetails.address, + macaroon: sourceNodeDetails.macaroon, + tlsCert: sourceNodeDetails.tlsCert || '', + clientCert: sourceNodeDetails.clientCert, + clientKey: sourceNodeDetails.clientKey, + }; + + const targetSimulationNode: SimulationActivityNode = { + id: targetNodeDetails.id || '', + label: targetNode.name, + type: targetNode.implementation, + address: targetNodeDetails.address, + macaroon: targetNodeDetails.macaroon, + tlsCert: targetNodeDetails.tlsCert || '', + clientCert: targetNodeDetails.clientCert, + clientKey: targetNodeDetails.clientKey, + }; + const activity = { + source: sourceSimulationNode, + destination: targetSimulationNode, + amountMsat: amount, + intervalSecs: frequency, + networkId: network.id, + }; + + editActivityId + ? await updateSimulationActivity({ ...activity, id: editActivityId }) + : await addSimulationActivity(activity); + reset(); + toggle(); + }; + + const handleSourceNodeChange = (selectedNodeName: string) => { + const selectedNode = lightning.find(n => n.name === selectedNodeName); + if (selectedNode?.name !== targetNode?.name) { + updater({ name: 'sourceNode', value: selectedNode }); + } + }; + const handleTargetNodeChange = (selectedNodeName: string) => { + const selectedNode = lightning.find(n => n.name === selectedNodeName); + if (selectedNode?.name !== sourceNode?.name) { + updater({ name: 'targetNode', value: selectedNode }); + } + }; + const handleFrequencyChange = (newValue: number) => { + updater({ name: 'frequency', value: newValue < 1 ? 1 : newValue }); + }; + const handleAmountChange = (newValue: number) => { + updater({ name: 'amount', value: newValue < 1 ? 1 : newValue }); + }; + + const handleCancel = () => { + toggle(); + reset(); + }; + + const sourceNodes = React.useMemo(() => { + return lightning.filter(n => !targetNode || n.id !== targetNode.id); + }, [lightning, targetNode]); + + const targetNodes = React.useMemo(() => { + return lightning.filter(n => !sourceNode || n.id !== sourceNode.id); + }, [lightning, sourceNode]); + + return ( + + + {l('sourceNode')} + handleSourceNodeChange(e as string)} + > + {sourceNodes.map(n => ( + + {n.name} + + ))} + + + {l('destinationNode')} + handleTargetNodeChange(e as string)} + > + {targetNodes.map(n => ( + + {n.name} + + ))} + + + {l('frequency')} + + + {l('amount')} + handleAmountChange(e as number)} + /> + + + {l('cancel')} + + {l('save')} + + + + {addActivityInvalidState?.state && addActivityInvalidState.action === 'save' && ( + setAddActivityInvalidState(null)} + type="warning" + message={addActivityInvalidState?.message || l('startWarning')} + closable={true} + showIcon + /> + )} + + ); +}; + +const IntegerStep: React.FC<{ + frequency: number; + onChange: (newValue: number) => void; +}> = ({ frequency, onChange }) => { + return ( + + + + + + onChange(newValue ?? 0)} + /> + + + ); +}; + +export default ActivityGenerator; diff --git a/src/components/designer/Sidebar.tsx b/src/components/designer/Sidebar.tsx index 40387f749..3a39f9b7c 100644 --- a/src/components/designer/Sidebar.tsx +++ b/src/components/designer/Sidebar.tsx @@ -32,7 +32,7 @@ const Sidebar: React.FC = ({ network, chart }) => { return link && ; } - return ; + return ; }, [network, chart.selected, chart.links]); return <>{cmp}; diff --git a/src/components/designer/custom/NodeInner.tsx b/src/components/designer/custom/NodeInner.tsx index 01be90b19..0a6e051bd 100644 --- a/src/components/designer/custom/NodeInner.tsx +++ b/src/components/designer/custom/NodeInner.tsx @@ -2,11 +2,11 @@ import React from 'react'; import styled from '@emotion/styled'; import { INodeInnerDefaultProps, ISize } from '@mrblenny/react-flow-chart'; import { useTheme } from 'hooks/useTheme'; +import { useStoreState } from 'store'; import { ThemeColors } from 'theme/colors'; import { LOADING_NODE_ID } from 'utils/constants'; import { Loader, StatusBadge } from 'components/common'; import NodeContextMenu from '../NodeContextMenu'; -import { useStoreState } from 'store'; const Styled = { Node: styled.div<{ size?: ISize; colors: ThemeColors['node']; isSelected: boolean }>` diff --git a/src/components/designer/default/DefaultSidebar.spec.tsx b/src/components/designer/default/DefaultSidebar.spec.tsx index 2bbecf5c7..c5b8a0e9e 100644 --- a/src/components/designer/default/DefaultSidebar.spec.tsx +++ b/src/components/designer/default/DefaultSidebar.spec.tsx @@ -58,7 +58,7 @@ describe('DefaultSidebar Component', () => { }, }; - const result = renderWithProviders(, { + const result = renderWithProviders(, { initialState, }); return { diff --git a/src/components/designer/default/DefaultSidebar.tsx b/src/components/designer/default/DefaultSidebar.tsx index 0579f35b3..7bb811908 100644 --- a/src/components/designer/default/DefaultSidebar.tsx +++ b/src/components/designer/default/DefaultSidebar.tsx @@ -1,34 +1,72 @@ -import React from 'react'; -import { CloudSyncOutlined } from '@ant-design/icons'; +import React, { ReactNode } from 'react'; import styled from '@emotion/styled'; -import { Button, Switch } from 'antd'; +import { Button } from 'antd'; import { usePrefixedTranslation } from 'hooks'; import { NodeImplementation } from 'shared/types'; import { useStoreActions, useStoreState } from 'store'; +import { Network } from 'types'; import { dockerConfigs } from 'utils/constants'; import { getPolarPlatform } from 'utils/system'; import SidebarCard from '../SidebarCard'; -import DraggableNode from './DraggableNode'; +import ActivityDesignerCard from './cards/ActivityDesignerCard'; +import NetworkDesignerCard from './cards/NetworkDesignerCard'; const Styled = { - AddNodes: styled.h3` - margin-top: 30px; - `, - AddDesc: styled.p` - opacity: 0.5; - font-size: 12px; - `, - Toggle: styled.div` + Title: styled.div` display: flex; justify-content: space-between; - margin: 10px 5px; + align-items: center; + margin-bottom: 30px; + > p { + margin: 0; + font-size: 16px; + } `, - UpdatesButton: styled(Button)` - margin-top: 30px; + DesignerButtons: styled(Button.Group)` + display: flex; + justify-content: center; + column-gap: 1px; + align-items: center; + height: 40px; + border-radius: 4px; + cursor: pointer; + `, + Button: styled(Button)<{ active: boolean }>` + padding: 0; + margin: 0; + width: 40px; + height: 40px; + color: ${props => (props.active ? '#d46b08' : 'gray')}; + background: ${props => (props.active ? '#000' : '')}; + border: ${props => (props.active ? '1px solid #d46b08' : '')}; + + &:hover { + background: #d46b08; + color: #f7f2f2f2; + } + + &:focus { + background: ${props => (props.active ? '#000' : '#d46b08')}; + color: ${props => (props.active ? '#f7f2f2f2' : '#000')}; + } `, }; -const DefaultSidebar: React.FC = () => { +interface Node { + label: string; + logo: string; + version: string; + type: string; + latest: boolean; + customId?: string; +} + +interface Props { + network: Network; +} + +const DefaultSidebar: React.FC = ({ network }) => { + const [designerType, setDesignerType] = React.useState('network'); const { l } = usePrefixedTranslation('cmps.designer.default.DefaultSidebar'); const { updateSettings } = useStoreActions(s => s.app); @@ -40,14 +78,26 @@ const DefaultSidebar: React.FC = () => { const toggleVersions = () => updateSettings({ showAllNodeVersions: !showAll }); const toggleModal = () => showImageUpdates(); - const nodes: { - label: string; - logo: string; - version: string; - type: string; - latest: boolean; - customId?: string; - }[] = []; + const nodes: Node[] = []; + + const tabHeaders = [ + { key: 'network', tab: l('networkTitle') }, + { key: 'activity', tab: l('activityTitle') }, + ]; + const tabContents: Record = { + network: ( + + ), + activity: ( + + ), + }; // add custom nodes settings.nodeImages.custom.forEach(image => { @@ -76,32 +126,19 @@ const DefaultSidebar: React.FC = () => { }); return ( - -

{l('mainDesc')}

- {l('addNodesTitle')} - {l('addNodesDesc')} - - {l('showVersions')} - - - {nodes.map(({ label, logo, version, latest, type, customId }) => ( - - ))} - } - onClick={toggleModal} - > - {l('checkUpdates')} - + + + {designerType === 'network' ? ( +

{l('networkTitle')}

+ ) : ( +

{l('activityTitle')}

+ )} +
+ {tabContents[designerType]}
); }; diff --git a/src/components/designer/default/cards/ActivityDesignerCard.tsx b/src/components/designer/default/cards/ActivityDesignerCard.tsx new file mode 100644 index 000000000..3aee85d2a --- /dev/null +++ b/src/components/designer/default/cards/ActivityDesignerCard.tsx @@ -0,0 +1,384 @@ +import React, { useState } from 'react'; +import { useAsyncCallback } from 'react-async-hook'; +import { + ArrowDownOutlined, + ArrowRightOutlined, + ArrowUpOutlined, + CopyOutlined, + DeleteOutlined, +} from '@ant-design/icons'; +import styled from '@emotion/styled'; +import { Alert, Button, Tooltip } from 'antd'; +import { usePrefixedTranslation } from 'hooks'; +import { useTheme } from 'hooks/useTheme'; +import { Status } from 'shared/types'; +import { useStoreActions } from 'store'; +import { ThemeColors } from 'theme/colors'; +import { ActivityInfo, Network, SimulationActivity } from 'types'; +import ActivityGenerator from '../../ActivityGenerator'; + +const Styled = { + AddNodes: styled.div` + display: flex; + align-items: center; + width: 100%; + margin: 30px 0 10px 0; + > h3 { + margin: 0; + padding: 0; + } + `, + AddDesc: styled.p` + opacity: 0.5; + font-size: 12px; + margin-bottom: 20px; + `, + ActivityButtons: styled(Button.Group)` + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + border-radius: 4px; + cursor: pointer; + `, + Activity: styled(Button)<{ colors: ThemeColors['dragNode'] }>` + display: flex; + align-items: center; + justify-content: space-between; + overflow: hidden; + opacity: 1; + width: 100%; + height: 46px; + padding: 10px 15px; + margin-top: 20px; + border: 1px solid ${props => props.colors.border}; + box-shadow: 4px 2px 9px ${props => props.colors.shadow}; + border-radius: 4px; + font-weight: bold; + + &:hover { + border: 1px solid #d46b08; + color: #f7f2f2f2; + } + `, + CopyButton: styled(Button)` + border: none; + height: 100%; + opacity: 0.5; + + &:hover { + opacity: 1; + } + `, + Divider: styled.div` + height: 1px; + width: 100%; + margin: 30px 0 20px 0; + background: #545353e6; + opacity: 0.5; + `, + DeleteButton: styled(Button)` + border: none; + height: 100%; + color: red; + opacity: 0.5; + + &:hover { + color: red; + opacity: 1; + } + `, + NodeWrapper: styled.div` + display: flex; + align-items: center; + justify-content: start; + column-gap: 15px; + width: 100%; + `, + StartStopButton: styled(Button)<{ active: boolean; colors: ThemeColors['dragNode'] }>` + padding: 10px 15px; + margin: 40px 0 0 0; + width: 100%; + height: 46px; + color: ${props => (props.active ? '#d46b08' : '#fff')}; + background: ${props => (props.active ? '#000' : '')}; + border: 1px solid ${props => props.colors.border}; + box-shadow: 4px 2px 9px ${props => props.colors.shadow}; + + &:hover { + background: #d46b08; + color: #fff; + } + + &:focus { + background: ${props => (props.active ? '#000' : '#d46b08')}; + color: ${props => (props.active ? '#f7f2f2f2' : '#000')}; + } + `, + Toggle: styled(Button)` + border: none; + opacity: 0.6; + margin-left: 10px; + + &:hover { + color: #fff; + border: 1px solid #fff; + } + + &:focus { + color: #fff; + border: 1px solid #fff; + } + `, +}; + +interface Props { + network: Network; + visible: boolean; +} + +export interface AddActivityInvalidState { + state: 'warning' | 'error'; + action: 'start' | 'save'; + message: string; +} + +const defaultActivityInfo: ActivityInfo = { + id: undefined, + sourceNode: undefined, + targetNode: undefined, + amount: 1, + frequency: 1, +}; + +const ActivityDesignerCard: React.FC = ({ visible, network }) => { + const [isSimulationActive, setIsSimulationActive] = React.useState(false); + const [isAddActivityActive, setIsAddActivityActive] = React.useState(false); + const [addActivityInvalidState, setAddActivityInvalidState] = + useState(null); + const [activityInfo, setActivityInfo] = useState(defaultActivityInfo); + + const theme = useTheme(); + const { l } = usePrefixedTranslation( + 'cmps.designer.default.cards.ActivityDesignerCard', + ); + const { + addSimulationActivity, + removeSimulationActivity, + startSimulation, + stopSimulation, + } = useStoreActions(s => s.network); + const { notify } = useStoreActions(s => s.app); + const { lightning } = network.nodes; + + const activities = network.simulationActivities ?? []; + const numberOfActivities = activities.length; + + const startSimulationActivity = useAsyncCallback(async () => { + try { + if (network.status !== Status.Started) { + setAddActivityInvalidState({ + state: 'warning', + message: l('startWarning'), + action: 'start', + }); + setIsSimulationActive(false); + return; + } + if (numberOfActivities === 0) { + setIsAddActivityActive(true); + setAddActivityInvalidState({ + state: 'warning', + message: l('NoActivityAddedWarning'), + action: 'start', + }); + setIsSimulationActive(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', + }); + setIsSimulationActive(false); + return; + } + setAddActivityInvalidState(null); + if (isSimulationActive) { + setIsSimulationActive(false); + await stopSimulation({ id: network.id }); + return; + } + setIsSimulationActive(true); + await startSimulation({ id: network.id }); + } catch (error: any) { + setIsSimulationActive(false); + notify({ message: l('startError'), error }); + } + }); + + const toggleAddActivity = () => { + setIsAddActivityActive(prev => !prev); + }; + + const handleRemoveActivity = async ( + e: React.MouseEvent, + activity: SimulationActivity, + ) => { + e.stopPropagation(); + await removeSimulationActivity(activity); + }; + + const handleDuplicateActivity = async ( + e: React.MouseEvent, + activity: SimulationActivity, + ) => { + e.stopPropagation(); + await addSimulationActivity(activity); + }; + + const resolveLabelToNode = (name: string) => { + const selectedNode = lightning.find(n => n.name === name); + return selectedNode; + }; + const handleSelectActivity = (activity: SimulationActivity) => { + const sourceNode = resolveLabelToNode(activity.source.label); + const targetNode = resolveLabelToNode(activity.destination.label); + setActivityInfo({ + id: activity.id, + sourceNode, + targetNode, + amount: activity.amountMsat, + frequency: activity.intervalSecs, + }); + setIsAddActivityActive(true); + }; + + const resolveUpdater = ({ + name, + value, + }: { + name: T; + value: ActivityInfo[T]; + }) => { + setActivityInfo(prev => ({ ...prev, [name]: value })); + }; + + const reset = () => { + setActivityInfo(defaultActivityInfo); + }; + + const primaryCtaText = () => { + if (!isSimulationActive && startSimulationActivity.loading) { + return l('stopping'); + } + if (isSimulationActive && startSimulationActivity.loading) { + return l('starting'); + } + if (isSimulationActive && !startSimulationActivity.loading) { + return l('stop'); + } + return l('start'); + }; + + if (!visible) return null; + return ( + <> +
+

{l('mainDesc')}

+
+ +

{l('addActivitiesTitle')}

+ : } + onClick={toggleAddActivity} + /> +
+ {l('addActivitiesDesc')} + + +

+ {l('activities')} + {` (${numberOfActivities})`} +

+ + {activities.map(activity => ( + handleSelectActivity(activity)} + > + + {activity.source.label} + + {activity.destination.label} + + + handleDuplicateActivity(e, activity)} + icon={} + /> + + handleRemoveActivity(e, activity)} + icon={} + /> + + ))} + + {addActivityInvalidState?.state && addActivityInvalidState.action === 'start' && ( + setAddActivityInvalidState(null)} + type="warning" + message={addActivityInvalidState?.message || l('startWarning')} + closable={true} + showIcon + style={{ marginTop: 20 }} + /> + )} + + {primaryCtaText()} + + + ); +}; + +export default ActivityDesignerCard; diff --git a/src/components/designer/default/cards/NetworkDesignerCard.tsx b/src/components/designer/default/cards/NetworkDesignerCard.tsx new file mode 100644 index 000000000..b2681fc5b --- /dev/null +++ b/src/components/designer/default/cards/NetworkDesignerCard.tsx @@ -0,0 +1,85 @@ +import React from 'react'; +import { CloudSyncOutlined } from '@ant-design/icons'; +import styled from '@emotion/styled'; +import { Button, Switch } from 'antd'; +import { usePrefixedTranslation } from 'hooks'; +import DraggableNode from '../DraggableNode'; + +const Styled = { + AddNodes: styled.h3` + margin-top: 30px; + `, + AddDesc: styled.p` + opacity: 0.5; + font-size: 12px; + `, + Toggle: styled.div` + display: flex; + justify-content: space-between; + margin: 10px 5px; + `, + UpdatesButton: styled(Button)` + margin-top: 30px; + `, +}; + +interface Node { + label: string; + logo: string; + version: string; + type: string; + latest: boolean; + customId?: string; +} + +interface Props { + nodes: Node[]; + showAll: boolean; + toggleVersions: () => void; + toggleModal: () => void; + visible: boolean; +} + +const NetworkDesignerCard: React.FC = ({ + nodes, + showAll, + toggleVersions, + toggleModal, + visible, +}) => { + const { l } = usePrefixedTranslation('cmps.designer.default.cards.NetworkDesignerCard'); + if (!visible) return null; + return ( + <> +
+

{l('mainDesc')}

+
+ {l('addNodesTitle')} + {l('addNodesDesc')} + + {l('showVersions')} + + + {nodes.map(({ label, logo, version, latest, type, customId }) => ( + + ))} + } + onClick={toggleModal} + > + {l('checkUpdates')} + + + ); +}; + +export default NetworkDesignerCard; diff --git a/src/i18n/locales/en-US.json b/src/i18n/locales/en-US.json index 4e58a7144..19c16a1f7 100644 --- a/src/i18n/locales/en-US.json +++ b/src/i18n/locales/en-US.json @@ -92,12 +92,25 @@ "cmps.designer.bitcoind.actions.SendOnChainModal.balanceError": "Amount must be less than the {{backendName}} balance of {{balance}} BTC", "cmps.designer.bitcoind.actions.SendOnChainModal.successTitle": "Successfully sent {{amount}} BTC", "cmps.designer.bitcoind.actions.SendOnChainModal.successDesc": "Transaction ID: {{txid}}", - "cmps.designer.default.DefaultSidebar.title": "Network Designer", - "cmps.designer.default.DefaultSidebar.mainDesc": "Click on an element in the designer to see details", - "cmps.designer.default.DefaultSidebar.addNodesTitle": "Add Nodes", - "cmps.designer.default.DefaultSidebar.addNodesDesc": "Drag a node below onto the canvas to add it to the network", - "cmps.designer.default.DefaultSidebar.showVersions": "Show All Versions", - "cmps.designer.default.DefaultSidebar.checkUpdates": "Check for new Node Versions", + "cmps.designer.default.DefaultSidebar.networkTitle": "Network Designer", + "cmps.designer.default.DefaultSidebar.activityTitle": "Activity Designer", + "cmps.designer.default.cards.NetworkDesignerCard.mainDesc": "Click on an element in the designer to see details", + "cmps.designer.default.cards.NetworkDesignerCard.addNodesTitle": "Add Nodes", + "cmps.designer.default.cards.NetworkDesignerCard.addNodesDesc": "Drag a node below onto the canvas to add it to the network", + "cmps.designer.default.cards.ActivityDesignerCard.mainDesc": "Click on an element in the designer to see details", + "cmps.designer.default.cards.ActivityDesignerCard.startError": "Unable to start the simulation for the network", + "cmps.designer.default.cards.ActivityDesignerCard.addActivitiesTitle": "Add Activities", + "cmps.designer.default.cards.ActivityDesignerCard.addActivitiesDesc": "Run a simulation between running nodes in the network", + "cmps.designer.default.cards.ActivityDesignerCard.activities": "Activities", + "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.stopping": "Stopping", + "cmps.designer.default.cards.ActivityDesignerCard.starting": "Starting", + "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", "cmps.designer.default.ImageUpdatesModal.updatesTitle": "There are new node versions available", "cmps.designer.default.ImageUpdatesModal.updatesDesc": "Add the new versions to begin using them in your Lightning networks.", @@ -292,6 +305,14 @@ "cmps.designer.AutoMineButton.autoFiveMinutesShort": "5m", "cmps.designer.AutoMineButton.autoTenMinutes": "Every 10 Minutes", "cmps.designer.AutoMineButton.autoTenMinutesShort": "10m", + "cmps.designer.ActivityGenerator.sourceNode": "Source node", + "cmps.designer.ActivityGenerator.destinationNode": "Destination node", + "cmps.designer.ActivityGenerator.frequency": "Frequency (seconds)", + "cmps.designer.ActivityGenerator.amount": "Amount (mSats)", + "cmps.designer.ActivityGenerator.amountPlaceholder": "Amount in milli sats", + "cmps.designer.ActivityGenerator.save": "Save", + "cmps.designer.ActivityGenerator.startWarning": "Unable to add activities, ensure the network is started", + "cmps.designer.ActivityGenerator.cancel": "Cancel", "cmps.designer.tap.info.AssetInfoModal.title": "TAP Asset Info", "cmps.designer.tap.info.AssetInfoModal.balance": "Balance", "cmps.designer.tap.info.AssetInfoModal.type": "Type", diff --git a/src/lib/docker/composeFile.ts b/src/lib/docker/composeFile.ts index 398e79843..e2bed5c6a 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 aa7df8167..48419ed82 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,92 @@ 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 => { + const { source, destination } = activity; + // split the macaroon and cert path at "volumes/" to get the relative path + // to the docker volume. This is necessary because the docker volumes are + // mounted as a different path in the container + const sourceMacaroon = source.macaroon.split('volumes/').pop(); + const sourceCert = source?.tlsCert + ? source.tlsCert?.split('volumes/').pop() + : source?.clientKey?.split('volumes/').pop(); + const destMacaroon = destination.macaroon.split('volumes/').pop(); + const destCert = destination.tlsCert + ? destination.tlsCert?.split('volumes/').pop() + : destination?.clientKey?.split('volumes/').pop(); + info({ sourceMacaroon, sourceCert, destMacaroon, destCert }); + nodes.add({ + id: activity.source.id, + address: activity.source.address, + macaroon: `/home/simln/.${sourceMacaroon}`, + cert: `/home/simln/.${sourceCert}`, + }); + nodes.add({ + id: activity.destination.id, + address: activity.destination.address, + macaroon: `/home/simln/.${destMacaroon}`, + cert: `/home/simln/.${destCert}`, + }); + + activities.add({ + source: activity.source.id, + destination: activity.destination.id, + interval_secs: activity.intervalSecs, + amount_msat: activity.amountMsat, + }); + }); + return { + nodes: Array.from(nodes), + activity: 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); + info( + `simJson: ${simJson} simJson.nodes: ${simJson.nodes} simJson.activities: ${simJson.activity}`, + ); + 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 + const removedContainer = await this.execute( + compose.rm as any, + this.getArgs(network), + 'simln', + ); + info(`Removed simln container ${removedContainer.out || removedContainer.err}`); + } + /** * Start a network using docker compose * @param network the network to start @@ -169,8 +266,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 +391,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 d26360702..1c4954dc8 100644 --- a/src/lib/docker/nodeTemplates.ts +++ b/src/lib/docker/nodeTemplates.ts @@ -5,6 +5,28 @@ 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`, + `./volumes/${dockerConfigs.LND.volumeDirName}:/home/simln/.lnd`, + `./volumes/${dockerConfigs['c-lightning'].volumeDirName}:/home/simln/.clightning`, + ], + expose: [], + ports: [], +}); + export const bitcoind = ( name: string, container: string, diff --git a/src/store/models/network.ts b/src/store/models/network.ts index fce0ad1b0..1e62b2551 100644 --- a/src/store/models/network.ts +++ b/src/store/models/network.ts @@ -13,7 +13,13 @@ import { TapdNode, TapNode, } from 'shared/types'; -import { AutoMineMode, CustomImage, Network, StoreInjections } from 'types'; +import { + AutoMineMode, + CustomImage, + Network, + SimulationActivity, + StoreInjections, +} from 'types'; import { delay } from 'utils/async'; import { initChartFromNetwork } from 'utils/chart'; import { APP_VERSION, DOCKER_REPO } from 'utils/constants'; @@ -47,6 +53,18 @@ interface AddNetworkArgs { customNodes: Record; } +interface AddSimulationActivityArgs { + source: SimulationActivity['source']; + destination: SimulationActivity['destination']; + amountMsat: SimulationActivity['amountMsat']; + intervalSecs: SimulationActivity['intervalSecs']; + networkId: SimulationActivity['networkId']; +} + +interface UpdateSimulationActivityArgs extends AddSimulationActivityArgs { + id: number; +} + export interface AutoMinerModel { startTime: number; timer?: NodeJS.Timer; @@ -124,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>; @@ -179,12 +211,34 @@ export interface NetworkModel { setAutoMineMode: Action; setMiningState: Action; mineBlock: Thunk; + addSimulationActivity: Thunk< + NetworkModel, + AddSimulationActivityArgs, + StoreInjections, + RootModel, + Promise + >; + updateSimulationActivity: Thunk< + NetworkModel, + UpdateSimulationActivityArgs, + StoreInjections, + RootModel, + Promise + >; + removeSimulationActivity: Thunk< + NetworkModel, + SimulationActivity, + StoreInjections, + RootModel, + Promise + >; } const networkModel: NetworkModel = { // state properties networks: [], autoMiners: {}, + // simulationActivities: [], // computed properties/functions networkById: computed(state => (id?: string | number) => { const networkId = typeof id === 'number' ? id : parseInt(id || ''); @@ -701,6 +755,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; + } + }, + ), stopAll: thunk(async (actions, _, { getState }) => { let networks = getState().networks.filter( n => n.status === Status.Started || n.status === Status.Stopping, @@ -917,6 +1011,72 @@ const networkModel: NetworkModel = { return network; }, ), + addSimulationActivity: thunk(async (actions, { networkId, ...rest }, { getState }) => { + const networks = getState().networks; + const networkIndex = networks.findIndex(n => n.id === networkId); + + if (networkIndex === -1) throw new Error(l('networkByIdErr', { networkId })); + + // Create a shallow copy of the network to update the object reference to cause a rerender on setNetworks + const network = { ...networks[networkIndex] }; + + 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: [...activities, activity], + }; + + actions.setNetworks(updatedNetworks); + await actions.save(); + }), + updateSimulationActivity: thunk( + async (actions, { networkId, id, ...rest }, { getState }) => { + const networks = getState().networks; + const networkIndex = networks.findIndex(n => n.id === networkId); + + if (networkIndex === -1) throw new Error(l('networkByIdErr', { networkId })); + + // Create a shallow copy of the network to update the object reference to cause a rerender on setNetworks + const updatedNetworks = [...networks]; + const network = { ...networks[networkIndex] }; + + const activity = { ...rest, networkId, id }; + + const activityIndex = network.simulationActivities.findIndex(n => n.id === id); + if (activityIndex === -1) throw new Error(l('networkByIdErr', { id })); + + network.simulationActivities[activityIndex] = activity; + updatedNetworks[networkIndex] = { + ...network, + }; + + actions.setNetworks(updatedNetworks); + await actions.save(); + }, + ), + removeSimulationActivity: thunk(async (actions, { networkId, id }, { getState }) => { + const networks = getState().networks; + const networkIndex = networks.findIndex(n => n.id === networkId); + if (networkIndex === -1) throw new Error(l('networkByIdErr', { networkId })); + + const updatedNetworks = [...networks]; + const network = { ...networks[networkIndex] }; + + const updatedSimulationActivities = network.simulationActivities.filter( + activity => activity.id !== id, + ); + + updatedNetworks[networkIndex] = { + ...network, + simulationActivities: [...updatedSimulationActivities], + }; + actions.setNetworks(updatedNetworks); + await actions.save(); + }), setAutoMineMode: action((state, { id, mode }) => { const network = state.networks.find(n => n.id === id); if (!network) throw new Error(l('networkByIdErr', { id })); diff --git a/src/types/index.ts b/src/types/index.ts index 7292f52a2..e635292f3 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -21,6 +21,7 @@ export interface Network { status: Status; path: string; autoMineMode: AutoMineMode; + simulationActivities: SimulationActivity[]; nodes: { bitcoin: BitcoinNode[]; lightning: LightningNode[]; @@ -96,6 +97,7 @@ export interface DockerConfig { variables: string[]; dataDir?: string; apiDir?: string; + env?: Record; } export interface DockerRepoImage { @@ -126,6 +128,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; @@ -223,6 +227,40 @@ export interface NetworksFile { charts: Record; } +/** + * A running lightning node that is used in the simulation activity + */ +export interface SimulationActivityNode { + id: string; + label: string; + type: LightningNode['implementation']; + address: string; + macaroon: string; + tlsCert: string; + clientCert?: string; + clientKey?: string; +} + +/** + * A simulation activity is a payment from one node to another + * at a given interval and amount + */ +export interface SimulationActivity { + id: number; + source: SimulationActivityNode; + destination: SimulationActivityNode; + intervalSecs: number; + amountMsat: number; + networkId: number; +} +export interface ActivityInfo { + id: number | undefined; + sourceNode: LightningNode | undefined; + targetNode: LightningNode | undefined; + amount: number; + frequency: number; +} + export enum AutoMineMode { AutoOff = 0, Auto30s = 30, diff --git a/src/utils/config.ts b/src/utils/config.ts index cdadf0281..152bfdc80 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 a0f26b180..83fff5bb9 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', @@ -240,6 +240,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/network.ts b/src/utils/network.ts index 0a0a32a0b..322ec737c 100644 --- a/src/utils/network.ts +++ b/src/utils/network.ts @@ -400,6 +400,7 @@ export const createNetwork = (config: { tap: [], }, autoMineMode: AutoMineMode.AutoOff, + simulationActivities: [], }; const { bitcoin, lightning } = network.nodes; diff --git a/src/utils/tests/renderWithProviders.tsx b/src/utils/tests/renderWithProviders.tsx index 063ae59d7..14888f933 100644 --- a/src/utils/tests/renderWithProviders.tsx +++ b/src/utils/tests/renderWithProviders.tsx @@ -54,6 +54,8 @@ export const injections: StoreInjections = { removeNode: jest.fn(), saveNetworks: jest.fn(), loadNetworks: jest.fn(), + startSimulationActivity: jest.fn(), + stopSimulationActivity: jest.fn(), }, repoService: { load: jest.fn(),