From 007c79d7a97424344e590588b23d06bcee304897 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 19 Apr 2026 00:33:46 +0000 Subject: [PATCH] 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 --- src/components/portal/BackendStatusBar.tsx | 52 ++++++++ src/components/portal/LiveNetworkPanel.tsx | 73 +++++++++++ src/config/endpoints.ts | 110 +++++++++++++++++ src/hooks/useLiveChain.ts | 54 +++++++++ src/hooks/useOnChainBalances.ts | 51 ++++++++ src/pages/DashboardPage.tsx | 57 ++++++--- src/services/chain138.ts | 133 +++++++++++++++++++++ src/services/dbisCore.ts | 63 ++++++++++ src/services/explorer.ts | 82 +++++++++++++ src/services/http.ts | 57 +++++++++ src/services/proxmox.ts | 63 ++++++++++ 11 files changed, 781 insertions(+), 14 deletions(-) create mode 100644 src/components/portal/BackendStatusBar.tsx create mode 100644 src/components/portal/LiveNetworkPanel.tsx create mode 100644 src/config/endpoints.ts create mode 100644 src/hooks/useLiveChain.ts create mode 100644 src/hooks/useOnChainBalances.ts create mode 100644 src/services/chain138.ts create mode 100644 src/services/dbisCore.ts create mode 100644 src/services/explorer.ts create mode 100644 src/services/http.ts create mode 100644 src/services/proxmox.ts diff --git a/src/components/portal/BackendStatusBar.tsx b/src/components/portal/BackendStatusBar.tsx new file mode 100644 index 0000000..84f23e4 --- /dev/null +++ b/src/components/portal/BackendStatusBar.tsx @@ -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 = { + 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>({}); + + 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 ( +
+ + Backends + {backendCatalog.map(withProbed).map(b => { + const s = STATUS_STYLE[b.status]; + return ( + + {b.name} · {s.label} + + ); + })} +
+ ); +} diff --git a/src/components/portal/LiveNetworkPanel.tsx b/src/components/portal/LiveNetworkPanel.tsx new file mode 100644 index 0000000..2cf9d25 --- /dev/null +++ b/src/components/portal/LiveNetworkPanel.tsx @@ -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 ( +
+
+

+ Chain 138 — Live Network + + {statusLabel} + +

+
+ {ago(lastUpdated)} + +
+
+ +
+ } label="Latest block" mono value={health ? health.blockNumber.toLocaleString() : '—'} href={latestBlock ? explorerBlockUrl(latestBlock.number) : undefined} /> + } label="Chain ID" mono value={health ? `${health.chainId}` : '—'} /> + } label="Gas price" mono value={health ? `${health.gasPriceGwei.toFixed(2)} gwei` : '—'} /> + } label="RPC latency" mono value={health ? `${health.latencyMs} ms` : '—'} /> + } label="Total blocks" mono value={stats ? stats.total_blocks.toLocaleString() : '—'} /> + } label="Total txns" mono value={stats ? stats.total_transactions.toLocaleString() : '—'} /> + } label="Txns today" mono value={stats ? stats.transactions_today.toLocaleString() : '—'} /> + } label="Addresses" mono value={stats ? stats.total_addresses.toLocaleString() : '—'} /> +
+ +
+ + RPC: {endpoints.chain138.rpcUrl} + + + Explorer: {endpoints.chain138.blockExplorerUrl} + + {error && Error: {error}} +
+
+ ); +} + +function Stat({ icon, label, value, mono, href }: { icon: React.ReactNode; label: string; value: string; mono?: boolean; href?: string }) { + const valueEl = ( + {value} + ); + return ( +
+ {icon} {label} + {href ? {valueEl} : valueEl} +
+ ); +} diff --git a/src/config/endpoints.ts b/src/config/endpoints.ts new file mode 100644 index 0000000..3dcbc94 --- /dev/null +++ b/src/config/endpoints.ts @@ -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 }).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.', + }, +]; diff --git a/src/hooks/useLiveChain.ts b/src/hooks/useLiveChain.ts new file mode 100644 index 0000000..3227b7a --- /dev/null +++ b/src/hooks/useLiveChain.ts @@ -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(null); + const [latestBlock, setLatestBlock] = useState(null); + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [lastUpdated, setLastUpdated] = useState(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(); } }; +} diff --git a/src/hooks/useOnChainBalances.ts b/src/hooks/useOnChainBalances.ts new file mode 100644 index 0000000..f997fc7 --- /dev/null +++ b/src/hooks/useOnChainBalances.ts @@ -0,0 +1,51 @@ +import { useEffect, useRef, useState } from 'react'; +import { getNativeBalances, type OnChainBalance } from '../services/chain138'; + +export interface OnChainBalancesState { + balances: Record; + 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>({}); + const [loading, setLoading] = useState(addresses.length > 0); + const [error, setError] = useState(null); + const [lastUpdated, setLastUpdated] = useState(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 }; +} diff --git a/src/pages/DashboardPage.tsx b/src/pages/DashboardPage.tsx index 111e94f..1348f8e 100644 --- a/src/pages/DashboardPage.tsx +++ b/src/pages/DashboardPage.tsx @@ -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 (

Portfolio Overview

Solace Bank Group PLC — Consolidated View

+
@@ -154,6 +165,11 @@ export default function DashboardPage() {
+ {/* Chain 138 live network health — wired to rpc-core.d-bis.org + explorer */} +
+ +
+ {/* Asset Allocation */}
@@ -217,20 +233,33 @@ export default function DashboardPage() {
- {sampleAccounts.filter(a => !a.parentId).slice(0, 5).map(acc => ( -
-
- {acc.type} - {acc.name} + {sampleAccounts.filter(a => !a.parentId).slice(0, 5).map(acc => { + const onChain = acc.walletAddress ? onChainBalances[acc.walletAddress] : undefined; + return ( +
+
+ {acc.type} + {acc.name} + {acc.walletAddress && ( + + {onChain ? `● live · chain ${endpoints.chain138.chainId}` : balancesLoading ? '○ fetching…' : '○ off-chain'} + + )} +
+
+ + {acc.currency === 'BTC' ? `${acc.balance.toFixed(2)} BTC` : formatCurrency(acc.balance, acc.currency)} + + {acc.currency} + {onChain && ( + + on-chain: {Number(onChain.balanceEth).toFixed(4)} META + + )} +
-
- - {acc.currency === 'BTC' ? `${acc.balance.toFixed(2)} BTC` : formatCurrency(acc.balance, acc.currency)} - - {acc.currency} -
-
- ))} + ); + })}
diff --git a/src/services/chain138.ts b/src/services/chain138.ts new file mode 100644 index 0000000..2aca59c --- /dev/null +++ b/src/services/chain138.ts @@ -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 { + 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 { + 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> { + 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 = {}; + 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 { + 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 ?? '', + }; +} diff --git a/src/services/dbisCore.ts b/src/services/dbisCore.ts new file mode 100644 index 0000000..c9ce6b9 --- /dev/null +++ b/src/services/dbisCore.ts @@ -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(value: T, ms = 150): Promise { + return new Promise(resolve => setTimeout(() => resolve(value), ms)); +} + +export async function listAccounts(): Promise { + 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 { + if (endpoints.dbisCore.mocked) { warnMock('getFinancialSummary'); return delay(financialSummary); } + throw new Error('dbis_core live mode not implemented'); +} + +export async function listTreasuryPositions(): Promise { + if (endpoints.dbisCore.mocked) { warnMock('listTreasuryPositions'); return delay(treasuryPositions); } + throw new Error('dbis_core live mode not implemented'); +} + +export async function listCashForecasts(): Promise { + if (endpoints.dbisCore.mocked) { warnMock('listCashForecasts'); return delay(cashForecasts); } + throw new Error('dbis_core live mode not implemented'); +} + +export async function listSettlements(): Promise { + if (endpoints.dbisCore.mocked) { warnMock('listSettlements'); return delay(settlementRecords); } + throw new Error('dbis_core live mode not implemented'); +} + +export async function listReports(): Promise { + if (endpoints.dbisCore.mocked) { warnMock('listReports'); return delay(reportConfigs); } + throw new Error('dbis_core live mode not implemented'); +} diff --git a/src/services/explorer.ts b/src/services/explorer.ts new file mode 100644 index 0000000..eb1c58e --- /dev/null +++ b/src/services/explorer.ts @@ -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 { + return httpJson(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 { + return httpJson(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 { + const data = await httpJson(api('/transactions')); + return (data.items ?? []).slice(0, limit); +} + +export async function getAddressTransactions(address: string, limit = 20): Promise { + const data = await httpJson(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}`; +} diff --git a/src/services/http.ts b/src/services/http.ts new file mode 100644 index 0000000..cd781fd --- /dev/null +++ b/src/services/http.ts @@ -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 { + /** Request body — automatically JSON-stringified when an object. */ + body?: unknown; + /** Abort the request after N ms. Default 10000. */ + timeoutMs?: number; +} + +export async function httpJson(url: string, opts: HttpOptions = {}): Promise { + 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); + } +} diff --git a/src/services/proxmox.ts b/src/services/proxmox.ts new file mode 100644 index 0000000..f122760 --- /dev/null +++ b/src/services/proxmox.ts @@ -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 { + 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'); +}