136 lines
8.1 KiB
TypeScript
136 lines
8.1 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' };
|
|
|
|
interface SisterListItem {
|
|
id?: number;
|
|
name: string;
|
|
status: string;
|
|
uptime?: string;
|
|
lastSeen?: string | null;
|
|
cpu?: number;
|
|
lxcId?: string | number | null;
|
|
currentTask?: string | null;
|
|
memory?: { used?: number; total?: number };
|
|
disk?: { used?: string; total?: string };
|
|
}
|
|
|
|
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: SisterListItem): boolean { return s.status === 'online' || s.status === 'working'; }
|
|
function getStatusBtns(s: SisterListItem): [string, string] { if (s.status === 'offline') return ['STANDBY', 'ACTIVATE']; return ['ACTIVE', 'REBOOT']; }
|
|
function diskPercent(disk?: SisterListItem['disk']): 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<SisterListItem[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
let active = true;
|
|
const load = async () => {
|
|
try {
|
|
const base: SisterListItem[] = await fetch(`${API_URL}/api/sisters`).then((r) => r.ok ? r.json() : Promise.reject());
|
|
const withSystem = await Promise.all(base.map(async (s: SisterListItem) => {
|
|
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 ?? 0) / 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>
|
|
</>
|
|
);
|
|
}
|