- 디자인 시스템: #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 성공
281 lines
7.5 KiB
TypeScript
281 lines
7.5 KiB
TypeScript
'use client';
|
|
|
|
import React, { useEffect, useState } from 'react';
|
|
import styled from 'styled-components';
|
|
import { API_URL } from '@/lib/config';
|
|
|
|
// ─── Styled ───
|
|
const MainViewport = styled.div`
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--space-xl);
|
|
min-height: 80vh;
|
|
`;
|
|
|
|
const HeaderInfo = styled.div`
|
|
display: flex;
|
|
gap: var(--space-xl);
|
|
font-family: var(--font-mono);
|
|
font-size: 11px;
|
|
color: var(--text-secondary);
|
|
border-bottom: 1px solid var(--border-color);
|
|
padding-bottom: var(--space-md);
|
|
|
|
span { color: var(--text-primary); }
|
|
`;
|
|
|
|
const TreeContainer = styled.div`
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
overflow-x: auto;
|
|
padding: var(--space-xl) 0;
|
|
|
|
@media (max-width: 767px) {
|
|
align-items: flex-start;
|
|
}
|
|
`;
|
|
|
|
const NodeGroup = styled.div`
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
position: relative;
|
|
`;
|
|
|
|
const TerminalCard = styled.div<{ $level: 'high' | 'mid' | 'low' | 'none' }>`
|
|
border: 1px solid ${({ $level }) => ({
|
|
high: '#f43f5e',
|
|
mid: '#3b82f6',
|
|
low: '#10b981',
|
|
none: '#525252',
|
|
})[$level]};
|
|
border-left-width: 3px;
|
|
padding: var(--space-md) var(--space-lg);
|
|
background: var(--bg-main);
|
|
min-width: 200px;
|
|
max-width: 260px;
|
|
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
|
|
transition: border-color 0.2s, box-shadow 0.2s;
|
|
position: relative;
|
|
cursor: default;
|
|
|
|
&:hover {
|
|
box-shadow: 0 12px 36px rgba(0,0,0,0.7);
|
|
}
|
|
`;
|
|
|
|
const NodeLabel = styled.div`
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
color: var(--text-primary);
|
|
margin-bottom: var(--space-sm);
|
|
|
|
span {
|
|
color: var(--text-secondary);
|
|
margin-right: var(--space-xs);
|
|
font-family: var(--font-mono);
|
|
font-size: 11px;
|
|
}
|
|
`;
|
|
|
|
const BadgeRow = styled.div`
|
|
display: flex;
|
|
gap: var(--space-xs);
|
|
flex-wrap: wrap;
|
|
`;
|
|
|
|
const Badge = styled.div`
|
|
background: #1a1a1a;
|
|
border: 1px solid var(--border-color);
|
|
padding: 2px 6px;
|
|
font-size: 9px;
|
|
font-family: var(--font-mono);
|
|
font-weight: 700;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.04em;
|
|
color: var(--text-secondary);
|
|
|
|
b { color: var(--text-primary); font-weight: normal; }
|
|
`;
|
|
|
|
const TreeConnector = styled.div`
|
|
width: 1px;
|
|
height: 24px;
|
|
background: var(--border-color);
|
|
margin: 0 auto;
|
|
`;
|
|
|
|
const TreeBranch = styled.div`
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
`;
|
|
|
|
const TreeChildren = styled.div`
|
|
display: flex;
|
|
gap: var(--space-xl);
|
|
align-items: flex-start;
|
|
position: relative;
|
|
|
|
&::before {
|
|
content: '';
|
|
position: absolute;
|
|
top: 0;
|
|
left: calc(var(--space-xl) / 2 + 100px);
|
|
right: calc(var(--space-xl) / 2 + 100px);
|
|
height: 1px;
|
|
background: var(--border-color);
|
|
}
|
|
|
|
@media (max-width: 767px) {
|
|
flex-direction: column;
|
|
gap: var(--space-lg);
|
|
|
|
&::before { display: none; }
|
|
}
|
|
`;
|
|
|
|
const PageFooter = styled.footer`
|
|
margin-top: auto;
|
|
width: 100%;
|
|
padding: var(--space-lg) 0;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
border-top: 1px solid var(--border-color);
|
|
font-family: var(--font-mono);
|
|
color: var(--text-secondary);
|
|
font-size: 10px;
|
|
`;
|
|
|
|
type Level = 'high' | 'mid' | 'low' | 'none';
|
|
|
|
interface OrgData {
|
|
owner: { name: string; role: string };
|
|
sisters: Array<{
|
|
name: string;
|
|
role: string;
|
|
description?: string;
|
|
status?: string;
|
|
lxcId?: number;
|
|
}>;
|
|
pipeline: Array<{ from: string; to: string; label: string }>;
|
|
}
|
|
|
|
const LEVEL_MAP: Record<string, Level> = {
|
|
Orchestrator: 'high',
|
|
Generator: 'mid',
|
|
Evaluator: 'mid',
|
|
'Infra Manager': 'low',
|
|
};
|
|
|
|
const ROLE_TAG: Record<string, string> = {
|
|
Orchestrator: 'ORCH',
|
|
Generator: 'GEN',
|
|
Evaluator: 'EVAL',
|
|
'Infra Manager': 'INFRA',
|
|
};
|
|
|
|
const LXC_ID: Record<string, number> = {
|
|
harang: 104,
|
|
narang: 105,
|
|
darang: 106,
|
|
erang: 107,
|
|
};
|
|
|
|
export default function OrgPage() {
|
|
const [orgData, setOrgData] = useState<OrgData | null>(null);
|
|
const ts = new Date().toLocaleDateString('ko-KR').replace(/\. /g, '.').replace('.', '') + '_' +
|
|
new Date().toLocaleTimeString('ko-KR', { hour12: false, hour: '2-digit', minute: '2-digit' });
|
|
|
|
useEffect(() => {
|
|
fetch(`${API_URL}/api/org`)
|
|
.then((r) => r.json())
|
|
.then(setOrgData)
|
|
.catch(() => {});
|
|
}, []);
|
|
|
|
const sisters = orgData?.sisters ?? [];
|
|
|
|
return (
|
|
<MainViewport>
|
|
<HeaderInfo>
|
|
<div>SYS_TYPE: <span>CORE_ROOT_01</span></div>
|
|
<div>TIMESTAMP: <span style={{ color: 'var(--text-secondary)' }}>{ts}</span></div>
|
|
</HeaderInfo>
|
|
|
|
<TreeContainer>
|
|
<NodeGroup>
|
|
{/* HQ */}
|
|
<TerminalCard $level="high">
|
|
<NodeLabel><span>[HQ]</span> 하나랑 글로벌</NodeLabel>
|
|
<BadgeRow>
|
|
<Badge>MEM: <b>{sisters.length + 1}</b></Badge>
|
|
<Badge>S_ADMIN</Badge>
|
|
<Badge>LV_09</Badge>
|
|
</BadgeRow>
|
|
</TerminalCard>
|
|
<TreeConnector />
|
|
|
|
{/* 자매 레벨 */}
|
|
<TreeBranch>
|
|
<TreeChildren>
|
|
{/* 하랑이 (Orchestrator) */}
|
|
{sisters.filter((s) => s.role === 'Orchestrator').map((s) => (
|
|
<NodeGroup key={s.name}>
|
|
<TerminalCard $level={LEVEL_MAP[s.role] ?? 'none'}>
|
|
<NodeLabel>
|
|
<span>[{ROLE_TAG[s.role] ?? s.role}]</span>
|
|
{s.name === 'harang' ? '하랑이' : s.name}
|
|
</NodeLabel>
|
|
<BadgeRow>
|
|
<Badge>LXC: <b>{LXC_ID[s.name] ?? '---'}</b></Badge>
|
|
<Badge>{ROLE_TAG[s.role] ?? s.role}</Badge>
|
|
<Badge>{s.status === 'online' ? 'ACTIVE' : s.status === 'offline' ? 'STBY' : 'RUN'}</Badge>
|
|
</BadgeRow>
|
|
</TerminalCard>
|
|
<TreeConnector />
|
|
<TreeBranch>
|
|
<TreeChildren>
|
|
{/* 나랑/다랑/이랑 */}
|
|
{sisters.filter((s2) => s2.role !== 'Orchestrator').map((s2) => (
|
|
<NodeGroup key={s2.name}>
|
|
<TerminalCard $level={LEVEL_MAP[s2.role] ?? 'none'}>
|
|
<NodeLabel>
|
|
{s2.name === 'narang' ? '나랑이' : s2.name === 'darang' ? '다랑이' : '이랑이'}
|
|
</NodeLabel>
|
|
<BadgeRow>
|
|
<Badge>LXC: <b>{LXC_ID[s2.name] ?? '---'}</b></Badge>
|
|
<Badge>{ROLE_TAG[s2.role] ?? s2.role}</Badge>
|
|
<Badge>{s2.status === 'online' ? 'ON' : s2.status === 'offline' ? '--' : 'RUN'}</Badge>
|
|
</BadgeRow>
|
|
</TerminalCard>
|
|
</NodeGroup>
|
|
))}
|
|
</TreeChildren>
|
|
</TreeBranch>
|
|
</NodeGroup>
|
|
))}
|
|
|
|
{/* Orchestrator 없을 때 fallback */}
|
|
{sisters.filter((s) => s.role === 'Orchestrator').length === 0 && (
|
|
<NodeGroup>
|
|
<TerminalCard $level="none">
|
|
<NodeLabel>데이터 없음</NodeLabel>
|
|
<BadgeRow><Badge>N/A</Badge></BadgeRow>
|
|
</TerminalCard>
|
|
</NodeGroup>
|
|
)}
|
|
</TreeChildren>
|
|
</TreeBranch>
|
|
</NodeGroup>
|
|
</TreeContainer>
|
|
|
|
<PageFooter>
|
|
<div>TOTAL_NODES: {String(sisters.length + 1).padStart(2, '0')}</div>
|
|
<div>STATUS: SYNC_COMPLETE</div>
|
|
</PageFooter>
|
|
</MainViewport>
|
|
);
|
|
}
|