Compare commits
1 Commits
devin/1776
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e83107d71f |
@@ -8,7 +8,6 @@ import ReportingPage from './pages/ReportingPage';
|
||||
import CompliancePage from './pages/CompliancePage';
|
||||
import SettlementsPage from './pages/SettlementsPage';
|
||||
import PortalLayout from './components/portal/PortalLayout';
|
||||
import LiveChainBanner from './components/portal/LiveChainBanner';
|
||||
import App from './App';
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
@@ -65,11 +64,8 @@ export default function Portal() {
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<PortalLayout>
|
||||
<div className="transaction-builder-module" style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<LiveChainBanner />
|
||||
<div style={{ flex: 1, minHeight: 0 }}>
|
||||
<App />
|
||||
</div>
|
||||
<div className="transaction-builder-module">
|
||||
<App />
|
||||
</div>
|
||||
</PortalLayout>
|
||||
</ProtectedRoute>
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import { useLiveChain } from '../../hooks/useLiveChain';
|
||||
import { endpoints } from '../../config/endpoints';
|
||||
import { Zap } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* Slim one-line banner suitable for embedding above dense UIs (e.g. the
|
||||
* transaction-builder canvas). Shows chain health + block + gas.
|
||||
* Flips to a red "RPC degraded" state on polling failure so you don't
|
||||
* accidentally compose a tx against a dead endpoint.
|
||||
*/
|
||||
export default function LiveChainBanner() {
|
||||
const { health, error, lastUpdated } = useLiveChain();
|
||||
const ok = !error && !!health;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
flexWrap: 'wrap',
|
||||
padding: '6px 12px',
|
||||
fontSize: 11,
|
||||
background: ok ? 'rgba(34,197,94,0.06)' : error ? 'rgba(239,68,68,0.08)' : 'rgba(148,163,184,0.06)',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.05)',
|
||||
}}
|
||||
>
|
||||
<Zap size={12} style={{ color: ok ? '#22c55e' : error ? '#ef4444' : '#6b7280' }} />
|
||||
<span style={{ color: '#cbd5e1' }}>
|
||||
Chain {endpoints.chain138.chainId} ({endpoints.chain138.name})
|
||||
</span>
|
||||
<span style={{ color: ok ? '#22c55e' : error ? '#ef4444' : '#eab308' }}>
|
||||
{error ? `● RPC degraded · ${error}` : ok ? '● LIVE' : '○ connecting…'}
|
||||
</span>
|
||||
{ok && (
|
||||
<>
|
||||
<span className="mono" style={{ color: '#6b7280' }}>block</span>
|
||||
<span className="mono">{health.blockNumber.toLocaleString()}</span>
|
||||
<span className="mono" style={{ color: '#6b7280' }}>gas</span>
|
||||
<span className="mono">{health.gasPriceGwei.toFixed(4)} gwei</span>
|
||||
<span className="mono" style={{ color: '#6b7280' }}>rpc</span>
|
||||
<span className="mono">{health.latencyMs}ms</span>
|
||||
</>
|
||||
)}
|
||||
<span style={{ marginLeft: 'auto', color: '#6b7280' }}>
|
||||
rpc: <a href={endpoints.chain138.rpcUrl} target="_blank" rel="noreferrer" style={{ color: '#60a5fa' }}>{endpoints.chain138.rpcUrl}</a>
|
||||
{lastUpdated && <span style={{ marginLeft: 8 }}>· {lastUpdated.toLocaleTimeString()}</span>}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
import { useLatestTransactions } from '../../hooks/useLatestTransactions';
|
||||
import { explorerTxUrl, explorerAddressUrl, type ExplorerTx } from '../../services/explorer';
|
||||
import { formatEther } from 'ethers';
|
||||
import { Activity } from 'lucide-react';
|
||||
|
||||
const shortHash = (h: string) => `${h.slice(0, 10)}…${h.slice(-6)}`;
|
||||
const shortAddr = (a: string) => `${a.slice(0, 6)}…${a.slice(-4)}`;
|
||||
const formatMETA = (wei: string) => {
|
||||
try { return `${Number(formatEther(BigInt(wei))).toFixed(4)} META`; } catch { return `${wei} wei`; }
|
||||
};
|
||||
const relativeTime = (iso: string) => {
|
||||
const then = new Date(iso).getTime();
|
||||
const dt = Date.now() - then;
|
||||
if (dt < 60_000) return `${Math.max(1, Math.round(dt / 1000))}s ago`;
|
||||
if (dt < 3_600_000) return `${Math.round(dt / 60_000)}m ago`;
|
||||
return `${Math.round(dt / 3_600_000)}h ago`;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
/** Max rows to show (default 10). */
|
||||
limit?: number;
|
||||
/** Custom card header label — defaults to "Live Chain-138 Transactions". */
|
||||
title?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the most recent on-chain transactions from SolaceScan.
|
||||
* Degraded state shows the error message; empty state shows a one-liner.
|
||||
* Links every hash/address to the explorer.
|
||||
*/
|
||||
export default function LiveTransactionsPanel({ limit = 10, title = 'Live Chain-138 Transactions' }: Props) {
|
||||
const { transactions, loading, error, lastUpdated } = useLatestTransactions(limit);
|
||||
|
||||
return (
|
||||
<div className="dashboard-card live-transactions-card">
|
||||
<div className="card-header">
|
||||
<h3><Activity size={16} /> {title}</h3>
|
||||
<span className="small" style={{ color: '#6b7280' }}>
|
||||
{error
|
||||
? <span style={{ color: '#ef4444' }}>RPC degraded · {error}</span>
|
||||
: loading
|
||||
? 'loading…'
|
||||
: `${transactions.length} tx · ${lastUpdated ? lastUpdated.toLocaleTimeString() : '—'}`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="live-transactions-list">
|
||||
{!loading && transactions.length === 0 && !error && (
|
||||
<div style={{ padding: 12, color: '#6b7280', fontSize: 12 }}>
|
||||
No transactions returned yet — SolaceScan may be indexing.
|
||||
</div>
|
||||
)}
|
||||
{transactions.map((tx: ExplorerTx) => (
|
||||
<div
|
||||
key={tx.hash}
|
||||
className="live-tx-row"
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1.3fr 1fr 1fr 0.9fr 0.7fr 0.4fr',
|
||||
gap: 8,
|
||||
padding: '6px 12px',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.04)',
|
||||
fontSize: 11,
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<a href={explorerTxUrl(tx.hash)} target="_blank" rel="noreferrer" className="mono" style={{ color: '#60a5fa' }}>
|
||||
{shortHash(tx.hash)}
|
||||
</a>
|
||||
<a href={explorerAddressUrl(tx.from.hash)} target="_blank" rel="noreferrer" className="mono" style={{ color: '#cbd5e1' }}>
|
||||
{shortAddr(tx.from.hash)}
|
||||
</a>
|
||||
<span className="mono" style={{ color: tx.to ? '#cbd5e1' : '#6b7280' }}>
|
||||
{tx.to ? shortAddr(tx.to.hash) : '— contract create —'}
|
||||
</span>
|
||||
<span className="mono">{formatMETA(tx.value)}</span>
|
||||
<span className="small" style={{ color: '#6b7280' }}>{relativeTime(tx.timestamp)}</span>
|
||||
<span style={{
|
||||
color: tx.status === 'error' ? '#ef4444' : tx.status === 'ok' ? '#22c55e' : '#eab308',
|
||||
fontSize: 9,
|
||||
}}>
|
||||
{tx.status ?? 'pending'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ padding: '6px 12px', fontSize: 10, color: '#6b7280' }}>
|
||||
Source: <a href="https://explorer.d-bis.org" target="_blank" rel="noreferrer" style={{ color: '#60a5fa' }}>SolaceScan Explorer</a>
|
||||
{' · polls every 15s · Blockscout v2 /transactions'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import type { OnChainBalance } from '../../services/chain138';
|
||||
import { endpoints } from '../../config/endpoints';
|
||||
import { explorerAddressUrl } from '../../services/explorer';
|
||||
|
||||
interface Props {
|
||||
address: string;
|
||||
balance: OnChainBalance | undefined;
|
||||
loading: boolean;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a small pill that flips between three states:
|
||||
* ● live · chain 138 — on-chain read succeeded
|
||||
* ○ fetching… — initial RPC call in flight
|
||||
* ○ off-chain — RPC call failed / empty state
|
||||
*
|
||||
* When `balance` is present, the pill becomes a link to the address page on
|
||||
* SolaceScan. We never hide this tag once a walletAddress is present —
|
||||
* otherwise there's no way to tell "not wired" apart from "backend is down".
|
||||
*/
|
||||
export default function OnChainBalanceTag({ address, balance, loading, compact }: Props) {
|
||||
const color = balance ? '#22c55e' : loading ? '#eab308' : '#6b7280';
|
||||
const label = balance ? `● live · chain ${endpoints.chain138.chainId}` : loading ? '○ fetching…' : '○ off-chain';
|
||||
const href = explorerAddressUrl(address);
|
||||
const style: React.CSSProperties = {
|
||||
fontSize: compact ? 9 : 10,
|
||||
color,
|
||||
textDecoration: 'none',
|
||||
letterSpacing: 0.2,
|
||||
};
|
||||
return (
|
||||
<a href={href} target="_blank" rel="noreferrer" title={`View ${address} on SolaceScan`} style={style}>
|
||||
{label}
|
||||
{balance && (
|
||||
<span className="mono" style={{ color: '#60a5fa', marginLeft: 6 }}>
|
||||
{Number(balance.balanceEth).toFixed(4)} META
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
/**
|
||||
* Central endpoint configuration for the Solace Bank Group PLC portal.
|
||||
*
|
||||
* All URLs can be overridden at build time via Vite env vars (VITE_*) so the
|
||||
* same codebase can target staging / production / local mocks without a rebuild.
|
||||
*
|
||||
* Live backend status (verified 2026-04-19):
|
||||
* - chain138.rpc LIVE (Besu QBFT, ChainID 138 / 0x8a)
|
||||
* - explorer.api LIVE (SolaceScan / Blockscout v2; CORS *)
|
||||
* - proxmox.api LIVE but CF-Access protected — browser calls
|
||||
* cannot carry CF-Access JWTs without an SSO flow,
|
||||
* so this is only reachable via a BFF today.
|
||||
* - dbisCore.api NOT DEPLOYED (api.dbis-core.d-bis.org 404/DNS)
|
||||
*/
|
||||
|
||||
export interface EndpointConfig {
|
||||
chain138: {
|
||||
rpcUrl: string;
|
||||
chainId: number;
|
||||
chainIdHex: `0x${string}`;
|
||||
name: string;
|
||||
nativeCurrency: { name: string; symbol: string; decimals: number };
|
||||
blockExplorerUrl: string;
|
||||
};
|
||||
explorer: {
|
||||
baseUrl: string;
|
||||
apiBaseUrl: string; // Blockscout v2
|
||||
};
|
||||
proxmox: {
|
||||
apiBaseUrl: string;
|
||||
/** proxmox-api.d-bis.org is behind Cloudflare Access — direct browser calls
|
||||
* are blocked. Must be proxied through a BFF that holds a CF-Access
|
||||
* Service Token, or the user must complete CF-Access SSO in-browser. */
|
||||
requiresBff: true;
|
||||
};
|
||||
dbisCore: {
|
||||
apiBaseUrl: string;
|
||||
/** dbis_core has no deployed public API yet. All methods fall back to
|
||||
* mock data with a console.warn. Flip this to `false` once the core
|
||||
* banking API is stood up. */
|
||||
mocked: true;
|
||||
};
|
||||
}
|
||||
|
||||
const env = (import.meta as unknown as { env?: Record<string, string> }).env ?? {};
|
||||
|
||||
export const endpoints: EndpointConfig = {
|
||||
chain138: {
|
||||
// Public gateway; `rpc-core.d-bis.org` is a working internal alias.
|
||||
rpcUrl: env.VITE_CHAIN138_RPC_URL || 'https://rpc.d-bis.org',
|
||||
chainId: 138,
|
||||
chainIdHex: '0x8a',
|
||||
name: 'DeFi Oracle Meta Mainnet',
|
||||
nativeCurrency: { name: 'Meta', symbol: 'META', decimals: 18 },
|
||||
blockExplorerUrl: env.VITE_EXPLORER_BASE_URL || 'https://explorer.d-bis.org',
|
||||
},
|
||||
explorer: {
|
||||
baseUrl: env.VITE_EXPLORER_BASE_URL || 'https://explorer.d-bis.org',
|
||||
apiBaseUrl: env.VITE_EXPLORER_API_BASE_URL || 'https://api.explorer.d-bis.org',
|
||||
},
|
||||
proxmox: {
|
||||
apiBaseUrl: env.VITE_PROXMOX_API_BASE_URL || 'https://proxmox-api.d-bis.org',
|
||||
requiresBff: true,
|
||||
},
|
||||
dbisCore: {
|
||||
apiBaseUrl: env.VITE_DBIS_CORE_API_BASE_URL || 'https://api.dbis-core.d-bis.org',
|
||||
mocked: true,
|
||||
},
|
||||
};
|
||||
|
||||
export type BackendStatus = 'live' | 'bff-required' | 'mocked' | 'degraded';
|
||||
|
||||
export interface BackendDescriptor {
|
||||
id: 'chain138' | 'explorer' | 'proxmox' | 'dbisCore';
|
||||
name: string;
|
||||
status: BackendStatus;
|
||||
url: string;
|
||||
note: string;
|
||||
}
|
||||
|
||||
export const backendCatalog: BackendDescriptor[] = [
|
||||
{
|
||||
id: 'chain138',
|
||||
name: 'Chain 138 RPC',
|
||||
status: 'live',
|
||||
url: endpoints.chain138.rpcUrl,
|
||||
note: 'DeFi Oracle Meta Mainnet — Besu / QBFT. Read-only browser calls via ethers.',
|
||||
},
|
||||
{
|
||||
id: 'explorer',
|
||||
name: 'SolaceScan Explorer',
|
||||
status: 'live',
|
||||
url: endpoints.explorer.apiBaseUrl,
|
||||
note: 'Blockscout v2 API. CORS * — safe for direct browser calls.',
|
||||
},
|
||||
{
|
||||
id: 'proxmox',
|
||||
name: 'Proxmox API',
|
||||
status: 'bff-required',
|
||||
url: endpoints.proxmox.apiBaseUrl,
|
||||
note: 'Live but behind Cloudflare Access. Needs a BFF/service token; mocked in the browser.',
|
||||
},
|
||||
{
|
||||
id: 'dbisCore',
|
||||
name: 'DBIS Core Banking',
|
||||
status: 'mocked',
|
||||
url: endpoints.dbisCore.apiBaseUrl,
|
||||
note: 'No public deployment yet. UI falls back to sample portal data.',
|
||||
},
|
||||
];
|
||||
@@ -1,54 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { getAddressTransactions, type ExplorerTx } from '../services/explorer';
|
||||
|
||||
export interface AddressTransactionsState {
|
||||
transactions: ExplorerTx[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
lastUpdated: Date | null;
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches recent transactions for a single address from SolaceScan.
|
||||
* Re-fetches on address change; also re-polls every `pollMs` (default 30s).
|
||||
* Empty address short-circuits — hook returns an idle state with no error.
|
||||
*/
|
||||
export function useAddressTransactions(address: string | null | undefined, limit = 10, pollMs = 30_000): AddressTransactionsState {
|
||||
const [transactions, setTransactions] = useState<ExplorerTx[]>([]);
|
||||
const [loading, setLoading] = useState(!!address);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
||||
const mounted = useRef(true);
|
||||
|
||||
const tick = useCallback(async () => {
|
||||
if (!address) {
|
||||
setTransactions([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const txs = await getAddressTransactions(address, limit);
|
||||
if (!mounted.current) return;
|
||||
setTransactions(txs);
|
||||
setError(null);
|
||||
setLastUpdated(new Date());
|
||||
} catch (e) {
|
||||
if (!mounted.current) return;
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
setTransactions([]);
|
||||
} finally {
|
||||
if (mounted.current) setLoading(false);
|
||||
}
|
||||
}, [address, limit]);
|
||||
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
void tick();
|
||||
if (!address) return () => { mounted.current = false; };
|
||||
const id = setInterval(tick, pollMs);
|
||||
return () => { mounted.current = false; clearInterval(id); };
|
||||
}, [tick, address, pollMs]);
|
||||
|
||||
return { transactions, loading, error, lastUpdated, refresh: () => { void tick(); } };
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { getLatestTransactions, type ExplorerTx } from '../services/explorer';
|
||||
|
||||
export interface LatestTransactionsState {
|
||||
transactions: ExplorerTx[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
lastUpdated: Date | null;
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Polls SolaceScan (Blockscout v2) `/transactions` every `pollMs` and
|
||||
* returns the top `limit` rows. Never throws — error surfaces in state.
|
||||
*/
|
||||
export function useLatestTransactions(limit = 20, pollMs = 15_000): LatestTransactionsState {
|
||||
const [transactions, setTransactions] = useState<ExplorerTx[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
||||
const mounted = useRef(true);
|
||||
|
||||
const tick = useCallback(async () => {
|
||||
try {
|
||||
const txs = await getLatestTransactions(limit);
|
||||
if (!mounted.current) return;
|
||||
setTransactions(txs);
|
||||
setError(null);
|
||||
setLastUpdated(new Date());
|
||||
} catch (e) {
|
||||
if (!mounted.current) return;
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
if (mounted.current) setLoading(false);
|
||||
}
|
||||
}, [limit]);
|
||||
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
void tick();
|
||||
const id = setInterval(tick, pollMs);
|
||||
return () => { mounted.current = false; clearInterval(id); };
|
||||
}, [tick, pollMs]);
|
||||
|
||||
return { transactions, loading, error, lastUpdated, refresh: () => { void tick(); } };
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { getChainHealth, getLatestBlock, type ChainHealth, type LatestBlock } from '../services/chain138';
|
||||
import { getExplorerStats, type ExplorerStats } from '../services/explorer';
|
||||
|
||||
export interface LiveChainState {
|
||||
health: ChainHealth | null;
|
||||
latestBlock: LatestBlock | null;
|
||||
stats: ExplorerStats | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
lastUpdated: Date | null;
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Polls chain-138 RPC + SolaceScan explorer every `pollMs` (default 12s).
|
||||
* Returns `null` values while loading the first time; never throws.
|
||||
*/
|
||||
export function useLiveChain(pollMs = 12_000): LiveChainState {
|
||||
const [health, setHealth] = useState<ChainHealth | null>(null);
|
||||
const [latestBlock, setLatestBlock] = useState<LatestBlock | null>(null);
|
||||
const [stats, setStats] = useState<ExplorerStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
||||
const mounted = useRef(true);
|
||||
|
||||
const tick = useCallback(async () => {
|
||||
try {
|
||||
const [h, b, s] = await Promise.allSettled([getChainHealth(), getLatestBlock(), getExplorerStats()]);
|
||||
if (!mounted.current) return;
|
||||
if (h.status === 'fulfilled') setHealth(h.value);
|
||||
if (b.status === 'fulfilled') setLatestBlock(b.value);
|
||||
if (s.status === 'fulfilled') setStats(s.value);
|
||||
const anyError = [h, b, s].find(r => r.status === 'rejected') as PromiseRejectedResult | undefined;
|
||||
setError(anyError ? String(anyError.reason?.message ?? anyError.reason) : null);
|
||||
setLastUpdated(new Date());
|
||||
} catch (e) {
|
||||
if (!mounted.current) return;
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
if (mounted.current) setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
void tick();
|
||||
const id = setInterval(tick, pollMs);
|
||||
return () => { mounted.current = false; clearInterval(id); };
|
||||
}, [tick, pollMs]);
|
||||
|
||||
return { health, latestBlock, stats, loading, error, lastUpdated, refresh: () => { void tick(); } };
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { getNativeBalances, type OnChainBalance } from '../services/chain138';
|
||||
|
||||
export interface OnChainBalancesState {
|
||||
balances: Record<string, OnChainBalance>;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
lastUpdated: Date | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches native Chain-138 balances for the given addresses and re-polls
|
||||
* every `pollMs` (default 30s). Addresses array must be stable — pass a
|
||||
* memoized list, or the hook will re-fetch on every render.
|
||||
*/
|
||||
export function useOnChainBalances(addresses: string[], pollMs = 30_000): OnChainBalancesState {
|
||||
const [balances, setBalances] = useState<Record<string, OnChainBalance>>({});
|
||||
const [loading, setLoading] = useState(addresses.length > 0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
||||
const mounted = useRef(true);
|
||||
|
||||
const key = addresses.join(',');
|
||||
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
if (addresses.length === 0) { setLoading(false); return; }
|
||||
let cancelled = false;
|
||||
|
||||
const tick = async () => {
|
||||
try {
|
||||
const result = await getNativeBalances(addresses);
|
||||
if (cancelled || !mounted.current) return;
|
||||
setBalances(result);
|
||||
setError(null);
|
||||
setLastUpdated(new Date());
|
||||
} catch (e) {
|
||||
if (cancelled || !mounted.current) return;
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
if (!cancelled && mounted.current) setLoading(false);
|
||||
}
|
||||
};
|
||||
void tick();
|
||||
const id = setInterval(tick, pollMs);
|
||||
return () => { cancelled = true; mounted.current = false; clearInterval(id); };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [key, pollMs]);
|
||||
|
||||
return { balances, loading, error, lastUpdated };
|
||||
}
|
||||
@@ -1,14 +1,10 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Building2, ChevronRight, ChevronDown, Search, Filter, Plus, Download,
|
||||
ExternalLink, Copy, MoreHorizontal
|
||||
} from 'lucide-react';
|
||||
import { sampleAccounts } from '../data/portalData';
|
||||
import type { Account, AccountType } from '../types/portal';
|
||||
import { useOnChainBalances } from '../hooks/useOnChainBalances';
|
||||
import type { OnChainBalance } from '../services/chain138';
|
||||
import OnChainBalanceTag from '../components/portal/OnChainBalanceTag';
|
||||
import { explorerAddressUrl } from '../services/explorer';
|
||||
|
||||
const typeColors: Record<AccountType, string> = {
|
||||
operating: '#3b82f6', reserve: '#22c55e', custody: '#a855f7', escrow: '#f97316',
|
||||
@@ -24,17 +20,9 @@ const formatBalance = (amount: number, currency: string) => {
|
||||
return `${sym}${amount.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
};
|
||||
|
||||
interface AccountRowProps {
|
||||
account: Account;
|
||||
level?: number;
|
||||
onChainBalances: Record<string, OnChainBalance>;
|
||||
balancesLoading: boolean;
|
||||
}
|
||||
|
||||
function AccountRow({ account, level = 0, onChainBalances, balancesLoading }: AccountRowProps) {
|
||||
function AccountRow({ account, level = 0 }: { account: Account; level?: number }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const hasChildren = account.subaccounts && account.subaccounts.length > 0;
|
||||
const onChain = account.walletAddress ? onChainBalances[account.walletAddress] : undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -51,16 +39,6 @@ function AccountRow({ account, level = 0, onChainBalances, balancesLoading }: Ac
|
||||
<div>
|
||||
<span className="account-name-text">{account.name}</span>
|
||||
<span className="account-type-label">{account.type.replace('_', ' ')}</span>
|
||||
{account.walletAddress && (
|
||||
<span style={{ display: 'block', marginTop: 2 }}>
|
||||
<OnChainBalanceTag
|
||||
address={account.walletAddress}
|
||||
balance={onChain}
|
||||
loading={balancesLoading}
|
||||
compact
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="account-table-cell currency">{account.currency}</div>
|
||||
@@ -71,18 +49,7 @@ function AccountRow({ account, level = 0, onChainBalances, balancesLoading }: Ac
|
||||
</div>
|
||||
<div className="account-table-cell identifier">
|
||||
{account.iban && <span className="mono small">{account.iban}</span>}
|
||||
{account.walletAddress && (
|
||||
<a
|
||||
href={explorerAddressUrl(account.walletAddress)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="mono small"
|
||||
title="View on SolaceScan"
|
||||
style={{ color: 'inherit', textDecoration: 'underline dotted' }}
|
||||
>
|
||||
{account.walletAddress.slice(0, 10)}…
|
||||
</a>
|
||||
)}
|
||||
{account.walletAddress && <span className="mono small">{account.walletAddress.slice(0, 10)}...</span>}
|
||||
{account.swift && <span className="swift-badge">{account.swift}</span>}
|
||||
</div>
|
||||
<div className="account-table-cell">
|
||||
@@ -95,7 +62,7 @@ function AccountRow({ account, level = 0, onChainBalances, balancesLoading }: Ac
|
||||
</div>
|
||||
</div>
|
||||
{expanded && hasChildren && account.subaccounts!.map(sub => (
|
||||
<AccountRow key={sub.id} account={sub} level={level + 1} onChainBalances={onChainBalances} balancesLoading={balancesLoading} />
|
||||
<AccountRow key={sub.id} account={sub} level={level + 1} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
@@ -106,15 +73,6 @@ export default function AccountsPage() {
|
||||
const [typeFilter, setTypeFilter] = useState<string>('all');
|
||||
const [view, setView] = useState<'tree' | 'flat'>('tree');
|
||||
|
||||
const onChainAddresses = useMemo(
|
||||
() => sampleAccounts
|
||||
.flatMap(a => [a, ...(a.subaccounts || [])])
|
||||
.filter(a => !!a.walletAddress)
|
||||
.map(a => a.walletAddress as string),
|
||||
[],
|
||||
);
|
||||
const { balances: onChainBalances, loading: balancesLoading } = useOnChainBalances(onChainAddresses);
|
||||
|
||||
const allAccounts = view === 'flat'
|
||||
? sampleAccounts.flatMap(a => [a, ...(a.subaccounts || [])])
|
||||
: sampleAccounts;
|
||||
@@ -217,12 +175,7 @@ export default function AccountsPage() {
|
||||
</div>
|
||||
<div className="account-table-body">
|
||||
{filtered.map(acc => (
|
||||
<AccountRow
|
||||
key={acc.id}
|
||||
account={acc}
|
||||
onChainBalances={onChainBalances}
|
||||
balancesLoading={balancesLoading}
|
||||
/>
|
||||
<AccountRow key={acc.id} account={acc} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Shield, AlertTriangle, CheckCircle2, Clock, Filter, Download, Eye, UserCheck, Activity } from 'lucide-react';
|
||||
import { complianceAlerts, sampleAccounts } from '../data/portalData';
|
||||
import { useLiveChain } from '../hooks/useLiveChain';
|
||||
import { useAddressTransactions } from '../hooks/useAddressTransactions';
|
||||
import { explorerAddressUrl } from '../services/explorer';
|
||||
import { endpoints } from '../config/endpoints';
|
||||
import { useState } from 'react';
|
||||
import { Shield, AlertTriangle, CheckCircle2, Clock, Filter, Download, Eye, UserCheck } from 'lucide-react';
|
||||
import { complianceAlerts } from '../data/portalData';
|
||||
|
||||
const severityColors: Record<string, string> = {
|
||||
critical: '#ef4444', high: '#f97316', medium: '#eab308', low: '#3b82f6',
|
||||
@@ -33,21 +29,6 @@ const regulatoryFrameworks = [
|
||||
export default function CompliancePage() {
|
||||
const [severityFilter, setSeverityFilter] = useState('all');
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
const { health, error: liveErr } = useLiveChain();
|
||||
|
||||
const tracked = useMemo(
|
||||
() => sampleAccounts
|
||||
.flatMap(a => [a, ...(a.subaccounts || [])])
|
||||
.filter(a => !!a.walletAddress)
|
||||
.map(a => ({ name: a.name, address: a.walletAddress as string, type: a.type })),
|
||||
[],
|
||||
);
|
||||
const [selectedWallet, setSelectedWallet] = useState(tracked[0]?.address ?? '');
|
||||
const {
|
||||
transactions: walletTxs,
|
||||
loading: walletLoading,
|
||||
error: walletErr,
|
||||
} = useAddressTransactions(selectedWallet, 10, 60_000);
|
||||
|
||||
const filtered = complianceAlerts.filter(a => {
|
||||
const matchSev = severityFilter === 'all' || a.severity === severityFilter;
|
||||
@@ -58,8 +39,6 @@ export default function CompliancePage() {
|
||||
const openCount = complianceAlerts.filter(a => a.status === 'open').length;
|
||||
const criticalCount = complianceAlerts.filter(a => a.severity === 'critical' && a.status !== 'resolved').length;
|
||||
|
||||
const selectedWalletName = tracked.find(t => t.address === selectedWallet)?.name ?? '';
|
||||
|
||||
return (
|
||||
<div className="compliance-page">
|
||||
<div className="page-header">
|
||||
@@ -73,44 +52,6 @@ export default function CompliancePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* On-Chain AML Monitoring strip */}
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr auto',
|
||||
gap: 12,
|
||||
alignItems: 'center',
|
||||
marginBottom: 12,
|
||||
padding: '10px 14px',
|
||||
border: '1px solid rgba(255,255,255,0.06)',
|
||||
borderRadius: 8,
|
||||
background: 'rgba(255,255,255,0.02)',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
||||
<Activity size={14} />
|
||||
<span style={{ fontSize: 12, color: '#cbd5e1' }}>
|
||||
On-chain AML monitor — Chain {endpoints.chain138.chainId}
|
||||
</span>
|
||||
<span style={{ fontSize: 11, color: liveErr ? '#ef4444' : '#22c55e' }}>
|
||||
{liveErr ? `● degraded · ${liveErr}` : health ? `● live · block ${health.blockNumber.toLocaleString()}` : '○ polling…'}
|
||||
</span>
|
||||
<span style={{ fontSize: 11, color: '#6b7280' }}>
|
||||
Tracked custody wallets: {tracked.length}
|
||||
</span>
|
||||
</div>
|
||||
<select
|
||||
value={selectedWallet}
|
||||
onChange={e => setSelectedWallet(e.target.value)}
|
||||
style={{ fontSize: 11, background: 'transparent', color: '#cbd5e1', padding: '4px 6px' }}
|
||||
>
|
||||
{tracked.length === 0 && <option value="">No tracked wallets</option>}
|
||||
{tracked.map(t => (
|
||||
<option key={t.address} value={t.address}>{t.name} · {t.address.slice(0, 8)}…</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Compliance Metrics */}
|
||||
<div className="compliance-metrics">
|
||||
{complianceMetrics.map(m => (
|
||||
@@ -175,60 +116,6 @@ export default function CompliancePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* On-chain transactions for the selected tracked wallet */}
|
||||
<div className="dashboard-card">
|
||||
<div className="card-header">
|
||||
<h3><Activity size={16} /> On-Chain Tx Feed</h3>
|
||||
<span className="small" style={{ color: '#6b7280' }}>
|
||||
{selectedWalletName ? selectedWalletName : 'select a wallet above'}
|
||||
{walletLoading ? ' · loading…' : walletErr ? ` · ${walletErr}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ maxHeight: 220, overflowY: 'auto' }}>
|
||||
{!walletLoading && walletTxs.length === 0 && !walletErr && (
|
||||
<div style={{ padding: 12, fontSize: 11, color: '#6b7280' }}>
|
||||
No on-chain activity for this wallet yet.
|
||||
{selectedWallet && (
|
||||
<> View on <a
|
||||
href={explorerAddressUrl(selectedWallet)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style={{ color: '#60a5fa' }}
|
||||
>SolaceScan</a>.</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{walletTxs.map(tx => (
|
||||
<div
|
||||
key={tx.hash}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr 0.8fr 0.5fr',
|
||||
gap: 6,
|
||||
padding: '6px 12px',
|
||||
fontSize: 11,
|
||||
borderBottom: '1px solid rgba(255,255,255,0.04)',
|
||||
}}
|
||||
>
|
||||
<span className="mono" style={{ color: '#60a5fa' }}>
|
||||
<a href={`${endpoints.explorer.baseUrl}/tx/${tx.hash}`} target="_blank" rel="noreferrer" style={{ color: 'inherit' }}>
|
||||
{tx.hash.slice(0, 14)}…
|
||||
</a>
|
||||
</span>
|
||||
<span className="mono">
|
||||
{tx.from.hash.toLowerCase() === selectedWallet.toLowerCase() ? 'OUT →' : 'IN ←'}
|
||||
{' '}
|
||||
{(tx.from.hash.toLowerCase() === selectedWallet.toLowerCase() ? tx.to?.hash : tx.from.hash)?.slice(0, 10) ?? '—'}…
|
||||
</span>
|
||||
<span className="small mono">{new Date(tx.timestamp).toLocaleTimeString()}</span>
|
||||
<span style={{ color: tx.status === 'error' ? '#ef4444' : '#22c55e', fontSize: 9 }}>
|
||||
{tx.status ?? 'pending'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Regulatory Frameworks */}
|
||||
<div className="dashboard-card regulatory-card">
|
||||
<div className="card-header">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
TrendingUp, TrendingDown, DollarSign, Activity, AlertTriangle, Clock,
|
||||
@@ -6,10 +6,6 @@ import {
|
||||
Landmark, FileText, Shield, CheckSquare, ChevronRight, RefreshCw
|
||||
} from 'lucide-react';
|
||||
import { financialSummary, sampleAccounts, treasuryPositions, complianceAlerts, recentActivity, portalModules } from '../data/portalData';
|
||||
import LiveNetworkPanel from '../components/portal/LiveNetworkPanel';
|
||||
import BackendStatusBar from '../components/portal/BackendStatusBar';
|
||||
import { useOnChainBalances } from '../hooks/useOnChainBalances';
|
||||
import { endpoints } from '../config/endpoints';
|
||||
|
||||
const formatCurrency = (amount: number, currency = 'USD') => {
|
||||
if (Math.abs(amount) >= 1_000_000_000) return `${currency === 'USD' ? '$' : ''}${(amount / 1_000_000_000).toFixed(2)}B`;
|
||||
@@ -60,19 +56,12 @@ export default function DashboardPage() {
|
||||
|
||||
const openAlerts = complianceAlerts.filter(a => a.status !== 'resolved');
|
||||
|
||||
const onChainAddresses = useMemo(
|
||||
() => sampleAccounts.filter(a => !!a.walletAddress).map(a => a.walletAddress as string),
|
||||
[],
|
||||
);
|
||||
const { balances: onChainBalances, loading: balancesLoading } = useOnChainBalances(onChainAddresses);
|
||||
|
||||
return (
|
||||
<div className="dashboard-page">
|
||||
<div className="dashboard-header">
|
||||
<div className="dashboard-header-left">
|
||||
<h1>Portfolio Overview</h1>
|
||||
<p className="dashboard-subtitle">Solace Bank Group PLC — Consolidated View</p>
|
||||
<div style={{ marginTop: 10 }}><BackendStatusBar /></div>
|
||||
</div>
|
||||
<div className="dashboard-header-right">
|
||||
<div className="time-range-selector">
|
||||
@@ -165,11 +154,6 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
|
||||
<div className="dashboard-grid">
|
||||
{/* Chain 138 live network health — wired to rpc-core.d-bis.org + explorer */}
|
||||
<div style={{ gridColumn: '1 / -1' }}>
|
||||
<LiveNetworkPanel />
|
||||
</div>
|
||||
|
||||
{/* Asset Allocation */}
|
||||
<div className="dashboard-card asset-allocation">
|
||||
<div className="card-header">
|
||||
@@ -233,33 +217,20 @@ export default function DashboardPage() {
|
||||
<button className="card-action" onClick={() => navigate('/accounts')}>Manage <ChevronRight size={12} /></button>
|
||||
</div>
|
||||
<div className="accounts-list">
|
||||
{sampleAccounts.filter(a => !a.parentId).slice(0, 5).map(acc => {
|
||||
const onChain = acc.walletAddress ? onChainBalances[acc.walletAddress] : undefined;
|
||||
return (
|
||||
<div key={acc.id} className="account-row">
|
||||
<div className="account-info">
|
||||
<span className={`account-type-badge ${acc.type}`}>{acc.type}</span>
|
||||
<span className="account-name">{acc.name}</span>
|
||||
{acc.walletAddress && (
|
||||
<span style={{ fontSize: 10, color: onChain ? '#22c55e' : balancesLoading ? '#eab308' : '#6b7280' }}>
|
||||
{onChain ? `● live · chain ${endpoints.chain138.chainId}` : balancesLoading ? '○ fetching…' : '○ off-chain'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="account-balance">
|
||||
<span className="mono">
|
||||
{acc.currency === 'BTC' ? `${acc.balance.toFixed(2)} BTC` : formatCurrency(acc.balance, acc.currency)}
|
||||
</span>
|
||||
<span className="account-currency">{acc.currency}</span>
|
||||
{onChain && (
|
||||
<span className="mono" style={{ fontSize: 10, color: '#60a5fa', marginTop: 2 }}>
|
||||
on-chain: {Number(onChain.balanceEth).toFixed(4)} META
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{sampleAccounts.filter(a => !a.parentId).slice(0, 5).map(acc => (
|
||||
<div key={acc.id} className="account-row">
|
||||
<div className="account-info">
|
||||
<span className={`account-type-badge ${acc.type}`}>{acc.type}</span>
|
||||
<span className="account-name">{acc.name}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="account-balance">
|
||||
<span className="mono">
|
||||
{acc.currency === 'BTC' ? `${acc.balance.toFixed(2)} BTC` : formatCurrency(acc.balance, acc.currency)}
|
||||
</span>
|
||||
<span className="account-currency">{acc.currency}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { FileText, Download, Filter, Plus, Eye, Clock, CheckCircle2, AlertTriangle, Send, Database } from 'lucide-react';
|
||||
import { FileText, Download, Filter, Plus, Eye, Clock, CheckCircle2, AlertTriangle, Send } from 'lucide-react';
|
||||
import { reportConfigs } from '../data/portalData';
|
||||
import type { ReportingStandard } from '../types/portal';
|
||||
import { useLiveChain } from '../hooks/useLiveChain';
|
||||
import { endpoints } from '../config/endpoints';
|
||||
|
||||
const standardColors: Record<ReportingStandard, string> = {
|
||||
IPSAS: '#a855f7',
|
||||
@@ -29,7 +27,6 @@ export default function ReportingPage() {
|
||||
const [standardFilter, setStandardFilter] = useState<string>('all');
|
||||
const [typeFilter, setTypeFilter] = useState<string>('all');
|
||||
const [activeStandard, setActiveStandard] = useState<ReportingStandard>('IFRS');
|
||||
const { health, stats, error: liveErr, lastUpdated: liveUpdatedAt } = useLiveChain();
|
||||
|
||||
const filtered = reportConfigs.filter(r => {
|
||||
const matchStandard = standardFilter === 'all' || r.standard === standardFilter;
|
||||
@@ -73,63 +70,6 @@ export default function ReportingPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* On-Chain Reporting Snapshot — live data from Chain-138 + SolaceScan */}
|
||||
<div
|
||||
className="onchain-report-snapshot"
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))',
|
||||
gap: 12,
|
||||
marginBottom: 16,
|
||||
padding: 12,
|
||||
border: '1px solid rgba(255,255,255,0.06)',
|
||||
borderRadius: 8,
|
||||
background: 'rgba(255,255,255,0.02)',
|
||||
}}
|
||||
>
|
||||
<div style={{ gridColumn: '1 / -1', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: '#cbd5e1' }}>
|
||||
<Database size={14} /> On-Chain Reporting Snapshot — Chain {endpoints.chain138.chainId}
|
||||
</span>
|
||||
<span className="small" style={{ color: liveErr ? '#ef4444' : '#6b7280' }}>
|
||||
{liveErr ? `RPC degraded · ${liveErr}` : liveUpdatedAt ? `updated ${liveUpdatedAt.toLocaleTimeString()}` : 'polling…'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="summary-label" style={{ fontSize: 10 }}>Latest Block</span>
|
||||
<div className="mono" style={{ fontSize: 18 }}>{liveErr ? '—' : (health?.blockNumber?.toLocaleString() ?? '…')}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="summary-label" style={{ fontSize: 10 }}>Total Blocks (ledger depth)</span>
|
||||
<div className="mono" style={{ fontSize: 18 }}>{liveErr ? '—' : (stats?.total_blocks?.toLocaleString() ?? '…')}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="summary-label" style={{ fontSize: 10 }}>Total Transactions</span>
|
||||
<div className="mono" style={{ fontSize: 18 }}>{liveErr ? '—' : (stats?.total_transactions?.toLocaleString() ?? '…')}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="summary-label" style={{ fontSize: 10 }}>Total Addresses</span>
|
||||
<div className="mono" style={{ fontSize: 18 }}>{liveErr ? '—' : (stats?.total_addresses?.toLocaleString() ?? '…')}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="summary-label" style={{ fontSize: 10 }}>Network Utilisation</span>
|
||||
<div className="mono" style={{ fontSize: 18 }}>
|
||||
{liveErr ? '—' : (stats ? `${stats.network_utilization_percentage.toFixed(1)}%` : '…')}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="summary-label" style={{ fontSize: 10 }}>Avg Block Time</span>
|
||||
<div className="mono" style={{ fontSize: 18 }}>
|
||||
{liveErr ? '—' : (stats ? `${stats.average_block_time.toFixed(1)}s` : '…')}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ gridColumn: '1 / -1', fontSize: 10, color: '#6b7280' }}>
|
||||
Sources: <a href={endpoints.chain138.rpcUrl} target="_blank" rel="noreferrer" style={{ color: '#60a5fa' }}>{endpoints.chain138.rpcUrl}</a>
|
||||
{' · '}<a href={`${endpoints.explorer.apiBaseUrl}/api/v2/stats`} target="_blank" rel="noreferrer" style={{ color: '#60a5fa' }}>{endpoints.explorer.apiBaseUrl}/api/v2/stats</a>
|
||||
{' · the IFRS / US GAAP / IPSAS reports below are generated by dbis_core (currently mocked — no public deployment).'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Standards Overview */}
|
||||
<div className="standards-tabs">
|
||||
{(['IPSAS', 'US_GAAP', 'IFRS'] as ReportingStandard[]).map(std => (
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { CheckSquare, Filter, Download, Clock, CheckCircle2, XCircle, ArrowUpDown } from 'lucide-react';
|
||||
import { settlementRecords } from '../data/portalData';
|
||||
import LiveTransactionsPanel from '../components/portal/LiveTransactionsPanel';
|
||||
import { useLiveChain } from '../hooks/useLiveChain';
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
pending: '#eab308', matched: '#3b82f6', affirmed: '#a855f7',
|
||||
@@ -28,7 +26,6 @@ export default function SettlementsPage() {
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
const [typeFilter, setTypeFilter] = useState('all');
|
||||
const [sortBy, setSortBy] = useState<'date' | 'amount'>('date');
|
||||
const { health, stats, error: liveErr } = useLiveChain();
|
||||
|
||||
const filtered = settlementRecords
|
||||
.filter(s => (statusFilter === 'all' || s.status === statusFilter) && (typeFilter === 'all' || s.type === typeFilter))
|
||||
@@ -55,31 +52,24 @@ export default function SettlementsPage() {
|
||||
|
||||
<div className="settlements-summary">
|
||||
<div className="summary-card">
|
||||
<span className="summary-label">Pending (CSD)</span>
|
||||
<span className="summary-label">Pending</span>
|
||||
<span className="summary-value orange">{pendingCount}</span>
|
||||
<span className="summary-sub">{formatCurrency(totalPending, 'USD')} total</span>
|
||||
</div>
|
||||
<div className="summary-card">
|
||||
<span className="summary-label">Settled (CSD)</span>
|
||||
<span className="summary-label">Settled</span>
|
||||
<span className="summary-value green">{settledCount}</span>
|
||||
</div>
|
||||
<div className="summary-card">
|
||||
<span className="summary-label">Failed (CSD)</span>
|
||||
<span className="summary-label">Failed</span>
|
||||
<span className="summary-value red">{failedCount}</span>
|
||||
</div>
|
||||
<div className="summary-card">
|
||||
<span className="summary-label">Chain-138 Block</span>
|
||||
<span className="summary-value" style={{ color: liveErr ? '#ef4444' : '#22c55e' }}>
|
||||
{liveErr ? '—' : health?.blockNumber?.toLocaleString() ?? '…'}
|
||||
</span>
|
||||
<span className="summary-sub">
|
||||
{liveErr ? 'RPC degraded' : stats ? `${stats.transactions_today.toLocaleString()} tx today` : 'polling…'}
|
||||
</span>
|
||||
<span className="summary-label">Settlement Rate</span>
|
||||
<span className="summary-value">{settledCount > 0 ? ((settledCount / (settledCount + failedCount)) * 100).toFixed(0) : 0}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LiveTransactionsPanel limit={12} />
|
||||
|
||||
<div className="dashboard-card">
|
||||
<div className="card-header">
|
||||
<h3>Settlement Queue</h3>
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Landmark, TrendingUp, TrendingDown, Download, Filter, ArrowUpDown, RefreshCw, Zap } from 'lucide-react';
|
||||
import { treasuryPositions, cashForecasts, sampleAccounts } from '../data/portalData';
|
||||
import { useLiveChain } from '../hooks/useLiveChain';
|
||||
import { useOnChainBalances } from '../hooks/useOnChainBalances';
|
||||
import { endpoints } from '../config/endpoints';
|
||||
import { useState } from 'react';
|
||||
import { Landmark, TrendingUp, TrendingDown, Download, Filter, ArrowUpDown, RefreshCw } from 'lucide-react';
|
||||
import { treasuryPositions, cashForecasts } from '../data/portalData';
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
if (Math.abs(amount) >= 1_000_000) return `$${(amount / 1_000_000).toFixed(2)}M`;
|
||||
@@ -14,18 +11,6 @@ const formatCurrency = (amount: number) => {
|
||||
export default function TreasuryPage() {
|
||||
const [assetFilter, setAssetFilter] = useState('all');
|
||||
const [sortBy, setSortBy] = useState<'value' | 'pnl' | 'name'>('value');
|
||||
const { health, error: liveErr } = useLiveChain();
|
||||
|
||||
const custodyAddresses = useMemo(
|
||||
() => sampleAccounts
|
||||
.flatMap(a => [a, ...(a.subaccounts || [])])
|
||||
.filter(a => !!a.walletAddress)
|
||||
.map(a => a.walletAddress as string),
|
||||
[],
|
||||
);
|
||||
const { balances: onChainBalances } = useOnChainBalances(custodyAddresses);
|
||||
const totalOnChainMETA = Object.values(onChainBalances)
|
||||
.reduce((sum, b) => sum + Number(b.balanceEth || 0), 0);
|
||||
|
||||
const assetClasses = [...new Set(treasuryPositions.map(p => p.assetClass))];
|
||||
|
||||
@@ -78,24 +63,6 @@ export default function TreasuryPage() {
|
||||
{totalPnL >= 0 ? '+' : ''}{((totalPnL / totalCostBasis) * 100).toFixed(2)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="summary-card">
|
||||
<span className="summary-label"><Zap size={11} /> Chain-138 Gas</span>
|
||||
<span className="summary-value" style={{ color: liveErr ? '#ef4444' : '#60a5fa' }}>
|
||||
{liveErr ? '—' : health ? `${health.gasPriceGwei.toFixed(3)} gwei` : '…'}
|
||||
</span>
|
||||
<span className="summary-sub">
|
||||
{liveErr ? 'RPC degraded' : health ? `block ${health.blockNumber.toLocaleString()}` : 'polling…'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="summary-card">
|
||||
<span className="summary-label">On-Chain Custody (META)</span>
|
||||
<span className="summary-value">
|
||||
{totalOnChainMETA.toFixed(4)}
|
||||
</span>
|
||||
<span className="summary-sub">
|
||||
{custodyAddresses.length} custody wallet{custodyAddresses.length === 1 ? '' : 's'} · chain {endpoints.chain138.chainId}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="treasury-grid">
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
/**
|
||||
* Chain 138 (DeFi Oracle Meta Mainnet) read-only client.
|
||||
*
|
||||
* Uses `ethers` v6 JsonRpcProvider against `rpc-core.d-bis.org`.
|
||||
* All calls are read-only — no signing, no tx submission from here.
|
||||
* Wallet signing happens through the MetaMask BrowserProvider in AuthContext.
|
||||
*/
|
||||
|
||||
import { JsonRpcProvider, formatEther, formatUnits, getAddress } from 'ethers';
|
||||
import { endpoints } from '../config/endpoints';
|
||||
|
||||
/**
|
||||
* Normalize an EVM address before handing it to the provider. Ethers v6
|
||||
* enforces EIP-55 checksum for mixed-case addresses and throws
|
||||
* `bad address checksum` otherwise — which silently loses balances for any
|
||||
* hand-typed sample address whose casing doesn't match the canonical
|
||||
* checksum. Lowercasing sidesteps that validation while remaining a
|
||||
* perfectly valid on-chain reference. If the string isn't a well-formed
|
||||
* address at all we still let `getAddress` surface the error.
|
||||
*/
|
||||
function normalizeAddress(address: string): string {
|
||||
try {
|
||||
return getAddress(address);
|
||||
} catch {
|
||||
return getAddress(address.toLowerCase());
|
||||
}
|
||||
}
|
||||
|
||||
let _provider: JsonRpcProvider | null = null;
|
||||
|
||||
export function getChain138Provider(): JsonRpcProvider {
|
||||
if (_provider) return _provider;
|
||||
_provider = new JsonRpcProvider(endpoints.chain138.rpcUrl, {
|
||||
chainId: endpoints.chain138.chainId,
|
||||
name: endpoints.chain138.name,
|
||||
});
|
||||
return _provider;
|
||||
}
|
||||
|
||||
export interface ChainHealth {
|
||||
chainId: number;
|
||||
blockNumber: number;
|
||||
gasPriceGwei: number;
|
||||
latencyMs: number;
|
||||
rpcUrl: string;
|
||||
}
|
||||
|
||||
export async function getChainHealth(): Promise<ChainHealth> {
|
||||
const provider = getChain138Provider();
|
||||
const t0 = performance.now();
|
||||
const [network, blockNumber, feeData] = await Promise.all([
|
||||
provider.getNetwork(),
|
||||
provider.getBlockNumber(),
|
||||
provider.getFeeData(),
|
||||
]);
|
||||
const latencyMs = Math.round(performance.now() - t0);
|
||||
const gasPriceWei = feeData.gasPrice ?? feeData.maxFeePerGas ?? 0n;
|
||||
const gasPriceGwei = Number(formatUnits(gasPriceWei, 'gwei'));
|
||||
return {
|
||||
chainId: Number(network.chainId),
|
||||
blockNumber,
|
||||
gasPriceGwei,
|
||||
latencyMs,
|
||||
rpcUrl: endpoints.chain138.rpcUrl,
|
||||
};
|
||||
}
|
||||
|
||||
export interface OnChainBalance {
|
||||
address: string;
|
||||
balanceEth: string;
|
||||
balanceWei: string;
|
||||
blockNumber: number;
|
||||
}
|
||||
|
||||
export async function getNativeBalance(address: string): Promise<OnChainBalance> {
|
||||
const provider = getChain138Provider();
|
||||
const normalized = normalizeAddress(address);
|
||||
const [balanceWei, blockNumber] = await Promise.all([
|
||||
provider.getBalance(normalized),
|
||||
provider.getBlockNumber(),
|
||||
]);
|
||||
return {
|
||||
address,
|
||||
balanceWei: balanceWei.toString(),
|
||||
balanceEth: formatEther(balanceWei),
|
||||
blockNumber,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getNativeBalances(addresses: string[]): Promise<Record<string, OnChainBalance>> {
|
||||
const results = await Promise.all(
|
||||
addresses.map(a =>
|
||||
getNativeBalance(a).catch(err => {
|
||||
// Surface in dev — otherwise a single bad address silently disappears
|
||||
// from the balances map and the UI shows "off-chain" forever.
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`[chain138] getNativeBalance(${a}) failed:`, err);
|
||||
return { address: a, error: err };
|
||||
}),
|
||||
),
|
||||
);
|
||||
const out: Record<string, OnChainBalance> = {};
|
||||
for (const r of results) {
|
||||
if ('error' in r) continue;
|
||||
out[r.address] = r;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface LatestBlock {
|
||||
number: number;
|
||||
hash: string;
|
||||
timestamp: number;
|
||||
txCount: number;
|
||||
gasUsed: string;
|
||||
gasLimit: string;
|
||||
miner: string;
|
||||
}
|
||||
|
||||
export async function getLatestBlock(): Promise<LatestBlock | null> {
|
||||
const provider = getChain138Provider();
|
||||
const block = await provider.getBlock('latest');
|
||||
if (!block) return null;
|
||||
return {
|
||||
number: block.number,
|
||||
hash: block.hash ?? '',
|
||||
timestamp: block.timestamp,
|
||||
txCount: block.transactions.length,
|
||||
gasUsed: block.gasUsed?.toString() ?? '0',
|
||||
gasLimit: block.gasLimit?.toString() ?? '0',
|
||||
miner: block.miner ?? '',
|
||||
};
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
/**
|
||||
* DBIS Core Banking client — STUB.
|
||||
*
|
||||
* The `d-bis/dbis_core` system is fully specified (707-line README describing
|
||||
* Global Ledger, Accounts, Payments, FX Engine, CBDC, Compliance, Settlement)
|
||||
* but it is not currently deployed at any resolvable hostname. `api.dbis-core.d-bis.org`
|
||||
* fails DNS.
|
||||
*
|
||||
* Every method here returns the existing sample data from `src/data/portalData.ts`
|
||||
* with a one-time console.warn so it's obvious the portal is not yet wired to
|
||||
* the real ledger. Flip `endpoints.dbisCore.mocked = false` (and implement the
|
||||
* fetch bodies below) when the core banking API is stood up.
|
||||
*/
|
||||
|
||||
import type { Account, FinancialSummary, TreasuryPosition, CashForecast, SettlementRecord, ReportConfig } from '../types/portal';
|
||||
import { sampleAccounts, financialSummary, treasuryPositions, cashForecasts, settlementRecords, reportConfigs } from '../data/portalData';
|
||||
import { endpoints } from '../config/endpoints';
|
||||
|
||||
let warned = false;
|
||||
function warnMock(method: string): void {
|
||||
if (warned) return;
|
||||
warned = true;
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`[dbisCore] Using sample data for ${method}() — dbis_core API is not deployed. ` +
|
||||
`Set VITE_DBIS_CORE_API_BASE_URL and flip endpoints.dbisCore.mocked once available.`,
|
||||
);
|
||||
}
|
||||
|
||||
function delay<T>(value: T, ms = 150): Promise<T> {
|
||||
return new Promise(resolve => setTimeout(() => resolve(value), ms));
|
||||
}
|
||||
|
||||
export async function listAccounts(): Promise<Account[]> {
|
||||
if (endpoints.dbisCore.mocked) { warnMock('listAccounts'); return delay(sampleAccounts); }
|
||||
// TODO: fetch(`${endpoints.dbisCore.apiBaseUrl}/v1/accounts`)
|
||||
throw new Error('dbis_core live mode not implemented');
|
||||
}
|
||||
|
||||
export async function getFinancialSummary(): Promise<FinancialSummary> {
|
||||
if (endpoints.dbisCore.mocked) { warnMock('getFinancialSummary'); return delay(financialSummary); }
|
||||
throw new Error('dbis_core live mode not implemented');
|
||||
}
|
||||
|
||||
export async function listTreasuryPositions(): Promise<TreasuryPosition[]> {
|
||||
if (endpoints.dbisCore.mocked) { warnMock('listTreasuryPositions'); return delay(treasuryPositions); }
|
||||
throw new Error('dbis_core live mode not implemented');
|
||||
}
|
||||
|
||||
export async function listCashForecasts(): Promise<CashForecast[]> {
|
||||
if (endpoints.dbisCore.mocked) { warnMock('listCashForecasts'); return delay(cashForecasts); }
|
||||
throw new Error('dbis_core live mode not implemented');
|
||||
}
|
||||
|
||||
export async function listSettlements(): Promise<SettlementRecord[]> {
|
||||
if (endpoints.dbisCore.mocked) { warnMock('listSettlements'); return delay(settlementRecords); }
|
||||
throw new Error('dbis_core live mode not implemented');
|
||||
}
|
||||
|
||||
export async function listReports(): Promise<ReportConfig[]> {
|
||||
if (endpoints.dbisCore.mocked) { warnMock('listReports'); return delay(reportConfigs); }
|
||||
throw new Error('dbis_core live mode not implemented');
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
/**
|
||||
* SolaceScan Explorer (Blockscout v2) client for Chain 138.
|
||||
*
|
||||
* Base URL: https://api.explorer.d-bis.org (CORS *)
|
||||
* Fallback: https://explorer.d-bis.org/api/v2 (same data, different host)
|
||||
*
|
||||
* We hit the `api.*` subdomain by default because it returns clean JSON
|
||||
* without the Next.js HTML wrapper.
|
||||
*/
|
||||
|
||||
import { httpJson } from './http';
|
||||
import { endpoints } from '../config/endpoints';
|
||||
|
||||
const api = (path: string) => `${endpoints.explorer.apiBaseUrl}/api/v2${path}`;
|
||||
|
||||
export interface ExplorerStats {
|
||||
total_blocks: number;
|
||||
total_transactions: number;
|
||||
total_addresses: number;
|
||||
latest_block: number;
|
||||
average_block_time: number;
|
||||
gas_prices: { average: number; fast?: number; slow?: number };
|
||||
network_utilization_percentage: number;
|
||||
transactions_today: number;
|
||||
}
|
||||
|
||||
export async function getExplorerStats(): Promise<ExplorerStats> {
|
||||
const raw = await httpJson<ExplorerStats>(api('/stats'));
|
||||
// Blockscout returns `average_block_time` in milliseconds; normalize to seconds
|
||||
// so callers can display `${value.toFixed(1)}s` directly. Chain-138 block time
|
||||
// is ~4s, so a raw value > 60 is a reliable signal that it is still in ms.
|
||||
const average_block_time =
|
||||
typeof raw.average_block_time === 'number' && raw.average_block_time > 60
|
||||
? raw.average_block_time / 1000
|
||||
: raw.average_block_time;
|
||||
return { ...raw, average_block_time };
|
||||
}
|
||||
|
||||
export interface ExplorerBlock {
|
||||
height: number;
|
||||
hash: string;
|
||||
timestamp: string;
|
||||
tx_count: number;
|
||||
gas_used: string;
|
||||
gas_limit: string;
|
||||
size: number;
|
||||
miner: { hash: string };
|
||||
}
|
||||
|
||||
export async function getLatestBlocks(): Promise<ExplorerBlock[]> {
|
||||
return httpJson<ExplorerBlock[]>(api('/main-page/blocks'));
|
||||
}
|
||||
|
||||
export interface ExplorerTx {
|
||||
hash: string;
|
||||
block_number: number;
|
||||
timestamp: string;
|
||||
from: { hash: string };
|
||||
to: { hash: string } | null;
|
||||
value: string; // wei
|
||||
gas_used: string;
|
||||
gas_price: string;
|
||||
status: 'ok' | 'error' | null;
|
||||
method: string | null;
|
||||
fee: { value: string };
|
||||
}
|
||||
|
||||
interface PagedTxResponse { items: ExplorerTx[]; next_page_params?: unknown }
|
||||
|
||||
export async function getLatestTransactions(limit = 20): Promise<ExplorerTx[]> {
|
||||
const data = await httpJson<PagedTxResponse>(api('/transactions'));
|
||||
return (data.items ?? []).slice(0, limit);
|
||||
}
|
||||
|
||||
export async function getAddressTransactions(address: string, limit = 20): Promise<ExplorerTx[]> {
|
||||
const data = await httpJson<PagedTxResponse>(api(`/addresses/${address}/transactions`));
|
||||
return (data.items ?? []).slice(0, limit);
|
||||
}
|
||||
|
||||
export function explorerTxUrl(hash: string): string {
|
||||
return `${endpoints.explorer.baseUrl}/tx/${hash}`;
|
||||
}
|
||||
|
||||
export function explorerAddressUrl(address: string): string {
|
||||
return `${endpoints.explorer.baseUrl}/address/${address}`;
|
||||
}
|
||||
|
||||
export function explorerBlockUrl(height: number): string {
|
||||
return `${endpoints.explorer.baseUrl}/block/${height}`;
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
/**
|
||||
* Thin fetch wrapper with timeout + JSON handling + typed errors.
|
||||
* Keep this dependency-free so every service can share it.
|
||||
*/
|
||||
|
||||
export class HttpError extends Error {
|
||||
readonly status: number;
|
||||
readonly statusText: string;
|
||||
readonly url: string;
|
||||
readonly body?: unknown;
|
||||
constructor(status: number, statusText: string, url: string, body?: unknown) {
|
||||
super(`HTTP ${status} ${statusText} (${url})`);
|
||||
this.name = 'HttpError';
|
||||
this.status = status;
|
||||
this.statusText = statusText;
|
||||
this.url = url;
|
||||
this.body = body;
|
||||
}
|
||||
}
|
||||
|
||||
export interface HttpOptions extends Omit<RequestInit, 'body'> {
|
||||
/** Request body — automatically JSON-stringified when an object. */
|
||||
body?: unknown;
|
||||
/** Abort the request after N ms. Default 10000. */
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export async function httpJson<T>(url: string, opts: HttpOptions = {}): Promise<T> {
|
||||
const { body, timeoutMs = 10_000, headers, ...rest } = opts;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
...rest,
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
|
||||
...headers,
|
||||
},
|
||||
body: body === undefined ? undefined : typeof body === 'string' ? body : JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
let parsed: unknown;
|
||||
try { parsed = await res.json(); } catch { parsed = await res.text().catch(() => undefined); }
|
||||
throw new HttpError(res.status, res.statusText, url, parsed);
|
||||
}
|
||||
|
||||
const ct = res.headers.get('content-type') ?? '';
|
||||
if (ct.includes('application/json')) return (await res.json()) as T;
|
||||
return (await res.text()) as unknown as T;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
/**
|
||||
* Proxmox infrastructure health client — STUB pending BFF.
|
||||
*
|
||||
* `proxmox-api.d-bis.org` is live but fronted by Cloudflare Access. A browser
|
||||
* from the Solace portal cannot present a CF-Access JWT without completing
|
||||
* the SSO flow (which is a full redirect, not appropriate for a dashboard
|
||||
* widget). The correct integration is via a BFF that holds a CF-Access
|
||||
* Service Token and exposes scoped read-only endpoints.
|
||||
*
|
||||
* Until that BFF exists, this returns static sample data with a console.warn
|
||||
* so the UI degrades gracefully.
|
||||
*/
|
||||
|
||||
import { endpoints } from '../config/endpoints';
|
||||
|
||||
export interface ProxmoxNode {
|
||||
id: string;
|
||||
name: string;
|
||||
status: 'online' | 'offline' | 'unknown';
|
||||
cpu: number; // 0..1
|
||||
memoryPct: number; // 0..100
|
||||
uptimeSec: number;
|
||||
}
|
||||
|
||||
export interface ProxmoxClusterHealth {
|
||||
nodes: ProxmoxNode[];
|
||||
vmCount: number;
|
||||
lxcCount: number;
|
||||
quorum: 'ok' | 'degraded' | 'lost';
|
||||
source: 'live' | 'mock';
|
||||
}
|
||||
|
||||
const MOCK: ProxmoxClusterHealth = {
|
||||
nodes: [
|
||||
{ id: 'pve1', name: 'pve1.d-bis.org', status: 'online', cpu: 0.34, memoryPct: 62, uptimeSec: 8_294_400 },
|
||||
{ id: 'pve2', name: 'pve2.d-bis.org', status: 'online', cpu: 0.18, memoryPct: 41, uptimeSec: 8_294_400 },
|
||||
{ id: 'pve3', name: 'pve3.d-bis.org', status: 'online', cpu: 0.52, memoryPct: 78, uptimeSec: 6_912_000 },
|
||||
],
|
||||
vmCount: 34,
|
||||
lxcCount: 112,
|
||||
quorum: 'ok',
|
||||
source: 'mock',
|
||||
};
|
||||
|
||||
let warned = false;
|
||||
function warnMock(): void {
|
||||
if (warned) return;
|
||||
warned = true;
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`[proxmox] Using mock cluster health — ${endpoints.proxmox.apiBaseUrl} is behind Cloudflare Access. ` +
|
||||
`Route calls through a BFF holding a CF-Access Service Token and remove this stub.`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getClusterHealth(): Promise<ProxmoxClusterHealth> {
|
||||
if (endpoints.proxmox.requiresBff) {
|
||||
warnMock();
|
||||
return new Promise(resolve => setTimeout(() => resolve(MOCK), 150));
|
||||
}
|
||||
// TODO: fetch(`${endpoints.proxmox.apiBaseUrl}/api2/json/cluster/status`, { headers: { 'CF-Access-Client-Id': ..., 'CF-Access-Client-Secret': ... } })
|
||||
throw new Error('proxmox live mode not implemented');
|
||||
}
|
||||
Reference in New Issue
Block a user