diff --git a/apps/stats-web/.gitignore b/apps/stats-web/.gitignore index fd3dbb571..47fa2a149 100644 --- a/apps/stats-web/.gitignore +++ b/apps/stats-web/.gitignore @@ -15,6 +15,7 @@ # production /build +env-config.schema.js # misc .DS_Store diff --git a/apps/stats-web/env/.env.production b/apps/stats-web/env/.env.production new file mode 100644 index 000000000..dbd0c6496 --- /dev/null +++ b/apps/stats-web/env/.env.production @@ -0,0 +1 @@ +NEXT_PUBLIC_API_BASE_URL=https://console-api.akash.network \ No newline at end of file diff --git a/apps/stats-web/env/.env.sample b/apps/stats-web/env/.env.sample new file mode 100644 index 000000000..0e09e65e8 --- /dev/null +++ b/apps/stats-web/env/.env.sample @@ -0,0 +1 @@ +NEXT_PUBLIC_API_BASE_URL= \ No newline at end of file diff --git a/apps/stats-web/env/.env.staging b/apps/stats-web/env/.env.staging new file mode 100644 index 000000000..968b942c0 --- /dev/null +++ b/apps/stats-web/env/.env.staging @@ -0,0 +1 @@ +NEXT_PUBLIC_API_BASE_URL=https://console-api-mainnet-staging.akash.network \ No newline at end of file diff --git a/apps/stats-web/next.config.js b/apps/stats-web/next.config.js index 35ad76b7c..273c77080 100644 --- a/apps/stats-web/next.config.js +++ b/apps/stats-web/next.config.js @@ -1,5 +1,16 @@ +require("@akashnetwork/env-loader"); const { version } = require("./package.json"); +try { + const { browserEnvSchema } = require("./env-config.schema"); + + browserEnvSchema.parse(process.env); +} catch (error) { + if (error.message.includes("Cannot find module")) { + console.warn("No env-config.schema.js found, skipping env validation"); + } +} + /** @type {import('next').NextConfig} */ const nextConfig = { output: "standalone", diff --git a/apps/stats-web/package.json b/apps/stats-web/package.json index bbeed2284..7134d2993 100644 --- a/apps/stats-web/package.json +++ b/apps/stats-web/package.json @@ -3,7 +3,8 @@ "version": "0.20.0", "private": true, "scripts": { - "build": "next build", + "build": "npm run build-env-schemas && next build", + "build-env-schemas": "tsc src/config/env-config.schema.ts --outDir . --skipLibCheck", "dev": "next dev", "format": "prettier --write ./*.{ts,js,json} **/*.{ts,tsx,js,json}", "lint": "eslint .", diff --git a/apps/stats-web/src/app/(home)/DashboardContainer.tsx b/apps/stats-web/src/app/(home)/DashboardContainer.tsx index 9d374e93d..0e5d985ea 100644 --- a/apps/stats-web/src/app/(home)/DashboardContainer.tsx +++ b/apps/stats-web/src/app/(home)/DashboardContainer.tsx @@ -5,14 +5,14 @@ import { Spinner } from "@akashnetwork/ui/components"; import { Dashboard } from "./Dashboard"; import { Title } from "@/components/Title"; -import { useSelectedNetwork } from "@/hooks/useSelectedNetwork"; import { useMarketData } from "@/queries"; import { useDashboardData } from "@/queries/useDashboardData"; +import { networkStore } from "@/store/network.store"; export const DashboardContainer: React.FunctionComponent = () => { const { data: dashboardData, isLoading: isLoadingDashboardData } = useDashboardData(); const { data: marketData, isLoading: isLoadingMarketData } = useMarketData(); - const selectedNetwork = useSelectedNetwork(); + const selectedNetwork = networkStore.useSelectedNetwork(); const isLoading = isLoadingMarketData || isLoadingDashboardData; return ( diff --git a/apps/stats-web/src/components/layout/CustomProviders.tsx b/apps/stats-web/src/components/layout/CustomProviders.tsx index 6bd86fe45..a330a52c8 100644 --- a/apps/stats-web/src/components/layout/CustomProviders.tsx +++ b/apps/stats-web/src/components/layout/CustomProviders.tsx @@ -12,12 +12,13 @@ import { CustomIntlProvider } from "./CustomIntlProvider"; import { PricingProvider } from "@/context/PricingProvider"; import { customColors } from "@/lib/colors"; import { queryClient } from "@/queries"; +import { store } from "@/store/global.store"; function Providers({ children }: React.PropsWithChildren) { return ( - + diff --git a/apps/stats-web/src/components/layout/NetworkSelect.tsx b/apps/stats-web/src/components/layout/NetworkSelect.tsx index 8f5b9bca0..c2f57fe03 100644 --- a/apps/stats-web/src/components/layout/NetworkSelect.tsx +++ b/apps/stats-web/src/components/layout/NetworkSelect.tsx @@ -1,50 +1,22 @@ "use client"; -import React, { useEffect, useState } from "react"; -import { MAINNET_ID, Network } from "@akashnetwork/network-store"; +import React from "react"; import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue, Spinner } from "@akashnetwork/ui/components"; -import { setNetworkVersion } from "@/lib/constants"; import { cn } from "@/lib/utils"; -import { initiateNetworkData, networks } from "@/store/networkStore"; +import { networkStore } from "@/store/network.store"; interface NetworkSelectProps { className?: string; } const NetworkSelect: React.FC = ({ className }) => { - const [isLoadingSettings, setIsLoadingSettings] = useState(true); - const [selectedNetworkId, setSelectedNetworkId] = useState(MAINNET_ID); - - useEffect(() => { - async function init() { - await initiateNetworkData(); - setNetworkVersion(); - - const selectedNetworkId = localStorage.getItem("selectedNetworkId") as Network["id"]; - if (selectedNetworkId) { - setSelectedNetworkId(selectedNetworkId); - } - - setIsLoadingSettings(false); - } - - init(); - }, []); - - const onSelectNetworkChange = (networkId: Network["id"]) => { - setSelectedNetworkId(networkId); - - // Set in the settings and local storage - localStorage.setItem("selectedNetworkId", networkId); - // Reset the ui to reload the settings for the currently selected network - - location.reload(); - }; + const [{ isLoading: isLoadingNetworks, data: networks }] = networkStore.useNetworksStore(); + const [selectedNetworkId, setSelectedNetworkId] = networkStore.useSelectedNetworkIdStore({ reloadOnChange: true }); return ( - - {isLoadingSettings && } + {isLoadingNetworks && } diff --git a/apps/stats-web/src/config/browser-env.config.ts b/apps/stats-web/src/config/browser-env.config.ts new file mode 100644 index 000000000..785dfe72f --- /dev/null +++ b/apps/stats-web/src/config/browser-env.config.ts @@ -0,0 +1,6 @@ +import { validateStaticEnvVars } from "./env-config.schema"; + +export const browserEnvConfig = validateStaticEnvVars({ + NEXT_PUBLIC_DEFAULT_NETWORK_ID: process.env.NEXT_PUBLIC_DEFAULT_NETWORK_ID, + NEXT_PUBLIC_API_BASE_URL: process.env.NEXT_PUBLIC_API_BASE_URL +}); diff --git a/apps/stats-web/src/config/env-config.schema.ts b/apps/stats-web/src/config/env-config.schema.ts new file mode 100644 index 000000000..9575f793e --- /dev/null +++ b/apps/stats-web/src/config/env-config.schema.ts @@ -0,0 +1,29 @@ +import { z } from "zod"; + +const networkId = z.enum(["mainnet", "sandbox", "testnet"]); +const coercedBoolean = () => z.enum(["true", "false"]).transform(val => val === "true"); + +export const browserEnvSchema = z.object({ + NEXT_PUBLIC_DEFAULT_NETWORK_ID: networkId.optional().default("mainnet"), + NEXT_PUBLIC_API_BASE_URL: z.string().url() +}); + +export const serverEnvSchema = browserEnvSchema.extend({ + MAINTENANCE_MODE: coercedBoolean().optional().default("false"), + BASE_API_MAINNET_URL: z.string().url(), + BASE_API_TESTNET_URL: z.string().url(), + BASE_API_SANDBOX_URL: z.string().url() +}); + +export type BrowserEnvConfig = z.infer; +export type ServerEnvConfig = z.infer; + +export const validateStaticEnvVars = (config: Record) => browserEnvSchema.parse(config); +export const validateRuntimeEnvVars = (config: Record) => { + if (process.env.NEXT_PHASE === "phase-production-build") { + console.log("Skipping validation of serverEnvConfig during build"); + return config as ServerEnvConfig; + } else { + return serverEnvSchema.parse(config); + } +}; diff --git a/apps/stats-web/src/config/server-env.config.ts b/apps/stats-web/src/config/server-env.config.ts new file mode 100644 index 000000000..1b85264cc --- /dev/null +++ b/apps/stats-web/src/config/server-env.config.ts @@ -0,0 +1,5 @@ +import "@akashnetwork/env-loader"; + +import { validateRuntimeEnvVars } from "./env-config.schema"; + +export const serverEnvConfig = validateRuntimeEnvVars(process.env); diff --git a/apps/stats-web/src/hooks/useDenom.ts b/apps/stats-web/src/hooks/useDenom.ts index 3a2f76265..d53706f8f 100644 --- a/apps/stats-web/src/hooks/useDenom.ts +++ b/apps/stats-web/src/hooks/useDenom.ts @@ -1,8 +1,7 @@ -import { useSelectedNetwork } from "./useSelectedNetwork"; - import { USDC_IBC_DENOMS } from "@/config/denom.config"; +import { networkStore } from "@/store/network.store"; export const useUsdcDenom = () => { - const selectedNetwork = useSelectedNetwork(); - return USDC_IBC_DENOMS[selectedNetwork.id]; + const selectedNetworkId = networkStore.useSelectedNetworkId(); + return USDC_IBC_DENOMS[selectedNetworkId]; }; diff --git a/apps/stats-web/src/hooks/useSelectedNetwork.ts b/apps/stats-web/src/hooks/useSelectedNetwork.ts deleted file mode 100644 index 83a65c86a..000000000 --- a/apps/stats-web/src/hooks/useSelectedNetwork.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { MAINNET_ID } from "@akashnetwork/network-store"; -import { useAtom } from "jotai"; -import { useEffectOnce } from "usehooks-ts"; - -import networkStore, { networks } from "@/store/networkStore"; - -export const getSelectedNetwork = () => { - const selectedNetworkId = localStorage.getItem("selectedNetworkId") ?? MAINNET_ID; - const selectedNetwork = networks.find(n => n.id === selectedNetworkId); - - // return mainnet if selected network is not found - return selectedNetwork ?? networks[0]; -}; - -export const useSelectedNetwork = () => { - const [selectedNetwork, setSelectedNetwork] = useAtom(networkStore.selectedNetwork); - - useEffectOnce(() => { - const selectedNetworkId = localStorage.getItem("selectedNetworkId") ?? MAINNET_ID; - setSelectedNetwork(networks.find(n => n.id === selectedNetworkId) || networks[0]); - }); - - return selectedNetwork ?? networks[0]; -}; diff --git a/apps/stats-web/src/lib/constants.ts b/apps/stats-web/src/lib/constants.ts index 151c7e8c9..dab4540e2 100644 --- a/apps/stats-web/src/lib/constants.ts +++ b/apps/stats-web/src/lib/constants.ts @@ -1,4 +1,6 @@ -import { MAINNET_ID, SANDBOX_ID, TESTNET_ID } from "@akashnetwork/network-store"; +import { SANDBOX_ID, TESTNET_ID } from "@akashnetwork/network-store"; + +import { networkStore } from "@/store/network.store"; const productionMainnetApiUrl = "https://console-api.akash.network"; const productionTestnetApiUrl = "https://console-api-testnet.akash.network"; @@ -50,8 +52,7 @@ function getApiUrl() { if (typeof window === "undefined") return "http://localhost:3080"; if (productionHostnames.includes(window.location?.hostname)) { try { - const _selectedNetworkId = localStorage.getItem("selectedNetworkId"); - return getNetworkBaseApiUrl(_selectedNetworkId); + return getNetworkBaseApiUrl(networkStore.selectedNetworkId); } catch (e) { console.error(e); return productionMainnetApiUrl; @@ -59,30 +60,3 @@ function getApiUrl() { } return "http://localhost:3080"; } - -export let selectedNetworkId = ""; -export let networkVersion: "v1beta2" | "v1beta3"; - -export function setNetworkVersion() { - const _selectedNetworkId = localStorage.getItem("selectedNetworkId"); - - switch (_selectedNetworkId) { - case MAINNET_ID: - networkVersion = "v1beta3"; - selectedNetworkId = MAINNET_ID; - break; - case TESTNET_ID: - networkVersion = "v1beta3"; - selectedNetworkId = TESTNET_ID; - break; - case SANDBOX_ID: - networkVersion = "v1beta3"; - selectedNetworkId = SANDBOX_ID; - break; - - default: - networkVersion = "v1beta3"; - selectedNetworkId = MAINNET_ID; - break; - } -} diff --git a/apps/stats-web/src/lib/urlUtils.ts b/apps/stats-web/src/lib/urlUtils.ts index d57940e4f..d6e070404 100644 --- a/apps/stats-web/src/lib/urlUtils.ts +++ b/apps/stats-web/src/lib/urlUtils.ts @@ -1,30 +1,20 @@ -import { selectedNetworkId } from "./constants"; - -function getSelectedNetworkQueryParam() { - if (selectedNetworkId) { - return selectedNetworkId; - } else if (typeof window !== "undefined") { - return new URLSearchParams(window.location.search).get("network"); - } - - return undefined; -} +import { networkStore } from "@/store/network.store"; export class UrlService { static home = () => "/"; static graph = (snapshot: string) => `/graph/${snapshot}`; static providerGraph = (snapshot: string) => `/provider-graph/${snapshot}`; static blocks = () => `/blocks`; - static block = (height: number) => `/blocks/${height}${appendSearchParams({ network: getSelectedNetworkQueryParam() as string })}`; + static block = (height: number) => `/blocks/${height}${appendSearchParams({ network: networkStore.selectedNetworkId })}`; static transactions = () => `/transactions`; - static transaction = (hash: string) => `/transactions/${hash}${appendSearchParams({ network: getSelectedNetworkQueryParam() as string })}`; - static address = (address: string) => `/addresses/${address}${appendSearchParams({ network: getSelectedNetworkQueryParam() as string })}`; + static transaction = (hash: string) => `/transactions/${hash}${appendSearchParams({ network: networkStore.selectedNetworkId })}`; + static address = (address: string) => `/addresses/${address}${appendSearchParams({ network: networkStore.selectedNetworkId })}`; static addressTransactions = (address: string) => `/addresses/${address}/transactions`; static addressDeployments = (address: string) => `/addresses/${address}/deployments`; static deployment = (owner: string, dseq: string) => - `/addresses/${owner}/deployments/${dseq}${appendSearchParams({ network: getSelectedNetworkQueryParam() as string })}`; + `/addresses/${owner}/deployments/${dseq}${appendSearchParams({ network: networkStore.selectedNetworkId })}`; static validators = () => "/validators"; - static validator = (address: string) => `/validators/${address}${appendSearchParams({ network: getSelectedNetworkQueryParam() as string })}`; + static validator = (address: string) => `/validators/${address}${appendSearchParams({ network: networkStore.selectedNetworkId })}`; static proposals = () => "/proposals"; static proposal = (id: number) => `/proposals/${id}`; } @@ -64,9 +54,3 @@ export function isValidHttpUrl(str: string): boolean { return url.protocol === "http:" || url.protocol === "https:"; } - -export function handleDocClick(ev: Event, url: string) { - ev.preventDefault(); - - window.open(url, "_blank"); -} diff --git a/apps/stats-web/src/store/global.store.ts b/apps/stats-web/src/store/global.store.ts new file mode 100644 index 000000000..2ff6e1b41 --- /dev/null +++ b/apps/stats-web/src/store/global.store.ts @@ -0,0 +1,3 @@ +import { createStore } from "jotai"; + +export const store = createStore(); diff --git a/apps/stats-web/src/store/network.store.ts b/apps/stats-web/src/store/network.store.ts new file mode 100644 index 000000000..da9ef9579 --- /dev/null +++ b/apps/stats-web/src/store/network.store.ts @@ -0,0 +1,10 @@ +import { NetworkStore } from "@akashnetwork/network-store"; + +import { browserEnvConfig } from "@/config/browser-env.config"; +import { store } from "@/store/global.store"; + +export const networkStore = NetworkStore.create({ + defaultNetworkId: browserEnvConfig.NEXT_PUBLIC_DEFAULT_NETWORK_ID, + apiBaseUrl: browserEnvConfig.NEXT_PUBLIC_API_BASE_URL, + store +}); diff --git a/apps/stats-web/src/store/networkStore.ts b/apps/stats-web/src/store/networkStore.ts deleted file mode 100644 index 9b02b4107..000000000 --- a/apps/stats-web/src/store/networkStore.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { MAINNET_ID, SANDBOX_ID, TESTNET_ID } from "@akashnetwork/network-store"; -import axios from "axios"; -import { atom } from "jotai"; - -import { ApiUrlService } from "@/lib/apiUtils"; -import { Network } from "@/types/network"; - -export let networks: Network[] = [ - { - id: MAINNET_ID, - title: "Mainnet", - description: "Akash Network mainnet network.", - chainId: "akashnet-2", - versionUrl: ApiUrlService.mainnetVersion(), - rpcEndpoint: "https://rpc.cosmos.directory/akash", - enabled: true, - version: null // Set asynchronously - }, - { - id: TESTNET_ID, - title: "GPU Testnet", - description: "Testnet of the new GPU features.", - chainId: "testnet-02", - versionUrl: ApiUrlService.testnetVersion(), - enabled: false, - version: null // Set asynchronously - }, - { - id: SANDBOX_ID, - title: "Sandbox", - description: "Sandbox of the mainnet version.", - chainId: "sandbox-01", - versionUrl: ApiUrlService.sandboxVersion(), - version: null, // Set asynchronously - enabled: true - } -]; - -/** - * Get the actual versions and metadata of the available networks - */ -export const initiateNetworkData = async () => { - networks = await Promise.all( - networks.map(async network => { - let version = null; - try { - const response = await axios.get(network.versionUrl, { timeout: 10000 }); - version = response.data; - } catch (error) { - console.log(error); - } - - return { - ...network, - version - }; - }) - ); -}; - -const selectedNetwork = atom(networks[0]); - -const networkStore = { - selectedNetwork -}; - -export default networkStore; diff --git a/packages/network-store/src/network.store.ts b/packages/network-store/src/network.store.ts index f756f8069..87e8f91e4 100644 --- a/packages/network-store/src/network.store.ts +++ b/packages/network-store/src/network.store.ts @@ -33,7 +33,7 @@ export class NetworkStore { readonly networksStore = atom({ isLoading: true, error: undefined, data: INITIAL_NETWORKS_CONFIG }); - private readonly selectedNetworkIdStore = atomWithStorage("selectedNetworkId", this.options.defaultNetworkId, undefined, { getOnInit: true }); + private readonly selectedNetworkIdStore = atomWithStorage("selectedNetworkId", this.getInitialNetworkId()); private readonly selectedNetworkStore = atom( get => { @@ -42,8 +42,8 @@ export class NetworkStore { return networks.find(n => n.id === networkId) ?? networks[0]; }, - async (get, set, next) => { - await set(this.selectedNetworkIdStore, next.id); + (get, set, next) => { + set(this.selectedNetworkIdStore, next.id); } ); @@ -101,6 +101,26 @@ export class NetworkStore { } } + private getInitialNetworkId(): Network["id"] { + if (typeof window === "undefined") { + return this.options.defaultNetworkId; + } + + const url = new URL(window.location.href); + + if (!url.searchParams.has("network")) { + return this.options.defaultNetworkId; + } + + const raw = url.searchParams.get("network"); + + if (this.networks.some(({ id }) => id === raw)) { + return raw as Network["id"]; + } + + return this.options.defaultNetworkId; + } + useNetworksStore() { return useAtom(this.networksStore); }