mirror of
https://github.com/luxfi/explore.git
synced 2026-07-27 05:54:15 +00:00
feat: add AI compute dashboard and DEX orderbook pages
- A-Chain (AI): dashboard with models, providers, attestations tabs - D-Chain (DEX): markets, orderbook, trades, pools tabs - API hooks with mock data (ready for indexer integration) - Navigation items and route registration for /ai and /dex
This commit is contained in:
@@ -39,6 +39,7 @@
|
||||
"allvms",
|
||||
"abkw",
|
||||
"ACMRT",
|
||||
"achain",
|
||||
"adbutler",
|
||||
"addrs",
|
||||
"adsbyslise",
|
||||
@@ -94,6 +95,7 @@
|
||||
"crios",
|
||||
"dappscout",
|
||||
"dbaeumer",
|
||||
"dchain",
|
||||
"deepdao",
|
||||
"defi",
|
||||
"devnet",
|
||||
@@ -183,6 +185,7 @@
|
||||
"okhttp",
|
||||
"opblock",
|
||||
"opengraph",
|
||||
"orderbook",
|
||||
"paych",
|
||||
"pchain",
|
||||
"PCHAIN",
|
||||
@@ -194,6 +197,7 @@
|
||||
"PWDEBUG",
|
||||
"pwstory",
|
||||
"pyftsubset",
|
||||
"Qwen",
|
||||
"qrcode",
|
||||
"rabby",
|
||||
"raleway",
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
// A-Chain (AI Chain) API client.
|
||||
// Uses mock data while the indexer is not yet deployed.
|
||||
// Structured so real API calls can be swapped in by replacing the fetch
|
||||
// functions below.
|
||||
|
||||
import type {
|
||||
AIModel,
|
||||
AIAttestation,
|
||||
AIInferenceResult,
|
||||
AIProvider,
|
||||
} from './types';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock data
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MOCK_MODELS: ReadonlyArray<AIModel> = [
|
||||
{
|
||||
id: 'model-001',
|
||||
name: 'Qwen3-235B',
|
||||
version: '3.0.0',
|
||||
framework: 'PyTorch',
|
||||
modelHash: '0xab12cd34ef56789001234567890abcdef01234567890abcdef01234567890ab',
|
||||
parameters: 235_000_000_000,
|
||||
registeredAt: '2026-02-20T10:30:00Z',
|
||||
provider: '0x1234567890abcdef1234567890abcdef12345678',
|
||||
},
|
||||
{
|
||||
id: 'model-002',
|
||||
name: 'Qwen3-30B-A3B',
|
||||
version: '3.0.0',
|
||||
framework: 'PyTorch',
|
||||
modelHash: '0xcd34ef56789001234567890abcdef01234567890abcdef01234567890abcdef',
|
||||
parameters: 30_000_000_000,
|
||||
registeredAt: '2026-02-19T14:15:00Z',
|
||||
provider: '0x2345678901abcdef2345678901abcdef23456789',
|
||||
},
|
||||
{
|
||||
id: 'model-003',
|
||||
name: 'LuxVision-7B',
|
||||
version: '1.2.0',
|
||||
framework: 'Candle',
|
||||
modelHash: '0xef56789001234567890abcdef01234567890abcdef01234567890abcdef0123',
|
||||
parameters: 7_000_000_000,
|
||||
registeredAt: '2026-02-18T08:45:00Z',
|
||||
provider: '0x3456789012abcdef3456789012abcdef34567890',
|
||||
},
|
||||
{
|
||||
id: 'model-004',
|
||||
name: 'LuxAudio-3B',
|
||||
version: '0.9.1',
|
||||
framework: 'Candle',
|
||||
modelHash: '0x1234567890abcdef01234567890abcdef01234567890abcdef01234567890ab',
|
||||
parameters: 3_000_000_000,
|
||||
registeredAt: '2026-02-17T16:00:00Z',
|
||||
provider: '0x1234567890abcdef1234567890abcdef12345678',
|
||||
},
|
||||
{
|
||||
id: 'model-005',
|
||||
name: 'ZooClassifier-1B',
|
||||
version: '2.1.0',
|
||||
framework: 'PyTorch',
|
||||
modelHash: '0x567890abcdef01234567890abcdef01234567890abcdef01234567890abcdef',
|
||||
parameters: 1_000_000_000,
|
||||
registeredAt: '2026-02-16T12:30:00Z',
|
||||
provider: '0x4567890123abcdef4567890123abcdef45678901',
|
||||
},
|
||||
];
|
||||
|
||||
const MOCK_ATTESTATIONS: ReadonlyArray<AIAttestation> = [
|
||||
{
|
||||
id: 'att-001',
|
||||
type: 'inference',
|
||||
provider: '0x1234567890abcdef1234567890abcdef12345678',
|
||||
modelHash: '0xab12cd34ef56789001234567890abcdef01234567890abcdef01234567890ab',
|
||||
timestamp: '2026-02-26T09:15:00Z',
|
||||
blockHeight: 142,
|
||||
status: 'verified',
|
||||
},
|
||||
{
|
||||
id: 'att-002',
|
||||
type: 'compute',
|
||||
provider: '0x2345678901abcdef2345678901abcdef23456789',
|
||||
modelHash: '0xcd34ef56789001234567890abcdef01234567890abcdef01234567890abcdef',
|
||||
timestamp: '2026-02-26T09:10:00Z',
|
||||
blockHeight: 141,
|
||||
status: 'verified',
|
||||
},
|
||||
{
|
||||
id: 'att-003',
|
||||
type: 'training',
|
||||
provider: '0x3456789012abcdef3456789012abcdef34567890',
|
||||
modelHash: '0xef56789001234567890abcdef01234567890abcdef01234567890abcdef0123',
|
||||
timestamp: '2026-02-26T09:05:00Z',
|
||||
blockHeight: 140,
|
||||
status: 'pending',
|
||||
},
|
||||
{
|
||||
id: 'att-004',
|
||||
type: 'inference',
|
||||
provider: '0x4567890123abcdef4567890123abcdef45678901',
|
||||
modelHash: '0x567890abcdef01234567890abcdef01234567890abcdef01234567890abcdef',
|
||||
timestamp: '2026-02-26T09:00:00Z',
|
||||
blockHeight: 139,
|
||||
status: 'verified',
|
||||
},
|
||||
{
|
||||
id: 'att-005',
|
||||
type: 'compute',
|
||||
provider: '0x1234567890abcdef1234567890abcdef12345678',
|
||||
modelHash: '0x1234567890abcdef01234567890abcdef01234567890abcdef01234567890ab',
|
||||
timestamp: '2026-02-26T08:55:00Z',
|
||||
blockHeight: 138,
|
||||
status: 'rejected',
|
||||
},
|
||||
{
|
||||
id: 'att-006',
|
||||
type: 'inference',
|
||||
provider: '0x2345678901abcdef2345678901abcdef23456789',
|
||||
modelHash: '0xab12cd34ef56789001234567890abcdef01234567890abcdef01234567890ab',
|
||||
timestamp: '2026-02-26T08:50:00Z',
|
||||
blockHeight: 137,
|
||||
status: 'verified',
|
||||
},
|
||||
{
|
||||
id: 'att-007',
|
||||
type: 'training',
|
||||
provider: '0x3456789012abcdef3456789012abcdef34567890',
|
||||
modelHash: '0xcd34ef56789001234567890abcdef01234567890abcdef01234567890abcdef',
|
||||
timestamp: '2026-02-26T08:45:00Z',
|
||||
blockHeight: 136,
|
||||
status: 'verified',
|
||||
},
|
||||
{
|
||||
id: 'att-008',
|
||||
type: 'compute',
|
||||
provider: '0x4567890123abcdef4567890123abcdef45678901',
|
||||
modelHash: '0xef56789001234567890abcdef01234567890abcdef01234567890abcdef0123',
|
||||
timestamp: '2026-02-26T08:40:00Z',
|
||||
blockHeight: 135,
|
||||
status: 'pending',
|
||||
},
|
||||
];
|
||||
|
||||
const MOCK_INFERENCE_RESULTS: ReadonlyArray<AIInferenceResult> = [
|
||||
{
|
||||
id: 'inf-001',
|
||||
modelId: 'model-001',
|
||||
inputHash: '0xaaaa111122223333444455556666777788889999aaaabbbbccccddddeeee0001',
|
||||
outputHash: '0xbbbb111122223333444455556666777788889999aaaabbbbccccddddeeee0001',
|
||||
confidence: 0.97,
|
||||
latencyMs: 245,
|
||||
timestamp: '2026-02-26T09:15:00Z',
|
||||
provider: '0x1234567890abcdef1234567890abcdef12345678',
|
||||
},
|
||||
{
|
||||
id: 'inf-002',
|
||||
modelId: 'model-002',
|
||||
inputHash: '0xaaaa111122223333444455556666777788889999aaaabbbbccccddddeeee0002',
|
||||
outputHash: '0xbbbb111122223333444455556666777788889999aaaabbbbccccddddeeee0002',
|
||||
confidence: 0.94,
|
||||
latencyMs: 180,
|
||||
timestamp: '2026-02-26T09:10:00Z',
|
||||
provider: '0x2345678901abcdef2345678901abcdef23456789',
|
||||
},
|
||||
{
|
||||
id: 'inf-003',
|
||||
modelId: 'model-003',
|
||||
inputHash: '0xaaaa111122223333444455556666777788889999aaaabbbbccccddddeeee0003',
|
||||
outputHash: '0xbbbb111122223333444455556666777788889999aaaabbbbccccddddeeee0003',
|
||||
confidence: 0.91,
|
||||
latencyMs: 120,
|
||||
timestamp: '2026-02-26T09:05:00Z',
|
||||
provider: '0x3456789012abcdef3456789012abcdef34567890',
|
||||
},
|
||||
{
|
||||
id: 'inf-004',
|
||||
modelId: 'model-005',
|
||||
inputHash: '0xaaaa111122223333444455556666777788889999aaaabbbbccccddddeeee0004',
|
||||
outputHash: '0xbbbb111122223333444455556666777788889999aaaabbbbccccddddeeee0004',
|
||||
confidence: 0.88,
|
||||
latencyMs: 95,
|
||||
timestamp: '2026-02-26T09:00:00Z',
|
||||
provider: '0x4567890123abcdef4567890123abcdef45678901',
|
||||
},
|
||||
];
|
||||
|
||||
const MOCK_PROVIDERS: ReadonlyArray<AIProvider> = [
|
||||
{
|
||||
address: '0x1234567890abcdef1234567890abcdef12345678',
|
||||
reputation: 98,
|
||||
capacity: 8,
|
||||
totalJobs: 1247,
|
||||
totalEarnings: '42500000000000000000',
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
address: '0x2345678901abcdef2345678901abcdef23456789',
|
||||
reputation: 95,
|
||||
capacity: 4,
|
||||
totalJobs: 893,
|
||||
totalEarnings: '31200000000000000000',
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
address: '0x3456789012abcdef3456789012abcdef34567890',
|
||||
reputation: 92,
|
||||
capacity: 16,
|
||||
totalJobs: 2156,
|
||||
totalEarnings: '78900000000000000000',
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
address: '0x4567890123abcdef4567890123abcdef45678901',
|
||||
reputation: 87,
|
||||
capacity: 2,
|
||||
totalJobs: 456,
|
||||
totalEarnings: '12800000000000000000',
|
||||
isActive: false,
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fetch functions (swap in real API calls when indexer is deployed)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function fetchModels(): Promise<ReadonlyArray<AIModel>> {
|
||||
// TODO: Replace with real A-chain indexer API call
|
||||
// const response = await fetch(`${getAChainUrl()}/api/v1/models`);
|
||||
// return response.json();
|
||||
return Promise.resolve(MOCK_MODELS);
|
||||
}
|
||||
|
||||
export async function fetchAttestations(): Promise<ReadonlyArray<AIAttestation>> {
|
||||
// TODO: Replace with real A-chain indexer API call
|
||||
return Promise.resolve(MOCK_ATTESTATIONS);
|
||||
}
|
||||
|
||||
export async function fetchInferenceResults(): Promise<ReadonlyArray<AIInferenceResult>> {
|
||||
// TODO: Replace with real A-chain indexer API call
|
||||
return Promise.resolve(MOCK_INFERENCE_RESULTS);
|
||||
}
|
||||
|
||||
export async function fetchProviders(): Promise<ReadonlyArray<AIProvider>> {
|
||||
// TODO: Replace with real A-chain indexer API call
|
||||
return Promise.resolve(MOCK_PROVIDERS);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// A-Chain (AI Chain) API hooks and types.
|
||||
|
||||
export {
|
||||
fetchModels,
|
||||
fetchAttestations,
|
||||
fetchInferenceResults,
|
||||
fetchProviders,
|
||||
} from './client';
|
||||
|
||||
export { useModels } from './useModels';
|
||||
export { useAttestations } from './useAttestations';
|
||||
export { useProviders } from './useProviders';
|
||||
export { useInferenceResults } from './useInferenceResults';
|
||||
|
||||
export type {
|
||||
AIModel,
|
||||
AIAttestation,
|
||||
AIInferenceResult,
|
||||
AIProvider,
|
||||
AIChainStats,
|
||||
} from './types';
|
||||
@@ -0,0 +1,50 @@
|
||||
// A-Chain (AI Chain) type definitions.
|
||||
// These match the Lux indexer A-Chain adapter schema.
|
||||
|
||||
export interface AIModel {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly version: string;
|
||||
readonly framework: string;
|
||||
readonly modelHash: string;
|
||||
readonly parameters: number;
|
||||
readonly registeredAt: string;
|
||||
readonly provider: string;
|
||||
}
|
||||
|
||||
export interface AIAttestation {
|
||||
readonly id: string;
|
||||
readonly type: 'compute' | 'training' | 'inference';
|
||||
readonly provider: string;
|
||||
readonly modelHash: string;
|
||||
readonly timestamp: string;
|
||||
readonly blockHeight: number;
|
||||
readonly status: 'pending' | 'verified' | 'rejected';
|
||||
}
|
||||
|
||||
export interface AIInferenceResult {
|
||||
readonly id: string;
|
||||
readonly modelId: string;
|
||||
readonly inputHash: string;
|
||||
readonly outputHash: string;
|
||||
readonly confidence: number;
|
||||
readonly latencyMs: number;
|
||||
readonly timestamp: string;
|
||||
readonly provider: string;
|
||||
}
|
||||
|
||||
export interface AIProvider {
|
||||
readonly address: string;
|
||||
readonly reputation: number;
|
||||
readonly capacity: number;
|
||||
readonly totalJobs: number;
|
||||
readonly totalEarnings: string;
|
||||
readonly isActive: boolean;
|
||||
}
|
||||
|
||||
export interface AIChainStats {
|
||||
readonly totalModels: number;
|
||||
readonly activeProviders: number;
|
||||
readonly totalAttestations: number;
|
||||
readonly avgLatencyMs: number;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// React Query hook for A-chain AI attestations.
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import React from 'react';
|
||||
|
||||
import type { AIAttestation } from './types';
|
||||
|
||||
import { fetchAttestations } from './client';
|
||||
|
||||
const ATTESTATIONS_STALE_TIME_MS = 30_000;
|
||||
const ATTESTATIONS_QUERY_KEY = 'achain:attestations' as const;
|
||||
const EMPTY_ATTESTATIONS: ReadonlyArray<AIAttestation> = [];
|
||||
|
||||
export function useAttestations() {
|
||||
const query = useQuery({
|
||||
queryKey: [ ATTESTATIONS_QUERY_KEY ],
|
||||
queryFn: fetchAttestations,
|
||||
staleTime: ATTESTATIONS_STALE_TIME_MS,
|
||||
});
|
||||
|
||||
const attestations = React.useMemo(
|
||||
() => query.data ?? EMPTY_ATTESTATIONS,
|
||||
[ query.data ],
|
||||
);
|
||||
|
||||
return {
|
||||
attestations,
|
||||
isLoading: query.isLoading,
|
||||
isError: query.isError,
|
||||
error: query.error,
|
||||
refetch: query.refetch,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// React Query hook for A-chain AI inference results.
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import React from 'react';
|
||||
|
||||
import type { AIInferenceResult } from './types';
|
||||
|
||||
import { fetchInferenceResults } from './client';
|
||||
|
||||
const INFERENCE_STALE_TIME_MS = 30_000;
|
||||
const INFERENCE_QUERY_KEY = 'achain:inferenceResults' as const;
|
||||
const EMPTY_RESULTS: ReadonlyArray<AIInferenceResult> = [];
|
||||
|
||||
export function useInferenceResults() {
|
||||
const query = useQuery({
|
||||
queryKey: [ INFERENCE_QUERY_KEY ],
|
||||
queryFn: fetchInferenceResults,
|
||||
staleTime: INFERENCE_STALE_TIME_MS,
|
||||
});
|
||||
|
||||
const results = React.useMemo(
|
||||
() => query.data ?? EMPTY_RESULTS,
|
||||
[ query.data ],
|
||||
);
|
||||
|
||||
return {
|
||||
results,
|
||||
isLoading: query.isLoading,
|
||||
isError: query.isError,
|
||||
error: query.error,
|
||||
refetch: query.refetch,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// React Query hook for A-chain AI models.
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import React from 'react';
|
||||
|
||||
import type { AIModel } from './types';
|
||||
|
||||
import { fetchModels } from './client';
|
||||
|
||||
const MODELS_STALE_TIME_MS = 60_000;
|
||||
const MODELS_QUERY_KEY = 'achain:models' as const;
|
||||
const EMPTY_MODELS: ReadonlyArray<AIModel> = [];
|
||||
|
||||
export function useModels() {
|
||||
const query = useQuery({
|
||||
queryKey: [ MODELS_QUERY_KEY ],
|
||||
queryFn: fetchModels,
|
||||
staleTime: MODELS_STALE_TIME_MS,
|
||||
});
|
||||
|
||||
const models = React.useMemo(
|
||||
() => query.data ?? EMPTY_MODELS,
|
||||
[ query.data ],
|
||||
);
|
||||
|
||||
return {
|
||||
models,
|
||||
isLoading: query.isLoading,
|
||||
isError: query.isError,
|
||||
error: query.error,
|
||||
refetch: query.refetch,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// React Query hook for A-chain AI compute providers.
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import React from 'react';
|
||||
|
||||
import type { AIProvider } from './types';
|
||||
|
||||
import { fetchProviders } from './client';
|
||||
|
||||
const PROVIDERS_STALE_TIME_MS = 60_000;
|
||||
const PROVIDERS_QUERY_KEY = 'achain:providers' as const;
|
||||
const EMPTY_PROVIDERS: ReadonlyArray<AIProvider> = [];
|
||||
|
||||
export function useProviders() {
|
||||
const query = useQuery({
|
||||
queryKey: [ PROVIDERS_QUERY_KEY ],
|
||||
queryFn: fetchProviders,
|
||||
staleTime: PROVIDERS_STALE_TIME_MS,
|
||||
});
|
||||
|
||||
const providers = React.useMemo(
|
||||
() => query.data ?? EMPTY_PROVIDERS,
|
||||
[ query.data ],
|
||||
);
|
||||
|
||||
return {
|
||||
providers,
|
||||
isLoading: query.isLoading,
|
||||
isError: query.isError,
|
||||
error: query.error,
|
||||
refetch: query.refetch,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// D-Chain (DEX) API hooks and types.
|
||||
|
||||
export { useDexData } from './useDexData';
|
||||
|
||||
export type { UseDexDataResult } from './useDexData';
|
||||
|
||||
export type {
|
||||
DexOrder,
|
||||
DexTrade,
|
||||
DexPool,
|
||||
DexSymbolStats,
|
||||
DexOverviewStats,
|
||||
} from './types';
|
||||
@@ -0,0 +1,55 @@
|
||||
// D-Chain (DEX Chain) API response types for the Lux DexVM indexer.
|
||||
|
||||
export interface DexOrder {
|
||||
readonly id: string;
|
||||
readonly symbol: string;
|
||||
readonly side: 'buy' | 'sell';
|
||||
readonly price: string;
|
||||
readonly quantity: string;
|
||||
readonly filled: string;
|
||||
readonly status: 'open' | 'filled' | 'cancelled' | 'partial';
|
||||
readonly maker: string;
|
||||
readonly timestamp: string;
|
||||
}
|
||||
|
||||
export interface DexTrade {
|
||||
readonly id: string;
|
||||
readonly symbol: string;
|
||||
readonly price: string;
|
||||
readonly quantity: string;
|
||||
readonly buyer: string;
|
||||
readonly seller: string;
|
||||
readonly fee: string;
|
||||
readonly timestamp: string;
|
||||
readonly blockHeight: number;
|
||||
}
|
||||
|
||||
export interface DexPool {
|
||||
readonly id: string;
|
||||
readonly tokenA: string;
|
||||
readonly tokenB: string;
|
||||
readonly reserveA: string;
|
||||
readonly reserveB: string;
|
||||
readonly tvl: string;
|
||||
readonly volume24h: string;
|
||||
readonly fee: string;
|
||||
}
|
||||
|
||||
export interface DexSymbolStats {
|
||||
readonly symbol: string;
|
||||
readonly lastPrice: string;
|
||||
readonly change24h: number;
|
||||
readonly volume24h: string;
|
||||
readonly high24h: string;
|
||||
readonly low24h: string;
|
||||
readonly trades24h: number;
|
||||
}
|
||||
|
||||
// Aggregated DEX statistics
|
||||
|
||||
export interface DexOverviewStats {
|
||||
readonly totalPairs: number;
|
||||
readonly volume24h: string;
|
||||
readonly activeOrders: number;
|
||||
readonly tradesToday: number;
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
// React Query hook for D-Chain DEX data.
|
||||
// Uses mock data until the DexVM indexer API is deployed.
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import React from 'react';
|
||||
|
||||
import type {
|
||||
DexOrder,
|
||||
DexTrade,
|
||||
DexPool,
|
||||
DexSymbolStats,
|
||||
DexOverviewStats,
|
||||
} from './types';
|
||||
|
||||
const DEX_STALE_TIME_MS = 30_000;
|
||||
const DEX_QUERY_KEY = 'dchain:dexData' as const;
|
||||
|
||||
// ── Mock data ──
|
||||
|
||||
/* eslint-disable max-len */
|
||||
const MOCK_SYMBOLS: ReadonlyArray<DexSymbolStats> = [
|
||||
{ symbol: 'LUX/USDT', lastPrice: '24.85', change24h: 3.42, volume24h: '1,245,890', high24h: '25.10', low24h: '23.90', trades24h: 482 },
|
||||
{ symbol: 'LUX/USDC', lastPrice: '24.83', change24h: 3.18, volume24h: '892,340', high24h: '25.05', low24h: '23.88', trades24h: 356 },
|
||||
{ symbol: 'ZOO/LUX', lastPrice: '0.0842', change24h: -1.25, volume24h: '456,120', high24h: '0.0870', low24h: '0.0830', trades24h: 198 },
|
||||
{ symbol: 'HNZ/LUX', lastPrice: '0.152', change24h: 7.04, volume24h: '334,560', high24h: '0.158', low24h: '0.140', trades24h: 145 },
|
||||
{ symbol: 'SPC/LUX', lastPrice: '0.0215', change24h: -0.47, volume24h: '123,450', high24h: '0.0220', low24h: '0.0210', trades24h: 87 },
|
||||
{ symbol: 'PARS/LUX', lastPrice: '0.0034', change24h: 12.50, volume24h: '89,230', high24h: '0.0036', low24h: '0.0030', trades24h: 64 },
|
||||
];
|
||||
|
||||
const MOCK_ORDERS: ReadonlyArray<DexOrder> = [
|
||||
{ id: 'ord-001', symbol: 'LUX/USDT', side: 'buy', price: '24.50', quantity: '100.00', filled: '0.00', status: 'open', maker: '0x1a2b...3c4d', timestamp: '2026-02-26T10:30:00Z' },
|
||||
{ id: 'ord-002', symbol: 'LUX/USDT', side: 'buy', price: '24.45', quantity: '250.00', filled: '50.00', status: 'partial', maker: '0x5e6f...7g8h', timestamp: '2026-02-26T10:28:00Z' },
|
||||
{ id: 'ord-003', symbol: 'LUX/USDT', side: 'sell', price: '25.10', quantity: '75.00', filled: '0.00', status: 'open', maker: '0x9i0j...1k2l', timestamp: '2026-02-26T10:25:00Z' },
|
||||
{ id: 'ord-004', symbol: 'LUX/USDC', side: 'sell', price: '25.05', quantity: '150.00', filled: '150.00', status: 'filled', maker: '0x3m4n...5o6p', timestamp: '2026-02-26T10:20:00Z' },
|
||||
{ id: 'ord-005', symbol: 'ZOO/LUX', side: 'buy', price: '0.0840', quantity: '5000.00', filled: '0.00', status: 'open', maker: '0x7q8r...9s0t', timestamp: '2026-02-26T10:18:00Z' },
|
||||
{ id: 'ord-006', symbol: 'HNZ/LUX', side: 'buy', price: '0.150', quantity: '2000.00', filled: '800.00', status: 'partial', maker: '0x1u2v...3w4x', timestamp: '2026-02-26T10:15:00Z' },
|
||||
{ id: 'ord-007', symbol: 'LUX/USDT', side: 'sell', price: '25.20', quantity: '300.00', filled: '0.00', status: 'open', maker: '0x5y6z...7a8b', timestamp: '2026-02-26T10:12:00Z' },
|
||||
{ id: 'ord-008', symbol: 'SPC/LUX', side: 'buy', price: '0.0210', quantity: '10000.00', filled: '0.00', status: 'open', maker: '0x9c0d...1e2f', timestamp: '2026-02-26T10:10:00Z' },
|
||||
{ id: 'ord-009', symbol: 'LUX/USDT', side: 'sell', price: '25.00', quantity: '120.00', filled: '120.00', status: 'filled', maker: '0x3g4h...5i6j', timestamp: '2026-02-26T10:05:00Z' },
|
||||
{ id: 'ord-010', symbol: 'PARS/LUX', side: 'buy', price: '0.0033', quantity: '50000.00', filled: '0.00', status: 'open', maker: '0x7k8l...9m0n', timestamp: '2026-02-26T10:00:00Z' },
|
||||
];
|
||||
|
||||
const MOCK_TRADES: ReadonlyArray<DexTrade> = [
|
||||
{ id: 'trd-001', symbol: 'LUX/USDT', price: '24.85', quantity: '50.00', buyer: '0x1a2b...3c4d', seller: '0x3m4n...5o6p', fee: '0.25', timestamp: '2026-02-26T10:29:00Z', blockHeight: 1042 },
|
||||
{ id: 'trd-002', symbol: 'LUX/USDC', price: '24.83', quantity: '100.00', buyer: '0x5e6f...7g8h', seller: '0x9i0j...1k2l', fee: '0.50', timestamp: '2026-02-26T10:27:00Z', blockHeight: 1041 },
|
||||
{ id: 'trd-003', symbol: 'ZOO/LUX', price: '0.0842', quantity: '2000.00', buyer: '0x7q8r...9s0t', seller: '0x1u2v...3w4x', fee: '0.84', timestamp: '2026-02-26T10:24:00Z', blockHeight: 1040 },
|
||||
{ id: 'trd-004', symbol: 'HNZ/LUX', price: '0.152', quantity: '500.00', buyer: '0x5y6z...7a8b', seller: '0x9c0d...1e2f', fee: '0.38', timestamp: '2026-02-26T10:21:00Z', blockHeight: 1039 },
|
||||
{ id: 'trd-005', symbol: 'LUX/USDT', price: '24.80', quantity: '75.00', buyer: '0x3g4h...5i6j', seller: '0x7k8l...9m0n', fee: '0.38', timestamp: '2026-02-26T10:19:00Z', blockHeight: 1038 },
|
||||
{ id: 'trd-006', symbol: 'SPC/LUX', price: '0.0215', quantity: '8000.00', buyer: '0x1a2b...3c4d', seller: '0x5e6f...7g8h', fee: '0.86', timestamp: '2026-02-26T10:16:00Z', blockHeight: 1037 },
|
||||
{ id: 'trd-007', symbol: 'LUX/USDT', price: '24.75', quantity: '200.00', buyer: '0x9i0j...1k2l', seller: '0x3m4n...5o6p', fee: '1.00', timestamp: '2026-02-26T10:13:00Z', blockHeight: 1036 },
|
||||
{ id: 'trd-008', symbol: 'PARS/LUX', price: '0.0034', quantity: '25000.00', buyer: '0x7q8r...9s0t', seller: '0x1u2v...3w4x', fee: '0.43', timestamp: '2026-02-26T10:11:00Z', blockHeight: 1035 },
|
||||
];
|
||||
|
||||
const MOCK_POOLS: ReadonlyArray<DexPool> = [
|
||||
{ id: 'pool-001', tokenA: 'LUX', tokenB: 'USDT', reserveA: '52,340', reserveB: '1,300,652', tvl: '2,601,304', volume24h: '1,245,890', fee: '0.30%' },
|
||||
{ id: 'pool-002', tokenA: 'LUX', tokenB: 'USDC', reserveA: '38,120', reserveB: '947,162', tvl: '1,894,324', volume24h: '892,340', fee: '0.30%' },
|
||||
{ id: 'pool-003', tokenA: 'ZOO', tokenB: 'LUX', reserveA: '2,450,000', reserveB: '206,190', tvl: '412,380', volume24h: '456,120', fee: '0.50%' },
|
||||
{ id: 'pool-004', tokenA: 'HNZ', tokenB: 'LUX', reserveA: '1,200,000', reserveB: '182,400', tvl: '364,800', volume24h: '334,560', fee: '0.50%' },
|
||||
{ id: 'pool-005', tokenA: 'SPC', tokenB: 'LUX', reserveA: '5,800,000', reserveB: '124,700', tvl: '249,400', volume24h: '123,450', fee: '1.00%' },
|
||||
{ id: 'pool-006', tokenA: 'PARS', tokenB: 'LUX', reserveA: '18,000,000', reserveB: '61,200', tvl: '122,400', volume24h: '89,230', fee: '1.00%' },
|
||||
];
|
||||
/* eslint-enable max-len */
|
||||
|
||||
const MOCK_OVERVIEW: DexOverviewStats = {
|
||||
totalPairs: 6,
|
||||
volume24h: '3,141,590',
|
||||
activeOrders: 7,
|
||||
tradesToday: 1332,
|
||||
};
|
||||
|
||||
// ── Hook ──
|
||||
|
||||
export interface UseDexDataResult {
|
||||
readonly symbols: ReadonlyArray<DexSymbolStats>;
|
||||
readonly orders: ReadonlyArray<DexOrder>;
|
||||
readonly trades: ReadonlyArray<DexTrade>;
|
||||
readonly pools: ReadonlyArray<DexPool>;
|
||||
readonly overview: DexOverviewStats;
|
||||
readonly isLoading: boolean;
|
||||
readonly isError: boolean;
|
||||
readonly error: Error | null;
|
||||
}
|
||||
|
||||
interface DexDataPayload {
|
||||
readonly symbols: ReadonlyArray<DexSymbolStats>;
|
||||
readonly orders: ReadonlyArray<DexOrder>;
|
||||
readonly trades: ReadonlyArray<DexTrade>;
|
||||
readonly pools: ReadonlyArray<DexPool>;
|
||||
readonly overview: DexOverviewStats;
|
||||
}
|
||||
|
||||
const EMPTY_SYMBOLS: ReadonlyArray<DexSymbolStats> = [];
|
||||
const EMPTY_ORDERS: ReadonlyArray<DexOrder> = [];
|
||||
const EMPTY_TRADES: ReadonlyArray<DexTrade> = [];
|
||||
const EMPTY_POOLS: ReadonlyArray<DexPool> = [];
|
||||
const EMPTY_OVERVIEW: DexOverviewStats = {
|
||||
totalPairs: 0,
|
||||
volume24h: '0',
|
||||
activeOrders: 0,
|
||||
tradesToday: 0,
|
||||
};
|
||||
|
||||
async function fetchDexData(): Promise<DexDataPayload> {
|
||||
// TODO: Replace with real DexVM indexer API call when deployed.
|
||||
// For now, return mock data with a small delay to simulate loading.
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 300);
|
||||
});
|
||||
|
||||
return {
|
||||
symbols: MOCK_SYMBOLS,
|
||||
orders: MOCK_ORDERS,
|
||||
trades: MOCK_TRADES,
|
||||
pools: MOCK_POOLS,
|
||||
overview: MOCK_OVERVIEW,
|
||||
};
|
||||
}
|
||||
|
||||
export function useDexData(): UseDexDataResult {
|
||||
const query = useQuery({
|
||||
queryKey: [ DEX_QUERY_KEY ],
|
||||
queryFn: fetchDexData,
|
||||
staleTime: DEX_STALE_TIME_MS,
|
||||
});
|
||||
|
||||
const symbols = React.useMemo(
|
||||
() => query.data?.symbols ?? EMPTY_SYMBOLS,
|
||||
[ query.data?.symbols ],
|
||||
);
|
||||
|
||||
const orders = React.useMemo(
|
||||
() => query.data?.orders ?? EMPTY_ORDERS,
|
||||
[ query.data?.orders ],
|
||||
);
|
||||
|
||||
const trades = React.useMemo(
|
||||
() => query.data?.trades ?? EMPTY_TRADES,
|
||||
[ query.data?.trades ],
|
||||
);
|
||||
|
||||
const pools = React.useMemo(
|
||||
() => query.data?.pools ?? EMPTY_POOLS,
|
||||
[ query.data?.pools ],
|
||||
);
|
||||
|
||||
const overview = React.useMemo(
|
||||
() => query.data?.overview ?? EMPTY_OVERVIEW,
|
||||
[ query.data?.overview ],
|
||||
);
|
||||
|
||||
return {
|
||||
symbols,
|
||||
orders,
|
||||
trades,
|
||||
pools,
|
||||
overview,
|
||||
isLoading: query.isLoading,
|
||||
isError: query.isError,
|
||||
error: query.error,
|
||||
};
|
||||
}
|
||||
@@ -103,6 +103,18 @@ export default function useNavItems(): ReturnType {
|
||||
icon: 'navigation/blockchain',
|
||||
isActive: pathname === '/chains',
|
||||
};
|
||||
const dex: NavItem = {
|
||||
text: 'DEX',
|
||||
nextRoute: { pathname: '/dex' as const },
|
||||
icon: 'navigation/dex_tracker',
|
||||
isActive: pathname === '/dex',
|
||||
};
|
||||
const aiCompute: NavItem = {
|
||||
text: 'AI Compute',
|
||||
nextRoute: { pathname: '/ai' as const },
|
||||
icon: 'navigation/blockchain',
|
||||
isActive: pathname === '/ai',
|
||||
};
|
||||
const bridge: NavItem = {
|
||||
text: 'Bridge',
|
||||
nextRoute: { pathname: '/bridge' as const },
|
||||
@@ -237,6 +249,8 @@ export default function useNavItems(): ReturnType {
|
||||
chains,
|
||||
validators,
|
||||
bridge,
|
||||
dex,
|
||||
aiCompute,
|
||||
verifiedContracts,
|
||||
nameLookup,
|
||||
beaconChainFeature.isEnabled && !beaconChainFeature.withdrawalsOnly && {
|
||||
|
||||
@@ -4,6 +4,7 @@ type OGPageType = 'Homepage' | 'Root page' | 'Regular page';
|
||||
|
||||
const OG_TYPE_DICT: Record<Route['pathname'], OGPageType> = {
|
||||
'/': 'Homepage',
|
||||
'/ai': 'Root page',
|
||||
'/txs': 'Root page',
|
||||
'/internal-txs': 'Root page',
|
||||
'/txs/kettle/[hash]': 'Regular page',
|
||||
@@ -46,6 +47,7 @@ const OG_TYPE_DICT: Record<Route['pathname'], OGPageType> = {
|
||||
'/txn-withdrawals': 'Root page',
|
||||
'/visualize/sol2uml': 'Regular page',
|
||||
'/csv-export': 'Regular page',
|
||||
'/dex': 'Root page',
|
||||
'/deposits': 'Root page',
|
||||
'/output-roots': 'Root page',
|
||||
'/dispute-games': 'Root page',
|
||||
|
||||
@@ -7,6 +7,7 @@ const DEFAULT_TEMPLATE = 'Lux Network Explorer - Search transactions, verify sma
|
||||
// FIXME all page descriptions will be updated later
|
||||
const TEMPLATE_MAP: Record<Route['pathname'], string> = {
|
||||
'/': DEFAULT_TEMPLATE,
|
||||
'/ai': 'Explore AI models, compute providers, inference results, and attestations on the Lux Network A-Chain.',
|
||||
'/txs': DEFAULT_TEMPLATE,
|
||||
'/internal-txs': DEFAULT_TEMPLATE,
|
||||
'/txs/kettle/[hash]': DEFAULT_TEMPLATE,
|
||||
@@ -49,6 +50,7 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
|
||||
'/txn-withdrawals': DEFAULT_TEMPLATE,
|
||||
'/visualize/sol2uml': DEFAULT_TEMPLATE,
|
||||
'/csv-export': DEFAULT_TEMPLATE,
|
||||
'/dex': 'Explore the D-Chain decentralized exchange on the Lux Network. View orderbook, trade history, liquidity pools, and market data.',
|
||||
'/deposits': DEFAULT_TEMPLATE,
|
||||
'/output-roots': DEFAULT_TEMPLATE,
|
||||
'/dispute-games': DEFAULT_TEMPLATE,
|
||||
|
||||
@@ -8,6 +8,7 @@ const dappEntityName = (getFeaturePayload(config.features.marketplace)?.titles.e
|
||||
|
||||
const TEMPLATE_MAP: Record<Route['pathname'], string> = {
|
||||
'/': '%network_name% blockchain explorer - View %network_name% stats',
|
||||
'/ai': '%network_name% AI compute - models, providers and attestations',
|
||||
'/txs': '%network_name% transactions - %network_name% explorer',
|
||||
'/internal-txs': '%network_name% internal transactions - %network_name% explorer',
|
||||
'/txs/kettle/[hash]': '%network_name% kettle %hash% transactions',
|
||||
@@ -50,6 +51,7 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
|
||||
'/txn-withdrawals': '%network_name% L2 to L1 message relayer',
|
||||
'/visualize/sol2uml': '%network_name% Solidity UML diagram',
|
||||
'/csv-export': '%network_name% export data to CSV',
|
||||
'/dex': '%network_name% DEX - D-Chain orderbook and market data',
|
||||
'/deposits': '%network_name% deposits - track on %network_name% explorer',
|
||||
'/output-roots': '%network_name% output roots',
|
||||
'/dispute-games': '%network_name% dispute games',
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Route } from 'nextjs-routes';
|
||||
|
||||
export const PAGE_TYPE_DICT: Record<Route['pathname'], string> = {
|
||||
'/': 'Homepage',
|
||||
'/ai': 'AI Compute',
|
||||
'/txs': 'Transactions',
|
||||
'/internal-txs': 'Internal transactions',
|
||||
'/txs/kettle/[hash]': 'Kettle transactions',
|
||||
@@ -44,6 +45,7 @@ export const PAGE_TYPE_DICT: Record<Route['pathname'], string> = {
|
||||
'/txn-withdrawals': 'Txn withdrawals',
|
||||
'/visualize/sol2uml': 'Solidity UML diagram',
|
||||
'/csv-export': 'Export data to CSV file',
|
||||
'/dex': 'DEX',
|
||||
'/deposits': 'Deposits',
|
||||
'/output-roots': 'Output roots',
|
||||
'/dispute-games': 'Dispute games',
|
||||
|
||||
Vendored
+2
@@ -17,6 +17,7 @@ declare module "nextjs-routes" {
|
||||
| DynamicRoute<"/accounts/label/[slug]", { "slug": string }>
|
||||
| DynamicRoute<"/address/[hash]/contract-verification", { "hash": string }>
|
||||
| DynamicRoute<"/address/[hash]", { "hash": string }>
|
||||
| StaticRoute<"/ai">
|
||||
| StaticRoute<"/advanced-filter">
|
||||
| StaticRoute<"/api/config">
|
||||
| StaticRoute<"/api/csrf">
|
||||
@@ -59,6 +60,7 @@ declare module "nextjs-routes" {
|
||||
| DynamicRoute<"/cross-chain-tx/[id]", { "id": string }>
|
||||
| StaticRoute<"/csv-export">
|
||||
| StaticRoute<"/deposits">
|
||||
| StaticRoute<"/dex">
|
||||
| StaticRoute<"/dispute-games">
|
||||
| DynamicRoute<"/epochs/[number]", { "number": string }>
|
||||
| StaticRoute<"/epochs">
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { NextPage } from 'next';
|
||||
import React from 'react';
|
||||
|
||||
import PageNextJs from 'nextjs/PageNextJs';
|
||||
|
||||
import AIPage from 'ui/ai/AIPage';
|
||||
|
||||
const Page: NextPage = () => {
|
||||
return (
|
||||
<PageNextJs pathname="/ai">
|
||||
<AIPage/>
|
||||
</PageNextJs>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
export { base as getServerSideProps } from 'nextjs/getServerSideProps/main';
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { NextPage } from 'next';
|
||||
import React from 'react';
|
||||
|
||||
import PageNextJs from 'nextjs/PageNextJs';
|
||||
|
||||
import DexPage from 'ui/dex/DexPage';
|
||||
|
||||
const Page: NextPage = () => {
|
||||
return (
|
||||
<PageNextJs pathname="/dex">
|
||||
<DexPage/>
|
||||
</PageNextJs>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
export { base as getServerSideProps } from 'nextjs/getServerSideProps/main';
|
||||
@@ -0,0 +1,687 @@
|
||||
import { Box, Flex, Grid } from '@chakra-ui/react';
|
||||
import React from 'react';
|
||||
|
||||
import type { AIAttestation, AIChainStats } from 'lib/api/achain';
|
||||
import { useModels, useAttestations, useProviders, useInferenceResults } from 'lib/api/achain';
|
||||
import { Skeleton } from 'toolkit/chakra/skeleton';
|
||||
import { Tag } from 'toolkit/chakra/tag';
|
||||
import PageTitle from 'ui/shared/Page/PageTitle';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const TABS = {
|
||||
dashboard: 'dashboard',
|
||||
models: 'models',
|
||||
providers: 'providers',
|
||||
attestations: 'attestations',
|
||||
} as const;
|
||||
|
||||
type TabId = typeof TABS[keyof typeof TABS];
|
||||
|
||||
const RECENT_ATTESTATIONS_COUNT = 5;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function truncateHash(hash: string): string {
|
||||
if (hash.length <= 14) {
|
||||
return hash;
|
||||
}
|
||||
return `${ hash.slice(0, 8) }...${ hash.slice(-4) }`;
|
||||
}
|
||||
|
||||
function truncateAddress(address: string): string {
|
||||
if (address.length <= 14) {
|
||||
return address;
|
||||
}
|
||||
return `${ address.slice(0, 8) }...${ address.slice(-4) }`;
|
||||
}
|
||||
|
||||
function formatParameters(params: number): string {
|
||||
if (params >= 1_000_000_000_000) {
|
||||
return `${ (params / 1_000_000_000_000).toFixed(1) }T`;
|
||||
}
|
||||
if (params >= 1_000_000_000) {
|
||||
return `${ (params / 1_000_000_000).toFixed(1) }B`;
|
||||
}
|
||||
if (params >= 1_000_000) {
|
||||
return `${ (params / 1_000_000).toFixed(1) }M`;
|
||||
}
|
||||
return params.toLocaleString();
|
||||
}
|
||||
|
||||
function formatEarnings(weiStr: string): string {
|
||||
const wei = BigInt(weiStr);
|
||||
const ether = Number(wei) / 1e18;
|
||||
return `${ ether.toFixed(2) } LUX`;
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
function formatTimestamp(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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>
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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>
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Attestation status tag
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function attestationStatusColor(status: AIAttestation['status']): 'green' | 'yellow' | 'red' {
|
||||
switch (status) {
|
||||
case 'verified': return 'green';
|
||||
case 'pending': return 'yellow';
|
||||
case 'rejected': return 'red';
|
||||
}
|
||||
}
|
||||
|
||||
function attestationTypeLabel(type: AIAttestation['type']): string {
|
||||
switch (type) {
|
||||
case 'compute': return 'Compute';
|
||||
case 'training': return 'Training';
|
||||
case 'inference': return 'Inference';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Table header component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface TableHeaderCellProps {
|
||||
readonly children: React.ReactNode;
|
||||
readonly flex?: number;
|
||||
readonly w?: string;
|
||||
readonly minW?: string;
|
||||
readonly textAlign?: 'left' | 'right' | 'center';
|
||||
readonly ml?: string;
|
||||
}
|
||||
|
||||
const TableHeaderCell = ({ children, flex, w, minW, textAlign, ml }: TableHeaderCellProps) => (
|
||||
<Box
|
||||
flex={ flex }
|
||||
w={ w }
|
||||
minW={ minW }
|
||||
flexShrink={ 0 }
|
||||
color="text.secondary"
|
||||
fontWeight="600"
|
||||
fontSize="xs"
|
||||
textTransform="uppercase"
|
||||
letterSpacing="wider"
|
||||
textAlign={ textAlign }
|
||||
ml={ ml }
|
||||
>
|
||||
{ children }
|
||||
</Box>
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dashboard tab
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface DashboardTabProps {
|
||||
readonly stats: AIChainStats;
|
||||
readonly attestations: ReadonlyArray<AIAttestation>;
|
||||
readonly isLoading: boolean;
|
||||
}
|
||||
|
||||
const DashboardTab = ({ stats, attestations, isLoading }: DashboardTabProps) => {
|
||||
const recentAttestations = React.useMemo(
|
||||
() => attestations.slice(0, RECENT_ATTESTATIONS_COUNT),
|
||||
[ attestations ],
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex direction="column" gap={ 6 }>
|
||||
{ /* Stat cards */ }
|
||||
<Grid
|
||||
gridTemplateColumns={{ base: '1fr 1fr', lg: 'repeat(4, 1fr)' }}
|
||||
gap={ 3 }
|
||||
>
|
||||
<StatCard
|
||||
label="Total Models"
|
||||
value={ stats.totalModels.toLocaleString() }
|
||||
isLoading={ isLoading }
|
||||
/>
|
||||
<StatCard
|
||||
label="Active Providers"
|
||||
value={ stats.activeProviders.toLocaleString() }
|
||||
isLoading={ isLoading }
|
||||
/>
|
||||
<StatCard
|
||||
label="Total Attestations"
|
||||
value={ stats.totalAttestations.toLocaleString() }
|
||||
isLoading={ isLoading }
|
||||
/>
|
||||
<StatCard
|
||||
label="Avg Latency"
|
||||
value={ `${ stats.avgLatencyMs }ms` }
|
||||
isLoading={ isLoading }
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{ /* Recent attestations */ }
|
||||
<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">
|
||||
Recent Attestations
|
||||
</Box>
|
||||
|
||||
{ /* Header */ }
|
||||
<Flex
|
||||
px={ 4 }
|
||||
py={ 2 }
|
||||
gap={ 4 }
|
||||
borderBottom="1px solid"
|
||||
borderColor="border.divider"
|
||||
display={{ base: 'none', lg: 'flex' }}
|
||||
>
|
||||
<TableHeaderCell minW="80px">ID</TableHeaderCell>
|
||||
<TableHeaderCell minW="100px">Type</TableHeaderCell>
|
||||
<TableHeaderCell flex={ 1 }>Provider</TableHeaderCell>
|
||||
<TableHeaderCell minW="100px">Block</TableHeaderCell>
|
||||
<TableHeaderCell minW="160px">Time</TableHeaderCell>
|
||||
<TableHeaderCell minW="100px" textAlign="right">Status</TableHeaderCell>
|
||||
</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 && recentAttestations.map((att) => (
|
||||
<Flex
|
||||
key={ att.id }
|
||||
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 minW="80px" flexShrink={ 0 } fontFamily="mono" fontSize="sm" color="text.primary">
|
||||
{ att.id }
|
||||
</Box>
|
||||
<Box minW="100px" flexShrink={ 0 }>
|
||||
<Tag size="sm" colorPalette="gray">{ attestationTypeLabel(att.type) }</Tag>
|
||||
</Box>
|
||||
<Box flex={ 1 } fontFamily="mono" fontSize="sm" color="text.secondary" title={ att.provider }>
|
||||
{ truncateAddress(att.provider) }
|
||||
</Box>
|
||||
<Box minW="100px" flexShrink={ 0 } fontSize="sm" color="text.secondary">
|
||||
{ att.blockHeight.toLocaleString() }
|
||||
</Box>
|
||||
<Box minW="160px" flexShrink={ 0 } fontSize="sm" color="text.secondary">
|
||||
{ formatTimestamp(att.timestamp) }
|
||||
</Box>
|
||||
<Box minW="100px" flexShrink={ 0 } textAlign={{ base: 'left', lg: 'right' }}>
|
||||
<Tag size="sm" colorPalette={ attestationStatusColor(att.status) }>
|
||||
{ att.status }
|
||||
</Tag>
|
||||
</Box>
|
||||
</Flex>
|
||||
)) }
|
||||
</Box>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Models tab
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ModelsTabProps {
|
||||
readonly isLoading: boolean;
|
||||
}
|
||||
|
||||
const ModelsTab = ({ isLoading }: ModelsTabProps) => {
|
||||
const { models } = useModels();
|
||||
|
||||
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' }}
|
||||
>
|
||||
<TableHeaderCell minW="180px">Name</TableHeaderCell>
|
||||
<TableHeaderCell minW="80px">Version</TableHeaderCell>
|
||||
<TableHeaderCell minW="100px">Framework</TableHeaderCell>
|
||||
<TableHeaderCell minW="100px" textAlign="right">Parameters</TableHeaderCell>
|
||||
<TableHeaderCell flex={ 1 }>Provider</TableHeaderCell>
|
||||
<TableHeaderCell minW="120px" textAlign="right">Registered</TableHeaderCell>
|
||||
</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 && models.length === 0 && (
|
||||
<Box px={ 4 } py={ 8 } textAlign="center" color="text.secondary" fontSize="sm">
|
||||
No models registered
|
||||
</Box>
|
||||
) }
|
||||
|
||||
{ !isLoading && models.map((model) => (
|
||||
<Flex
|
||||
key={ model.id }
|
||||
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 minW="180px" flexShrink={ 0 }>
|
||||
<Box fontSize="sm" fontWeight="600" color="text.primary">{ model.name }</Box>
|
||||
<Box fontSize="xs" fontFamily="mono" color="text.secondary" title={ model.modelHash }>
|
||||
{ truncateHash(model.modelHash) }
|
||||
</Box>
|
||||
</Box>
|
||||
<Box minW="80px" flexShrink={ 0 }>
|
||||
<Tag size="sm" colorPalette="gray">v{ model.version }</Tag>
|
||||
</Box>
|
||||
<Box minW="100px" flexShrink={ 0 } fontSize="sm" color="text.secondary">
|
||||
{ model.framework }
|
||||
</Box>
|
||||
<Box minW="100px" flexShrink={ 0 } fontSize="sm" color="text.primary" textAlign={{ base: 'left', lg: 'right' }}>
|
||||
{ formatParameters(model.parameters) }
|
||||
</Box>
|
||||
<Box flex={ 1 } fontFamily="mono" fontSize="sm" color="text.secondary" title={ model.provider }>
|
||||
{ truncateAddress(model.provider) }
|
||||
</Box>
|
||||
<Box minW="120px" flexShrink={ 0 } fontSize="sm" color="text.secondary" textAlign={{ base: 'left', lg: 'right' }}>
|
||||
{ formatDate(model.registeredAt) }
|
||||
</Box>
|
||||
</Flex>
|
||||
)) }
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Providers tab
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ProvidersTabProps {
|
||||
readonly isLoading: boolean;
|
||||
}
|
||||
|
||||
const ProvidersTab = ({ isLoading }: ProvidersTabProps) => {
|
||||
const { providers } = useProviders();
|
||||
|
||||
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' }}
|
||||
>
|
||||
<TableHeaderCell flex={ 1 }>Address</TableHeaderCell>
|
||||
<TableHeaderCell minW="100px" textAlign="right">Reputation</TableHeaderCell>
|
||||
<TableHeaderCell minW="100px" textAlign="right">Capacity</TableHeaderCell>
|
||||
<TableHeaderCell minW="100px" textAlign="right">Total Jobs</TableHeaderCell>
|
||||
<TableHeaderCell minW="140px" textAlign="right">Earnings</TableHeaderCell>
|
||||
<TableHeaderCell minW="80px" textAlign="right">Status</TableHeaderCell>
|
||||
</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 && providers.length === 0 && (
|
||||
<Box px={ 4 } py={ 8 } textAlign="center" color="text.secondary" fontSize="sm">
|
||||
No compute providers found
|
||||
</Box>
|
||||
) }
|
||||
|
||||
{ !isLoading && providers.map((provider) => (
|
||||
<Flex
|
||||
key={ provider.address }
|
||||
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 flex={ 1 } fontFamily="mono" fontSize="sm" color="text.primary" title={ provider.address }>
|
||||
{ truncateAddress(provider.address) }
|
||||
</Box>
|
||||
<Box minW="100px" flexShrink={ 0 } fontSize="sm" color="text.primary" textAlign={{ base: 'left', lg: 'right' }}>
|
||||
{ provider.reputation }%
|
||||
</Box>
|
||||
<Box minW="100px" flexShrink={ 0 } fontSize="sm" color="text.secondary" textAlign={{ base: 'left', lg: 'right' }}>
|
||||
{ provider.capacity } GPUs
|
||||
</Box>
|
||||
<Box minW="100px" flexShrink={ 0 } fontSize="sm" color="text.secondary" textAlign={{ base: 'left', lg: 'right' }}>
|
||||
{ provider.totalJobs.toLocaleString() }
|
||||
</Box>
|
||||
<Box minW="140px" flexShrink={ 0 } fontSize="sm" color="text.primary" textAlign={{ base: 'left', lg: 'right' }}>
|
||||
{ formatEarnings(provider.totalEarnings) }
|
||||
</Box>
|
||||
<Box minW="80px" flexShrink={ 0 } textAlign={{ base: 'left', lg: 'right' }}>
|
||||
<Tag size="sm" colorPalette={ provider.isActive ? 'green' : 'gray' }>
|
||||
{ provider.isActive ? 'Active' : 'Offline' }
|
||||
</Tag>
|
||||
</Box>
|
||||
</Flex>
|
||||
)) }
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Attestations tab
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface AttestationsTabProps {
|
||||
readonly attestations: ReadonlyArray<AIAttestation>;
|
||||
readonly isLoading: boolean;
|
||||
}
|
||||
|
||||
const AttestationsTab = ({ attestations, isLoading }: AttestationsTabProps) => (
|
||||
<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' }}
|
||||
>
|
||||
<TableHeaderCell minW="80px">ID</TableHeaderCell>
|
||||
<TableHeaderCell minW="100px">Type</TableHeaderCell>
|
||||
<TableHeaderCell flex={ 1 }>Provider</TableHeaderCell>
|
||||
<TableHeaderCell flex={ 1 }>Model Hash</TableHeaderCell>
|
||||
<TableHeaderCell minW="160px">Timestamp</TableHeaderCell>
|
||||
<TableHeaderCell minW="100px" textAlign="right">Status</TableHeaderCell>
|
||||
</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 && attestations.length === 0 && (
|
||||
<Box px={ 4 } py={ 8 } textAlign="center" color="text.secondary" fontSize="sm">
|
||||
No attestations found
|
||||
</Box>
|
||||
) }
|
||||
|
||||
{ !isLoading && attestations.map((att) => (
|
||||
<Flex
|
||||
key={ att.id }
|
||||
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 minW="80px" flexShrink={ 0 } fontFamily="mono" fontSize="sm" color="text.primary">
|
||||
{ att.id }
|
||||
</Box>
|
||||
<Box minW="100px" flexShrink={ 0 }>
|
||||
<Tag size="sm" colorPalette="gray">{ attestationTypeLabel(att.type) }</Tag>
|
||||
</Box>
|
||||
<Box flex={ 1 } fontFamily="mono" fontSize="sm" color="text.secondary" title={ att.provider }>
|
||||
{ truncateAddress(att.provider) }
|
||||
</Box>
|
||||
<Box flex={ 1 } fontFamily="mono" fontSize="sm" color="text.secondary" title={ att.modelHash }>
|
||||
{ truncateHash(att.modelHash) }
|
||||
</Box>
|
||||
<Box minW="160px" flexShrink={ 0 } fontSize="sm" color="text.secondary">
|
||||
{ formatTimestamp(att.timestamp) }
|
||||
</Box>
|
||||
<Box minW="100px" flexShrink={ 0 } textAlign={{ base: 'left', lg: 'right' }}>
|
||||
<Tag size="sm" colorPalette={ attestationStatusColor(att.status) }>
|
||||
{ att.status }
|
||||
</Tag>
|
||||
</Box>
|
||||
</Flex>
|
||||
)) }
|
||||
</Box>
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const AIPage = () => {
|
||||
const [ activeTab, setActiveTab ] = React.useState<TabId>(TABS.dashboard);
|
||||
const { models, isLoading: modelsLoading } = useModels();
|
||||
const { attestations, isLoading: attestationsLoading } = useAttestations();
|
||||
const { providers, isLoading: providersLoading } = useProviders();
|
||||
const { results, isLoading: inferenceLoading } = useInferenceResults();
|
||||
|
||||
const isLoading = modelsLoading || attestationsLoading || providersLoading || inferenceLoading;
|
||||
|
||||
const stats = React.useMemo<AIChainStats>(() => {
|
||||
const activeProviderCount = providers.filter((p) => p.isActive).length;
|
||||
const totalLatency = results.reduce((sum, r) => sum + r.latencyMs, 0);
|
||||
const avgLatency = results.length > 0 ? Math.round(totalLatency / results.length) : 0;
|
||||
|
||||
return {
|
||||
totalModels: models.length,
|
||||
activeProviders: activeProviderCount,
|
||||
totalAttestations: attestations.length,
|
||||
avgLatencyMs: avgLatency,
|
||||
};
|
||||
}, [ models, attestations, providers, results ]);
|
||||
|
||||
const handleDashboardClick = React.useCallback(() => {
|
||||
setActiveTab(TABS.dashboard);
|
||||
}, []);
|
||||
|
||||
const handleModelsClick = React.useCallback(() => {
|
||||
setActiveTab(TABS.models);
|
||||
}, []);
|
||||
|
||||
const handleProvidersClick = React.useCallback(() => {
|
||||
setActiveTab(TABS.providers);
|
||||
}, []);
|
||||
|
||||
const handleAttestationsClick = React.useCallback(() => {
|
||||
setActiveTab(TABS.attestations);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle
|
||||
title="AI Compute"
|
||||
secondRow={ (
|
||||
<Box fontSize="sm" color="text.secondary">
|
||||
A-Chain AI models, compute providers, and attestations
|
||||
</Box>
|
||||
) }
|
||||
/>
|
||||
|
||||
{ /* Tabs */ }
|
||||
<Flex
|
||||
borderBottom="1px solid"
|
||||
borderColor="border.divider"
|
||||
mb={ 6 }
|
||||
gap={ 0 }
|
||||
>
|
||||
<TabButton
|
||||
label="Dashboard"
|
||||
isActive={ activeTab === TABS.dashboard }
|
||||
onClick={ handleDashboardClick }
|
||||
/>
|
||||
<TabButton
|
||||
label={ `Models (${ models.length })` }
|
||||
isActive={ activeTab === TABS.models }
|
||||
onClick={ handleModelsClick }
|
||||
/>
|
||||
<TabButton
|
||||
label={ `Providers (${ providers.length })` }
|
||||
isActive={ activeTab === TABS.providers }
|
||||
onClick={ handleProvidersClick }
|
||||
/>
|
||||
<TabButton
|
||||
label={ `Attestations (${ attestations.length })` }
|
||||
isActive={ activeTab === TABS.attestations }
|
||||
onClick={ handleAttestationsClick }
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
{ /* Tab content */ }
|
||||
{ activeTab === TABS.dashboard && (
|
||||
<DashboardTab
|
||||
stats={ stats }
|
||||
attestations={ attestations }
|
||||
isLoading={ isLoading }
|
||||
/>
|
||||
) }
|
||||
|
||||
{ activeTab === TABS.models && (
|
||||
<ModelsTab isLoading={ modelsLoading }/>
|
||||
) }
|
||||
|
||||
{ activeTab === TABS.providers && (
|
||||
<ProvidersTab isLoading={ providersLoading }/>
|
||||
) }
|
||||
|
||||
{ activeTab === TABS.attestations && (
|
||||
<AttestationsTab
|
||||
attestations={ attestations }
|
||||
isLoading={ attestationsLoading }
|
||||
/>
|
||||
) }
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AIPage;
|
||||
@@ -0,0 +1,490 @@
|
||||
// DEX Chain (D-Chain) orderbook page for the Lux multi-chain explorer.
|
||||
// Displays market stats, orderbook, trade history, and liquidity pools
|
||||
// sourced from the DexVM indexer.
|
||||
|
||||
import { Box, Flex, Text } from '@chakra-ui/react';
|
||||
import React from 'react';
|
||||
|
||||
import { useDexData } from 'lib/api/dchain';
|
||||
import type { DexOrder, DexTrade, DexPool, DexSymbolStats } from 'lib/api/dchain';
|
||||
import { Skeleton } from 'toolkit/chakra/skeleton';
|
||||
import { Tag } from 'toolkit/chakra/tag';
|
||||
import PageTitle from 'ui/shared/Page/PageTitle';
|
||||
|
||||
// ── Constants ──
|
||||
|
||||
const TAB_IDS = {
|
||||
markets: 'markets',
|
||||
orderbook: 'orderbook',
|
||||
trades: 'trades',
|
||||
pools: 'pools',
|
||||
} as const;
|
||||
|
||||
type TabId = typeof TAB_IDS[keyof typeof TAB_IDS];
|
||||
|
||||
const STAT_CARD_BG = { _light: 'gray.50', _dark: 'whiteAlpha.50' };
|
||||
|
||||
// ── Sub-components ──
|
||||
|
||||
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>
|
||||
);
|
||||
|
||||
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={ STAT_CARD_BG }
|
||||
>
|
||||
<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>
|
||||
);
|
||||
|
||||
// ── Table header ──
|
||||
|
||||
interface ColumnHeaderProps {
|
||||
readonly children: React.ReactNode;
|
||||
readonly flex?: number | string;
|
||||
readonly minW?: string;
|
||||
readonly w?: string;
|
||||
readonly ml?: string;
|
||||
readonly textAlign?: 'left' | 'right';
|
||||
}
|
||||
|
||||
const ColumnHeader = ({ children, flex, minW, w, ml, textAlign = 'left' }: ColumnHeaderProps) => (
|
||||
<Box
|
||||
flex={ flex }
|
||||
minW={ minW }
|
||||
w={ w }
|
||||
ml={ ml }
|
||||
color="text.secondary"
|
||||
fontWeight="600"
|
||||
fontSize="xs"
|
||||
textTransform="uppercase"
|
||||
letterSpacing="wider"
|
||||
textAlign={ textAlign }
|
||||
>
|
||||
{ children }
|
||||
</Box>
|
||||
);
|
||||
|
||||
// ── Symbol row ──
|
||||
|
||||
interface SymbolRowProps {
|
||||
readonly stat: DexSymbolStats;
|
||||
}
|
||||
|
||||
const SymbolRow = ({ stat }: SymbolRowProps) => {
|
||||
const isPositive = stat.change24h >= 0;
|
||||
|
||||
return (
|
||||
<Flex
|
||||
alignItems="center"
|
||||
py={ 3 }
|
||||
px={ 4 }
|
||||
borderBottom="1px solid"
|
||||
borderColor="border.divider"
|
||||
_hover={{ bg: STAT_CARD_BG }}
|
||||
transition="background 0.15s"
|
||||
gap={ 4 }
|
||||
flexWrap={{ base: 'wrap', lg: 'nowrap' }}
|
||||
>
|
||||
<Box minW="120px" flexShrink={ 0 }>
|
||||
<Text fontWeight="600" fontSize="sm" color="text.primary">
|
||||
{ stat.symbol }
|
||||
</Text>
|
||||
</Box>
|
||||
<Box minW="100px" flexShrink={ 0 } textAlign="right">
|
||||
<Text fontSize="sm" fontFamily="mono" color="text.primary">
|
||||
{ stat.lastPrice }
|
||||
</Text>
|
||||
</Box>
|
||||
<Box minW="80px" flexShrink={ 0 } textAlign="right">
|
||||
<Text fontSize="sm" fontWeight="500" color={ isPositive ? 'green.400' : 'red.400' }>
|
||||
{ isPositive ? '+' : '' }{ stat.change24h.toFixed(2) }%
|
||||
</Text>
|
||||
</Box>
|
||||
<Box minW="100px" flexShrink={ 0 } textAlign="right">
|
||||
<Text fontSize="sm" fontFamily="mono" color="text.secondary">
|
||||
{ stat.high24h }
|
||||
</Text>
|
||||
</Box>
|
||||
<Box minW="100px" flexShrink={ 0 } textAlign="right">
|
||||
<Text fontSize="sm" fontFamily="mono" color="text.secondary">
|
||||
{ stat.low24h }
|
||||
</Text>
|
||||
</Box>
|
||||
<Box minW="120px" flexShrink={ 0 } textAlign="right">
|
||||
<Text fontSize="sm" fontFamily="mono" color="text.secondary">
|
||||
${ stat.volume24h }
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexShrink={ 0 } textAlign="right" ml={{ base: 0, lg: 'auto' }}>
|
||||
<Text fontSize="sm" color="text.secondary">
|
||||
{ stat.trades24h }
|
||||
</Text>
|
||||
</Box>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Order row ──
|
||||
|
||||
interface OrderRowProps {
|
||||
readonly order: DexOrder;
|
||||
}
|
||||
|
||||
const OrderRow = ({ order }: OrderRowProps) => {
|
||||
const isBuy = order.side === 'buy';
|
||||
|
||||
return (
|
||||
<Flex
|
||||
alignItems="center"
|
||||
py={ 3 }
|
||||
px={ 4 }
|
||||
borderBottom="1px solid"
|
||||
borderColor="border.divider"
|
||||
_hover={{ bg: STAT_CARD_BG }}
|
||||
transition="background 0.15s"
|
||||
gap={ 4 }
|
||||
flexWrap={{ base: 'wrap', lg: 'nowrap' }}
|
||||
>
|
||||
<Box minW="100px" flexShrink={ 0 }>
|
||||
<Text fontSize="sm" color="text.primary">
|
||||
{ order.symbol }
|
||||
</Text>
|
||||
</Box>
|
||||
<Box minW="60px" flexShrink={ 0 }>
|
||||
<Tag size="sm" variant="subtle" colorPalette={ isBuy ? 'green' : 'red' }>
|
||||
{ order.side.toUpperCase() }
|
||||
</Tag>
|
||||
</Box>
|
||||
<Box minW="100px" flexShrink={ 0 } textAlign="right">
|
||||
<Text fontSize="sm" fontFamily="mono" color={ isBuy ? 'green.400' : 'red.400' }>
|
||||
{ order.price }
|
||||
</Text>
|
||||
</Box>
|
||||
<Box minW="100px" flexShrink={ 0 } textAlign="right">
|
||||
<Text fontSize="sm" fontFamily="mono" color="text.primary">
|
||||
{ order.quantity }
|
||||
</Text>
|
||||
</Box>
|
||||
<Box minW="120px" flexShrink={ 0 } textAlign="right">
|
||||
<Text fontSize="sm" fontFamily="mono" color="text.secondary">
|
||||
{ (parseFloat(order.price) * parseFloat(order.quantity)).toFixed(2) }
|
||||
</Text>
|
||||
</Box>
|
||||
<Box minW="100px" flexShrink={ 0 }>
|
||||
<Text fontSize="sm" fontFamily="mono" color="text.secondary">
|
||||
{ order.maker }
|
||||
</Text>
|
||||
</Box>
|
||||
<Box minW="80px" flexShrink={ 0 }>
|
||||
<Text fontSize="sm" color="text.secondary">
|
||||
{ formatTime(order.timestamp) }
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexShrink={ 0 } ml={{ base: 0, lg: 'auto' }}>
|
||||
<Tag size="sm" variant="subtle" colorPalette={ getStatusColor(order.status) }>
|
||||
{ order.status }
|
||||
</Tag>
|
||||
</Box>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Trade row ──
|
||||
|
||||
interface TradeRowProps {
|
||||
readonly trade: DexTrade;
|
||||
}
|
||||
|
||||
const TradeRow = ({ trade }: TradeRowProps) => (
|
||||
<Flex
|
||||
alignItems="center"
|
||||
py={ 3 }
|
||||
px={ 4 }
|
||||
borderBottom="1px solid"
|
||||
borderColor="border.divider"
|
||||
_hover={{ bg: STAT_CARD_BG }}
|
||||
transition="background 0.15s"
|
||||
gap={ 4 }
|
||||
flexWrap={{ base: 'wrap', lg: 'nowrap' }}
|
||||
>
|
||||
<Box minW="100px" flexShrink={ 0 }>
|
||||
<Text fontWeight="500" fontSize="sm" color="text.primary">
|
||||
{ trade.symbol }
|
||||
</Text>
|
||||
</Box>
|
||||
<Box minW="100px" flexShrink={ 0 } textAlign="right">
|
||||
<Text fontSize="sm" fontFamily="mono" color="text.primary">
|
||||
{ trade.price }
|
||||
</Text>
|
||||
</Box>
|
||||
<Box minW="100px" flexShrink={ 0 } textAlign="right">
|
||||
<Text fontSize="sm" fontFamily="mono" color="text.primary">
|
||||
{ trade.quantity }
|
||||
</Text>
|
||||
</Box>
|
||||
<Box minW="100px" flexShrink={ 0 }>
|
||||
<Text fontSize="sm" fontFamily="mono" color="text.secondary">
|
||||
{ trade.buyer }
|
||||
</Text>
|
||||
</Box>
|
||||
<Box minW="100px" flexShrink={ 0 }>
|
||||
<Text fontSize="sm" fontFamily="mono" color="text.secondary">
|
||||
{ trade.seller }
|
||||
</Text>
|
||||
</Box>
|
||||
<Box minW="60px" flexShrink={ 0 } textAlign="right">
|
||||
<Text fontSize="sm" fontFamily="mono" color="text.secondary">
|
||||
{ trade.fee }
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexShrink={ 0 } textAlign="right" ml={{ base: 0, lg: 'auto' }}>
|
||||
<Text fontSize="sm" color="text.secondary">
|
||||
{ formatTime(trade.timestamp) }
|
||||
</Text>
|
||||
</Box>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
// ── Pool row ──
|
||||
|
||||
interface PoolRowProps {
|
||||
readonly pool: DexPool;
|
||||
}
|
||||
|
||||
const PoolRow = ({ pool }: PoolRowProps) => (
|
||||
<Flex
|
||||
alignItems="center"
|
||||
py={ 3 }
|
||||
px={ 4 }
|
||||
borderBottom="1px solid"
|
||||
borderColor="border.divider"
|
||||
_hover={{ bg: STAT_CARD_BG }}
|
||||
transition="background 0.15s"
|
||||
gap={ 4 }
|
||||
flexWrap={{ base: 'wrap', lg: 'nowrap' }}
|
||||
>
|
||||
<Box minW="120px" flexShrink={ 0 }>
|
||||
<Text fontWeight="600" fontSize="sm" color="text.primary">
|
||||
{ pool.tokenA }/{ pool.tokenB }
|
||||
</Text>
|
||||
</Box>
|
||||
<Box minW="120px" flexShrink={ 0 } textAlign="right">
|
||||
<Text fontSize="sm" fontFamily="mono" color="text.primary">
|
||||
{ pool.reserveA }
|
||||
</Text>
|
||||
</Box>
|
||||
<Box minW="120px" flexShrink={ 0 } textAlign="right">
|
||||
<Text fontSize="sm" fontFamily="mono" color="text.primary">
|
||||
{ pool.reserveB }
|
||||
</Text>
|
||||
</Box>
|
||||
<Box minW="120px" flexShrink={ 0 } textAlign="right">
|
||||
<Text fontSize="sm" fontFamily="mono" fontWeight="600" color="text.primary">
|
||||
${ pool.tvl }
|
||||
</Text>
|
||||
</Box>
|
||||
<Box minW="120px" flexShrink={ 0 } textAlign="right">
|
||||
<Text fontSize="sm" fontFamily="mono" color="text.secondary">
|
||||
${ pool.volume24h }
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexShrink={ 0 } textAlign="right" ml={{ base: 0, lg: 'auto' }}>
|
||||
<Text fontSize="sm" color="text.secondary">
|
||||
{ pool.fee }
|
||||
</Text>
|
||||
</Box>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
// ── Loading skeleton ──
|
||||
|
||||
const LoadingSkeleton = () => (
|
||||
<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>
|
||||
);
|
||||
|
||||
// ── Main component ──
|
||||
|
||||
const DexPage = () => {
|
||||
const [ activeTab, setActiveTab ] = React.useState<TabId>(TAB_IDS.markets);
|
||||
const { symbols, orders, trades, pools, overview, isLoading } = useDexData();
|
||||
|
||||
const handleMarketsClick = React.useCallback(() => setActiveTab(TAB_IDS.markets), []);
|
||||
const handleOrderbookClick = React.useCallback(() => setActiveTab(TAB_IDS.orderbook), []);
|
||||
const handleTradesClick = React.useCallback(() => setActiveTab(TAB_IDS.trades), []);
|
||||
const handlePoolsClick = React.useCallback(() => setActiveTab(TAB_IDS.pools), []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle
|
||||
title="DEX"
|
||||
secondRow={ (
|
||||
<Box fontSize="sm" color="text.secondary">
|
||||
D-Chain decentralized exchange orderbook and market data
|
||||
</Box>
|
||||
) }
|
||||
/>
|
||||
|
||||
{ /* Stats cards */ }
|
||||
<Box
|
||||
display="grid"
|
||||
gridTemplateColumns={{ base: '1fr 1fr', lg: 'repeat(4, 1fr)' }}
|
||||
gap={ 3 }
|
||||
mb={ 6 }
|
||||
>
|
||||
<StatCard label="Total Pairs" value={ String(overview.totalPairs) } isLoading={ isLoading }/>
|
||||
<StatCard label="24h Volume" value={ `$${ overview.volume24h }` } isLoading={ isLoading }/>
|
||||
<StatCard label="Active Orders" value={ String(overview.activeOrders) } isLoading={ isLoading }/>
|
||||
<StatCard label="Trades Today" value={ String(overview.tradesToday) } isLoading={ isLoading }/>
|
||||
</Box>
|
||||
|
||||
{ /* Tabs */ }
|
||||
<Flex borderBottom="1px solid" borderColor="border.divider" mb={ 4 } gap={ 0 }>
|
||||
<TabButton label="Markets" isActive={ activeTab === TAB_IDS.markets } onClick={ handleMarketsClick }/>
|
||||
<TabButton label="Orderbook" isActive={ activeTab === TAB_IDS.orderbook } onClick={ handleOrderbookClick }/>
|
||||
<TabButton label="Trades" isActive={ activeTab === TAB_IDS.trades } onClick={ handleTradesClick }/>
|
||||
<TabButton label="Pools" isActive={ activeTab === TAB_IDS.pools } onClick={ handlePoolsClick }/>
|
||||
</Flex>
|
||||
|
||||
{ /* Markets tab */ }
|
||||
{ activeTab === TAB_IDS.markets && (
|
||||
<Box border="1px solid" borderColor="border.divider" borderRadius="lg" overflow="hidden">
|
||||
<Flex px={ 4 } py={ 2 } gap={ 4 } borderBottom="1px solid" borderColor="border.divider" display={{ base: 'none', lg: 'flex' }}>
|
||||
<ColumnHeader minW="120px">Symbol</ColumnHeader>
|
||||
<ColumnHeader minW="100px" textAlign="right">Price</ColumnHeader>
|
||||
<ColumnHeader minW="80px" textAlign="right">24h Change</ColumnHeader>
|
||||
<ColumnHeader minW="100px" textAlign="right">24h High</ColumnHeader>
|
||||
<ColumnHeader minW="100px" textAlign="right">24h Low</ColumnHeader>
|
||||
<ColumnHeader minW="120px" textAlign="right">24h Volume</ColumnHeader>
|
||||
<ColumnHeader ml="auto" textAlign="right">Trades</ColumnHeader>
|
||||
</Flex>
|
||||
{ isLoading && <LoadingSkeleton/> }
|
||||
{ !isLoading && symbols.map((stat) => (
|
||||
<SymbolRow key={ stat.symbol } stat={ stat }/>
|
||||
)) }
|
||||
</Box>
|
||||
) }
|
||||
|
||||
{ /* Orderbook tab */ }
|
||||
{ activeTab === TAB_IDS.orderbook && (
|
||||
<Box border="1px solid" borderColor="border.divider" borderRadius="lg" overflow="hidden">
|
||||
<Flex px={ 4 } py={ 2 } gap={ 4 } borderBottom="1px solid" borderColor="border.divider" display={{ base: 'none', lg: 'flex' }}>
|
||||
<ColumnHeader minW="100px">Symbol</ColumnHeader>
|
||||
<ColumnHeader minW="60px">Side</ColumnHeader>
|
||||
<ColumnHeader minW="100px" textAlign="right">Price</ColumnHeader>
|
||||
<ColumnHeader minW="100px" textAlign="right">Quantity</ColumnHeader>
|
||||
<ColumnHeader minW="120px" textAlign="right">Total</ColumnHeader>
|
||||
<ColumnHeader minW="100px">Maker</ColumnHeader>
|
||||
<ColumnHeader minW="80px">Time</ColumnHeader>
|
||||
<ColumnHeader ml="auto" textAlign="right">Status</ColumnHeader>
|
||||
</Flex>
|
||||
{ isLoading && <LoadingSkeleton/> }
|
||||
{ !isLoading && orders.map((order) => (
|
||||
<OrderRow key={ order.id } order={ order }/>
|
||||
)) }
|
||||
</Box>
|
||||
) }
|
||||
|
||||
{ /* Trades tab */ }
|
||||
{ activeTab === TAB_IDS.trades && (
|
||||
<Box border="1px solid" borderColor="border.divider" borderRadius="lg" overflow="hidden">
|
||||
<Flex px={ 4 } py={ 2 } gap={ 4 } borderBottom="1px solid" borderColor="border.divider" display={{ base: 'none', lg: 'flex' }}>
|
||||
<ColumnHeader minW="100px">Symbol</ColumnHeader>
|
||||
<ColumnHeader minW="100px" textAlign="right">Price</ColumnHeader>
|
||||
<ColumnHeader minW="100px" textAlign="right">Quantity</ColumnHeader>
|
||||
<ColumnHeader minW="100px">Buyer</ColumnHeader>
|
||||
<ColumnHeader minW="100px">Seller</ColumnHeader>
|
||||
<ColumnHeader minW="60px" textAlign="right">Fee</ColumnHeader>
|
||||
<ColumnHeader ml="auto" textAlign="right">Time</ColumnHeader>
|
||||
</Flex>
|
||||
{ isLoading && <LoadingSkeleton/> }
|
||||
{ !isLoading && trades.map((trade) => (
|
||||
<TradeRow key={ trade.id } trade={ trade }/>
|
||||
)) }
|
||||
</Box>
|
||||
) }
|
||||
|
||||
{ /* Pools tab */ }
|
||||
{ activeTab === TAB_IDS.pools && (
|
||||
<Box border="1px solid" borderColor="border.divider" borderRadius="lg" overflow="hidden">
|
||||
<Flex px={ 4 } py={ 2 } gap={ 4 } borderBottom="1px solid" borderColor="border.divider" display={{ base: 'none', lg: 'flex' }}>
|
||||
<ColumnHeader minW="120px">Pair</ColumnHeader>
|
||||
<ColumnHeader minW="120px" textAlign="right">Reserve A</ColumnHeader>
|
||||
<ColumnHeader minW="120px" textAlign="right">Reserve B</ColumnHeader>
|
||||
<ColumnHeader minW="120px" textAlign="right">TVL</ColumnHeader>
|
||||
<ColumnHeader minW="120px" textAlign="right">24h Volume</ColumnHeader>
|
||||
<ColumnHeader ml="auto" textAlign="right">Fee</ColumnHeader>
|
||||
</Flex>
|
||||
{ isLoading && <LoadingSkeleton/> }
|
||||
{ !isLoading && pools.map((pool) => (
|
||||
<PoolRow key={ pool.id } pool={ pool }/>
|
||||
)) }
|
||||
</Box>
|
||||
) }
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
function formatTime(timestamp: string): string {
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false });
|
||||
}
|
||||
|
||||
function getStatusColor(status: DexOrder['status']): string {
|
||||
switch (status) {
|
||||
case 'open': return 'blue';
|
||||
case 'filled': return 'green';
|
||||
case 'partial': return 'orange';
|
||||
case 'cancelled': return 'red';
|
||||
default: return 'gray';
|
||||
}
|
||||
}
|
||||
|
||||
export default React.memo(DexPage);
|
||||
Reference in New Issue
Block a user