diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000000..47a7bf4746 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: [jamaljsr] diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 06cdc9636d..54196f6f5a 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -18,17 +18,17 @@ jobs: strategy: matrix: node_version: [20.x] - os: [ubuntu-latest, windows-latest, macos-13] + os: [ubuntu-latest, windows-latest, macos-15-intel] steps: - name: ubuntu file watcher tweak if: matrix.os == 'ubuntu-latest' run: echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf && sudo sysctl -p - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: setup nodejs - uses: actions/setup-node@v5 + uses: actions/setup-node@v6 with: node-version: ${{ matrix.node_version }} cache: yarn diff --git a/docker/README.md b/docker/README.md index 4ef2ceb791..f5216fe6c0 100644 --- a/docker/README.md +++ b/docker/README.md @@ -21,6 +21,7 @@ $ docker buildx inspect --bootstrap ### Tags +- `30.0` ([bitcoind/Dockerfile](https://github.com/jamaljsr/polar/blob/master/docker/bitcoind/Dockerfile)) - `29.0` ([bitcoind/Dockerfile](https://github.com/jamaljsr/polar/blob/master/docker/bitcoind/Dockerfile)) - `28.0` ([bitcoind/Dockerfile](https://github.com/jamaljsr/polar/blob/master/docker/bitcoind/Dockerfile)) - `27.0` ([bitcoind/Dockerfile](https://github.com/jamaljsr/polar/blob/master/docker/bitcoind/Dockerfile)) @@ -50,6 +51,8 @@ Replace `` with the desired bitcoind version (ex: `0.18.1`) ### Tags +- `0.20.0-beta` ([lnd/Dockerfile](https://github.com/jamaljsr/polar/blob/master/docker/lnd/Dockerfile)) +- `0.19.3-beta` ([lnd/Dockerfile](https://github.com/jamaljsr/polar/blob/master/docker/lnd/Dockerfile)) - `0.19.2-beta` ([lnd/Dockerfile](https://github.com/jamaljsr/polar/blob/master/docker/lnd/Dockerfile)) - `0.19.1-beta` ([lnd/Dockerfile](https://github.com/jamaljsr/polar/blob/master/docker/lnd/Dockerfile)) - `0.19.0-beta` ([lnd/Dockerfile](https://github.com/jamaljsr/polar/blob/master/docker/lnd/Dockerfile)) @@ -107,6 +110,7 @@ Replace `` with the desired LND version (ex: `0.7.1-beta`) ### Tags +- `25.09.3` ([clightning/Dockerfile](https://github.com/jamaljsr/polar/blob/master/docker/clightning/Dockerfile)) - `25.05` ([clightning/Dockerfile](https://github.com/jamaljsr/polar/blob/master/docker/clightning/Dockerfile)) - `25.02.2` ([clightning/Dockerfile](https://github.com/jamaljsr/polar/blob/master/docker/clightning/Dockerfile)) - `25.02` ([clightning/Dockerfile](https://github.com/jamaljsr/polar/blob/master/docker/clightning/Dockerfile)) @@ -146,6 +150,7 @@ Replace `` with the desired c-lightning version (ex: `0.8.0`). ### Tags +- `0.13.1` ([eclair/Dockerfile](https://github.com/jamaljsr/polar/blob/master/docker/eclair/Dockerfile)) - `0.12.0` ([eclair/Dockerfile](https://github.com/jamaljsr/polar/blob/master/docker/eclair/Dockerfile)) - `0.11.0` ([eclair/Dockerfile](https://github.com/jamaljsr/polar/blob/master/docker/eclair/Dockerfile)) - `0.10.0` ([eclair/Dockerfile](https://github.com/jamaljsr/polar/blob/master/docker/eclair/Dockerfile)) @@ -172,6 +177,7 @@ Replace `` with the desired Eclair version (ex: `0.3.3`). ### Tags +- `0.7.0-alpha` ([tap/Dockerfile](https://github.com/jamaljsr/polar/blob/master/docker/tapd/Dockerfile)) - `0.6.1-alpha` ([tap/Dockerfile](https://github.com/jamaljsr/polar/blob/master/docker/tapd/Dockerfile)) - `0.6.0-alpha` ([tap/Dockerfile](https://github.com/jamaljsr/polar/blob/master/docker/tapd/Dockerfile)) - `0.5.1-alpha` ([tap/Dockerfile](https://github.com/jamaljsr/polar/blob/master/docker/tapd/Dockerfile)) @@ -199,6 +205,8 @@ Replace `` with the desired Tap version (ex: `0.2.0-alpha`). ### Tags +- `0.16.0-alpha` ([litd/Dockerfile](https://github.com/jamaljsr/polar/blob/master/docker/litd/Dockerfile)) +- `0.15.3-alpha` ([litd/Dockerfile](https://github.com/jamaljsr/polar/blob/master/docker/litd/Dockerfile)) - `0.15.1-alpha` ([litd/Dockerfile](https://github.com/jamaljsr/polar/blob/master/docker/litd/Dockerfile)) - `0.15.0-alpha` ([litd/Dockerfile](https://github.com/jamaljsr/polar/blob/master/docker/litd/Dockerfile)) - `0.14.1-alpha` ([litd/Dockerfile](https://github.com/jamaljsr/polar/blob/master/docker/litd/Dockerfile)) @@ -213,6 +221,21 @@ $ docker buildx build --platform linux/amd64,linux/arm64 --build-arg LITD_VERSIO Replace `` with the desired Tap version (ex: `0.13.3-alpha`). +## SimLN + +### Tags + +- `0.2.5` ([simln/Dockerfile](https://github.com/jamaljsr/polar/blob/master/docker/simln/Dockerfile)) + +**Building the image** + +```sh +$ cd simln +$ docker buildx build --platform linux/amd64, linux/arm64 --build-arg SIMLN_VERSION= -t polarlightning/simln: --push . +``` + +Replace `` with the desired SimLN version (ex: `0.2.5`). + # Out-of-Band Image Updates > Note: These steps can only be performed by developers with commit access to this GitHub repo and push access to the Docker Hub repo diff --git a/docker/bitcoind/Dockerfile b/docker/bitcoind/Dockerfile index 0f0ffca6e3..04e8a071f5 100644 --- a/docker/bitcoind/Dockerfile +++ b/docker/bitcoind/Dockerfile @@ -14,7 +14,7 @@ RUN SYS_ARCH="$(uname -m)" \ && rm *.tar.gz RUN curl -SLO https://raw.githubusercontent.com/bitcoin/bitcoin/master/contrib/completions/bash/bitcoin-cli.bash \ - && mkdir /etc/bash_completion.d \ + && mkdir -p /etc/bash_completion.d \ && mv bitcoin-cli.bash /etc/bash_completion.d/bitcoin-cli.bash-completion \ && curl -SLO https://raw.githubusercontent.com/scop/bash-completion/master/bash_completion \ && mv bash_completion /usr/share/bash-completion/ diff --git a/docker/eclair/Dockerfile b/docker/eclair/Dockerfile index 4c64ca8764..3316e1a0ba 100644 --- a/docker/eclair/Dockerfile +++ b/docker/eclair/Dockerfile @@ -1,7 +1,7 @@ ######################### # Original Source: https://github.com/ACINQ/eclair/blob/v0.12.0/Dockerfile ######################### -FROM eclipse-temurin:21.0.8_9-jdk-alpine as BUILD +FROM eclipse-temurin:25.0.1_8-jdk-alpine as BUILD # Let's fetch eclair dependencies, so that Docker can cache them # This way we won't have to fetch dependencies again if only the source code changes @@ -55,7 +55,7 @@ RUN cp -R -f /usr/repo/* . RUN ./mvnw package -pl eclair-node -am -DskipTests -Dgit.commit.id=notag -Dgit.commit.id.abbrev=notag -o # It might be good idea to run the tests here, so that the docker build fail if the code is bugged -FROM eclipse-temurin:21.0.8_9-jre-alpine +FROM eclipse-temurin:25.0.1_8-jre-alpine WORKDIR /app # install jq for eclair-cli diff --git a/docker/litd/src.Dockerfile b/docker/litd/src.Dockerfile index f2cf26a261..ecb5d587fe 100644 --- a/docker/litd/src.Dockerfile +++ b/docker/litd/src.Dockerfile @@ -1,5 +1,5 @@ # Start with a NodeJS base image that also contains yarn. -FROM node:22.14.0-alpine@sha256:9bef0ef1e268f60627da9ba7d7605e8831d5b56ad07487d24d1aa386336d1944 as nodejsbuilder +FROM node:22.21.1-alpine@sha256:9632533eda8061fc1e9960cfb3f8762781c07a00ee7317f5dc0e13c05e15166f as nodejsbuilder ARG LITD_VERSION diff --git a/docker/lnd/Dockerfile b/docker/lnd/Dockerfile index 29f6154daa..c28aeda94b 100644 --- a/docker/lnd/Dockerfile +++ b/docker/lnd/Dockerfile @@ -16,7 +16,7 @@ RUN SYS_ARCH="$(dpkg --print-architecture)" \ && rm *.tar.gz RUN curl -SLO https://raw.githubusercontent.com/lightningnetwork/lnd/master/contrib/lncli.bash-completion \ - && mkdir /etc/bash_completion.d \ + && mkdir -p /etc/bash_completion.d \ && mv lncli.bash-completion /etc/bash_completion.d/ \ && curl -SLO https://raw.githubusercontent.com/scop/bash-completion/master/bash_completion \ && mv bash_completion /usr/share/bash-completion/ diff --git a/docker/nodes.json b/docker/nodes.json index e672b80bfc..3d4b2a50c9 100644 --- a/docker/nodes.json +++ b/docker/nodes.json @@ -1,9 +1,11 @@ { - "version": 75, + "version": 76, "images": { "LND": { - "latest": "0.19.2-beta", + "latest": "0.20.0-beta", "versions": [ + "0.20.0-beta", + "0.19.3-beta", "0.19.2-beta", "0.19.1-beta", "0.19.0-beta", @@ -17,6 +19,8 @@ "0.16.4-beta" ], "compatibility": { + "0.20.0-beta": "29.0", + "0.19.3-beta": "29.0", "0.19.2-beta": "29.0", "0.19.1-beta": "29.0", "0.19.0-beta": "29.0", @@ -31,8 +35,8 @@ } }, "c-lightning": { - "latest": "25.05", - "versions": ["25.05", "25.02.2", "25.02", "24.11.1", "24.11", "24.08.1", "24.05"] + "latest": "25.09.3", + "versions": ["25.09.3", "25.05", "25.02.2", "25.02", "24.11.1", "24.11", "24.08.1"] }, "eclair": { "latest": "0.12.0", @@ -47,8 +51,9 @@ "versions": [] }, "tapd": { - "latest": "0.6.1-alpha", + "latest": "0.7.0-alpha", "versions": [ + "0.7.0-alpha", "0.6.1-alpha", "0.6.0-alpha", "0.5.1-alpha", @@ -57,6 +62,7 @@ "0.3.3-alpha" ], "compatibility": { + "0.7.0-alpha": "0.19.0-beta", "0.6.1-alpha": "0.19.0-beta", "0.6.0-alpha": "0.19.0-beta", "0.5.1-alpha": "0.18.5-beta", @@ -66,13 +72,20 @@ } }, "litd": { - "latest": "0.15.1-alpha", - "versions": ["0.15.1-alpha", "0.15.0-alpha", "0.14.1-alpha", "0.14.0-alpha"], + "latest": "0.16.0-alpha", + "versions": [ + "0.16.0-alpha", + "0.15.3-alpha", + "0.15.1-alpha", + "0.15.0-alpha", + "0.14.1-alpha" + ], "compatibility": { + "0.16.0-alpha": "29.0", + "0.15.3-alpha": "29.0", "0.15.1-alpha": "29.0", "0.15.0-alpha": "29.0", - "0.14.1-alpha": "29.0", - "0.14.0-alpha": "29.0" + "0.14.1-alpha": "29.0" } } } diff --git a/docker/simln/Dockerfile b/docker/simln/Dockerfile new file mode 100644 index 0000000000..f53bd78454 --- /dev/null +++ b/docker/simln/Dockerfile @@ -0,0 +1,76 @@ +######################### +# Original Source: https://github.com/bitcoin-dev-project/sim-ln/blob/main/docker/Dockerfile + +# Use the rust image as the base image for the build stage +FROM rust:1.92.0 AS base + +# buildkit will provide this automatically, no need to pass it in +ARG TARGETARCH +######################### + +#################### Polar Modification +# The version of sim-ln to use +ARG SIMLN_VERSION +#################### + +FROM base AS builder-amd64 +ENV TARGET_RUST_ARCH="x86_64-unknown-linux-musl" + +FROM base AS builder-arm64 +ENV TARGET_RUST_ARCH="aarch64-unknown-linux-musl" +RUN apt-get update \ + && apt-get install clang llvm -y --no-install-recommends \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* +ENV CC_aarch64_unknown_linux_musl=clang +ENV AR_aarch64_unknown_linux_musl=llvm-ar +ENV CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_RUSTFLAGS="-Clink-self-contained=yes -Clinker=rust-lld" + +FROM builder-${TARGETARCH} AS builder +RUN echo "Building for architecture: ${TARGET_RUST_ARCH}" + +#################### Polar Modification +# Download the simln source code +RUN curl -SLO https://github.com/bitcoin-dev-project/sim-ln/archive/refs/tags/v${SIMLN_VERSION}.tar.gz \ + && tar -xzf *.tar.gz -C . \ + && mv sim-ln-${SIMLN_VERSION} /sim-ln \ + && rm *.tar.gz +#################### +# Install the dependencies required for building sim-ln +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + protobuf-compiler musl-tools build-essential perl \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# Set to 1 to instruct openssl-sys to link OpenSSL statically, ensuring a fully static binary +ENV OPENSSL_STATIC=1 +# Set to 1 to enable vendoring, making openssl-sys build OpenSSL from source instead of using system libraries +ENV OPENSSL_VENDORED=1 + +RUN rustup target add ${TARGET_RUST_ARCH} +RUN rustup component add rustfmt +RUN cd /sim-ln && RUSTFLAGS='-C target-feature=+crt-static' cargo build --locked --release --target=${TARGET_RUST_ARCH} +RUN mv /sim-ln/target/${TARGET_RUST_ARCH}/release/sim-cli /sim-ln/sim-cli + +# Use a new stage with a smaller base image to reduce image size +FROM alpine:latest + +RUN apk update && apk upgrade + +# Copy the sim-cli binaries from the build stage to the new stage +COPY --from=builder /sim-ln/sim-cli /usr/local/bin/ + +#################### Polar Modification +# Original lines: +# COPY entrypoint.sh /entrypoint.sh +# Copy the entrypoint script to the container +COPY docker-entrypoint.sh /entrypoint.sh +#################### + +RUN chmod +x entrypoint.sh + +STOPSIGNAL SIGINT + +ENTRYPOINT [ "/entrypoint.sh" ] + diff --git a/docker/simln/docker-entrypoint.sh b/docker/simln/docker-entrypoint.sh new file mode 100644 index 0000000000..c83e349a0c --- /dev/null +++ b/docker/simln/docker-entrypoint.sh @@ -0,0 +1,41 @@ +#!/bin/sh + +#################### +# Original Source: https://github.com/bitcoin-dev-project/sim-ln/blob/main/docker/entrypoint.sh +#################### + +# Define the start command +START_COMMAND="/usr/local/bin/sim-cli --sim-file $SIMFILE_PATH" + +# Check if a custom data directory was provided +if [[ ! -z ${DATA_DIR} ]]; then + START_COMMAND="$START_COMMAND --data-dir $DATA_DIR" +fi + +# Check for version arg +if [[ ! -z ${VERSION} ]]; then + START_COMMAND="$START_COMMAND --version" +fi + +# Check for help arg +if [[ ! -z ${HELP} ]]; then + START_COMMAND="$START_COMMAND --help" +fi + +# Check for log level arg +if [[ ! -z ${LOG_LEVEL} ]]; then + START_COMMAND="$START_COMMAND --log-level $LOG_LEVEL" +fi + +# Check for total time arg +if [[ ! -z ${TOTAL_TIME} ]]; then + START_COMMAND="$START_COMMAND --total-time $TOTAL_TIME" +fi + +# Check for print-batch-size arg +if [[ ! -z ${PRINT_BATCH_SIZE} ]]; then + START_COMMAND="$START_COMMAND --print-batch-size $PRINT_BATCH_SIZE" +fi + +# start the container +exec $START_COMMAND \ No newline at end of file diff --git a/package.json b/package.json index 5c6e5ac0c8..5310a85da9 100644 --- a/package.json +++ b/package.json @@ -44,9 +44,9 @@ }, "dependencies": { "@lightningpolar/litd-api": "0.15.0-alpha.rc1", - "@lightningpolar/lnd-api": "0.19.0-beta", - "@lightningpolar/tapd-api": "0.6.0-alpha.rc2", - "@types/lodash": "4.17.20", + "@lightningpolar/lnd-api": "0.20.0-beta", + "@lightningpolar/tapd-api": "0.7.0-alpha", + "@types/lodash": "4.17.21", "archiver": "7.0.1", "docker-compose": "0.24.0", "dockerode": "4.0.2", @@ -56,7 +56,7 @@ "electron-window-state": "5.0.3", "fs-extra": "11.1.0", "lodash": "4.17.21", - "semver": "7.7.2", + "semver": "7.7.3", "shell-env": "3.0.1", "unzipper": "0.12.3", "xterm": "5.3.0", @@ -65,8 +65,8 @@ "devDependencies": { "@ant-design/icons": "5.6.1", "@babel/plugin-proposal-private-property-in-object": "7.21.11", - "@commitlint/cli": "19.8.1", - "@commitlint/config-conventional": "19.8.1", + "@commitlint/cli": "20.2.0", + "@commitlint/config-conventional": "20.2.0", "@emotion/babel-plugin": "11.13.5", "@emotion/react": "11.14.0", "@emotion/styled": "11.14.1", @@ -77,14 +77,14 @@ "@testing-library/jest-dom": "5.17.0", "@testing-library/react": "12.1.5", "@testing-library/user-event": "14.6.1", - "@types/archiver": "6.0.3", + "@types/archiver": "7.0.0", "@types/detect-port": "1.3.5", - "@types/dockerode": "3.3.43", + "@types/dockerode": "3.3.47", "@types/fs-extra": "9.0.13", "@types/jest": "30.0.0", "@types/js-yaml": "4.0.9", "@types/node": "18.17.19", - "@types/react": "18.3.24", + "@types/react": "18.3.27", "@types/react-copy-to-clipboard": "5.0.7", "@types/react-dom": "18.3.7", "@types/react-redux": "7.1.34", @@ -102,7 +102,7 @@ "commitizen": "4.3.1", "concurrently": "9.2.1", "connected-react-router": "6.9.3", - "cross-env": "10.0.0", + "cross-env": "10.1.0", "detect-port": "1.6.1", "docker-compose": "0.24.0", "dockerode": "4.0.2", @@ -121,13 +121,13 @@ "i18next-scanner": "4.6.0", "jest-canvas-mock": "2.5.2", "jest-environment-jsdom-sixteen": "2.0.0", - "js-yaml": "4.1.0", - "less": "4.4.0", + "js-yaml": "4.1.1", + "less": "4.5.1", "less-loader": "12.3.0", - "lint-staged": "16.1.6", + "lint-staged": "16.2.7", "lndconnect": "0.2.10", - "nodemon": "3.1.10", - "patch-package": "8.0.0", + "nodemon": "3.1.11", + "patch-package": "8.0.1", "prettier": "2.8.8", "react": "17.0.2", "react-async-hook": "4.0.0", @@ -147,16 +147,16 @@ "standard-version": "9.5.0", "strip-ansi": "6.0.1", "styled-components": "6.1.19", - "stylelint": "16.24.0", + "stylelint": "16.26.1", "stylelint-config-prettier": "9.0.5", - "stylelint-config-standard": "39.0.0", + "stylelint-config-standard": "39.0.1", "testcafe": "3.7.2", "testcafe-browser-provider-electron": "0.0.21", "testcafe-react-selectors": "5.0.3", "ts-node": "10.9.2", "typescript": "5.5.4", "utf-8-validate": "6.0.5", - "wait-on": "9.0.0", + "wait-on": "9.0.3", "webpack": "4.44.2", "ws": "8.18.3" }, diff --git a/src/components/common/RenameNodeModal.spec.tsx b/src/components/common/RenameNodeModal.spec.tsx index c77a128005..5a8f14739b 100644 --- a/src/components/common/RenameNodeModal.spec.tsx +++ b/src/components/common/RenameNodeModal.spec.tsx @@ -6,9 +6,9 @@ import { initChartFromNetwork } from 'utils/chart'; import { defaultRepoState } from 'utils/constants'; import { createNetwork } from 'utils/network'; import { + bitcoinServiceMock, injections, lightningServiceMock, - bitcoinServiceMock, litdServiceMock, renderWithProviders, tapServiceMock, @@ -41,6 +41,7 @@ describe('RenameNodeModal', () => { repoState: defaultRepoState, managedImages: testManagedImages, customImages: [], + manualMineCount: 6, }); const initialState = { network: { diff --git a/src/components/common/StatusButton.spec.tsx b/src/components/common/StatusButton.spec.tsx new file mode 100644 index 0000000000..5ecc3a092e --- /dev/null +++ b/src/components/common/StatusButton.spec.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { Status } from 'shared/types'; +import { renderWithProviders } from 'utils/tests'; +import StatusButton from './StatusButton'; + +describe('StatusButton Component', () => { + const renderComponent = (status: Status) => + renderWithProviders( ''} />); + + it('should render starting button', () => { + const { getByText } = renderComponent(Status.Starting); + expect(getByText('Starting')).toBeInTheDocument(); + }); + + it('should render started button', () => { + const { getByText } = renderComponent(Status.Started); + expect(getByText('Stop')).toBeInTheDocument(); + }); + + it('should render stopping button', () => { + const { getByText } = renderComponent(Status.Stopping); + expect(getByText('Stopping')).toBeInTheDocument(); + }); + + it('should render stopped button', () => { + const { getByText } = renderComponent(Status.Stopped); + expect(getByText('Start')).toBeInTheDocument(); + }); + + it('should render error button', () => { + const { getByText } = renderComponent(Status.Error); + expect(getByText('Restart')).toBeInTheDocument(); + }); +}); diff --git a/src/components/common/StatusButton.tsx b/src/components/common/StatusButton.tsx new file mode 100644 index 0000000000..0d73dc04a6 --- /dev/null +++ b/src/components/common/StatusButton.tsx @@ -0,0 +1,78 @@ +import React, { ReactNode } from 'react'; +import styled from '@emotion/styled'; +import { Button } from 'antd'; +import { Status } from 'shared/types'; +import { ButtonType } from 'antd/lib/button'; +import { PlayCircleOutlined, StopOutlined, WarningOutlined } from '@ant-design/icons'; + +const Styled = { + Button: styled(Button)<{ fullWidth?: boolean }>` + margin-left: 0; + ${({ fullWidth }) => fullWidth && 'width: 100%;'} + `, +}; + +interface StatusButtonProps { + status: Status; + onClick: () => void; + fullWidth?: boolean; +} + +const config: { + [key: number]: { + label: string; + type: ButtonType; + danger?: boolean; + icon: ReactNode; + }; +} = { + [Status.Starting]: { + label: 'Starting', + type: 'primary', + icon: '', + }, + [Status.Started]: { + label: 'Stop', + type: 'primary', + danger: true, + icon: , + }, + [Status.Stopping]: { + label: 'Stopping', + type: 'default', + icon: '', + }, + [Status.Stopped]: { + label: 'Start', + type: 'primary', + icon: , + }, + [Status.Error]: { + label: 'Restart', + type: 'primary', + danger: true, + icon: , + }, +}; + +const StatusButton: React.FC = ({ status, onClick, fullWidth }) => { + const { type, danger, icon, label } = config[status]; + const ghost = status === Status.Started; + const loading = status === Status.Starting || status === Status.Stopping; + return ( + + {label} + + ); +}; + +export default StatusButton; diff --git a/src/components/common/form/LightningNodeSelect.spec.tsx b/src/components/common/form/LightningNodeSelect.spec.tsx index 3cdee1e4e8..72ead48363 100644 --- a/src/components/common/form/LightningNodeSelect.spec.tsx +++ b/src/components/common/form/LightningNodeSelect.spec.tsx @@ -23,7 +23,7 @@ describe('LightningNodeSelect', () => { name="from" label="Source" initialValue={initialValue} - implementation="LND" + implementation={['LND']} nodes={nodes} /> diff --git a/src/components/common/form/LightningNodeSelect.tsx b/src/components/common/form/LightningNodeSelect.tsx index a038ab9603..9e1e5ff926 100644 --- a/src/components/common/form/LightningNodeSelect.tsx +++ b/src/components/common/form/LightningNodeSelect.tsx @@ -13,7 +13,7 @@ export interface Props extends SelectProps { name: string; label?: string; nodeStatus?: Status; - implementation?: LightningNode['implementation']; + implementation?: LightningNode['implementation'][] | LightningNode['implementation']; initialValue?: string; nodes?: { [key: string]: LightningNodeModel; @@ -54,7 +54,11 @@ const LightningNodeSelect: React.FC = ({ lnNodes = lnNodes.filter(n => n.status === nodeStatus); } if (implementation) { - lnNodes = lnNodes.filter(n => n.implementation === implementation); + if (Array.isArray(implementation)) { + lnNodes = lnNodes.filter(n => implementation.includes(n.implementation)); + } else { + lnNodes = lnNodes.filter(n => n.implementation === implementation); + } } return ( { fireEvent.click(getByText('Cancel')); }); + it('should display the Add Simulation modal', async () => { + const { getByText, findByText, store } = renderComponent(); + act(() => { + store.getActions().modals.showAddSimulation({}); + }); + expect(await findByText('Add Simulation')).toBeInTheDocument(); + fireEvent.click(getByText('Cancel')); + }); + it('should remove a node from the network', async () => { const { getByText, findByText, queryByText, store } = renderComponent(); // add a new LN node that doesn't have a tap node connected diff --git a/src/components/designer/NetworkDesigner.tsx b/src/components/designer/NetworkDesigner.tsx index 04a16fba4d..66ba288d4f 100644 --- a/src/components/designer/NetworkDesigner.tsx +++ b/src/components/designer/NetworkDesigner.tsx @@ -27,6 +27,7 @@ import { NewAddressModal, SendAssetModal, } from './tap/actions'; +import AddSimulationModal from './default/AddSimulationModal'; const Styled = { Designer: styled.div` @@ -67,6 +68,7 @@ const NetworkDesigner: React.FC = ({ network, updateStateDelay = 3000 }) changeTapBackend, renameNode, addLncSession, + addSimulation, } = useStoreState(s => s.modals); const { save } = useStoreActions(s => s.network); @@ -117,6 +119,7 @@ const NetworkDesigner: React.FC = ({ network, updateStateDelay = 3000 }) {sendAsset.visible && } {renameNode.visible && } {addLncSession.visible && } + {addSimulation.visible && } ); }; diff --git a/src/components/designer/Sidebar.tsx b/src/components/designer/Sidebar.tsx index a5e4ec88bb..0332cf6770 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/bitcoin/actions/MineBlocksInput.tsx b/src/components/designer/bitcoin/actions/MineBlocksInput.tsx index c8bb21e7f8..2bf25fc63a 100644 --- a/src/components/designer/bitcoin/actions/MineBlocksInput.tsx +++ b/src/components/designer/bitcoin/actions/MineBlocksInput.tsx @@ -5,7 +5,7 @@ import styled from '@emotion/styled'; import { Button, Form, Input, InputNumber } from 'antd'; import { usePrefixedTranslation } from 'hooks'; import { BitcoinNode } from 'shared/types'; -import { useStoreActions } from 'store'; +import { useStoreActions, useStoreState } from 'store'; const InputGroup = Input.Group; @@ -20,12 +20,17 @@ const Styled = { const MineBlocksInput: React.FC<{ node: BitcoinNode }> = ({ node }) => { const { l } = usePrefixedTranslation('cmps.designer.bitcoind.MineBlocksInput'); - const [value, setValue] = useState(6); + const network = useStoreState(s => s.network.networkById(node.networkId)); const { notify } = useStoreActions(s => s.app); const { mine } = useStoreActions(s => s.bitcoin); + const { updateManualMineCount } = useStoreActions(actions => actions.network); + + const [count, setCount] = useState(network.manualMineCount); + const mineAsync = useAsyncCallback(async () => { try { - await mine({ blocks: value, node }); + await mine({ blocks: count, node }); + await updateManualMineCount({ id: node.networkId, count }); } catch (error: any) { notify({ message: l('error'), error }); } @@ -35,9 +40,9 @@ const MineBlocksInput: React.FC<{ node: BitcoinNode }> = ({ node }) => { setValue(parseInt(v as any))} + onChange={v => setCount(parseInt(v as any))} disabled={mineAsync.loading} /> { repoState: defaultRepoState, managedImages: testManagedImages, customImages: [], + manualMineCount: 6, }); const initialState = { diff --git a/src/components/designer/default/AddSimulationModal.spec.tsx b/src/components/designer/default/AddSimulationModal.spec.tsx new file mode 100644 index 0000000000..b39b2e649a --- /dev/null +++ b/src/components/designer/default/AddSimulationModal.spec.tsx @@ -0,0 +1,155 @@ +import React from 'react'; +import AddSimulationModal from './AddSimulationModal'; +import { createNetwork } from 'utils/network'; +import { defaultRepoState } from 'utils/constants'; +import { renderWithProviders, testManagedImages } from 'utils/tests'; +import { Status } from 'shared/types'; +import { fireEvent, waitFor } from '@testing-library/react'; +import { initChartFromNetwork } from 'utils/chart'; + +describe('AddSimulationModal', () => { + let unmount: () => void; + + const renderComponent = async (status?: Status) => { + const network = createNetwork({ + id: 1, + name: 'test network', + description: 'network description', + lndNodes: 2, + clightningNodes: 0, + eclairNodes: 0, + litdNodes: 0, + bitcoindNodes: 2, + tapdNodes: 0, + repoState: defaultRepoState, + managedImages: testManagedImages, + customImages: [], + status, + manualMineCount: 6, + }); + const chart = initChartFromNetwork(network); + const initialState = { + network: { + networks: [network], + }, + designer: { + activeId: network.id, + allCharts: { + [network.id]: chart, + }, + }, + modals: { + addSimulation: { + visible: true, + }, + }, + }; + + const cmp = ; + const result = renderWithProviders(cmp, { initialState }); + unmount = result.unmount; + return { + ...result, + network, + }; + }; + + afterEach(() => unmount()); + + it('should render labels', async () => { + const { getByText } = await renderComponent(); + expect(getByText('Add Simulation')).toBeInTheDocument(); + expect(getByText('Source')).toBeInTheDocument(); + expect(getByText('Destination')).toBeInTheDocument(); + expect(getByText('Interval (secs)')).toBeInTheDocument(); + expect(getByText('Amount (sat)')).toBeInTheDocument(); + }); + + it('should render form inputs', async () => { + const { getByLabelText } = await renderComponent(); + expect(getByLabelText('Source')).toBeInTheDocument(); + expect(getByLabelText('Destination')).toBeInTheDocument(); + expect(getByLabelText('Interval (secs)')).toBeInTheDocument(); + expect(getByLabelText('Amount (sat)')).toBeInTheDocument(); + }); + + it('should render button', async () => { + const { getByText } = await renderComponent(); + expect(getByText('Create')).toBeInTheDocument(); + }); + + it('should hide modal when cancel is clicked', async () => { + const { getByText, queryByText } = await renderComponent(); + const btn = getByText('Cancel'); + expect(btn).toBeInTheDocument(); + expect(btn.parentElement).toBeInstanceOf(HTMLButtonElement); + fireEvent.click(getByText('Cancel')); + expect(queryByText('Cancel')).not.toBeInTheDocument(); + }); + + it('should do nothing if an invalid node name is used', async () => { + const { getByText } = await renderComponent(); + fireEvent.click(getByText('Create')); + await waitFor(() => { + expect(getByText('Create')).toBeInTheDocument(); + }); + }); + + describe('with form submitted', () => { + it('should create a simulation', async () => { + const { getByText, getByLabelText, store } = await renderComponent(); + fireEvent.change(getByLabelText('Source'), { target: { value: 'alice' } }); + fireEvent.change(getByLabelText('Destination'), { target: { value: 'bob' } }); + fireEvent.change(getByLabelText('Interval (secs)'), { target: { value: '1000' } }); + fireEvent.change(getByLabelText('Amount (sat)'), { target: { value: '1000' } }); + fireEvent.click(getByText('Create')); + await waitFor(() => { + expect(store.getState().modals.addSimulation.visible).toBe(false); + expect(store.getState().network.networks[0].simulation).toBeDefined(); + }); + }); + + it('should add a second activity', async () => { + const { getByText, getByLabelText, store, network } = await renderComponent(); + const activity = { + id: 0, + source: network.nodes.lightning[0].name, + destination: network.nodes.lightning[1].name, + intervalSecs: 1000, + amountMsat: 1000000, + }; + const sim = { + networkId: 1, + status: Status.Stopped, + activity: [activity], + }; + network.simulation = sim; + store.getActions().network.setNetworks([network]); + + fireEvent.change(getByLabelText('Source'), { target: { value: 'bob' } }); + fireEvent.change(getByLabelText('Destination'), { target: { value: 'alice' } }); + fireEvent.change(getByLabelText('Interval (secs)'), { target: { value: '1000' } }); + fireEvent.change(getByLabelText('Amount (sat)'), { target: { value: '1000' } }); + fireEvent.click(getByText('Create')); + await waitFor(() => { + expect(store.getState().modals.addSimulation.visible).toBe(false); + expect(store.getState().network.networks[0].simulation?.activity.length).toBe(2); + }); + }); + + it('should throw an error if source or destination node is not found', async () => { + const { getByText, getByLabelText, network, findByText } = await renderComponent( + Status.Started, + ); + fireEvent.change(getByLabelText('Source'), { target: { value: 'alice' } }); + fireEvent.change(getByLabelText('Destination'), { target: { value: 'bob' } }); + fireEvent.change(getByLabelText('Interval (secs)'), { target: { value: '10' } }); + fireEvent.change(getByLabelText('Amount (sat)'), { target: { value: '1000' } }); + network.nodes.lightning = []; + fireEvent.click(getByText('Create')); + expect( + await findByText('Source or destination node not found'), + ).toBeInTheDocument(); + }); + }); +}); diff --git a/src/components/designer/default/AddSimulationModal.tsx b/src/components/designer/default/AddSimulationModal.tsx new file mode 100644 index 0000000000..838242fce4 --- /dev/null +++ b/src/components/designer/default/AddSimulationModal.tsx @@ -0,0 +1,163 @@ +import React from 'react'; +import { Modal, Form, Alert, Row, Col, InputNumber } from 'antd'; +import { useStoreActions } from 'store'; +import { useStoreState } from 'store'; +import { usePrefixedTranslation } from 'hooks'; +import LightningNodeSelect from 'components/common/form/LightningNodeSelect'; +import { Status } from 'shared/types'; +import styled from '@emotion/styled'; +import { Activity, Network } from 'types'; +import { useAsyncCallback } from 'react-async-hook'; + +interface Props { + network: Network; +} + +const Styled = { + FormItem: styled(Form.Item)` + width: 100%; + `, + InputNumber: styled(InputNumber)` + width: 100%; + `, +}; +interface FormValues { + source: string; + destination: string; + intervalSecs: number; + amountSat: number; +} + +const AddSimulationModal: React.FC = ({ network }) => { + const { l } = usePrefixedTranslation('cmps.designer.default.AddSimulationModal'); + + const { nodes } = useStoreState(s => s.lightning); + + const [form] = Form.useForm(); + const { visible } = useStoreState(s => s.modals.addSimulation); + const { hideAddSimulation } = useStoreActions(s => s.modals); + const { addSimulation } = useStoreActions(s => s.network); + + const { notify } = useStoreActions(s => s.app); + + const selectedSource = Form.useWatch('source', form) || ''; + const selectedDestination = Form.useWatch('destination', form) || ''; + const isSameNode = selectedSource === selectedDestination; + + const addSimulationAsync = useAsyncCallback(async (values: FormValues) => { + const { lightning } = network.nodes; + const source = lightning.find(n => n.name === values.source); + const destination = lightning.find(n => n.name === values.destination); + + if (!source || !destination) { + notify({ message: l('sourceOrDestinationNotFound') }); + return; + } + + const { intervalSecs, amountSat } = values; + const id = network.simulation?.activity.length + ? Math.max(...network.simulation.activity.map(a => a.id)) + 1 + : 0; + const activity: Activity = { + id, + source: source.name, + destination: destination.name, + intervalSecs, + amountMsat: amountSat * 1000, + }; + + await addSimulation({ + networkId: network.id, + simulation: { + activity: [activity], + status: Status.Stopped, + }, + }); + + hideAddSimulation(); + }); + + return ( + hideAddSimulation()} + destroyOnClose + cancelText={l('cancelBtn')} + okText={l('createBtn')} + okButtonProps={{ + disabled: isSameNode, + loading: addSimulationAsync.loading, + }} + onOk={form.submit} + > +
+ {isSameNode && } + + + + + + + + + + + + + `${v}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')} + parser={v => parseInt(`${v}`.replace(/(undefined|,*)/g, ''))} + /> + + + + + `${v}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')} + parser={v => parseInt(`${v}`.replace(/(undefined|,*)/g, ''))} + /> + + + + +
+ ); +}; + +export default AddSimulationModal; diff --git a/src/components/designer/default/DefaultSidebar.spec.tsx b/src/components/designer/default/DefaultSidebar.spec.tsx index 84c4b3b8a3..681945a72f 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 { @@ -126,4 +126,14 @@ describe('DefaultSidebar Component', () => { JSON.stringify({ type: 'LND', version: lndLatest }), ); }); + + it('should display the Simulation Designer Tab', () => { + const { getByText } = renderComponent(); + fireEvent.click(getByText('Simulations')); + expect( + getByText( + 'Automate Lightning payments between nodes by adding simulation activities below.', + ), + ).toBeInTheDocument(); + }); }); diff --git a/src/components/designer/default/DefaultSidebar.tsx b/src/components/designer/default/DefaultSidebar.tsx index 44abbf024c..19f699f2cf 100644 --- a/src/components/designer/default/DefaultSidebar.tsx +++ b/src/components/designer/default/DefaultSidebar.tsx @@ -1,153 +1,30 @@ -import React from 'react'; -import { CloudSyncOutlined, DownOutlined, UpOutlined } from '@ant-design/icons'; -import styled from '@emotion/styled'; -import { Button } from 'antd'; +import React, { ReactNode, useState } from 'react'; import { usePrefixedTranslation } from 'hooks'; -import { NodeImplementation } from 'shared/types'; -import { useStoreActions, useStoreState } from 'store'; -import { dockerConfigs } from 'utils/constants'; -import { getPolarPlatform } from 'utils/system'; import SidebarCard from '../SidebarCard'; -import DraggableNode from './DraggableNode'; +import NetworkDesignerTab from './NetworkDesignerTab'; +import SimulationDesignerTab from './SimulationDesignerTab'; +import { Network } from 'types'; -const Styled = { - AddNodes: styled.h3` - margin-top: 30px; - `, - AddDesc: styled.p` - opacity: 0.5; - font-size: 12px; - `, - Nodes: styled.div` - display: flex; - flex-direction: column; - `, - NodeRow: styled.div` - display: flex; - align-items: center; - justify-content: space-between; - `, - Arrow: styled.span<{ visible?: boolean; collapsible?: boolean }>` - margin-top: ${props => (props.visible ? 20 : 0)}px; - width: 25px; - text-align: right; +interface Props { + network: Network; +} - > .anticon { - opacity: 0.4; - display: ${props => (props.collapsible ? 'inline' : 'none')}; - cursor: pointer; - - &:hover { - opacity: 1; - } - } - `, - UpdatesButton: styled(Button)` - margin-top: 30px; - `, -}; - -const DefaultSidebar: React.FC = () => { +const DefaultSidebar: React.FC = ({ network }) => { const { l } = usePrefixedTranslation('cmps.designer.default.DefaultSidebar'); - - const { showImageUpdates } = useStoreActions(s => s.modals); - const { settings, dockerRepoState } = useStoreState(s => s.app); - const currPlatform = getPolarPlatform(); - - const [expanded, setExpanded] = React.useState([]); - const toggleExpanded = (type: NodeImplementation) => { - if (expanded.includes(type)) { - setExpanded(expanded.filter(t => t !== type)); - } else { - setExpanded([...expanded, type]); - } + const [activeTab, setActiveTab] = useState('network'); + + const tabHeaders = [ + { key: 'network', tab: l('networkTabTitle') }, + { key: 'simulation', tab: l('simulationTabTitle') }, + ]; + const tabContents: Record = { + network: , + simulation: , }; - const toggleModal = () => showImageUpdates(); - - const nodes: { - label: string; - logo: string; - version: string; - type: NodeImplementation; - collapsible: boolean; - visible: boolean; - customId?: string; - }[] = []; - - // add custom nodes - settings.nodeImages.custom.forEach(image => { - const { logo, platforms } = dockerConfigs[image.implementation]; - const { latest } = dockerRepoState.images[image.implementation]; - if (!platforms.includes(currPlatform)) return; - nodes.push({ - label: image.name, - logo, - version: latest, - type: image.implementation, - collapsible: false, - customId: image.id, - visible: true, - }); - }); - - // add managed nodes - Object.entries(dockerRepoState.images).forEach(([key, entry]) => { - const type = key as NodeImplementation; - const { name, logo, platforms } = dockerConfigs[type as NodeImplementation]; - if (!platforms.includes(currPlatform)) return; - if (!entry.latest) return; - - nodes.push({ - label: `${name} v${entry.latest}`, - logo, - version: entry.latest, - type, - collapsible: entry.versions.length > 1, - visible: true, - }); - entry.versions - .filter(v => v != entry.latest) - .forEach(version => { - const label = `${name} v${version}`; - const collapsible = false; - const visible = expanded.includes(type); - nodes.push({ label, logo, version, collapsible, visible, type }); - }); - }); return ( - -

{l('mainDesc')}

- {l('addNodesTitle')} - {l('addNodesDesc')} - - {nodes.map(({ label, logo, version, type, collapsible, visible, customId }) => ( - - - - {expanded.includes(type) ? ( - toggleExpanded(type)} /> - ) : ( - toggleExpanded(type)} /> - )} - - - ))} - - } - onClick={toggleModal} - > - {l('checkUpdates')} - + + {tabContents[activeTab]} ); }; diff --git a/src/components/designer/default/NetworkDesignerTab.tsx b/src/components/designer/default/NetworkDesignerTab.tsx new file mode 100644 index 0000000000..d10079add1 --- /dev/null +++ b/src/components/designer/default/NetworkDesignerTab.tsx @@ -0,0 +1,154 @@ +import React from 'react'; +import { CloudSyncOutlined, DownOutlined, UpOutlined } from '@ant-design/icons'; +import styled from '@emotion/styled'; +import { Button } from 'antd'; +import { usePrefixedTranslation } from 'hooks'; +import { NodeImplementation } from 'shared/types'; +import { useStoreActions, useStoreState } from 'store'; +import { dockerConfigs } from 'utils/constants'; +import { getPolarPlatform } from 'utils/system'; +import DraggableNode from './DraggableNode'; + +const Styled = { + AddNodes: styled.h3` + margin-top: 30px; + `, + AddDesc: styled.p` + opacity: 0.5; + font-size: 12px; + `, + Nodes: styled.div` + display: flex; + flex-direction: column; + `, + NodeRow: styled.div` + display: flex; + align-items: center; + justify-content: space-between; + `, + Arrow: styled.span<{ visible?: boolean; collapsible?: boolean }>` + margin-top: ${props => (props.visible ? 20 : 0)}px; + width: 25px; + text-align: right; + + > .anticon { + opacity: 0.4; + display: ${props => (props.collapsible ? 'inline' : 'none')}; + cursor: pointer; + + &:hover { + opacity: 1; + } + } + `, + UpdatesButton: styled(Button)` + margin-top: 30px; + `, +}; + +const NetworkDesignerTab: React.FC = () => { + const { l } = usePrefixedTranslation('cmps.designer.default.NetworkDesignerTab'); + + const { showImageUpdates } = useStoreActions(s => s.modals); + const { settings, dockerRepoState } = useStoreState(s => s.app); + const currPlatform = getPolarPlatform(); + + const [expanded, setExpanded] = React.useState([]); + const toggleExpanded = (type: NodeImplementation) => { + if (expanded.includes(type)) { + setExpanded(expanded.filter(t => t !== type)); + } else { + setExpanded([...expanded, type]); + } + }; + const toggleModal = () => showImageUpdates(); + + const nodes: { + label: string; + logo: string; + version: string; + type: NodeImplementation; + collapsible: boolean; + visible: boolean; + customId?: string; + }[] = []; + + // add custom nodes + settings.nodeImages.custom.forEach(image => { + const { logo, platforms } = dockerConfigs[image.implementation]; + const { latest } = dockerRepoState.images[image.implementation]; + if (!platforms.includes(currPlatform)) return; + nodes.push({ + label: image.name, + logo, + version: latest, + type: image.implementation, + collapsible: false, + customId: image.id, + visible: true, + }); + }); + + // add managed nodes + Object.entries(dockerRepoState.images).forEach(([key, entry]) => { + const type = key as NodeImplementation; + const { name, logo, platforms } = dockerConfigs[type as NodeImplementation]; + if (!platforms.includes(currPlatform)) return; + if (!entry.latest) return; + + nodes.push({ + label: `${name} v${entry.latest}`, + logo, + version: entry.latest, + type, + collapsible: entry.versions.length > 1, + visible: true, + }); + entry.versions + .filter(v => v != entry.latest) + .forEach(version => { + const label = `${name} v${version}`; + const collapsible = false; + const visible = expanded.includes(type); + nodes.push({ label, logo, version, collapsible, visible, type }); + }); + }); + + return ( + <> +

{l('mainDesc')}

+ {l('addNodesTitle')} + {l('addNodesDesc')} + + {nodes.map(({ label, logo, version, type, collapsible, visible, customId }) => ( + + + + {expanded.includes(type) ? ( + toggleExpanded(type)} /> + ) : ( + toggleExpanded(type)} /> + )} + + + ))} + + } + onClick={toggleModal} + > + {l('checkUpdates')} + + + ); +}; + +export default NetworkDesignerTab; diff --git a/src/components/designer/default/SimulationDesignerTab.spec.tsx b/src/components/designer/default/SimulationDesignerTab.spec.tsx new file mode 100644 index 0000000000..d2b082550f --- /dev/null +++ b/src/components/designer/default/SimulationDesignerTab.spec.tsx @@ -0,0 +1,258 @@ +import React from 'react'; +import { fireEvent, waitFor } from '@testing-library/react'; +import SimulationDesignerTab from './SimulationDesignerTab'; +import { Status } from 'shared/types'; +import { injections, renderWithProviders, testManagedImages } from 'utils/tests'; +import { createNetwork } from 'utils/network'; +import { defaultRepoState } from 'utils/constants'; +import { initChartFromNetwork } from 'utils/chart'; +import { ipcChannels } from 'shared'; + +const mockDockerService = injections.dockerService as jest.Mocked< + typeof injections.dockerService +>; + +describe('SimulationDesignerTab', () => { + const renderComponent = (status?: Status, simStatus?: Status, noSim?: boolean) => { + const network = createNetwork({ + id: 1, + name: 'test network', + description: 'network description', + lndNodes: 2, + clightningNodes: 0, + eclairNodes: 0, + litdNodes: 0, + bitcoindNodes: 2, + tapdNodes: 0, + repoState: defaultRepoState, + managedImages: testManagedImages, + customImages: [], + status, + manualMineCount: 6, + }); + + if (!noSim) { + network.simulation = { + activity: [ + { + id: 0, + source: network.nodes.lightning[0].name, + destination: network.nodes.lightning[1].name, + intervalSecs: 10, + amountMsat: 1000000, + }, + ], + status: simStatus ?? Status.Stopped, + }; + } + const chart = initChartFromNetwork(network); + + const initialState = { + network: { + networks: [network], + }, + modals: { + addSimulation: { + visible: false, + }, + }, + designer: { + allCharts: { + [network.id]: chart, + }, + }, + }; + + const result = renderWithProviders(, { + initialState, + }); + + return { + ...result, + network, + }; + }; + + describe('Simulation Designer Tab', () => { + it('should render the simulation designer tab', async () => { + const { getByText, store } = renderComponent(Status.Started, Status.Started, true); + expect(getByText('No simulations created yet')).toBeInTheDocument(); + + const addSimulationBtn = getByText('Add a new Simulation'); + expect(addSimulationBtn).toBeInTheDocument(); + fireEvent.click(addSimulationBtn); + await waitFor(() => { + expect(store.getState().modals.addSimulation.visible).toBe(true); + }); + }); + + it('should show add simulation button if no simulation is defined', async () => { + const { getByText } = renderComponent(Status.Started, Status.Started, true); + expect(getByText('No simulations created yet')).toBeInTheDocument(); + + const addSimulationBtn = getByText('Add a new Simulation'); + expect(addSimulationBtn).toBeInTheDocument(); + }); + }); + + describe('Start Simulation', () => { + it('should start simulation successfully', async () => { + const { getByText } = renderComponent(Status.Started); + expect(getByText('Start')).toBeInTheDocument(); + + fireEvent.click(getByText('Start')); + await waitFor(() => { + expect(mockDockerService.startSimulation).toHaveBeenCalled(); + }); + }); + + it('should show error if simulation fails to start', async () => { + // Simulation should fail to start because the network is not started, + // which means the lightning nodes are not running. + const { getByText } = renderComponent(Status.Stopped); + fireEvent.click(getByText('Start')); + + await waitFor(() => { + expect(getByText('Unable to start the simulation')).toBeInTheDocument(); + }); + }); + }); + + describe('Stop Simulation', () => { + it('should stop simulation successfully', async () => { + const { getByText } = renderComponent(Status.Started, Status.Started); + expect(getByText('Stop')).toBeInTheDocument(); + fireEvent.click(getByText('Stop')); + await waitFor(() => { + expect(mockDockerService.stopSimulation).toHaveBeenCalled(); + }); + }); + + it('should show error if simulation fails to stop', async () => { + mockDockerService.stopSimulation.mockRejectedValue(new Error('simulation-error')); + const { getByText } = renderComponent(Status.Started, Status.Started); + fireEvent.click(getByText('Stop')); + + await waitFor(() => { + expect(getByText('Unable to stop the simulation')).toBeInTheDocument(); + expect(getByText('simulation-error')).toBeInTheDocument(); + }); + }); + }); + + describe('Remove Simulation', () => { + it('should remove simulation', async () => { + const { getByText, getByLabelText, findByText } = renderComponent(Status.Started); + fireEvent.mouseOver(getByLabelText('more')); + fireEvent.click(await findByText('Delete')); + await waitFor(() => { + expect(getByText('Remove Simulation')).toBeInTheDocument(); + }); + + fireEvent.click(getByText('Remove')); + await waitFor(() => { + expect(getByText('Simulation removed successfully')).toBeInTheDocument(); + }); + }); + + it('should return early if no simulation is defined', async () => { + const { getByText, network, findByText, getByLabelText } = renderComponent( + Status.Started, + Status.Stopped, + ); + const activity = { + id: 0, + source: network.nodes.lightning[0].name, + destination: network.nodes.lightning[1].name, + intervalSecs: 10, + amountMsat: 1000000, + }; + const sim = { + networkId: 1, + status: Status.Stopped, + activity: [activity], + }; + + // This is an unlikely scenario, as the start button is not + // visible if no simulation is defined. + network.simulation = undefined; + expect(getByText('Start')).toBeInTheDocument(); + fireEvent.click(getByText('Start')); + expect(getByText('No simulations created yet')).toBeInTheDocument(); + + network.simulation = { ...sim, status: Status.Started }; + await waitFor(() => { + expect(getByText('Stop')).toBeInTheDocument(); + }); + network.simulation = undefined; + expect(getByText('Stop')).toBeInTheDocument(); + fireEvent.click(getByText('Stop')); + expect(getByText('No simulations created yet')).toBeInTheDocument(); + + network.simulation = { ...sim, status: Status.Stopped }; + await waitFor(() => { + expect(getByText('Start')).toBeInTheDocument(); + }); + + network.simulation = undefined; + fireEvent.mouseOver(getByLabelText('more')); + fireEvent.click(await findByText('Delete')); + fireEvent.click(await findByText('Remove')); + await waitFor(() => { + expect(mockDockerService.removeSimulation).not.toHaveBeenCalled(); + }); + }); + + it('should show error if no activities are added', async () => { + const { getByText, network, store } = renderComponent( + Status.Started, + Status.Started, + true, + ); + + const sim = { + networkId: 1, + status: Status.Stopped, + activity: [], + }; + + network.simulation = sim; + store.getActions().network.setNetworks([network]); + + fireEvent.click(getByText('Start')); + await waitFor(() => { + expect( + getByText('Add activities to the simulation to start it.'), + ).toBeInTheDocument(); + }); + }); + + it('should send an ipc message when the button is clicked', async () => { + const { getByText, getByLabelText, findByText, network, store } = renderComponent(); + const activity = { + id: 0, + source: network.nodes.lightning[0].name, + destination: network.nodes.lightning[1].name, + intervalSecs: 10, + amountMsat: 1000000, + }; + const sim = { + networkId: 1, + status: Status.Started, + activity: [activity], + }; + + network.simulation = sim; + store.getActions().network.setNetworks([network]); + fireEvent.mouseOver(getByLabelText('more')); + fireEvent.click(await findByText('Delete')); + const ipcMock = injections.ipc as jest.Mock; + ipcMock.mockResolvedValue(true); + fireEvent.click(getByText('View Logs')); + const url = '/logs/simln/polar-n1-simln'; + await waitFor(() => { + expect(ipcMock).toBeCalledWith(ipcChannels.openWindow, { url }); + }); + }); + }); +}); diff --git a/src/components/designer/default/SimulationDesignerTab.tsx b/src/components/designer/default/SimulationDesignerTab.tsx new file mode 100644 index 0000000000..bd4f53e6fe --- /dev/null +++ b/src/components/designer/default/SimulationDesignerTab.tsx @@ -0,0 +1,239 @@ +import React, { ReactNode, useCallback, useEffect } from 'react'; +import { useAsyncCallback } from 'react-async-hook'; +import { + ArrowRightOutlined, + CloseOutlined, + MoreOutlined, + PlusOutlined, +} from '@ant-design/icons'; +import styled from '@emotion/styled'; +import { Alert, Button, Dropdown, Empty, MenuProps, Modal, Tooltip } from 'antd'; +import { usePrefixedTranslation } from 'hooks'; +import { Status } from 'shared/types'; +import { useStoreActions, useStoreState } from 'store'; +import { Network } from 'types'; +import { abbreviate } from 'utils/numbers'; +import StatusButton from 'components/common/StatusButton'; + +interface Props { + network: Network; +} + +const Styled = { + Title: styled.div` + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; + font-weight: bold; + `, + SimContainer: styled.div` + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + height: 46px; + padding: 10px 15px; + margin-top: 20px; + margin-bottom: 20px; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 4px; + font-weight: bold; + `, + NodeWrapper: styled.div` + display: flex; + align-items: center; + justify-content: start; + column-gap: 10px; + width: 100%; + `, + ActivityDetails: styled.span` + opacity: 0.5; + font-size: 12px; + `, + Dropdown: styled(Dropdown)` + margin-left: 12px; + `, + ViewLogsButton: styled(Button)` + margin-top: 10px; + width: 100%; + `, +}; + +const SimulationDesignerTab: React.FC = ({ network }) => { + const { l } = usePrefixedTranslation('cmps.designer.default.SimulationDesignerTab'); + + // Getting the network from the store makes this component to + // re-render when the network is updated (i.e when we add a simulation). + const { networks } = useStoreState(s => s.network); + const currentNetwork = networks.find(n => n.id === network.id); + + const { showAddSimulation } = useStoreActions(s => s.modals); + + const { notify, openWindow } = useStoreActions(s => s.app); + + const { startSimulation, stopSimulation, removeSimulation } = useStoreActions( + s => s.network, + ); + + const loading = + currentNetwork?.simulation?.status === Status.Starting || + currentNetwork?.simulation?.status === Status.Stopping; + const started = currentNetwork?.simulation?.status === Status.Started; + const status = currentNetwork?.simulation?.status ?? Status.Stopped; + + const startSimulationAsync = useAsyncCallback(async () => { + if (!network.simulation) return; + if (network.simulation.activity.length === 0) { + notify({ + message: l('noActivities'), + error: new Error('Add activities to the simulation to start it.'), + }); + return; + } + try { + await startSimulation({ id: network.id }); + } catch (error: any) { + notify({ message: l('startError'), error }); + } + }); + + const stopSimulationAsync = useAsyncCallback(async () => { + if (!network.simulation) return; + try { + await stopSimulation({ id: network.id }); + } catch (error: any) { + notify({ message: l('stopError'), error }); + } + }); + + const addSimulation = () => { + showAddSimulation({}); + }; + + let modal: any; + const showRemoveModal = (networkId: number, activityId: number) => { + modal = Modal.confirm({ + title: l('removeTitle'), + content: l('removeDesc'), + okText: l('removeBtn'), + okType: 'danger', + cancelText: l('cancelBtn'), + onOk: async () => { + await removeSimulation({ id: activityId, networkId }); + notify({ message: l('removeSuccess') }); + }, + }); + }; + + const openAsync = useAsyncCallback(async () => { + await openWindow(`/logs/simln/polar-n${network.id}-simln`); + }); + + const handleClick = useCallback( + (activityId: number): MenuProps['onClick'] => + (info: { key: string }) => { + switch (info.key) { + case 'delete': + showRemoveModal(network.id, activityId); + break; + } + }, + [], + ); + + const items: MenuProps['items'] = [ + { + key: 'delete', + label: 'Delete', + icon: , + disabled: started || loading, + }, + ]; + + // cleanup the modal when the component unmounts + useEffect(() => () => modal && modal.destroy(), [modal]); + + let cmp: ReactNode; + + if (network.simulation) { + const { activity } = network.simulation; + cmp = ( + <> + {activity.length === 0 && ( + + )} + {activity.map(a => ( + + + {a.source} + + {a.destination} + + {abbreviate(a.amountMsat / 1000)} sats / {a.intervalSecs}s + + + + + + + ))} + + {started && ( + + View Logs + + )} + + ); + } else { + cmp = ( + <> + {network.status !== Status.Started && ( + + )} + + + + + ); + } + + return ( +
+

{l('mainDesc')}

+ + {l('title')} + +
+ ); +}; + +export default SimulationDesignerTab; diff --git a/src/components/designer/lightning/ConnectTab.tsx b/src/components/designer/lightning/ConnectTab.tsx index cdc1f52ec3..c7125fc449 100644 --- a/src/components/designer/lightning/ConnectTab.tsx +++ b/src/components/designer/lightning/ConnectTab.tsx @@ -135,9 +135,9 @@ const ConnectTab: React.FC = ({ node }) => { } else if (node.implementation === 'litd') { const litd = node as LitdNode; return { - restUrl: `https://127.0.0.1:${litd.ports.web}`, + restUrl: `https://127.0.0.1:${litd.ports.rest}`, restDocsUrl: 'https://lightning.engineering/api-docs/api/lit/', - grpcUrl: `127.0.0.1:${litd.ports.web}`, + grpcUrl: `127.0.0.1:${litd.ports.web}`, // external grpc is on web port grpcDocsUrl: 'https://lightning.engineering/api-docs/api/lit/', webUrl: `https://127.0.0.1:${litd.ports.web}`, credentials: { diff --git a/src/components/designer/lightning/LightningDetails.spec.tsx b/src/components/designer/lightning/LightningDetails.spec.tsx index 6c701b397d..fb6ec8cd55 100644 --- a/src/components/designer/lightning/LightningDetails.spec.tsx +++ b/src/components/designer/lightning/LightningDetails.spec.tsx @@ -466,6 +466,13 @@ describe('LightningDetails', () => { expect(getByText('500')).toBeInTheDocument(); }); + it('should display the REST Host', async () => { + const { getByText, findByText } = renderComponent(Status.Started); + fireEvent.click(await findByText('Connect')); + expect(getByText('REST Host')).toBeInTheDocument(); + expect(getByText('https://127.0.0.1:8385')).toBeInTheDocument(); + }); + it('should display the GRPC Host', async () => { const { getByText, findByText } = renderComponent(Status.Started); fireEvent.click(await findByText('Connect')); diff --git a/src/components/designer/lightning/actions/CreateInvoiceModal.spec.tsx b/src/components/designer/lightning/actions/CreateInvoiceModal.spec.tsx index c092bedda4..9a212adcec 100644 --- a/src/components/designer/lightning/actions/CreateInvoiceModal.spec.tsx +++ b/src/components/designer/lightning/actions/CreateInvoiceModal.spec.tsx @@ -150,6 +150,7 @@ describe('CreateInvoiceModal', () => { repoState: defaultRepoState, managedImages: testManagedImages, customImages: [], + manualMineCount: 6, }); const asset: LightningNodeChannelAsset = { id: 'abcd', @@ -209,9 +210,40 @@ describe('CreateInvoiceModal', () => { 200, '', 3600, + undefined, ); }); + it('should create invoice with metadata for asset invoice', async () => { + const { getByText, getByLabelText, findByText, changeSelect } = + await renderComponent(); + expect(await findByText('Node')).toBeInTheDocument(); + changeSelect('Asset to Receive', 'test asset'); + fireEvent.change(getByLabelText('Amount'), { target: { value: '10' } }); + fireEvent.click(getByText('Advanced Options')); + const metadataInput = getByLabelText('Metadata'); + expect(metadataInput).toBeInTheDocument(); + fireEvent.change(metadataInput, { target: { value: 'test metadata' } }); + fireEvent.click(getByText('Create Invoice')); + expect(await findByText('Successfully Created the Invoice')).toBeInTheDocument(); + const node = network.nodes.lightning[0]; + const tapNode = mapToTapd(node); + expect(tapServiceMock.addInvoice).toHaveBeenCalledWith( + tapNode, + 'abcd', + 10, + '', + 3600, + 'test metadata', + ); + }); + + it('should not show metadata field for Bitcoin payments', async () => { + const { queryByLabelText, findByText } = await renderComponent(); + expect(await findByText('Node')).toBeInTheDocument(); + expect(queryByLabelText('Advanced Options')).not.toBeInTheDocument(); + }); + it('should display an error when creating an asset invoice with a high balance', async () => { const { getByText, getByLabelText, findByText, changeSelect } = await renderComponent(); diff --git a/src/components/designer/lightning/actions/CreateInvoiceModal.tsx b/src/components/designer/lightning/actions/CreateInvoiceModal.tsx index 1c700326d1..b9d9d7b3bc 100644 --- a/src/components/designer/lightning/actions/CreateInvoiceModal.tsx +++ b/src/components/designer/lightning/actions/CreateInvoiceModal.tsx @@ -2,7 +2,17 @@ import React, { ReactNode, useCallback, useMemo } from 'react'; import { useAsync, useAsyncCallback } from 'react-async-hook'; import CopyToClipboard from 'react-copy-to-clipboard'; import styled from '@emotion/styled'; -import { Button, Form, InputNumber, message, Modal, Result, Select } from 'antd'; +import { + Button, + Form, + Input, + InputNumber, + message, + Modal, + Result, + Select, + Collapse, +} from 'antd'; import { usePrefixedTranslation } from 'hooks'; import { LitdNode } from 'shared/types'; import { LightningNodeChannelAsset } from 'lib/lightning/types'; @@ -32,6 +42,7 @@ interface FormValues { node: string; assetId: string; amount: number; + metadata?: string; } interface Props { @@ -88,7 +99,12 @@ const CreateInvoiceModal: React.FC = ({ network }) => { } else { const litdNode = node as LitdNode; const amount = toAssetUnits({ assetId, amount: values.amount }); - const res = await createAssetInvoice({ node: litdNode, assetId, amount }); + const res = await createAssetInvoice({ + node: litdNode, + assetId, + amount, + metadata: values.metadata, + }); invoice = res.invoice; const asset = assets.find(a => a.id === assetId) as LightningNodeChannelAsset; assetName = `${asset.name} (${format(res.sats)} sats)`; @@ -131,7 +147,12 @@ const CreateInvoiceModal: React.FC = ({ network }) => { layout="vertical" requiredMark={false} colon={false} - initialValues={{ node: nodeName, amount: 1_000_000, assetId: 'sats' }} + initialValues={{ + node: nodeName, + amount: 1_000_000, + assetId: 'sats', + metadata: '', + }} onFinish={createAsync.execute} > = ({ network }) => { style={{ width: '100%' }} />
+ + {isLitd && assetId && assetId !== 'sats' && ( + + + + + + + + )} ); } else { diff --git a/src/components/designer/lightning/actions/OpenChannelModal.spec.tsx b/src/components/designer/lightning/actions/OpenChannelModal.spec.tsx index 8e1a4ac755..d76da8723d 100644 --- a/src/components/designer/lightning/actions/OpenChannelModal.spec.tsx +++ b/src/components/designer/lightning/actions/OpenChannelModal.spec.tsx @@ -288,6 +288,7 @@ describe('OpenChannelModal', () => { repoState: defaultRepoState, managedImages: testManagedImages, customImages: [], + manualMineCount: 6, }); const asset: LightningNodeChannelAsset = { id: 'abcd', diff --git a/src/components/designer/lightning/actions/PayInvoiceModal.spec.tsx b/src/components/designer/lightning/actions/PayInvoiceModal.spec.tsx index b9bf825f55..545f196da2 100644 --- a/src/components/designer/lightning/actions/PayInvoiceModal.spec.tsx +++ b/src/components/designer/lightning/actions/PayInvoiceModal.spec.tsx @@ -118,6 +118,9 @@ describe('PayInvoiceModal', () => { node, 'lnbc1', undefined, + undefined, + undefined, + undefined, ); const element = await findByText('Sent 1,000 sats from alice'); expect(element).toBeInTheDocument(); @@ -131,6 +134,27 @@ describe('PayInvoiceModal', () => { expect(await findByText('Unable to pay the Invoice')).toBeInTheDocument(); expect(await findByText('error-msg')).toBeInTheDocument(); }); + + it('should not show metadata field for Bitcoin payments', async () => { + const { getByText, getByLabelText, queryByLabelText, store } = + await renderComponent(); + fireEvent.change(getByLabelText('BOLT 11 Invoice'), { target: { value: 'lnbc1' } }); + fireEvent.click(getByText('Advanced Options')); + expect(queryByLabelText('Metadata')).not.toBeInTheDocument(); + fireEvent.click(getByText('Pay Invoice')); + await waitFor(() => { + expect(store.getState().modals.payInvoice.visible).toBe(false); + }); + const node = network.nodes.lightning[0]; + expect(lightningServiceMock.payInvoice).toHaveBeenCalledWith( + node, + 'lnbc1', + undefined, + undefined, + undefined, + undefined, + ); + }); }); describe('with assets', () => { @@ -149,6 +173,7 @@ describe('PayInvoiceModal', () => { repoState: defaultRepoState, managedImages: testManagedImages, customImages: [], + manualMineCount: 6, }); const asset: LightningNodeChannelAsset = { id: 'abcd', @@ -200,7 +225,64 @@ describe('PayInvoiceModal', () => { 'lnbc1', 400000, '', + undefined, + undefined, ); }); + + it('should pay invoice with allowSelfPayment option', async () => { + const { findByText, getByText, getByLabelText, store, changeSelect } = + await renderComponent('bob'); + expect(await findByText('From Node')).toBeInTheDocument(); + fireEvent.change(getByLabelText('BOLT 11 Invoice'), { target: { value: 'lnbc1' } }); + changeSelect('Asset to Send', 'test asset'); + fireEvent.click(getByText('Advanced Options')); + fireEvent.click(getByLabelText('Allow self-payment')); + fireEvent.click(getByText('Pay Invoice')); + await waitFor(() => { + expect(store.getState().modals.payInvoice.visible).toBe(false); + }); + const node = network.nodes.lightning[1]; + const tapdNode = mapToTapd(node); + expect(tapServiceMock.sendPayment).toHaveBeenCalledWith( + tapdNode, + 'abcd', + 'lnbc1', + 400000, + '', + true, + undefined, + ); + }); + + it('should pay invoice with metadata for Taproot Assets', async () => { + const { findByText, getByText, getByLabelText, store, changeSelect } = + await renderComponent('bob'); + expect(await findByText('From Node')).toBeInTheDocument(); + fireEvent.change(getByLabelText('BOLT 11 Invoice'), { target: { value: 'lnbc1' } }); + changeSelect('Asset to Send', 'test asset'); + fireEvent.click(getByText('Advanced Options')); + const metadataInput = getByLabelText('Metadata'); + expect(metadataInput).toBeInTheDocument(); + fireEvent.change(metadataInput, { + target: { value: 'test metadata' }, + }); + fireEvent.click(getByText('Pay Invoice')); + await waitFor(() => { + expect(store.getState().modals.payInvoice.visible).toBe(false); + }); + const node = network.nodes.lightning[1]; // Changed from 0 to 1 + expect(tapServiceMock.sendPayment).toHaveBeenCalledWith( + mapToTapd(node), + 'abcd', + 'lnbc1', + 400000, + '', + false, + 'test metadata', + ); + const element = await findByText('Sent 1,000 test asset from bob'); + expect(element).toBeInTheDocument(); + }); }); }); diff --git a/src/components/designer/lightning/actions/PayInvoiceModal.tsx b/src/components/designer/lightning/actions/PayInvoiceModal.tsx index 3f76a7a11b..4cd30c84ed 100644 --- a/src/components/designer/lightning/actions/PayInvoiceModal.tsx +++ b/src/components/designer/lightning/actions/PayInvoiceModal.tsx @@ -1,7 +1,8 @@ import React, { useMemo } from 'react'; import { useAsync, useAsyncCallback } from 'react-async-hook'; import styled from '@emotion/styled'; -import { Form, Input, Modal, Select } from 'antd'; +import { Form, Input, Modal, Select, Collapse, Checkbox } from 'antd'; +import TextArea from 'antd/lib/input/TextArea'; import { usePrefixedTranslation } from 'hooks'; import { LitdNode } from 'shared/types'; import { LightningNodeChannelAsset } from 'lib/lightning/types'; @@ -29,6 +30,8 @@ const Styled = { interface FormValues { node: string; invoice: string; + allowSelfPayment?: boolean; + metadata?: string; } interface Props { @@ -73,11 +76,21 @@ const PayInvoiceModal: React.FC = ({ network }) => { let amount = '0'; let assetName = 'sats'; if (assetId === 'sats') { - const res = await payInvoice({ node, invoice }); + const res = await payInvoice({ + node, + invoice, + allowSelfPayment: values.allowSelfPayment, + }); amount = format(res.amount); } else { const litdNode = node as LitdNode; - const res = await payAssetInvoice({ node: litdNode, assetId, invoice }); + const res = await payAssetInvoice({ + node: litdNode, + assetId, + invoice, + allowSelfPayment: values.allowSelfPayment, + metadata: values.metadata, + }); amount = formatAssetAmount({ assetId, amount: res.amount }); const asset = assets.find(a => a.id === assetId) as LightningNodeChannelAsset; assetName = asset.name; @@ -114,7 +127,7 @@ const PayInvoiceModal: React.FC = ({ network }) => { layout="vertical" requiredMark={false} colon={false} - initialValues={{ node: nodeName, assetId: 'sats' }} + initialValues={{ node: nodeName, assetId: 'sats', allowSelfPayment: false }} onFinish={payAsync.execute} disabled={payAsync.loading} > @@ -156,6 +169,25 @@ const PayInvoiceModal: React.FC = ({ network }) => { >
+ + + + {l('allowSelfPayment')} + + {assetId !== 'sats' && ( + +