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.
259 lines
12 KiB
TypeScript
259 lines
12 KiB
TypeScript
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';
|
|
|
|
const severityColors: Record<string, string> = {
|
|
critical: '#ef4444', high: '#f97316', medium: '#eab308', low: '#3b82f6',
|
|
};
|
|
|
|
const statusColors: Record<string, string> = {
|
|
open: '#ef4444', acknowledged: '#eab308', resolved: '#22c55e',
|
|
};
|
|
|
|
const complianceMetrics = [
|
|
{ label: 'KYC Verified', value: '142', total: '145', pct: 98, color: '#22c55e' },
|
|
{ label: 'AML Screening', value: 'Active', total: '47 rules', pct: 100, color: '#3b82f6' },
|
|
{ label: 'Sanctions Check', value: 'Current', total: 'OFAC/EU/UN', pct: 100, color: '#a855f7' },
|
|
{ label: 'Travel Rule', value: '98.5%', total: 'compliant', pct: 98.5, color: '#14b8a6' },
|
|
];
|
|
|
|
const regulatoryFrameworks = [
|
|
{ name: 'FATF Travel Rule', status: 'compliant', lastReview: '2024-03-15', nextReview: '2024-06-15' },
|
|
{ name: 'MiCA (EU)', status: 'compliant', lastReview: '2024-02-28', nextReview: '2024-05-28' },
|
|
{ name: 'Bank Secrecy Act (US)', status: 'compliant', lastReview: '2024-03-01', nextReview: '2024-06-01' },
|
|
{ name: 'FCA Regulations (UK)', status: 'review_needed', lastReview: '2024-01-15', nextReview: '2024-04-15' },
|
|
{ name: 'MAS Guidelines (SG)', status: 'compliant', lastReview: '2024-03-10', nextReview: '2024-06-10' },
|
|
{ name: 'JFSA Standards (JP)', status: 'compliant', lastReview: '2024-02-20', nextReview: '2024-05-20' },
|
|
];
|
|
|
|
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;
|
|
const matchStatus = statusFilter === 'all' || a.status === statusFilter;
|
|
return matchSev && matchStatus;
|
|
});
|
|
|
|
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">
|
|
<div>
|
|
<h1><Shield size={24} /> Compliance & Risk Management</h1>
|
|
<p className="page-subtitle">Regulatory compliance monitoring, AML/KYC oversight, and risk controls</p>
|
|
</div>
|
|
<div className="page-header-actions">
|
|
<button className="btn-secondary"><Download size={14} /> Export Report</button>
|
|
<button className="btn-primary"><UserCheck size={14} /> Run Full Scan</button>
|
|
</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 => (
|
|
<div key={m.label} className="metric-card">
|
|
<div className="metric-header">
|
|
<span className="metric-label">{m.label}</span>
|
|
<CheckCircle2 size={14} color={m.color} />
|
|
</div>
|
|
<div className="metric-value" style={{ color: m.color }}>{m.value}</div>
|
|
<div className="metric-sub">{m.total}</div>
|
|
<div className="metric-bar">
|
|
<div className="metric-bar-fill" style={{ width: `${m.pct}%`, background: m.color }} />
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div className="compliance-grid">
|
|
{/* Alerts */}
|
|
<div className="dashboard-card alerts-table-card">
|
|
<div className="card-header">
|
|
<h3><AlertTriangle size={16} /> Active Alerts ({openCount} open, {criticalCount} critical)</h3>
|
|
<div className="card-header-actions">
|
|
<div className="filter-group">
|
|
<Filter size={12} />
|
|
<select value={severityFilter} onChange={e => setSeverityFilter(e.target.value)}>
|
|
<option value="all">All Severity</option>
|
|
<option value="critical">Critical</option>
|
|
<option value="high">High</option>
|
|
<option value="medium">Medium</option>
|
|
<option value="low">Low</option>
|
|
</select>
|
|
</div>
|
|
<div className="filter-group">
|
|
<select value={statusFilter} onChange={e => setStatusFilter(e.target.value)}>
|
|
<option value="all">All Status</option>
|
|
<option value="open">Open</option>
|
|
<option value="acknowledged">Acknowledged</option>
|
|
<option value="resolved">Resolved</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="alerts-table">
|
|
{filtered.map(alert => (
|
|
<div key={alert.id} className="alert-table-row">
|
|
<span className="alert-sev-badge" style={{ background: severityColors[alert.severity] + '20', color: severityColors[alert.severity], borderColor: severityColors[alert.severity] + '40' }}>
|
|
{alert.severity.toUpperCase()}
|
|
</span>
|
|
<span className="alert-cat">{alert.category}</span>
|
|
<span className="alert-msg">{alert.message}</span>
|
|
<span className="alert-status-badge" style={{ color: statusColors[alert.status] }}>
|
|
{alert.status === 'resolved' ? <CheckCircle2 size={10} /> : alert.status === 'acknowledged' ? <Eye size={10} /> : <Clock size={10} />}
|
|
{alert.status}
|
|
</span>
|
|
<span className="alert-time mono">
|
|
{alert.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
|
</span>
|
|
{alert.assignedTo && <span className="alert-assigned">{alert.assignedTo}</span>}
|
|
</div>
|
|
))}
|
|
</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">
|
|
<h3><Shield size={16} /> Regulatory Frameworks</h3>
|
|
</div>
|
|
<div className="regulatory-list">
|
|
{regulatoryFrameworks.map(fw => (
|
|
<div key={fw.name} className="regulatory-row">
|
|
<div className="regulatory-info">
|
|
<span className="regulatory-name">{fw.name}</span>
|
|
<span className={`regulatory-status ${fw.status}`}>
|
|
{fw.status === 'compliant' ? <CheckCircle2 size={10} /> : <AlertTriangle size={10} />}
|
|
{fw.status.replace('_', ' ')}
|
|
</span>
|
|
</div>
|
|
<div className="regulatory-dates">
|
|
<span className="small">Last: {fw.lastReview}</span>
|
|
<span className="small">Next: {fw.nextReview}</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|