diff --git a/frontend/src/components/DashboardPage.tsx b/frontend/src/components/DashboardPage.tsx index 276d89f..0e21ea9 100644 --- a/frontend/src/components/DashboardPage.tsx +++ b/frontend/src/components/DashboardPage.tsx @@ -1,5 +1,6 @@ /** - * GoldenChart 대시보드 — 투자분석 레포트형 카드 UI + * GoldenChart Command Center — Standard + System/Tech + * (기존 테마·내용 유지, 카드 레이아웃·정렬만 정돈) */ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { Theme } from '../types'; @@ -8,24 +9,13 @@ import { type DashboardSummaryDto, type ProcessMonitorDto, type SystemMonitorDto, - type TradeSignalDto, } from '../utils/backendApi'; +import DashboardGauge from './dashboard/DashboardGauge'; +import DashboardSegmentBar from './dashboard/DashboardSegmentBar'; import DashboardSparkChart from './dashboard/DashboardSparkChart'; -import { - pct, - pctAbs, - num, - wonFmt, - fmtKrwShort, - colorCls, - SectionTitle, - KpiCard, - MetricRow, - WinRateCircle, - CompareBar, - ResourceBar, -} from './shared/analysisDashboardUi'; -import '../styles/dashboardReport.css'; +import DashboardIndicatorBar from './dashboard/DashboardIndicatorBar'; +import '../styles/strategyEditorTheme.css'; +import '../styles/dashboardCommand.css'; interface Props { theme: Theme; @@ -39,6 +29,11 @@ const STATUS_LABEL: Record = { 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 { if (!ms) return '—'; const sec = Math.floor((Date.now() - ms) / 1000); @@ -47,6 +42,13 @@ function formatAgo(ms: number): string { 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' { if (!mon.pipelineEnabled) return 'red'; 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'; } -function healthScore(level: 'green' | 'yellow' | 'red'): number { - if (level === 'green') return 1; - if (level === 'yellow') return 0.55; - return 0.2; -} - -function procMiniClass(status: string): string { - if (status === 'healthy') return 'gc-dash-proc-mini--ok'; - if (status === 'degraded') return 'gc-dash-proc-mini--warn'; - if (status === 'down') return 'gc-dash-proc-mini--down'; - return 'gc-dash-proc-mini--off'; -} - -const SIG_LABEL: Record = { BUY: '매수', SELL: '매도' }; - -const ProcessMini: React.FC<{ proc: ProcessMonitorDto }> = ({ proc }) => ( -
-

{proc.name}

- {STATUS_LABEL[proc.status] ?? proc.status} +const ProcessCard: React.FC<{ proc: ProcessMonitorDto }> = ({ proc }) => ( +
+
+ {proc.layer} + {STATUS_LABEL[proc.status] ?? proc.status} +
+

{proc.name}

+
    + {Object.entries(proc.metrics).slice(0, 4).map(([k, v]) => ( +
  • {k}{String(v ?? '—')}
  • + ))} +
); const HISTORY_LEN = 48; -const DashboardPage: React.FC = ({ onGoChart }) => { +const DashboardPage: React.FC = ({ theme, onGoChart }) => { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -116,7 +111,6 @@ const DashboardPage: React.FC = ({ onGoChart }) => { const mon = data?.systemMonitor; const app = data?.app as Record | undefined; const health = mon ? healthLevel(mon) : 'yellow'; - const healthPct = healthScore(health); const latencyMs = useMemo(() => { if (!mon) return 0; @@ -124,371 +118,238 @@ const DashboardPage: React.FC = ({ onGoChart }) => { return tps > 0 ? Math.max(12, Math.round(1000 / Math.max(tps, 1))) : 45; }, [mon]); + const stompConnections = data?.monitoredMarkets ?? 0; + const ta4jPct = mon && mon.memory.maxBarsPerSeries > 0 ? Math.min(100, (mon.memory.totalBars / (mon.memory.series * mon.memory.maxBarsPerSeries || 1)) * 100) : 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 ( -
- {error &&

{error}

} - {loading && !data &&

대시보드 불러오는 중…

} +
+
+
+ [01] Standard Dashboard +

GoldenChart Command Center

+

실시간 투자 분석 · 파이프라인 모니터링 · 시스템 헬스체크

+
+
+ Global Latency: {latencyMs}ms + Active Markets: {stompConnections} + {mon && ( + + Pipeline {mon.pipelineEnabled ? 'ON' : 'OFF'} + {mon.sampledAtMs ? ` · ${formatAgo(mon.sampledAtMs)}` : ''} + + )} + +
+
+ + {error &&

{error}

} {data && mon && ( -
-
-
-

GoldenChart 대시보드

-

- 출력일시 {printedAt} - {mon.sampledAtMs ? ` · 시스템 갱신 ${formatAgo(mon.sampledAtMs)}` : ''} -

-
-
- -
-
- -
- {/* 요약 헤더 */} -
-
-
투자·시스템 현황 요약
-
- {tradingMode} - - Pipeline {mon.pipelineEnabled ? 'ON' : 'OFF'} - - 관심 {data.watchlistCount} - 감시 {data.monitoredMarkets} - - Latency {latencyMs}ms - -
-
-
-
-
- {pct(data.paper.totalReturnPct)} -
-
모의 총 수익률
-
-
-
- {fmtKrwShort(data.paper.totalAsset)} -
-
모의 총 자산
-
-
-
- - {/* KPI 6 */} -
-
- - - 85 ? 'brd-neg' : heapPct > 70 ? 'brd-warn' : 'brd-pos'} - accent="red" - /> - 70 ? 'brd-neg' : ''} - accent="yellow" - /> - - -
-
- - {/* 3열 상세 */} -
-
- -
- - - - - - - -
-
- -
- -
- - 85 ? 'brd-neg' : ''} note="≥85% 주의" /> - - 0 ? 'brd-neg' : ''} /> - - 0 ? 'brd-warn' : ''} /> - 0 ? 'brd-neg' : ''} /> -
-
- -
- -
- p.status === 'healthy').length} - total={Math.max(mon.processes.length, 1)} - centerLabel="헬스" - /> -
- - - - - -
-
-
-
- - {/* 2열: 수익 비교 + 리소스 */} -
-
- -
- 0 - ? data.paper.realizedPnl / data.paper.initialCapital - : 0} - colorA={data.paper.totalReturnPct >= 0 ? 'var(--brd-pos)' : 'var(--brd-neg)'} - colorB="var(--text3)" - /> -
-
- 모의 자산 - {fmtKrwShort(data.paper.totalAsset)} -
-
-
- 실거래 KRW - {fmtKrwShort(data.live.krwBalance)} -
-
-
- 평가 손익 - - {wonFmt(data.paper.unrealizedPnl)} - -
-
-
-
- -
- -
-
- 85 ? 'brd-neg' : 'brd-pos'} /> - 70 ? 'brd-neg' : 'brd-pos'} /> - 90 ? 'brd-neg' : 'brd-pos'} /> - -
-
- 주문 처리 - - {mon.orders.executed}건 · 실패 {mon.orders.failed} +
+ {/* ── LEFT ── */} +
- - {/* 실시간 차트 */} -
-
- -
-
- -
-
- +
+ 실거래 KRW + {fmtKrw(data.live.krwBalance)} + {data.live.configured ? '연동됨' : '미설정'}
+
    +
  • 모의 현금{fmtKrw(data.paper.cashBalance)}
  • +
  • 보유 종목{data.paper.positions?.length ?? 0}
  • +
  • 관심종목{data.watchlistCount}
  • +
  • 전략 감시{data.monitoredMarkets}
  • +
-
+ - {/* 프로세스 */} -
-
- {mon.processes.length}개} - /> -
- {mon.processes.map(p => )} +
+

시스템 상태

+
+
+ + + +
+

+ {health === 'green' ? '정상 운영' : health === 'yellow' ? '주의 필요' : '점검 필요'} +

+
+
+ +
+

리소스 인디케이터

+
+
+ + + +
-
+ + +
+

매매 모드

+
+
    +
  • 운영 모드{String(app?.tradingMode ?? '—')}
  • +
  • 실거래 자동{app?.liveAutoTradeEnabled ? 'ON' : 'OFF'}
  • +
  • 모의 자동{app?.paperAutoTradeEnabled ? 'ON' : 'OFF'}
  • +
  • FCM{data.fcm.pushEnabled ? (data.fcm.available ? '활성' : '미연결') : 'OFF'}
  • +
+
+
+ + + {/* ── CENTER ── */} +
+
+

[04] 데이터 파이프라인 · Ta4j 엔진

+
+
+ + 0.7 ? 'amber' : 'cyan'} + /> + + +
+
+
+ +
+
+ +
+
+ +
+

멀티스레드 프로세스

+
+
+ {mon.processes.map(p => )} +
+
+
{mon.staleMarkets.length > 0 && ( -
-
- -
- {mon.staleMarkets.slice(0, 12).map(s => ( - ))}
-
+ )} +
- {/* 최근 시그널 */} -
-
- {data.recentSignals.length}건} - /> + {/* ── RIGHT ── */} +
-
+ +
)}
diff --git a/frontend/src/styles/dashboardCommand.css b/frontend/src/styles/dashboardCommand.css index fe7620f..5089fda 100644 --- a/frontend/src/styles/dashboardCommand.css +++ b/frontend/src/styles/dashboardCommand.css @@ -105,9 +105,10 @@ min-height: 0; display: grid; grid-template-columns: minmax(220px, 24%) minmax(0, 1fr) minmax(240px, 26%); - gap: 10px; - padding: 10px 12px 12px; + gap: 12px; + padding: 12px 14px 14px; overflow: hidden; + align-items: stretch; } .gc-dash-col { @@ -121,11 +122,15 @@ .gc-dash-card { flex-shrink: 0; + display: flex; + flex-direction: column; + min-width: 0; 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); - padding: 12px; - box-shadow: 0 4px 24px rgba(0, 0, 0, 0.25), inset 0 1px 0 color-mix(in srgb, #22d3ee 6%, transparent); + padding: 0; + 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 { @@ -139,11 +144,11 @@ .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 { flex: 1; @@ -153,12 +158,38 @@ } .gc-dash-card-title { - margin: 0 0 10px; + flex-shrink: 0; + margin: 0; + padding: 9px 12px 8px; font-size: 0.72rem; font-weight: 700; letter-spacing: 0.04em; color: var(--se-gold, #e6c200); 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 ── */ @@ -167,11 +198,16 @@ display: grid; grid-template-columns: 1fr 1fr; gap: 8px; - margin-bottom: 10px; + margin-bottom: 8px; } .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: 1px solid var(--se-border); background: color-mix(in srgb, var(--se-bg) 60%, transparent); @@ -181,7 +217,8 @@ display: block; font-size: 0.62rem; color: var(--se-text-muted); - margin-bottom: 4px; + margin-bottom: 6px; + line-height: 1.2; } .gc-dash-kpi-val { @@ -189,13 +226,17 @@ font-size: 1rem; font-weight: 800; color: var(--se-text); + line-height: 1.15; + letter-spacing: -0.02em; } .gc-dash-kpi-sub { display: block; font-size: 0.68rem; - margin-top: 2px; + margin-top: auto; + padding-top: 4px; color: var(--se-text-muted); + line-height: 1.2; } .gc-dash-kpi-sub.up { color: #34d399; } @@ -208,19 +249,30 @@ } .gc-dash-kv li { - display: flex; - justify-content: space-between; - align-items: baseline; - padding: 5px 0; + display: grid; + grid-template-columns: 1fr auto; + align-items: center; + gap: 12px; + padding: 6px 0; 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 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 ── */ @@ -325,9 +377,10 @@ } .gc-seg-bar-head { - display: flex; - justify-content: space-between; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; align-items: baseline; + gap: 8px; margin-bottom: 6px; } @@ -340,6 +393,9 @@ .gc-seg-bar-val { font-size: 0.62rem; color: var(--se-text-muted); + text-align: right; + white-space: nowrap; + font-variant-numeric: tabular-nums; } .gc-seg-bar-track { @@ -383,7 +439,8 @@ .gc-gauge-grid { display: grid; grid-template-columns: 1fr 1fr; - gap: 8px; + gap: 10px 8px; + align-items: start; } .gc-gauge { @@ -420,14 +477,18 @@ .gc-proc-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(168px, 1fr)); gap: 8px; + align-items: stretch; } .gc-proc { + display: flex; + flex-direction: column; + min-height: 100%; border: 1px solid var(--se-border); border-radius: 8px; - padding: 10px; + padding: 10px 10px 8px; background: color-mix(in srgb, var(--se-bg) 70%, transparent); } @@ -475,13 +536,30 @@ } .gc-proc-metrics li { - display: flex; - justify-content: space-between; - padding: 2px 0; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + align-items: baseline; + padding: 3px 0; 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 ── */ @@ -496,28 +574,33 @@ .gc-signal-item { display: grid; - grid-template-columns: 20px 1fr auto; - gap: 6px; + grid-template-columns: 22px minmax(0, 1fr) 42px; + gap: 8px; align-items: center; - padding: 7px 6px; + padding: 8px 4px; border-bottom: 1px solid color-mix(in srgb, var(--se-border) 50%, transparent); 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--sell .gc-signal-arrow { color: var(--gc-trade-sell); } .gc-signal-main { - display: flex; - flex-wrap: wrap; - gap: 4px 8px; - align-items: baseline; + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 6px 8px; + align-items: center; background: none; border: none; padding: 0; color: var(--se-text); cursor: pointer; text-align: left; + min-width: 0; } .gc-signal-main strong { @@ -533,6 +616,9 @@ .gc-signal-time { font-size: 0.58rem; color: var(--se-text-dim); + text-align: right; + font-variant-numeric: tabular-nums; + white-space: nowrap; } .gc-dash-muted { diff --git a/frontend/src/styles/dashboardReport.css b/frontend/src/styles/dashboardReport.css index 4213045..13fe02b 100644 --- a/frontend/src/styles/dashboardReport.css +++ b/frontend/src/styles/dashboardReport.css @@ -234,3 +234,60 @@ .gc-dash-stale-chip:hover { 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; +}