fix(hotfix-004): DISK bar + 카드 전체 클릭 + 운영 메타 교체

TASK-049: DISK 사용률 퍼센트 계산 추가, NaN/0 fallback 처리
TASK-050: 자매 카드 전체를 Link로 전환
TASK-051: 우측 영역을 리소스 중복 텍스트에서 운영 메타(STATUS/ROLE/LXC/LAST CHECK/CURRENT TASK)로 교체

테스트 23/23 pass, FE 16 routes build 성공
This commit is contained in:
2026-04-04 20:16:35 +09:00
parent 59bf0f38de
commit 2cacdcf54e

View File

@@ -10,9 +10,9 @@ import SisterAvatar from '@/components/common/SisterAvatar';
const pulse = keyframes`0%{opacity:.35}50%{opacity:.7}100%{opacity:.35}`;
const SISTER_ROLES: Record<string, string> = { harang: 'Primary', narang: 'Secondary', darang: 'Standby', erang: 'Sync' };
const NodeGrid = styled.section`display:flex;flex-direction:column;gap:var(--space-lg);`;
const NodeEntry = styled.div`display:grid;grid-template-columns:220px 1fr 160px;gap:0;border:1px solid var(--border-color);transition:border-color .2s;&:hover{border-color:var(--border-hover)}@media (max-width:1199px){grid-template-columns:200px 1fr}@media (max-width:767px){grid-template-columns:1fr}`;
const NodeEntry = styled(Link)`display:grid;grid-template-columns:220px 1fr 180px;gap:0;border:1px solid var(--border-color);transition:border-color .2s, background .15s;text-decoration:none;&:hover{border-color:var(--border-hover);background:#111}@media (max-width:1199px){grid-template-columns:200px 1fr}@media (max-width:767px){grid-template-columns:1fr}`;
const NodeInfo = styled.div`border-right:1px solid var(--border-color);padding:var(--space-lg);display:flex;flex-direction:column;gap:var(--space-md);@media (max-width:767px){border-right:none;border-bottom:1px solid var(--border-color)}`;
const NodeName = styled(Link)`display:flex;align-items:center;gap:var(--space-sm);font-size:16px;font-weight:600;color:var(--text-primary);text-decoration:none;transition:color .15s;&:hover{color:var(--accent-hover)}`;
const NodeName = styled.div`display:flex;align-items:center;gap:var(--space-sm);font-size:16px;font-weight:600;color:var(--text-primary);`;
const StatusIndicator = styled.div<{ $active: boolean }>`width:8px;height:8px;border-radius:50%;background:${({$active})=>$active?'#FFF':'#555'};flex-shrink:0;${({$active})=>$active&&`box-shadow:0 0 8px rgba(255,255,255,.3);`}`;
const MetaPair = styled.div`display:flex;flex-direction:column;gap:2px;`;
const MetaVal = styled.div`font-size:13px;color:var(--text-primary);font-family:var(--font-mono);`;
@@ -20,15 +20,30 @@ const NodeControls = styled.div`display:flex;gap:var(--space-sm);flex-wrap:wrap;
const CapacitySection = styled.div`border-right:1px solid var(--border-color);padding:var(--space-lg);display:flex;flex-direction:column;gap:var(--space-md);@media (max-width:1199px){border-right:none}@media (max-width:767px){border-bottom:1px solid var(--border-color)}`;
const BarWrap = styled.div`display:flex;flex-direction:column;gap:8px;`;
const BarRow = styled.div`display:flex;align-items:center;gap:8px;font-family:var(--font-mono);font-size:11px;color:var(--text-secondary);`;
const Bar = styled.div`flex:1;height:8px;background:#151515;border:1px solid #222;`;
const Fill = styled.div<{ $width:number }>`height:100%;width:${({$width})=>$width}%;background:var(--text-primary);`;
const SyncHistory = styled.div`padding:var(--space-lg);display:flex;flex-direction:column;gap:var(--space-sm);@media (max-width:1199px){display:none}`;
const SyncRow = styled.div`display:flex;justify-content:space-between;font-family:var(--font-mono);font-size:11px;color:var(--text-secondary);padding:2px 0;`;
const Bar = styled.div`flex:1;height:8px;background:#151515;border:1px solid #222;overflow:hidden;`;
const Fill = styled.div<{ $width:number }>`height:100%;width:${({$width})=>$width}%;background:var(--text-primary);min-width:${({$width})=>$width > 0 ? '2px' : '0'};`;
const OpsMeta = styled.div`padding:var(--space-lg);display:flex;flex-direction:column;gap:var(--space-sm);@media (max-width:1199px){display:none}`;
const MetaRow = styled.div`display:flex;justify-content:space-between;font-family:var(--font-mono);font-size:11px;color:var(--text-secondary);padding:2px 0;gap:var(--space-md);`;
const Skeleton = styled.div`height:120px;border:1px solid var(--border-color);background:#111;animation:${pulse} 1.4s ease-in-out infinite;`;
const ErrorBox = styled.div`padding:var(--space-lg);border:1px solid #5a2a2a;color:#ff9b9b;`;
function getNodeStatus(s: any): boolean { return s.status === 'online' || s.status === 'working'; }
function getStatusBtns(s: any): [string, string] { if (s.status === 'offline') return ['STANDBY', 'ACTIVATE']; return ['ACTIVE', 'REBOOT']; }
function diskPercent(disk: any): number {
if (!disk?.used || !disk?.total) return 0;
const parse = (v: string) => {
const m = String(v).match(/([0-9.]+)\s*([KMGTP]?)/i);
if (!m) return 0;
const n = parseFloat(m[1]);
const unit = (m[2] || 'G').toUpperCase();
const scale: Record<string, number> = { K: 1/1024/1024, M: 1/1024, G: 1, T: 1024, P: 1024*1024, '': 1 };
return n * (scale[unit] ?? 1);
};
const used = parse(disk.used);
const total = parse(disk.total);
if (!isFinite(used) || !isFinite(total) || total <= 0) return 0;
return Math.max(0, Math.min(100, (used / total) * 100));
}
export default function SistersPage() {
const [sisters, setSisters] = useState<any[]>([]);
@@ -72,9 +87,9 @@ export default function SistersPage() {
const isActive = getNodeStatus(s);
const [btn1, btn2] = getStatusBtns(s);
return (
<NodeEntry key={s.id ?? s.name}>
<NodeEntry key={s.id ?? s.name} href={`/sisters/${s.name}`}>
<NodeInfo>
<NodeName href={`/sisters/${s.name}`}>
<NodeName>
<StatusIndicator $active={isActive} />
<SisterAvatar name={s.name} size={28} />
{s.name === 'harang' ? '하랑' : s.name === 'narang' ? '나랑' : s.name === 'darang' ? '다랑' : '이랑'} ({SISTER_ROLES[s.name] ?? s.name})
@@ -86,16 +101,17 @@ export default function SistersPage() {
<CapacitySection>
<BarWrap>
<BarRow><span>CPU</span><span>{s.cpu?.toFixed?.(1) ?? '0.0'}%</span><Bar><Fill $width={Math.max(0, Math.min(100, Number(s.cpu ?? 0)))} /></Bar></BarRow>
<BarRow><span>MEM</span><span>{s.memory?.used ?? 0}/{s.memory?.total ?? 0}MB</span><Bar><Fill $width={s.memory?.total ? (s.memory.used / s.memory.total) * 100 : 0} /></Bar></BarRow>
<BarRow><span>DSK</span><span>{s.disk?.used ?? '0G'}/{s.disk?.total ?? '0G'}</span><Bar><Fill $width={0} /></Bar></BarRow>
<BarRow><span>MEM</span><span>{s.memory?.used ?? 0}/{s.memory?.total ?? 0}MB</span><Bar><Fill $width={s.memory?.total ? Math.max(0, Math.min(100, (s.memory.used / s.memory.total) * 100)) : 0} /></Bar></BarRow>
<BarRow><span>DSK</span><span>{s.disk?.used ?? '0G'}/{s.disk?.total ?? '0G'}</span><Bar><Fill $width={diskPercent(s.disk)} /></Bar></BarRow>
</BarWrap>
</CapacitySection>
<SyncHistory>
<SyncRow><span>STATUS</span><span>{String(s.status).toUpperCase()}</span></SyncRow>
<SyncRow><span>CPU</span><span>{s.cpu?.toFixed?.(1) ?? '0.0'}%</span></SyncRow>
<SyncRow><span>MEMORY</span><span>{s.memory?.used ?? 0}/{s.memory?.total ?? 0}MB</span></SyncRow>
<SyncRow><span>DISK</span><span>{s.disk?.used ?? '0G'} / {s.disk?.total ?? '0G'}</span></SyncRow>
</SyncHistory>
<OpsMeta>
<MetaRow><span>STATUS</span><span>{String(s.status).toUpperCase()}</span></MetaRow>
<MetaRow><span>ROLE</span><span>{String(SISTER_ROLES[s.name] ?? s.name).toUpperCase()}</span></MetaRow>
<MetaRow><span>LXC ID</span><span>{s.lxcId ?? '--'}</span></MetaRow>
<MetaRow><span>LAST CHECK</span><span>{s.lastSeen ? 'just synced' : '--'}</span></MetaRow>
<MetaRow><span>CURRENT TASK</span><span>{s.currentTask ?? '-'}</span></MetaRow>
</OpsMeta>
</NodeEntry>
);
})}