대시보드 시스템 상태 카드 — 세로 블록바+신호등 2열 레이아웃으로 재설계

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Macbook
2026-06-11 16:45:08 +09:00
parent 75738c424f
commit 50aed38f62
3 changed files with 160 additions and 72 deletions
+17 -52
View File
@@ -15,6 +15,7 @@ import DashboardGauge from './dashboard/DashboardGauge';
import DashboardSegmentBar from './dashboard/DashboardSegmentBar';
import DashboardSparkChart from './dashboard/DashboardSparkChart';
import DashboardIndicatorBar from './dashboard/DashboardIndicatorBar';
import DashboardSystemCard from './dashboard/DashboardSystemCard';
import '../styles/strategyEditorTheme.css';
import '../styles/dashboardCommand.css';
@@ -184,64 +185,28 @@ const DashboardPage: React.FC<Props> = ({ theme, onGoChart }) => {
<h2 className="gc-dash-card-title"> </h2>
<div className="gc-dash-card-inner">
<div className="gc-dash-sys-status">
{/* 전체 시스템 */}
<div className="gc-sys-tl-item">
<div className={`gc-traffic-light gc-traffic-light--${health}`} aria-label="시스템 상태">
<span className="gc-traffic-bulb gc-traffic-bulb--r" />
<span className="gc-traffic-bulb gc-traffic-bulb--y" />
<span className="gc-traffic-bulb gc-traffic-bulb--g" />
</div>
<p className="gc-traffic-caption">
{health === 'green' ? '정상 운영' : health === 'yellow' ? '주의 필요' : '점검 필요'}
</p>
<span className="gc-sys-tl-label"></span>
</div>
{/* 메모리 */}
{(() => {
const memPct = mon.system?.osMemPct ?? mon.jvm.heapPct;
const memLevel: 'green' | 'yellow' | 'red' =
memPct >= 90 ? 'red' : memPct >= 70 ? 'yellow' : 'green';
const memUsed = mon.system?.osMemUsedMb ?? mon.jvm.heapUsedMb;
const memTotal = mon.system?.osMemTotalMb ?? mon.jvm.heapMaxMb;
const pct = mon.system?.osMemPct ?? mon.jvm.heapPct;
const used = mon.system?.osMemUsedMb ?? mon.jvm.heapUsedMb;
const total = mon.system?.osMemTotalMb ?? mon.jvm.heapMaxMb;
const fmt = (mb: number) => mb >= 1024 ? `${(mb / 1024).toFixed(1)}GB` : `${mb}MB`;
return (
<div className="gc-sys-tl-item">
<div className={`gc-traffic-light gc-traffic-light--${memLevel}`} aria-label="메모리 사용량">
<span className="gc-traffic-bulb gc-traffic-bulb--r" />
<span className="gc-traffic-bulb gc-traffic-bulb--y" />
<span className="gc-traffic-bulb gc-traffic-bulb--g" />
</div>
<p className="gc-traffic-caption">{memPct.toFixed(0)}%</p>
<p className="gc-traffic-caption gc-traffic-caption--sub">
{memUsed >= 1024 ? `${(memUsed/1024).toFixed(1)}GB` : `${memUsed}MB`}
{' / '}
{memTotal >= 1024 ? `${(memTotal/1024).toFixed(1)}GB` : `${memTotal}MB`}
</p>
<span className="gc-sys-tl-label"></span>
</div>
<DashboardSystemCard
label="메모리"
pct={pct}
usedLabel={fmt(used)}
totalLabel={fmt(total)}
/>
);
})()}
{/* 디스크 */}
{(() => {
const diskPct = mon.system?.diskPct ?? 0;
const diskLevel: 'green' | 'yellow' | 'red' =
diskPct >= 90 ? 'red' : diskPct >= 70 ? 'yellow' : 'green';
const diskUsed = mon.system?.diskUsedGb ?? 0;
const diskTotal = mon.system?.diskTotalGb ?? 0;
return (
<div className="gc-sys-tl-item">
<div className={`gc-traffic-light gc-traffic-light--${diskLevel}`} aria-label="디스크 사용량">
<span className="gc-traffic-bulb gc-traffic-bulb--r" />
<span className="gc-traffic-bulb gc-traffic-bulb--y" />
<span className="gc-traffic-bulb gc-traffic-bulb--g" />
</div>
<p className="gc-traffic-caption">{diskPct.toFixed(0)}%</p>
<p className="gc-traffic-caption gc-traffic-caption--sub">
{diskUsed.toFixed(1)}GB / {diskTotal.toFixed(1)}GB
</p>
<span className="gc-sys-tl-label"></span>
</div>
);
})()}
<DashboardSystemCard
label="디스크"
pct={mon.system?.diskPct ?? 0}
usedLabel={`${(mon.system?.diskUsedGb ?? 0).toFixed(1)}GB`}
totalLabel={`${(mon.system?.diskTotalGb ?? 0).toFixed(1)}GB`}
/>
</div>
</div>
</section>
@@ -0,0 +1,60 @@
import React from 'react';
interface Props {
label: string;
pct: number; // 0100
usedLabel: string; // e.g. "5.8GB"
totalLabel: string; // e.g. "7.8GB"
}
const BLOCKS = 12;
function trafficLevel(pct: number): 'green' | 'yellow' | 'red' {
if (pct >= 90) return 'red';
if (pct >= 70) return 'yellow';
return 'green';
}
/** 리소스 카드 — 세로 블록 바 + 신호등 */
export default function DashboardSystemCard({ label, pct, usedLabel, totalLabel }: Props) {
const clamped = Math.max(0, Math.min(100, pct));
const filled = Math.round((clamped / 100) * BLOCKS);
const level = trafficLevel(clamped);
return (
<div className="gc-sys-card">
<span className="gc-sys-card-title">{label}</span>
<div className="gc-sys-card-body">
{/* 세로 블록 바 */}
<div className="gc-sys-bar-wrap" aria-hidden>
{Array.from({ length: BLOCKS }, (_, i) => {
const idx = BLOCKS - 1 - i; // 위→낮음, 아래→높음
const on = idx < filled;
let tone = 'gc-sys-block--mid';
if (idx >= BLOCKS - 3) tone = 'gc-sys-block--lo'; // 하위(초록)
else if (idx <= 2) tone = 'gc-sys-block--hi'; // 상위(빨강)
return (
<span
key={i}
className={`gc-sys-block${on ? ` ${tone} gc-sys-block--on` : ''}`}
/>
);
})}
</div>
{/* 신호등 */}
<div className={`gc-traffic-light gc-traffic-light--${level}`} aria-label={`${label} 상태`}>
<span className="gc-traffic-bulb gc-traffic-bulb--r" />
<span className="gc-traffic-bulb gc-traffic-bulb--y" />
<span className="gc-traffic-bulb gc-traffic-bulb--g" />
</div>
</div>
<div className="gc-sys-card-foot">
<span className="gc-sys-card-pct">{clamped.toFixed(0)}%</span>
<span className="gc-sys-card-detail">{usedLabel} / {totalLabel}</span>
</div>
</div>
);
}
+76 -13
View File
@@ -326,32 +326,95 @@
margin-top: 2px;
}
/* ── 시스템 상태 — 3신호등 (가로 배치) ── */
/* ── 시스템 상태 — 2리소스 카드 (가로 배치) ── */
.gc-dash-sys-status {
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: space-around;
gap: 8px;
padding: 8px 4px;
gap: 10px;
width: 100%;
}
.gc-sys-tl-item {
/* ── DashboardSystemCard ── */
.gc-sys-card {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
flex: 1;
gap: 6px;
padding: 10px 8px;
border-radius: 10px;
border: 1px solid var(--se-border);
background: color-mix(in srgb, var(--se-bg) 60%, transparent);
min-width: 0;
}
.gc-sys-tl-label {
.gc-sys-card-title {
font-size: 0.65rem;
font-weight: 600;
color: var(--se-text-muted);
letter-spacing: 0.03em;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
margin-top: 2px;
color: var(--se-text-muted);
}
.gc-sys-card-body {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
}
/* 세로 블록 바 */
.gc-sys-bar-wrap {
display: flex;
flex-direction: column;
gap: 3px;
}
.gc-sys-block {
width: 18px;
height: 8px;
border-radius: 2px;
background: color-mix(in srgb, var(--se-text-dim, #666) 18%, #111);
opacity: 0.28;
}
.gc-sys-block--on { opacity: 1; }
/* 하위 블록 (낮은 사용률 = 초록) */
.gc-sys-block--lo.gc-sys-block--on {
background: #34d399;
box-shadow: 0 0 4px rgba(52,211,153,0.6);
}
/* 중간 블록 (중간 사용률 = 노랑) */
.gc-sys-block--mid.gc-sys-block--on {
background: #fbbf24;
box-shadow: 0 0 4px rgba(251,191,36,0.6);
}
/* 상위 블록 (높은 사용률 = 빨강) */
.gc-sys-block--hi.gc-sys-block--on {
background: #f87171;
box-shadow: 0 0 4px rgba(248,113,113,0.6);
}
.gc-sys-card-foot {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
}
.gc-sys-card-pct {
font-size: 0.95rem;
font-weight: 800;
color: var(--se-text);
line-height: 1;
}
.gc-sys-card-detail {
font-size: 0.62rem;
color: var(--se-text-muted);
}
/* ── Indicator bars ── */