feat(portal): wire DashboardPage to live Chain-138 RPC + SolaceScan Explorer
- Add services/{http,chain138,explorer,proxmox,dbisCore} + hooks/{useLiveChain,useOnChainBalances}
- Add BackendStatusBar + LiveNetworkPanel components on DashboardPage
- Overlay on-chain META balance on account rows carrying a walletAddress
- Normalize EIP-55 checksum in chain138.getNativeBalance so hand-typed
sample custody addresses (e.g. 0x742d35Cc...bD38) don't silently drop
out of the balance map
- Default RPC: https://rpc.d-bis.org (user-preferred gateway)
- proxmox.ts stays mocked (CF-Access, needs BFF); dbisCore.ts stays
mocked (no public deployment yet)
Co-Authored-By: Nakamoto, S <defi@defi-oracle.io>
This commit is contained in:
52
src/components/portal/BackendStatusBar.tsx
Normal file
52
src/components/portal/BackendStatusBar.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Circle, AlertCircle, CheckCircle2, MinusCircle } from 'lucide-react';
|
||||
import { backendCatalog, type BackendDescriptor, type BackendStatus } from '../../config/endpoints';
|
||||
import { getChainHealth } from '../../services/chain138';
|
||||
import { getExplorerStats } from '../../services/explorer';
|
||||
|
||||
const STATUS_STYLE: Record<BackendStatus, { color: string; label: string; Icon: typeof CheckCircle2 }> = {
|
||||
live: { color: '#22c55e', label: 'Live', Icon: CheckCircle2 },
|
||||
'bff-required': { color: '#eab308', label: 'BFF required', Icon: AlertCircle },
|
||||
mocked: { color: '#6b7280', label: 'Mocked', Icon: MinusCircle },
|
||||
degraded: { color: '#ef4444', label: 'Degraded', Icon: AlertCircle },
|
||||
};
|
||||
|
||||
export default function BackendStatusBar() {
|
||||
const [probed, setProbed] = useState<Record<string, BackendStatus>>({});
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const results = await Promise.allSettled([
|
||||
getChainHealth().then(() => 'live' as const),
|
||||
getExplorerStats().then(() => 'live' as const),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
setProbed({
|
||||
chain138: results[0].status === 'fulfilled' ? 'live' : 'degraded',
|
||||
explorer: results[1].status === 'fulfilled' ? 'live' : 'degraded',
|
||||
});
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
const withProbed = (b: BackendDescriptor): BackendDescriptor => ({
|
||||
...b,
|
||||
status: probed[b.id] ?? b.status,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="backend-status-bar" style={{ display: 'flex', gap: 10, flexWrap: 'wrap', padding: '6px 12px', background: 'rgba(17,24,39,0.6)', borderRadius: 8, border: '1px solid rgba(75,85,99,0.3)', alignItems: 'center', fontSize: 11 }}>
|
||||
<Circle size={8} style={{ color: '#9ca3af', fill: '#9ca3af' }} />
|
||||
<span style={{ color: '#9ca3af', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5 }}>Backends</span>
|
||||
{backendCatalog.map(withProbed).map(b => {
|
||||
const s = STATUS_STYLE[b.status];
|
||||
return (
|
||||
<span key={b.id} title={`${b.name} — ${b.note}\n${b.url}`} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, padding: '2px 8px', borderRadius: 12, background: `${s.color}18`, color: s.color, cursor: 'help' }}>
|
||||
<s.Icon size={11} /> {b.name} <span style={{ opacity: 0.7 }}>· {s.label}</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
73
src/components/portal/LiveNetworkPanel.tsx
Normal file
73
src/components/portal/LiveNetworkPanel.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { Activity, Box, Cpu, ExternalLink, Gauge, Radio, RefreshCw } from 'lucide-react';
|
||||
import { useLiveChain } from '../../hooks/useLiveChain';
|
||||
import { endpoints } from '../../config/endpoints';
|
||||
import { explorerBlockUrl } from '../../services/explorer';
|
||||
|
||||
function ago(date: Date | null): string {
|
||||
if (!date) return '—';
|
||||
const s = Math.round((Date.now() - date.getTime()) / 1000);
|
||||
if (s < 60) return `${s}s ago`;
|
||||
const m = Math.floor(s / 60);
|
||||
if (m < 60) return `${m}m ago`;
|
||||
return `${Math.floor(m / 60)}h ago`;
|
||||
}
|
||||
|
||||
export default function LiveNetworkPanel() {
|
||||
const { health, latestBlock, stats, loading, error, lastUpdated, refresh } = useLiveChain();
|
||||
|
||||
const chainOk = health && health.chainId === endpoints.chain138.chainId;
|
||||
const statusLabel = error ? 'degraded' : chainOk ? 'live' : loading ? 'connecting' : 'offline';
|
||||
const statusColor = error ? '#ef4444' : chainOk ? '#22c55e' : loading ? '#eab308' : '#6b7280';
|
||||
|
||||
return (
|
||||
<div className="dashboard-card live-network-panel">
|
||||
<div className="card-header">
|
||||
<h3>
|
||||
<Radio size={16} style={{ color: statusColor }} /> Chain 138 — Live Network
|
||||
<span className="status-pill" style={{ background: `${statusColor}22`, color: statusColor, marginLeft: 8, padding: '2px 8px', borderRadius: 10, fontSize: 10, textTransform: 'uppercase' }}>
|
||||
{statusLabel}
|
||||
</span>
|
||||
</h3>
|
||||
<div className="card-header-actions" style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<span style={{ fontSize: 11, color: '#9ca3af' }}>{ago(lastUpdated)}</span>
|
||||
<button className="card-action" onClick={refresh} title="Refresh">
|
||||
<RefreshCw size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="live-network-grid" style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: 12, padding: '12px 16px' }}>
|
||||
<Stat icon={<Box size={14} />} label="Latest block" mono value={health ? health.blockNumber.toLocaleString() : '—'} href={latestBlock ? explorerBlockUrl(latestBlock.number) : undefined} />
|
||||
<Stat icon={<Cpu size={14} />} label="Chain ID" mono value={health ? `${health.chainId}` : '—'} />
|
||||
<Stat icon={<Gauge size={14} />} label="Gas price" mono value={health ? `${health.gasPriceGwei.toFixed(2)} gwei` : '—'} />
|
||||
<Stat icon={<Activity size={14} />} label="RPC latency" mono value={health ? `${health.latencyMs} ms` : '—'} />
|
||||
<Stat icon={<Box size={14} />} label="Total blocks" mono value={stats ? stats.total_blocks.toLocaleString() : '—'} />
|
||||
<Stat icon={<Activity size={14} />} label="Total txns" mono value={stats ? stats.total_transactions.toLocaleString() : '—'} />
|
||||
<Stat icon={<Activity size={14} />} label="Txns today" mono value={stats ? stats.transactions_today.toLocaleString() : '—'} />
|
||||
<Stat icon={<Cpu size={14} />} label="Addresses" mono value={stats ? stats.total_addresses.toLocaleString() : '—'} />
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '6px 16px 12px', fontSize: 11, color: '#6b7280', display: 'flex', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
|
||||
<span>
|
||||
RPC: <a href={endpoints.chain138.rpcUrl} target="_blank" rel="noreferrer" className="mono" style={{ color: '#9ca3af' }}>{endpoints.chain138.rpcUrl}</a>
|
||||
</span>
|
||||
<span>
|
||||
Explorer: <a href={endpoints.chain138.blockExplorerUrl} target="_blank" rel="noreferrer" style={{ color: '#9ca3af' }}>{endpoints.chain138.blockExplorerUrl} <ExternalLink size={10} style={{ verticalAlign: 'middle' }} /></a>
|
||||
</span>
|
||||
{error && <span style={{ color: '#ef4444' }}>Error: {error}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ icon, label, value, mono, href }: { icon: React.ReactNode; label: string; value: string; mono?: boolean; href?: string }) {
|
||||
const valueEl = (
|
||||
<span className={mono ? 'mono' : ''} style={{ fontSize: 15, fontWeight: 600 }}>{value}</span>
|
||||
);
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, minWidth: 0 }}>
|
||||
<span style={{ fontSize: 11, color: '#9ca3af', display: 'flex', alignItems: 'center', gap: 4 }}>{icon} {label}</span>
|
||||
{href ? <a href={href} target="_blank" rel="noreferrer" style={{ color: '#60a5fa', textDecoration: 'none' }}>{valueEl}</a> : valueEl}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user