diff --git a/.env.example b/.env.example index e02d83a6..006c61c6 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,7 @@ -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 +PORT=3000 +BACKEND_URL=http://127.0.0.1:3001 +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="your-password" +SSL_ENABLED=true diff --git a/.eslintrc.json b/.eslintrc.json index e0a6d123..25846b0c 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,47 +1,29 @@ { - "env": { - "browser": true, - "es2021": true - }, - "extends": [ - "eslint:recommended", - "plugin:react/recommended", - "plugin:@typescript-eslint/recommended", - "prettier", - "plugin:storybook/recommended" + "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 + } + } ], - "overrides": [], - "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 26ffb60f..b600fe2e 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -1,129 +1,128 @@ 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@v4 - - name: Use node 18 - uses: actions/setup-node@v4 - 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/ - - 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] + # 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@v4 + - name: Use node 18 + uses: actions/setup-node@v4 + with: + node-version: 18 + cache: 'yarn' + - name: Install dependencies 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: 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] + NODE_ENV: development + run: | + yarn + - name: Build Siren 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} + NODE_ENV: production + run: yarn build + - name: Upload artifact + uses: actions/upload-artifact@v3 + with: + name: html + path: build/ + + 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@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: 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} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 331d2d0e..278fc87a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,22 +27,22 @@ jobs: strategy: matrix: arch: [ - aarch64-unknown-linux-gnu, - x86_64-unknown-linux-gnu, - # Requires apple signature secrets - # x86_64-apple-darwin, - x86_64-windows - ] + 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 + - 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 + - arch: x86_64-windows + platform: windows-2019 runs-on: ${{ matrix.platform }} needs: extract-version @@ -87,7 +87,6 @@ jobs: 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 @@ -95,12 +94,12 @@ jobs: strategy: matrix: arch: [ - aarch64-unknown-linux-gnu, - x86_64-unknown-linux-gnu, - # Requires apple signature secrets - # x86_64-apple-darwin, - x86_64-windows - ] + 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 @@ -172,36 +171,36 @@ jobs: run: | body=$(cat <<- "ENDBODY" - + ## Release Checklist (DELETE ME) - + - [ ] Merge `unstable` -> `stable`. - [ ] Ensure docker images are published (check `latest` and the version tag). - [ ] Prepare Discord post. - + ## 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) | diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml deleted file mode 100644 index 3de5afe7..00000000 --- a/.github/workflows/ui-tests.yml +++ /dev/null @@ -1,29 +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 visual and composition tests with Chromatic - visual-and-composition: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - 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/preview.js b/.storybook/preview.js index e2df85e2..4d785606 100644 --- a/.storybook/preview.js +++ b/.storybook/preview.js @@ -1,12 +1,10 @@ import '../src/i18n' -import '../src/global.css'; +import '../src/global.css' import { themes } from '@storybook/theming' -import { - RecoilRoot -} from 'recoil'; +import { RecoilRoot } from 'recoil' export const parameters = { - actions: { argTypesRegex: "^on[A-Z].*" }, + actions: { argTypesRegex: '^on[A-Z].*' }, controls: { matchers: { color: /(background|color)$/i, @@ -15,21 +13,17 @@ export const parameters = { }, darkMode: { dark: { - ...themes.dark, // copy existing values + ...themes.dark, // copy existing values appContentBg: '#1E1E1E', // override main story view frame - barBg: '#202020' // override top toolbar - } - } + barBg: '#202020', // override top toolbar + }, + }, } export const globalTypes = { darkMode: true, -}; +} -const withRecoil = (StoryFn) => ( - - {StoryFn()} - -); +const withRecoil = (StoryFn) => {StoryFn()} -export const decorators = [withRecoil]; \ No newline at end of file +export const decorators = [withRecoil] diff --git a/Dockerfile b/Dockerfile index 33256115..bb486b86 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,23 +1,44 @@ -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/ -WORKDIR /app +ENV NEXT_TELEMETRY_DISABLED=1 ENV NODE_ENV=development -# install (dev) deps -# on GitHub runners, timeouts occur in emulated containers +WORKDIR /app +RUN yarn --network-timeout 300000 +WORKDIR /app/backend RUN yarn --network-timeout 300000 -ENV NODE_ENV=production -# build (prod) app +ENV NODE_ENV=production + +RUN yarn build +WORKDIR /app RUN yarn build -# STAGE 2 -FROM nginx:alpine AS production +FROM node:${node_version}-alpine AS production + +WORKDIR /app + +ENV NODE_ENV=production +RUN npm install --global pm2 +RUN apk add -U nginx openssl + +COPY ./docker-assets /app/docker-assets/ +RUN rm /etc/nginx/http.d/default.conf; \ + ln -s /app/docker-assets/siren-http.conf /etc/nginx/http.d/siren-http.conf + +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/siren.js +COPY --from=builder /app/package.json /app/package.json +COPY --from=builder /app/node_modules /app/node_modules +COPY --from=builder /app/public /app/public +COPY --from=builder /app/.next /app/.next -COPY --from=builder /app/build/ /usr/share/nginx/html/ +ENTRYPOINT /app/docker-assets/docker-entrypoint.sh diff --git a/Dockerfile.dev b/Dockerfile.dev index b67b0450..6001cb3d 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -1,12 +1,24 @@ ARG node_version=18 ARG node_image=node:${node_version} -FROM $node_image -ENV NODE_ENV=development +FROM $node_image AS dev + +RUN apt update;\ + apt install -y nginx -EXPOSE 5000/tcp COPY . /app/ +RUN rm /etc/nginx/sites-enabled/default; \ + rm /etc/nginx/conf.d/default.conf; \ + ln -s /app/docker-assets/siren-http.conf /etc/nginx/conf.d/siren-http.conf + +ENV NODE_ENV=development + +WORKDIR /app/backend +RUN yarn --network-timeout 300000 + WORKDIR /app +RUN yarn --network-timeout 300000 + +ENTRYPOINT /app/docker-assets/docker-entrypoint-dev.sh -RUN yarn install -CMD ["yarn", "run", "dev"] \ No newline at end of file +# run with docker run --rm -ti -p 3000:3000 -v $PWD/.env:/app/.env:ro your-image-name 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 index 366ab82a..57a1233f 100644 --- a/Makefile +++ b/Makefile @@ -10,10 +10,6 @@ build: 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 diff --git a/README.md b/README.md index 4823a711..cbc71519 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 @@ -21,7 +21,7 @@ developers. Specifically the [Lighthouse UI](https://lighthouse-book.sigmaprime. ### Requirements -Building from source requires `Node v18` and `yarn`. +Building from source requires `Node v18` and `yarn`. ### Building From Source @@ -56,32 +56,38 @@ $ yarn dev #### Docker (Recommended) 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. +connected to via a web browser. `docker` is required to be installed with the service running. -The docker image can be built and run via the Makefile by running: +Recommended config for using the docker image (assuming the BN/VC API's are exposed on your localhost): + ``` -$ make docker +PORT=3000 +BACKEND_URL=http://127.0.0.1:3001 +VALIDATOR_URL=http://host.docker.internal:5062 +BEACON_URL=http://host.docker.internal:5052 +SSL_ENABLED=true ``` -Alternatively, to run with Docker, the image needs to be built. From the repository directory -run: +The docker image can be built and run with the following commands: ``` -$ docker build -t siren . +$ docker build -f Dockerfile -t siren . ``` Then to run the image: + ``` -$ docker run --rm -ti --name siren -p 80:80 siren +$ docker run --rm -ti --name siren -p 3443:443 -v $PWD/.env:/app/.env:ro siren ``` +Linux users may want to add this flag: +`--add-host=host.docker.internal:host-gateway` + +This will open port 3443 and allow your browser to connect. -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 start Siren, visit `https://localhost:3443` in your web browser. (ignore the certificate warning). -To view Siren, simply go to `http://localhost` in your web browser. +Advanced users can mount their own certificate with `-v $PWD/certs:/certs` (the config expects 3 files: `/certs/cert.pem` `/certs/key.pem` `/certs/key.pass`) # Running a Local Testnet @@ -104,6 +110,7 @@ $ make install-lcli 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 ``` @@ -111,6 +118,7 @@ $ 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 ``` 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..449c68cb --- /dev/null +++ b/app/api/authenticate/route.ts @@ -0,0 +1,28 @@ +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}); + + console.log(res) + + 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/route.ts b/app/api/dismiss-log/route.ts new file mode 100644 index 00000000..49aae2b9 --- /dev/null +++ b/app/api/dismiss-log/route.ts @@ -0,0 +1,26 @@ +import { NextResponse } from 'next/server' +import getReqAuthToken from '../../../utilities/getReqAuthToken'; +import { dismissLogAlert } from '../logs'; + +export async function GET(req: Request) { + try { + const url = new URL(req.url); + const index = url.searchParams.get('index'); + 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/route.ts b/app/api/validator-graffiti/route.ts new file mode 100644 index 00000000..f36e44dc --- /dev/null +++ b/app/api/validator-graffiti/route.ts @@ -0,0 +1,26 @@ +import { NextResponse } from 'next/server' +import getReqAuthToken from '../../../utilities/getReqAuthToken'; +import { fetchValGraffiti } from '../validator'; + +export async function GET(req: Request) { + try { + const url = new URL(req.url) + const index = url.searchParams.get('index') + 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/route.ts b/app/api/validator-metrics/route.ts new file mode 100644 index 00000000..75ca0c47 --- /dev/null +++ b/app/api/validator-metrics/route.ts @@ -0,0 +1,22 @@ +import { NextResponse } from 'next/server' +import getReqAuthToken from '../../../utilities/getReqAuthToken'; +import { fetchValMetrics } from '../validator'; + +export async function GET(req: Request) { + try { + const url = new URL(req.url) + const index = url.searchParams.get('index') + 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..b3fdb738 --- /dev/null +++ b/app/dashboard/settings/Main.tsx @@ -0,0 +1,189 @@ +'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')} + + + +
+
+ +
+
+ +
+ + + + + +
+
+
+
+ + {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..d23daeca --- /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('error.title')} +
+
+ +
+
+ ) +} + +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 ( + + +