diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..b299feac --- /dev/null +++ b/.dockerignore @@ -0,0 +1,2 @@ +node_modules +backend/node_modules diff --git a/.env.example b/.env.example index e02d83a6..485185bc 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,9 @@ -PORT= -ELECTRON_START_URL= -APPLE_ID=YOUR-APPLE-ID -APPLE_PASSWORD=APP-SPECIFIC-PASSWORD -APPLE_TEAM_ID=YOUR-TEAM-ID \ No newline at end of file +BEACON_URL=http://your-BN-ip:5052 +VALIDATOR_URL=http://your-VC-ip:5062 +API_TOKEN=get-it-from-'.lighthouse/validators/api-token.txt' +SESSION_PASSWORD=default-siren-password +SSL_ENABLED=true +DEBUG=false +# don't change these when building the docker image, only change when running outside of docker +PORT=3000 +BACKEND_URL=http://127.0.0.1:3001 diff --git a/.eslintignore b/.eslintignore index f7bbf2a9..e2d3d5ce 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,2 +1 @@ -src/main.js src/scripts/release.js \ No newline at end of file diff --git a/.eslintrc.json b/.eslintrc.json index 7b7ea1a3..25846b0c 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,39 +1,29 @@ { - "env": { - "browser": true, - "es2021": true - }, - "extends": [ - "eslint:recommended", - "plugin:react/recommended", - "plugin:@typescript-eslint/recommended", - "prettier" - ], - "overrides": [ + "plugins": ["@typescript-eslint", "import", "react-hooks", "unused-imports"], + "extends": ["next/core-web-vitals"], + "rules": { + "@typescript-eslint/ban-ts-comment": "off", + "import/order": [ + "error", + { + "alphabetize": { + "order": "asc", + "caseInsensitive": true + } + } ], - "parser": "@typescript-eslint/parser", - "parserOptions": { - "ecmaVersion": "latest", - "sourceType": "module" - }, - "plugins": [ - "react", - "@typescript-eslint", - "prettier" + "react-hooks/rules-of-hooks": "error", + "react-hooks/exhaustive-deps": "off", + "unused-imports/no-unused-imports": "error", + "unused-imports/no-unused-vars": [ + "warn", + { + "vars": "all", + "varsIgnorePattern": "^_", + "args": "after-used", + "argsIgnorePattern": "^_" + } ], - "rules": { - "react/react-in-jsx-scope": "off", - "spaced-comment": "error", - "quotes": ["error", "single"], - "no-duplicate-imports": "error", - "react/prop-types": 0, - "@typescript-eslint/no-empty-function": "off", - "@typescript-eslint/no-explicit-any": "off", - "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }] - }, - "settings": { - "import/resolver": { - "typescript": {} - } - } + "react/react-in-jsx-scope": "off" + } } diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index dcb15153..68834499 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -4,7 +4,6 @@ about: Create a report to help us improve title: '' labels: bug assignees: rickimoore - --- **Describe the bug** @@ -12,6 +11,7 @@ A clear and concise description of what the bug is. **To Reproduce** Steps to reproduce the behavior: + 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' @@ -24,15 +24,17 @@ A clear and concise description of what you expected to happen. If applicable, add screenshots to help explain your problem. **Desktop (please complete the following information):** - - OS: [e.g. iOS] - - Browser [e.g. chrome, safari] - - Version [e.g. 22] + +- OS: [e.g. iOS] +- Browser [e.g. chrome, safari] +- Version [e.g. 22] **Smartphone (please complete the following information):** - - Device: [e.g. iPhone6] - - OS: [e.g. iOS8.1] - - Browser [e.g. stock browser, safari] - - Version [e.g. 22] + +- Device: [e.g. iPhone6] +- OS: [e.g. iOS8.1] +- Browser [e.g. stock browser, safari] +- Version [e.g. 22] **Additional context** Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index a097ed20..63e411e0 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -4,7 +4,6 @@ about: Suggest an idea for this project title: '' labels: enhancement assignees: rickimoore - --- **Is your feature request related to a problem? Please describe.** diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 888ff512..76eaab3f 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -1,129 +1,96 @@ name: docker on: - push: - branches: - - unstable - - stable - tags: - - v* + push: + branches: + - unstable + - stable + tags: + - v* env: - DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} - DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} - IMAGE_NAME: ${{ secrets.DOCKER_USERNAME }}/siren + DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} + DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} + IMAGE_NAME: ${{ secrets.DOCKER_USERNAME }}/siren jobs: - # Extract the VERSION which is either `latest` or `vX.Y.Z`, and the VERSION_SUFFIX - # which is either empty or `-unstable`. - # - # It would be nice if the arch didn't get spliced into the version between `latest` and - # `unstable`, but for now we keep the two parts of the version separate for backwards - # compatibility. - extract-version: - runs-on: ubuntu-22.04 - steps: - - name: Extract version (if stable) - if: github.event.ref == 'refs/heads/stable' - run: | - echo "VERSION=latest" >> $GITHUB_ENV - echo "VERSION_SUFFIX=" >> $GITHUB_ENV - - name: Extract version (if unstable) - if: github.event.ref == 'refs/heads/unstable' - run: | - echo "VERSION=latest" >> $GITHUB_ENV - echo "VERSION_SUFFIX=-unstable" >> $GITHUB_ENV - - name: Extract version (if tagged release) - if: startsWith(github.event.ref, 'refs/tags') - run: | - echo "VERSION=$(echo ${GITHUB_REF#refs/tags/})" >> $GITHUB_ENV - echo "VERSION_SUFFIX=" >> $GITHUB_ENV - outputs: - VERSION: ${{ env.VERSION }} - VERSION_SUFFIX: ${{ env.VERSION_SUFFIX }} - build-html: - name: build html - runs-on: ubuntu-22.04 - needs: [extract-version] - steps: - - name: Checkout sources - uses: actions/checkout@v3 - - name: Use node 18 - uses: actions/setup-node@v3 - with: - node-version: 18 - cache: 'yarn' - - name: Install dependencies - env: - NODE_ENV: development - run: | - yarn - - name: Build Siren - env: - NODE_ENV: production - run: yarn build - - name: Upload artifact - uses: actions/upload-artifact@v3 - with: - name: html - path: build/ + # Extract the VERSION which is either `latest` or `vX.Y.Z`, and the VERSION_SUFFIX + # which is either empty or `-unstable`. + # + # It would be nice if the arch didn't get spliced into the version between `latest` and + # `unstable`, but for now we keep the two parts of the version separate for backwards + # compatibility. + extract-version: + runs-on: ubuntu-22.04 + steps: + - name: Extract version (if stable) + if: github.event.ref == 'refs/heads/stable' + run: | + echo "VERSION=latest" >> $GITHUB_ENV + echo "VERSION_SUFFIX=" >> $GITHUB_ENV + - name: Extract version (if unstable) + if: github.event.ref == 'refs/heads/unstable' + run: | + echo "VERSION=latest" >> $GITHUB_ENV + echo "VERSION_SUFFIX=-unstable" >> $GITHUB_ENV + - name: Extract version (if tagged release) + if: startsWith(github.event.ref, 'refs/tags') + run: | + echo "VERSION=$(echo ${GITHUB_REF#refs/tags/})" >> $GITHUB_ENV + echo "VERSION_SUFFIX=" >> $GITHUB_ENV + outputs: + VERSION: ${{ env.VERSION }} + VERSION_SUFFIX: ${{ env.VERSION_SUFFIX }} - build-docker-single-arch: - name: build-docker-${{ matrix.binary }} - runs-on: ubuntu-22.04 - strategy: - matrix: - binary: [aarch64, x86_64] + build-docker-single-arch: + name: build-docker-${{ matrix.binary }} + runs-on: ubuntu-22.04 + strategy: + matrix: + binary: [aarch64, x86_64] - needs: [extract-version, build-html] - env: - # We need to enable experimental docker features in order to use `docker buildx` - DOCKER_CLI_EXPERIMENTAL: enabled - VERSION: ${{ needs.extract-version.outputs.VERSION }} - VERSION_SUFFIX: ${{ needs.extract-version.outputs.VERSION_SUFFIX }} - steps: - - uses: actions/checkout@v3 - - uses: docker/setup-qemu-action@v2 - - name: Dockerhub login - run: | - echo "${DOCKER_PASSWORD}" | docker login --username ${DOCKER_USERNAME} --password-stdin - - name: Map aarch64 to arm64 short arch - if: startsWith(matrix.binary, 'aarch64') - run: echo "SHORT_ARCH=arm64" >> $GITHUB_ENV - - name: Map x86_64 to amd64 short arch - if: startsWith(matrix.binary, 'x86_64') - run: echo "SHORT_ARCH=amd64" >> $GITHUB_ENV; - - name: Download artifacts - uses: actions/download-artifact@v3 - with: - name: html - path: html/ - - name: Build Dockerfile and push - run: | - docker buildx build \ - --platform=linux/${SHORT_ARCH} \ - --file ./Dockerfile.release . \ - --tag ${IMAGE_NAME}:${VERSION}-${SHORT_ARCH}${VERSION_SUFFIX} \ - --provenance=false \ - --push - - build-docker-multiarch: - name: build-docker-multiarch - runs-on: ubuntu-22.04 - needs: [build-docker-single-arch, extract-version] - env: - # We need to enable experimental docker features in order to use `docker manifest` - DOCKER_CLI_EXPERIMENTAL: enabled - VERSION: ${{ needs.extract-version.outputs.VERSION }} - VERSION_SUFFIX: ${{ needs.extract-version.outputs.VERSION_SUFFIX }} - steps: - - name: Dockerhub login - run: | - echo "${DOCKER_PASSWORD}" | docker login --username ${DOCKER_USERNAME} --password-stdin - - name: Create and push multiarch manifest - run: | - docker manifest create ${IMAGE_NAME}:${VERSION}${VERSION_SUFFIX} \ - --amend ${IMAGE_NAME}:${VERSION}-arm64${VERSION_SUFFIX} \ - --amend ${IMAGE_NAME}:${VERSION}-amd64${VERSION_SUFFIX}; - docker manifest push ${IMAGE_NAME}:${VERSION}${VERSION_SUFFIX} + needs: [extract-version] + env: + # We need to enable experimental docker features in order to use `docker buildx` + DOCKER_CLI_EXPERIMENTAL: enabled + VERSION: ${{ needs.extract-version.outputs.VERSION }} + VERSION_SUFFIX: ${{ needs.extract-version.outputs.VERSION_SUFFIX }} + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-qemu-action@v3 + - name: Dockerhub login + run: | + echo "${DOCKER_PASSWORD}" | docker login --username ${DOCKER_USERNAME} --password-stdin + - name: Map aarch64 to arm64 short arch + if: startsWith(matrix.binary, 'aarch64') + run: echo "SHORT_ARCH=arm64" >> $GITHUB_ENV + - name: Map x86_64 to amd64 short arch + if: startsWith(matrix.binary, 'x86_64') + run: echo "SHORT_ARCH=amd64" >> $GITHUB_ENV; + - name: Build Dockerfile and push + run: | + docker buildx build \ + --platform=linux/${SHORT_ARCH} \ + --file ./Dockerfile . \ + --tag ${IMAGE_NAME}:${VERSION}-${SHORT_ARCH}${VERSION_SUFFIX} \ + --push + build-docker-multiarch: + name: build-docker-multiarch + runs-on: ubuntu-22.04 + needs: [build-docker-single-arch, extract-version] + env: + # We need to enable experimental docker features in order to use `docker manifest` + DOCKER_CLI_EXPERIMENTAL: enabled + VERSION: ${{ needs.extract-version.outputs.VERSION }} + VERSION_SUFFIX: ${{ needs.extract-version.outputs.VERSION_SUFFIX }} + steps: + - name: Dockerhub login + run: | + echo "${DOCKER_PASSWORD}" | docker login --username ${DOCKER_USERNAME} --password-stdin + - name: Create and push multiarch manifest + run: | + docker manifest create ${IMAGE_NAME}:${VERSION}${VERSION_SUFFIX} \ + --amend ${IMAGE_NAME}:${VERSION}-arm64${VERSION_SUFFIX} \ + --amend ${IMAGE_NAME}:${VERSION}-amd64${VERSION_SUFFIX}; + docker manifest push ${IMAGE_NAME}:${VERSION}${VERSION_SUFFIX} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a57a97f4..20b129a7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,14 +7,16 @@ on: env: REPO_NAME: ${{ github.repository_owner }}/siren - IMAGE_NAME: sigmaprime/siren + DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} + DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} + IMAGE_NAME: ${{ secrets.DOCKER_USERNAME }}/siren jobs: extract-version: runs-on: ubuntu-latest steps: - name: Checkout sources - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Extract version run: echo "VERSION=$(echo ${GITHUB_REF#refs/tags/})" >> $GITHUB_OUTPUT # Used for running without tags @@ -22,152 +24,20 @@ jobs: id: extract_version outputs: VERSION: ${{ steps.extract_version.outputs.VERSION }} - build: - name: Build Release - strategy: - matrix: - arch: [ - aarch64-unknown-linux-gnu, - x86_64-unknown-linux-gnu, - # Requires apple signature secrets - # x86_64-apple-darwin, - x86_64-windows - ] - include: - - arch: aarch64-unknown-linux-gnu - platform: ubuntu-latest - - arch: x86_64-unknown-linux-gnu - platform: ubuntu-latest - # Requires apple signature secrets - #- arch: x86_64-apple-darwin - # platform: macos-latest - - arch: x86_64-windows - platform: windows-2019 - - runs-on: ${{ matrix.platform }} - needs: extract-version - steps: - - name: Checkout sources - uses: actions/checkout@v3 - - name: Use node 18 - uses: actions/setup-node@v3 - with: - node-version: 18 - - name: Configure Yarn Timeout - run: yarn config set network-timeout 300000 - - # ============================== - # Windows & Mac dependencies - # ============================== - - # ============================== - # Builds - # ============================== - - name: Build Siren - run: make release - - - name: Move unsigned packages (*nix) - if: startsWith(matrix.arch, 'x86_64-windows') != true - run: | - mv out/make/zip/*/*/*.zip ./siren-${{ needs.extract-version.outputs.VERSION }}-${{ matrix.arch }}.zip - - - name: Move unsigned packages (windows) - if: startsWith(matrix.arch, 'x86_64-windows') - run: | - move out/make/zip/*/*/*.zip ./siren-${{ needs.extract-version.outputs.VERSION }}-${{ matrix.arch }}.zip - - # ======================================================================= - # Upload artifacts - # This is required to share artifacts between different jobs - # ======================================================================= - - - name: Upload artifact - uses: actions/upload-artifact@v3 - with: - name: siren-${{ needs.extract-version.outputs.VERSION }}-${{ matrix.arch }}.zip - path: ./siren-${{ needs.extract-version.outputs.VERSION }}-${{ matrix.arch }}.zip - - - sign: - name: Sign Release - runs-on: ubuntu-latest - needs: [extract-version, build] - strategy: - matrix: - arch: [ - aarch64-unknown-linux-gnu, - x86_64-unknown-linux-gnu, - # Requires apple signature secrets - # x86_64-apple-darwin, - x86_64-windows - ] - steps: - - name: Download artifact - uses: actions/download-artifact@v3 - with: - name: siren-${{ needs.extract-version.outputs.VERSION }}-${{ matrix.arch }}.zip - - - name: Move artifacts - run: | - mkdir artifacts - #find siren-*/ -type f -name "*.zip" -exec mv {} artifacts/ \; - mv siren-${{ needs.extract-version.outputs.VERSION }}-${{ matrix.arch }}.zip artifacts/ - ls -hal artifacts - - name: Configure GPG and create artifacts - env: - GPG_SIGNING_KEY: ${{ secrets.GPG_SIGNING_KEY }} - GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} - run: | - export GPG_TTY=$(tty) - echo "$GPG_SIGNING_KEY" | gpg --batch --import - #for file in $(ls artifacts); do - # echo "$GPG_PASSPHRASE" | gpg --passphrase-fd 0 --pinentry-mode loopback --batch -ab artifacts/$file ; - #done - echo "$GPG_PASSPHRASE" | gpg --passphrase-fd 0 --pinentry-mode loopback --batch -ab artifacts/siren-${{ needs.extract-version.outputs.VERSION }}-${{ matrix.arch }}.zip - ls -hal artifacts - - - name: Upload signature (${{ matrix.arch }}) - uses: actions/upload-artifact@v3 - with: - name: siren-${{ needs.extract-version.outputs.VERSION }}-${{ matrix.arch }}.zip.asc - path: artifacts/siren-${{ needs.extract-version.outputs.VERSION }}-${{ matrix.arch }}.zip.asc - - # - name: Upload signature (aarch64-unknown-linux-gnu) - # uses: actions/upload-artifact@v3 - # with: - # name: siren-${{ needs.extract-version.outputs.VERSION }}-aarch64-unknown-linux-gnu.zip.asc - # path: artifacts//siren-${{ needs.extract-version.outputs.VERSION }}-aarch64-unknown-linux-gnu.zip.asc - # - name: Upload signature (x86_64-unknown-linux-gnu) - # uses: actions/upload-artifact@v3 - # with: - # name: siren-${{ needs.extract-version.outputs.VERSION }}-x86_64-unknown-linux-gnu.zip.asc - # path: artifacts/siren-${{ needs.extract-version.outputs.VERSION }}-x86_64-unknown-linux-gnu.zip.asc - # - name: Upload signature (x86_64-windows) - # uses: actions/upload-artifact@v3 - # with: - # name: siren-${{ needs.extract-version.outputs.VERSION }}-x86_64-windows.zip.asc - # path: artifacts/siren-${{ needs.extract-version.outputs.VERSION }}-x86_64-windows.zip.asc draft-release: name: Draft Release - needs: [build, extract-version] + needs: [extract-version] runs-on: ubuntu-latest env: VERSION: ${{ needs.extract-version.outputs.VERSION }} steps: # This is necessary for generating the changelog. It has to come before "Download Artifacts" or else it deletes the artifacts. - name: Checkout sources - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 0 - # ============================== - # Download artifacts - # ============================== - - - name: Download artifacts - uses: actions/download-artifact@v3 - # ============================== # Create release draft # ============================== @@ -188,50 +58,45 @@ jobs: run: | body=$(cat <<- "ENDBODY" - + ## Release Checklist (DELETE ME) - + - [ ] Merge `unstable` -> `stable`. - - [ ] Ensure docker images are published (check `latest` and the version tag). + - [ ] Ensure docker images are published to `sigp` namespace (instructions below) - [ ] Prepare Discord post. - + + Someone with appropriate access rights should run these commands: + 0. sanity check: did the `docker` workflow complete correctly, are the images published under the `sigmaprime` namespace? + see: https://hub.docker.com/r/${{ env.DOCKER_USERNAME }}/siren/tags?name=${{ env.VERSION }} and https://hub.docker.com/r/${{ env.DOCKER_USERNAME }}/siren/tags?name=latest + 1. publish `latest`: + `docker manifest create sigp/siren:latest --amend sigmaprime/siren:latest-amd64 --amend sigmaprime/siren:latest-arm64` + `docker manifest push sigp/siren:latest` + 2. publish `${{ env.VERSION }}`: + `docker manifest create sigp/siren:${{ env.VERSION }} --amend sigmaprime/siren:${{ env.VERSION }}-amd64 --amend sigmaprime/siren:${{ env.VERSION }}-arm64` + `docker manifest push sigp/siren:${{ env.VERSION }}` + 3. verify: https://hub.docker.com/r/siren/siren/tags?name=${{ env.VERSION }} and https://hub.docker.com/r/sigp/siren/tags?name=latest + ## Summary - + Add a summary. - + ## Update Priority - + This table provides priorities for which classes of users should update particular components. - + |User Class |Beacon Node | Validator Client| --- | --- | --- |Staking Users| | | |Non-Staking Users| |---| - + ## All Changes - + ${{ steps.changelog.outputs.CHANGELOG }} - - ## Binaries - - [See pre-built binaries documentation.](https://lighthouse-book.sigmaprime.io/installation-binaries.html) - - The binaries are signed with Sigma Prime's PGP key: `15E66D941F697E28F49381F426416DC3F30674B0` - - | System | Architecture | Binary | PGP Signature | - |:---:|:---:|:---:|:---| - | | x86_64 | [siren-${{ env.VERSION }}-x86_64-apple-darwin.zip](https://github.com/${{ env.REPO_NAME }}/releases/download/${{ env.VERSION }}/siren-${{ env.VERSION }}-x86_64-apple-darwin.zip) | [PGP Signature](https://github.com/${{ env.REPO_NAME }}/releases/download/${{ env.VERSION }}/siren-${{ env.VERSION }}-x86_64-apple-darwin.zip.asc) | - | | x86_64 | [siren-${{ env.VERSION }}-x86_64-unknown-linux-gnu.zip](https://github.com/${{ env.REPO_NAME }}/releases/download/${{ env.VERSION }}/siren-${{ env.VERSION }}-x86_64-unknown-linux-gnu.zip) | [PGP Signature](https://github.com/${{ env.REPO_NAME }}/releases/download/${{ env.VERSION }}/siren-${{ env.VERSION }}-x86_64-unknown-linux-gnu.zip.asc) | - | | aarch64 | [siren-${{ env.VERSION }}-aarch64-unknown-linux-gnu.zip](https://github.com/${{ env.REPO_NAME }}/releases/download/${{ env.VERSION }}/siren-${{ env.VERSION }}-aarch64-unknown-linux-gnu.zip) | [PGP Signature](https://github.com/${{ env.REPO_NAME }}/releases/download/${{ env.VERSION }}/siren-${{ env.VERSION }}-aarch64-unknown-linux-gnu.zip.asc) | - | | x86_64 | [siren-${{ env.VERSION }}-x86_64-windows.zip](https://github.com/${{ env.REPO_NAME }}/releases/download/${{ env.VERSION }}/siren-${{ env.VERSION }}-x86_64-windows.zip) | [PGP Signature](https://github.com/${{ env.REPO_NAME }}/releases/download/${{ env.VERSION }}/siren-${{ env.VERSION }}-x86_64-windows.zip.asc) | - | | | | | - | **System** | **Option** | - | **Resource** | - | | Docker | [${{ env.VERSION }}](https://hub.docker.com/r/${{ env.IMAGE_NAME }}/tags?page=1&ordering=last_updated&name=${{ env.VERSION }}) | [${{ env.IMAGE_NAME }}](https://hub.docker.com/r/${{ env.IMAGE_NAME }}) | + + ## Docker Hub + https://hub.docker.com/r/sigp/siren/tags?name=${{ env.VERSION }} + ENDBODY ) - assets=() - for asset in ./siren-*.zip*; do - assets+=("-a" "$asset/$asset") - done tag_name="${{ env.VERSION }}" - echo "$body" | hub release create --draft "${assets[@]}" -F "-" "$tag_name" \ No newline at end of file + echo "$body" | gh release create --draft -F "-" "$tag_name" diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml deleted file mode 100644 index c94ba70d..00000000 --- a/.github/workflows/ui-tests.yml +++ /dev/null @@ -1,52 +0,0 @@ -name: 'UI Tests' - -on: push - -jobs: - target-branch-check: - name: target-branch-check - runs-on: ubuntu-latest - if: github.event_name == 'pull_request' - steps: - - name: Check that the pull request is not targeting the stable branch - run: test ${{ github.base_ref }} != "stable" - # Run interaction and accessibility tests - build-storybook: - runs-on: ubuntu-latest - outputs: - deploy_storybook: ${{ steps.final.outputs.deploy_storybook }} - steps: - - uses: actions/checkout@v2 - - uses: actions/setup-node@v2 - with: - node-version: '14.x' - - name: Install dependencies - run: yarn - - name: Install Playwright - run: npx playwright install --with-deps - - name: Build Storybook - run: yarn build-storybook --quiet - - id: final - name: Serve Storybook and run tests - run: | - npx concurrently -k -s first -n "SB,TEST" -c "magenta,blue" \ - "npx http-server storybook-static --port 6006 --silent" \ - "npx wait-on tcp:6006 && yarn test-storybook" - echo "::set-output name=deploy_storybook::storybookUrl" - # Run visual and composition tests with Chromatic - visual-and-composition: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - with: - fetch-depth: 0 # Required to retrieve git history - - name: Install dependencies - run: yarn - - id: publish - name: Publish to Chromatic - uses: chromaui/action@v1 - with: - # Grab this from the Chromatic manage page - projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} - - name: print - run: echo ${{steps.publish.outputs.url}} diff --git a/.gitignore b/.gitignore index e7451e7d..089e036e 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,8 @@ .idea +certs + # testing /coverage @@ -31,3 +33,7 @@ build-storybook.log /local-testnet/bls_to_execution_changes /out .env + +.next +next-env.d.ts +dist diff --git a/.husky/pre-commit b/.husky/pre-commit index 71817319..86e2b7f6 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,4 +1,21 @@ #!/bin/sh . "$(dirname "$0")/_/husky.sh" -npx lint-staged && npx tsc --project tsconfig.json && yarn test +export PATH="/Users/rickimoore/lighthouse-ui:$PATH" + +# Run the Next.js linter with auto-fixing +npx next lint --fix + +# Run Jest tests using yarn +yarn test + +# Navigate to the backend directory +cd backend + +yarn test + +# Check if the Jest tests passed +if [ $? -ne 0 ]; then + echo "Jest tests failed. Aborting commit." + exit 1 +fi diff --git a/.prettierrc b/.prettierrc index 919cb979..61e4fa61 100644 --- a/.prettierrc +++ b/.prettierrc @@ -6,4 +6,4 @@ "trailingComma": "all", "jsxSingleQuote": true, "bracketSpacing": true -} \ No newline at end of file +} diff --git a/.storybook/main.js b/.storybook/main.js deleted file mode 100644 index c3321ba9..00000000 --- a/.storybook/main.js +++ /dev/null @@ -1,20 +0,0 @@ -const path = require('path'); - -module.exports = { - "stories": [ - "../src/**/*.stories.mdx", - "../src/**/*.stories.@(js|jsx|ts|tsx)" - ], - "addons": [ - "@storybook/addon-links", - "@storybook/addon-essentials", - "@storybook/addon-interactions", - "@storybook/preset-create-react-app", - 'storybook-dark-mode/register', - 'storybook-tailwind-dark-mode' - ], - "framework": "@storybook/react", - "core": { - "builder": "@storybook/builder-webpack5" - }, -} \ No newline at end of file diff --git a/.storybook/preview.js b/.storybook/preview.js deleted file mode 100644 index e2df85e2..00000000 --- a/.storybook/preview.js +++ /dev/null @@ -1,35 +0,0 @@ -import '../src/i18n' -import '../src/global.css'; -import { themes } from '@storybook/theming' -import { - RecoilRoot -} from 'recoil'; - -export const parameters = { - actions: { argTypesRegex: "^on[A-Z].*" }, - controls: { - matchers: { - color: /(background|color)$/i, - date: /Date$/, - }, - }, - darkMode: { - dark: { - ...themes.dark, // copy existing values - appContentBg: '#1E1E1E', // override main story view frame - barBg: '#202020' // override top toolbar - } - } -} - -export const globalTypes = { - darkMode: true, -}; - -const withRecoil = (StoryFn) => ( - - {StoryFn()} - -); - -export const decorators = [withRecoil]; \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 33256115..c586dd49 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,23 +1,42 @@ -ARG node_version=18 +ARG node_version=18.18 ARG node_image=node:${node_version} -# STAGE 1: builder FROM $node_image AS builder -COPY . /app/ +ENV NEXT_TELEMETRY_DISABLED=1 \ + NODE_ENV=development WORKDIR /app +COPY . /app/ + +RUN yarn --network-timeout 300000; \ + NODE_ENV=production yarn build +WORKDIR /app/backend +RUN yarn --network-timeout 300000; \ + NODE_ENV=production yarn build + +FROM alpine AS intermediate -ENV NODE_ENV=development +COPY ./docker-assets /app/docker-assets/ -# install (dev) deps -# on GitHub runners, timeouts occur in emulated containers -RUN yarn --network-timeout 300000 +COPY --from=builder /app/backend/package.json /app/backend/package.json +COPY --from=builder /app/backend/node_modules /app/backend/node_modules +COPY --from=builder /app/backend/dist /app/backend/dist + +COPY --from=builder /app/siren.js /app/package.json /app/ +COPY --from=builder /app/node_modules /app/node_modules +COPY --from=builder /app/public /app/public +COPY --from=builder /app/.next /app/.next + +FROM $node_image AS production ENV NODE_ENV=production -# build (prod) app -RUN yarn build +RUN npm install --global pm2; \ + apt update; \ + apt install -y nginx openssl curl ncat + +RUN rm /etc/nginx/sites-enabled/default; \ + ln -s /app/docker-assets/siren-http.conf /etc/nginx/conf.d/siren-http.conf -# STAGE 2 -FROM nginx:alpine AS production +COPY --from=intermediate /app /app/ -COPY --from=builder /app/build/ /usr/share/nginx/html/ +ENTRYPOINT /app/docker-assets/docker-entrypoint.sh diff --git a/Dockerfile.dev b/Dockerfile.dev deleted file mode 100644 index b67b0450..00000000 --- a/Dockerfile.dev +++ /dev/null @@ -1,12 +0,0 @@ -ARG node_version=18 -ARG node_image=node:${node_version} -FROM $node_image - -ENV NODE_ENV=development - -EXPOSE 5000/tcp -COPY . /app/ -WORKDIR /app - -RUN yarn install -CMD ["yarn", "run", "dev"] \ No newline at end of file diff --git a/Dockerfile.release b/Dockerfile.release deleted file mode 100644 index 86e40597..00000000 --- a/Dockerfile.release +++ /dev/null @@ -1,3 +0,0 @@ -FROM nginx:alpine - -COPY html/ /usr/share/nginx/html/ diff --git a/Makefile b/Makefile deleted file mode 100644 index 366ab82a..00000000 --- a/Makefile +++ /dev/null @@ -1,23 +0,0 @@ -# Make the Siren App - - -# Default rule -# Builds the electron app and executes it -build: - yarn && yarn dev - -# Builds a development server -dev: - yarn && yarn start - -# Runs a docker production webserver -docker: - docker build -t siren . && docker run --rm -it --name siren -p 80:80 siren - -# Compile into a number of releases -release: - yarn && yarn build-all - -# Remove compiled artifacts -clean: - rm -r out diff --git a/README.md b/README.md index 4823a711..89e2233f 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ and Validator Client. [Chat Badge]: https://img.shields.io/badge/chat-discord-%237289da [Chat Link]: https://discord.gg/jpqcHXPRVJ -[Book Status]:https://img.shields.io/badge/user--docs-unstable-informational +[Book Status]: https://img.shields.io/badge/user--docs-unstable-informational [Book Link]: https://lighthouse-book.sigmaprime.io/lighthouse-ui.html [stable]: https://github.com/sigp/siren/tree/stable [unstable]: https://github.com/sigp/siren/tree/unstable @@ -17,128 +17,43 @@ and Validator Client. The [Lighthouse Book](https://lighthouse-book.sigmaprime.io) contains information for users and developers. Specifically the [Lighthouse UI](https://lighthouse-book.sigmaprime.io/lighthouse-ui.html) section of the book. -## Building From Source - -### Requirements - -Building from source requires `Node v18` and `yarn`. - -### Building From Source - -The electron app can be built from source by first cloning the repository and -entering the directory: - -``` -$ git clone https://github.com/sigp/siren.git -$ cd siren -``` - -Once cloned, the electron app can be built and ran via the Makefile by: - -``` -$ make -``` - -alternatively it can be built via: - -``` -$ yarn -``` - -Once completed successfully the electron app can be run via: - -``` -$ yarn dev -``` +## Running Siren -### Running In The Browser +### Docker (Recommended) -#### Docker (Recommended) +Docker is the recommended way to run Siren. This will expose Siren as a webapp. -Docker is the recommended way to run a webserver that hosts Siren and can be -connected to via a web browser. We recommend this method as it established a -production-grade web-server to host the application. - -`docker` is required to be installed with the service running. - -The docker image can be built and run via the Makefile by running: -``` -$ make docker -``` - -Alternatively, to run with Docker, the image needs to be built. From the repository directory -run: -``` -$ docker build -t siren . -``` +Configuration is done through environment variables, the best way to get started is by copying `.env.example` to `.env` and editing the relevant sections (typically, this would at least include `BEACON_URL`, `VALIDATOR_URL` and `API_TOKEN`) Then to run the image: -``` -$ docker run --rm -ti --name siren -p 80:80 siren -``` - -This will open port 80 and allow your browser to connect. You can choose -another local port by modifying the command. For example `-p 8000:80` will open -port 8000. - -To view Siren, simply go to `http://localhost` in your web browser. - -# Running a Local Testnet -For development, one can spin up a local lighthouse testnet. This can be used -for the UI to connect to and retrieve real-time results from a local testnet. +`docker compose up` +or +`docker run --rm -ti --name siren -p 3443:443 --env-file $PWD/.env sigp/siren` -## Requirements +This will open port 3443 and allow your browser to connect. -In order to run a local lighthouse network, lighthouse needs to be installed on -the system. For detailed instructions see the [Lighthouse Book](https://lighthouse-book.sigmaprime.io/). -Both `lighthouse` and `lcli` are required to be installed. This can be done by -cloning the Lighthouse repository, entering the cloned repository and running: +To start Siren, visit `https://localhost:3443` in your web browser (ignore the certificate warning). -```bash -$ make -$ make install-lcli -``` +Advanced users can mount their own certificate (the config expects 3 files: `/certs/cert.pem` `/certs/key.pem` `/certs/key.pass`) -note: you need a version of lcli that includes [these](https://github.com/sigp/lighthouse/pull/3807) changes - -`ganache` is also required to be installed. This can be installed via `npm` or via the OS. If using `npm` it can be installed as: -``` -$ npm install ganache --global -``` - -## Starting the Testnet - -To start a local testnet, move into the `local-testnet` directory. Then run: -```bash -./start_local_testnet.sh genesis.json -``` - -This will spin up both a validator client and a beacon node. These will run in -the background and can be accessed via their local http APIs. - -## Stopping the Testnet +## Building From Source -A running local testnet can be stopped by running: +### Docker -```bash -./stop_local_testnet.sh -``` +The docker image can be built with the following command: +`docker build -f Dockerfile -t siren .` -## Configuring the Testnet +### Building locally -The default settings should be sufficient for a development network useful for -testing the UI. However various configurations can be modified by modifying the -`vars.env` file. +To build from source, ensure that your system has `Node v18.18` and `yarn` installed. Start by configuring your environment variables. The recommended approach is to duplicate the `.env.example` file, rename it to `.env`, and modify the necessary settings. Essential variables typically include `BEACON_URL`, `VALIDATOR_URL`, and `API_TOKEN`. -## Creating a new testnet +#### Build and run the backend +Navigate to the backend directory `cd backend`. Install all required Node packages by running `yarn`. Once the installation is complete, compile the backend with `yarn build`. Deploy the backend in a production environment, `yarn start:production`. This ensures optimal performance. -The data for a previously run testnet is stored at -`./local-testnet/testnet-data` (assuming the scripts were run inside the -`local-testnet` directory. Simply removing this directory and its -subdirectories will create a new testnet when running these commands again. -## Logs and Errors +#### Build and run the frontend +After initializing the backend, return to the root directory. Install all frontend dependencies by executing `yarn`. Build the frontend using `yarn build`. Start the frontend production server with `yarn start`. -Logs and errors can be found in the `./local-testnet/testnet-data` directory. +This will allow you to access siren at `http://localhost:3000` by default. diff --git a/app/Main.tsx b/app/Main.tsx new file mode 100644 index 00000000..f3947730 --- /dev/null +++ b/app/Main.tsx @@ -0,0 +1,163 @@ +'use client'; + +import axios from 'axios'; +import Cookies from 'js-cookie'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import AppDescription from '../src/components/AppDescription/AppDescription'; +import AuthPrompt from '../src/components/AuthPrompt/AuthPrompt'; +import ConfigModal from '../src/components/ConfigModal/ConfigModal'; +import LoadingSpinner from '../src/components/LoadingSpinner/LoadingSpinner'; +import Typography from '../src/components/Typography/Typography'; +import VersionModal from '../src/components/VersionModal/VersionModal'; +import { REQUIRED_VALIDATOR_VERSION } from '../src/constants/constants'; +import { UiMode } from '../src/constants/enums'; +import useLocalStorage from '../src/hooks/useLocalStorage'; +import { ToastType } from '../src/types'; +import displayToast from '../utilities/displayToast'; +import formatSemanticVersion from '../utilities/formatSemanticVersion'; +import isExpiredToken from '../utilities/isExpiredToken'; +import isRequiredVersion from '../utilities/isRequiredVersion'; + +const Main = () => { + const { t } = useTranslation() + const router = useRouter() + const searchParams = useSearchParams() + const redirect = searchParams.get('redirect') + const [isLoading, setLoading] = useState(false) + const [step] = useState(1) + const [isReady, setReady] = useState(false) + const [isVersionError, setVersionError] = useState(false) + const [sessionToken, setToken] = useState(Cookies.get('session-token')) + const [, setUsername] = useLocalStorage('username', 'Keeper') + const [healthCheck] = useLocalStorage('health-check', false) + + const [beaconNodeVersion, setBeaconVersion] = useState('') + const [lighthouseVersion, setLighthouseVersion] = useState('') + + useEffect(() => { + if(sessionToken) { + if(isExpiredToken(sessionToken)) { + setToken(undefined) + return + } + + (async () => { + try { + const config = { + headers: { + Authorization: `Bearer ${sessionToken}` + } + } + + const [beaconResults, lightResults] = await Promise.all([ + axios.get('/api/beacon-version', config), + axios.get('/api/lighthouse-version', config) + ]) + + setBeaconVersion(beaconResults.data.version) + setLighthouseVersion(lightResults.data.version) + + setReady(true) + + } catch (e) { + setReady(true) + console.error(e) + } + })() + } + }, [sessionToken]) + + useEffect(() => { + if(beaconNodeVersion && lighthouseVersion) { + + if (!isRequiredVersion(lighthouseVersion, REQUIRED_VALIDATOR_VERSION)) { + setVersionError(true) + return + } + + let nextRoute = '/setup/health-check' + + if(healthCheck) { + nextRoute = '/dashboard' + } + + router.push(redirect || nextRoute) + } + }, [beaconNodeVersion, lighthouseVersion, router, redirect]) + + const configError = !beaconNodeVersion || !lighthouseVersion + const vcVersion = beaconNodeVersion + ? formatSemanticVersion(beaconNodeVersion as string) + : undefined + + const storeSessionCookie = async (password: string, username: string) => { + try { + setLoading(true) + setUsername(username) + const {status, data} = await axios.post('/api/authenticate', {password}) + const token = data.token; + setLoading(false) + + if(status === 200) { + setToken(token) + Cookies.set('session-token', token) + } + + } catch (e: any) { + setLoading(false) + displayToast(t(e.response.data.error as string), ToastType.ERROR) + } + } + + return ( +
+ + {vcVersion && ( + + )} + +
+
+ +
+
+
+ + {`${t('initScreen.initializing')}...`} + +
+ {step >= 0 && ( + <> + + {`${t('initScreen.fetchingEndpoints')}...`} + + + {`${t('initScreen.connectingBeacon')}...`} + + + {`${t('initScreen.connectingValidator')}...`} + + + )} + {step > 1 && ( + + {`${t('initScreen.fetchBeaconSync')}...`} + + )} + + - - - + +
+
+
+ +
+
+ ) +} + +export default Main diff --git a/app/Providers.tsx b/app/Providers.tsx new file mode 100644 index 00000000..5890d212 --- /dev/null +++ b/app/Providers.tsx @@ -0,0 +1,28 @@ +'use client' + +import React, { FC, ReactElement } from 'react' +import { QueryClient, QueryClientProvider } from 'react-query' +import { ToastContainer } from 'react-toastify' +import { RecoilRoot } from 'recoil' +import 'react-tooltip/dist/react-tooltip.css' +import 'react-toastify/dist/ReactToastify.min.css' +import 'rodal/lib/rodal.css' + +const queryClient = new QueryClient() + +export interface ProviderProps { + children: ReactElement | ReactElement[] +} + +const Providers: FC = ({ children }) => { + return ( + + + {children} + + + + ) +} + +export default Providers diff --git a/app/Wrapper.tsx b/app/Wrapper.tsx new file mode 100644 index 00000000..fd6226fb --- /dev/null +++ b/app/Wrapper.tsx @@ -0,0 +1,16 @@ +'use client' + +import React from 'react' +import Main from './Main' +import Providers from './Providers' +import '../src/i18n' + +const Wrapper = () => { + return ( + +
+ + ) +} + +export default Wrapper diff --git a/app/api/authenticate/route.ts b/app/api/authenticate/route.ts new file mode 100644 index 00000000..b8e13496 --- /dev/null +++ b/app/api/authenticate/route.ts @@ -0,0 +1,26 @@ +import axios from 'axios'; +import { NextResponse } from 'next/server'; + +const backendUrl = process.env.BACKEND_URL + +export async function POST(req: Request) { + try { + const {password} = await req.json(); + const res = await axios.post(`${backendUrl}/authenticate`, {password}); + + if(!res?.data) { + return NextResponse.json({ error: 'authPrompt.unableToReach' }, { status: 500 }) + } + + const token = res.data.access_token + + return NextResponse.json({token}, {status: 200}) + } catch (error: any) { + let message = error?.response?.data?.message + + if(!message) { + message = 'authPrompt.defaultErrorMessage' + } + return NextResponse.json({ error: message }, { status: 500 }) + } +} \ No newline at end of file diff --git a/app/api/beacon-heartbeat/route.ts b/app/api/beacon-heartbeat/route.ts new file mode 100644 index 00000000..c3434f33 --- /dev/null +++ b/app/api/beacon-heartbeat/route.ts @@ -0,0 +1,20 @@ +import { NextResponse } from 'next/server' +import getReqAuthToken from '../../../utilities/getReqAuthToken'; +import { fetchBeaconNodeVersion } from '../config' + +const errorMessage = 'Failed to maintain beacon heartbeat' + +export async function GET(req: Request) { + try { + const token = getReqAuthToken(req) + const { version } = await fetchBeaconNodeVersion(token) + + if (version) { + return NextResponse.json({ data: 'success' }, { status: 200 }) + } + + return NextResponse.json({ error: errorMessage }, { status: 500 }) + } catch (error) { + return NextResponse.json({ error: errorMessage }, { status: 500 }) + } +} diff --git a/app/api/beacon-version/route.ts b/app/api/beacon-version/route.ts new file mode 100644 index 00000000..ac5e8fc4 --- /dev/null +++ b/app/api/beacon-version/route.ts @@ -0,0 +1,13 @@ +import { NextResponse } from 'next/server' +import getReqAuthToken from '../../../utilities/getReqAuthToken'; +import { fetchBeaconNodeVersion } from '../config'; + +export async function GET(req: Request) { + try { + const token = getReqAuthToken(req) + const {version} = await fetchBeaconNodeVersion(token) + return NextResponse.json({ version }) + } catch (error) { + return NextResponse.json({ error: 'Failed to fetch beacon version' }, { status: 500 }) + } +} diff --git a/app/api/beacon.ts b/app/api/beacon.ts new file mode 100644 index 00000000..f3556045 --- /dev/null +++ b/app/api/beacon.ts @@ -0,0 +1,26 @@ +import fetchFromApi from '../../utilities/fetchFromApi' + +const backendUrl = process.env.BACKEND_URL + +export const fetchNodeHealth = async (token: string) => + await fetchFromApi(`${backendUrl}/node/health`, token) +export const fetchSyncData = async (token: string) => + await fetchFromApi(`${backendUrl}/beacon/sync`, token) +export const fetchInclusionRate = async (token: string) => + await fetchFromApi(`${backendUrl}/beacon/inclusion`, token) +export const fetchPeerData = async (token: string) => + await fetchFromApi(`${backendUrl}/beacon/peer`, token) +export const fetchBeaconSpec = async (token: string) => await fetchFromApi(`${backendUrl}/beacon/spec`, token) +export const fetchValidatorCountData = async (token: string) => + await fetchFromApi(`${backendUrl}/beacon/validator-count`, token) +export const fetchProposerDuties = async (token: string) => fetchFromApi(`${backendUrl}/beacon/proposer-duties`, token) +export const broadcastBlsChange = async (data: any, token: string) => + await fetchFromApi(`${backendUrl}/beacon/bls-execution`, token, { + method: 'POST', + body: JSON.stringify(data) + }) +export const submitSignedExit = async (data: any, token: string) => + await fetchFromApi(`${backendUrl}/beacon/execute-exit`, token,{ + method: 'POST', + body: JSON.stringify(data) + }) diff --git a/app/api/bls-execution/route.ts b/app/api/bls-execution/route.ts new file mode 100644 index 00000000..db5d6e38 --- /dev/null +++ b/app/api/bls-execution/route.ts @@ -0,0 +1,21 @@ +import { NextResponse } from 'next/server'; +import getReqAuthToken from '../../../utilities/getReqAuthToken'; +import { broadcastBlsChange } from '../beacon'; + +export async function POST(req: Request) { + try { + const data = await req.json(); + const token = getReqAuthToken(req) + await broadcastBlsChange(data, token) + + return NextResponse.json('done', {status: 200}) + } catch (error) { + let status = 500 + let message = 'Unknown error occurred...' + if (error instanceof Error && error.message.includes('401')) { + status = 401; + message = error.message + } + return NextResponse.json({ error: message }, { status }) + } +} \ No newline at end of file diff --git a/app/api/config.ts b/app/api/config.ts new file mode 100644 index 00000000..b1f99759 --- /dev/null +++ b/app/api/config.ts @@ -0,0 +1,11 @@ +import fetchFromApi from '../../utilities/fetchFromApi' + +const backendUrl = process.env.BACKEND_URL + +export const fetchBeaconNodeVersion = async (token: string) => + await fetchFromApi(`${backendUrl}/beacon/version`, token) +export const fetchValidatorAuthKey = async (token: string) => + await fetchFromApi(`${backendUrl}/validator/auth-key`, token) +export const fetchValidatorVersion = async (token: string) => + await fetchFromApi(`${backendUrl}/validator/version`, token) +export const fetchGenesisData = async (token: string) => await fetchFromApi(`${backendUrl}/beacon/genesis`, token) diff --git a/app/api/dismiss-log/[index]/route.ts b/app/api/dismiss-log/[index]/route.ts new file mode 100644 index 00000000..5dfa1eeb --- /dev/null +++ b/app/api/dismiss-log/[index]/route.ts @@ -0,0 +1,25 @@ +import { NextResponse } from 'next/server' +import getReqAuthToken from '../../../../utilities/getReqAuthToken'; +import { dismissLogAlert } from '../../logs'; + +export async function PUT(req: Request, context: any) { + try { + const { index } = context.params; + const token = getReqAuthToken(req); + + if (!index) { + return NextResponse.json({ error: 'No log index found' }, { status: 400 }); + } + + if (!token) { + return NextResponse.json({ error: 'Authentication token is missing' }, { status: 401 }); + } + + const data = await dismissLogAlert(token, index); + return NextResponse.json(data); + } catch (error: any) { + console.error('Error dismissing log alert:', error); + const errorMessage = error.message || 'Failed to dismiss log alert'; + return NextResponse.json({ error: errorMessage }, { status: 500 }); + } +} diff --git a/app/api/execute-validator-exit/route.ts b/app/api/execute-validator-exit/route.ts new file mode 100644 index 00000000..94088882 --- /dev/null +++ b/app/api/execute-validator-exit/route.ts @@ -0,0 +1,15 @@ +import { NextResponse } from 'next/server'; +import getReqAuthToken from '../../../utilities/getReqAuthToken'; +import { submitSignedExit } from '../beacon'; + +export async function POST(req: Request) { + try { + const data = await req.json(); + const token = getReqAuthToken(req) + + const res = await submitSignedExit(data, token) + return NextResponse.json(res, {status: 200}) + } catch (error) { + return NextResponse.json({ error }, { status: 500 }) + } +} \ No newline at end of file diff --git a/app/api/lighthouse-version/route.ts b/app/api/lighthouse-version/route.ts new file mode 100644 index 00000000..07e94e60 --- /dev/null +++ b/app/api/lighthouse-version/route.ts @@ -0,0 +1,13 @@ +import { NextResponse } from 'next/server' +import getReqAuthToken from '../../../utilities/getReqAuthToken'; +import { fetchValidatorVersion } from '../config' + +export async function GET(req: Request) { + try { + const token = getReqAuthToken(req) + const { version } = await fetchValidatorVersion(token) + return NextResponse.json({ version }) + } catch (error) { + return NextResponse.json({ error: 'Failed to fetch lighthouse version' }, { status: 500 }) + } +} diff --git a/app/api/logs.ts b/app/api/logs.ts new file mode 100644 index 00000000..8d27d496 --- /dev/null +++ b/app/api/logs.ts @@ -0,0 +1,5 @@ +import fetchFromApi from '../../utilities/fetchFromApi'; + +const backendUrl = process.env.BACKEND_URL +export const fetchLogMetrics = async (token: string) => fetchFromApi(`${backendUrl}/logs/metrics`, token) +export const dismissLogAlert = async (token: string, index: string) => fetchFromApi(`${backendUrl}/logs/dismiss/${index}`, token) \ No newline at end of file diff --git a/app/api/node-health/route.ts b/app/api/node-health/route.ts new file mode 100644 index 00000000..79f45893 --- /dev/null +++ b/app/api/node-health/route.ts @@ -0,0 +1,13 @@ +import { NextResponse } from 'next/server' +import getReqAuthToken from '../../../utilities/getReqAuthToken'; +import { fetchNodeHealth } from '../beacon' + +export async function GET(req: Request) { + try { + const token = getReqAuthToken(req) + const data = await fetchNodeHealth(token) + return NextResponse.json(data) + } catch (error) { + return NextResponse.json({ error: 'Failed to fetch node health data' }, { status: 500 }) + } +} diff --git a/app/api/node-sync/route.ts b/app/api/node-sync/route.ts new file mode 100644 index 00000000..0425320a --- /dev/null +++ b/app/api/node-sync/route.ts @@ -0,0 +1,13 @@ +import { NextResponse } from 'next/server' +import getReqAuthToken from '../../../utilities/getReqAuthToken'; +import { fetchSyncData } from '../beacon' + +export async function GET(req: Request) { + try { + const token = getReqAuthToken(req) + const data = await fetchSyncData(token) + return NextResponse.json(data) + } catch (error) { + return NextResponse.json({ error: 'Failed to fetch sync status' }, { status: 500 }) + } +} diff --git a/app/api/peer-data/route.ts b/app/api/peer-data/route.ts new file mode 100644 index 00000000..4261fd89 --- /dev/null +++ b/app/api/peer-data/route.ts @@ -0,0 +1,13 @@ +import { NextResponse } from 'next/server' +import getReqAuthToken from '../../../utilities/getReqAuthToken'; +import { fetchPeerData } from '../beacon' + +export async function GET(req: Request) { + try { + const token = getReqAuthToken(req) + const data = await fetchPeerData(token) + return NextResponse.json(data) + } catch (error) { + return NextResponse.json({ error: 'Failed to fetch peer data' }, { status: 500 }) + } +} diff --git a/app/api/priority-logs/route.ts b/app/api/priority-logs/route.ts new file mode 100644 index 00000000..d64d0247 --- /dev/null +++ b/app/api/priority-logs/route.ts @@ -0,0 +1,13 @@ +import { NextResponse } from 'next/server' +import getReqAuthToken from '../../../utilities/getReqAuthToken'; +import { fetchLogMetrics } from '../logs'; + +export async function GET(req: Request) { + try { + const token = getReqAuthToken(req) + const data = await fetchLogMetrics(token) + return NextResponse.json(data) + } catch (error) { + return NextResponse.json({ error: 'Failed to fetch priority logs' }, { status: 500 }) + } +} diff --git a/app/api/sign-validator-exit/route.ts b/app/api/sign-validator-exit/route.ts new file mode 100644 index 00000000..349f4c7f --- /dev/null +++ b/app/api/sign-validator-exit/route.ts @@ -0,0 +1,21 @@ +import { NextResponse } from 'next/server'; +import getReqAuthToken from '../../../utilities/getReqAuthToken'; +import { signVoluntaryExit } from '../validator'; + +export async function POST(req: Request) { + try { + const data = await req.json(); + const token = getReqAuthToken(req) + const res = await signVoluntaryExit(data, token) + + return NextResponse.json(res, {status: 200}) + } catch (error) { + let status = 500 + let message = 'Unknown error occurred...' + if (error instanceof Error && error.message.includes('401')) { + status = 401; + message = error.message + } + return NextResponse.json({ error: message }, { status }) + } +} \ No newline at end of file diff --git a/app/api/update-graffiti/route.ts b/app/api/update-graffiti/route.ts new file mode 100644 index 00000000..d68e5884 --- /dev/null +++ b/app/api/update-graffiti/route.ts @@ -0,0 +1,15 @@ +import { NextResponse } from 'next/server'; +import getReqAuthToken from '../../../utilities/getReqAuthToken'; +import { updateValGraffiti } from '../validator'; + +export async function PUT(req: Request) { + try { + const data = await req.json(); + const token = getReqAuthToken(req) + + const res = await updateValGraffiti(token, data) + return NextResponse.json(res, {status: 200}) + } catch (error) { + return NextResponse.json({ error }, { status: 500 }) + } +} \ No newline at end of file diff --git a/app/api/validator-cache/route.ts b/app/api/validator-cache/route.ts new file mode 100644 index 00000000..37c95793 --- /dev/null +++ b/app/api/validator-cache/route.ts @@ -0,0 +1,13 @@ +import { NextResponse } from 'next/server' +import getReqAuthToken from '../../../utilities/getReqAuthToken'; +import { fetchValCaches } from '../validator' + +export async function GET(req: Request) { + try { + const token = getReqAuthToken(req) + const data = await fetchValCaches(token) + return NextResponse.json(data) + } catch (error) { + return NextResponse.json({ error: 'Failed to fetch validator cache' }, { status: 500 }) + } +} diff --git a/app/api/validator-duties/route.ts b/app/api/validator-duties/route.ts new file mode 100644 index 00000000..f5fd1b05 --- /dev/null +++ b/app/api/validator-duties/route.ts @@ -0,0 +1,13 @@ +import { NextResponse } from 'next/server' +import getReqAuthToken from '../../../utilities/getReqAuthToken'; +import { fetchProposerDuties } from '../beacon'; + +export async function GET(req: Request) { + try { + const token = getReqAuthToken(req) + const data = await fetchProposerDuties(token) + return NextResponse.json(data) + } catch (error) { + return NextResponse.json({ error: 'Failed to fetch proposer data' }, { status: 500 }) + } +} diff --git a/app/api/validator-graffiti/[index]/route.ts b/app/api/validator-graffiti/[index]/route.ts new file mode 100644 index 00000000..38539e26 --- /dev/null +++ b/app/api/validator-graffiti/[index]/route.ts @@ -0,0 +1,25 @@ +import { NextResponse } from 'next/server' +import getReqAuthToken from '../../../../utilities/getReqAuthToken'; +import { fetchValGraffiti } from '../../validator'; + +export async function GET(req: Request, context: any) { + try { + const { index } = context.params; + const token = getReqAuthToken(req) + + if (!index) { + return NextResponse.json({ error: 'No validator index found' }, { status: 400 }); + } + + if (!token) { + return NextResponse.json({ error: 'Authentication token is missing' }, { status: 401 }); + } + + const data = await fetchValGraffiti(token, index) + return NextResponse.json(data) + } catch (error: any) { + console.error('Error fetching val graffiti:', error); + const errorMessage = error.message || 'Failed to fetch validator graffiti'; + return NextResponse.json({ error: errorMessage }, { status: 500 }); + } +} diff --git a/app/api/validator-heartbeat/route.ts b/app/api/validator-heartbeat/route.ts new file mode 100644 index 00000000..54bad2e5 --- /dev/null +++ b/app/api/validator-heartbeat/route.ts @@ -0,0 +1,20 @@ +import { NextResponse } from 'next/server' +import getReqAuthToken from '../../../utilities/getReqAuthToken'; +import { fetchValidatorAuthKey } from '../config' + +const errorMessage = 'Failed to maintain validator heartbeat' + +export async function GET(req: Request) { + try { + const token = getReqAuthToken(req) + const {token_path} = await fetchValidatorAuthKey(token) + + if (token_path) { + return NextResponse.json({ data: 'success' }, { status: 200 }) + } + + return NextResponse.json({ error: errorMessage }, { status: 500 }) + } catch (error) { + return NextResponse.json({ error: errorMessage }, { status: 500 }) + } +} diff --git a/app/api/validator-inclusion/route.ts b/app/api/validator-inclusion/route.ts new file mode 100644 index 00000000..fd2be35a --- /dev/null +++ b/app/api/validator-inclusion/route.ts @@ -0,0 +1,13 @@ +import { NextResponse } from 'next/server' +import getReqAuthToken from '../../../utilities/getReqAuthToken'; +import { fetchInclusionRate } from '../beacon' + +export async function GET(req: Request) { + try { + const token = getReqAuthToken(req) + const data = await fetchInclusionRate(token) + return NextResponse.json(data) + } catch (error) { + return NextResponse.json({ error: 'Failed to fetch validator inclusion data' }, { status: 500 }) + } +} diff --git a/app/api/validator-metrics/[[...index]]/route.ts b/app/api/validator-metrics/[[...index]]/route.ts new file mode 100644 index 00000000..2d87a939 --- /dev/null +++ b/app/api/validator-metrics/[[...index]]/route.ts @@ -0,0 +1,21 @@ +import { NextResponse } from 'next/server' +import getReqAuthToken from '../../../../utilities/getReqAuthToken'; +import { fetchValMetrics } from '../../validator'; + +export async function GET(req: Request, context: any) { + try { + const { index } = context.params; + const token = getReqAuthToken(req) + + if (!token) { + return NextResponse.json({ error: 'Authentication token is missing' }, { status: 401 }); + } + + const data = await fetchValMetrics(token, index) + return NextResponse.json(data) + } catch (error: any) { + console.error('Error fetching val metrics:', error); + const errorMessage = error.message || 'Failed to fetch validator metrics'; + return NextResponse.json({ error: errorMessage }, { status: 500 }); + } +} diff --git a/app/api/validator-network/route.ts b/app/api/validator-network/route.ts new file mode 100644 index 00000000..88ddfd11 --- /dev/null +++ b/app/api/validator-network/route.ts @@ -0,0 +1,13 @@ +import { NextResponse } from 'next/server' +import getReqAuthToken from '../../../utilities/getReqAuthToken'; +import { fetchValidatorCountData } from '../beacon' + +export async function GET(req: Request) { + try { + const token = getReqAuthToken(req) + const data = await fetchValidatorCountData(token) + return NextResponse.json(data) + } catch (error) { + return NextResponse.json({ error: 'Failed to fetch validator data' }, { status: 500 }) + } +} diff --git a/app/api/validator-states/route.ts b/app/api/validator-states/route.ts new file mode 100644 index 00000000..63069782 --- /dev/null +++ b/app/api/validator-states/route.ts @@ -0,0 +1,13 @@ +import { NextResponse } from 'next/server' +import getReqAuthToken from '../../../utilities/getReqAuthToken'; +import { fetchValStates } from '../validator' + +export async function GET(req: Request) { + try { + const token = getReqAuthToken(req) + const data = await fetchValStates(token) + return NextResponse.json(data) + } catch (error) { + return NextResponse.json({ error: 'Failed to fetch validator state data' }, { status: 500 }) + } +} diff --git a/app/api/validator.ts b/app/api/validator.ts new file mode 100644 index 00000000..c0297d72 --- /dev/null +++ b/app/api/validator.ts @@ -0,0 +1,22 @@ +import fetchFromApi from '../../utilities/fetchFromApi' + +const backendUrl = process.env.BACKEND_URL + +export const fetchValStates = async (token: string,) => + await fetchFromApi(`${backendUrl}/validator/states`, token) +export const fetchValCaches = async (token: string,) => + await fetchFromApi(`${backendUrl}/validator/caches`, token) +export const fetchValMetrics = async (token: string, index?: string | null) => + await fetchFromApi(`${backendUrl}/validator/metrics${index ? `/${index}` : ''}`, token) +export const signVoluntaryExit = async (data: any, token: string) => + await fetchFromApi(`${backendUrl}/validator/sign-exit`, token,{ + method: 'POST', + body: JSON.stringify(data) + }) +export const fetchValGraffiti = async (token: string, index: string) => + await fetchFromApi(`${backendUrl}/validator/graffiti/${index}`, token) +export const updateValGraffiti = async (token: string, data: any) => + await fetchFromApi(`${backendUrl}/validator/graffiti`, token,{ + method: 'PUT', + body: JSON.stringify(data) + }) \ No newline at end of file diff --git a/app/dashboard/Main.tsx b/app/dashboard/Main.tsx new file mode 100644 index 00000000..d3cff9c2 --- /dev/null +++ b/app/dashboard/Main.tsx @@ -0,0 +1,264 @@ +'use client' + +import React, { FC, useEffect } from 'react'; +import { useTranslation } from 'react-i18next' +import { useSetRecoilState } from 'recoil' +import pckJson from '../../package.json' +import AccountEarning from '../../src/components/AccountEarnings/AccountEarning' +import AppGreeting from '../../src/components/AppGreeting/AppGreeting' +import DashboardWrapper from '../../src/components/DashboardWrapper/DashboardWrapper' +import DiagnosticTable from '../../src/components/DiagnosticTable/DiagnosticTable' +import NetworkStats from '../../src/components/NetworkStats/NetworkStats' +import ValidatorBalances from '../../src/components/ValidatorBalances/ValidatorBalances' +import ValidatorTable from '../../src/components/ValidatorTable/ValidatorTable' +import { ALERT_ID, CoinbaseExchangeRateUrl } from '../../src/constants/constants' +import useDiagnosticAlerts from '../../src/hooks/useDiagnosticAlerts' +import useLocalStorage from '../../src/hooks/useLocalStorage' +import useNetworkMonitor from '../../src/hooks/useNetworkMonitor' +import useSWRPolling from '../../src/hooks/useSWRPolling' +import { exchangeRates, proposerDuties } from '../../src/recoil/atoms'; +import { LogMetric, ProposerDuty, StatusColor } from '../../src/types'; +import { BeaconNodeSpecResults, SyncData } from '../../src/types/beacon' +import { Diagnostics, PeerDataResults } from '../../src/types/diagnostic' +import { ValidatorCache, ValidatorInclusionData, ValidatorInfo } from '../../src/types/validator' +import formatUniqueObjectArray from '../../utilities/formatUniqueObjectArray'; + +export interface MainProps { + initNodeHealth: Diagnostics + initSyncData: SyncData + bnVersion: string + lighthouseVersion: string + beaconSpec: BeaconNodeSpecResults + initValStates: ValidatorInfo[] + genesisTime: number + initPeerData: PeerDataResults + initValCaches: ValidatorCache + initInclusionRate: ValidatorInclusionData + initProposerDuties: ProposerDuty[] + initLogMetrics: LogMetric +} + +const Main: FC = (props) => { + const { + initNodeHealth, + initSyncData, + initValStates, + initValCaches, + initPeerData, + initInclusionRate, + beaconSpec, + bnVersion, + lighthouseVersion, + genesisTime, + initProposerDuties, + initLogMetrics, + } = props + + const { t } = useTranslation() + + const { SECONDS_PER_SLOT, SLOTS_PER_EPOCH } = beaconSpec + const { version } = pckJson + const { updateAlert, storeAlert, removeAlert } = useDiagnosticAlerts() + const [username] = useLocalStorage('username', 'Keeper') + const setExchangeRate = useSetRecoilState(exchangeRates) + const setDuties = useSetRecoilState(proposerDuties) + + const { isValidatorError, isBeaconError } = useNetworkMonitor() + + const networkError = isValidatorError || isBeaconError + const slotInterval = SECONDS_PER_SLOT * 1000 + const halfEpochInterval = ((Number(SECONDS_PER_SLOT) * Number(SLOTS_PER_EPOCH)) / 2) * 1000 + + const { data: exchangeData } = useSWRPolling(CoinbaseExchangeRateUrl, { + refreshInterval: 60 * 1000, + networkError, + }) + + const { data: peerData } = useSWRPolling('/api/peer-data', { + refreshInterval: slotInterval, + fallbackData: initPeerData, + networkError, + }) + const { data: validatorCache } = useSWRPolling('/api/validator-cache', { + refreshInterval: slotInterval / 2, + fallbackData: initValCaches, + networkError, + }) + const { data: validatorStates } = useSWRPolling('/api/validator-states', { + refreshInterval: slotInterval, + fallbackData: initValStates, + networkError, + }) + const { data: nodeHealth } = useSWRPolling('/api/node-health', { + refreshInterval: 6000, + fallbackData: initNodeHealth, + networkError, + }) + const { data: syncData } = useSWRPolling('/api/node-sync', { + refreshInterval: slotInterval, + fallbackData: initSyncData, + networkError, + }) + const { data: valInclusion } = useSWRPolling('/api/validator-inclusion', { + refreshInterval: slotInterval, + fallbackData: initInclusionRate, + networkError, + }) + + const { data: valDuties } = useSWRPolling('/api/validator-duties', { + refreshInterval: halfEpochInterval, + fallbackData: initProposerDuties, + networkError, + }) + + const { data: logMetrics } = useSWRPolling('/api/priority-logs', { + refreshInterval: slotInterval / 2, + fallbackData: initLogMetrics, + networkError, + }) + + const { beaconSync, executionSync } = syncData + const { isSyncing } = beaconSync + const { isReady } = executionSync + const { connected } = peerData + const { natOpen } = nodeHealth + const warningCount = logMetrics.warningLogs?.length || 0 + + useEffect(() => { + setDuties(prev => formatUniqueObjectArray([...prev, ...valDuties])) + }, [valDuties]) + + useEffect(() => { + if (exchangeData) { + const { rates } = exchangeData.data + setExchangeRate({ + rates, + currencies: Object.keys(rates), + }) + } + }, [t, exchangeData, setExchangeRate]) + + useEffect(() => { + if (!isSyncing) { + removeAlert(ALERT_ID.BEACON_SYNC) + return + } + + storeAlert({ + id: ALERT_ID.BEACON_SYNC, + severity: StatusColor.WARNING, + subText: t('fair'), + message: t('alertMessages.beaconNotSync'), + }) + }, [t, isSyncing, storeAlert, removeAlert]) + + useEffect(() => { + if (isReady) { + removeAlert(ALERT_ID.VALIDATOR_SYNC) + return + } + + storeAlert({ + id: ALERT_ID.VALIDATOR_SYNC, + severity: StatusColor.WARNING, + subText: t('fair'), + message: t('alertMessages.ethClientNotSync'), + }) + }, [t, isReady, storeAlert, removeAlert]) + + useEffect(() => { + if (connected <= 50) { + if (connected <= 20) { + updateAlert({ + message: t('alert.peerCountLow', { type: t('alert.type.nodeValidator') }), + subText: t('poor'), + severity: StatusColor.ERROR, + id: ALERT_ID.PEER_COUNT, + }) + return + } + updateAlert({ + message: t('alert.peerCountMedium', { type: t('alert.type.nodeValidator') }), + subText: t('fair'), + severity: StatusColor.WARNING, + id: ALERT_ID.PEER_COUNT, + }) + } + }, [t, connected, updateAlert]) + + useEffect(() => { + if (natOpen) { + removeAlert(ALERT_ID.NAT) + return + } + + storeAlert({ + id: ALERT_ID.NAT, + message: t('alert.natClosedStatus', { type: t('alert.type.network') }), + subText: t('poor'), + severity: StatusColor.ERROR, + }) + }, [t, natOpen, storeAlert, removeAlert]) + + useEffect(() => { + if (warningCount > 5) { + storeAlert({ + id: ALERT_ID.WARNING_LOG, + message: t('alertMessages.excessiveWarningLogs'), + severity: StatusColor.WARNING, + subText: t('fair'), + }) + + return + } + + removeAlert(ALERT_ID.WARNING_LOG) + }, [warningCount, storeAlert, removeAlert]) + + return ( + +
+
+ + + +
+
+ + + +
+
+
+ ) +} + +export default Main diff --git a/app/dashboard/Wrapper.tsx b/app/dashboard/Wrapper.tsx new file mode 100644 index 00000000..27a2e247 --- /dev/null +++ b/app/dashboard/Wrapper.tsx @@ -0,0 +1,16 @@ +'use client' + +import React, { FC } from 'react' +import Providers from '../Providers' +import Main, { MainProps } from './Main' +import '../../src/i18n' + +const Wrapper: FC = (props) => { + return ( + +
+ + ) +} + +export default Wrapper diff --git a/app/dashboard/logs/Content.tsx b/app/dashboard/logs/Content.tsx new file mode 100644 index 00000000..fba6b102 --- /dev/null +++ b/app/dashboard/logs/Content.tsx @@ -0,0 +1,13 @@ +import { FC } from 'react' +import SSELogProvider from '../../../src/components/SSELogProvider/SSELogProvider' +import Main, { MainProps } from './Main' + +const Content: FC = (props) => { + return ( + +
+ + ) +} + +export default Content diff --git a/app/dashboard/logs/Main.tsx b/app/dashboard/logs/Main.tsx new file mode 100644 index 00000000..b0a4640e --- /dev/null +++ b/app/dashboard/logs/Main.tsx @@ -0,0 +1,88 @@ +import { FC, useEffect, useMemo, useState } from 'react'; +import DashboardWrapper from '../../../src/components/DashboardWrapper/DashboardWrapper' +import LogControls from '../../../src/components/LogControls/LogControls' +import LogDisplay from '../../../src/components/LogDisplay/LogDisplay' +import { OptionType } from '../../../src/components/SelectDropDown/SelectDropDown' +import useNetworkMonitor from '../../../src/hooks/useNetworkMonitor' +import useSWRPolling from '../../../src/hooks/useSWRPolling' +import { LogMetric, LogType } from '../../../src/types'; +import { BeaconNodeSpecResults, SyncData } from '../../../src/types/beacon' +import { Diagnostics } from '../../../src/types/diagnostic' + +export interface MainProps { + initNodeHealth: Diagnostics + beaconSpec: BeaconNodeSpecResults + initSyncData: SyncData + initLogMetrics: LogMetric +} + +const Main: FC = ({ initSyncData, beaconSpec, initNodeHealth, initLogMetrics }) => { + const { SECONDS_PER_SLOT } = beaconSpec + const { isValidatorError, isBeaconError } = useNetworkMonitor() + const networkError = isValidatorError || isBeaconError + const slotInterval = SECONDS_PER_SLOT * 1000 + + const [logType, selectType] = useState(LogType.VALIDATOR) + const [isLoading, setLoading] = useState(true) + + useEffect(() => { + setTimeout(() => { + setLoading(false) + }, 500) + }, []) + + const { data: syncData } = useSWRPolling('/api/node-sync', { + refreshInterval: slotInterval, + fallbackData: initSyncData, + networkError, + }) + const { data: nodeHealth } = useSWRPolling('/api/node-health', { + refreshInterval: 6000, + fallbackData: initNodeHealth, + networkError, + }) + + const { data: logMetrics } = useSWRPolling('/api/priority-logs', { + refreshInterval: slotInterval / 2, + fallbackData: initLogMetrics, + networkError, + }) + + const filteredLogs = useMemo(() => { + return { + warningLogs: logMetrics.warningLogs.filter(({type}) => type === logType), + errorLogs: logMetrics.errorLogs.filter(({type}) => type === logType), + criticalLogs: logMetrics.criticalLogs.filter(({type}) => type === logType) + } + }, [logMetrics, logType]) + + const toggleLogType = (selection: OptionType) => { + if (selection === logType) return + + setLoading(true) + + setTimeout(() => { + setLoading(false) + setTimeout(() => { + selectType(selection as LogType) + }, 500) + }, 500) + } + + return ( + +
+ + +
+
+ ) +} + +export default Main diff --git a/app/dashboard/logs/Wrapper.tsx b/app/dashboard/logs/Wrapper.tsx new file mode 100644 index 00000000..bcd12d0d --- /dev/null +++ b/app/dashboard/logs/Wrapper.tsx @@ -0,0 +1,17 @@ +'use client' + +import React, { FC } from 'react' +import Providers from '../../Providers' +import Content from './Content' +import { MainProps } from './Main' +import '../../../src/i18n' + +const Wrapper: FC = (props) => { + return ( + + + + ) +} + +export default Wrapper diff --git a/app/dashboard/logs/page.tsx b/app/dashboard/logs/page.tsx new file mode 100644 index 00000000..2359fa50 --- /dev/null +++ b/app/dashboard/logs/page.tsx @@ -0,0 +1,21 @@ +import '../../../src/global.css' +import { redirect } from 'next/navigation'; +import getSessionCookie from '../../../utilities/getSessionCookie'; +import { fetchBeaconSpec, fetchNodeHealth, fetchSyncData } from '../../api/beacon' +import { fetchLogMetrics } from '../../api/logs'; +import Wrapper from './Wrapper' + +export default async function Page() { + try { + const token = getSessionCookie() + + const logMetrics = await fetchLogMetrics(token) + const beaconSpec = await fetchBeaconSpec(token) + const syncData = await fetchSyncData(token) + const nodeHealth = await fetchNodeHealth(token) + + return + } catch (e) { + redirect('/error') + } +} diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx new file mode 100644 index 00000000..cb131f8e --- /dev/null +++ b/app/dashboard/page.tsx @@ -0,0 +1,52 @@ +import '../../src/global.css' +import { redirect } from 'next/navigation'; +import getSessionCookie from '../../utilities/getSessionCookie'; +import { + fetchBeaconSpec, + fetchInclusionRate, + fetchNodeHealth, + fetchPeerData, fetchProposerDuties, + fetchSyncData +} from '../api/beacon'; +import { fetchBeaconNodeVersion, fetchGenesisData, fetchValidatorVersion } from '../api/config' +import { fetchLogMetrics } from '../api/logs'; +import { fetchValCaches, fetchValStates } from '../api/validator' +import Wrapper from './Wrapper' + +export default async function Page() { + try { + const token = getSessionCookie() + + const beaconSpec = await fetchBeaconSpec(token) + const genesisBlock = await fetchGenesisData(token) + const peerData = await fetchPeerData(token) + const syncData = await fetchSyncData(token) + const nodeHealth = await fetchNodeHealth(token) + const states = await fetchValStates(token) + const caches = await fetchValCaches(token) + const inclusion = await fetchInclusionRate(token) + const bnVersion = await fetchBeaconNodeVersion(token) + const lighthouseVersion = await fetchValidatorVersion(token) + const proposerDuties = await fetchProposerDuties(token) + const logMetrics = await fetchLogMetrics(token) + + return ( + + ) + } catch (e) { + redirect('/error') + } +} diff --git a/app/dashboard/settings/Main.tsx b/app/dashboard/settings/Main.tsx new file mode 100644 index 00000000..9cb05b1c --- /dev/null +++ b/app/dashboard/settings/Main.tsx @@ -0,0 +1,183 @@ +'use client'; + +import React, { FC, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import LighthouseSvg from '../../../src/assets/images/lighthouse-black.svg'; +import AppDescription from '../../../src/components/AppDescription/AppDescription'; +import AppVersion from '../../../src/components/AppVersion/AppVersion'; +import DashboardWrapper from '../../../src/components/DashboardWrapper/DashboardWrapper'; +import Input from '../../../src/components/Input/Input'; +import SocialIcon from '../../../src/components/SocialIcon/SocialIcon'; +import Toggle from '../../../src/components/Toggle/Toggle'; +import Typography from '../../../src/components/Typography/Typography'; +import UiModeIcon from '../../../src/components/UiModeIcon/UiModeIcon'; +import { DiscordUrl, LighthouseBookUrl, SigPGithubUrl, SigPIoUrl, SigPTwitter } from '../../../src/constants/constants'; +import { UiMode } from '../../../src/constants/enums'; +import useLocalStorage from '../../../src/hooks/useLocalStorage'; +import useNetworkMonitor from '../../../src/hooks/useNetworkMonitor'; +import useSWRPolling from '../../../src/hooks/useSWRPolling'; +import useUiMode from '../../../src/hooks/useUiMode'; +import { OptionalString } from '../../../src/types'; +import { BeaconNodeSpecResults, SyncData } from '../../../src/types/beacon'; +import { Diagnostics } from '../../../src/types/diagnostic'; +import { UsernameStorage } from '../../../src/types/storage'; +import addClassString from '../../../utilities/addClassString'; + +export interface MainProps { + initNodeHealth: Diagnostics + initSyncData: SyncData + beaconSpec: BeaconNodeSpecResults + bnVersion: string + lighthouseVersion: string +} + +const Main: FC = (props) => { + const { t } = useTranslation() + const { initNodeHealth, initSyncData, beaconSpec, lighthouseVersion, bnVersion } = props + + const { SECONDS_PER_SLOT } = beaconSpec + const { isValidatorError, isBeaconError } = useNetworkMonitor() + const { mode, toggleUiMode } = useUiMode() + const [userNameError, setError] = useState() + const [username, storeUserName] = useLocalStorage('username', undefined) + + const handleUserNameChange = (e: any) => { + const value = e.target.value + setError(undefined) + + if (!value) { + setError(t('error.userName.required')) + } + + storeUserName(value) + } + + const networkError = isValidatorError || isBeaconError + const slotInterval = SECONDS_PER_SLOT * 1000 + const { data: nodeHealth } = useSWRPolling('/api/node-health', { + refreshInterval: 6000, + fallbackData: initNodeHealth, + networkError, + }) + const { data: syncData } = useSWRPolling('/api/node-sync', { + refreshInterval: slotInterval, + fallbackData: initSyncData, + networkError, + }) + + const svgClasses = addClassString('hidden md:block absolute top-14 right-10', [ + mode === UiMode.DARK ? 'opacity-20' : 'opacity-40', + ]) + + return ( + +
+ +
+
+ + {t('sidebar.settings')} + +
+
+
+
+
+ + {t('settings.currentVersion')} + +
+
+ + {t('sidebar.theme')} + + + toggleUiMode(value ? UiMode.DARK : UiMode.LIGHT)} /> +
+
+ +
+
+ +
+ + + + + +
+
+
+
+ + {t('settings.general')} + +
+ +
+
+
+
+
+ ) +} + +export default Main diff --git a/app/dashboard/settings/Wrapper.tsx b/app/dashboard/settings/Wrapper.tsx new file mode 100644 index 00000000..8384e94b --- /dev/null +++ b/app/dashboard/settings/Wrapper.tsx @@ -0,0 +1,16 @@ +'use client' + +import React, { FC } from 'react' +import Providers from '../../Providers' +import Main, { MainProps } from './Main' +import '../../../src/i18n' + +const Wrapper: FC = (props) => { + return ( + +
+ + ) +} + +export default Wrapper diff --git a/app/dashboard/settings/page.tsx b/app/dashboard/settings/page.tsx new file mode 100644 index 00000000..9c761d9a --- /dev/null +++ b/app/dashboard/settings/page.tsx @@ -0,0 +1,30 @@ +import '../../../src/global.css' +import { redirect } from 'next/navigation'; +import getSessionCookie from '../../../utilities/getSessionCookie'; +import { fetchBeaconSpec, fetchNodeHealth, fetchSyncData } from '../../api/beacon' +import { fetchBeaconNodeVersion, fetchValidatorVersion } from '../../api/config' +import Wrapper from './Wrapper' + +export default async function Page() { + try { + const token = getSessionCookie() + + const beaconSpec = await fetchBeaconSpec(token) + const syncData = await fetchSyncData(token) + const nodeHealth = await fetchNodeHealth(token) + const bnVersion = await fetchBeaconNodeVersion(token) + const lighthouseVersion = await fetchValidatorVersion(token) + + return ( + + ) + } catch (e) { + redirect('/error') + } +} diff --git a/app/dashboard/validators/Main.tsx b/app/dashboard/validators/Main.tsx new file mode 100644 index 00000000..a90e06cc --- /dev/null +++ b/app/dashboard/validators/Main.tsx @@ -0,0 +1,245 @@ +'use client' + +import { useMotionValueEvent, useScroll } from 'framer-motion'; +import { useRouter, useSearchParams } from 'next/navigation'; +import React, { FC, useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next' +import { useRecoilState, useSetRecoilState } from 'recoil'; +import BlsExecutionModal from '../../../src/components/BlsExecutionModal/BlsExecutionModal'; +import Button, { ButtonFace } from '../../../src/components/Button/Button' +import DashboardWrapper from '../../../src/components/DashboardWrapper/DashboardWrapper' +import DisabledTooltip from '../../../src/components/DisabledTooltip/DisabledTooltip' +import EditValidatorModal from '../../../src/components/EditValidatorModal/EditValidatorModal'; +import Typography from '../../../src/components/Typography/Typography' +import ValidatorModal from '../../../src/components/ValidatorModal/ValidatorModal' +import ValidatorSearchInput from '../../../src/components/ValidatorSearchInput/ValidatorSearchInput' +import ValidatorSummary from '../../../src/components/ValidatorSummary/ValidatorSummary' +import ValidatorTable from '../../../src/components/ValidatorTable/ValidatorTable' +import { CoinbaseExchangeRateUrl } from '../../../src/constants/constants' +import useNetworkMonitor from '../../../src/hooks/useNetworkMonitor' +import useSWRPolling from '../../../src/hooks/useSWRPolling' +import { activeValidatorId, exchangeRates, isEditValidator, isValidatorDetail } from '../../../src/recoil/atoms'; +import { + BeaconNodeSpecResults, + SyncData, ValidatorMetricResult +} from '../../../src/types/beacon'; +import { Diagnostics } from '../../../src/types/diagnostic' +import { ValidatorCache, ValidatorCountResult, ValidatorInfo } from '../../../src/types/validator' + +export interface MainProps { + initNodeHealth: Diagnostics + initValStates: ValidatorInfo[] + initValidatorCountData: ValidatorCountResult + initSyncData: SyncData + initValCaches: ValidatorCache + initValMetrics: ValidatorMetricResult + beaconSpec: BeaconNodeSpecResults +} + +const Main: FC = (props) => { + const { t } = useTranslation() + const { + initNodeHealth, + initSyncData, + beaconSpec, + initValidatorCountData, + initValStates, + initValCaches, + initValMetrics, + } = props + + const [scrollPercentage, setPercentage] = useState(0) + + const container = useRef(null) + const { scrollY } = useScroll({ + container + }) + + useMotionValueEvent(scrollY, "change", (latest) => { + if(container?.current) { + const totalHeight = container.current.scrollHeight - container.current.clientHeight; + setPercentage(Math.round((latest / totalHeight) * 100)) + } + }) + + const router = useRouter() + const { SECONDS_PER_SLOT, SLOTS_PER_EPOCH } = beaconSpec + const setExchangeRate = useSetRecoilState(exchangeRates) + const [search, setSearch] = useState('') + const [activeValId, setValidatorId] = useRecoilState(activeValidatorId) + const [isEditVal, setIsEditValidator] = useRecoilState(isEditValidator) + const setValDetail = useSetRecoilState(isValidatorDetail) + const [isValDetail] = useRecoilState(isValidatorDetail) + const [isRendered, setRender] = useState(false) + + const { isValidatorError, isBeaconError } = useNetworkMonitor() + + const networkError = isValidatorError || isBeaconError + + const slotInterval = SECONDS_PER_SLOT * 1000 + const epochInterval = slotInterval * Number(SLOTS_PER_EPOCH) + const searchParams = useSearchParams() + const validatorId = searchParams.get('id') + const modalView = searchParams.get('view') + const { data: exchangeData } = useSWRPolling(CoinbaseExchangeRateUrl, { + refreshInterval: 60 * 1000, + networkError, + }) + + const { data: valNetworkData } = useSWRPolling('/api/validator-network', { + refreshInterval: 60 * 1000, + fallbackData: initValidatorCountData, + networkError, + }) + const { data: validatorCache } = useSWRPolling('/api/validator-cache', { + refreshInterval: slotInterval / 2, + fallbackData: initValCaches, + networkError, + }) + const { data: validatorStates } = useSWRPolling(`/api/validator-states`, { + refreshInterval: slotInterval, + fallbackData: initValStates, + networkError, + }) + const { data: nodeHealth } = useSWRPolling('/api/node-health', { + refreshInterval: 6000, + fallbackData: initNodeHealth, + networkError, + }) + const { data: syncData } = useSWRPolling('/api/node-sync', { + refreshInterval: slotInterval, + fallbackData: initSyncData, + networkError, + }) + const { data: validatorMetrics } = useSWRPolling('/api/validator-metrics', { refreshInterval: epochInterval / 2, fallbackData: initValMetrics, networkError }) + + const filteredValidators = useMemo(() => { + return validatorStates.filter((validator) => { + const query = search.toLowerCase() + + return ( + validator.name.toLowerCase().includes(query) || + (query.length > 3 && validator.pubKey.toLowerCase().includes(query)) || + validator.index.toString().includes(query) + ) + }) + }, [search, validatorStates]) + + const rates = exchangeData?.data.rates + + const activeValidator = useMemo(() => { + if (activeValId === undefined) return + + return validatorStates.find(({ index }) => Number(activeValId) === index) + }, [activeValId, validatorStates]) + + useEffect(() => { + if(isRendered) return + + if(validatorId) { + setValidatorId(Number(validatorId)) + } + + if(modalView === 'detail') { + setValDetail(true) + } + + if(modalView === 'edit') { + setIsEditValidator(true) + } + + setRender(true) + }, [validatorId, isRendered, modalView]) + + useEffect(() => { + if (rates) { + setExchangeRate({ + rates, + currencies: Object.keys(rates), + }) + } + }, [rates, setExchangeRate]) + + const closeEditValModal = () => { + setIsEditValidator(false); + setValidatorId(undefined) + router.push('/dashboard/validators') + } + + return ( + <> + +
+
+
+ + {t('validatorManagement.title')} + + +
+
+ + {t('validatorManagement.overview')} + +
+ +
+ + + + + + +
+
+
+
+ +
+
+ + {isValDetail && activeValidator && ( + + )} + { + isEditVal && activeValidator && ( + + ) + } + + ) +} + +export default Main diff --git a/app/dashboard/validators/Wrapper.tsx b/app/dashboard/validators/Wrapper.tsx new file mode 100644 index 00000000..8384e94b --- /dev/null +++ b/app/dashboard/validators/Wrapper.tsx @@ -0,0 +1,16 @@ +'use client' + +import React, { FC } from 'react' +import Providers from '../../Providers' +import Main, { MainProps } from './Main' +import '../../../src/i18n' + +const Wrapper: FC = (props) => { + return ( + +
+ + ) +} + +export default Wrapper diff --git a/app/dashboard/validators/page.tsx b/app/dashboard/validators/page.tsx new file mode 100644 index 00000000..10e93169 --- /dev/null +++ b/app/dashboard/validators/page.tsx @@ -0,0 +1,39 @@ +import '../../../src/global.css' +import { redirect } from 'next/navigation'; +import getSessionCookie from '../../../utilities/getSessionCookie'; +import { + fetchBeaconSpec, + fetchNodeHealth, + fetchSyncData, + fetchValidatorCountData, +} from '../../api/beacon' +import { fetchValCaches, fetchValMetrics, fetchValStates } from '../../api/validator'; +import Wrapper from './Wrapper' + +export default async function Page() { + try { + const token = getSessionCookie() + + const bnHealth = await fetchNodeHealth(token) + const beaconSpec = await fetchBeaconSpec(token) + const validatorCount = await fetchValidatorCountData(token) + const syncData = await fetchSyncData(token) + const states = await fetchValStates(token) + const caches = await fetchValCaches(token) + const metrics = await fetchValMetrics(token) + + return ( + + ) + } catch (e) { + redirect('/error') + } +} diff --git a/app/error/Main.tsx b/app/error/Main.tsx new file mode 100644 index 00000000..790be6be --- /dev/null +++ b/app/error/Main.tsx @@ -0,0 +1,22 @@ +'use client' + +import { useTranslation } from 'react-i18next'; +import Button, { ButtonFace } from '../../src/components/Button/Button'; +import Typography from '../../src/components/Typography/Typography'; + +const Main = () => { + const {t} = useTranslation() + return ( +
+
+ {t('errorPage.title')} + {t('errorPage.subText')} +
+
+ +
+
+ ) +} + +export default Main \ No newline at end of file diff --git a/app/error/page.tsx b/app/error/page.tsx new file mode 100644 index 00000000..5823c891 --- /dev/null +++ b/app/error/page.tsx @@ -0,0 +1,18 @@ +import '../../src/global.css'; +import Lighthouse from '../../src/assets/images/lightHouse.svg' +import TopographyCanvas from '../../src/components/Topography/Topography'; +import Main from './Main'; + +export default async function Page() { + return ( +
+
+ +
+ +
+
+
+
+ ) +} diff --git a/app/icon.png b/app/icon.png new file mode 100644 index 00000000..1deeea8a Binary files /dev/null and b/app/icon.png differ diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 00000000..cfc35db7 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,120 @@ +import type { Metadata } from 'next' +import localFont from 'next/font/local' + +export const metas = { + title: 'Siren', + description: 'User interface built for Lighthouse that connects to a Lighthouse Beacon Node and a Lighthouse Validator Client to monitor performance and display key validator metrics.', + image: '/siren.png', +} + +export const metadata: Metadata = { + // metadataBase: new URL('http://localhost'), + ...metas, + twitter: { + title: metas.title, + description: metas.description, + creator: 'sigmaPrime', + images: [metas.image], + }, + openGraph: { + title: metas.title, + description: metas.description, + siteName: metas.title, + locale: 'en_US', + type: 'website', + images: [ + { + url: metas.image, + width: 800, + height: 600, + }, + { + url: metas.image, + width: 1800, + height: 1600, + alt: metas.title, + }, + ], + } +} as any + +const openSauce = localFont({ + src: [ + { + path: '../public/Fonts/OpenSauce/OpenSauceOne-Light.ttf', + weight: '300', + style: 'normal', + }, + { + path: '../public/Fonts/OpenSauce/OpenSauceOne-Regular.ttf', + weight: '400', + style: 'normal', + }, + { + path: '../public/Fonts/OpenSauce/OpenSauceOne-Bold.ttf', + weight: '700', + style: 'normal', + }, + ], + variable: '--openSauce', +}) + +const roboto = localFont({ + src: [ + { + path: '../public/Fonts/Roboto/Roboto-Regular.ttf', + weight: '300', + style: 'normal', + }, + { + path: '../public/Fonts/Roboto/Roboto-Medium.ttf', + weight: '600', + style: 'normal', + }, + ], + variable: '--roboto', +}) + +const archivo = localFont({ + src: [ + { + path: '../public/Fonts/Archivo/Archivo-Regular.ttf', + weight: '400', + style: 'normal', + }, + { + path: '../public/Fonts/Archivo/Archivo-Bold.ttf', + weight: '700', + style: 'normal', + }, + ], + variable: '--archivo', +}) + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + +