대시보드 화면 레이아웃 수정

This commit is contained in:
Macbook
2026-06-03 14:21:29 +09:00
parent 4dc7962a2f
commit 3e17d6cfb8
3 changed files with 415 additions and 411 deletions
+210 -349
View File
@@ -1,5 +1,6 @@
/** /**
* GoldenChart 대시보드 — 투자분석 레포트형 카드 UI * GoldenChart Command Center — Standard + System/Tech
* (기존 테마·내용 유지, 카드 레이아웃·정렬만 정돈)
*/ */
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { Theme } from '../types'; import type { Theme } from '../types';
@@ -8,24 +9,13 @@ import {
type DashboardSummaryDto, type DashboardSummaryDto,
type ProcessMonitorDto, type ProcessMonitorDto,
type SystemMonitorDto, type SystemMonitorDto,
type TradeSignalDto,
} from '../utils/backendApi'; } from '../utils/backendApi';
import DashboardGauge from './dashboard/DashboardGauge';
import DashboardSegmentBar from './dashboard/DashboardSegmentBar';
import DashboardSparkChart from './dashboard/DashboardSparkChart'; import DashboardSparkChart from './dashboard/DashboardSparkChart';
import { import DashboardIndicatorBar from './dashboard/DashboardIndicatorBar';
pct, import '../styles/strategyEditorTheme.css';
pctAbs, import '../styles/dashboardCommand.css';
num,
wonFmt,
fmtKrwShort,
colorCls,
SectionTitle,
KpiCard,
MetricRow,
WinRateCircle,
CompareBar,
ResourceBar,
} from './shared/analysisDashboardUi';
import '../styles/dashboardReport.css';
interface Props { interface Props {
theme: Theme; theme: Theme;
@@ -39,6 +29,11 @@ const STATUS_LABEL: Record<string, string> = {
disabled: '비활성', disabled: '비활성',
}; };
const fmtKrw = (n: number) =>
n >= 1e8 ? `${(n / 1e8).toFixed(2)}` : n >= 1e4 ? `${(n / 1e4).toFixed(0)}` : n.toLocaleString('ko-KR');
const fmtPct = (v: number) => `${v >= 0 ? '+' : ''}${(v * 100).toFixed(1)}%`;
function formatAgo(ms: number): string { function formatAgo(ms: number): string {
if (!ms) return '—'; if (!ms) return '—';
const sec = Math.floor((Date.now() - ms) / 1000); const sec = Math.floor((Date.now() - ms) / 1000);
@@ -47,6 +42,13 @@ function formatAgo(ms: number): string {
return `${Math.floor(sec / 3600)}시간 전`; return `${Math.floor(sec / 3600)}시간 전`;
} }
function statusClass(s: string): string {
if (s === 'healthy') return 'gc-proc--ok';
if (s === 'degraded') return 'gc-proc--warn';
if (s === 'down') return 'gc-proc--down';
return 'gc-proc--off';
}
function healthLevel(mon: SystemMonitorDto): 'green' | 'yellow' | 'red' { function healthLevel(mon: SystemMonitorDto): 'green' | 'yellow' | 'red' {
if (!mon.pipelineEnabled) return 'red'; if (!mon.pipelineEnabled) return 'red';
if (mon.jvm.heapPct > 85 || mon.queue.dropped > 0 || mon.staleMarkets.length > 5) return 'yellow'; if (mon.jvm.heapPct > 85 || mon.queue.dropped > 0 || mon.staleMarkets.length > 5) return 'yellow';
@@ -55,31 +57,24 @@ function healthLevel(mon: SystemMonitorDto): 'green' | 'yellow' | 'red' {
return 'green'; return 'green';
} }
function healthScore(level: 'green' | 'yellow' | 'red'): number { const ProcessCard: React.FC<{ proc: ProcessMonitorDto }> = ({ proc }) => (
if (level === 'green') return 1; <article className={`gc-proc ${statusClass(proc.status)}`}>
if (level === 'yellow') return 0.55; <header className="gc-proc-head">
return 0.2; <span className="gc-proc-layer">{proc.layer}</span>
} <span className={`gc-proc-badge ${statusClass(proc.status)}`}>{STATUS_LABEL[proc.status] ?? proc.status}</span>
</header>
function procMiniClass(status: string): string { <h3 className="gc-proc-name">{proc.name}</h3>
if (status === 'healthy') return 'gc-dash-proc-mini--ok'; <ul className="gc-proc-metrics">
if (status === 'degraded') return 'gc-dash-proc-mini--warn'; {Object.entries(proc.metrics).slice(0, 4).map(([k, v]) => (
if (status === 'down') return 'gc-dash-proc-mini--down'; <li key={k}><span>{k}</span><strong>{String(v ?? '—')}</strong></li>
return 'gc-dash-proc-mini--off'; ))}
} </ul>
const SIG_LABEL: Record<string, string> = { BUY: '매수', SELL: '매도' };
const ProcessMini: React.FC<{ proc: ProcessMonitorDto }> = ({ proc }) => (
<article className={`gc-dash-proc-mini ${procMiniClass(proc.status)}`}>
<p className="gc-dash-proc-mini-name">{proc.name}</p>
<span className="gc-dash-proc-mini-badge">{STATUS_LABEL[proc.status] ?? proc.status}</span>
</article> </article>
); );
const HISTORY_LEN = 48; const HISTORY_LEN = 48;
const DashboardPage: React.FC<Props> = ({ onGoChart }) => { const DashboardPage: React.FC<Props> = ({ theme, onGoChart }) => {
const [data, setData] = useState<DashboardSummaryDto | null>(null); const [data, setData] = useState<DashboardSummaryDto | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -116,7 +111,6 @@ const DashboardPage: React.FC<Props> = ({ onGoChart }) => {
const mon = data?.systemMonitor; const mon = data?.systemMonitor;
const app = data?.app as Record<string, unknown> | undefined; const app = data?.app as Record<string, unknown> | undefined;
const health = mon ? healthLevel(mon) : 'yellow'; const health = mon ? healthLevel(mon) : 'yellow';
const healthPct = healthScore(health);
const latencyMs = useMemo(() => { const latencyMs = useMemo(() => {
if (!mon) return 0; if (!mon) return 0;
@@ -124,371 +118,238 @@ const DashboardPage: React.FC<Props> = ({ onGoChart }) => {
return tps > 0 ? Math.max(12, Math.round(1000 / Math.max(tps, 1))) : 45; return tps > 0 ? Math.max(12, Math.round(1000 / Math.max(tps, 1))) : 45;
}, [mon]); }, [mon]);
const stompConnections = data?.monitoredMarkets ?? 0;
const ta4jPct = mon && mon.memory.maxBarsPerSeries > 0 const ta4jPct = mon && mon.memory.maxBarsPerSeries > 0
? Math.min(100, (mon.memory.totalBars / (mon.memory.series * mon.memory.maxBarsPerSeries || 1)) * 100) ? Math.min(100, (mon.memory.totalBars / (mon.memory.series * mon.memory.maxBarsPerSeries || 1)) * 100)
: 0; : 0;
const queuePct = (mon?.queue.fillRatio ?? 0) * 100;
const heapPct = mon?.jvm.heapPct ?? 0;
const buySignals = useMemo(
() => (data?.recentSignals ?? []).filter(s => s.signalType === 'BUY').length,
[data?.recentSignals],
);
const sellSignals = (data?.recentSignals?.length ?? 0) - buySignals;
const printedAt = new Date().toLocaleString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
const tradingMode = String(app?.tradingMode ?? '—');
return ( return (
<div className="gc-dash-report-page"> <div className={`gc-dash-page se-page se-page--${theme}`}>
{error && <p className="gc-dash-report-error">{error}</p>} <header className="gc-dash-header">
{loading && !data && <p className="gc-dash-report-loading"> </p>} <div className="gc-dash-header-left">
<span className="gc-dash-badge">[01] Standard Dashboard</span>
{data && mon && ( <h1 className="gc-dash-title">GoldenChart Command Center</h1>
<div className="gc-dash-report-sheet"> <p className="gc-dash-sub"> · · </p>
<header className="gc-dash-report-header">
<div className="gc-dash-report-brand">
<h1>GoldenChart </h1>
<p className="gc-dash-report-meta">
{printedAt}
{mon.sampledAtMs ? ` · 시스템 갱신 ${formatAgo(mon.sampledAtMs)}` : ''}
</p>
</div> </div>
<div className="gc-dash-report-actions"> <div className="gc-dash-header-right">
<button <span className="gc-dash-stat">Global Latency: <strong>{latencyMs}ms</strong></span>
type="button" <span className="gc-dash-stat">Active Markets: <strong>{stompConnections}</strong></span>
className="gc-dash-report-refresh" {mon && (
onClick={refresh} <span className="gc-dash-stat">
disabled={loading} Pipeline <strong className={mon.pipelineEnabled ? 'up' : 'down'}>{mon.pipelineEnabled ? 'ON' : 'OFF'}</strong>
> {mon.sampledAtMs ? ` · ${formatAgo(mon.sampledAtMs)}` : ''}
</span>
)}
<button type="button" className="gc-dash-btn" onClick={refresh} disabled={loading}>
{loading ? '갱신…' : '새로고침'} {loading ? '갱신…' : '새로고침'}
</button> </button>
</div> </div>
</header> </header>
<div className="brd-dashboard brd-dashboard--report"> {error && <p className="gc-dash-error">{error}</p>}
{/* 요약 헤더 */}
<div className="brd-dash-header"> {data && mon && (
<div className="brd-dash-header-left"> <div className="gc-dash-body">
<div className="brd-dash-strategy">· </div> {/* ── LEFT ── */}
<div className="brd-dash-meta"> <aside className="gc-dash-col gc-dash-col--left">
<span className="brd-dash-badge">{tradingMode}</span> <section className="gc-dash-card gc-dash-card--glow">
<span className="brd-dash-badge"> <h2 className="gc-dash-card-title"></h2>
Pipeline {mon.pipelineEnabled ? 'ON' : 'OFF'} <div className="gc-dash-card-inner">
</span> <div className="gc-dash-kpi-hero">
<span className="brd-dash-badge"> {data.watchlistCount}</span> <div className="gc-dash-kpi-block">
<span className="brd-dash-badge"> {data.monitoredMarkets}</span> <span className="gc-dash-kpi-label"> </span>
<span className="brd-dash-badge brd-dash-badge--time"> <strong className="gc-dash-kpi-val">{fmtKrw(data.paper.totalAsset)}</strong>
Latency {latencyMs}ms <span className={`gc-dash-kpi-sub ${data.paper.totalReturnPct >= 0 ? 'up' : 'down'}`}>
{fmtPct(data.paper.totalReturnPct)}
</span> </span>
</div> </div>
</div> <div className="gc-dash-kpi-block">
<div className="brd-dash-header-kpi brd-dash-header-kpi--report"> <span className="gc-dash-kpi-label"> KRW</span>
<div className="brd-dash-big-kpi brd-dash-big-kpi--report"> <strong className="gc-dash-kpi-val">{fmtKrw(data.live.krwBalance)}</strong>
<div className={`brd-dash-big-val ${colorCls(data.paper.totalReturnPct)}`}> <span className="gc-dash-kpi-sub">{data.live.configured ? '연동됨' : '미설정'}</span>
{pct(data.paper.totalReturnPct)}
</div>
<div className="brd-dash-big-label"> </div>
</div>
<div className="brd-dash-big-kpi brd-dash-big-kpi--report">
<div className={`brd-dash-big-val ${colorCls(data.paper.totalReturnPct)}`}>
{fmtKrwShort(data.paper.totalAsset)}
</div>
<div className="brd-dash-big-label"> </div>
</div> </div>
</div> </div>
<ul className="gc-dash-kv">
<li><span> </span><strong>{fmtKrw(data.paper.cashBalance)}</strong></li>
<li><span> </span><strong>{data.paper.positions?.length ?? 0}</strong></li>
<li><span></span><strong>{data.watchlistCount}</strong></li>
<li><span> </span><strong>{data.monitoredMarkets}</strong></li>
</ul>
</div> </div>
</section>
{/* KPI 6 */} <section className="gc-dash-card">
<div className="brd-row"> <h2 className="gc-dash-card-title"> </h2>
<div className="brd-kpi-grid"> <div className="gc-dash-card-inner gc-dash-card-inner--center">
<KpiCard <div className={`gc-traffic-light gc-traffic-light--${health}`} aria-label="시스템 상태">
icon="💼" <span className="gc-traffic-bulb gc-traffic-bulb--r" />
label="모의 현금" <span className="gc-traffic-bulb gc-traffic-bulb--y" />
value={fmtKrwShort(data.paper.cashBalance)} <span className="gc-traffic-bulb gc-traffic-bulb--g" />
sub={`보유 ${data.paper.positions?.length ?? 0}종목`}
accent="blue"
/>
<KpiCard
icon="🏦"
label="실거래 KRW"
value={fmtKrwShort(data.live.krwBalance)}
sub={data.live.configured ? '연동됨' : '미설정'}
accent="teal"
/>
<KpiCard
icon="📉"
label="JVM Heap"
value={`${heapPct.toFixed(0)}%`}
sub={`${mon.jvm.heapUsedMb} / ${mon.jvm.heapMaxMb} MB`}
valueColor={heapPct > 85 ? 'brd-neg' : heapPct > 70 ? 'brd-warn' : 'brd-pos'}
accent="red"
/>
<KpiCard
icon="📬"
label="이벤트 큐"
value={`${queuePct.toFixed(0)}%`}
sub={`${mon.queue.pending} / ${mon.queue.capacity?.toLocaleString() ?? '—'}`}
valueColor={queuePct > 70 ? 'brd-neg' : ''}
accent="yellow"
/>
<KpiCard
icon="⚡"
label="Tick 처리"
value={String(mon.traffic.ticksProcessedPerSec ?? 0)}
sub="ticks / sec"
accent="purple"
/>
<KpiCard
icon="📡"
label="WebSocket"
value={String(mon.traffic.wsMessagesPerSec ?? 0)}
sub="msg / sec"
accent="green"
/>
</div> </div>
<p className="gc-traffic-caption">
{health === 'green' ? '정상 운영' : health === 'yellow' ? '주의 필요' : '점검 필요'}
</p>
</div> </div>
</section>
{/* 3열 상세 */} <section className="gc-dash-card">
<div className="brd-row brd-row--3col"> <h2 className="gc-dash-card-title"> </h2>
<div className="brd-card"> <div className="gc-dash-card-inner">
<SectionTitle icon="📊" title="포트폴리오" /> <div className="gc-ind-grid">
<div className="brd-card-body"> <DashboardIndicatorBar label="JVM Heap" pct={mon.jvm.heapPct} />
<MetricRow label="모의 총자산" value={fmtKrwShort(data.paper.totalAsset)} /> <DashboardIndicatorBar label="Event Queue" pct={(mon.queue.fillRatio ?? 0) * 100} />
<MetricRow <DashboardIndicatorBar label="Ta4j Cache" pct={ta4jPct} />
label="총 수익률" <DashboardIndicatorBar label="WS Load" pct={Math.min(100, (mon.traffic.wsMessagesPerSec ?? 0) * 2)} />
value={pct(data.paper.totalReturnPct)}
cls={colorCls(data.paper.totalReturnPct)}
/>
<MetricRow
label="평가 손익"
value={wonFmt(data.paper.unrealizedPnl)}
cls={colorCls(data.paper.unrealizedPnl)}
/>
<MetricRow
label="실현 손익"
value={wonFmt(data.paper.realizedPnl)}
cls={colorCls(data.paper.realizedPnl)}
/>
<MetricRow label="모의 현금" value={fmtKrwShort(data.paper.cashBalance)} />
<MetricRow label="주식 평가" value={fmtKrwShort(data.paper.stockEvalAmount)} />
<MetricRow label="초기 자본" value={fmtKrwShort(data.paper.initialCapital)} />
</div> </div>
</div> </div>
</section>
<div className="brd-card"> <section className="gc-dash-card gc-dash-card--compact">
<SectionTitle icon="🛡" title="시스템·리스크" /> <h2 className="gc-dash-card-title"> </h2>
<div className="brd-card-body"> <div className="gc-dash-card-inner">
<MetricRow <ul className="gc-dash-kv">
label="시스템 상태" <li><span> </span><strong>{String(app?.tradingMode ?? '—')}</strong></li>
value={health === 'green' ? '정상' : health === 'yellow' ? '주의' : '점검'} <li><span> </span><strong>{app?.liveAutoTradeEnabled ? 'ON' : 'OFF'}</strong></li>
cls={health === 'green' ? 'brd-pos' : health === 'yellow' ? 'brd-warn' : 'brd-neg'} <li><span> </span><strong>{app?.paperAutoTradeEnabled ? 'ON' : 'OFF'}</strong></li>
/> <li><span>FCM</span><strong>{data.fcm.pushEnabled ? (data.fcm.available ? '활성' : '미연결') : 'OFF'}</strong></li>
<MetricRow label="JVM Heap" value={`${heapPct.toFixed(1)}%`} cls={heapPct > 85 ? 'brd-neg' : ''} note="≥85% 주의" /> </ul>
<MetricRow label="큐 적재" value={`${queuePct.toFixed(1)}%`} note="fill ratio" />
<MetricRow label="큐 드롭" value={String(mon.queue.dropped)} cls={mon.queue.dropped > 0 ? 'brd-neg' : ''} />
<MetricRow label="Ta4j 캐시" value={`${ta4jPct.toFixed(0)}%`} note={`${mon.memory.totalBars.toLocaleString()}`} />
<MetricRow label="틱 정체" value={`${mon.staleMarkets.length}종목`} cls={mon.staleMarkets.length > 0 ? 'brd-warn' : ''} />
<MetricRow label="주문 실패" value={String(mon.orders.failed)} cls={mon.orders.failed > 0 ? 'brd-neg' : ''} />
</div>
</div> </div>
</section>
</aside>
<div className="brd-card"> {/* ── CENTER ── */}
<SectionTitle icon="🔢" title="운영 통계" /> <main className="gc-dash-col gc-dash-col--center">
<div className="brd-card-body brd-card-body--center"> <section className="gc-dash-card gc-dash-card--wide">
<WinRateCircle <h2 className="gc-dash-card-title">[04] · Ta4j </h2>
winRate={healthPct} <div className="gc-dash-card-inner">
winning={mon.processes.filter(p => p.status === 'healthy').length} <div className="gc-infra-grid">
total={Math.max(mon.processes.length, 1)} <DashboardSegmentBar
centerLabel="헬스" label="Ta4jStorage"
valueLabel={`${mon.memory.markets}종목 · ${mon.memory.totalBars.toLocaleString()}`}
pct={ta4jPct}
tone="green"
/> />
<div className="brd-trade-stats"> <DashboardSegmentBar
<MetricRow label="관심종목" value={`${data.watchlistCount}`} /> label="Event Queue"
<MetricRow label="전략 감시" value={`${data.monitoredMarkets}`} /> valueLabel={`${mon.queue.pending} / ${mon.queue.capacity?.toLocaleString() ?? '—'}`}
<MetricRow label="실거래 자동" value={app?.liveAutoTradeEnabled ? 'ON' : 'OFF'} /> pct={(mon.queue.fillRatio ?? 0) * 100}
<MetricRow label="모의 자동" value={app?.paperAutoTradeEnabled ? 'ON' : 'OFF'} /> tone={mon.queue.fillRatio > 0.7 ? 'amber' : 'cyan'}
<MetricRow label="FCM 푸시" value={data.fcm.pushEnabled ? (data.fcm.available ? '활성' : '미연결') : 'OFF'} />
</div>
</div>
</div>
</div>
{/* 2열: 수익 비교 + 리소스 */}
<div className="brd-row brd-row--2col">
<div className="brd-card">
<SectionTitle icon="📐" title="자산·수익 비교" />
<div className="brd-card-body">
<CompareBar
labelA="모의 수익률"
valA={data.paper.totalReturnPct}
labelB="실현 손익률"
valB={data.paper.initialCapital > 0
? data.paper.realizedPnl / data.paper.initialCapital
: 0}
colorA={data.paper.totalReturnPct >= 0 ? 'var(--brd-pos)' : 'var(--brd-neg)'}
colorB="var(--text3)"
/> />
<div className="brd-bench-summary"> <DashboardSegmentBar
<div className="brd-bench-item"> label="Order Worker"
<span className="brd-bench-label"> </span> valueLabel={`${mon.orders.executed} executed · ${mon.orders.failed} fail`}
<span className="brd-bench-val">{fmtKrwShort(data.paper.totalAsset)}</span> pct={Math.min(100, mon.orders.ratePerSecond * 10)}
</div> tone="cyan"
<div className="brd-bench-divider" /> />
<div className="brd-bench-item"> <DashboardSegmentBar
<span className="brd-bench-label"> KRW</span> label="Tick Worker"
<span className="brd-bench-val">{fmtKrwShort(data.live.krwBalance)}</span> valueLabel={`${mon.workers.processed.toLocaleString()} processed`}
</div> pct={mon.workers.active ? 85 : 20}
<div className="brd-bench-divider" /> tone={mon.workers.active ? 'green' : 'red'}
<div className="brd-bench-item">
<span className="brd-bench-label"> </span>
<span className={`brd-bench-val ${colorCls(data.paper.unrealizedPnl)}`}>
{wonFmt(data.paper.unrealizedPnl)}
</span>
</div>
</div>
</div>
</div>
<div className="brd-card">
<SectionTitle icon="💻" title="리소스 사용" />
<div className="brd-card-body">
<div className="brd-pnl-bar-section">
<ResourceBar label="Heap" pctFill={heapPct} cls={heapPct > 85 ? 'brd-neg' : 'brd-pos'} />
<ResourceBar label="Queue" pctFill={queuePct} cls={queuePct > 70 ? 'brd-neg' : 'brd-pos'} />
<ResourceBar label="Ta4j" pctFill={ta4jPct} cls={ta4jPct > 90 ? 'brd-neg' : 'brd-pos'} />
<ResourceBar
label="WS부하"
pctFill={Math.min(100, (mon.traffic.wsMessagesPerSec ?? 0) * 3)}
cls="brd-pos"
/> />
</div> </div>
<div className="brd-pnl-net">
<span className="brd-pnl-net-label"> </span>
<span className="brd-pnl-net-val">
{mon.orders.executed} · {mon.orders.failed}
</span>
</div>
<MetricRow label="Tick Worker" value={mon.workers.active ? '가동' : '대기'} cls={mon.workers.active ? 'brd-pos' : 'brd-warn'} />
<MetricRow label="처리 건수" value={mon.workers.processed.toLocaleString()} />
</div>
</div>
</div> </div>
</section>
{/* 실시간 차트 */} <section className="gc-dash-card gc-dash-card--chart">
<div className="brd-row"> <div className="gc-dash-card-inner">
<div className="brd-card brd-card--full"> <DashboardSparkChart series={tickHistory.current} label="Tick 처리량 (실시간)" height={160} />
<SectionTitle icon="📈" title="실시간 트래픽" />
<div className="gc-dash-spark-row">
<div className="gc-dash-spark-box">
<DashboardSparkChart series={tickHistory.current} label="Tick 처리량" height={120} />
</div>
<div className="gc-dash-spark-box">
<DashboardSparkChart series={wsHistory.current} label="WebSocket 수신" height={120} />
</div>
</div>
</div>
</div> </div>
</section>
{/* 프로세스 */} <section className="gc-dash-card gc-dash-card--wide">
<div className="brd-row"> <h2 className="gc-dash-card-title"> </h2>
<div className="brd-card brd-card--full"> <div className="gc-dash-card-inner">
<SectionTitle <div className="gc-proc-grid">
icon="⚙️" {mon.processes.map(p => <ProcessCard key={p.id} proc={p} />)}
title="멀티스레드 프로세스"
right={<span className="brd-section-count">{mon.processes.length}</span>}
/>
<div className="gc-dash-proc-mini-grid">
{mon.processes.map(p => <ProcessMini key={p.id} proc={p} />)}
</div>
</div> </div>
</div> </div>
</section>
{mon.staleMarkets.length > 0 && ( {mon.staleMarkets.length > 0 && (
<div className="brd-row"> <section className="gc-dash-card gc-dash-card--warn">
<div className="brd-card brd-card--full"> <h2 className="gc-dash-card-title"> ({mon.staleMarkets.length})</h2>
<SectionTitle icon="⚠️" title={`틱 정체 종목 (${mon.staleMarkets.length})`} /> <div className="gc-dash-card-inner">
<div className="gc-dash-stale-chips"> <div className="gc-stale-chips">
{mon.staleMarkets.slice(0, 12).map(s => ( {mon.staleMarkets.slice(0, 8).map(s => (
<button <button key={s.market} type="button" className="gc-stale-chip" onClick={() => onGoChart?.(s.market)}>
key={s.market}
type="button"
className="gc-dash-stale-chip"
onClick={() => onGoChart?.(s.market)}
>
{s.market.replace('KRW-', '')} · {s.lastTickAgeSec}s {s.market.replace('KRW-', '')} · {s.lastTickAgeSec}s
</button> </button>
))} ))}
</div> </div>
</div> </div>
</div> </section>
)} )}
</main>
{/* 최근 시그널 */} {/* ── RIGHT ── */}
<div className="brd-row"> <aside className="gc-dash-col gc-dash-col--right">
<div className="brd-card brd-card--full"> <section className="gc-dash-card gc-dash-card--glow">
<SectionTitle <h2 className="gc-dash-card-title"> · </h2>
icon="📋" <div className="gc-dash-card-inner">
title="최근 매매 시그널" <div className="gc-gauge-grid">
right={<span className="brd-section-count">{data.recentSignals.length}</span>} <DashboardGauge
label="JVM Heap"
value={`${mon.jvm.heapPct}%`}
pct={mon.jvm.heapPct}
tone={mon.jvm.heapPct > 85 ? 'red' : 'green'}
/> />
<DashboardGauge
label="Queue Fill"
value={`${Math.round((mon.queue.fillRatio ?? 0) * 100)}%`}
pct={(mon.queue.fillRatio ?? 0) * 100}
tone={(mon.queue.fillRatio ?? 0) > 0.7 ? 'amber' : 'cyan'}
/>
<DashboardGauge
label="모의 수익률"
value={fmtPct(data.paper.totalReturnPct)}
pct={Math.min(100, Math.abs(data.paper.totalReturnPct) * 200)}
max={100}
tone={data.paper.totalReturnPct >= 0 ? 'green' : 'red'}
/>
<DashboardGauge
label="WS msg/s"
value={String(mon.traffic.wsMessagesPerSec ?? 0)}
pct={Math.min(100, (mon.traffic.wsMessagesPerSec ?? 0) * 3)}
tone="cyan"
/>
</div>
</div>
</section>
<section className="gc-dash-card gc-dash-card--chart-sm">
<div className="gc-dash-card-inner">
<DashboardSparkChart series={wsHistory.current} label="WebSocket 수신" height={80} />
</div>
</section>
<section className="gc-dash-card gc-dash-card--flex">
<h2 className="gc-dash-card-title"> · </h2>
<div className="gc-dash-card-inner gc-dash-card-inner--flex">
{data.recentSignals.length === 0 ? ( {data.recentSignals.length === 0 ? (
<div className="brd-empty-msg"> .</div> <p className="gc-dash-muted"> </p>
) : ( ) : (
<div className="brd-sig-scroll"> <ul className="gc-signal-list">
<table className="brd-sig-table"> {data.recentSignals.slice(0, 12).map(s => {
<thead>
<tr>
<th style={{ width: 36 }}>#</th>
<th></th>
<th></th>
<th></th>
<th style={{ textAlign: 'right' }}></th>
</tr>
</thead>
<tbody>
{data.recentSignals.slice(0, 20).map((s: TradeSignalDto, i: number) => {
const buy = s.signalType === 'BUY'; const buy = s.signalType === 'BUY';
return ( return (
<tr key={s.id} className={i % 2 === 0 ? 'brd-sig-even' : ''}> <li key={s.id} className={`gc-signal-item gc-signal-item--${buy ? 'buy' : 'sell'}`}>
<td style={{ textAlign: 'center', color: 'var(--text3)' }}>{i + 1}</td> <span className="gc-signal-arrow">{buy ? '▲' : '▼'}</span>
<td style={{ fontVariantNumeric: 'tabular-nums' }}> <button type="button" className="gc-signal-main" onClick={() => onGoChart?.(s.market)}>
{s.createdAt?.slice(0, 16).replace('T', ' ') ?? '—'} <strong>{buy ? 'BUY' : 'SELL'}</strong>
</td> <span>{s.market.replace('KRW-', '')}</span>
<td> <span className="gc-signal-price">@ {s.price?.toLocaleString('ko-KR')}</span>
<button
type="button"
className="brd-sig-market-btn"
onClick={() => onGoChart?.(s.market)}
>
{s.market.replace('KRW-', '')}
</button> </button>
</td> <span className="gc-signal-time">{s.createdAt?.slice(11, 16) ?? ''}</span>
<td> </li>
<span className={`brd-sig-badge ${buy ? 'brd-sig-buy' : 'brd-sig-sell'}`}>
{SIG_LABEL[s.signalType] ?? s.signalType}
</span>
</td>
<td style={{ textAlign: 'right', fontVariantNumeric: 'tabular-nums', fontWeight: 600 }}>
{Math.round(s.price).toLocaleString()}
</td>
</tr>
); );
})} })}
</tbody> </ul>
</table>
</div>
)} )}
</div> </div>
</div> </section>
</div> </aside>
</div> </div>
)} )}
</div> </div>
+122 -36
View File
@@ -105,9 +105,10 @@
min-height: 0; min-height: 0;
display: grid; display: grid;
grid-template-columns: minmax(220px, 24%) minmax(0, 1fr) minmax(240px, 26%); grid-template-columns: minmax(220px, 24%) minmax(0, 1fr) minmax(240px, 26%);
gap: 10px; gap: 12px;
padding: 10px 12px 12px; padding: 12px 14px 14px;
overflow: hidden; overflow: hidden;
align-items: stretch;
} }
.gc-dash-col { .gc-dash-col {
@@ -121,11 +122,15 @@
.gc-dash-card { .gc-dash-card {
flex-shrink: 0; flex-shrink: 0;
display: flex;
flex-direction: column;
min-width: 0;
border: 1px solid color-mix(in srgb, #22d3ee 18%, var(--se-border)); border: 1px solid color-mix(in srgb, #22d3ee 18%, var(--se-border));
border-radius: 12px; border-radius: 10px;
background: color-mix(in srgb, var(--se-bg-elevated) 92%, #0a1628); background: color-mix(in srgb, var(--se-bg-elevated) 92%, #0a1628);
padding: 12px; padding: 0;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.25), inset 0 1px 0 color-mix(in srgb, #22d3ee 6%, transparent); overflow: hidden;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.2), inset 0 1px 0 color-mix(in srgb, #22d3ee 6%, transparent);
} }
.gc-dash-card--glow { .gc-dash-card--glow {
@@ -139,11 +144,11 @@
.gc-dash-card--wide { /* grid child */ } .gc-dash-card--wide { /* grid child */ }
.gc-dash-card--chart { padding: 8px 10px 4px; } .gc-dash-card--chart .gc-dash-card-inner { padding: 8px 12px 6px; }
.gc-dash-card--chart-sm { padding: 6px 8px 2px; } .gc-dash-card--chart-sm .gc-dash-card-inner { padding: 6px 12px 4px; }
.gc-dash-card--compact { padding: 10px; } .gc-dash-card--compact .gc-dash-card-inner { padding: 10px 12px; }
.gc-dash-card--flex { .gc-dash-card--flex {
flex: 1; flex: 1;
@@ -153,12 +158,38 @@
} }
.gc-dash-card-title { .gc-dash-card-title {
margin: 0 0 10px; flex-shrink: 0;
margin: 0;
padding: 9px 12px 8px;
font-size: 0.72rem; font-size: 0.72rem;
font-weight: 700; font-weight: 700;
letter-spacing: 0.04em; letter-spacing: 0.04em;
color: var(--se-gold, #e6c200); color: var(--se-gold, #e6c200);
text-transform: uppercase; text-transform: uppercase;
border-bottom: 1px solid color-mix(in srgb, var(--se-border) 80%, transparent);
background: color-mix(in srgb, var(--se-bg) 40%, transparent);
}
/* 카드 본문 — 패딩·정렬 통일 */
.gc-dash-card-inner {
flex: 1;
min-height: 0;
padding: 10px 12px 12px;
display: flex;
flex-direction: column;
gap: 0;
}
.gc-dash-card-inner--center {
align-items: center;
justify-content: center;
text-align: center;
}
.gc-dash-card-inner--flex {
flex: 1;
min-height: 0;
overflow: hidden;
} }
/* ── Portfolio KPI ── */ /* ── Portfolio KPI ── */
@@ -167,11 +198,16 @@
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: 8px; gap: 8px;
margin-bottom: 10px; margin-bottom: 8px;
} }
.gc-dash-kpi-block { .gc-dash-kpi-block {
padding: 10px; display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
min-height: 72px;
padding: 10px 10px 8px;
border-radius: 8px; border-radius: 8px;
border: 1px solid var(--se-border); border: 1px solid var(--se-border);
background: color-mix(in srgb, var(--se-bg) 60%, transparent); background: color-mix(in srgb, var(--se-bg) 60%, transparent);
@@ -181,7 +217,8 @@
display: block; display: block;
font-size: 0.62rem; font-size: 0.62rem;
color: var(--se-text-muted); color: var(--se-text-muted);
margin-bottom: 4px; margin-bottom: 6px;
line-height: 1.2;
} }
.gc-dash-kpi-val { .gc-dash-kpi-val {
@@ -189,13 +226,17 @@
font-size: 1rem; font-size: 1rem;
font-weight: 800; font-weight: 800;
color: var(--se-text); color: var(--se-text);
line-height: 1.15;
letter-spacing: -0.02em;
} }
.gc-dash-kpi-sub { .gc-dash-kpi-sub {
display: block; display: block;
font-size: 0.68rem; font-size: 0.68rem;
margin-top: 2px; margin-top: auto;
padding-top: 4px;
color: var(--se-text-muted); color: var(--se-text-muted);
line-height: 1.2;
} }
.gc-dash-kpi-sub.up { color: #34d399; } .gc-dash-kpi-sub.up { color: #34d399; }
@@ -208,19 +249,30 @@
} }
.gc-dash-kv li { .gc-dash-kv li {
display: flex; display: grid;
justify-content: space-between; grid-template-columns: 1fr auto;
align-items: baseline; align-items: center;
padding: 5px 0; gap: 12px;
padding: 6px 0;
font-size: 0.72rem; font-size: 0.72rem;
border-bottom: 1px solid color-mix(in srgb, var(--se-border) 60%, transparent); border-bottom: 1px solid color-mix(in srgb, var(--se-border) 50%, transparent);
min-height: 28px;
} }
.gc-dash-kv li:last-child { border-bottom: none; } .gc-dash-kv li:last-child { border-bottom: none; }
.gc-dash-kv span { color: var(--se-text-muted); } .gc-dash-kv span {
color: var(--se-text-muted);
text-align: left;
}
.gc-dash-kv strong { color: var(--se-text); font-weight: 600; } .gc-dash-kv strong {
color: var(--se-text);
font-weight: 600;
text-align: right;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
/* ── Traffic light ── */ /* ── Traffic light ── */
@@ -325,9 +377,10 @@
} }
.gc-seg-bar-head { .gc-seg-bar-head {
display: flex; display: grid;
justify-content: space-between; grid-template-columns: minmax(0, 1fr) auto;
align-items: baseline; align-items: baseline;
gap: 8px;
margin-bottom: 6px; margin-bottom: 6px;
} }
@@ -340,6 +393,9 @@
.gc-seg-bar-val { .gc-seg-bar-val {
font-size: 0.62rem; font-size: 0.62rem;
color: var(--se-text-muted); color: var(--se-text-muted);
text-align: right;
white-space: nowrap;
font-variant-numeric: tabular-nums;
} }
.gc-seg-bar-track { .gc-seg-bar-track {
@@ -383,7 +439,8 @@
.gc-gauge-grid { .gc-gauge-grid {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: 8px; gap: 10px 8px;
align-items: start;
} }
.gc-gauge { .gc-gauge {
@@ -420,14 +477,18 @@
.gc-proc-grid { .gc-proc-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(168px, 1fr));
gap: 8px; gap: 8px;
align-items: stretch;
} }
.gc-proc { .gc-proc {
display: flex;
flex-direction: column;
min-height: 100%;
border: 1px solid var(--se-border); border: 1px solid var(--se-border);
border-radius: 8px; border-radius: 8px;
padding: 10px; padding: 10px 10px 8px;
background: color-mix(in srgb, var(--se-bg) 70%, transparent); background: color-mix(in srgb, var(--se-bg) 70%, transparent);
} }
@@ -475,13 +536,30 @@
} }
.gc-proc-metrics li { .gc-proc-metrics li {
display: flex; display: grid;
justify-content: space-between; grid-template-columns: minmax(0, 1fr) auto;
padding: 2px 0; gap: 8px;
align-items: baseline;
padding: 3px 0;
color: var(--se-text-muted); color: var(--se-text-muted);
border-bottom: 1px solid color-mix(in srgb, var(--se-border) 35%, transparent);
} }
.gc-proc-metrics strong { color: var(--se-text); font-weight: 500; } .gc-proc-metrics li:last-child { border-bottom: none; }
.gc-proc-metrics span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.gc-proc-metrics strong {
color: var(--se-text);
font-weight: 500;
text-align: right;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
/* ── Signal list ── */ /* ── Signal list ── */
@@ -496,28 +574,33 @@
.gc-signal-item { .gc-signal-item {
display: grid; display: grid;
grid-template-columns: 20px 1fr auto; grid-template-columns: 22px minmax(0, 1fr) 42px;
gap: 6px; gap: 8px;
align-items: center; align-items: center;
padding: 7px 6px; padding: 8px 4px;
border-bottom: 1px solid color-mix(in srgb, var(--se-border) 50%, transparent); border-bottom: 1px solid color-mix(in srgb, var(--se-border) 50%, transparent);
font-size: 0.68rem; font-size: 0.68rem;
} }
.gc-signal-item:nth-child(even) {
background: color-mix(in srgb, var(--se-bg) 35%, transparent);
}
.gc-signal-item--buy .gc-signal-arrow { color: var(--gc-trade-buy); } .gc-signal-item--buy .gc-signal-arrow { color: var(--gc-trade-buy); }
.gc-signal-item--sell .gc-signal-arrow { color: var(--gc-trade-sell); } .gc-signal-item--sell .gc-signal-arrow { color: var(--gc-trade-sell); }
.gc-signal-main { .gc-signal-main {
display: flex; display: grid;
flex-wrap: wrap; grid-template-columns: auto minmax(0, 1fr) auto;
gap: 4px 8px; gap: 6px 8px;
align-items: baseline; align-items: center;
background: none; background: none;
border: none; border: none;
padding: 0; padding: 0;
color: var(--se-text); color: var(--se-text);
cursor: pointer; cursor: pointer;
text-align: left; text-align: left;
min-width: 0;
} }
.gc-signal-main strong { .gc-signal-main strong {
@@ -533,6 +616,9 @@
.gc-signal-time { .gc-signal-time {
font-size: 0.58rem; font-size: 0.58rem;
color: var(--se-text-dim); color: var(--se-text-dim);
text-align: right;
font-variant-numeric: tabular-nums;
white-space: nowrap;
} }
.gc-dash-muted { .gc-dash-muted {
+57
View File
@@ -234,3 +234,60 @@
.gc-dash-stale-chip:hover { .gc-dash-stale-chip:hover {
background: #fef3c7; background: #fef3c7;
} }
/* 레포트형 헤더 KPI (라벨·수치 한 줄) */
.gc-dash-report-sheet .brd-dash-header-kpi--report {
align-items: center !important;
}
.gc-dash-report-sheet .brd-dash-big-kpi--report {
display: inline-flex !important;
flex-direction: row !important;
align-items: baseline !important;
gap: 5px !important;
white-space: nowrap !important;
min-width: 4.8rem;
}
.gc-dash-report-sheet .brd-dash-big-kpi--report:last-child {
min-width: 7.5rem;
}
.gc-dash-report-sheet .brd-dash-big-kpi--report .brd-dash-big-label {
order: -1;
margin-top: 0 !important;
font-size: 0.62rem !important;
color: #6b7280;
}
.gc-dash-report-sheet .brd-dash-big-val {
font-size: 1.05rem !important;
font-weight: 800 !important;
}
.gc-dash-report-sheet .brd-sig-market-btn {
border: none;
background: none;
padding: 0;
font-size: inherit;
font-weight: 600;
color: #1d4ed8;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
}
.gc-dash-report-sheet .brd-sig-market-btn:hover {
color: #1e40af;
}
.gc-dash-report-sheet .gc-spark-chart-head {
display: flex;
justify-content: space-between;
font-size: 0.68rem;
color: #6b7280;
margin-bottom: 4px;
}
.gc-dash-report-sheet .gc-spark-chart-head strong {
color: #111827;
font-weight: 700;
}