Files
hanarang-dashboard/frontend/app/sisters/page.tsx
narang-ai 230d3847ef feat(redesign-v2): 미니멀 터미널 UI 전체 리디자인
- 디자인 시스템: #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 성공
2026-04-04 13:10:57 +09:00

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>
</>
);
}