- 디자인 시스템: #151515 배경, 1px 보더 카드, CSS vars, mono 포인트 - GlobalStyle/theme.ts v2 (DESIGN-SYSTEM.md 기반) - 공통 컴포넌트 (ui/base.tsx): LabelMeta, BracketValue, Card, TechBar, Timeline 등 - Sidebar: [대시] 브라켓 네비 + 모바일 하단 탭바 (767px) - LayoutShell: SidebarContext 제거, 단순화 - 대시보드 (/): SYS 상태 카드 4개 + ONGOING PROJECTS + ACTIVITY FEED - 활동 로그 (/activities): 로그 테이블 + 필터 + 검색 + 페이지네이션 (신규) - 설정 (/settings): 4섹션 Toggle/Bracket Input 그리드 - 자매 노드 관리 (/sisters): 3열 수평 레이아웃 (INFO/GRAPH/SYNC) - 자매 상세 (/sisters/[name]): 간소화 + 터미널 스타일 - 조직도 (/org): 터미널 카드 트리 구조 + 레벨 색상 - 프로젝트 목록 (/projects): 새 라우트 - 프로젝트 상세 (/projects/[id]): Phase Timeline + Audit Log + Checklist - FE 14 routes build 성공
265 lines
7.6 KiB
TypeScript
265 lines
7.6 KiB
TypeScript
'use client';
|
|
|
|
import React, { useEffect, useState } from 'react';
|
|
import styled from 'styled-components';
|
|
import Link from 'next/link';
|
|
import { PageTitle, LabelMeta, BtnToggle } from '@/components/ui/base';
|
|
import { API_URL } from '@/lib/config';
|
|
|
|
const SISTER_ROLES: Record<string, string> = {
|
|
harang: 'Primary',
|
|
narang: 'Secondary',
|
|
darang: 'Standby',
|
|
erang: 'Sync',
|
|
};
|
|
|
|
const GRAPH_BARS: Record<string, number[]> = {
|
|
harang: [40, 45, 60, 55, 70, 85, 92, 84],
|
|
narang: [30, 32, 35, 40, 38, 45, 50, 48],
|
|
darang: [10, 10, 5, 5, 5, 5, 2, 2],
|
|
erang: [20, 25, 15, 30, 40, 35, 20, 15],
|
|
};
|
|
|
|
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 0.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 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 0.15s;
|
|
|
|
&:hover { color: var(--accent-hover); }
|
|
`;
|
|
|
|
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,0.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 GraphContainer = styled.div`
|
|
display: flex;
|
|
align-items: flex-end;
|
|
gap: 3px;
|
|
height: 48px;
|
|
`;
|
|
|
|
const GraphBar = styled.div<{ $height: number; $highlight?: boolean }>`
|
|
flex: 1;
|
|
height: ${({ $height }) => $height}%;
|
|
min-height: 2px;
|
|
background: ${({ $highlight }) => $highlight ? 'var(--text-primary)' : 'var(--border-hover)'};
|
|
transition: height 0.3s;
|
|
`;
|
|
|
|
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 SyncHistory = styled.div`
|
|
padding: var(--space-lg);
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--space-sm);
|
|
|
|
@media (max-width: 1199px) {
|
|
display: none;
|
|
}
|
|
`;
|
|
|
|
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'];
|
|
if (s.status === 'working') return ['ACTIVE', 'REBOOT'];
|
|
return ['ACTIVE', 'REBOOT'];
|
|
}
|
|
|
|
function formatLastSeen(lastSeen: string | null): string {
|
|
if (!lastSeen) return '—';
|
|
const diff = Date.now() - new Date(lastSeen).getTime();
|
|
const h = Math.floor(diff / 3600000);
|
|
const m = Math.floor((diff % 3600000) / 60000);
|
|
const s = Math.floor((diff % 60000) / 1000);
|
|
return `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')} ago`;
|
|
}
|
|
|
|
const MOCK_SISTERS = [
|
|
{ id: 1, name: 'harang', status: 'online', lastSeen: new Date(Date.now() - 3600000).toISOString() },
|
|
{ id: 2, name: 'narang', status: 'working', lastSeen: new Date(Date.now() - 1800000).toISOString() },
|
|
{ id: 3, name: 'darang', status: 'offline', lastSeen: new Date(Date.now() - 7200000).toISOString() },
|
|
{ id: 4, name: 'erang', status: 'online', lastSeen: new Date(Date.now() - 900000).toISOString() },
|
|
];
|
|
|
|
export default function SistersPage() {
|
|
const [sisters, setSisters] = useState<any[]>(MOCK_SISTERS);
|
|
|
|
useEffect(() => {
|
|
fetch(`${API_URL}/api/sisters`)
|
|
.then((r) => r.json())
|
|
.then(setSisters)
|
|
.catch(() => {});
|
|
const iv = setInterval(() => {
|
|
fetch(`${API_URL}/api/sisters`).then((r) => r.json()).then(setSisters).catch(() => {});
|
|
}, 15000);
|
|
return () => 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>
|
|
|
|
<NodeGrid>
|
|
{sisters.map((s) => {
|
|
const isActive = getNodeStatus(s);
|
|
const [btn1, btn2] = getStatusBtns(s);
|
|
const bars = GRAPH_BARS[s.name] ?? [5, 5, 5, 5, 5, 5, 5, 5];
|
|
const loadIndex = isActive
|
|
? `${(bars[bars.length - 1]).toFixed(1)}%`
|
|
: '0.0%';
|
|
const metaLabel = s.status === 'offline' ? 'Last Active' : s.name === 'erang' ? 'Queue Depth' : 'Uptime';
|
|
const metaVal = s.status === 'offline'
|
|
? formatLastSeen(s.lastSeen)
|
|
: s.name === 'erang' ? '12 PKTS'
|
|
: formatLastSeen(s.lastSeen).replace(' ago', '');
|
|
|
|
return (
|
|
<NodeEntry key={s.id ?? s.name}>
|
|
<NodeInfo>
|
|
<NodeName href={`/sisters/${s.name}`}>
|
|
<StatusIndicator $active={isActive} />
|
|
{s.name === 'harang' ? '하랑' : s.name === 'narang' ? '나랑' : s.name === 'darang' ? '다랑' : '이랑'}{' '}
|
|
({SISTER_ROLES[s.name] ?? s.name})
|
|
</NodeName>
|
|
<MetaPair>
|
|
<LabelMeta>{metaLabel}</LabelMeta>
|
|
<MetaVal>{metaVal}</MetaVal>
|
|
</MetaPair>
|
|
<NodeControls>
|
|
<BtnToggle $active>{btn1}</BtnToggle>
|
|
<BtnToggle>{btn2}</BtnToggle>
|
|
</NodeControls>
|
|
</NodeInfo>
|
|
|
|
<CapacitySection>
|
|
<LabelMeta>CAPACITY HISTORY (24H)</LabelMeta>
|
|
<GraphContainer>
|
|
{bars.map((h, i) => (
|
|
<GraphBar key={i} $height={h} $highlight={i >= bars.length - 2} />
|
|
))}
|
|
</GraphContainer>
|
|
<SyncRow>
|
|
<span>LOAD INDEX</span>
|
|
<span>{loadIndex}</span>
|
|
</SyncRow>
|
|
</CapacitySection>
|
|
|
|
<SyncHistory>
|
|
<LabelMeta>SYNC LOG</LabelMeta>
|
|
{[
|
|
{ time: '14:05:22', status: isActive ? 'SUCCESS' : 'IDLE' },
|
|
{ time: '13:55:01', status: isActive ? 'SUCCESS' : 'IDLE' },
|
|
{ time: '13:44:10', status: isActive ? 'SUCCESS' : 'IDLE' },
|
|
].map((row) => (
|
|
<SyncRow key={row.time}>
|
|
<span>{row.time}</span>
|
|
<span style={{ color: row.status === 'SUCCESS' ? '#00FF00' : 'var(--text-secondary)' }}>
|
|
{row.status}
|
|
</span>
|
|
</SyncRow>
|
|
))}
|
|
</SyncHistory>
|
|
</NodeEntry>
|
|
);
|
|
})}
|
|
</NodeGrid>
|
|
</>
|
|
);
|
|
}
|