// home.jsx — Mobile-first home page (real data via /api/*). // 5 widgets: // ① 간단 포트폴리오 ② 보유 주식 가로 스크롤 // ③ 24h 스케줄 다이얼 ④ 알람 횟수 ⑤ 시스템 자원 const { useState: useHomeState, useMemo: useHomeMemo } = React; // Fallback rate used only if /api/fx/rates is unavailable. Picked to be // obviously not-current (low) so the operator notices in case of failure. const FX_FALLBACK_KRW_PER_USD = 1380; /* ─── data transforms ───────────────────────────────── */ function _num(s) { if (s == null) return 0; const v = parseFloat(s); return isNaN(v) ? 0 : v; } function buildPortfolioView(summary, fxRate, fxFresh) { if (!summary || summary.ok === false) { return { ok: false, total: 0, totalKRW: 0, kr: { krw: 0, pnl: 0 }, us: { usd: 0, krw: 0, pnl: 0 }, fxNote: null }; } const fx = fxRate || FX_FALLBACK_KRW_PER_USD; const krOk = summary.kr && summary.kr.ok !== false; const usOk = summary.us && summary.us.ok !== false; const krEquityKRW = krOk ? _num(summary.kr.summary?.total_equity_krw) : 0; const krPnl = krOk ? _num(summary.kr.summary?.unrealized_pnl_krw) : 0; const usEquityUSD = usOk ? _num(summary.us.summary?.total_equity_usd) : 0; const usPnlUSD = usOk ? _num(summary.us.summary?.unrealized_pnl_usd) : 0; const usEquityKRW = usEquityUSD * fx; const usPnlKRW = usPnlUSD * fx; const fxNote = fxFresh ? `$1 ≈ ${fx.toLocaleString(undefined, { maximumFractionDigits: 2 })}₩ (Toss 실시간)` : `$1 ≈ ${fx.toLocaleString(undefined, { maximumFractionDigits: 2 })}₩ (대체값)`; return { ok: krOk || usOk, krOk, usOk, total: krEquityKRW + usEquityKRW, kr: { krw: krEquityKRW, pnl: krPnl, ok: krOk, err: krOk ? null : summary.kr?.error }, us: { usd: usEquityUSD, krw: usEquityKRW, pnl: usPnlUSD, pnlKRW: usPnlKRW, ok: usOk, err: usOk ? null : summary.us?.error }, fxNote, }; } function buildHoldings(positions) { if (!positions || positions.ok === false) return []; const out = []; const list = (side) => { const inner = positions[side]; if (!inner || inner.ok === false) return; for (const p of inner.positions || []) { const qty = _num(p.qty); const avg = _num(p.avg_price); const cur = _num(p.current_price); const pct = _num(p.pnl_pct); out.push({ sym: p.symbol, name: p.name || p.symbol, market: p.market || (side === 'kr' ? 'KR' : 'NASDAQ'), qty, avg, cur, pct, marketValue: _num(p.market_value), currency: side === 'kr' ? 'KRW' : 'USD', }); } }; list('kr'); list('us'); return out; } function buildProblemView(problems) { if (!problems || problems.ok === false) return { counts: { CRITICAL: 0, ERROR: 0, WARN: 0, INFO: 0 }, latest: null, total: 0 }; const counts = { CRITICAL: 0, ERROR: 0, WARN: 0, INFO: 0 }; for (const it of problems.items || []) { const sev = (it.severity || '').toUpperCase(); if (counts[sev] != null) counts[sev]++; } const total = counts.CRITICAL + counts.ERROR + counts.WARN; const latest = (problems.items || []).find(x => ['CRITICAL', 'ERROR', 'WARN'].includes((x.severity || '').toUpperCase())); return { counts, total, latest }; } function buildResources(system) { if (!system || system.ok === false) return null; const r = system.resources || {}; const cpu = r.cpu || {}; const mem = r.memory || {}; const disk = system.disk || {}; return { cpu: { pct: cpu.percent, cores: cpu.cores, load1: cpu.load_1m }, memory: { pct: mem.used_pct, usedMB: mem.used_bytes ? mem.used_bytes / 1e6 : null, totalMB: mem.total_bytes ? mem.total_bytes / 1e6 : null }, disk: { pct: disk.data_used_pct, freeGB: disk.data_free_bytes ? disk.data_free_bytes / 1e9 : null, totalGB: disk.data_total_bytes ? disk.data_total_bytes / 1e9 : null }, uptimeSec: system.host?.uptime_seconds, processUptimeSec: system.host?.process_uptime_seconds, }; } function fmtUptime(seconds) { if (seconds == null || isNaN(seconds)) return '—'; const s = Math.floor(seconds); const d = Math.floor(s / 86400); const h = Math.floor((s % 86400) / 3600); const m = Math.floor((s % 3600) / 60); if (d > 0) return `${d}d ${h}h`; if (h > 0) return `${h}h ${m}m`; return `${m}m`; } /* ─── cron next-occurrence (minimal, covers our 5 expressions) ─── */ function cronNextRun(expr, from) { if (!expr || expr === '@reboot') return null; const parts = expr.split(/\s+/).slice(0, 5); if (parts.length < 5) return null; const [mStr, hStr, domStr, monStr, dowStr] = parts; const m = parseInt(mStr, 10), h = parseInt(hStr, 10); if (isNaN(m) || isNaN(h)) return null; const dom = domStr === '*' ? null : parseInt(domStr, 10); let dowOk; if (dowStr === '*') dowOk = () => true; else if (dowStr.includes('-')) { const [a, b] = dowStr.split('-').map(x => parseInt(x, 10)); dowOk = (d) => d >= a && d <= b; } else { const v = parseInt(dowStr, 10); dowOk = (d) => d === v; } const start = new Date(from); for (let i = 0; i < 60; i++) { const cand = new Date(Date.UTC(start.getUTCFullYear(), start.getUTCMonth(), start.getUTCDate() + i, h, m, 0, 0)); if (cand <= from) continue; if (dom != null && cand.getUTCDate() !== dom) continue; if (!dowOk(cand.getUTCDay())) continue; return cand; } return null; } function scheduleKind(label) { if (!label) return 'other'; if (label.includes('KR close')) return 'kr_cycle'; if (label.includes('US close')) return 'us_cycle'; if (label.includes('KR open')) return 'kr_trade'; if (label.includes('US open')) return 'us_trade'; if (label.includes('backtest')) return 'backtest'; if (label.includes('Dashboard')) return 'dashboard'; return 'other'; } const KIND_COLOR = { kr_cycle: 'var(--m-kr)', us_cycle: 'var(--m-us)', kr_trade: 'var(--m-kr-worker)', us_trade: 'var(--accent)', backtest: 'var(--uncertain)', dashboard: 'var(--text-3)', other: 'var(--text-3)', }; function buildScheduleJobs(schedule, now) { if (!schedule || schedule.ok === false) return { jobs: [], next: null, total: 0 }; const jobs = []; for (const it of schedule.items || []) { const kind = scheduleKind(it.label); const next = cronNextRun(it.cron_expression, now); const lastT = it.last_run_utc ? new Date(it.last_run_utc) : null; jobs.push({ id: it.label, name: it.label, kind, color: KIND_COLOR[kind] || KIND_COLOR.other, lastT, next, cron: it.cron_expression, }); } jobs.sort((a, b) => (a.next ? +a.next : Infinity) - (b.next ? +b.next : Infinity)); const nextJob = jobs.find(j => j.next && j.next > now); // "Today" window: 36h forward from start of today const today = new Date(now); today.setUTCHours(0, 0, 0, 0); const cutoff = new Date(+today + 36 * 3600 * 1000); const inWindow = (t) => t && t >= today && t < cutoff; const dialJobs = []; for (const j of jobs) { if (inWindow(j.lastT)) dialJobs.push({ t: j.lastT, label: j.name + ' (완료)', color: j.color, key: j.id + '-last' }); if (inWindow(j.next)) dialJobs.push({ t: j.next, label: j.name, color: j.color, key: j.id + '-next' }); } dialJobs.sort((a, b) => +a.t - +b.t); return { jobs, dialJobs, next: nextJob, total: dialJobs.length }; } function relUntil(target, from) { if (!target) return '—'; const diff = (+target - +from) / 1000; if (diff < 0) return '경과'; if (diff < 60) return Math.floor(diff) + '초'; if (diff < 3600) return Math.floor(diff / 60) + '분'; if (diff < 86400) return Math.floor(diff / 3600) + '시간'; return Math.floor(diff / 86400) + '일'; } function fmtAbsKST(d) { if (!d) return '—'; try { return new Intl.DateTimeFormat('ko-KR', { timeZone: 'Asia/Seoul', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false, }).format(d); } catch (_) { return d.toISOString().slice(5, 16); } } /* ─── ① Hero ───────────────────────────────────────── */ function Hero({ pview, view, onViewChange, onTap, now }) { if (!pview.ok) { return (
총 자산
데이터 없음
포트폴리오를 불러올 수 없음
); } const main = view === 'total' ? pview.total : view === 'kr' ? pview.kr.krw : pview.us.usd; const isUsd = view === 'us'; const pnl = view === 'total' ? (pview.kr.pnl + pview.us.pnlKRW) : view === 'kr' ? pview.kr.pnl : pview.us.pnl; const pnlBase = view === 'total' ? pview.total - (pview.kr.pnl + pview.us.pnlKRW) : view === 'kr' ? pview.kr.krw - pview.kr.pnl : pview.us.usd - pview.us.pnl; const pnlPct = pnlBase > 0 ? (pnl / pnlBase) * 100 : 0; const positive = pnl >= 0; return (
{ if (e.target.closest('.seg')) return; onTap?.(); }} style={{ cursor: 'pointer' }}>
{view === 'total' ? '총 자산' : view === 'kr' ? '한국 자산' : '미국 자산'}
{fmtAbsKST(now)} KST · 누적 평가손익
onViewChange(v)} options={[ { value: 'total', label: '통합' }, { value: 'kr', label: 'KR' }, { value: 'us', label: 'US' }, ]} />
{isUsd ? main.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : fmtKRWFull(Math.round(main))} {isUsd ? '달러' : '원'}
{fmtSigned(pnl)}{isUsd ? '$' : '₩'} · 매수가 대비
한국
{fmtKRW(pview.kr.krw)}
= 0 ? 'var(--buy)' : 'var(--sell)' }}> {fmtSigned(pview.kr.pnl)} {!pview.krOk && · 오류}
미국
${pview.us.usd.toFixed(2)} USD
= 0 ? 'var(--buy)' : 'var(--sell)' }}> {fmtSigned(pview.us.pnl, 2)}$ ≈ {fmtKRW(pview.us.krw)}원
{pview.fxNote}
); } /* ─── ② Holdings Strip ─────────────────────────────── */ function HoldingsStrip({ holdings, onTap }) { if (!holdings || holdings.length === 0) { return (
보유 주식 /portfolio.holdings · 0개
보유 종목 없음
); } return (
보유 주식 /portfolio.holdings · {holdings.length}개
{holdings.map(h => { const positive = h.pct >= 0; const c = positive ? 'var(--buy)' : 'var(--sell)'; const evalNative = h.qty * h.cur; // synthetic sparkline derived from pct const series = (() => { const start = h.cur / (1 + h.pct / 100 || 1); const arr = []; for (let i = 0; i < 30; i++) { const p = i / 29; const noise = Math.sin(i * 0.5) * 0.012 + Math.sin(i * 0.21) * 0.008; arr.push(start * (1 + (h.pct / 100) * p + noise)); } return arr; })(); return ( ); })}
); } /* ─── ③ Schedule Dial ──────────────────────────────── */ function ScheduleDial({ schedView, now, onTap }) { return (
onTap?.()} style={{ cursor: 'pointer' }}>
스케줄 /schedule · 오늘
{fmtAbsKST(now).slice(0, 5)}
다음 자동 실행
{schedView.next ? ( <>
{relUntil(schedView.next.next, now)}
{schedView.next.name}
{fmtAbsKST(schedView.next.next)} KST
) : (
예약된 잡 없음
)}
총 {schedView.jobs?.length || 0}개 · 오늘 {schedView.total} 실행
); } /* ─── ④ Alarms (Problems) ──────────────────────────── */ function AlarmsCard({ pv, onTap }) { const total = pv.total; const clean = total === 0; const p = pv.counts; const latest = pv.latest; return (
onTap?.()} style={{ cursor: 'pointer' }}>
알람 /problems · 미해결
상세 →
{clean ? (
이상 없음
미해결 ERROR/WARN 없음
) : ( <>
{total}
건의 미해결 이슈
{p.CRITICAL > 0 && {p.CRITICAL}} {p.ERROR > 0 && {p.ERROR}} {p.WARN > 0 && {p.WARN}} {p.INFO > 0 && {p.INFO}}
{latest && (
{(latest.body || '').split('\n')[0].replace(/^\*\*Message\*\*:\s*/, '').slice(0, 100)}
{fmtRelativeTime(latest.timestamp_utc)} · {latest.source}
)} )}
); } /* ─── ⑤ Resources ──────────────────────────────────── */ function ResourcesCard({ res, onTap }) { if (!res) { return (
시스템 자원 /system
시스템 상태 로딩 중…
); } const toneFor = (pct) => pct >= 80 ? 'var(--critical)' : pct >= 60 ? 'var(--uncertain)' : 'var(--success)'; const cpuPct = res.cpu.pct == null ? 0 : res.cpu.pct; const memPct = res.memory.pct == null ? 0 : res.memory.pct; const diskPct = res.disk.pct == null ? 0 : res.disk.pct; return (
onTap?.()} style={{ cursor: 'pointer' }}>
시스템 자원 /system · uptime {fmtUptime(res.uptimeSec)}
OK
{res.cpu.cores || '?'}코어 · L1 {res.cpu.load1 != null ? res.cpu.load1.toFixed(2) : '—'}
{res.memory.usedMB != null ? (res.memory.usedMB / 1024).toFixed(1) : '?'} / {res.memory.totalMB != null ? (res.memory.totalMB / 1024).toFixed(0) : '?'} GB
{res.disk.freeGB != null ? (res.disk.totalGB - res.disk.freeGB).toFixed(1) : '?'} / {res.disk.totalGB != null ? res.disk.totalGB.toFixed(0) : '?'} GB
backend uptime {fmtUptime(res.processUptimeSec)}
); } /* ─── Home ─────────────────────────────────────────── */ function Home({ onNavigate }) { const [heroView, setHeroView] = useHomeState('total'); const now = useHomeMemo(() => new Date(), []); const portfolio = useApi(() => API.portfolio.summary(), [], { refreshMs: 60000 }); const positions = useApi(() => API.portfolio.positions(), [], { refreshMs: 60000 }); const problems = useApi(() => API.problems.list({ limit: 50 }), [], { refreshMs: 60000 }); const schedule = useApi(() => API.schedule.read(), [], { refreshMs: 60000 }); const system = useApi(() => API.system.state(), [], { refreshMs: 15000 }); // FX backend caches for 60s; we poll at 5min on the client to avoid noise. const fx = useApi(() => API.fx.rates(), [], { refreshMs: 300000 }); const fxRate = fx.data && fx.data.ok !== false ? Number(fx.data.krw_per_usd) : null; const fxFresh = !!fxRate; const pview = useHomeMemo(() => buildPortfolioView(portfolio.data, fxRate, fxFresh), [portfolio.data, fxRate, fxFresh]); const holdings = useHomeMemo(() => buildHoldings(positions.data), [positions.data]); const pv = useHomeMemo(() => buildProblemView(problems.data), [problems.data]); const schedView = useHomeMemo(() => buildScheduleJobs(schedule.data, now), [schedule.data, now]); const res = useHomeMemo(() => buildResources(system.data), [system.data]); return (
onNavigate('portfolio')} now={now} /> onNavigate('portfolio')} />
onNavigate('schedule')} /> onNavigate('problems')} />
onNavigate('system')} />
); } Object.assign(window, { Home });