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:
Devin AI
2026-04-19 00:33:46 +00:00
parent 52676016fb
commit 007c79d7a9
11 changed files with 781 additions and 14 deletions

View 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>
);
}

View 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>
);
}

110
src/config/endpoints.ts Normal file
View File

@@ -0,0 +1,110 @@
/**
* 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.',
},
];

54
src/hooks/useLiveChain.ts Normal file
View File

@@ -0,0 +1,54 @@
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(); } };
}

View File

@@ -0,0 +1,51 @@
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 };
}

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
TrendingUp, TrendingDown, DollarSign, Activity, AlertTriangle, Clock,
@@ -6,6 +6,10 @@ 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`;
@@ -56,12 +60,19 @@ 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">
@@ -154,6 +165,11 @@ 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">
@@ -217,20 +233,33 @@ 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 => (
<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>
{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>
</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>

133
src/services/chain138.ts Normal file
View File

@@ -0,0 +1,133 @@
/**
* 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 ?? '',
};
}

63
src/services/dbisCore.ts Normal file
View File

@@ -0,0 +1,63 @@
/**
* 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');
}

82
src/services/explorer.ts Normal file
View File

@@ -0,0 +1,82 @@
/**
* 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> {
return httpJson<ExplorerStats>(api('/stats'));
}
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}`;
}

57
src/services/http.ts Normal file
View File

@@ -0,0 +1,57 @@
/**
* 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);
}
}

63
src/services/proxmox.ts Normal file
View File

@@ -0,0 +1,63 @@
/**
* 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');
}