feat: horizontal blocks layout, stats grid, network overview fallback chains

- LatestBlocks: full-width layout instead of fixed 280px sidebar
- Stats: 4-column grid on desktop, 2-column on mobile
- NetworkOverview: fallback known L1 chains when P-chain API unreachable
- NetworkOverview: expose isError from useCurrentValidators
- P-chain hooks: add retry:2 for resilience
- UserProfile: use address_hash instead of id for display
- Replace middleware.ts with proxy.ts (unused in production Docker)
- Add toolkit/chakra/separator re-export
- tsconfig: jsx react-jsx, formatting cleanup
- dev.sh: disable turbopack (stability)
This commit is contained in:
Zach Kelling
2026-03-02 10:36:17 -08:00
parent 66f33743d7
commit 6439fa7f1d
14 changed files with 1571 additions and 1130 deletions
+1 -1
View File
@@ -458,7 +458,7 @@ export default tseslint.config(
'playwright/**',
'deploy/scripts/**',
'deploy/tools/**',
'middleware.ts',
'proxy.ts',
'instrumentation*.ts',
'*.config.ts',
'*.config.js',
+1
View File
@@ -22,6 +22,7 @@ export function useBlockchains() {
queryKey: [ BLOCKCHAINS_QUERY_KEY ],
queryFn: fetchBlockchains,
staleTime: BLOCKCHAINS_STALE_TIME_MS,
retry: 2,
});
const blockchains = React.useMemo(
+1
View File
@@ -75,6 +75,7 @@ export function useCurrentValidators() {
queryKey: [ VALIDATORS_QUERY_KEY ],
queryFn: fetchCurrentValidators,
staleTime: VALIDATORS_STALE_TIME_MS,
retry: 2,
});
const validators = React.useMemo(
+1 -1
View File
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
import './.next/types/routes.d.ts';
// NOTE: This file should not be edited
// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.
+10 -10
View File
@@ -11,8 +11,8 @@
"dev": "./tools/scripts/dev.sh",
"dev:preset": "./tools/scripts/dev.preset.sh",
"dev:preset:sync": "tsc -p ./tools/preset-sync/tsconfig.json && node ./tools/preset-sync/index.js",
"build": "next build",
"build:next": "./deploy/scripts/download_assets.sh ./public/assets/configs && pnpm svg:build-sprite && ./deploy/scripts/make_envs_script.sh && next build",
"build": "next build --webpack",
"build:next": "./deploy/scripts/download_assets.sh ./public/assets/configs && pnpm svg:build-sprite && ./deploy/scripts/make_envs_script.sh && next build --webpack",
"build:docker": "./tools/scripts/build.docker.sh",
"start": "next start",
"start:docker:local": "docker run -p 3000:3000 --env-file .env.local blockscout-frontend:local",
@@ -102,7 +102,7 @@
"magic-bytes.js": "1.8.0",
"mixpanel-browser": "2.67.0",
"monaco-editor": "^0.34.1",
"next": "15.5.10",
"next": "16.1.6",
"next-themes": "0.4.4",
"nextjs-routes": "^1.0.8",
"node-fetch": "^3.2.9",
@@ -113,9 +113,9 @@
"pino-pretty": "^9.1.1",
"prom-client": "15.1.1",
"qrcode": "^1.5.1",
"react": "19.1.4",
"react": "19.2.4",
"react-device-detect": "^2.2.3",
"react-dom": "19.1.4",
"react-dom": "19.2.4",
"react-google-recaptcha": "3.1.0",
"react-hook-form": "7.52.1",
"react-icons": "5.4.0",
@@ -136,8 +136,8 @@
"@chakra-ui/cli": "3.30.0",
"@eslint/compat": "1.2.2",
"@eslint/js": "9.14.0",
"@next/bundle-analyzer": "15.5.9",
"@next/eslint-plugin-next": "15.0.3",
"@next/bundle-analyzer": "16.1.6",
"@next/eslint-plugin-next": "16.1.6",
"@openzeppelin/contracts": "5.4.0",
"@playwright/experimental-ct-react": "1.57.0",
"@playwright/test": "1.57.0",
@@ -156,8 +156,8 @@
"@types/papaparse": "^5.3.5",
"@types/phoenix": "^1.5.4",
"@types/qrcode": "^1.5.0",
"@types/react": "19.1.7",
"@types/react-dom": "19.1.6",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"@types/react-google-recaptcha": "^2.1.5",
"@types/react-scroll": "^1.8.4",
"@types/swagger-ui-react": "5.18.0",
@@ -169,7 +169,7 @@
"dotenv-cli": "^6.0.0",
"esbuild": "0.25.12",
"eslint": "9.39.2",
"eslint-config-next": "15.4.10",
"eslint-config-next": "16.1.6",
"eslint-plugin-consistent-default-export-name": "^0.0.15",
"eslint-plugin-import": "2.31.0",
"eslint-plugin-import-helpers": "2.0.1",
+1350 -972
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -4,7 +4,7 @@ import { NextResponse } from 'next/server';
import * as csp from 'nextjs/csp/index';
import * as middlewares from 'nextjs/middlewares/index';
export async function middleware(req: NextRequest) {
export async function proxy(req: NextRequest) {
const isPageRequest = req.headers.get('accept')?.includes('text/html');
const start = Date.now();
+2
View File
@@ -0,0 +1,2 @@
export { Separator } from '@chakra-ui/react';
export type { SeparatorProps } from '@chakra-ui/react';
+1 -1
View File
@@ -26,5 +26,5 @@ dotenv \
-e .env.local \
-e .env.development \
-e .env \
-- bash -c './deploy/scripts/make_envs_script.sh && next dev --turbopack -p $NEXT_PUBLIC_APP_PORT' |
-- bash -c './deploy/scripts/make_envs_script.sh && next dev -p $NEXT_PUBLIC_APP_PORT' |
pino-pretty
+28 -5
View File
@@ -1,7 +1,11 @@
{
"compilerOptions": {
"target": "es2017",
"lib": ["dom", "dom.iterable", "esnext"],
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
@@ -12,11 +16,30 @@
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"jsx": "react-jsx",
"incremental": true,
"baseUrl": ".",
"types": ["node"],
"types": [
"node"
]
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.node.ts", "**/*.tsx", "**/*.pw.tsx", "decs.d.ts", "global.d.ts"],
"exclude": ["node_modules", "node_modules_linux", "./deploy/tools/envs-validator", "./deploy/tools/favicon-generator", "./deploy/tools/multichain-config-generator", "./deploy/tools/essential-dapps-chains-config-generator", "./toolkit/package", "./deploy/tools/llms-txt-generator"],
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.node.ts",
"**/*.tsx",
"**/*.pw.tsx",
"decs.d.ts",
"global.d.ts"
],
"exclude": [
"node_modules",
"node_modules_linux",
"./deploy/tools/envs-validator",
"./deploy/tools/favicon-generator",
"./deploy/tools/multichain-config-generator",
"./deploy/tools/essential-dapps-chains-config-generator",
"./toolkit/package",
"./deploy/tools/llms-txt-generator"
]
}
+1 -1
View File
@@ -107,7 +107,7 @@ const LatestBlocks = () => {
const networkUtilization = getNetworkUtilizationParams(statsQueryResult.data?.network_utilization_percentage ?? 0);
return (
<Box width={{ base: '100%', lg: '280px' }} flexShrink={ 0 }>
<Box width="100%">
<Heading level="3">Latest blocks</Heading>
{ statsQueryResult.data?.network_utilization_percentage !== undefined && (
<Skeleton loading={ statsQueryResult.isPlaceholderData } mt={ 2 } display="inline-block" textStyle="sm">
+1 -1
View File
@@ -214,7 +214,7 @@ const Stats = () => {
return (
<Grid
gridTemplateColumns="1fr 1fr"
gridTemplateColumns={{ base: '1fr 1fr', lg: 'repeat(4, 1fr)' }}
gridGap={{ base: 1, lg: 2 }}
flexBasis="50%"
flexGrow={ 1 }
+171 -135
View File
@@ -3,7 +3,6 @@ import React from 'react';
import { useBlockchains, useChainHeights, useCurrentValidators } from 'lib/api/pchain';
import type { PChainBlockchain } from 'lib/api/pchain';
import useIsMobile from 'lib/hooks/useIsMobile';
import { Heading } from 'toolkit/chakra/heading';
import { Link } from 'toolkit/chakra/link';
import { Skeleton } from 'toolkit/chakra/skeleton';
@@ -33,6 +32,14 @@ const PRIMARY_CHAINS = [
{ id: 'I', name: 'I-Chain', fullName: 'Identity Chain', vm: 'IdentityVM', href: '/chain/i-chain' },
] as const;
// Known L1 chains (fallback when P-chain API is unreachable from browser)
const KNOWN_L1_CHAINS: ReadonlyArray<{ readonly name: string; readonly href: string }> = [
{ name: 'Zoo', href: 'https://explore-zoo.lux.network' },
{ name: 'Hanzo', href: 'https://explore-hanzo.lux.network' },
{ name: 'SPC', href: 'https://explore-spc.lux.network' },
{ name: 'Pars', href: 'https://explore-pars.lux.network' },
];
const L1_EXPLORER_URLS: Readonly<Record<string, string>> = {
Zoo: 'https://explore-zoo.lux.network',
Hanzo: 'https://explore-hanzo.lux.network',
@@ -166,6 +173,36 @@ const L1ChainRow = ({ chain }: L1ChainRowProps) => {
return <Link href={ `/chains/${ slug }` } variant="plain">{ content }</Link>;
};
// ── Known L1 chain row (fallback when API unavailable) ──
interface KnownL1RowProps {
readonly name: string;
readonly href: string;
}
const KnownL1Row = ({ name, href }: KnownL1RowProps) => (
<Link href={ href } variant="plain" target="_blank">
<Flex
align="center"
justify="space-between"
py={ 2 }
px={ 3 }
borderRadius="md"
cursor="pointer"
_hover={{ bg: CARD_HOVER }}
transition="background 0.15s"
>
<Text fontWeight={ 600 } color="text.primary" fontSize="sm">
{ name }
</Text>
<Flex align="center" gap={ 1.5 }>
<Tag size="sm" variant="subtle">L1</Tag>
<Text color="text.secondary" fontSize="xs">{ '\u2192' }</Text>
</Flex>
</Flex>
</Link>
);
// ── Sidebar card ──
interface SidebarCardProps {
@@ -206,8 +243,7 @@ const SidebarCard = ({ title, count, isLoading, action, children }: SidebarCardP
// ── Main page ──
const NetworkOverview = () => {
const isMobile = useIsMobile();
const { stats, isLoading: validatorsLoading } = useCurrentValidators();
const { stats, isLoading: validatorsLoading, isError: validatorsError } = useCurrentValidators();
const { blockchains, isLoading: chainsLoading } = useBlockchains();
const { pChainHeight, cChainHeight, isLoading: heightsLoading } = useChainHeights();
@@ -216,9 +252,16 @@ const NetworkOverview = () => {
[ blockchains ],
);
const totalChains = PRIMARY_CHAINS.length + l1Chains.length;
// Use known L1 chains as fallback when P-chain API is unreachable
const hasL1Data = l1Chains.length > 0;
const showFallbackL1 = !chainsLoading && !hasL1Data;
const totalChains = PRIMARY_CHAINS.length + (hasL1Data ? l1Chains.length : KNOWN_L1_CHAINS.length);
const isLoading = validatorsLoading || chainsLoading;
// Hide validator metrics when API returned an error (show dashes instead of 0)
const hasValidatorData = !validatorsError && stats.validatorCount > 0;
return (
<Box as="main">
{ /* ── Hero search ── */ }
@@ -238,164 +281,157 @@ const NetworkOverview = () => {
flexWrap="wrap"
overflow="hidden"
>
<Metric label="C-Chain" value={ cChainHeight > 0 ? `#${ cChainHeight.toLocaleString() }` : '' } isLoading={ heightsLoading }/>
<Metric label="C-Chain" value={ cChainHeight > 0 ? `#${ cChainHeight.toLocaleString() }` : '\u2014' } isLoading={ heightsLoading }/>
<Box w="1px" h="28px" bgColor="border.divider" display={{ base: 'none', md: 'block' }}/>
<Metric label="P-Chain" value={ pChainHeight > 0 ? `#${ pChainHeight.toLocaleString() }` : '' } isLoading={ heightsLoading }/>
<Metric label="P-Chain" value={ pChainHeight > 0 ? `#${ pChainHeight.toLocaleString() }` : '\u2014' } isLoading={ heightsLoading }/>
<Box w="1px" h="28px" bgColor="border.divider" display={{ base: 'none', md: 'block' }}/>
<Metric label="Chains" value={ String(totalChains) } isLoading={ isLoading }/>
<Box w="1px" h="28px" bgColor="border.divider" display={{ base: 'none', md: 'block' }}/>
<Metric label="Validators" value={ String(stats.validatorCount) } isLoading={ isLoading }/>
<Metric label="Validators" value={ hasValidatorData ? String(stats.validatorCount) : '\u2014' } isLoading={ validatorsLoading }/>
<Box w="1px" h="28px" bgColor="border.divider" display={{ base: 'none', md: 'block' }}/>
<Metric label="Staked" value={ `${ formatStake(stats.totalStake) } LUX` } isLoading={ isLoading }/>
<Metric label="Staked" value={ hasValidatorData ? `${ formatStake(stats.totalStake) } LUX` : '\u2014' } isLoading={ validatorsLoading }/>
<Box w="1px" h="28px" bgColor="border.divider" display={{ base: 'none', md: 'block' }}/>
<Metric label="Uptime" value={ `${ stats.averageUptime.toFixed(1) }%` } isLoading={ isLoading }/>
<Metric label="Uptime" value={ hasValidatorData ? `${ stats.averageUptime.toFixed(1) }%` : '\u2014' } isLoading={ validatorsLoading }/>
<Box w="1px" h="28px" bgColor="border.divider" display={{ base: 'none', md: 'block' }}/>
<Metric label="Connected" value={ `${ stats.connectedCount }/${ stats.validatorCount }` } isLoading={ isLoading }/>
<Metric
label="Connected"
value={ hasValidatorData ? `${ stats.connectedCount }/${ stats.validatorCount }` : '\u2014' }
isLoading={ validatorsLoading }
/>
</Flex>
{ /* ── Two-column: Activity (primary) + Chain Health (secondary) ── */ }
<Grid
templateColumns={{ base: '1fr', lg: '1fr 340px' }}
gap={ 5 }
mt={ 5 }
>
{ /* ── Left: Activity ── */ }
<Box>
{ /* Stats grid */ }
<Stats/>
{ /* ── Stats widgets ── */ }
<Box mt={ 5 }>
<Stats/>
</Box>
{ /* Latest blocks */ }
<Box mt={ 5 }>
<LatestBlocks/>
</Box>
{ /* Transactions */ }
<Box mt={ 5 }>
<Transactions/>
</Box>
{ /* ── Two-column: Latest blocks (left) + Latest transactions (right) ── */ }
<Flex mt={ 8 } direction={{ base: 'column', lg: 'row' }} columnGap={ 12 } rowGap={ 6 }>
<Box flexGrow={ 1 } flexBasis="50%" overflow="hidden">
<LatestBlocks/>
</Box>
<Box flexGrow={ 1 } flexBasis="50%" overflow="hidden">
<Transactions/>
</Box>
</Flex>
{ /* ── Right: Chain Health sidebar ── */ }
<Flex direction="column" gap={ 4 } display={{ base: 'none', lg: 'flex' }}>
{ /* Primary Network chains */ }
<SidebarCard
title="Primary Network"
count={ PRIMARY_CHAINS.length }
>
<Flex direction="column" gap={ 0 }>
{ PRIMARY_CHAINS.map((chain) => (
{ /* ── Chain Health section (below blocks/txns) ── */ }
<Grid
templateColumns={{ base: '1fr', lg: 'repeat(3, 1fr)' }}
gap={ 4 }
mt={ 8 }
>
{ /* Primary Network chains */ }
<SidebarCard
title="Primary Network"
count={ PRIMARY_CHAINS.length }
>
<Flex direction="column" gap={ 0 }>
{ PRIMARY_CHAINS.map((chain) => {
const chainHeight = (() => {
if (chain.id === 'C') return cChainHeight;
if (chain.id === 'P') return pChainHeight;
return undefined;
})();
return (
<ChainRow
key={ chain.id }
name={ chain.name }
fullName={ chain.fullName }
vm={ chain.vm }
href={ chain.href }
height={ chain.id === 'C' ? cChainHeight : chain.id === 'P' ? pChainHeight : undefined }
height={ chainHeight }
heightLoading={ heightsLoading }
/>
);
}) }
</Flex>
</SidebarCard>
{ /* Subnet / L1 chains */ }
<SidebarCard
title="Chains"
count={ hasL1Data ? l1Chains.length : KNOWN_L1_CHAINS.length }
isLoading={ chainsLoading }
action={{ label: 'View all', href: '/chains' }}
>
{ chainsLoading && (
<Flex direction="column" gap={ 1 }>
{ Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={ i } loading h="40px" borderRadius="md"/>
)) }
</Flex>
</SidebarCard>
{ /* Subnet / L1 chains — renamed to just "Chains" */ }
<SidebarCard
title="Chains"
count={ l1Chains.length }
isLoading={ chainsLoading }
action={{ label: 'View all', href: '/chains' }}
>
{ chainsLoading && (
<Flex direction="column" gap={ 1 }>
{ Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={ i } loading h="40px" borderRadius="md"/>
)) }
</Flex>
) }
{ !chainsLoading && l1Chains.length === 0 && (
<Text color="text.secondary" fontSize="sm" py={ 2 }>
No chains registered yet.
</Text>
) }
{ !chainsLoading && l1Chains.length > 0 && (
<Flex direction="column" gap={ 0 }>
{ l1Chains.map((chain) => (
<L1ChainRow key={ chain.id } chain={ chain }/>
)) }
</Flex>
) }
</SidebarCard>
{ /* Validators summary card */ }
<SidebarCard title="Validators">
<Grid templateColumns="1fr 1fr" gap={ 3 }>
<Box>
<Skeleton loading={ isLoading }>
<Text fontWeight={ 700 } fontFamily="mono" fontSize="md">
{ stats.validatorCount }
</Text>
</Skeleton>
<Text fontSize="2xs" color="text.secondary">Active</Text>
</Box>
<Box>
<Skeleton loading={ isLoading }>
<Text fontWeight={ 700 } fontFamily="mono" fontSize="md">
{ formatStake(stats.totalStake) }
</Text>
</Skeleton>
<Text fontSize="2xs" color="text.secondary">Total Stake (LUX)</Text>
</Box>
<Box>
<Skeleton loading={ isLoading }>
<Text fontWeight={ 700 } fontFamily="mono" fontSize="md">
{ stats.delegatorCount }
</Text>
</Skeleton>
<Text fontSize="2xs" color="text.secondary">Delegators</Text>
</Box>
<Box>
<Skeleton loading={ isLoading }>
<Text fontWeight={ 700 } fontFamily="mono" fontSize="md">
{ `${ stats.connectedCount }/${ stats.validatorCount }` }
</Text>
</Skeleton>
<Text fontSize="2xs" color="text.secondary">Connected</Text>
</Box>
</Grid>
<Flex justify="center" mt={ 3 }>
<Link href="/validators" textStyle="xs" color="text.secondary" _hover={{ color: 'text.primary' }}>
View validators
</Link>
</Flex>
</SidebarCard>
</Flex>
</Grid>
{ /* ── Mobile-only chains section ── */ }
{ isMobile && (
<Box mt={ 5 }>
<SidebarCard
title="Chains"
count={ totalChains }
isLoading={ isLoading }
action={{ label: 'View all', href: '/chains' }}
>
) }
{ !chainsLoading && hasL1Data && (
<Flex direction="column" gap={ 0 }>
{ PRIMARY_CHAINS.slice(0, 5).map((chain) => (
<ChainRow
key={ chain.id }
name={ chain.name }
fullName={ chain.fullName }
vm={ chain.vm }
href={ chain.href }
/>
)) }
{ l1Chains.map((chain) => (
<L1ChainRow key={ chain.id } chain={ chain }/>
)) }
</Flex>
</SidebarCard>
</Box>
) }
) }
{ showFallbackL1 && (
<Flex direction="column" gap={ 0 }>
{ KNOWN_L1_CHAINS.map((chain) => (
<KnownL1Row key={ chain.name } name={ chain.name } href={ chain.href }/>
)) }
</Flex>
) }
</SidebarCard>
{ /* Validators summary card */ }
<SidebarCard title="Validators">
{ validatorsError ? (
<Flex direction="column" align="center" py={ 3 }>
<Text color="text.secondary" fontSize="sm">Unable to fetch validator data.</Text>
<Link href="/validators" textStyle="xs" color="text.secondary" _hover={{ color: 'text.primary' }} mt={ 2 }>
View validators
</Link>
</Flex>
) : (
<>
<Grid templateColumns="1fr 1fr" gap={ 3 }>
<Box>
<Skeleton loading={ validatorsLoading }>
<Text fontWeight={ 700 } fontFamily="mono" fontSize="md">
{ stats.validatorCount }
</Text>
</Skeleton>
<Text fontSize="2xs" color="text.secondary">Active</Text>
</Box>
<Box>
<Skeleton loading={ validatorsLoading }>
<Text fontWeight={ 700 } fontFamily="mono" fontSize="md">
{ formatStake(stats.totalStake) }
</Text>
</Skeleton>
<Text fontSize="2xs" color="text.secondary">Total Stake (LUX)</Text>
</Box>
<Box>
<Skeleton loading={ validatorsLoading }>
<Text fontWeight={ 700 } fontFamily="mono" fontSize="md">
{ stats.delegatorCount }
</Text>
</Skeleton>
<Text fontSize="2xs" color="text.secondary">Delegators</Text>
</Box>
<Box>
<Skeleton loading={ validatorsLoading }>
<Text fontWeight={ 700 } fontFamily="mono" fontSize="md">
{ `${ stats.connectedCount }/${ stats.validatorCount }` }
</Text>
</Skeleton>
<Text fontSize="2xs" color="text.secondary">Connected</Text>
</Box>
</Grid>
<Flex justify="center" mt={ 3 }>
<Link href="/validators" textStyle="xs" color="text.secondary" _hover={{ color: 'text.primary' }}>
View validators
</Link>
</Flex>
</>
) }
</SidebarCard>
</Grid>
</Box>
);
};
@@ -8,9 +8,9 @@ import React from 'react';
import config from 'configs/app';
import shortenString from 'lib/shortenString';
import { Button } from 'toolkit/chakra/button';
import { Link } from 'toolkit/chakra/link';
import { PopoverBody, PopoverContent, PopoverRoot, PopoverTrigger } from 'toolkit/chakra/popover';
import { Separator } from 'toolkit/chakra/separator';
import { Link } from 'toolkit/chakra/link';
import { useDisclosure } from 'toolkit/hooks/useDisclosure';
import IconSvg from 'ui/shared/IconSvg';
import useLogout from 'ui/snippets/auth/useLogout';
@@ -112,7 +112,7 @@ const UserProfileOidc = ({ buttonSize, buttonVariant = 'header' }: Props) => {
<PopoverBody>
<Flex direction="column" gap={ 2 } py={ 1 }>
<Box fontSize="sm" color="text.secondary" px={ 1 }>
{ data.email ?? shortenString(String(data.id ?? '')) }
{ data.email ?? shortenString(String(data.address_hash ?? '')) }
</Box>
<Separator/>
<Link href="/auth/profile" textStyle="sm" color="text.primary" px={ 1 }>