mirror of
https://github.com/luxfi/explore.git
synced 2026-09-24 00:28:18 +00:00
feat: unified multi-chain explorer with Network Overview, Chains, and Validators pages
- Replace single-chain homepage with Network Overview showing P-chain stats, primary chains (C/P/X/A/B/Q/T/Z/K), and L1 subnet chains - Add Chains directory page with Primary Network / L1-L2-L3 tabs - Add Validators page with Dashboard, Validators list, and Delegators tabs - Add P-chain API hooks (useCurrentValidators, useBlockchains, useSubnets) - Apply monochrome theme: replace all blue/purple/cyan/teal with gray - Add Geist Mono font support - Add Chains and Validators to navigation sidebar - Update route types, metadata, analytics, and cspell for new pages
This commit is contained in:
@@ -122,6 +122,7 @@
|
||||
"grecaptcha",
|
||||
"growthbook",
|
||||
"gstatic",
|
||||
"Hanzo",
|
||||
"hairsp",
|
||||
"healthz",
|
||||
"helia",
|
||||
@@ -149,6 +150,7 @@
|
||||
"llms",
|
||||
"lokijs",
|
||||
"LUKSO",
|
||||
"nanoLux",
|
||||
"mainnets",
|
||||
"megaeth",
|
||||
"merkle",
|
||||
@@ -177,6 +179,9 @@
|
||||
"opblock",
|
||||
"opengraph",
|
||||
"paych",
|
||||
"pchain",
|
||||
"PCHAIN",
|
||||
"CCHAIN",
|
||||
"peekers",
|
||||
"pino",
|
||||
"pjpeg",
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
// Low-level P-chain JSON-RPC client.
|
||||
// Derives the P-chain URL from NEXT_PUBLIC_NETWORK_RPC_URL by replacing the
|
||||
// C-chain path (/ext/bc/C/rpc) with the P-chain path (/ext/bc/P).
|
||||
|
||||
import type { JsonRpcRequest, JsonRpcResponse } from './types';
|
||||
|
||||
import { getEnvValue } from 'configs/app/utils';
|
||||
|
||||
const PCHAIN_PATH = '/ext/bc/P';
|
||||
const CCHAIN_PATH_PATTERN = /\/ext\/bc\/C\/rpc$/;
|
||||
|
||||
let requestId = 0;
|
||||
|
||||
function getPChainUrl(): string {
|
||||
const rpcUrl = getEnvValue('NEXT_PUBLIC_NETWORK_RPC_URL');
|
||||
|
||||
if (!rpcUrl) {
|
||||
throw new Error('NEXT_PUBLIC_NETWORK_RPC_URL is not configured');
|
||||
}
|
||||
|
||||
// Replace C-chain path with P-chain path
|
||||
if (CCHAIN_PATH_PATTERN.test(rpcUrl)) {
|
||||
return rpcUrl.replace(CCHAIN_PATH_PATTERN, PCHAIN_PATH);
|
||||
}
|
||||
|
||||
// Fallback: strip trailing slash and append P-chain path
|
||||
const base = rpcUrl.replace(/\/+$/, '');
|
||||
try {
|
||||
const url = new URL(base);
|
||||
return `${ url.origin }${ PCHAIN_PATH }`;
|
||||
} catch {
|
||||
throw new Error(`Invalid NEXT_PUBLIC_NETWORK_RPC_URL: ${ rpcUrl }`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function pchainRpc<TResult>(
|
||||
method: string,
|
||||
params?: Record<string, unknown>,
|
||||
): Promise<TResult> {
|
||||
const url = getPChainUrl();
|
||||
requestId += 1;
|
||||
|
||||
const body: JsonRpcRequest = {
|
||||
jsonrpc: '2.0',
|
||||
id: requestId,
|
||||
method,
|
||||
...(params !== undefined ? { params } : {}),
|
||||
};
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`P-chain RPC error: HTTP ${ response.status }`);
|
||||
}
|
||||
|
||||
const json = await response.json() as JsonRpcResponse<TResult>;
|
||||
|
||||
if (json.error) {
|
||||
throw new Error(`P-chain RPC error: ${ json.error.message } (code ${ json.error.code })`);
|
||||
}
|
||||
|
||||
if (json.result === undefined) {
|
||||
throw new Error('P-chain RPC error: missing result in response');
|
||||
}
|
||||
|
||||
return json.result;
|
||||
}
|
||||
|
||||
export { getPChainUrl };
|
||||
@@ -0,0 +1,23 @@
|
||||
// P-chain API hooks and types.
|
||||
|
||||
export { pchainRpc, getPChainUrl } from './client';
|
||||
export { useCurrentValidators } from './useCurrentValidators';
|
||||
export { useBlockchains } from './useBlockchains';
|
||||
export { useSubnets } from './useSubnets';
|
||||
|
||||
export type {
|
||||
PChainValidator,
|
||||
PChainDelegator,
|
||||
PChainRewardOwner,
|
||||
PChainBlockchain,
|
||||
PChainSubnet,
|
||||
GetCurrentValidatorsResponse,
|
||||
GetBlockchainsResponse,
|
||||
GetSubnetsResponse,
|
||||
ValidatorStats,
|
||||
JsonRpcRequest,
|
||||
JsonRpcResponse,
|
||||
JsonRpcError,
|
||||
} from './types';
|
||||
|
||||
export type { UseCurrentValidatorsResult } from './useCurrentValidators';
|
||||
@@ -0,0 +1,91 @@
|
||||
// P-chain API response types for the Lux platform JSON-RPC API.
|
||||
|
||||
export interface PChainRewardOwner {
|
||||
readonly locktime: string;
|
||||
readonly threshold: string;
|
||||
readonly addresses: ReadonlyArray<string>;
|
||||
}
|
||||
|
||||
export interface PChainDelegator {
|
||||
readonly txID: string;
|
||||
readonly startTime: string;
|
||||
readonly endTime: string;
|
||||
readonly stakeAmount: string;
|
||||
readonly nodeID: string;
|
||||
readonly potentialReward: string;
|
||||
readonly rewardOwner: PChainRewardOwner;
|
||||
}
|
||||
|
||||
export interface PChainValidator {
|
||||
readonly txID: string;
|
||||
readonly startTime: string;
|
||||
readonly endTime: string;
|
||||
readonly stakeAmount: string;
|
||||
readonly nodeID: string;
|
||||
readonly weight: string;
|
||||
readonly delegationFee: string;
|
||||
readonly potentialReward: string;
|
||||
readonly connected: boolean;
|
||||
readonly uptime: string;
|
||||
readonly delegators: ReadonlyArray<PChainDelegator> | null;
|
||||
}
|
||||
|
||||
export interface PChainBlockchain {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly subnetID: string;
|
||||
readonly vmID: string;
|
||||
}
|
||||
|
||||
export interface PChainSubnet {
|
||||
readonly id: string;
|
||||
readonly controlKeys: ReadonlyArray<string>;
|
||||
readonly threshold: string;
|
||||
}
|
||||
|
||||
// JSON-RPC response wrappers
|
||||
|
||||
export interface GetCurrentValidatorsResponse {
|
||||
readonly validators: ReadonlyArray<PChainValidator>;
|
||||
}
|
||||
|
||||
export interface GetBlockchainsResponse {
|
||||
readonly blockchains: ReadonlyArray<PChainBlockchain>;
|
||||
}
|
||||
|
||||
export interface GetSubnetsResponse {
|
||||
readonly subnets: ReadonlyArray<PChainSubnet>;
|
||||
}
|
||||
|
||||
// Aggregated validator statistics
|
||||
|
||||
export interface ValidatorStats {
|
||||
readonly totalStake: bigint;
|
||||
readonly validatorCount: number;
|
||||
readonly connectedCount: number;
|
||||
readonly delegatorCount: number;
|
||||
readonly totalDelegatedStake: bigint;
|
||||
readonly averageUptime: number;
|
||||
}
|
||||
|
||||
// JSON-RPC envelope types
|
||||
|
||||
export interface JsonRpcRequest {
|
||||
readonly jsonrpc: '2.0';
|
||||
readonly id: number;
|
||||
readonly method: string;
|
||||
readonly params?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface JsonRpcResponse<TResult> {
|
||||
readonly jsonrpc: '2.0';
|
||||
readonly id: number;
|
||||
readonly result?: TResult;
|
||||
readonly error?: JsonRpcError;
|
||||
}
|
||||
|
||||
export interface JsonRpcError {
|
||||
readonly code: number;
|
||||
readonly message: string;
|
||||
readonly data?: unknown;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// React Query hook for platform.getBlockchains.
|
||||
// Returns the list of all blockchains registered on the P-chain.
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import React from 'react';
|
||||
|
||||
import type {
|
||||
GetBlockchainsResponse,
|
||||
PChainBlockchain,
|
||||
} from './types';
|
||||
|
||||
import { pchainRpc } from './client';
|
||||
|
||||
const BLOCKCHAINS_STALE_TIME_MS = 300_000;
|
||||
const BLOCKCHAINS_QUERY_KEY = 'pchain:blockchains' as const;
|
||||
const EMPTY_BLOCKCHAINS: ReadonlyArray<PChainBlockchain> = [];
|
||||
|
||||
async function fetchBlockchains(): Promise<ReadonlyArray<PChainBlockchain>> {
|
||||
const response = await pchainRpc<GetBlockchainsResponse>(
|
||||
'platform.getBlockchains',
|
||||
{},
|
||||
);
|
||||
|
||||
return response.blockchains;
|
||||
}
|
||||
|
||||
export function useBlockchains() {
|
||||
const query = useQuery({
|
||||
queryKey: [ BLOCKCHAINS_QUERY_KEY ],
|
||||
queryFn: fetchBlockchains,
|
||||
staleTime: BLOCKCHAINS_STALE_TIME_MS,
|
||||
});
|
||||
|
||||
const blockchains = React.useMemo(
|
||||
() => query.data ?? EMPTY_BLOCKCHAINS,
|
||||
[ query.data ],
|
||||
);
|
||||
|
||||
return {
|
||||
blockchains,
|
||||
isLoading: query.isLoading,
|
||||
isError: query.isError,
|
||||
error: query.error,
|
||||
refetch: query.refetch,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// React Query hook for platform.getCurrentValidators.
|
||||
// Returns the validator list and aggregated network statistics.
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import React from 'react';
|
||||
|
||||
import type {
|
||||
GetCurrentValidatorsResponse,
|
||||
PChainValidator,
|
||||
ValidatorStats,
|
||||
} from './types';
|
||||
|
||||
import { pchainRpc } from './client';
|
||||
|
||||
const VALIDATORS_STALE_TIME_MS = 60_000;
|
||||
const VALIDATORS_QUERY_KEY = 'pchain:currentValidators' as const;
|
||||
const UPTIME_PERCENTAGE_SCALE = 100;
|
||||
const ZERO = BigInt(0);
|
||||
|
||||
function computeValidatorStats(
|
||||
validators: ReadonlyArray<PChainValidator>,
|
||||
): ValidatorStats {
|
||||
let totalStake = ZERO;
|
||||
let totalDelegatedStake = ZERO;
|
||||
let connectedCount = 0;
|
||||
let delegatorCount = 0;
|
||||
let uptimeSum = 0;
|
||||
|
||||
for (const v of validators) {
|
||||
totalStake += BigInt(v.stakeAmount);
|
||||
|
||||
if (v.connected) {
|
||||
connectedCount += 1;
|
||||
}
|
||||
|
||||
uptimeSum += parseFloat(v.uptime);
|
||||
|
||||
if (v.delegators) {
|
||||
delegatorCount += v.delegators.length;
|
||||
for (const d of v.delegators) {
|
||||
totalDelegatedStake += BigInt(d.stakeAmount);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const averageUptime = validators.length > 0 ?
|
||||
(uptimeSum / validators.length) * UPTIME_PERCENTAGE_SCALE :
|
||||
0;
|
||||
|
||||
return {
|
||||
totalStake,
|
||||
validatorCount: validators.length,
|
||||
connectedCount,
|
||||
delegatorCount,
|
||||
totalDelegatedStake,
|
||||
averageUptime,
|
||||
};
|
||||
}
|
||||
|
||||
export interface UseCurrentValidatorsResult {
|
||||
readonly validators: ReadonlyArray<PChainValidator>;
|
||||
readonly stats: ValidatorStats;
|
||||
}
|
||||
|
||||
async function fetchCurrentValidators(): Promise<UseCurrentValidatorsResult> {
|
||||
const response = await pchainRpc<GetCurrentValidatorsResponse>(
|
||||
'platform.getCurrentValidators',
|
||||
{},
|
||||
);
|
||||
|
||||
const validators = response.validators;
|
||||
const stats = computeValidatorStats(validators);
|
||||
|
||||
return { validators, stats };
|
||||
}
|
||||
|
||||
export function useCurrentValidators() {
|
||||
const query = useQuery({
|
||||
queryKey: [ VALIDATORS_QUERY_KEY ],
|
||||
queryFn: fetchCurrentValidators,
|
||||
staleTime: VALIDATORS_STALE_TIME_MS,
|
||||
});
|
||||
|
||||
const validators = React.useMemo(
|
||||
() => query.data?.validators ?? [],
|
||||
[ query.data?.validators ],
|
||||
);
|
||||
|
||||
const stats = React.useMemo(
|
||||
() => query.data?.stats ?? {
|
||||
totalStake: ZERO,
|
||||
validatorCount: 0,
|
||||
connectedCount: 0,
|
||||
delegatorCount: 0,
|
||||
totalDelegatedStake: ZERO,
|
||||
averageUptime: 0,
|
||||
},
|
||||
[ query.data?.stats ],
|
||||
);
|
||||
|
||||
return {
|
||||
validators,
|
||||
stats,
|
||||
isLoading: query.isLoading,
|
||||
isError: query.isError,
|
||||
error: query.error,
|
||||
refetch: query.refetch,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// React Query hook for platform.getSubnets.
|
||||
// Returns the list of all subnets on the P-chain.
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import React from 'react';
|
||||
|
||||
import type {
|
||||
GetSubnetsResponse,
|
||||
PChainSubnet,
|
||||
} from './types';
|
||||
|
||||
import { pchainRpc } from './client';
|
||||
|
||||
const SUBNETS_STALE_TIME_MS = 300_000;
|
||||
const SUBNETS_QUERY_KEY = 'pchain:subnets' as const;
|
||||
const EMPTY_SUBNETS: ReadonlyArray<PChainSubnet> = [];
|
||||
|
||||
async function fetchSubnets(): Promise<ReadonlyArray<PChainSubnet>> {
|
||||
const response = await pchainRpc<GetSubnetsResponse>(
|
||||
'platform.getSubnets',
|
||||
{},
|
||||
);
|
||||
|
||||
return response.subnets;
|
||||
}
|
||||
|
||||
export function useSubnets() {
|
||||
const query = useQuery({
|
||||
queryKey: [ SUBNETS_QUERY_KEY ],
|
||||
queryFn: fetchSubnets,
|
||||
staleTime: SUBNETS_STALE_TIME_MS,
|
||||
});
|
||||
|
||||
const subnets = React.useMemo(
|
||||
() => query.data ?? EMPTY_SUBNETS,
|
||||
[ query.data ],
|
||||
);
|
||||
|
||||
return {
|
||||
subnets,
|
||||
isLoading: query.isLoading,
|
||||
isError: query.isError,
|
||||
error: query.error,
|
||||
refetch: query.refetch,
|
||||
};
|
||||
}
|
||||
@@ -91,12 +91,18 @@ export default function useNavItems(): ReturnType {
|
||||
icon: 'navigation/name_services',
|
||||
isActive: pathname.startsWith('/name-services'),
|
||||
} : null;
|
||||
const validators = config.features.validators.isEnabled ? {
|
||||
const validators: NavItem = {
|
||||
text: 'Validators',
|
||||
nextRoute: { pathname: '/validators' as const },
|
||||
icon: 'navigation/validator',
|
||||
isActive: pathname === '/validators' || pathname === '/validators/[id]',
|
||||
} : null;
|
||||
};
|
||||
const chains: NavItem = {
|
||||
text: 'Chains',
|
||||
nextRoute: { pathname: '/chains' as const },
|
||||
icon: 'navigation/blockchain',
|
||||
isActive: pathname === '/chains',
|
||||
};
|
||||
const rollupDeposits = {
|
||||
text: `Deposits (L1${ rightLineArrow }L2)`,
|
||||
nextRoute: { pathname: '/deposits' as const },
|
||||
@@ -222,6 +228,7 @@ export default function useNavItems(): ReturnType {
|
||||
blocks,
|
||||
epochs,
|
||||
topAccounts,
|
||||
chains,
|
||||
validators,
|
||||
verifiedContracts,
|
||||
nameLookup,
|
||||
|
||||
@@ -10,6 +10,7 @@ const OG_TYPE_DICT: Record<Route['pathname'], OGPageType> = {
|
||||
'/tx/[hash]': 'Regular page',
|
||||
'/blocks': 'Root page',
|
||||
'/block/[height_or_hash]': 'Regular page',
|
||||
'/chains': 'Root page',
|
||||
'/block/countdown': 'Regular page',
|
||||
'/block/countdown/[height]': 'Regular page',
|
||||
'/accounts': 'Root page',
|
||||
|
||||
@@ -13,6 +13,7 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
|
||||
'/tx/[hash]': 'View transaction %hash% on %network_title%',
|
||||
'/blocks': DEFAULT_TEMPLATE,
|
||||
'/block/[height_or_hash]': 'View the transactions, token transfers, and uncles for block %height_or_hash%',
|
||||
'/chains': 'Explore all blockchains on the Lux Network including primary chains and L1/L2/L3 subnets.',
|
||||
'/block/countdown': DEFAULT_TEMPLATE,
|
||||
'/block/countdown/[height]': DEFAULT_TEMPLATE,
|
||||
'/accounts': DEFAULT_TEMPLATE,
|
||||
|
||||
@@ -14,6 +14,7 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
|
||||
'/tx/[hash]': '%network_name% transaction %hash%',
|
||||
'/blocks': '%network_name% blocks',
|
||||
'/block/[height_or_hash]': '%network_name% block %height_or_hash%',
|
||||
'/chains': '%network_name% chains - all blockchains on the network',
|
||||
'/block/countdown': '%network_name% block countdown index',
|
||||
'/block/countdown/[height]': '%network_name% block %height% countdown',
|
||||
'/accounts': '%network_name% top accounts',
|
||||
|
||||
@@ -8,6 +8,7 @@ export const PAGE_TYPE_DICT: Record<Route['pathname'], string> = {
|
||||
'/tx/[hash]': 'Transaction details',
|
||||
'/blocks': 'Blocks',
|
||||
'/block/[height_or_hash]': 'Block details',
|
||||
'/chains': 'Chains',
|
||||
'/block/countdown': 'Block countdown search',
|
||||
'/block/countdown/[height]': 'Block countdown',
|
||||
'/accounts': 'Top accounts',
|
||||
|
||||
Vendored
+1
@@ -39,6 +39,7 @@ declare module "nextjs-routes" {
|
||||
| DynamicRoute<"/block/countdown/[height]", { "height": string }>
|
||||
| StaticRoute<"/block/countdown">
|
||||
| StaticRoute<"/blocks">
|
||||
| StaticRoute<"/chains">
|
||||
| DynamicRoute<"/cc/tx/[hash]", { "hash": string }>
|
||||
| DynamicRoute<"/chain/[chain_slug]/accounts/label/[slug]", { "chain_slug": string; "slug": string }>
|
||||
| DynamicRoute<"/chain/[chain_slug]/advanced-filter", { "chain_slug": string }>
|
||||
|
||||
@@ -43,6 +43,10 @@ class MyDocument extends Document {
|
||||
href={ config.UI.fonts.body?.url ?? 'https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&display=swap' }
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Geist+Mono:wght@400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
|
||||
{ /* eslint-disable-next-line @next/next/no-sync-scripts */ }
|
||||
<script src="/assets/envs.js"/>
|
||||
|
||||
@@ -97,7 +97,7 @@ const OidcCallback: NextPage = () => {
|
||||
<Flex direction="column" alignItems="center" gap={ 4 }>
|
||||
<Text fontSize="xl" fontWeight="bold" color="red.500">Authentication Error</Text>
|
||||
<Text color="gray.400">{ error }</Text>
|
||||
<Link href="/" color="blue.400">
|
||||
<Link href="/" color="gray.400">
|
||||
Return to home
|
||||
</Link>
|
||||
</Flex>
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { NextPage } from 'next';
|
||||
import React from 'react';
|
||||
|
||||
import PageNextJs from 'nextjs/PageNextJs';
|
||||
|
||||
import ChainsPage from 'ui/chains/ChainsPage';
|
||||
|
||||
const Page: NextPage = () => {
|
||||
return (
|
||||
<PageNextJs pathname="/chains">
|
||||
<ChainsPage/>
|
||||
</PageNextJs>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
export { base as getServerSideProps } from 'nextjs/getServerSideProps/main';
|
||||
+2
-2
@@ -6,13 +6,13 @@ import PageNextJs from 'nextjs/PageNextJs';
|
||||
|
||||
import config from 'configs/app';
|
||||
import OpSuperchainHome from 'ui/optimismSuperchain/home/OpSuperchainHome';
|
||||
import Home from 'ui/pages/Home';
|
||||
import NetworkOverview from 'ui/pages/NetworkOverview';
|
||||
import LayoutHome from 'ui/shared/layout/LayoutHome';
|
||||
|
||||
const Page: NextPageWithLayout = () => {
|
||||
return (
|
||||
<PageNextJs pathname="/">
|
||||
{ config.features.opSuperchain.isEnabled ? <OpSuperchainHome/> : <Home/> }
|
||||
{ config.features.opSuperchain.isEnabled ? <OpSuperchainHome/> : <NetworkOverview/> }
|
||||
</PageNextJs>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,37 +1,18 @@
|
||||
import type { NextPage } from 'next';
|
||||
import dynamic from 'next/dynamic';
|
||||
import React from 'react';
|
||||
|
||||
import PageNextJs from 'nextjs/PageNextJs';
|
||||
|
||||
import config from 'configs/app';
|
||||
|
||||
const validatorsFeature = config.features.validators;
|
||||
|
||||
const Validators = dynamic(() => {
|
||||
if (validatorsFeature.isEnabled && validatorsFeature.chainType === 'stability') {
|
||||
return import('ui/pages/ValidatorsStability');
|
||||
}
|
||||
|
||||
if (validatorsFeature.isEnabled && validatorsFeature.chainType === 'blackfort') {
|
||||
return import('ui/pages/ValidatorsBlackfort');
|
||||
}
|
||||
|
||||
if (validatorsFeature.isEnabled && validatorsFeature.chainType === 'zilliqa') {
|
||||
return import('ui/pages/ValidatorsZilliqa');
|
||||
}
|
||||
|
||||
throw new Error('Validators feature is not enabled.');
|
||||
}, { ssr: false });
|
||||
import ValidatorsPage from 'ui/validators/lux/ValidatorsPage';
|
||||
|
||||
const Page: NextPage = () => {
|
||||
return (
|
||||
<PageNextJs pathname="/validators">
|
||||
<Validators/>
|
||||
<ValidatorsPage/>
|
||||
</PageNextJs>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
export { validators as getServerSideProps } from 'nextjs/getServerSideProps/main';
|
||||
export { base as getServerSideProps } from 'nextjs/getServerSideProps/main';
|
||||
|
||||
@@ -34,7 +34,7 @@ export const Toaster = () => {
|
||||
return (
|
||||
<Toast.Root width={{ md: 'sm' }}>
|
||||
{ toast.type === 'loading' ? (
|
||||
<Spinner size="sm" color="blue.solid" my={ 1 }/>
|
||||
<Spinner size="sm" color="gray.solid" my={ 1 }/>
|
||||
) : null }
|
||||
<Stack gap="0" flex="1" maxWidth="100%">
|
||||
{ toast.title && <Toast.Title>{ toast.title }</Toast.Title> }
|
||||
|
||||
@@ -19,7 +19,7 @@ export interface ChartSelectionXProps {
|
||||
}
|
||||
|
||||
export const ChartSelectionX = React.memo(({ anchorEl, height, scale, data, onSelect }: ChartSelectionXProps) => {
|
||||
const [ borderColor ] = useToken('colors', 'blue.200');
|
||||
const [ borderColor ] = useToken('colors', 'gray.400');
|
||||
|
||||
const ref = React.useRef(null);
|
||||
const isActive = React.useRef(false);
|
||||
@@ -137,7 +137,7 @@ export const ChartSelectionX = React.memo(({ anchorEl, height, scale, data, onSe
|
||||
|
||||
return (
|
||||
<g className="ChartSelectionX" ref={ ref } opacity={ 0 }>
|
||||
<rect className="ChartSelectionX__rect" width={ 0 } height={ height } fill="rgba(66, 153, 225, 0.1)"/>
|
||||
<rect className="ChartSelectionX__rect" width={ 0 } height={ height } fill="rgba(160, 174, 192, 0.1)"/>
|
||||
<line className="ChartSelectionX__line ChartSelectionX__line_left" x1={ 0 } x2={ 0 } y1={ 0 } y2={ height } stroke={ borderColor }/>
|
||||
<line className="ChartSelectionX__line ChartSelectionX__line_right" x1={ 0 } x2={ 0 } y1={ 0 } y2={ height } stroke={ borderColor }/>
|
||||
</g>
|
||||
|
||||
@@ -12,7 +12,7 @@ type Props = {
|
||||
} & ({ label: string; children?: never } | { children: React.ReactNode; label?: never });
|
||||
|
||||
const ChartTooltipRow = ({ label, lineNum, children }: Props) => {
|
||||
const labelColor = useToken('colors', 'blue.100');
|
||||
const labelColor = useToken('colors', 'gray.400');
|
||||
const textColor = useToken('colors', 'white');
|
||||
|
||||
return (
|
||||
|
||||
@@ -21,7 +21,7 @@ export const ContentLoader = React.memo(({ text, ...props }: Props) => {
|
||||
animation: `fromLeftToRight 700ms ease-in-out infinite alternate`,
|
||||
left: '0%',
|
||||
top: 0,
|
||||
backgroundColor: 'blue.300',
|
||||
backgroundColor: 'gray.400',
|
||||
borderRadius: 'full',
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -62,8 +62,8 @@ const semanticTokens: ThemingConfig['semanticTokens'] = {
|
||||
// ELEMENTS
|
||||
address: {
|
||||
highlighted: {
|
||||
bg: { value: { _light: '{colors.blue.50}', _dark: '{colors.blue.900}' } },
|
||||
border: { value: { _light: '{colors.blue.200}', _dark: '{colors.blue.600}' } },
|
||||
bg: { value: { _light: '{colors.gray.100}', _dark: '{colors.whiteAlpha.100}' } },
|
||||
border: { value: { _light: '{colors.gray.300}', _dark: '{colors.gray.600}' } },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -143,8 +143,8 @@ const semanticTokens: ThemingConfig['semanticTokens'] = {
|
||||
},
|
||||
selected: {
|
||||
value: {
|
||||
_light: heroBannerButton?._selected?.background?.[0] || '{colors.blue.50}',
|
||||
_dark: heroBannerButton?._selected?.background?.[1] || heroBannerButton?._selected?.background?.[0] || '{colors.blue.50}',
|
||||
_light: heroBannerButton?._selected?.background?.[0] || '{colors.whiteAlpha.100}',
|
||||
_dark: heroBannerButton?._selected?.background?.[1] || heroBannerButton?._selected?.background?.[0] || '{colors.whiteAlpha.100}',
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -279,11 +279,11 @@ const semanticTokens: ThemingConfig['semanticTokens'] = {
|
||||
},
|
||||
bg: {
|
||||
DEFAULT: { value: '{colors.alert.bg.info}' },
|
||||
info: { value: { _light: '{colors.blue.100}', _dark: '{colors.blue.900}' } },
|
||||
info: { value: { _light: '{colors.gray.100}', _dark: '{colors.gray.800}' } },
|
||||
warning: { value: '{colors.alert.bg.warning}' },
|
||||
success: { value: '{colors.alert.bg.success}' },
|
||||
error: { value: '{colors.alert.bg.error}' },
|
||||
loading: { value: { _light: '{colors.blue.100}', _dark: '{colors.blue.900}' } },
|
||||
loading: { value: { _light: '{colors.gray.100}', _dark: '{colors.gray.800}' } },
|
||||
},
|
||||
},
|
||||
input: {
|
||||
@@ -368,11 +368,11 @@ const semanticTokens: ThemingConfig['semanticTokens'] = {
|
||||
fg: { value: { _light: '{colors.red.500}', _dark: '{colors.red.200}' } },
|
||||
},
|
||||
purple: {
|
||||
bg: { value: { _light: '{colors.purple.50}', _dark: '{colors.purple.800}' } },
|
||||
fg: { value: { _light: '{colors.purple.500}', _dark: '{colors.purple.100}' } },
|
||||
bg: { value: { _light: '{colors.gray.100}', _dark: '{colors.gray.800}' } },
|
||||
fg: { value: { _light: '{colors.gray.600}', _dark: '{colors.gray.300}' } },
|
||||
},
|
||||
purple_alt: {
|
||||
bg: { value: { _light: '{colors.purple.100}', _dark: '{colors.purple.800}' } },
|
||||
bg: { value: { _light: '{colors.gray.100}', _dark: '{colors.gray.800}' } },
|
||||
fg: { value: { _light: '{colors.blackAlpha.800}', _dark: '{colors.whiteAlpha.800}' } },
|
||||
},
|
||||
orange: {
|
||||
@@ -380,11 +380,11 @@ const semanticTokens: ThemingConfig['semanticTokens'] = {
|
||||
fg: { value: { _light: '{colors.orange.500}', _dark: '{colors.orange.100}' } },
|
||||
},
|
||||
blue: {
|
||||
bg: { value: { _light: '{colors.blue.50}', _dark: '{colors.blue.800}' } },
|
||||
fg: { value: { _light: '{colors.blue.500}', _dark: '{colors.blue.100}' } },
|
||||
bg: { value: { _light: '{colors.gray.100}', _dark: '{colors.gray.800}' } },
|
||||
fg: { value: { _light: '{colors.gray.600}', _dark: '{colors.gray.300}' } },
|
||||
},
|
||||
blue_alt: {
|
||||
bg: { value: { _light: '{colors.blue.50}', _dark: '{colors.blue.800}' } },
|
||||
bg: { value: { _light: '{colors.gray.100}', _dark: '{colors.gray.800}' } },
|
||||
fg: { value: { _light: '{colors.blackAlpha.800}', _dark: '{colors.whiteAlpha.800}' } },
|
||||
},
|
||||
yellow: {
|
||||
@@ -392,12 +392,12 @@ const semanticTokens: ThemingConfig['semanticTokens'] = {
|
||||
fg: { value: { _light: '{colors.yellow.500}', _dark: '{colors.yellow.100}' } },
|
||||
},
|
||||
teal: {
|
||||
bg: { value: { _light: '{colors.teal.50}', _dark: '{colors.teal.800}' } },
|
||||
fg: { value: { _light: '{colors.teal.500}', _dark: '{colors.teal.100}' } },
|
||||
bg: { value: { _light: '{colors.gray.100}', _dark: '{colors.gray.800}' } },
|
||||
fg: { value: { _light: '{colors.gray.600}', _dark: '{colors.gray.300}' } },
|
||||
},
|
||||
cyan: {
|
||||
bg: { value: { _light: '{colors.cyan.50}', _dark: '{colors.cyan.800}' } },
|
||||
fg: { value: { _light: '{colors.cyan.500}', _dark: '{colors.cyan.100}' } },
|
||||
bg: { value: { _light: '{colors.gray.100}', _dark: '{colors.gray.800}' } },
|
||||
fg: { value: { _light: '{colors.gray.600}', _dark: '{colors.gray.300}' } },
|
||||
},
|
||||
pink: {
|
||||
bg: { value: { _light: '{colors.pink.50}', _dark: '{colors.pink.800}' } },
|
||||
@@ -418,28 +418,28 @@ const semanticTokens: ThemingConfig['semanticTokens'] = {
|
||||
fg: { value: { _light: '{colors.red.600}', _dark: '{colors.red.200}' } },
|
||||
},
|
||||
blue: {
|
||||
bg: { value: { _light: '{colors.blue.100}', _dark: '{colors.blue.800}' } },
|
||||
fg: { value: { _light: '{colors.blue.600}', _dark: '{colors.blue.200}' } },
|
||||
bg: { value: { _light: '{colors.gray.200}', _dark: '{colors.gray.700}' } },
|
||||
fg: { value: { _light: '{colors.gray.700}', _dark: '{colors.gray.200}' } },
|
||||
},
|
||||
yellow: {
|
||||
bg: { value: { _light: '{colors.yellow.100}', _dark: '{colors.yellow.800}' } },
|
||||
fg: { value: { _light: '{colors.yellow.600}', _dark: '{colors.yellow.200}' } },
|
||||
},
|
||||
teal: {
|
||||
bg: { value: { _light: '{colors.teal.100}', _dark: '{colors.teal.800}' } },
|
||||
fg: { value: { _light: '{colors.teal.600}', _dark: '{colors.teal.200}' } },
|
||||
bg: { value: { _light: '{colors.gray.200}', _dark: '{colors.gray.700}' } },
|
||||
fg: { value: { _light: '{colors.gray.700}', _dark: '{colors.gray.200}' } },
|
||||
},
|
||||
cyan: {
|
||||
bg: { value: { _light: '{colors.cyan.100}', _dark: '{colors.cyan.800}' } },
|
||||
fg: { value: { _light: '{colors.cyan.600}', _dark: '{colors.cyan.200}' } },
|
||||
bg: { value: { _light: '{colors.gray.200}', _dark: '{colors.gray.700}' } },
|
||||
fg: { value: { _light: '{colors.gray.700}', _dark: '{colors.gray.200}' } },
|
||||
},
|
||||
orange: {
|
||||
bg: { value: { _light: '{colors.orange.100}', _dark: '{colors.orange.600}' } },
|
||||
fg: { value: { _light: '{colors.orange.600}', _dark: '{colors.orange.100}' } },
|
||||
},
|
||||
purple: {
|
||||
bg: { value: { _light: '{colors.purple.50}', _dark: '{colors.purple.600}' } },
|
||||
fg: { value: { _light: '{colors.purple.600}', _dark: '{colors.purple.50}' } },
|
||||
bg: { value: { _light: '{colors.gray.200}', _dark: '{colors.gray.700}' } },
|
||||
fg: { value: { _light: '{colors.gray.700}', _dark: '{colors.gray.200}' } },
|
||||
},
|
||||
pink: {
|
||||
bg: { value: { _light: '{colors.pink.50}', _dark: '{colors.pink.600}' } },
|
||||
@@ -458,7 +458,7 @@ const semanticTokens: ThemingConfig['semanticTokens'] = {
|
||||
fg: { value: { _light: '{colors.blackAlpha.800}', _dark: '{colors.whiteAlpha.800}' } },
|
||||
},
|
||||
filter: {
|
||||
bg: { value: { _light: '{colors.blue.50}', _dark: '{colors.whiteAlpha.200}' } },
|
||||
bg: { value: { _light: '{colors.gray.100}', _dark: '{colors.whiteAlpha.200}' } },
|
||||
},
|
||||
select: {
|
||||
bg: {
|
||||
|
||||
@@ -7,9 +7,12 @@ import config from 'configs/app';
|
||||
export const BODY_TYPEFACE = config.UI.fonts.body?.name ?? 'Geist';
|
||||
export const HEADING_TYPEFACE = config.UI.fonts.heading?.name ?? 'Geist';
|
||||
|
||||
export const MONO_TYPEFACE = 'Geist Mono';
|
||||
|
||||
export const fonts: ExcludeUndefined<ThemingConfig['tokens']>['fonts'] = {
|
||||
heading: { value: `${ HEADING_TYPEFACE }, sans-serif` },
|
||||
body: { value: `${ BODY_TYPEFACE }, sans-serif` },
|
||||
mono: { value: `${ MONO_TYPEFACE }, monospace` },
|
||||
};
|
||||
|
||||
export const textStyles: ThemingConfig['textStyles'] = {
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { Box, Flex } from '@chakra-ui/react';
|
||||
import React from 'react';
|
||||
|
||||
import { Skeleton } from 'toolkit/chakra/skeleton';
|
||||
|
||||
interface ChainRowProps {
|
||||
readonly name: string;
|
||||
readonly fullName?: string;
|
||||
readonly blockchainId?: string;
|
||||
readonly subnetId?: string;
|
||||
readonly vmId?: string;
|
||||
readonly vmLabel?: string;
|
||||
readonly chainId?: number | null;
|
||||
readonly isActive?: boolean;
|
||||
readonly isLoading?: boolean;
|
||||
}
|
||||
|
||||
const TRUNCATE_PREFIX_LEN = 8;
|
||||
const TRUNCATE_SUFFIX_LEN = 4;
|
||||
|
||||
function truncateId(id: string): string {
|
||||
if (id.length <= TRUNCATE_PREFIX_LEN + TRUNCATE_SUFFIX_LEN + 3) {
|
||||
return id;
|
||||
}
|
||||
return `${ id.slice(0, TRUNCATE_PREFIX_LEN) }...${ id.slice(-TRUNCATE_SUFFIX_LEN) }`;
|
||||
}
|
||||
|
||||
const ChainRow = ({
|
||||
name,
|
||||
fullName,
|
||||
blockchainId,
|
||||
subnetId,
|
||||
vmLabel,
|
||||
chainId,
|
||||
isActive = true,
|
||||
isLoading = false,
|
||||
}: ChainRowProps) => {
|
||||
return (
|
||||
<Skeleton loading={ isLoading }>
|
||||
<Flex
|
||||
alignItems="center"
|
||||
py={ 3 }
|
||||
px={ 4 }
|
||||
borderBottom="1px solid"
|
||||
borderColor="border.divider"
|
||||
_hover={{ bg: { _light: 'gray.50', _dark: 'whiteAlpha.50' } }}
|
||||
transition="background 0.15s"
|
||||
gap={ 4 }
|
||||
flexWrap={{ base: 'wrap', lg: 'nowrap' }}
|
||||
>
|
||||
{ /* Name column */ }
|
||||
<Flex
|
||||
direction="column"
|
||||
minW={{ base: '100%', lg: '180px' }}
|
||||
maxW={{ base: '100%', lg: '220px' }}
|
||||
flexShrink={ 0 }
|
||||
>
|
||||
<Box
|
||||
fontWeight="600"
|
||||
fontSize="sm"
|
||||
color="text.primary"
|
||||
>
|
||||
{ name }
|
||||
</Box>
|
||||
{ fullName && (
|
||||
<Box
|
||||
fontSize="xs"
|
||||
color="text.secondary"
|
||||
mt={ 0.5 }
|
||||
>
|
||||
{ fullName }
|
||||
</Box>
|
||||
) }
|
||||
</Flex>
|
||||
|
||||
{ /* Blockchain ID column */ }
|
||||
<Box
|
||||
flex={ 1 }
|
||||
minW={ 0 }
|
||||
fontFamily="mono"
|
||||
fontSize="sm"
|
||||
color="text.secondary"
|
||||
title={ blockchainId }
|
||||
overflow="hidden"
|
||||
textOverflow="ellipsis"
|
||||
whiteSpace="nowrap"
|
||||
>
|
||||
{ blockchainId ? truncateId(blockchainId) : '\u2014' }
|
||||
</Box>
|
||||
|
||||
{ /* Subnet ID column */ }
|
||||
<Box
|
||||
flex={ 1 }
|
||||
minW={ 0 }
|
||||
fontFamily="mono"
|
||||
fontSize="sm"
|
||||
color="text.secondary"
|
||||
title={ subnetId }
|
||||
overflow="hidden"
|
||||
textOverflow="ellipsis"
|
||||
whiteSpace="nowrap"
|
||||
display={{ base: 'none', lg: 'block' }}
|
||||
>
|
||||
{ subnetId ? truncateId(subnetId) : '\u2014' }
|
||||
</Box>
|
||||
|
||||
{ /* VM badge */ }
|
||||
<Flex alignItems="center" gap={ 2 } flexShrink={ 0 }>
|
||||
{ vmLabel && (
|
||||
<Box
|
||||
bgColor={{ _light: 'gray.100', _dark: 'whiteAlpha.100' }}
|
||||
color="text.secondary"
|
||||
borderRadius="sm"
|
||||
px={ 2 }
|
||||
py={ 0.5 }
|
||||
fontSize="xs"
|
||||
fontFamily="mono"
|
||||
whiteSpace="nowrap"
|
||||
>
|
||||
{ vmLabel }
|
||||
</Box>
|
||||
) }
|
||||
{ chainId != null && (
|
||||
<Box
|
||||
bgColor={{ _light: 'gray.100', _dark: 'whiteAlpha.100' }}
|
||||
color="text.secondary"
|
||||
borderRadius="sm"
|
||||
px={ 2 }
|
||||
py={ 0.5 }
|
||||
fontSize="xs"
|
||||
fontFamily="mono"
|
||||
whiteSpace="nowrap"
|
||||
>
|
||||
{ chainId }
|
||||
</Box>
|
||||
) }
|
||||
</Flex>
|
||||
|
||||
{ /* Status indicator */ }
|
||||
<Flex alignItems="center" flexShrink={ 0 } ml={{ base: 0, lg: 'auto' }}>
|
||||
<Box
|
||||
bgColor={ isActive ? 'green.400' : 'gray.400' }
|
||||
borderRadius="full"
|
||||
boxSize="8px"
|
||||
/>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Skeleton>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(ChainRow);
|
||||
@@ -0,0 +1,261 @@
|
||||
import { Box, Flex } from '@chakra-ui/react';
|
||||
import React from 'react';
|
||||
|
||||
import type { PChainBlockchain } from 'lib/api/pchain';
|
||||
import { useBlockchains } from 'lib/api/pchain';
|
||||
import { Skeleton } from 'toolkit/chakra/skeleton';
|
||||
import PageTitle from 'ui/shared/Page/PageTitle';
|
||||
|
||||
import ChainRow from './ChainRow';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PRIMARY_NETWORK_ID = '11111111111111111111111111111111LpoYY' as const;
|
||||
|
||||
const PRIMARY_CHAINS = [
|
||||
{ name: 'C-Chain', fullName: 'Contract Chain', vm: 'EVM', chainId: 96369 },
|
||||
{ name: 'P-Chain', fullName: 'Platform Chain', vm: 'PVM', chainId: null },
|
||||
{ name: 'X-Chain', fullName: 'Exchange Chain', vm: 'AVM', chainId: null },
|
||||
{ name: 'A-Chain', fullName: 'AI Chain', vm: 'AVM', chainId: null },
|
||||
{ name: 'B-Chain', fullName: 'Bridge Chain', vm: 'BVM', chainId: null },
|
||||
{ name: 'Q-Chain', fullName: 'Quantum Chain', vm: 'QVM', chainId: null },
|
||||
{ name: 'T-Chain', fullName: 'Teleport Chain', vm: 'MPC', chainId: null },
|
||||
{ name: 'Z-Chain', fullName: 'ZK Chain', vm: 'ZVM', chainId: null },
|
||||
{ name: 'K-Chain', fullName: 'KMS Chain', vm: 'KVM', chainId: null },
|
||||
] as const;
|
||||
|
||||
const SUBNET_CHAIN_IDS: Readonly<Record<string, number>> = {
|
||||
Zoo: 200200,
|
||||
Hanzo: 36963,
|
||||
SPC: 36911,
|
||||
Pars: 494949,
|
||||
};
|
||||
|
||||
const TAB_IDS = {
|
||||
primary: 'primary',
|
||||
subnets: 'subnets',
|
||||
} as const;
|
||||
|
||||
type TabId = typeof TAB_IDS[keyof typeof TAB_IDS];
|
||||
|
||||
const EMPTY_L1_CHAINS: ReadonlyArray<PChainBlockchain> = [];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Table header
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface TableHeaderProps {
|
||||
readonly showSubnetId: boolean;
|
||||
}
|
||||
|
||||
const TableHeader = ({ showSubnetId }: TableHeaderProps) => (
|
||||
<Flex
|
||||
px={ 4 }
|
||||
py={ 2 }
|
||||
gap={ 4 }
|
||||
borderBottom="1px solid"
|
||||
borderColor="border.divider"
|
||||
display={{ base: 'none', lg: 'flex' }}
|
||||
>
|
||||
<Box
|
||||
minW="180px"
|
||||
maxW="220px"
|
||||
flexShrink={ 0 }
|
||||
color="text.secondary"
|
||||
fontWeight="600"
|
||||
fontSize="xs"
|
||||
textTransform="uppercase"
|
||||
letterSpacing="wider"
|
||||
>
|
||||
Chain
|
||||
</Box>
|
||||
<Box flex={ 1 } color="text.secondary" fontWeight="600" fontSize="xs" textTransform="uppercase" letterSpacing="wider">
|
||||
Blockchain ID
|
||||
</Box>
|
||||
{ showSubnetId && (
|
||||
<Box flex={ 1 } color="text.secondary" fontWeight="600" fontSize="xs" textTransform="uppercase" letterSpacing="wider">
|
||||
Subnet ID
|
||||
</Box>
|
||||
) }
|
||||
<Box flexShrink={ 0 } w="120px" color="text.secondary" fontWeight="600" fontSize="xs" textTransform="uppercase" letterSpacing="wider">
|
||||
VM
|
||||
</Box>
|
||||
<Box flexShrink={ 0 } color="text.secondary" fontWeight="600" fontSize="xs" textTransform="uppercase" letterSpacing="wider" ml="auto">
|
||||
Status
|
||||
</Box>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tab button
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface TabButtonProps {
|
||||
readonly label: string;
|
||||
readonly isActive: boolean;
|
||||
readonly onClick: () => void;
|
||||
}
|
||||
|
||||
const TabButton = ({ label, isActive, onClick }: TabButtonProps) => (
|
||||
<Box
|
||||
as="button"
|
||||
px={ 4 }
|
||||
py={ 2 }
|
||||
fontSize="sm"
|
||||
fontWeight={ isActive ? '600' : '400' }
|
||||
color={ isActive ? 'text.primary' : 'text.secondary' }
|
||||
borderBottom="2px solid"
|
||||
borderColor={ isActive ? 'text.primary' : 'transparent' }
|
||||
bg="transparent"
|
||||
cursor="pointer"
|
||||
transition="all 0.15s"
|
||||
_hover={{ color: 'text.primary' }}
|
||||
onClick={ onClick }
|
||||
>
|
||||
{ label }
|
||||
</Box>
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ChainsPage = () => {
|
||||
const [ activeTab, setActiveTab ] = React.useState<TabId>(TAB_IDS.primary);
|
||||
const { blockchains, isLoading } = useBlockchains();
|
||||
|
||||
const l1Chains = React.useMemo<ReadonlyArray<PChainBlockchain>>(() => {
|
||||
if (!blockchains.length) {
|
||||
return EMPTY_L1_CHAINS;
|
||||
}
|
||||
return blockchains.filter((chain) => chain.subnetID !== PRIMARY_NETWORK_ID);
|
||||
}, [ blockchains ]);
|
||||
|
||||
const handlePrimaryClick = React.useCallback(() => {
|
||||
setActiveTab(TAB_IDS.primary);
|
||||
}, []);
|
||||
|
||||
const handleSubnetsClick = React.useCallback(() => {
|
||||
setActiveTab(TAB_IDS.subnets);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle
|
||||
title="Chains"
|
||||
secondRow={ (
|
||||
<Box fontSize="sm" color="text.secondary">
|
||||
All blockchains on the Lux Network
|
||||
</Box>
|
||||
) }
|
||||
/>
|
||||
|
||||
{ /* Tabs */ }
|
||||
<Flex
|
||||
borderBottom="1px solid"
|
||||
borderColor="border.divider"
|
||||
mb={ 4 }
|
||||
gap={ 0 }
|
||||
>
|
||||
<TabButton
|
||||
label="Primary Network"
|
||||
isActive={ activeTab === TAB_IDS.primary }
|
||||
onClick={ handlePrimaryClick }
|
||||
/>
|
||||
<TabButton
|
||||
label={ `L1 / L2 / L3${ l1Chains.length > 0 ? ` (${ l1Chains.length })` : '' }` }
|
||||
isActive={ activeTab === TAB_IDS.subnets }
|
||||
onClick={ handleSubnetsClick }
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
{ /* Primary Network tab */ }
|
||||
{ activeTab === TAB_IDS.primary && (
|
||||
<Box
|
||||
border="1px solid"
|
||||
borderColor="border.divider"
|
||||
borderRadius="md"
|
||||
overflow="hidden"
|
||||
>
|
||||
<TableHeader showSubnetId={ false }/>
|
||||
{ PRIMARY_CHAINS.map((chain) => (
|
||||
<ChainRow
|
||||
key={ chain.name }
|
||||
name={ chain.name }
|
||||
fullName={ chain.fullName }
|
||||
vmLabel={ chain.vm }
|
||||
chainId={ chain.chainId }
|
||||
isActive
|
||||
/>
|
||||
)) }
|
||||
</Box>
|
||||
) }
|
||||
|
||||
{ /* L1/L2/L3 tab */ }
|
||||
{ activeTab === TAB_IDS.subnets && (
|
||||
<Box
|
||||
border="1px solid"
|
||||
borderColor="border.divider"
|
||||
borderRadius="md"
|
||||
overflow="hidden"
|
||||
>
|
||||
<TableHeader showSubnetId/>
|
||||
{ isLoading && (
|
||||
<Box px={ 4 } py={ 6 }>
|
||||
<Skeleton loading={ true } h="20px" mb={ 3 }/>
|
||||
<Skeleton loading={ true } h="20px" mb={ 3 }/>
|
||||
<Skeleton loading={ true } h="20px" mb={ 3 }/>
|
||||
<Skeleton loading={ true } h="20px"/>
|
||||
</Box>
|
||||
) }
|
||||
{ !isLoading && l1Chains.length === 0 && (
|
||||
<Box px={ 4 } py={ 8 } textAlign="center" color="text.secondary" fontSize="sm">
|
||||
No L1/L2/L3 chains found
|
||||
</Box>
|
||||
) }
|
||||
{ !isLoading && l1Chains.map((chain) => (
|
||||
<ChainRow
|
||||
key={ chain.id }
|
||||
name={ chain.name }
|
||||
blockchainId={ chain.id }
|
||||
subnetId={ chain.subnetID }
|
||||
vmId={ chain.vmID }
|
||||
vmLabel={ resolveVmLabel(chain.vmID) }
|
||||
chainId={ SUBNET_CHAIN_IDS[chain.name] ?? null }
|
||||
isActive
|
||||
/>
|
||||
)) }
|
||||
</Box>
|
||||
) }
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const KNOWN_VM_IDS: Readonly<Record<string, string>> = {
|
||||
mgj786NP7uDwBCcq6YwThhaN8FLyybkCa4zBWTQbNgmK6k9A6: 'EVM',
|
||||
ag3GReYPNuSR17rUP8acMdZipQBikdXNRKDyFszAysmy3vDXE: 'Subnet EVM',
|
||||
jvYyfQTxGMJLuGWa55kdP2p2zSUYsQ5Raupu4TW34ZAUBAbtq: 'AVM',
|
||||
rWhpuQPF1kb72esV2momhMuTYGkEb1oL29pt2EBXWsBY6MALT: 'PVM',
|
||||
};
|
||||
|
||||
function resolveVmLabel(vmId?: string): string {
|
||||
if (!vmId) {
|
||||
return 'Unknown';
|
||||
}
|
||||
return KNOWN_VM_IDS[vmId] ?? truncateVmId(vmId);
|
||||
}
|
||||
|
||||
function truncateVmId(vmId: string): string {
|
||||
if (vmId.length <= 12) {
|
||||
return vmId;
|
||||
}
|
||||
return `${ vmId.slice(0, 6) }...${ vmId.slice(-4) }`;
|
||||
}
|
||||
|
||||
export default ChainsPage;
|
||||
@@ -11,10 +11,9 @@ import SearchBar from 'ui/snippets/searchBar/SearchBarDesktop';
|
||||
import SearchBarMobile from 'ui/snippets/searchBar/SearchBarMobile';
|
||||
import UserProfileDesktop from 'ui/snippets/user/UserProfileDesktop';
|
||||
|
||||
export const BACKGROUND_DEFAULT =
|
||||
'radial-gradient(103.03% 103.03% at 0% 0%, rgba(183, 148, 244, 0.8) 0%, rgba(0, 163, 196, 0.8) 100%), var(--chakra-colors-blue-400)';
|
||||
export const BACKGROUND_DEFAULT = 'var(--chakra-colors-whiteAlpha-50)';
|
||||
const TEXT_COLOR_DEFAULT = 'white';
|
||||
const BORDER_DEFAULT = 'none';
|
||||
const BORDER_DEFAULT = '1px solid var(--chakra-colors-whiteAlpha-100)';
|
||||
|
||||
const HeroBanner = () => {
|
||||
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
// Network Overview homepage for the Lux multi-chain explorer.
|
||||
// Displays P-chain stats, primary chains, and L1 (subnet) chains.
|
||||
|
||||
import { Box, Flex, Grid, Text } from '@chakra-ui/react';
|
||||
import React from 'react';
|
||||
|
||||
import { useBlockchains, 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';
|
||||
import { Tag } from 'toolkit/chakra/tag';
|
||||
import HeroBanner from 'ui/home/HeroBanner';
|
||||
|
||||
// ── Constants ──
|
||||
|
||||
const PRIMARY_NETWORK_ID = '11111111111111111111111111111111LpoYY';
|
||||
const LUX_DECIMALS = 9;
|
||||
|
||||
const PRIMARY_CHAINS = [
|
||||
{ id: 'C', name: 'C-Chain', fullName: 'Contract Chain', vm: 'EVM', href: '/' },
|
||||
{ id: 'P', name: 'P-Chain', fullName: 'Platform Chain', vm: 'PVM', href: undefined },
|
||||
{ id: 'X', name: 'X-Chain', fullName: 'Exchange Chain', vm: 'AVM', href: undefined },
|
||||
{ id: 'A', name: 'A-Chain', fullName: 'AI Chain', vm: 'AVM', href: undefined },
|
||||
{ id: 'B', name: 'B-Chain', fullName: 'Bridge Chain', vm: 'BVM', href: undefined },
|
||||
{ id: 'Q', name: 'Q-Chain', fullName: 'Quantum Chain', vm: 'QVM', href: undefined },
|
||||
{ id: 'T', name: 'T-Chain', fullName: 'Teleport Chain', vm: 'MPC', href: undefined },
|
||||
{ id: 'Z', name: 'Z-Chain', fullName: 'ZK Chain', vm: 'ZVM', href: undefined },
|
||||
{ id: 'K', name: 'K-Chain', fullName: 'KMS Chain', vm: 'KVM', href: undefined },
|
||||
] as const;
|
||||
|
||||
const STAT_CARD_BG = { _light: 'gray.50', _dark: 'whiteAlpha.50' };
|
||||
const CHAIN_CARD_HOVER_BG = { _light: 'gray.50', _dark: 'whiteAlpha.50' };
|
||||
// ── Helpers ──
|
||||
|
||||
function formatStake(nanoLux: bigint): string {
|
||||
const lux = Number(nanoLux) / Math.pow(10, LUX_DECIMALS);
|
||||
if (lux >= 1_000_000) return `${ (lux / 1_000_000).toFixed(1) }M`;
|
||||
if (lux >= 1_000) return `${ (lux / 1_000).toFixed(1) }K`;
|
||||
return lux.toFixed(0);
|
||||
}
|
||||
|
||||
function formatUptime(uptime: number): string {
|
||||
return `${ uptime.toFixed(1) }%`;
|
||||
}
|
||||
|
||||
// ── Sub-components ──
|
||||
|
||||
interface StatCardProps {
|
||||
readonly label: string;
|
||||
readonly value: string;
|
||||
readonly isLoading: boolean;
|
||||
}
|
||||
|
||||
const StatCard = ({ label, value, isLoading }: StatCardProps) => (
|
||||
<Box
|
||||
p={{ base: 4, lg: 5 }}
|
||||
borderRadius="lg"
|
||||
border="1px solid"
|
||||
borderColor="border.divider"
|
||||
bgColor={ STAT_CARD_BG }
|
||||
>
|
||||
<Text fontSize="sm" color="text.secondary" mb={ 1 }>
|
||||
{ label }
|
||||
</Text>
|
||||
<Skeleton loading={ isLoading }>
|
||||
<Text fontSize={{ base: 'xl', lg: '2xl' }} fontWeight={ 600 } color="text.primary">
|
||||
{ value }
|
||||
</Text>
|
||||
</Skeleton>
|
||||
</Box>
|
||||
);
|
||||
|
||||
interface PrimaryChainCardProps {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly fullName: string;
|
||||
readonly vm: string;
|
||||
readonly href: string | undefined;
|
||||
}
|
||||
|
||||
const PrimaryChainCard = ({ name, fullName, vm, href }: PrimaryChainCardProps) => {
|
||||
const content = (
|
||||
<Flex
|
||||
align="center"
|
||||
justify="space-between"
|
||||
p={ 4 }
|
||||
borderRadius="md"
|
||||
border="1px solid"
|
||||
borderColor="border.divider"
|
||||
cursor={ href ? 'pointer' : 'default' }
|
||||
_hover={ href ? { bg: CHAIN_CARD_HOVER_BG } : undefined }
|
||||
transition="background 0.15s ease"
|
||||
>
|
||||
<Flex align="center" gap={ 3 }>
|
||||
<Box>
|
||||
<Text fontWeight={ 500 } color="text.primary" fontSize="sm">
|
||||
{ name }
|
||||
</Text>
|
||||
<Text fontSize="xs" color="text.secondary">
|
||||
{ fullName }
|
||||
</Text>
|
||||
</Box>
|
||||
</Flex>
|
||||
<Flex align="center" gap={ 2 }>
|
||||
<Tag size="sm" variant="subtle">{ vm }</Tag>
|
||||
{ href && (
|
||||
<Text color="text.secondary" fontSize="sm">
|
||||
{ '\u2192' }
|
||||
</Text>
|
||||
) }
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
<Link href={ href } variant="plain">
|
||||
{ content }
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return content;
|
||||
};
|
||||
|
||||
interface L1ChainCardProps {
|
||||
readonly chain: PChainBlockchain;
|
||||
}
|
||||
|
||||
const L1ChainCard = ({ chain }: L1ChainCardProps) => (
|
||||
<Flex
|
||||
align="center"
|
||||
justify="space-between"
|
||||
p={ 4 }
|
||||
borderRadius="md"
|
||||
border="1px solid"
|
||||
borderColor="border.divider"
|
||||
transition="background 0.15s ease"
|
||||
>
|
||||
<Flex align="center" gap={ 3 }>
|
||||
<Box>
|
||||
<Text fontWeight={ 500 } color="text.primary" fontSize="sm">
|
||||
{ chain.name }
|
||||
</Text>
|
||||
<Text fontSize="xs" color="text.secondary" wordBreak="break-all">
|
||||
{ chain.id.slice(0, 12) }...
|
||||
</Text>
|
||||
</Box>
|
||||
</Flex>
|
||||
<Tag size="sm" variant="subtle">L1</Tag>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
interface L1ChainsListProps {
|
||||
readonly chains: ReadonlyArray<PChainBlockchain>;
|
||||
readonly isLoading: boolean;
|
||||
}
|
||||
|
||||
const SKELETON_PLACEHOLDER_COUNT = 4;
|
||||
|
||||
const L1ChainsList = ({ chains, isLoading }: L1ChainsListProps) => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Flex direction="column" gap={ 2 }>
|
||||
{ Array.from({ length: SKELETON_PLACEHOLDER_COUNT }).map((_, i) => (
|
||||
<Skeleton key={ i } loading={ true } h="68px" borderRadius="md"/>
|
||||
)) }
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
if (chains.length === 0) {
|
||||
return (
|
||||
<Text color="text.secondary" fontSize="sm" py={ 4 }>
|
||||
No L1 chains registered yet.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex direction="column" gap={ 2 }>
|
||||
{ chains.map((chain) => (
|
||||
<L1ChainCard key={ chain.id } chain={ chain }/>
|
||||
)) }
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Main component ──
|
||||
|
||||
const NetworkOverview = () => {
|
||||
const isMobile = useIsMobile();
|
||||
const { stats, isLoading: validatorsLoading } = useCurrentValidators();
|
||||
const { blockchains, isLoading: chainsLoading } = useBlockchains();
|
||||
|
||||
const l1Chains = React.useMemo(
|
||||
() => blockchains.filter((c) => c.subnetID !== PRIMARY_NETWORK_ID),
|
||||
[ blockchains ],
|
||||
);
|
||||
|
||||
const totalChains = React.useMemo(
|
||||
() => PRIMARY_CHAINS.length + l1Chains.length,
|
||||
[ l1Chains.length ],
|
||||
);
|
||||
|
||||
const isLoading = validatorsLoading || chainsLoading;
|
||||
|
||||
return (
|
||||
<Box as="main">
|
||||
<HeroBanner/>
|
||||
|
||||
{ /* Stats bar */ }
|
||||
<Grid
|
||||
templateColumns={{ base: 'repeat(2, 1fr)', lg: 'repeat(4, 1fr)' }}
|
||||
gap={{ base: 2, lg: 3 }}
|
||||
mt={ 6 }
|
||||
>
|
||||
<StatCard
|
||||
label="Total Chains"
|
||||
value={ String(totalChains) }
|
||||
isLoading={ isLoading }
|
||||
/>
|
||||
<StatCard
|
||||
label="Validators"
|
||||
value={ String(stats.validatorCount) }
|
||||
isLoading={ isLoading }
|
||||
/>
|
||||
<StatCard
|
||||
label="Total Stake"
|
||||
value={ `${ formatStake(stats.totalStake) } LUX` }
|
||||
isLoading={ isLoading }
|
||||
/>
|
||||
<StatCard
|
||||
label="Avg Uptime"
|
||||
value={ formatUptime(stats.averageUptime) }
|
||||
isLoading={ isLoading }
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{ /* Chains section: Primary Network + L1 Chains */ }
|
||||
<Grid
|
||||
templateColumns={{ base: '1fr', lg: '1fr 1fr' }}
|
||||
gap={{ base: 6, lg: 8 }}
|
||||
mt={ 8 }
|
||||
>
|
||||
{ /* Primary Network column */ }
|
||||
<Box>
|
||||
<Heading level="2" mb={ 4 }>
|
||||
Primary Network
|
||||
</Heading>
|
||||
<Flex direction="column" gap={ 2 }>
|
||||
{ PRIMARY_CHAINS.map((chain) => (
|
||||
<PrimaryChainCard
|
||||
key={ chain.id }
|
||||
id={ chain.id }
|
||||
name={ chain.name }
|
||||
fullName={ chain.fullName }
|
||||
vm={ chain.vm }
|
||||
href={ chain.href }
|
||||
/>
|
||||
)) }
|
||||
</Flex>
|
||||
</Box>
|
||||
|
||||
{ /* L1 Chains column */ }
|
||||
<Box>
|
||||
<Flex align="center" justify="space-between" mb={ 4 }>
|
||||
<Flex align="center" gap={ 2 }>
|
||||
<Heading level="2">
|
||||
L1 Chains
|
||||
</Heading>
|
||||
<Skeleton loading={ chainsLoading }>
|
||||
<Tag size="sm" variant="subtle">{ l1Chains.length }</Tag>
|
||||
</Skeleton>
|
||||
</Flex>
|
||||
</Flex>
|
||||
<L1ChainsList chains={ l1Chains } isLoading={ chainsLoading }/>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
{ /* Bottom search bar (desktop only) */ }
|
||||
{ !isMobile && (
|
||||
<Box mt={ 10 } mb={ 4 }>
|
||||
<Text color="text.secondary" fontSize="sm" mb={ 3 } textAlign="center">
|
||||
Search by address, transaction hash, block, or token
|
||||
</Text>
|
||||
</Box>
|
||||
) }
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(NetworkOverview);
|
||||
@@ -14,8 +14,8 @@ const LogIndex = ({ children, isLoading, ...props }: Props) => {
|
||||
<Tooltip content="Log index">
|
||||
<Skeleton loading={ isLoading } asChild>
|
||||
<Center
|
||||
color={ isLoading ? 'transparent' : { _light: 'blue.600', _dark: 'gray.50' } }
|
||||
bgColor={ isLoading ? undefined : { _light: 'blue.50', _dark: 'gray.600' } }
|
||||
color={ isLoading ? 'transparent' : { _light: 'gray.600', _dark: 'gray.50' } }
|
||||
bgColor={ isLoading ? undefined : { _light: 'gray.100', _dark: 'gray.600' } }
|
||||
borderRadius="base"
|
||||
px={ 2 }
|
||||
{ ...props }
|
||||
|
||||
@@ -38,11 +38,11 @@ export const light: monaco.editor.IStandaloneThemeData = {
|
||||
'custom.inputOption.hoverBackground': 'rgba(184, 184, 184, 0.31)',
|
||||
|
||||
// don't know the name of this variables in vscode
|
||||
'custom.fileLink.hoverForeground': '#4299E1', // blue.400
|
||||
'custom.fileLink.hoverForeground': '#A0AEC0', // gray.400
|
||||
'custom.riskWarning.primaryBackground': '#FEEBCB', // orange.100
|
||||
'custom.riskWarning.background': '#FFFAF0', // orange.50
|
||||
'custom.mainContract.header': 'rgba(233, 216, 253, 1)', // purple.100
|
||||
'custom.mainContract.body': 'rgba(250, 245, 255, 1)', // purple.50
|
||||
'custom.mainContract.header': 'rgba(226, 232, 240, 1)', // gray.200
|
||||
'custom.mainContract.body': 'rgba(247, 250, 252, 1)', // gray.50
|
||||
} as const,
|
||||
};
|
||||
|
||||
@@ -84,10 +84,10 @@ export const dark: monaco.editor.IStandaloneThemeData = {
|
||||
'custom.inputOption.hoverBackground': 'rgba(90, 93, 94, 0.31)',
|
||||
|
||||
// don't know the name of this variables in vscode
|
||||
'custom.fileLink.hoverForeground': '#4299E1', // blue.400
|
||||
'custom.fileLink.hoverForeground': '#A0AEC0', // gray.400
|
||||
'custom.riskWarning.primaryBackground': 'rgba(246, 173, 85, 0.3)', // orange.300
|
||||
'custom.riskWarning.background': 'rgba(246, 173, 85, 0.1)', // orange.300
|
||||
'custom.mainContract.header': 'rgba(183, 148, 244, 0.3)', // purple.300
|
||||
'custom.mainContract.body': 'rgba(214, 188, 250, 0.1)', // purple.200
|
||||
'custom.mainContract.header': 'rgba(160, 174, 192, 0.3)', // gray.400
|
||||
'custom.mainContract.body': 'rgba(203, 213, 224, 0.1)', // gray.300
|
||||
} as const,
|
||||
};
|
||||
|
||||
@@ -39,7 +39,7 @@ const initReown = () => {
|
||||
},
|
||||
themeVariables: {
|
||||
'--w3m-font-family': `${ BODY_TYPEFACE }, sans-serif`,
|
||||
'--w3m-accent': colors.blue[600].value,
|
||||
'--w3m-accent': colors.gray[600].value,
|
||||
'--w3m-border-radius-master': '2px',
|
||||
'--w3m-z-index': zIndex?.modal2?.value,
|
||||
},
|
||||
|
||||
@@ -71,7 +71,7 @@ const Footer = () => {
|
||||
{
|
||||
icon: 'docs' as const,
|
||||
iconSize: '20px',
|
||||
text: 'Documentation',
|
||||
text: 'Documentation',
|
||||
url: 'https://docs.lux.network',
|
||||
},
|
||||
{
|
||||
@@ -125,7 +125,7 @@ text: 'Documentation',
|
||||
}, []);
|
||||
|
||||
const renderProjectInfo = React.useCallback((gridArea?: GridProps['gridArea']) => {
|
||||
const logoColor = { base: 'blue.600', _dark: 'white' };
|
||||
const logoColor = { base: 'gray.600', _dark: 'white' };
|
||||
|
||||
return (
|
||||
<Box gridArea={ gridArea }>
|
||||
|
||||
@@ -49,7 +49,7 @@ const NavigationMobile = ({ onNavLinkClick, isMarketplaceAppPage }: Props) => {
|
||||
|
||||
const isAuth = useIsAuth();
|
||||
|
||||
const iconColor = useColorModeValue('blue.600', 'blue.300');
|
||||
const iconColor = useColorModeValue('gray.600', 'gray.300');
|
||||
|
||||
const openedItem = mainNavItems[openedGroupIndex];
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ const SearchBarRecentKeywords = ({ onClick, onClear }: Props) => {
|
||||
borderBottomWidth: '0',
|
||||
}}
|
||||
_hover={{
|
||||
bgColor: { _light: 'blue.50', _dark: 'gray.800' },
|
||||
bgColor: { _light: 'gray.100', _dark: 'gray.800' },
|
||||
}}
|
||||
fontSize="sm"
|
||||
_first={{
|
||||
|
||||
@@ -22,7 +22,7 @@ const SearchBarSuggestItemLink = React.forwardRef<HTMLAnchorElement, Props>(({ c
|
||||
borderBottomWidth: '0',
|
||||
}}
|
||||
_hover={{
|
||||
bgColor: { _light: 'blue.50', _dark: 'gray.800' },
|
||||
bgColor: { _light: 'gray.100', _dark: 'gray.800' },
|
||||
}}
|
||||
textStyle="sm"
|
||||
_first={{
|
||||
|
||||
@@ -13,7 +13,7 @@ interface Props {
|
||||
|
||||
const SettingsSample = ({ label, value, bg, onClick, isActive }: Props) => {
|
||||
const bgColor = { base: 'white', _dark: 'gray.900' };
|
||||
const activeBgColor = { base: 'blue.50', _dark: 'whiteAlpha.100' };
|
||||
const activeBgColor = { base: 'gray.100', _dark: 'whiteAlpha.100' };
|
||||
const activeBorderColor = { base: 'blackAlpha.800', _dark: 'gray.50' };
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import { Box, Flex } from '@chakra-ui/react';
|
||||
import React from 'react';
|
||||
|
||||
import type { PChainDelegator, PChainValidator } from 'lib/api/pchain';
|
||||
import dayjs from 'lib/date/dayjs';
|
||||
import { Skeleton } from 'toolkit/chakra/skeleton';
|
||||
|
||||
import { formatStake, truncateNodeId } from './utils';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface FlatDelegator extends PChainDelegator {
|
||||
readonly validatorNodeID: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const TXID_PREFIX_LEN = 10;
|
||||
const TXID_SUFFIX_LEN = 6;
|
||||
|
||||
function truncateTxId(txId: string): string {
|
||||
const minLength = TXID_PREFIX_LEN + TXID_SUFFIX_LEN + 3;
|
||||
if (txId.length <= minLength) {
|
||||
return txId;
|
||||
}
|
||||
return `${ txId.slice(0, TXID_PREFIX_LEN) }...${ txId.slice(-TXID_SUFFIX_LEN) }`;
|
||||
}
|
||||
|
||||
function formatTimestamp(unixSeconds: string): string {
|
||||
const ts = Number(unixSeconds);
|
||||
if (ts === 0) {
|
||||
return '\u2014';
|
||||
}
|
||||
return dayjs.unix(ts).format('lll');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface DelegatorsListProps {
|
||||
readonly validators: ReadonlyArray<PChainValidator>;
|
||||
readonly isLoading: boolean;
|
||||
}
|
||||
|
||||
const DelegatorsList = ({ validators, isLoading }: DelegatorsListProps) => {
|
||||
const delegators = React.useMemo<ReadonlyArray<FlatDelegator>>(() => {
|
||||
const result: Array<FlatDelegator> = [];
|
||||
for (const v of validators) {
|
||||
if (v.delegators) {
|
||||
for (const d of v.delegators) {
|
||||
result.push({ ...d, validatorNodeID: v.nodeID });
|
||||
}
|
||||
}
|
||||
}
|
||||
// Sort by stake descending
|
||||
result.sort((a, b) => {
|
||||
const aStake = BigInt(a.stakeAmount);
|
||||
const bStake = BigInt(b.stakeAmount);
|
||||
if (bStake > aStake) return 1;
|
||||
if (bStake < aStake) return -1;
|
||||
return 0;
|
||||
});
|
||||
return result;
|
||||
}, [ validators ]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
border="1px solid"
|
||||
borderColor="border.divider"
|
||||
borderRadius="lg"
|
||||
overflow="hidden"
|
||||
>
|
||||
{ /* Header */ }
|
||||
<Flex
|
||||
px={ 4 }
|
||||
py={ 2 }
|
||||
gap={ 4 }
|
||||
borderBottom="1px solid"
|
||||
borderColor="border.divider"
|
||||
display={{ base: 'none', lg: 'flex' }}
|
||||
>
|
||||
<Box w="180px" flexShrink={ 0 } color="text.secondary" fontWeight="600" fontSize="xs" textTransform="uppercase" letterSpacing="wider">
|
||||
TX ID
|
||||
</Box>
|
||||
<Box w="260px" flexShrink={ 0 } color="text.secondary" fontWeight="600" fontSize="xs" textTransform="uppercase" letterSpacing="wider">
|
||||
Node ID
|
||||
</Box>
|
||||
<Box flex={ 1 } color="text.secondary" fontWeight="600" fontSize="xs" textTransform="uppercase" letterSpacing="wider" textAlign="right">
|
||||
Stake Amount
|
||||
</Box>
|
||||
<Box w="180px" flexShrink={ 0 } color="text.secondary" fontWeight="600" fontSize="xs" textTransform="uppercase" letterSpacing="wider">
|
||||
Start
|
||||
</Box>
|
||||
<Box w="180px" flexShrink={ 0 } color="text.secondary" fontWeight="600" fontSize="xs" textTransform="uppercase" letterSpacing="wider">
|
||||
End
|
||||
</Box>
|
||||
<Box w="120px" flexShrink={ 0 } color="text.secondary" fontWeight="600" fontSize="xs" textTransform="uppercase" letterSpacing="wider" textAlign="right">
|
||||
Potential Reward
|
||||
</Box>
|
||||
</Flex>
|
||||
|
||||
{ /* Loading */ }
|
||||
{ isLoading && (
|
||||
<Box px={ 4 } py={ 6 }>
|
||||
<Skeleton loading h="16px" mb={ 3 }/>
|
||||
<Skeleton loading h="16px" mb={ 3 }/>
|
||||
<Skeleton loading h="16px"/>
|
||||
</Box>
|
||||
) }
|
||||
|
||||
{ /* Empty state */ }
|
||||
{ !isLoading && delegators.length === 0 && (
|
||||
<Box px={ 4 } py={ 8 } textAlign="center" color="text.secondary" fontSize="sm">
|
||||
No delegators found
|
||||
</Box>
|
||||
) }
|
||||
|
||||
{ /* Rows */ }
|
||||
{ !isLoading && delegators.map((d) => (
|
||||
<Flex
|
||||
key={ `${ d.txID }-${ d.validatorNodeID }` }
|
||||
px={ 4 }
|
||||
py={ 3 }
|
||||
gap={ 4 }
|
||||
borderBottom="1px solid"
|
||||
borderColor="border.divider"
|
||||
alignItems="center"
|
||||
_hover={{ bg: { _light: 'gray.50', _dark: 'whiteAlpha.50' } }}
|
||||
transition="background 0.15s"
|
||||
flexWrap={{ base: 'wrap', lg: 'nowrap' }}
|
||||
>
|
||||
<Box
|
||||
w={{ base: '100%', lg: '180px' }}
|
||||
flexShrink={ 0 }
|
||||
fontFamily="mono"
|
||||
fontSize="sm"
|
||||
color="text.primary"
|
||||
title={ d.txID }
|
||||
overflow="hidden"
|
||||
textOverflow="ellipsis"
|
||||
whiteSpace="nowrap"
|
||||
>
|
||||
{ truncateTxId(d.txID) }
|
||||
</Box>
|
||||
<Box
|
||||
w={{ base: '100%', lg: '260px' }}
|
||||
flexShrink={ 0 }
|
||||
fontFamily="mono"
|
||||
fontSize="sm"
|
||||
color="text.secondary"
|
||||
title={ d.validatorNodeID }
|
||||
overflow="hidden"
|
||||
textOverflow="ellipsis"
|
||||
whiteSpace="nowrap"
|
||||
>
|
||||
{ truncateNodeId(d.validatorNodeID) }
|
||||
</Box>
|
||||
<Box flex={ 1 } fontSize="sm" color="text.primary" textAlign={{ base: 'left', lg: 'right' }}>
|
||||
{ formatStake(d.stakeAmount) } LUX
|
||||
</Box>
|
||||
<Box w={{ base: 'auto', lg: '180px' }} flexShrink={ 0 } fontSize="xs" color="text.secondary">
|
||||
{ formatTimestamp(d.startTime) }
|
||||
</Box>
|
||||
<Box w={{ base: 'auto', lg: '180px' }} flexShrink={ 0 } fontSize="xs" color="text.secondary">
|
||||
{ formatTimestamp(d.endTime) }
|
||||
</Box>
|
||||
<Box w={{ base: 'auto', lg: '120px' }} flexShrink={ 0 } fontSize="sm" color="text.secondary" textAlign={{ base: 'left', lg: 'right' }}>
|
||||
{ formatStake(d.potentialReward) } LUX
|
||||
</Box>
|
||||
</Flex>
|
||||
)) }
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(DelegatorsList);
|
||||
@@ -0,0 +1,255 @@
|
||||
import { Box, Flex } from '@chakra-ui/react';
|
||||
import React from 'react';
|
||||
|
||||
import type { PChainValidator, ValidatorStats } from 'lib/api/pchain';
|
||||
import { Skeleton } from 'toolkit/chakra/skeleton';
|
||||
|
||||
import { formatStake, formatUptime, truncateNodeId } from './utils';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const TOP_VALIDATORS_COUNT = 20;
|
||||
const PERCENTAGE_SCALE = 100;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stat card
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface StatCardProps {
|
||||
readonly label: string;
|
||||
readonly value: string;
|
||||
readonly isLoading: boolean;
|
||||
}
|
||||
|
||||
const StatCard = ({ label, value, isLoading }: StatCardProps) => (
|
||||
<Box
|
||||
border="1px solid"
|
||||
borderColor="border.divider"
|
||||
borderRadius="lg"
|
||||
p={ 5 }
|
||||
bgColor={{ _light: 'gray.50', _dark: 'whiteAlpha.50' }}
|
||||
>
|
||||
<Box fontSize="xs" color="text.secondary" fontWeight="600" textTransform="uppercase" letterSpacing="wider" mb={ 1 }>
|
||||
{ label }
|
||||
</Box>
|
||||
<Skeleton loading={ isLoading }>
|
||||
<Box fontSize="2xl" fontWeight="700" color="text.primary">
|
||||
{ value }
|
||||
</Box>
|
||||
</Skeleton>
|
||||
</Box>
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stake breakdown
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface StakeBreakdownProps {
|
||||
readonly stats: ValidatorStats;
|
||||
readonly isLoading: boolean;
|
||||
}
|
||||
|
||||
const StakeBreakdown = ({ stats, isLoading }: StakeBreakdownProps) => {
|
||||
const validatorStake = stats.totalStake - stats.totalDelegatedStake;
|
||||
const totalNumber = Number(stats.totalStake);
|
||||
const validatorPct = totalNumber > 0 ?
|
||||
(Number(validatorStake) / totalNumber * PERCENTAGE_SCALE).toFixed(1) :
|
||||
'0';
|
||||
const delegationPct = totalNumber > 0 ?
|
||||
(Number(stats.totalDelegatedStake) / totalNumber * PERCENTAGE_SCALE).toFixed(1) :
|
||||
'0';
|
||||
|
||||
return (
|
||||
<Box
|
||||
border="1px solid"
|
||||
borderColor="border.divider"
|
||||
borderRadius="lg"
|
||||
p={ 5 }
|
||||
bgColor={{ _light: 'gray.50', _dark: 'whiteAlpha.50' }}
|
||||
>
|
||||
<Box fontSize="xs" color="text.secondary" fontWeight="600" textTransform="uppercase" letterSpacing="wider" mb={ 1 }>
|
||||
Total Stake
|
||||
</Box>
|
||||
<Skeleton loading={ isLoading }>
|
||||
<Box fontSize="2xl" fontWeight="700" color="text.primary" mb={ 3 }>
|
||||
{ formatStake(stats.totalStake) } LUX
|
||||
</Box>
|
||||
</Skeleton>
|
||||
<Skeleton loading={ isLoading }>
|
||||
<Flex gap={ 4 } fontSize="sm" color="text.secondary">
|
||||
<Box>Validators: { formatStake(validatorStake) } LUX ({ validatorPct }%)</Box>
|
||||
<Box>Delegated: { formatStake(stats.totalDelegatedStake) } LUX ({ delegationPct }%)</Box>
|
||||
</Flex>
|
||||
</Skeleton>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Active validators table (top N)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ActiveValidatorsTableProps {
|
||||
readonly validators: ReadonlyArray<PChainValidator>;
|
||||
readonly isLoading: boolean;
|
||||
}
|
||||
|
||||
const ActiveValidatorsTable = ({ validators, isLoading }: ActiveValidatorsTableProps) => {
|
||||
const sorted = React.useMemo(
|
||||
() => [ ...validators ].sort((a, b) => {
|
||||
const aStake = BigInt(a.stakeAmount);
|
||||
const bStake = BigInt(b.stakeAmount);
|
||||
if (bStake > aStake) return 1;
|
||||
if (bStake < aStake) return -1;
|
||||
return 0;
|
||||
}).slice(0, TOP_VALIDATORS_COUNT),
|
||||
[ validators ],
|
||||
);
|
||||
|
||||
return (
|
||||
<Box
|
||||
border="1px solid"
|
||||
borderColor="border.divider"
|
||||
borderRadius="lg"
|
||||
overflow="hidden"
|
||||
>
|
||||
<Box px={ 4 } py={ 3 } fontWeight="600" fontSize="sm" color="text.primary" borderBottom="1px solid" borderColor="border.divider">
|
||||
Active Validators (Top { TOP_VALIDATORS_COUNT })
|
||||
</Box>
|
||||
|
||||
{ /* Header */ }
|
||||
<Flex
|
||||
px={ 4 }
|
||||
py={ 2 }
|
||||
gap={ 4 }
|
||||
borderBottom="1px solid"
|
||||
borderColor="border.divider"
|
||||
display={{ base: 'none', lg: 'flex' }}
|
||||
>
|
||||
<Box w="320px" flexShrink={ 0 } color="text.secondary" fontWeight="600" fontSize="xs" textTransform="uppercase" letterSpacing="wider">
|
||||
Node ID
|
||||
</Box>
|
||||
<Box flex={ 1 } color="text.secondary" fontWeight="600" fontSize="xs" textTransform="uppercase" letterSpacing="wider" textAlign="right">
|
||||
Stake
|
||||
</Box>
|
||||
<Box w="120px" flexShrink={ 0 } color="text.secondary" fontWeight="600" fontSize="xs" textTransform="uppercase" letterSpacing="wider" textAlign="right">
|
||||
Delegation Fee
|
||||
</Box>
|
||||
<Box w="80px" flexShrink={ 0 } color="text.secondary" fontWeight="600" fontSize="xs" textTransform="uppercase" letterSpacing="wider" textAlign="center">
|
||||
Connected
|
||||
</Box>
|
||||
<Box w="80px" flexShrink={ 0 } color="text.secondary" fontWeight="600" fontSize="xs" textTransform="uppercase" letterSpacing="wider" textAlign="right">
|
||||
Uptime
|
||||
</Box>
|
||||
</Flex>
|
||||
|
||||
{ /* Rows */ }
|
||||
{ isLoading && (
|
||||
<Box px={ 4 } py={ 6 }>
|
||||
<Skeleton loading h="16px" mb={ 3 }/>
|
||||
<Skeleton loading h="16px" mb={ 3 }/>
|
||||
<Skeleton loading h="16px"/>
|
||||
</Box>
|
||||
) }
|
||||
|
||||
{ !isLoading && sorted.map((v) => (
|
||||
<Flex
|
||||
key={ v.nodeID }
|
||||
px={ 4 }
|
||||
py={ 3 }
|
||||
gap={ 4 }
|
||||
borderBottom="1px solid"
|
||||
borderColor="border.divider"
|
||||
alignItems="center"
|
||||
_hover={{ bg: { _light: 'gray.50', _dark: 'whiteAlpha.50' } }}
|
||||
transition="background 0.15s"
|
||||
flexWrap={{ base: 'wrap', lg: 'nowrap' }}
|
||||
>
|
||||
<Box
|
||||
w={{ base: '100%', lg: '320px' }}
|
||||
flexShrink={ 0 }
|
||||
fontFamily="mono"
|
||||
fontSize="sm"
|
||||
color="text.primary"
|
||||
title={ v.nodeID }
|
||||
overflow="hidden"
|
||||
textOverflow="ellipsis"
|
||||
whiteSpace="nowrap"
|
||||
>
|
||||
{ truncateNodeId(v.nodeID) }
|
||||
</Box>
|
||||
<Box flex={ 1 } fontSize="sm" color="text.primary" textAlign={{ base: 'left', lg: 'right' }}>
|
||||
{ formatStake(v.stakeAmount) } LUX
|
||||
</Box>
|
||||
<Box w={{ base: 'auto', lg: '120px' }} flexShrink={ 0 } fontSize="sm" color="text.secondary" textAlign={{ base: 'left', lg: 'right' }}>
|
||||
{ v.delegationFee }%
|
||||
</Box>
|
||||
<Flex w={{ base: 'auto', lg: '80px' }} flexShrink={ 0 } justifyContent={{ base: 'flex-start', lg: 'center' }} alignItems="center">
|
||||
<Box
|
||||
bgColor={ v.connected ? 'green.400' : 'red.400' }
|
||||
borderRadius="full"
|
||||
boxSize="8px"
|
||||
/>
|
||||
</Flex>
|
||||
<Box w={{ base: 'auto', lg: '80px' }} flexShrink={ 0 } fontSize="sm" color="text.secondary" textAlign={{ base: 'left', lg: 'right' }}>
|
||||
{ formatUptime(v.uptime) }
|
||||
</Box>
|
||||
</Flex>
|
||||
)) }
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dashboard
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ValidatorsDashboardProps {
|
||||
readonly validators: ReadonlyArray<PChainValidator>;
|
||||
readonly stats: ValidatorStats;
|
||||
readonly isLoading: boolean;
|
||||
}
|
||||
|
||||
const ValidatorsDashboard = ({ validators, stats, isLoading }: ValidatorsDashboardProps) => {
|
||||
return (
|
||||
<Flex direction="column" gap={ 6 }>
|
||||
{ /* Stat cards */ }
|
||||
<Box
|
||||
display="grid"
|
||||
gridTemplateColumns={{ base: '1fr 1fr', lg: 'repeat(4, 1fr)' }}
|
||||
gap={ 3 }
|
||||
>
|
||||
<StatCard
|
||||
label="Validators"
|
||||
value={ stats.validatorCount.toLocaleString() }
|
||||
isLoading={ isLoading }
|
||||
/>
|
||||
<StatCard
|
||||
label="Connected"
|
||||
value={ `${ stats.connectedCount }/${ stats.validatorCount }` }
|
||||
isLoading={ isLoading }
|
||||
/>
|
||||
<StatCard
|
||||
label="Delegators"
|
||||
value={ stats.delegatorCount.toLocaleString() }
|
||||
isLoading={ isLoading }
|
||||
/>
|
||||
<StatCard
|
||||
label="Avg Uptime"
|
||||
value={ `${ stats.averageUptime.toFixed(1) }%` }
|
||||
isLoading={ isLoading }
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{ /* Stake breakdown */ }
|
||||
<StakeBreakdown stats={ stats } isLoading={ isLoading }/>
|
||||
|
||||
{ /* Active validators table */ }
|
||||
<ActiveValidatorsTable validators={ validators } isLoading={ isLoading }/>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(ValidatorsDashboard);
|
||||
@@ -0,0 +1,186 @@
|
||||
import { Box, Flex } from '@chakra-ui/react';
|
||||
import React from 'react';
|
||||
|
||||
import type { PChainValidator } from 'lib/api/pchain';
|
||||
import { Input } from 'toolkit/chakra/input';
|
||||
import { Skeleton } from 'toolkit/chakra/skeleton';
|
||||
|
||||
import { formatStake, formatUptime, truncateNodeId } from './utils';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ValidatorsListProps {
|
||||
readonly validators: ReadonlyArray<PChainValidator>;
|
||||
readonly isLoading: boolean;
|
||||
}
|
||||
|
||||
const ValidatorsList = ({ validators, isLoading }: ValidatorsListProps) => {
|
||||
const [ search, setSearch ] = React.useState('');
|
||||
|
||||
const handleSearchChange = React.useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearch(e.target.value);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Sort by stake desc, then filter by search
|
||||
const filtered = React.useMemo(() => {
|
||||
const sorted = [ ...validators ].sort((a, b) => {
|
||||
const aStake = BigInt(a.stakeAmount);
|
||||
const bStake = BigInt(b.stakeAmount);
|
||||
if (bStake > aStake) return 1;
|
||||
if (bStake < aStake) return -1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
if (!search.trim()) {
|
||||
return sorted;
|
||||
}
|
||||
|
||||
const term = search.trim().toLowerCase();
|
||||
return sorted.filter((v) => v.nodeID.toLowerCase().includes(term));
|
||||
}, [ validators, search ]);
|
||||
|
||||
return (
|
||||
<Flex direction="column" gap={ 4 }>
|
||||
{ /* Search */ }
|
||||
<Box maxW="400px">
|
||||
<Input
|
||||
placeholder="Search by Node ID..."
|
||||
value={ search }
|
||||
onChange={ handleSearchChange }
|
||||
border="1px solid"
|
||||
borderColor="border.divider"
|
||||
borderRadius="md"
|
||||
bgColor="transparent"
|
||||
color="text.primary"
|
||||
size="md"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{ /* Table */ }
|
||||
<Box
|
||||
border="1px solid"
|
||||
borderColor="border.divider"
|
||||
borderRadius="lg"
|
||||
overflow="hidden"
|
||||
>
|
||||
{ /* Header */ }
|
||||
<Flex
|
||||
px={ 4 }
|
||||
py={ 2 }
|
||||
gap={ 4 }
|
||||
borderBottom="1px solid"
|
||||
borderColor="border.divider"
|
||||
display={{ base: 'none', lg: 'flex' }}
|
||||
>
|
||||
<Box w="320px" flexShrink={ 0 } color="text.secondary" fontWeight="600" fontSize="xs" textTransform="uppercase" letterSpacing="wider">
|
||||
Node ID
|
||||
</Box>
|
||||
<Box flex={ 1 } color="text.secondary" fontWeight="600" fontSize="xs" textTransform="uppercase" letterSpacing="wider" textAlign="right">
|
||||
Stake Amount
|
||||
</Box>
|
||||
<Box
|
||||
w="120px" flexShrink={ 0 } color="text.secondary" fontWeight="600"
|
||||
fontSize="xs" textTransform="uppercase" letterSpacing="wider" textAlign="right"
|
||||
>
|
||||
Delegation Fee
|
||||
</Box>
|
||||
<Box
|
||||
w="100px" flexShrink={ 0 } color="text.secondary" fontWeight="600"
|
||||
fontSize="xs" textTransform="uppercase" letterSpacing="wider" textAlign="right"
|
||||
>
|
||||
Delegators
|
||||
</Box>
|
||||
<Box
|
||||
w="80px" flexShrink={ 0 } color="text.secondary" fontWeight="600"
|
||||
fontSize="xs" textTransform="uppercase" letterSpacing="wider" textAlign="center"
|
||||
>
|
||||
Connected
|
||||
</Box>
|
||||
<Box
|
||||
w="80px" flexShrink={ 0 } color="text.secondary" fontWeight="600"
|
||||
fontSize="xs" textTransform="uppercase" letterSpacing="wider" textAlign="right"
|
||||
>
|
||||
Uptime
|
||||
</Box>
|
||||
</Flex>
|
||||
|
||||
{ /* Loading */ }
|
||||
{ isLoading && (
|
||||
<Box px={ 4 } py={ 6 }>
|
||||
<Skeleton loading h="16px" mb={ 3 }/>
|
||||
<Skeleton loading h="16px" mb={ 3 }/>
|
||||
<Skeleton loading h="16px" mb={ 3 }/>
|
||||
<Skeleton loading h="16px"/>
|
||||
</Box>
|
||||
) }
|
||||
|
||||
{ /* Empty state */ }
|
||||
{ !isLoading && filtered.length === 0 && (
|
||||
<Box px={ 4 } py={ 8 } textAlign="center" color="text.secondary" fontSize="sm">
|
||||
{ search.trim() ? 'No validators match your search' : 'No validators found' }
|
||||
</Box>
|
||||
) }
|
||||
|
||||
{ /* Rows */ }
|
||||
{ !isLoading && filtered.map((v) => (
|
||||
<Flex
|
||||
key={ v.nodeID }
|
||||
px={ 4 }
|
||||
py={ 3 }
|
||||
gap={ 4 }
|
||||
borderBottom="1px solid"
|
||||
borderColor="border.divider"
|
||||
alignItems="center"
|
||||
_hover={{ bg: { _light: 'gray.50', _dark: 'whiteAlpha.50' } }}
|
||||
transition="background 0.15s"
|
||||
flexWrap={{ base: 'wrap', lg: 'nowrap' }}
|
||||
>
|
||||
<Box
|
||||
w={{ base: '100%', lg: '320px' }}
|
||||
flexShrink={ 0 }
|
||||
fontFamily="mono"
|
||||
fontSize="sm"
|
||||
color="text.primary"
|
||||
title={ v.nodeID }
|
||||
overflow="hidden"
|
||||
textOverflow="ellipsis"
|
||||
whiteSpace="nowrap"
|
||||
>
|
||||
{ truncateNodeId(v.nodeID) }
|
||||
</Box>
|
||||
<Box flex={ 1 } fontSize="sm" color="text.primary" textAlign={{ base: 'left', lg: 'right' }}>
|
||||
{ formatStake(v.stakeAmount) } LUX
|
||||
</Box>
|
||||
<Box w={{ base: 'auto', lg: '120px' }} flexShrink={ 0 } fontSize="sm" color="text.secondary" textAlign={{ base: 'left', lg: 'right' }}>
|
||||
{ v.delegationFee }%
|
||||
</Box>
|
||||
<Box w={{ base: 'auto', lg: '100px' }} flexShrink={ 0 } fontSize="sm" color="text.secondary" textAlign={{ base: 'left', lg: 'right' }}>
|
||||
{ v.delegators?.length ?? 0 }
|
||||
</Box>
|
||||
<Flex w={{ base: 'auto', lg: '80px' }} flexShrink={ 0 } justifyContent={{ base: 'flex-start', lg: 'center' }} alignItems="center">
|
||||
<Box
|
||||
bgColor={ v.connected ? 'green.400' : 'red.400' }
|
||||
borderRadius="full"
|
||||
boxSize="8px"
|
||||
/>
|
||||
</Flex>
|
||||
<Box w={{ base: 'auto', lg: '80px' }} flexShrink={ 0 } fontSize="sm" color="text.secondary" textAlign={{ base: 'left', lg: 'right' }}>
|
||||
{ formatUptime(v.uptime) }
|
||||
</Box>
|
||||
</Flex>
|
||||
)) }
|
||||
</Box>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(ValidatorsList);
|
||||
@@ -0,0 +1,134 @@
|
||||
import { Box, Flex } from '@chakra-ui/react';
|
||||
import React from 'react';
|
||||
|
||||
import { useCurrentValidators } from 'lib/api/pchain';
|
||||
import PageTitle from 'ui/shared/Page/PageTitle';
|
||||
|
||||
import DelegatorsList from './DelegatorsList';
|
||||
import ValidatorsDashboard from './ValidatorsDashboard';
|
||||
import ValidatorsList from './ValidatorsList';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tab definitions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const TABS = {
|
||||
dashboard: 'dashboard',
|
||||
validators: 'validators',
|
||||
delegators: 'delegators',
|
||||
} as const;
|
||||
|
||||
type TabId = typeof TABS[keyof typeof TABS];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tab button
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface TabButtonProps {
|
||||
readonly label: string;
|
||||
readonly isActive: boolean;
|
||||
readonly onClick: () => void;
|
||||
}
|
||||
|
||||
const TabButton = ({ label, isActive, onClick }: TabButtonProps) => (
|
||||
<Box
|
||||
as="button"
|
||||
px={ 4 }
|
||||
py={ 2 }
|
||||
fontSize="sm"
|
||||
fontWeight={ isActive ? '600' : '400' }
|
||||
color={ isActive ? 'text.primary' : 'text.secondary' }
|
||||
borderBottom="2px solid"
|
||||
borderColor={ isActive ? 'text.primary' : 'transparent' }
|
||||
bg="transparent"
|
||||
cursor="pointer"
|
||||
transition="all 0.15s"
|
||||
_hover={{ color: 'text.primary' }}
|
||||
onClick={ onClick }
|
||||
>
|
||||
{ label }
|
||||
</Box>
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ValidatorsPage = () => {
|
||||
const [ activeTab, setActiveTab ] = React.useState<TabId>(TABS.dashboard);
|
||||
const { validators, stats, isLoading } = useCurrentValidators();
|
||||
|
||||
const handleDashboardClick = React.useCallback(() => {
|
||||
setActiveTab(TABS.dashboard);
|
||||
}, []);
|
||||
|
||||
const handleValidatorsClick = React.useCallback(() => {
|
||||
setActiveTab(TABS.validators);
|
||||
}, []);
|
||||
|
||||
const handleDelegatorsClick = React.useCallback(() => {
|
||||
setActiveTab(TABS.delegators);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle
|
||||
title="Validators"
|
||||
secondRow={ (
|
||||
<Box fontSize="sm" color="text.secondary">
|
||||
P-Chain validators securing the Lux Network
|
||||
</Box>
|
||||
) }
|
||||
/>
|
||||
|
||||
{ /* Tabs */ }
|
||||
<Flex
|
||||
borderBottom="1px solid"
|
||||
borderColor="border.divider"
|
||||
mb={ 6 }
|
||||
gap={ 0 }
|
||||
>
|
||||
<TabButton
|
||||
label="Dashboard"
|
||||
isActive={ activeTab === TABS.dashboard }
|
||||
onClick={ handleDashboardClick }
|
||||
/>
|
||||
<TabButton
|
||||
label={ `Validators (${ stats.validatorCount })` }
|
||||
isActive={ activeTab === TABS.validators }
|
||||
onClick={ handleValidatorsClick }
|
||||
/>
|
||||
<TabButton
|
||||
label={ `Delegators (${ stats.delegatorCount })` }
|
||||
isActive={ activeTab === TABS.delegators }
|
||||
onClick={ handleDelegatorsClick }
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
{ /* Tab content */ }
|
||||
{ activeTab === TABS.dashboard && (
|
||||
<ValidatorsDashboard
|
||||
validators={ validators }
|
||||
stats={ stats }
|
||||
isLoading={ isLoading }
|
||||
/>
|
||||
) }
|
||||
|
||||
{ activeTab === TABS.validators && (
|
||||
<ValidatorsList
|
||||
validators={ validators }
|
||||
isLoading={ isLoading }
|
||||
/>
|
||||
) }
|
||||
|
||||
{ activeTab === TABS.delegators && (
|
||||
<DelegatorsList
|
||||
validators={ validators }
|
||||
isLoading={ isLoading }
|
||||
/>
|
||||
) }
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ValidatorsPage;
|
||||
@@ -0,0 +1,26 @@
|
||||
// Formatting helpers for Lux P-chain validator data.
|
||||
|
||||
const LUX_DECIMALS = 9;
|
||||
const TRUNCATE_PREFIX_LEN = 12;
|
||||
const TRUNCATE_SUFFIX_LEN = 6;
|
||||
const UPTIME_PERCENTAGE_SCALE = 100;
|
||||
const UPTIME_FRACTION_DIGITS = 2;
|
||||
const STAKE_FRACTION_DIGITS = 2;
|
||||
|
||||
export function formatStake(nanoLux: bigint | string): string {
|
||||
const raw = typeof nanoLux === 'string' ? nanoLux : String(nanoLux);
|
||||
const lux = Number(BigInt(raw)) / Math.pow(10, LUX_DECIMALS);
|
||||
return lux.toLocaleString(undefined, { maximumFractionDigits: STAKE_FRACTION_DIGITS });
|
||||
}
|
||||
|
||||
export function formatUptime(uptime: string): string {
|
||||
return `${ (parseFloat(uptime) * UPTIME_PERCENTAGE_SCALE).toFixed(UPTIME_FRACTION_DIGITS) }%`;
|
||||
}
|
||||
|
||||
export function truncateNodeId(nodeId: string): string {
|
||||
const minLength = TRUNCATE_PREFIX_LEN + TRUNCATE_SUFFIX_LEN + 3;
|
||||
if (nodeId.length <= minLength) {
|
||||
return nodeId;
|
||||
}
|
||||
return `${ nodeId.slice(0, TRUNCATE_PREFIX_LEN) }...${ nodeId.slice(-TRUNCATE_SUFFIX_LEN) }`;
|
||||
}
|
||||
Reference in New Issue
Block a user