feat(portal): wire Accounts/Treasury/Reporting/Compliance/Settlements/TransactionBuilder to live Chain-138 + SolaceScan
Extends the POC from #2 beyond the Dashboard so every portal page that can benefit from on-chain signal now pulls from live backends while preserving its existing UX. Pages without an on-chain analogue (the IFRS/GAAP/IPSAS report rows, the dbis_core compliance alerts) stay on sample data with an explicit 'mocked' note. New shared primitives --------------------- src/hooks/useLatestTransactions.ts — polls SolaceScan /transactions every 15s src/hooks/useAddressTransactions.ts — per-address tx feed, 60s polling src/components/portal/LiveTransactionsPanel.tsx — reusable live-tx card src/components/portal/LiveChainBanner.tsx — slim status banner src/components/portal/OnChainBalanceTag.tsx — shared live/off-chain pill Per-page wiring --------------- AccountsPage — on-chain pill + META balance + SolaceScan link on each account row that carries a walletAddress; overlay renders only on wallet rows (negative check). SettlementsPage — replaces the static 'Settlement Rate' tile with a live Chain-138 block + tx-today tile; adds a LiveTransactionsPanel above the CSD queue so the page no longer renders identical output when RPC is dead. ReportingPage — new On-Chain Reporting Snapshot row (Blockscout /stats: block depth, total tx, total addrs, utilisation, avg block time). Clear note that the IFRS/GAAP/IPSAS rows come from dbis_core and are still mocked. TreasuryPage — two new summary tiles: live Chain-138 gas + aggregated on-chain custody (META) from sample wallet addresses. Uses the same useOnChainBalances hook as Accounts. CompliancePage — AML monitor strip with wallet selector; dedicated 'On-Chain Tx Feed' card shows IN/OUT per tracked wallet via SolaceScan. dbis_core alerts still mocked (no public deploy). TransactionBuilder — LiveChainBanner inserted above the composer so users know chain health + gas + latency before composing; transaction-builder-module made a flex column so the banner doesn't cover the canvas. Assertions baked into every live widget --------------------------------------- - RPC failure flips colour + text to 'degraded'/'—' (no silent freeze). - Loading state is distinct from both live and degraded. - Each overlay is only rendered where real data differs from sample data (walletAddress rows for balances, tracked custody for AML, etc.) so a page without live overlays is proof-of-scope, not proof-of-brokenness. Verified locally ---------------- - tsc --noEmit: clean - npm run build: clean (2066 modules, 565 ms) Still intentionally mocked -------------------------- - proxmox.ts — CF-Access protected; a BFF route is now open in orchestrator PR (see companion PR for /api/proxmox/*). - dbisCore.ts — no public deployment exists yet.
This commit is contained in:
50
src/components/portal/LiveChainBanner.tsx
Normal file
50
src/components/portal/LiveChainBanner.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
92
src/components/portal/LiveTransactionsPanel.tsx
Normal file
92
src/components/portal/LiveTransactionsPanel.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
42
src/components/portal/OnChainBalanceTag.tsx
Normal file
42
src/components/portal/OnChainBalanceTag.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user