Files
hanarang-dashboard/frontend/app/sisters/page.tsx
나랑이 2cacdcf54e 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 성공
2026-04-04 20:16:35 +09:00

122 lines
7.7 KiB
TypeScript

'use client';
import React, { useEffect, useState } from 'react';
import styled, { keyframes } from 'styled-components';
import Link from 'next/link';
import { PageTitle, LabelMeta, BtnToggle } from '@/components/ui/base';
import { API_URL } from '@/lib/config';
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(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.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);`;
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;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[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let active = true;
const load = async () => {
try {
const base = await fetch(`${API_URL}/api/sisters`).then((r) => r.ok ? r.json() : Promise.reject());
const withSystem = await Promise.all(base.map(async (s: any) => {
try {
const system = await fetch(`${API_URL}/api/sisters/${s.name}/system`).then((r) => r.ok ? r.json() : null);
return { ...s, ...(system ?? {}) };
} catch {
return { ...s, uptime: '00:00:00', cpu: 0, memory: { used: 0, total: 0 }, disk: { used: '0G', total: '0G' } };
}
}));
if (active) { setSisters(withSystem); setError(null); }
} catch {
if (active) setError('데이터를 불러올 수 없습니다');
} finally {
if (active) setLoading(false);
}
};
load();
const iv = setInterval(load, 15000);
return () => { active = false; clearInterval(iv); };
}, []);
return (
<>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 'var(--space-lg)' }}>
<PageTitle> </PageTitle>
<LabelMeta><span>TOTAL NODES:</span> {String(sisters.length).padStart(2, '0')}</LabelMeta>
</div>
{error && <ErrorBox>{error}</ErrorBox>}
<NodeGrid>
{loading ? Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} />) : sisters.map((s) => {
const isActive = getNodeStatus(s);
const [btn1, btn2] = getStatusBtns(s);
return (
<NodeEntry key={s.id ?? s.name} href={`/sisters/${s.name}`}>
<NodeInfo>
<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})
</NodeName>
<MetaPair><LabelMeta>Uptime</LabelMeta><MetaVal>{s.uptime ?? '00:00:00'}</MetaVal></MetaPair>
<MetaPair><LabelMeta>Last Seen</LabelMeta><MetaVal>{s.lastSeen ? new Date(s.lastSeen).toLocaleString('ko-KR') : '—'}</MetaVal></MetaPair>
<NodeControls><BtnToggle>{btn1}</BtnToggle><BtnToggle>{btn2}</BtnToggle></NodeControls>
</NodeInfo>
<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 ? 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>
<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>
);
})}
</NodeGrid>
</>
);
}