diff --git a/.env.example b/.env.example index d5f4a4e..25101eb 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,2 @@ -NEXT_PUBLIC_API_URL=http://localhost:8080 +NEXT_PUBLIC_FINALITY_GADGET_API_URL=http://localhost:8080 NEXT_PUBLIC_DISPLAY_TESTING_MESSAGES=true \ No newline at end of file diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..eb43fe7 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,77 @@ +name: Docker + +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +on: + push: + branches: ["main"] + # Publish semver tags as releases. + tags: ["v*.*.*"] + +env: + # Use docker.io for Docker Hub if empty + REGISTRY: docker.io + # Remove the slash at the end of the username + IMAGE_NAME: ${{ secrets.DOCKER_HUB_USERNAME }}/${{ github.event.repository.name }} + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + # This is used to complete the identity challenge + # with sigstore/fulcio when running outside of PRs. + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + # Set up QEMU for multi-architecture support + # https://github.com/docker/setup-qemu-action + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + # Set up BuildKit Docker container builder to be able to build + # multi-platform images and export cache + # https://github.com/docker/setup-buildx-action + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + # Login against a Docker registry + # https://github.com/docker/login-action + - name: Log into Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} + + # Extract metadata (tags, labels) for Docker + # https://github.com/docker/metadata-action + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=raw,value=latest,enable={{is_default_branch}} + type=ref,event=branch + type=semver,pattern={{version}} + type=sha + + # Build and push Docker image with Buildx (don't push on PR) + # https://github.com/docker/build-push-action + - name: Build and push Docker image + id: build-and-push + uses: docker/build-push-action@v6 + with: + push: true + context: . + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/Dockerfile b/Dockerfile index b47e885..6858f4a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,9 +15,9 @@ COPY tailwind.config.ts . COPY postcss.config.js . COPY .env.example ./.env # Replace the variables in the .env file -ARG NEXT_PUBLIC_API_URL +ARG NEXT_PUBLIC_FINALITY_GADGET_API_URL ARG NEXT_PUBLIC_DISPLAY_TESTING_MESSAGES -RUN sed -i "s|NEXT_PUBLIC_API_URL=.*|NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}|g" .env +RUN sed -i "s|NEXT_PUBLIC_FINALITY_GADGET_API_URL=.*|NEXT_PUBLIC_FINALITY_GADGET_API_URL=${NEXT_PUBLIC_FINALITY_GADGET_API_URL}|g" .env RUN sed -i "s|NEXT_PUBLIC_DISPLAY_TESTING_MESSAGES=.*|NEXT_PUBLIC_DISPLAY_TESTING_MESSAGES=${NEXT_PUBLIC_DISPLAY_TESTING_MESSAGES}|g" .env RUN npm run build diff --git a/Makefile b/Makefile index 18fb07e..b2e06b1 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ build: npm run build build-docker: $(DOCKER) build \ --tag snapchain/finality-explorer \ - --build-arg NEXT_PUBLIC_API_URL=$$(grep NEXT_PUBLIC_API_URL .env | cut -d '=' -f2) \ + --build-arg NEXT_PUBLIC_FINALITY_GADGET_API_URL=$$(grep NEXT_PUBLIC_FINALITY_GADGET_API_URL .env | cut -d '=' -f2) \ --build-arg NEXT_PUBLIC_DISPLAY_TESTING_MESSAGES=$$(grep NEXT_PUBLIC_DISPLAY_TESTING_MESSAGES .env | cut -d '=' -f2) \ -f Dockerfile \ $(GIT_ROOT) diff --git a/README.md b/README.md index 6d3eef7..77f29f6 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ cp .env.example .env.local where, -- `NEXT_PUBLIC_API_URL` specifies the back-end API for the finality gadget +- `NEXT_PUBLIC_FINALITY_GADGET_API_URL` specifies the back-end API for the finality gadget - `NEXT_PUBLIC_DISPLAY_TESTING_MESSAGES` boolean value to indicate whether display testing network related message. Default to true diff --git a/docker-compose.yml b/docker-compose.yml index 899609a..e1beb10 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,7 +4,7 @@ services: context: . dockerfile: Dockerfile args: - - NEXT_PUBLIC_API_URL=http://35.193.78.151:18080 + - NEXT_PUBLIC_FINALITY_GADGET_API_URL=${NEXT_PUBLIC_FINALITY_GADGET_API_URL} - NEXT_PUBLIC_DISPLAY_TESTING_MESSAGES=true image: snapchain/finality-explorer:latest ports: @@ -12,4 +12,7 @@ services: environment: - NODE_ENV=production - NEXT_TELEMETRY_DISABLED=1 + - NEXT_PUBLIC_FINALITY_GADGET_API_URL=${NEXT_PUBLIC_FINALITY_GADGET_API_URL} + env_file: + - .env restart: unless-stopped diff --git a/src/app/api/apiWrapper.ts b/src/app/api/apiWrapper.ts index e662932..645e8d5 100644 --- a/src/app/api/apiWrapper.ts +++ b/src/app/api/apiWrapper.ts @@ -22,12 +22,8 @@ export const apiWrapper = async ( try { // destructure params in case of post request - console.log( - "process.env.NEXT_PUBLIC_API_URL", - process.env.NEXT_PUBLIC_API_URL, - ); response = await handler( - `${process.env.NEXT_PUBLIC_API_URL}${url}`, + `${process.env.NEXT_PUBLIC_FINALITY_GADGET_API_URL}${url}`, method === "POST" ? { ...params } : { diff --git a/src/app/api/getChainSyncStatus.ts b/src/app/api/getChainSyncStatus.ts new file mode 100644 index 0000000..5bfddfe --- /dev/null +++ b/src/app/api/getChainSyncStatus.ts @@ -0,0 +1,13 @@ +import { ChainSyncStatus } from "../types/chainSyncStatus"; + +import { apiWrapper } from "./apiWrapper"; + +export const getChainSyncStatus = async (): Promise => { + const response = await apiWrapper( + "GET", + `/v1/chainSyncStatus`, + "Error fetching chain sync status", + ); + + return response.data; +}; diff --git a/src/app/api/healthCheckClient.ts b/src/app/api/healthCheckClient.ts index 8d80a66..4216558 100644 --- a/src/app/api/healthCheckClient.ts +++ b/src/app/api/healthCheckClient.ts @@ -6,7 +6,7 @@ interface HealthCheckResponse { export const fetchHealthCheck = async (): Promise => { const response = await axios.get( - `${process.env.NEXT_PUBLIC_API_URL}/healthcheck`, + `${process.env.NEXT_PUBLIC_FINALITY_GADGET_API_URL}/healthcheck`, ); return response.data; }; diff --git a/src/app/assets/blue-check.svg b/src/app/assets/blue-check.svg deleted file mode 100644 index 0b3e283..0000000 --- a/src/app/assets/blue-check.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/src/app/assets/gold-check.svg b/src/app/assets/gold-check.svg deleted file mode 100644 index eac74b0..0000000 --- a/src/app/assets/gold-check.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/src/app/components/Modals/ErrorModal.tsx b/src/app/components/Modals/ErrorModal.tsx index 20331e2..179805a 100644 --- a/src/app/components/Modals/ErrorModal.tsx +++ b/src/app/components/Modals/ErrorModal.tsx @@ -90,7 +90,7 @@ export const ErrorModal: React.FC = ({
-

{getErrorTitle()}

+

{getErrorTitle()}

{getErrorMessage()}

diff --git a/src/app/components/Modals/RPCModal.tsx b/src/app/components/Modals/RPCModal.tsx index bba646b..387883a 100644 --- a/src/app/components/Modals/RPCModal.tsx +++ b/src/app/components/Modals/RPCModal.tsx @@ -1,10 +1,10 @@ interface RPCModalProps {} export const RPCModal: React.FC = () => { - const rpcAddress = process.env.NEXT_PUBLIC_API_URL; + const rpcAddress = process.env.NEXT_PUBLIC_FINALITY_GADGET_API_URL; return (
-
+
{rpcAddress}
diff --git a/src/app/components/Stats/Stats.tsx b/src/app/components/Stats/Stats.tsx new file mode 100644 index 0000000..2a6103a --- /dev/null +++ b/src/app/components/Stats/Stats.tsx @@ -0,0 +1,101 @@ +import Image from "next/image"; +import { Fragment } from "react"; +import { AiOutlineInfoCircle } from "react-icons/ai"; +import { Tooltip } from "react-tooltip"; + +import { ChainSyncStatus } from "@/app/types/chainSyncStatus"; + +import blockIcon from "./icons/block.svg"; + +interface StatsProps { + chainSyncStatus: ChainSyncStatus | undefined; +} + +export const Stats: React.FC = ({ chainSyncStatus }) => { + const sections = [ + [ + { + title: "Latest", + value: chainSyncStatus?.latest_block, + icon: blockIcon, + tooltip: "Latest L2 block number", + }, + { + title: "ETH Finalized", + value: chainSyncStatus?.latest_eth_finalized_block, + icon: blockIcon, + tooltip: "Latest ETH finalized L2 block number", + }, + { + title: "Earliest BTC Finalized", + value: chainSyncStatus?.earliest_btc_finalized_block, + icon: blockIcon, + tooltip: "Earliest consecutively BTC finalized L2 block number", + }, + { + title: "Latest BTC Finalized", + value: chainSyncStatus?.latest_btc_finalized_block, + icon: blockIcon, + tooltip: "Latest BTC finalized L2 block number", + }, + ], + ]; + + return ( +
+
+ Block Status +
+ {sections.map((section, index) => ( +
+ {section.map((subSection, subIndex) => ( + +
+
+ {subSection.title} +
+

+ {subSection.title} +

+ {subSection.tooltip && ( + <> + + + + + + )} +
+
+
+

+ {!!chainSyncStatus ? ( + {subSection.value} + ) : ( + + )} +

+
+
+ {/* Add divider between sections */} + {subIndex !== section.length - 1 && ( +
+ )} + + ))} +
+ ))} +
+ ); +}; diff --git a/src/app/components/Stats/icons/block.svg b/src/app/components/Stats/icons/block.svg new file mode 100644 index 0000000..e217a2e --- /dev/null +++ b/src/app/components/Stats/icons/block.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/src/app/components/Transaction/Transaction.tsx b/src/app/components/Transaction/Transaction.tsx index e85f510..aefd00a 100644 --- a/src/app/components/Transaction/Transaction.tsx +++ b/src/app/components/Transaction/Transaction.tsx @@ -8,9 +8,13 @@ import { LoadingSmall } from "../Loading/Loading"; interface TransactionProps { transaction: TransactionInfo | undefined; + isLoading: boolean; } -export const Transaction: React.FC = ({ transaction }) => { +export const Transaction: React.FC = ({ + transaction, + isLoading, +}) => { const status = (tx: TransactionInfo) => { switch (tx.status) { case "pending": @@ -28,6 +32,16 @@ export const Transaction: React.FC = ({ transaction }) => { } }; + if (!transaction && !isLoading) { + return ( +
+

+ Transaction not found. +

+
+ ); + } + return (

Transaction status

diff --git a/src/app/globals.css b/src/app/globals.css index 2154258..e4ac998 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -5,7 +5,7 @@ @import url("styles/terms-privacy.css"); :root { - --primary: #ff7c2a; + --primary: #84d019; --secondary: "#0DB7BF"; } @@ -68,7 +68,7 @@ input[type="number"] { /* tabs selection */ .tab.tab-active:not(.tab-disabled):not([disabled]), .tab:is(input:checked) { - border-color: #ff7c2a; + border-color: #84d019; } .divider:after, diff --git a/src/app/page.tsx b/src/app/page.tsx index 9719e12..f070a99 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -3,6 +3,7 @@ import { useQuery } from "@tanstack/react-query"; import { useEffect, useState } from "react"; +import { getChainSyncStatus } from "./api/getChainSyncStatus"; import { getTxFinalityStatus } from "./api/getTxFinalityStatus"; import { Footer } from "./components/Footer/Footer"; import { Header } from "./components/Header/Header"; @@ -10,6 +11,7 @@ import { ErrorModal } from "./components/Modals/ErrorModal"; import { PrivacyModal } from "./components/Modals/Privacy/PrivacyModal"; import { TermsModal } from "./components/Modals/Terms/TermsModal"; import { SearchBar } from "./components/SearchBar/SearchBar"; +import { Stats } from "./components/Stats/Stats"; import { Transaction } from "./components/Transaction/Transaction"; import { useError } from "./context/Error/ErrorContext"; import { usePrivacy } from "./context/Privacy/PrivacyContext"; @@ -36,6 +38,26 @@ const Home: React.FC = () => { undefined, ); + const { + data: chainSyncStatus, + error: chainSyncStatusError, + isError: isChainSyncStatusError, + refetch: refetchChainSyncStatus, + } = useQuery({ + queryKey: ["chainSyncStatus"], + queryFn: async () => { + const chainSyncStatus = await getChainSyncStatus(); + return chainSyncStatus; + }, + refetchInterval: 10000, // 10 seconds + // will try refetching for RETRY_COUNT times before giving up + // user can trigger a retry action in the error modal that pops up + retry: (failureCount, error) => { + const RETRY_COUNT = 3; + return !isErrorOpen && failureCount <= RETRY_COUNT; + }, + }); + const { data: txInfo, isLoading: isLoadingTxInfo, @@ -50,13 +72,20 @@ const Home: React.FC = () => { }, refetchInterval: refetchInterval, enabled: !!searchTerm && /^0x([A-Fa-f0-9]{64})$/.test(searchTerm), + // will try refetching for RETRY_COUNT times before giving up + // user can trigger a retry action in the error modal that pops up retry: (failureCount, error) => { - return !isErrorOpen && failureCount <= 3; + const RETRY_COUNT = 3; + return !isErrorOpen && failureCount <= RETRY_COUNT; }, }); + // refetch every REFETCH_INTERVAL_IN_MS / 1000 seconds if not yet babylon finalized useEffect(() => { - setRefetchInterval(!txInfo || txInfo?.babylonFinalized ? undefined : 2000); // refetch every 2 secs if not yet babylon finalized + const REFETCH_INTERVAL_IN_MS = 2000; + setRefetchInterval( + !txInfo || txInfo?.babylonFinalized ? undefined : REFETCH_INTERVAL_IN_MS, + ); }, [txInfo]); useEffect(() => { @@ -66,16 +95,31 @@ const Home: React.FC = () => { errorState: ErrorState.SERVER_ERROR, refetchFunction: refetchTxInfo, }); - }, [isTxInfoError, txInfoError, refetchTxInfo, handleError]); + handleError({ + error: chainSyncStatusError, + hasError: isChainSyncStatusError, + errorState: ErrorState.SERVER_ERROR, + refetchFunction: refetchChainSyncStatus, + }); + }, [ + isTxInfoError, + txInfoError, + refetchTxInfo, + handleError, + isChainSyncStatusError, + chainSyncStatusError, + refetchChainSyncStatus, + ]); return (
+ - {txInfo && !isLoadingTxInfo ? ( - + {!!searchTerm ? ( + ) : ( <> )} diff --git a/src/app/types/chainSyncStatus.ts b/src/app/types/chainSyncStatus.ts new file mode 100644 index 0000000..2194a7c --- /dev/null +++ b/src/app/types/chainSyncStatus.ts @@ -0,0 +1,6 @@ +export type ChainSyncStatus = { + latest_block: number; + earliest_btc_finalized_block: number; + latest_btc_finalized_block: number; + latest_eth_finalized_block: number; +}; diff --git a/tailwind.config.ts b/tailwind.config.ts index e582aa7..de8734d 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -17,7 +17,7 @@ const config: Config = { }, extend: { colors: { - primary: "#FF7C2A", + primary: "#84d019", secondary: "#0DB7BF", "base-400": "hsl(var(--base-400) / )", }, @@ -32,7 +32,7 @@ const config: Config = { { light: { ...require("daisyui/src/theming/themes")["light"], - primary: "#FF7C2A", + primary: "#84d019", secondary: "#0DB7BF", "base-100": "#F6F6F6", "base-200": "rgba(225, 225, 225, 0.3)", @@ -47,7 +47,7 @@ const config: Config = { { dark: { ...require("daisyui/src/theming/themes")["dark"], - primary: "#FF7C2A", + primary: "#84d019", secondary: "#0DB7BF", "base-100": "#000", "base-200": "#303030",