From 73cb98d0804c9e4d37f483636161677cb543197a Mon Sep 17 00:00:00 2001 From: Theophilus Date: Sat, 13 Jan 2024 00:04:58 +0100 Subject: [PATCH 1/9] feat(designer): add activity designer component --- src/components/designer/ActivityGenerator.tsx | 191 ++++++++++++++ src/components/designer/Sidebar.tsx | 2 +- .../designer/default/DefaultSidebar.spec.tsx | 2 +- .../designer/default/DefaultSidebar.tsx | 138 ++++++---- .../default/cards/ActivityDesignerCard.tsx | 249 ++++++++++++++++++ .../default/cards/NetworkDesignerCard.tsx | 85 ++++++ src/i18n/locales/en-US.json | 24 +- 7 files changed, 634 insertions(+), 57 deletions(-) create mode 100644 src/components/designer/ActivityGenerator.tsx create mode 100644 src/components/designer/default/cards/ActivityDesignerCard.tsx create mode 100644 src/components/designer/default/cards/NetworkDesignerCard.tsx diff --git a/src/components/designer/ActivityGenerator.tsx b/src/components/designer/ActivityGenerator.tsx new file mode 100644 index 000000000..1e07ef004 --- /dev/null +++ b/src/components/designer/ActivityGenerator.tsx @@ -0,0 +1,191 @@ +import React, { useState } from 'react'; +import styled from '@emotion/styled'; +import { Button, Col, Form, InputNumber, Row, Select, Slider } from 'antd'; +import { usePrefixedTranslation } from 'hooks'; +import { LightningNode } from 'shared/types'; +import { Network } from 'types'; + +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; + `, +}; + +interface Props { + visible: boolean; + activities: any; + networkNodes: Network['nodes']; +} + +const ActivityGenerator: React.FC = ({ visible, networkNodes }) => { + if (!visible) return null; + const { l } = usePrefixedTranslation('cmps.designer.ActivityGenerator'); + const { lightning } = networkNodes; + + const [sourceNode, setSourceNode] = useState(undefined); + const [targetNode, setTargetNode] = useState(undefined); + const [amount, setAmount] = useState(1); + const [frequency, setFrequency] = useState(1); + + const handleSourceNodeChange = (selectedNodeName: string) => { + const selectedNode = lightning.find(n => n.name === selectedNodeName); + if (selectedNode?.name !== targetNode?.name) { + return setSourceNode(selectedNode); + } + }; + const handleTargetNodeChange = (selectedNodeName: string) => { + const selectedNode = lightning.find(n => n.name === selectedNodeName); + if (selectedNode?.name !== sourceNode?.name) { + return setTargetNode(selectedNode); + } + }; + const handleFrequencyChange = (newValue: number) => { + setFrequency(newValue); + }; + + 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]); + + React.useEffect(() => { + if (amount !== undefined && amount <= 0) { + setAmount(1); + } + if (frequency !== undefined && frequency <= 0) { + setFrequency(1); + } + }, [amount, frequency]); + + 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')} + setAmount(e as number)} + /> + + + ); +}; + +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..dbe85bb69 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/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..faa8e3a33 100644 --- a/src/components/designer/default/DefaultSidebar.tsx +++ b/src/components/designer/default/DefaultSidebar.tsx @@ -1,34 +1,75 @@ import React from 'react'; -import { CloudSyncOutlined } from '@ant-design/icons'; +import { DeploymentUnitOutlined, SisternodeOutlined } from '@ant-design/icons'; 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 { + networkNodes: Network['nodes']; +} + +const DefaultSidebar: React.FC = ({ networkNodes }) => { + const [designerType, setDesignerType] = React.useState<'network' | 'activity'>( + 'network', + ); const { l } = usePrefixedTranslation('cmps.designer.default.DefaultSidebar'); const { updateSettings } = useStoreActions(s => s.app); @@ -40,14 +81,7 @@ 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[] = []; // add custom nodes settings.nodeImages.custom.forEach(image => { @@ -76,32 +110,38 @@ 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')}

+ )} + + } + onClick={() => setDesignerType('network')} + /> + } + onClick={() => setDesignerType('activity')} + color="red" + /> + +
+ +
); }; diff --git a/src/components/designer/default/cards/ActivityDesignerCard.tsx b/src/components/designer/default/cards/ActivityDesignerCard.tsx new file mode 100644 index 000000000..d0b50d0c8 --- /dev/null +++ b/src/components/designer/default/cards/ActivityDesignerCard.tsx @@ -0,0 +1,249 @@ +import React from 'react'; +import { + ArrowDownOutlined, + ArrowRightOutlined, + ArrowUpOutlined, + DeleteOutlined, +} from '@ant-design/icons'; +import styled from '@emotion/styled'; +import { Button } from 'antd'; +import { usePrefixedTranslation } from 'hooks'; +import { useTheme } from 'hooks/useTheme'; +import { ThemeColors } from 'theme/colors'; +import { Network } 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; + } + `, + 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 { + networkNodes: Network['nodes']; + visible: boolean; +} + +interface ActivityNode { + label: string; + type: string; + id: string; + address: string; + macaroon: string; + cert: string; +} + +interface Activity { + source_node: ActivityNode; + target_node: ActivityNode; + frequency: number; + amountInMsat: number; +} + +const mockActivities: Activity[] = [ + { + source_node: { + label: 'alice', + type: 'lnd', + id: 'node-1', + address: `https://ip:port or domain:port`, + macaroon: `path_to_selected_macaroon`, + cert: `path_to_tls_cert`, + }, + target_node: { + label: 'bob', + type: 'lnd', + id: 'node-2', + address: `https://ip:port or domain:port`, + macaroon: `path_to_selected_macaroon`, + cert: `path_to_tls_cert`, + }, + frequency: 10, + amountInMsat: 100000, + }, + { + source_node: { + label: 'bob', + type: 'lnd', + id: 'node-3', + address: `https://ip:port or domain:port`, + macaroon: `path_to_selected_macaroon`, + cert: `path_to_tls_cert`, + }, + target_node: { + label: 'alice', + type: 'lnd', + id: 'node-4', + address: `https://ip:port or domain:port`, + macaroon: `path_to_selected_macaroon`, + cert: `path_to_tls_cert`, + }, + frequency: 10, + amountInMsat: 100000, + }, +]; + +const ActivityDesignerCard: React.FC = ({ visible, networkNodes }) => { + const [isSimulationActive, setIsStartSimulationActive] = React.useState(false); + const [isAddActivityActive, setIsAddActivityActive] = React.useState(false); + const theme = useTheme(); + const { l } = usePrefixedTranslation( + 'cmps.designer.default.cards.ActivityDesignerCard', + ); + const numberOfActivities = mockActivities.length; + + if (!visible) return null; + return ( + <> +
+

{l('mainDesc')}

+
+ +

{l('addActivitiesTitle')}

+ : } + onClick={() => setIsAddActivityActive(!isAddActivityActive)} + /> +
+ {l('addActivitiesDesc')} + + +

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

+ + {mockActivities.map(activity => ( + console.log('clicked')} + > + + {activity.source_node.label} + + {activity.target_node.label} + + } /> + + ))} + + setIsStartSimulationActive(!isSimulationActive)} + > + {isSimulationActive ? l('stop') : l('start')} + + + ); +}; + +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..fe08e7ffc 100644 --- a/src/i18n/locales/en-US.json +++ b/src/i18n/locales/en-US.json @@ -92,12 +92,19 @@ "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.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.start": "Start", + "cmps.designer.default.cards.ActivityDesignerCard.stop": "Stop", + "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 +299,11 @@ "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.tap.info.AssetInfoModal.title": "TAP Asset Info", "cmps.designer.tap.info.AssetInfoModal.balance": "Balance", "cmps.designer.tap.info.AssetInfoModal.type": "Type", From 96e8662e925bf02388cb977c4d5da84381e00b92 Mon Sep 17 00:00:00 2001 From: Theophilus Date: Tue, 30 Jan 2024 12:31:03 +0100 Subject: [PATCH 2/9] feat: add simulation activities and update network sidebar --- src/components/designer/ActivityGenerator.tsx | 148 +++++++++++++++++- src/components/designer/Sidebar.tsx | 2 +- .../designer/default/DefaultSidebar.tsx | 9 +- .../default/cards/ActivityDesignerCard.tsx | 66 +++----- src/i18n/locales/en-US.json | 2 + src/store/models/network.ts | 20 ++- src/types/index.ts | 27 ++++ src/utils/network.ts | 1 + 8 files changed, 222 insertions(+), 53 deletions(-) diff --git a/src/components/designer/ActivityGenerator.tsx b/src/components/designer/ActivityGenerator.tsx index 1e07ef004..700f0ff4e 100644 --- a/src/components/designer/ActivityGenerator.tsx +++ b/src/components/designer/ActivityGenerator.tsx @@ -1,9 +1,11 @@ import React, { useState } from 'react'; +import { PlusSquareOutlined } from '@ant-design/icons'; import styled from '@emotion/styled'; import { Button, Col, Form, InputNumber, Row, Select, Slider } from 'antd'; import { usePrefixedTranslation } from 'hooks'; -import { LightningNode } from 'shared/types'; -import { Network } from 'types'; +import { CLightningNode, LightningNode, LndNode } from 'shared/types'; +import { useStoreActions, useStoreState } from 'store'; +import { Network, SimulationActivityNode } from 'types'; const Styled = { ActivityGen: styled.div` @@ -14,6 +16,22 @@ const Styled = { width: 100%; border-radius: 4px; `, + AddActivity: styled(Button)<{ canAdd: boolean }>` + display: flex; + align-items: center; + justify-content: center; + height: 100%; + border: none; + font-size: 100px; + color: #fff; + cursor: ${props => (props.canAdd ? 'pointer' : 'not-allowed')}; + opacity: ${props => (props.canAdd ? '1' : '0.6')}; + + svg { + font-size: 33px; + color: ${props => (props.canAdd ? '#d46b08' : '#545353e6')}; + } + `, Divider: styled.div` height: 1px; width: 100%; @@ -65,24 +83,128 @@ const Styled = { border-radius: 4px; margin: 0 0 10px 0; `, + Save: styled(Button)<{ canSave: boolean }>` + border: 1px solid #545353e6; + width: 100%; + height: 100%; + color: #fff; + opacity: ${props => (props.canSave ? '1' : '0.6')}; + cursor: ${props => (props.canSave ? 'pointer' : 'not-allowed')}; + + &:hover { + background: ${props => (props.canSave ? '#d46b08' : '')}; + color: #fff; + border: 1px solid #fff; + } + `, + Cancel: styled(Button)` + height: 100%; + width: 100%; + color: #fff; + opacity: 0.6; + background: #000; + border: 1px solid red; + + &:hover { + background: red; + color: #fff; + border: 1px solid #fff; + } + `, }; interface Props { visible: boolean; activities: any; - networkNodes: Network['nodes']; + network: Network; } -const ActivityGenerator: React.FC = ({ visible, networkNodes }) => { +const ActivityGenerator: React.FC = ({ visible, network }) => { if (!visible) return null; const { l } = usePrefixedTranslation('cmps.designer.ActivityGenerator'); - const { lightning } = networkNodes; + const nodeState = useStoreState(s => s.lightning); + const { lightning } = network.nodes; const [sourceNode, setSourceNode] = useState(undefined); const [targetNode, setTargetNode] = useState(undefined); const [amount, setAmount] = useState(1); const [frequency, setFrequency] = useState(1); + // get store actions for adding activities + const store = useStoreActions(s => s.network); + + const getAuthDetails = (node: LightningNode) => { + const id = nodeState && nodeState.nodes[node.name].info?.pubkey; + + 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 = () => { + if (!sourceNode || !targetNode) return; + const sourceSimulationNode: SimulationActivityNode = { + id: getAuthDetails(sourceNode).id || '', + label: sourceNode.name, + type: sourceNode.implementation, + address: getAuthDetails(sourceNode).address, + macaroon: getAuthDetails(sourceNode).macaroon, + tlsCert: getAuthDetails(sourceNode).tlsCert || '', + clientCert: getAuthDetails(sourceNode).clientCert, + clientKey: getAuthDetails(sourceNode).clientKey, + }; + const targetSimulationNode: SimulationActivityNode = { + id: getAuthDetails(targetNode).id || '', + label: targetNode.name, + type: targetNode.implementation, + address: getAuthDetails(targetNode).address, + macaroon: getAuthDetails(targetNode).macaroon, + tlsCert: getAuthDetails(targetNode).tlsCert || '', + clientCert: getAuthDetails(targetNode).clientCert, + clientKey: getAuthDetails(targetNode).clientKey, + }; + const activity = { + source: sourceSimulationNode, + destination: targetSimulationNode, + amountMsat: amount, + intervalSecs: frequency, + networkId: network.id, + }; + // const newActivities = [...network.simulationActivities, activity]; + // network.simulationActivities.push(activity); + store.addSimulationActivity(activity); + console.log('activity', activity); + console.log('network', network.simulationActivities); + }; + const handleSourceNodeChange = (selectedNodeName: string) => { const selectedNode = lightning.find(n => n.name === selectedNodeName); if (selectedNode?.name !== targetNode?.name) { @@ -152,6 +274,22 @@ const ActivityGenerator: React.FC = ({ visible, networkNodes }) => { value={amount} onChange={e => setAmount(e as number)} /> + + + } + /> + + {l('save')} + + {l('cancel')} + ); diff --git a/src/components/designer/Sidebar.tsx b/src/components/designer/Sidebar.tsx index dbe85bb69..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/default/DefaultSidebar.tsx b/src/components/designer/default/DefaultSidebar.tsx index faa8e3a33..f12165f51 100644 --- a/src/components/designer/default/DefaultSidebar.tsx +++ b/src/components/designer/default/DefaultSidebar.tsx @@ -63,10 +63,10 @@ interface Node { } interface Props { - networkNodes: Network['nodes']; + network: Network; } -const DefaultSidebar: React.FC = ({ networkNodes }) => { +const DefaultSidebar: React.FC = ({ network }) => { const [designerType, setDesignerType] = React.useState<'network' | 'activity'>( 'network', ); @@ -138,10 +138,7 @@ const DefaultSidebar: React.FC = ({ networkNodes }) => { toggleModal={toggleModal} visible={designerType === 'network'} /> - +
); }; diff --git a/src/components/designer/default/cards/ActivityDesignerCard.tsx b/src/components/designer/default/cards/ActivityDesignerCard.tsx index d0b50d0c8..28c465adc 100644 --- a/src/components/designer/default/cards/ActivityDesignerCard.tsx +++ b/src/components/designer/default/cards/ActivityDesignerCard.tsx @@ -10,7 +10,7 @@ import { Button } from 'antd'; import { usePrefixedTranslation } from 'hooks'; import { useTheme } from 'hooks/useTheme'; import { ThemeColors } from 'theme/colors'; -import { Network } from 'types'; +import { Network, SimulationActivity } from 'types'; import ActivityGenerator from '../../ActivityGenerator'; const Styled = { @@ -120,70 +120,56 @@ const Styled = { }; interface Props { - networkNodes: Network['nodes']; + network: Network; visible: boolean; } -interface ActivityNode { - label: string; - type: string; - id: string; - address: string; - macaroon: string; - cert: string; -} - -interface Activity { - source_node: ActivityNode; - target_node: ActivityNode; - frequency: number; - amountInMsat: number; -} - -const mockActivities: Activity[] = [ +const mockActivities: SimulationActivity[] = [ { - source_node: { + source: { label: 'alice', - type: 'lnd', + type: 'LND', id: 'node-1', address: `https://ip:port or domain:port`, macaroon: `path_to_selected_macaroon`, - cert: `path_to_tls_cert`, + tlsCert: `path_to_tls_cert`, }, - target_node: { + destination: { label: 'bob', - type: 'lnd', + type: 'LND', id: 'node-2', address: `https://ip:port or domain:port`, macaroon: `path_to_selected_macaroon`, - cert: `path_to_tls_cert`, + tlsCert: `path_to_tls_cert`, }, - frequency: 10, - amountInMsat: 100000, + intervalSecs: 10, + amountMsat: 100000, + networkId: 1, }, { - source_node: { + source: { label: 'bob', - type: 'lnd', + type: 'LND', id: 'node-3', address: `https://ip:port or domain:port`, macaroon: `path_to_selected_macaroon`, - cert: `path_to_tls_cert`, + tlsCert: `path_to_tls_cert`, }, - target_node: { + destination: { label: 'alice', - type: 'lnd', + type: 'LND', id: 'node-4', address: `https://ip:port or domain:port`, macaroon: `path_to_selected_macaroon`, - cert: `path_to_tls_cert`, + tlsCert: `path_to_tls_cert`, }, - frequency: 10, - amountInMsat: 100000, + intervalSecs: 10, + amountMsat: 100000, + networkId: 1, }, ]; -const ActivityDesignerCard: React.FC = ({ visible, networkNodes }) => { +const ActivityDesignerCard: React.FC = ({ visible, network }) => { const [isSimulationActive, setIsStartSimulationActive] = React.useState(false); const [isAddActivityActive, setIsAddActivityActive] = React.useState(false); const theme = useTheme(); @@ -212,7 +198,7 @@ const ActivityDesignerCard: React.FC = ({ visible, networkNodes }) => {

@@ -222,14 +208,14 @@ const ActivityDesignerCard: React.FC = ({ visible, networkNodes }) => { {mockActivities.map(activity => ( console.log('clicked')} > - {activity.source_node.label} + {activity.source.label} - {activity.target_node.label} + {activity.destination.label} } /> diff --git a/src/i18n/locales/en-US.json b/src/i18n/locales/en-US.json index fe08e7ffc..40bfe3dbd 100644 --- a/src/i18n/locales/en-US.json +++ b/src/i18n/locales/en-US.json @@ -304,6 +304,8 @@ "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.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/store/models/network.ts b/src/store/models/network.ts index fce0ad1b0..df0ee714f 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'; @@ -179,12 +185,16 @@ export interface NetworkModel { setAutoMineMode: Action; setMiningState: Action; mineBlock: Thunk; + simulationActivities: SimulationActivity[]; + addSimulationActivity: Thunk; + // removeSimulationActivity: Thunk; } 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 || ''); @@ -917,6 +927,14 @@ const networkModel: NetworkModel = { return network; }, ), + addSimulationActivity: thunk((actions, { networkId, ...rest }, { getState }) => { + const networks = getState().networks; + const network = networks.find(n => n.id === networkId); + if (!network) throw new Error(l('networkByIdErr', { networkId })); + const activity = { ...rest, networkId }; + actions.addSimulationActivity(activity); + // network.simulationActivities.push(activity); + }), 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..aa981bae1 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[]; @@ -223,6 +224,32 @@ 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 { + source: SimulationActivityNode; + destination: SimulationActivityNode; + intervalSecs: number; + amountMsat: number; + networkId: number; +} + export enum AutoMineMode { AutoOff = 0, Auto30s = 30, 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; From 6518830ce62bfcb77b1d9ba886e44ad6d7e42f04 Mon Sep 17 00:00:00 2001 From: Emmanuel-Develops Date: Wed, 31 Jan 2024 16:28:32 +0100 Subject: [PATCH 3/9] feat: add and remove simulation activities --- src/components/designer/ActivityGenerator.tsx | 8 +-- .../default/cards/ActivityDesignerCard.tsx | 64 ++++------------- src/store/models/network.ts | 71 ++++++++++++++++--- src/types/index.ts | 1 + 4 files changed, 79 insertions(+), 65 deletions(-) diff --git a/src/components/designer/ActivityGenerator.tsx b/src/components/designer/ActivityGenerator.tsx index 700f0ff4e..c30ee9420 100644 --- a/src/components/designer/ActivityGenerator.tsx +++ b/src/components/designer/ActivityGenerator.tsx @@ -131,7 +131,7 @@ const ActivityGenerator: React.FC = ({ visible, network }) => { const [frequency, setFrequency] = useState(1); // get store actions for adding activities - const store = useStoreActions(s => s.network); + const { addSimulationActivity } = useStoreActions(s => s.network); const getAuthDetails = (node: LightningNode) => { const id = nodeState && nodeState.nodes[node.name].info?.pubkey; @@ -169,7 +169,7 @@ const ActivityGenerator: React.FC = ({ visible, network }) => { } }; - const handleAddActivity = () => { + const handleAddActivity = async () => { if (!sourceNode || !targetNode) return; const sourceSimulationNode: SimulationActivityNode = { id: getAuthDetails(sourceNode).id || '', @@ -198,9 +198,7 @@ const ActivityGenerator: React.FC = ({ visible, network }) => { intervalSecs: frequency, networkId: network.id, }; - // const newActivities = [...network.simulationActivities, activity]; - // network.simulationActivities.push(activity); - store.addSimulationActivity(activity); + await addSimulationActivity(activity); console.log('activity', activity); console.log('network', network.simulationActivities); }; diff --git a/src/components/designer/default/cards/ActivityDesignerCard.tsx b/src/components/designer/default/cards/ActivityDesignerCard.tsx index 28c465adc..be0d296c6 100644 --- a/src/components/designer/default/cards/ActivityDesignerCard.tsx +++ b/src/components/designer/default/cards/ActivityDesignerCard.tsx @@ -12,6 +12,7 @@ import { useTheme } from 'hooks/useTheme'; import { ThemeColors } from 'theme/colors'; import { Network, SimulationActivity } from 'types'; import ActivityGenerator from '../../ActivityGenerator'; +import { useStoreActions } from 'store'; const Styled = { AddNodes: styled.div` @@ -124,51 +125,6 @@ interface Props { visible: boolean; } -const mockActivities: SimulationActivity[] = [ - { - source: { - label: 'alice', - type: 'LND', - id: 'node-1', - address: `https://ip:port or domain:port`, - macaroon: `path_to_selected_macaroon`, - tlsCert: `path_to_tls_cert`, - }, - destination: { - label: 'bob', - type: 'LND', - id: 'node-2', - address: `https://ip:port or domain:port`, - macaroon: `path_to_selected_macaroon`, - tlsCert: `path_to_tls_cert`, - }, - intervalSecs: 10, - amountMsat: 100000, - networkId: 1, - }, - { - source: { - label: 'bob', - type: 'LND', - id: 'node-3', - address: `https://ip:port or domain:port`, - macaroon: `path_to_selected_macaroon`, - tlsCert: `path_to_tls_cert`, - }, - destination: { - label: 'alice', - type: 'LND', - id: 'node-4', - address: `https://ip:port or domain:port`, - macaroon: `path_to_selected_macaroon`, - tlsCert: `path_to_tls_cert`, - }, - intervalSecs: 10, - amountMsat: 100000, - networkId: 1, - }, -]; - const ActivityDesignerCard: React.FC = ({ visible, network }) => { const [isSimulationActive, setIsStartSimulationActive] = React.useState(false); const [isAddActivityActive, setIsAddActivityActive] = React.useState(false); @@ -176,7 +132,12 @@ const ActivityDesignerCard: React.FC = ({ visible, network }) => { const { l } = usePrefixedTranslation( 'cmps.designer.default.cards.ActivityDesignerCard', ); - const numberOfActivities = mockActivities.length; + const numberOfActivities = network.simulationActivities.length; + const { removeSimulationActivity } = useStoreActions(s => s.network); + + const handleRemoveActivity = async (activity: SimulationActivity) => { + await removeSimulationActivity(activity); + }; if (!visible) return null; return ( @@ -197,7 +158,7 @@ const ActivityDesignerCard: React.FC = ({ visible, network }) => { {l('addActivitiesDesc')} @@ -206,9 +167,9 @@ const ActivityDesignerCard: React.FC = ({ visible, network }) => { {` (${numberOfActivities})`}

- {mockActivities.map(activity => ( + {network.simulationActivities.map(activity => ( console.log('clicked')} > @@ -217,7 +178,10 @@ const ActivityDesignerCard: React.FC = ({ visible, network }) => { {activity.destination.label} - } /> + handleRemoveActivity(activity)} + icon={} + /> ))} diff --git a/src/store/models/network.ts b/src/store/models/network.ts index df0ee714f..a769c449c 100644 --- a/src/store/models/network.ts +++ b/src/store/models/network.ts @@ -53,6 +53,14 @@ interface AddNetworkArgs { customNodes: Record; } +interface AddSimulationActivityArgs { + source: SimulationActivity['source']; + destination: SimulationActivity['destination']; + amountMsat: SimulationActivity['amountMsat']; + intervalSecs: SimulationActivity['intervalSecs']; + networkId: SimulationActivity['networkId']; +} + export interface AutoMinerModel { startTime: number; timer?: NodeJS.Timer; @@ -185,16 +193,27 @@ export interface NetworkModel { setAutoMineMode: Action; setMiningState: Action; mineBlock: Thunk; - simulationActivities: SimulationActivity[]; - addSimulationActivity: Thunk; - // removeSimulationActivity: Thunk; + addSimulationActivity: Thunk< + NetworkModel, + AddSimulationActivityArgs, + StoreInjections, + RootModel, + Promise + >; + removeSimulationActivity: Thunk< + NetworkModel, + SimulationActivity, + StoreInjections, + RootModel, + Promise + >; } const networkModel: NetworkModel = { // state properties networks: [], autoMiners: {}, - simulationActivities: [], + // simulationActivities: [], // computed properties/functions networkById: computed(state => (id?: string | number) => { const networkId = typeof id === 'number' ? id : parseInt(id || ''); @@ -927,13 +946,45 @@ const networkModel: NetworkModel = { return network; }, ), - addSimulationActivity: thunk((actions, { networkId, ...rest }, { getState }) => { + addSimulationActivity: thunk(async (actions, { networkId, ...rest }, { getState }) => { const networks = getState().networks; - const network = networks.find(n => n.id === networkId); - if (!network) throw new Error(l('networkByIdErr', { networkId })); - const activity = { ...rest, networkId }; - actions.addSimulationActivity(activity); - // network.simulationActivities.push(activity); + 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 nextId = Math.max(0, ...network.simulationActivities.map(n => n.id)) + 1; + const activity = { ...rest, networkId, id: nextId }; + + const updatedNetworks = [...networks]; + updatedNetworks[networkIndex] = { + ...network, + simulationActivities: [...network.simulationActivities, activity], + }; + + 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); diff --git a/src/types/index.ts b/src/types/index.ts index aa981bae1..56ad34011 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -243,6 +243,7 @@ export interface SimulationActivityNode { * at a given interval and amount */ export interface SimulationActivity { + id: number; source: SimulationActivityNode; destination: SimulationActivityNode; intervalSecs: number; From d3bfa925a65b8ce8df7ec3dcac38a0ddfc081862 Mon Sep 17 00:00:00 2001 From: Emmanuel-Develops Date: Wed, 7 Feb 2024 12:47:41 +0100 Subject: [PATCH 4/9] feat: ui suggested changes --- src/components/designer/ActivityGenerator.tsx | 134 +++++++++++------- .../designer/default/DefaultSidebar.tsx | 54 +++---- .../default/cards/ActivityDesignerCard.tsx | 62 +++++++- src/i18n/locales/en-US.json | 2 + src/types/index.ts | 6 + 5 files changed, 174 insertions(+), 84 deletions(-) diff --git a/src/components/designer/ActivityGenerator.tsx b/src/components/designer/ActivityGenerator.tsx index c30ee9420..9f037d689 100644 --- a/src/components/designer/ActivityGenerator.tsx +++ b/src/components/designer/ActivityGenerator.tsx @@ -1,11 +1,11 @@ import React, { useState } from 'react'; import { PlusSquareOutlined } from '@ant-design/icons'; import styled from '@emotion/styled'; -import { Button, Col, Form, InputNumber, Row, Select, Slider } from 'antd'; +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 { Network, SimulationActivityNode } from 'types'; +import { ActivityInfo, Network, SimulationActivityNode } from 'types'; const Styled = { ActivityGen: styled.div` @@ -84,57 +84,63 @@ const Styled = { margin: 0 0 10px 0; `, Save: styled(Button)<{ canSave: boolean }>` - border: 1px solid #545353e6; width: 100%; - height: 100%; - color: #fff; opacity: ${props => (props.canSave ? '1' : '0.6')}; cursor: ${props => (props.canSave ? 'pointer' : 'not-allowed')}; &:hover { background: ${props => (props.canSave ? '#d46b08' : '')}; - color: #fff; - border: 1px solid #fff; } `, Cancel: styled(Button)` - height: 100%; width: 100%; - color: #fff; - opacity: 0.6; - background: #000; - border: 1px solid red; - - &:hover { - background: red; - color: #fff; - border: 1px solid #fff; - } `, }; +interface AvtivityUpdater { + (params: { name: K; value: ActivityInfo[K] }): void; +} + interface Props { visible: boolean; activities: any; + activityInfo: ActivityInfo; network: Network; + toggle: () => void; + updater: AvtivityUpdater; + reset: () => void; +} +interface AddActivityInvalidState { + state: 'warning' | 'error'; + message: string; } -const ActivityGenerator: React.FC = ({ visible, network }) => { +const ActivityGenerator: React.FC = ({ + visible, + network, + activityInfo, + toggle, + reset, + updater, +}) => { if (!visible) return null; + + const [addActivityInvalidState, setAddActivityInvalidState] = + useState(null); + const { sourceNode, targetNode, frequency, amount } = activityInfo; + const { l } = usePrefixedTranslation('cmps.designer.ActivityGenerator'); const nodeState = useStoreState(s => s.lightning); const { lightning } = network.nodes; - const [sourceNode, setSourceNode] = useState(undefined); - const [targetNode, setTargetNode] = useState(undefined); - const [amount, setAmount] = useState(1); - const [frequency, setFrequency] = useState(1); - // get store actions for adding activities const { addSimulationActivity } = useStoreActions(s => s.network); const getAuthDetails = (node: LightningNode) => { - const id = nodeState && nodeState.nodes[node.name].info?.pubkey; + console.log(nodeState); + const id = nodeState && nodeState.nodes[node.name]?.info?.pubkey; + + if (!id) return; switch (node.implementation) { case 'LND': @@ -170,26 +176,39 @@ const ActivityGenerator: React.FC = ({ visible, network }) => { }; const handleAddActivity = async () => { + setAddActivityInvalidState(null); if (!sourceNode || !targetNode) return; + const sourceNodeDetails = getAuthDetails(sourceNode); + const targetNodeDetails = getAuthDetails(targetNode); + + if (!sourceNodeDetails || !targetNodeDetails) { + setAddActivityInvalidState({ + state: 'error', + message: '', + }); + return; + } + const sourceSimulationNode: SimulationActivityNode = { - id: getAuthDetails(sourceNode).id || '', + id: sourceNodeDetails.id || '', label: sourceNode.name, type: sourceNode.implementation, - address: getAuthDetails(sourceNode).address, - macaroon: getAuthDetails(sourceNode).macaroon, - tlsCert: getAuthDetails(sourceNode).tlsCert || '', - clientCert: getAuthDetails(sourceNode).clientCert, - clientKey: getAuthDetails(sourceNode).clientKey, + address: sourceNodeDetails.address, + macaroon: sourceNodeDetails.macaroon, + tlsCert: sourceNodeDetails.tlsCert || '', + clientCert: sourceNodeDetails.clientCert, + clientKey: sourceNodeDetails.clientKey, }; + const targetSimulationNode: SimulationActivityNode = { - id: getAuthDetails(targetNode).id || '', + id: targetNodeDetails.id || '', label: targetNode.name, type: targetNode.implementation, - address: getAuthDetails(targetNode).address, - macaroon: getAuthDetails(targetNode).macaroon, - tlsCert: getAuthDetails(targetNode).tlsCert || '', - clientCert: getAuthDetails(targetNode).clientCert, - clientKey: getAuthDetails(targetNode).clientKey, + address: targetNodeDetails.address, + macaroon: targetNodeDetails.macaroon, + tlsCert: targetNodeDetails.tlsCert || '', + clientCert: targetNodeDetails.clientCert, + clientKey: targetNodeDetails.clientKey, }; const activity = { source: sourceSimulationNode, @@ -199,24 +218,32 @@ const ActivityGenerator: React.FC = ({ visible, network }) => { networkId: network.id, }; await addSimulationActivity(activity); - console.log('activity', activity); - console.log('network', network.simulationActivities); + reset(); + toggle(); }; const handleSourceNodeChange = (selectedNodeName: string) => { const selectedNode = lightning.find(n => n.name === selectedNodeName); if (selectedNode?.name !== targetNode?.name) { - return setSourceNode(selectedNode); + updater({ name: 'sourceNode', value: selectedNode }); } }; const handleTargetNodeChange = (selectedNodeName: string) => { const selectedNode = lightning.find(n => n.name === selectedNodeName); if (selectedNode?.name !== sourceNode?.name) { - return setTargetNode(selectedNode); + updater({ name: 'targetNode', value: selectedNode }); } }; const handleFrequencyChange = (newValue: number) => { - setFrequency(newValue); + 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(() => { @@ -227,15 +254,6 @@ const ActivityGenerator: React.FC = ({ visible, network }) => { return lightning.filter(n => !sourceNode || n.id !== sourceNode.id); }, [lightning, sourceNode]); - React.useEffect(() => { - if (amount !== undefined && amount <= 0) { - setAmount(1); - } - if (frequency !== undefined && frequency <= 0) { - setFrequency(1); - } - }, [amount, frequency]); - return ( @@ -270,7 +288,7 @@ const ActivityGenerator: React.FC = ({ visible, network }) => { setAmount(e as number)} + onChange={e => handleAmountChange(e as number)} /> @@ -279,6 +297,7 @@ const ActivityGenerator: React.FC = ({ visible, network }) => { canAdd={!!sourceNode && !!targetNode} icon={} /> + {l('cancel')} = ({ visible, network }) => { > {l('save')} - {l('cancel')} + {addActivityInvalidState?.state && ( + setAddActivityInvalidState(null)} + type="warning" + message={addActivityInvalidState?.message || l('startWarning')} + closable={true} + showIcon + /> + )} ); }; diff --git a/src/components/designer/default/DefaultSidebar.tsx b/src/components/designer/default/DefaultSidebar.tsx index f12165f51..7bb811908 100644 --- a/src/components/designer/default/DefaultSidebar.tsx +++ b/src/components/designer/default/DefaultSidebar.tsx @@ -1,5 +1,4 @@ -import React from 'react'; -import { DeploymentUnitOutlined, SisternodeOutlined } from '@ant-design/icons'; +import React, { ReactNode } from 'react'; import styled from '@emotion/styled'; import { Button } from 'antd'; import { usePrefixedTranslation } from 'hooks'; @@ -67,9 +66,7 @@ interface Props { } const DefaultSidebar: React.FC = ({ network }) => { - const [designerType, setDesignerType] = React.useState<'network' | 'activity'>( - 'network', - ); + const [designerType, setDesignerType] = React.useState('network'); const { l } = usePrefixedTranslation('cmps.designer.default.DefaultSidebar'); const { updateSettings } = useStoreActions(s => s.app); @@ -83,6 +80,25 @@ const DefaultSidebar: React.FC = ({ network }) => { 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 => { const { logo, platforms } = dockerConfigs[image.implementation]; @@ -110,35 +126,19 @@ const DefaultSidebar: React.FC = ({ network }) => { }); return ( - + {designerType === 'network' ? (

{l('networkTitle')}

) : (

{l('activityTitle')}

)} - - } - onClick={() => setDesignerType('network')} - /> - } - onClick={() => setDesignerType('activity')} - color="red" - /> -
- - + {tabContents[designerType]}
); }; diff --git a/src/components/designer/default/cards/ActivityDesignerCard.tsx b/src/components/designer/default/cards/ActivityDesignerCard.tsx index be0d296c6..003093087 100644 --- a/src/components/designer/default/cards/ActivityDesignerCard.tsx +++ b/src/components/designer/default/cards/ActivityDesignerCard.tsx @@ -1,16 +1,17 @@ -import React from 'react'; +import React, { useState } from 'react'; import { ArrowDownOutlined, ArrowRightOutlined, ArrowUpOutlined, DeleteOutlined, + CopyOutlined, } from '@ant-design/icons'; import styled from '@emotion/styled'; -import { Button } from 'antd'; +import { Button, Tooltip } from 'antd'; import { usePrefixedTranslation } from 'hooks'; import { useTheme } from 'hooks/useTheme'; import { ThemeColors } from 'theme/colors'; -import { Network, SimulationActivity } from 'types'; +import { ActivityInfo, Network, SimulationActivity } from 'types'; import ActivityGenerator from '../../ActivityGenerator'; import { useStoreActions } from 'store'; @@ -58,6 +59,15 @@ const Styled = { color: #f7f2f2f2; } `, + CopyButton: styled(Button)` + border: none; + height: 100%; + opacity: 0.5; + + &:hover { + opacity: 1; + } + `, Divider: styled.div` height: 1px; width: 100%; @@ -125,9 +135,21 @@ interface Props { visible: boolean; } +const defaultActivityInfo: ActivityInfo = { + sourceNode: undefined, + targetNode: undefined, + amount: 1, + frequency: 1, +}; + 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 [activityInfo, setActivityInfo] = useState(defaultActivityInfo); + const theme = useTheme(); const { l } = usePrefixedTranslation( 'cmps.designer.default.cards.ActivityDesignerCard', @@ -135,10 +157,32 @@ const ActivityDesignerCard: React.FC = ({ visible, network }) => { const numberOfActivities = network.simulationActivities.length; const { removeSimulationActivity } = useStoreActions(s => s.network); + const toggleAddActivity = () => { + setIsAddActivityActive(prev => !prev); + }; + const handleRemoveActivity = async (activity: SimulationActivity) => { await removeSimulationActivity(activity); }; + const handleDuplicateActivity = async (activity: SimulationActivity) => { + await addSimulationActivity(activity); + }; + + const resolveUpdater = ({ + name, + value, + }: { + name: T; + value: ActivityInfo[T]; + }) => { + setActivityInfo(prev => ({ ...prev, [name]: value })); + }; + + const reset = () => { + setActivityInfo(defaultActivityInfo); + }; + if (!visible) return null; return ( <> @@ -152,14 +196,18 @@ const ActivityDesignerCard: React.FC = ({ visible, network }) => { size="small" shape="circle" icon={isAddActivityActive ? : } - onClick={() => setIsAddActivityActive(!isAddActivityActive)} + onClick={toggleAddActivity} /> {l('addActivitiesDesc')}

@@ -178,6 +226,12 @@ const ActivityDesignerCard: React.FC = ({ visible, network }) => { {activity.destination.label} + + handleDuplicateActivity(activity)} + icon={} + /> + handleRemoveActivity(activity)} icon={} diff --git a/src/i18n/locales/en-US.json b/src/i18n/locales/en-US.json index 40bfe3dbd..f11605a39 100644 --- a/src/i18n/locales/en-US.json +++ b/src/i18n/locales/en-US.json @@ -101,6 +101,7 @@ "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.NetworkDesignerCard.showVersions": "Show All Versions", @@ -305,6 +306,7 @@ "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", diff --git a/src/types/index.ts b/src/types/index.ts index 56ad34011..a1b6ec9be 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -250,6 +250,12 @@ export interface SimulationActivity { amountMsat: number; networkId: number; } +export interface ActivityInfo { + sourceNode: LightningNode | undefined; + targetNode: LightningNode | undefined; + amount: number; + frequency: number; +} export enum AutoMineMode { AutoOff = 0, From 6e655754483b2349eef96e5d4344192fd595e0d6 Mon Sep 17 00:00:00 2001 From: Emmanuel-Develops Date: Wed, 14 Feb 2024 16:37:50 +0100 Subject: [PATCH 5/9] feat: update activity logic --- src/components/designer/ActivityGenerator.tsx | 33 ++++------------- .../default/cards/ActivityDesignerCard.tsx | 37 ++++++++++++++++--- src/store/models/network.ts | 36 ++++++++++++++++++ src/types/index.ts | 1 + 4 files changed, 77 insertions(+), 30 deletions(-) diff --git a/src/components/designer/ActivityGenerator.tsx b/src/components/designer/ActivityGenerator.tsx index 9f037d689..184170f0a 100644 --- a/src/components/designer/ActivityGenerator.tsx +++ b/src/components/designer/ActivityGenerator.tsx @@ -1,5 +1,4 @@ import React, { useState } from 'react'; -import { PlusSquareOutlined } from '@ant-design/icons'; import styled from '@emotion/styled'; import { Alert, Button, Col, Form, InputNumber, Row, Select, Slider } from 'antd'; import { usePrefixedTranslation } from 'hooks'; @@ -16,22 +15,6 @@ const Styled = { width: 100%; border-radius: 4px; `, - AddActivity: styled(Button)<{ canAdd: boolean }>` - display: flex; - align-items: center; - justify-content: center; - height: 100%; - border: none; - font-size: 100px; - color: #fff; - cursor: ${props => (props.canAdd ? 'pointer' : 'not-allowed')}; - opacity: ${props => (props.canAdd ? '1' : '0.6')}; - - svg { - font-size: 33px; - color: ${props => (props.canAdd ? '#d46b08' : '#545353e6')}; - } - `, Divider: styled.div` height: 1px; width: 100%; @@ -125,6 +108,7 @@ const ActivityGenerator: React.FC = ({ }) => { if (!visible) return null; + const editActivityId = activityInfo.id; const [addActivityInvalidState, setAddActivityInvalidState] = useState(null); const { sourceNode, targetNode, frequency, amount } = activityInfo; @@ -134,10 +118,11 @@ const ActivityGenerator: React.FC = ({ const { lightning } = network.nodes; // get store actions for adding activities - const { addSimulationActivity } = useStoreActions(s => s.network); + const { addSimulationActivity, updateSimulationActivity } = useStoreActions( + s => s.network, + ); const getAuthDetails = (node: LightningNode) => { - console.log(nodeState); const id = nodeState && nodeState.nodes[node.name]?.info?.pubkey; if (!id) return; @@ -217,7 +202,10 @@ const ActivityGenerator: React.FC = ({ intervalSecs: frequency, networkId: network.id, }; - await addSimulationActivity(activity); + + editActivityId + ? await updateSimulationActivity({ ...activity, id: editActivityId }) + : await addSimulationActivity(activity); reset(); toggle(); }; @@ -292,11 +280,6 @@ const ActivityGenerator: React.FC = ({ /> - } - /> {l('cancel')} = ({ visible, network }) => { const [isAddActivityActive, setIsAddActivityActive] = React.useState(false); const { addSimulationActivity } = useStoreActions(s => s.network); + const { lightning } = network.nodes; const [activityInfo, setActivityInfo] = useState(defaultActivityInfo); @@ -161,14 +163,39 @@ const ActivityDesignerCard: React.FC = ({ visible, network }) => { setIsAddActivityActive(prev => !prev); }; - const handleRemoveActivity = async (activity: SimulationActivity) => { + const handleRemoveActivity = async ( + e: React.MouseEvent, + activity: SimulationActivity, + ) => { + e.stopPropagation(); await removeSimulationActivity(activity); }; - const handleDuplicateActivity = async (activity: SimulationActivity) => { + 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, @@ -219,7 +246,7 @@ const ActivityDesignerCard: React.FC = ({ visible, network }) => { console.log('clicked')} + onClick={() => handleSelectActivity(activity)} > {activity.source.label} @@ -228,12 +255,12 @@ const ActivityDesignerCard: React.FC = ({ visible, network }) => { handleDuplicateActivity(activity)} + onClick={e => handleDuplicateActivity(e, activity)} icon={} /> handleRemoveActivity(activity)} + onClick={e => handleRemoveActivity(e, activity)} icon={} /> diff --git a/src/store/models/network.ts b/src/store/models/network.ts index a769c449c..4183647a9 100644 --- a/src/store/models/network.ts +++ b/src/store/models/network.ts @@ -61,6 +61,10 @@ interface AddSimulationActivityArgs { networkId: SimulationActivity['networkId']; } +interface UpdateSimulationActivityArgs extends AddSimulationActivityArgs { + id: number; +} + export interface AutoMinerModel { startTime: number; timer?: NodeJS.Timer; @@ -200,6 +204,13 @@ export interface NetworkModel { RootModel, Promise >; + updateSimulationActivity: Thunk< + NetworkModel, + UpdateSimulationActivityArgs, + StoreInjections, + RootModel, + Promise + >; removeSimulationActivity: Thunk< NetworkModel, SimulationActivity, @@ -967,6 +978,31 @@ const networkModel: NetworkModel = { 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); diff --git a/src/types/index.ts b/src/types/index.ts index a1b6ec9be..430769753 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -251,6 +251,7 @@ export interface SimulationActivity { networkId: number; } export interface ActivityInfo { + id: number | undefined; sourceNode: LightningNode | undefined; targetNode: LightningNode | undefined; amount: number; From 27795df7c956400d9f3e96820f8edeb1716426ed Mon Sep 17 00:00:00 2001 From: Theophilus Date: Wed, 3 Apr 2024 21:57:07 +0100 Subject: [PATCH 6/9] feat: use simln docker image for node activity simulation --- .vscode/settings.json | 2 +- docker/nodes.json | 4 + src/components/designer/ActivityGenerator.tsx | 16 +-- .../default/cards/ActivityDesignerCard.tsx | 123 ++++++++++++++++-- src/i18n/locales/en-US.json | 2 + src/lib/docker/composeFile.ts | 14 +- src/lib/docker/dockerService.ts | 112 +++++++++++++++- src/lib/docker/nodeTemplates.ts | 18 +++ src/store/models/network.ts | 79 +++++++---- src/types/index.ts | 3 + src/utils/config.ts | 2 +- src/utils/constants.ts | 16 ++- src/utils/tests/renderWithProviders.tsx | 2 + 13 files changed, 344 insertions(+), 49 deletions(-) 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/docker/nodes.json b/docker/nodes.json index 4ea685a44..ee1ba59c2 100644 --- a/docker/nodes.json +++ b/docker/nodes.json @@ -59,6 +59,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 184170f0a..9f81fb651 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 63b15420e..ac890c86f 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 f11605a39..09556ea71 100644 --- a/src/i18n/locales/en-US.json +++ b/src/i18n/locales/en-US.json @@ -104,6 +104,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 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..c8a45d8b3 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 d26360702..6fb39de0f 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 4183647a9..3d0fed9d5 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>; @@ -741,26 +755,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 })); @@ -966,13 +1000,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 430769753..e635292f3 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -97,6 +97,7 @@ export interface DockerConfig { variables: string[]; dataDir?: string; apiDir?: string; + env?: Record; } export interface DockerRepoImage { @@ -127,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; 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/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(), From 98ed68d9a17a3311ebb48257bfa95c07f7008a82 Mon Sep 17 00:00:00 2001 From: Theophilus Date: Wed, 3 Apr 2024 23:43:44 +0100 Subject: [PATCH 7/9] chore: rebase on updated master branch --- src/store/models/network.ts | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/store/models/network.ts b/src/store/models/network.ts index 3d0fed9d5..1e62b2551 100644 --- a/src/store/models/network.ts +++ b/src/store/models/network.ts @@ -1,4 +1,4 @@ -import { remote, SaveDialogOptions } from 'electron'; +import { ipcRenderer, remote, SaveDialogOptions } from 'electron'; import { info } from 'electron-log'; import { join } from 'path'; import { push } from 'connected-react-router'; @@ -795,6 +795,26 @@ const networkModel: NetworkModel = { } }, ), + 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'); + } + }, 2000); + }), toggle: thunk(async (actions, networkId, { getState }) => { const network = getState().networks.find(n => n.id === networkId); if (!network) throw new Error(l('networkByIdErr', { networkId })); From bfafb44d8aa6f76496658baa5b224b7e8f98eddc Mon Sep 17 00:00:00 2001 From: Theophilus Date: Wed, 29 May 2024 14:28:48 +0100 Subject: [PATCH 8/9] feat: update simulation activity logic and volumes configuration --- docker/nodes.json | 4 --- .../default/cards/ActivityDesignerCard.tsx | 3 +- src/lib/docker/dockerService.ts | 36 ++++++++++++++----- src/lib/docker/nodeTemplates.ts | 6 +++- 4 files changed, 34 insertions(+), 15 deletions(-) diff --git a/docker/nodes.json b/docker/nodes.json index ee1ba59c2..4ea685a44 100644 --- a/docker/nodes.json +++ b/docker/nodes.json @@ -59,10 +59,6 @@ "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/default/cards/ActivityDesignerCard.tsx b/src/components/designer/default/cards/ActivityDesignerCard.tsx index ac890c86f..d803ece13 100644 --- a/src/components/designer/default/cards/ActivityDesignerCard.tsx +++ b/src/components/designer/default/cards/ActivityDesignerCard.tsx @@ -186,9 +186,10 @@ const ActivityDesignerCard: React.FC = ({ visible, network }) => { useEffect(() => { isSimulationContainerRunning().then(isRunning => { + console.log('isRunning', isRunning); setIsStartSimulationActive(isRunning); }); - }, []); + }, [isSimulationContainerRunning]); const startSimulationActivity = () => { if (network.status !== Status.Started) { diff --git a/src/lib/docker/dockerService.ts b/src/lib/docker/dockerService.ts index c8a45d8b3..48419ed82 100644 --- a/src/lib/docker/dockerService.ts +++ b/src/lib/docker/dockerService.ts @@ -178,17 +178,30 @@ class DockerService implements DockerLibrary { 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: activity.source.macaroon, - cert: activity.source.clientCert ?? activity.source.clientKey, + macaroon: `/home/simln/.${sourceMacaroon}`, + cert: `/home/simln/.${sourceCert}`, }); nodes.add({ id: activity.destination.id, address: activity.destination.address, - macaroon: activity.destination.macaroon, - cert: activity.destination.clientCert ?? activity.destination.clientKey, + macaroon: `/home/simln/.${destMacaroon}`, + cert: `/home/simln/.${destCert}`, }); activities.add({ @@ -200,7 +213,7 @@ class DockerService implements DockerLibrary { }); return { nodes: Array.from(nodes), - activities: Array.from(activities) as SimulationActivity[], + activity: Array.from(activities) as SimulationActivity[], }; } @@ -210,8 +223,9 @@ class DockerService implements DockerLibrary { */ async startSimulationActivity(network: Network) { const simJson = await this.constructSimJson(network); - console.log('simJson', simJson); - info(`simJson: ${simJson}`); + info( + `simJson: ${simJson} simJson.nodes: ${simJson.nodes} simJson.activities: ${simJson.activity}`, + ); await this.ensureDirs(network, [ ...network.nodes.bitcoin, ...network.nodes.lightning, @@ -234,8 +248,12 @@ class DockerService implements DockerLibrary { 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`); + const removedContainer = await this.execute( + compose.rm as any, + this.getArgs(network), + 'simln', + ); + info(`Removed simln container ${removedContainer.out || removedContainer.err}`); } /** diff --git a/src/lib/docker/nodeTemplates.ts b/src/lib/docker/nodeTemplates.ts index 6fb39de0f..1c4954dc8 100644 --- a/src/lib/docker/nodeTemplates.ts +++ b/src/lib/docker/nodeTemplates.ts @@ -18,7 +18,11 @@ export const simln = ( command: trimInside(command), environment, restart: 'always', - volumes: [`./volumes/${name}:/home/simln/.simln`], + volumes: [ + `./volumes/${name}:/home/simln/.simln`, + `./volumes/${dockerConfigs.LND.volumeDirName}:/home/simln/.lnd`, + `./volumes/${dockerConfigs['c-lightning'].volumeDirName}:/home/simln/.clightning`, + ], expose: [], ports: [], }); From fd6aee10f02f4d21f39a8bde6c50819d4f7ef713 Mon Sep 17 00:00:00 2001 From: Theophilus Date: Tue, 11 Jun 2024 16:42:37 +0100 Subject: [PATCH 9/9] feat: add starting and stopping states to ActivityDesignerCard --- src/components/designer/custom/NodeInner.tsx | 2 +- .../default/cards/ActivityDesignerCard.tsx | 150 +++++++++--------- src/i18n/locales/en-US.json | 3 + 3 files changed, 82 insertions(+), 73 deletions(-) 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/cards/ActivityDesignerCard.tsx b/src/components/designer/default/cards/ActivityDesignerCard.tsx index d803ece13..3aee85d2a 100644 --- a/src/components/designer/default/cards/ActivityDesignerCard.tsx +++ b/src/components/designer/default/cards/ActivityDesignerCard.tsx @@ -1,4 +1,5 @@ -import React, { useEffect, useState } from 'react'; +import React, { useState } from 'react'; +import { useAsyncCallback } from 'react-async-hook'; import { ArrowDownOutlined, ArrowRightOutlined, @@ -11,7 +12,6 @@ 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'; @@ -152,7 +152,7 @@ const defaultActivityInfo: ActivityInfo = { }; const ActivityDesignerCard: React.FC = ({ visible, network }) => { - const [isSimulationActive, setIsStartSimulationActive] = React.useState(false); + const [isSimulationActive, setIsSimulationActive] = React.useState(false); const [isAddActivityActive, setIsAddActivityActive] = React.useState(false); const [addActivityInvalidState, setAddActivityInvalidState] = useState(null); @@ -168,81 +168,72 @@ const ActivityDesignerCard: React.FC = ({ visible, network }) => { 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 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 => { - console.log('isRunning', isRunning); - setIsStartSimulationActive(isRunning); - }); - }, [isSimulationContainerRunning]); - - 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; + const startSimulationActivity = useAsyncCallback(async () => { + try { + if (network.status !== Status.Started) { + setAddActivityInvalidState({ + state: 'warning', + message: l('startWarning'), + action: 'start', }); - }); - if (nodes.length > 0) { - setIsAddActivityActive(true); - 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; + }); }); - setIsStartSimulationActive(false); - return; - } - setAddActivityInvalidState(null); - if (isSimulationActive) { - setIsStartSimulationActive(false); - stopSimulation({ id: network.id }); - return; + 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 }); } - setIsStartSimulationActive(true); - startSimulation({ id: network.id }); - }; + }); const toggleAddActivity = () => { setIsAddActivityActive(prev => !prev); @@ -295,6 +286,19 @@ const ActivityDesignerCard: React.FC = ({ visible, network }) => { 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 ( <> @@ -367,9 +371,11 @@ const ActivityDesignerCard: React.FC = ({ visible, network }) => { - {isSimulationActive ? l('stop') : l('start')} + {primaryCtaText()} ); diff --git a/src/i18n/locales/en-US.json b/src/i18n/locales/en-US.json index 09556ea71..19c16a1f7 100644 --- a/src/i18n/locales/en-US.json +++ b/src/i18n/locales/en-US.json @@ -98,12 +98,15 @@ "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",