// Dashboard scene — 1:1 with the SOC console at app.dipoli.io/. // HeroMetric + 3 MetricCards + VolumeChart + DecisionPie + RecentTransactions // + TopRules. All number counters use useTween for the ease-out-cubic feel. function DashboardScene({ active }) { return (

Dashboard

n.toFixed(1) + '%'} delta="▲ +0.4%" deltaUp icon={} /> } /> } />
); } function DashHero({ active }) { const val = useTween(31065); return (
Evaluations (24h)
{val} transactions
); } function DashMetricCard({ variant, label, target, format, delta, deltaUp, icon }) { const val = useTween(target, format ? { format } : undefined); const color = variant === 'v-block' ? 'var(--block)' : variant === 'v-approve' ? 'var(--approve)' : 'var(--review)'; return (
{label}
{val}
{icon}
{deltaUp ? : } {delta.replace(/[▲▼]\s*/, '')} vs yesterday
); } function MetricBlockIcon() { return ; } function MetricApproveIcon() { return ; } function MetricReviewIcon() { return ; } // ── Volume chart ───────────────────────────────────────────────────── function VolumeChartPanel() { // 24-hour synthetic series — same easing curve the legacy chart used. const series = React.useMemo(() => { const pts = []; for (let i = 0; i < 24; i++) { const t = i / 23; const wave = Math.sin(t * Math.PI * 2) * 0.4 + 0.55; const noise = (Math.sin(i * 7.3) + 1) * 0.08; pts.push(Math.max(0.05, Math.min(0.95, wave - noise))); } return pts; }, []); const W = 700, H = 200, padL = 30, padR = 8, padT = 30, padB = 30; const innerW = W - padL - padR; const innerH = H - padT - padB; const xs = series.map((_, i) => padL + (i / (series.length - 1)) * innerW); const ys = series.map((v) => padT + (1 - v) * innerH); // Smooth path via cubic Bézier midpoints — keeps the line organic // without piling on dependencies. let path = `M ${xs[0]} ${ys[0]}`; for (let i = 1; i < xs.length; i++) { const cx = (xs[i - 1] + xs[i]) / 2; path += ` Q ${cx} ${ys[i - 1]} ${cx} ${(ys[i - 1] + ys[i]) / 2}`; path += ` Q ${cx} ${ys[i]} ${xs[i]} ${ys[i]}`; } const area = path + ` L ${xs[xs.length - 1]} ${padT + innerH} L ${xs[0]} ${padT + innerH} Z`; const labels = ['00:00', '04:00', '08:00', '12:00', '16:00', '20:00']; return (
Transaction volume (24h)
{labels.map((l, i) => ( {l} ))}
); } // ── Decision pie ──────────────────────────────────────────────────── function DecisionPiePanel() { const total = 31065; const dist = { APPROVE: { count: 27842, color: 'var(--approve)' }, REVIEW: { count: 1791, color: 'var(--review)' }, BLOCK: { count: 1432, color: 'var(--block)' }, }; // Arc length on r=40 ⇒ circumference 2πr ≈ 251.3. Each segment gets // a stroke-dasharray "len gap" and a rotation offset based on the // cumulative count. const circ = 2 * Math.PI * 40; const order = ['APPROVE', 'REVIEW', 'BLOCK']; let acc = 0; const arcs = order.map((k) => { const frac = dist[k].count / total; const len = frac * circ; const rotateDeg = -90 + (acc / total) * 360; acc += dist[k].count; return { k, len, rotateDeg, color: dist[k].color }; }); // Pretty-print percent + count tween. const a = useTween(dist.APPROVE.count); const r = useTween(dist.REVIEW.count); const b = useTween(dist.BLOCK.count); return (
Decision distribution
{arcs.map(({ k, len, rotateDeg, color }) => ( ))}
{[ { name: 'APPROVE', val: a, pct: ((dist.APPROVE.count / total) * 100).toFixed(1) + '%', color: 'var(--approve)' }, { name: 'REVIEW', val: r, pct: ((dist.REVIEW.count / total) * 100).toFixed(1) + '%', color: 'var(--review)' }, { name: 'BLOCK', val: b, pct: ((dist.BLOCK.count / total) * 100).toFixed(1) + '%', color: 'var(--block)' }, ].map((row) => (
{row.name} {row.val}{row.pct}
))}
); } // ── Recent evaluations table ───────────────────────────────────────── function RecentEvaluationsPanel({ active }) { // Initial 6 rows + a live appended row every ~3s while the scene is on. const [rows, setRows] = React.useState(() => TX_TEMPLATES.slice(0, 6).map((t) => ({ ...t, ts: nowTs(), anim: false })) ); React.useEffect(() => { if (!active) return; const id = setInterval(() => { const t = TX_TEMPLATES[Math.floor(Math.random() * TX_TEMPLATES.length)]; const s = Math.max(0, Math.min(99, t.score + Math.floor(Math.random() * 14) - 7)); const d = s > 70 ? 'BLOCK' : s > 40 ? 'REVIEW' : 'APPROVE'; const fresh = { id: `tx-2026-05-06-${Math.random().toString(16).slice(2, 6)}`, client: t.client, amt: t.amt, ip: t.ip, score: s, decision: d, ts: nowTs(), anim: true, }; setRows((prev) => [fresh, ...prev].slice(0, 7)); }, 3500); return () => clearInterval(id); }, [active]); return (
Recent evaluations
{rows.map((row, i) => ( ))}
Time TX ID Client Amount Score Rules Decision
{row.ts} {row.id} {row.client} {row.amt} {row.score} {rulesCount(row.score)} {row.decision}
); } // ── Top triggered signals ──────────────────────────────────────────── function TopRulesPanel() { const RULES = [ { name: 'High frequency · 1h', target: 412, pct: 100 }, { name: 'Sanctioned country', target: 287, pct: 69 }, { name: 'Anonymous network', target: 194, pct: 47 }, { name: 'New IP address', target: 142, pct: 34 }, { name: 'Amount anomaly', target: 98, pct: 24 }, ]; return (
Top triggered signals
{RULES.map((r) => )}
); } function TopRuleRow({ name, target, pct }) { const n = useTween(target); const [w, setW] = React.useState(0); React.useEffect(() => { const id = requestAnimationFrame(() => setW(pct)); return () => cancelAnimationFrame(id); }, [pct]); return (
{name} {n}
); }