feat: SPRINT-018 dashboard truth redesign implementation
- /office: Replace 4-box mobile decomposition with group mental model (Planning/Delivery/Review/Deploy blocks). Fallback seed no longer injects "working" state. Show operational summary on first load. Add truthful state labels (connected idle/active working/snapshot only/fallback/offline). Fix 360/390 mobile overflow. - / dashboard: Separate socket connection from freshness badges. "EVENT STREAM OFF" is neutral, not error-like. Remove owner fallback to narang — show "NO ACTIVE OWNER" when null. Add consistent source labels (MIRRORED EVENT/SNAPSHOT/DOC-DERIVED) to all sections. - /projects/[id]: Replace "ASSIGNED NODES" with truthful participant model. Separate CURRENT OWNERS, PARTICIPANTS (with whyVisible badges), and TASK ASSIGNEES into distinct sections. Fix 나랑이-only display by showing reason/source so it doesn't read as confirmed participation. - /org: Complete redesign from hardcoded sister tree to company org chart. HQ → functional teams → members structure with status/source/ freshness. Independent agents section. Data-driven from /api/org, not hardcoded harang→children tree. Mobile-friendly card layout. - Backend sisters.service.ts: Add truth contract fields — connection (gateway/runtime), activity (state/label/lastActiveAt), working (state/confidence/reason/label), source, freshness. States: connected idle, active working, snapshot only, fallback, offline. Fallback seeds never inject "working" directly. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,137 +3,329 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { API_URL } from '@/lib/config';
|
||||
import SisterAvatar from '@/components/common/SisterAvatar';
|
||||
|
||||
// ─── Styled ───
|
||||
const MainViewport = styled.div`
|
||||
// ─── Types ───
|
||||
|
||||
interface OrgUnit {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: 'hq' | 'functional_team';
|
||||
lead: string | null;
|
||||
memberIds: string[];
|
||||
}
|
||||
|
||||
interface OrgMember {
|
||||
id: string;
|
||||
type: 'sister' | 'agent';
|
||||
role: string;
|
||||
status: { connection: string; working: string };
|
||||
source: string;
|
||||
freshness: { lastSnapshotAt: string | null };
|
||||
}
|
||||
|
||||
interface IndependentAgent {
|
||||
id: string;
|
||||
name: string;
|
||||
ownerSister: string | null;
|
||||
source: string;
|
||||
}
|
||||
|
||||
interface OrgApiData {
|
||||
owner?: { name: string; role: string };
|
||||
sisters?: Array<{
|
||||
name: string;
|
||||
role: string;
|
||||
status?: string;
|
||||
}>;
|
||||
orgUnits?: OrgUnit[];
|
||||
members?: OrgMember[];
|
||||
reportingLines?: Array<{ from: string; to: string; kind: string }>;
|
||||
independentAgents?: IndependentAgent[];
|
||||
}
|
||||
|
||||
// ─── Fallback org structure (data-driven, not hardcoded tree) ───
|
||||
|
||||
const FALLBACK_ORG_UNITS: OrgUnit[] = [
|
||||
{ id: 'hq', name: '하나랑 HQ', kind: 'hq', lead: 'harang', memberIds: ['harang'] },
|
||||
{ id: 'delivery', name: 'Delivery', kind: 'functional_team', lead: 'narang', memberIds: ['narang'] },
|
||||
{ id: 'review', name: 'QA / Review', kind: 'functional_team', lead: 'darang', memberIds: ['darang'] },
|
||||
{ id: 'infra', name: 'Infra / Deploy', kind: 'functional_team', lead: 'erang', memberIds: ['erang'] },
|
||||
];
|
||||
|
||||
const FALLBACK_MEMBERS: OrgMember[] = [
|
||||
{ id: 'harang', type: 'sister', role: 'Orchestrator', status: { connection: 'unknown', working: 'unknown' }, source: 'fallback', freshness: { lastSnapshotAt: null } },
|
||||
{ id: 'narang', type: 'sister', role: 'Generator', status: { connection: 'unknown', working: 'unknown' }, source: 'fallback', freshness: { lastSnapshotAt: null } },
|
||||
{ id: 'darang', type: 'sister', role: 'Evaluator', status: { connection: 'unknown', working: 'unknown' }, source: 'fallback', freshness: { lastSnapshotAt: null } },
|
||||
{ id: 'erang', type: 'sister', role: 'Infra Manager', status: { connection: 'unknown', working: 'unknown' }, source: 'fallback', freshness: { lastSnapshotAt: null } },
|
||||
];
|
||||
|
||||
const FALLBACK_INDEPENDENT_AGENTS: IndependentAgent[] = [
|
||||
{ id: 'security-auditor', name: 'security-auditor', ownerSister: 'darang', source: 'fallback' },
|
||||
{ id: 'monitoring', name: 'monitoring', ownerSister: 'erang', source: 'fallback' },
|
||||
];
|
||||
|
||||
const MEMBER_DISPLAY: Record<string, string> = {
|
||||
harang: '하랑이', narang: '나랑이', darang: '다랑이', erang: '이랑이',
|
||||
};
|
||||
|
||||
const LXC_ID: Record<string, number> = {
|
||||
harang: 104, narang: 105, darang: 106, erang: 107,
|
||||
};
|
||||
|
||||
// ─── Helpers ───
|
||||
|
||||
function statusLabel(status: OrgMember['status']): string {
|
||||
if (status.working === 'active') return 'active working';
|
||||
if (status.connection === 'connected') return 'connected idle';
|
||||
if (status.connection === 'unknown') return 'unknown';
|
||||
return 'offline';
|
||||
}
|
||||
|
||||
function statusTone(status: OrgMember['status']): 'active' | 'idle' | 'offline' | 'unknown' {
|
||||
if (status.working === 'active') return 'active';
|
||||
if (status.connection === 'connected') return 'idle';
|
||||
if (status.connection === 'unknown') return 'unknown';
|
||||
return 'offline';
|
||||
}
|
||||
|
||||
function formatFreshness(ts: string | null): string {
|
||||
if (!ts) return 'NO DATA';
|
||||
const diff = Date.now() - new Date(ts).getTime();
|
||||
if (!Number.isFinite(diff) || diff < 0) return 'UNKNOWN';
|
||||
const sec = Math.floor(diff / 1000);
|
||||
if (sec < 60) return `${sec}s AGO`;
|
||||
const min = Math.floor(sec / 60);
|
||||
if (min < 60) return `${min}m AGO`;
|
||||
return `${Math.floor(min / 60)}h AGO`;
|
||||
}
|
||||
|
||||
function buildOrgFromLegacy(data: OrgApiData): {
|
||||
orgUnits: OrgUnit[];
|
||||
members: OrgMember[];
|
||||
independentAgents: IndependentAgent[];
|
||||
} {
|
||||
if (data.orgUnits && data.members) {
|
||||
return {
|
||||
orgUnits: data.orgUnits,
|
||||
members: data.members,
|
||||
independentAgents: data.independentAgents ?? FALLBACK_INDEPENDENT_AGENTS,
|
||||
};
|
||||
}
|
||||
|
||||
// Transform legacy /api/org response into org chart model
|
||||
const sisters = data.sisters ?? [];
|
||||
if (sisters.length === 0) {
|
||||
return { orgUnits: FALLBACK_ORG_UNITS, members: FALLBACK_MEMBERS, independentAgents: FALLBACK_INDEPENDENT_AGENTS };
|
||||
}
|
||||
|
||||
const ROLE_TO_TEAM: Record<string, string> = {
|
||||
Orchestrator: 'hq',
|
||||
Generator: 'delivery',
|
||||
Evaluator: 'review',
|
||||
'Infra Manager': 'infra',
|
||||
};
|
||||
|
||||
const orgUnits: OrgUnit[] = [
|
||||
{ id: 'hq', name: '하나랑 HQ', kind: 'hq', lead: null, memberIds: [] },
|
||||
{ id: 'delivery', name: 'Delivery', kind: 'functional_team', lead: null, memberIds: [] },
|
||||
{ id: 'review', name: 'QA / Review', kind: 'functional_team', lead: null, memberIds: [] },
|
||||
{ id: 'infra', name: 'Infra / Deploy', kind: 'functional_team', lead: null, memberIds: [] },
|
||||
];
|
||||
|
||||
const members: OrgMember[] = sisters.map((s) => {
|
||||
const teamId = ROLE_TO_TEAM[s.role] ?? 'delivery';
|
||||
const unit = orgUnits.find((u) => u.id === teamId);
|
||||
if (unit) {
|
||||
unit.memberIds.push(s.name);
|
||||
if (!unit.lead) unit.lead = s.name;
|
||||
}
|
||||
return {
|
||||
id: s.name,
|
||||
type: 'sister' as const,
|
||||
role: s.role,
|
||||
status: {
|
||||
connection: s.status === 'online' || s.status === 'working' ? 'connected' : s.status === 'offline' ? 'disconnected' : 'unknown',
|
||||
working: s.status === 'working' ? 'active' : 'idle',
|
||||
},
|
||||
source: 'snapshot',
|
||||
freshness: { lastSnapshotAt: new Date().toISOString() },
|
||||
};
|
||||
});
|
||||
|
||||
return { orgUnits, members, independentAgents: FALLBACK_INDEPENDENT_AGENTS };
|
||||
}
|
||||
|
||||
// ─── Styled Components ───
|
||||
|
||||
const Shell = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xl);
|
||||
min-height: 80vh;
|
||||
`;
|
||||
|
||||
const HeaderInfo = styled.div`
|
||||
const OrgHeader = styled.div`
|
||||
display: flex;
|
||||
gap: var(--space-xl);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: var(--space-lg);
|
||||
flex-wrap: wrap;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding-bottom: var(--space-md);
|
||||
|
||||
span { color: var(--text-primary); }
|
||||
`;
|
||||
|
||||
const TreeContainer = styled.div`
|
||||
const OrgTitle = styled.h1`
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.01em;
|
||||
`;
|
||||
|
||||
const OrgMeta = styled.div`
|
||||
display: flex;
|
||||
gap: var(--space-md);
|
||||
flex-wrap: wrap;
|
||||
`;
|
||||
|
||||
const OrgBadge = styled.span<{ $tone: 'default' | 'active' | 'fallback' }>`
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
padding: 2px 6px;
|
||||
border: 1px solid ${({ $tone }) =>
|
||||
$tone === 'active' ? '#00BFA5' : $tone === 'fallback' ? '#777' : 'var(--border-color)'};
|
||||
color: ${({ $tone }) =>
|
||||
$tone === 'active' ? '#00BFA5' : $tone === 'fallback' ? '#777' : 'var(--text-secondary)'};
|
||||
`;
|
||||
|
||||
const SectionLabel = styled.h2`
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
const HQCard = styled.div`
|
||||
border: 1px solid var(--border-color);
|
||||
border-left-width: 3px;
|
||||
border-left-color: #f6b26b;
|
||||
padding: var(--space-lg);
|
||||
background: var(--bg-surface);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
overflow-x: auto;
|
||||
padding: var(--space-xl) 0;
|
||||
gap: var(--space-md);
|
||||
`;
|
||||
|
||||
const TeamGrid = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: var(--space-lg);
|
||||
|
||||
@media (max-width: 767px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
`;
|
||||
|
||||
const TeamCard = styled.div`
|
||||
border: 1px solid var(--border-color);
|
||||
padding: var(--space-lg);
|
||||
background: var(--bg-surface);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
`;
|
||||
|
||||
const TeamTitle = styled.div`
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
`;
|
||||
|
||||
const TeamLead = styled.div`
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
`;
|
||||
|
||||
const MemberChipRow = styled.div`
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
flex-wrap: wrap;
|
||||
`;
|
||||
|
||||
const MemberChip = styled.div<{ $tone: 'active' | 'idle' | 'offline' | 'unknown' }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
border: 1px solid ${({ $tone }) =>
|
||||
$tone === 'active' ? '#2979FF' : $tone === 'idle' ? '#00BFA5' : $tone === 'offline' ? '#555' : '#444'};
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
`;
|
||||
|
||||
const StatusDot = styled.span<{ $tone: 'active' | 'idle' | 'offline' | 'unknown' }>`
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: ${({ $tone }) =>
|
||||
$tone === 'active' ? '#2979FF' : $tone === 'idle' ? '#00BFA5' : $tone === 'offline' ? '#FF1744' : '#555'};
|
||||
`;
|
||||
|
||||
const MetaRow = styled.div`
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
flex-wrap: wrap;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
`;
|
||||
|
||||
const AgentSection = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
`;
|
||||
|
||||
const AgentCard = styled.div`
|
||||
border: 1px solid var(--border-color);
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
flex-wrap: wrap;
|
||||
|
||||
@media (max-width: 389px) {
|
||||
flex-direction: column;
|
||||
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;
|
||||
const AgentName = styled.div`
|
||||
font-size: 13px;
|
||||
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;
|
||||
const AgentMeta = styled.div`
|
||||
font-size: 11px;
|
||||
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 EmptyState = styled.div`
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
padding: var(--space-md) 0;
|
||||
`;
|
||||
|
||||
const PageFooter = styled.footer`
|
||||
@@ -142,139 +334,175 @@ const PageFooter = styled.footer`
|
||||
padding: var(--space-lg) 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-md);
|
||||
border-top: 1px solid var(--border-color);
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-secondary);
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
`;
|
||||
|
||||
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,
|
||||
};
|
||||
// ─── Component ───
|
||||
|
||||
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' });
|
||||
const [orgData, setOrgData] = useState<OrgApiData | null>(null);
|
||||
const [dataSource, setDataSource] = useState<'live' | 'snapshot' | 'fallback'>('fallback');
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${API_URL}/api/org`)
|
||||
.then((r) => r.json())
|
||||
.then(setOrgData)
|
||||
.catch(() => {});
|
||||
.then((data) => {
|
||||
setOrgData(data);
|
||||
setDataSource(data.orgUnits ? 'snapshot' : data.sisters?.length > 0 ? 'snapshot' : 'fallback');
|
||||
})
|
||||
.catch(() => {
|
||||
setDataSource('fallback');
|
||||
});
|
||||
}, []);
|
||||
|
||||
const sisters = orgData?.sisters ?? [];
|
||||
const { orgUnits, members, independentAgents } = orgData
|
||||
? buildOrgFromLegacy(orgData)
|
||||
: { orgUnits: FALLBACK_ORG_UNITS, members: FALLBACK_MEMBERS, independentAgents: FALLBACK_INDEPENDENT_AGENTS };
|
||||
|
||||
const memberMap = new Map(members.map((m) => [m.id, m]));
|
||||
const hqUnit = orgUnits.find((u) => u.kind === 'hq');
|
||||
const functionalTeams = orgUnits.filter((u) => u.kind === 'functional_team');
|
||||
|
||||
const totalMembers = members.length;
|
||||
const totalUnits = orgUnits.length;
|
||||
|
||||
return (
|
||||
<MainViewport>
|
||||
<HeaderInfo>
|
||||
<div>SYS_TYPE: <span>CORE_ROOT_01</span></div>
|
||||
<div>TIMESTAMP: <span style={{ color: 'var(--text-secondary)' }}>{ts}</span></div>
|
||||
</HeaderInfo>
|
||||
<Shell>
|
||||
<OrgHeader>
|
||||
<OrgTitle>조직도</OrgTitle>
|
||||
<OrgMeta>
|
||||
<OrgBadge $tone={dataSource === 'fallback' ? 'fallback' : 'default'}>
|
||||
source: {dataSource}
|
||||
</OrgBadge>
|
||||
<OrgBadge $tone="default">{totalUnits} units</OrgBadge>
|
||||
<OrgBadge $tone="default">{totalMembers} members</OrgBadge>
|
||||
<OrgBadge $tone="default">{independentAgents.length} agents</OrgBadge>
|
||||
</OrgMeta>
|
||||
</OrgHeader>
|
||||
|
||||
<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 />
|
||||
{/* HQ Section */}
|
||||
{hqUnit && (
|
||||
<>
|
||||
<SectionLabel>HQ</SectionLabel>
|
||||
<HQCard>
|
||||
<TeamTitle>{hqUnit.name}</TeamTitle>
|
||||
<TeamLead>
|
||||
{hqUnit.lead
|
||||
? `LEAD: ${MEMBER_DISPLAY[hqUnit.lead] ?? hqUnit.lead}`
|
||||
: 'LEAD 미정'}
|
||||
</TeamLead>
|
||||
<MemberChipRow>
|
||||
{hqUnit.memberIds.map((id) => {
|
||||
const member = memberMap.get(id);
|
||||
const tone = member ? statusTone(member.status) : 'unknown';
|
||||
return (
|
||||
<MemberChip key={id} $tone={tone}>
|
||||
<SisterAvatar name={id} size={20} />
|
||||
<StatusDot $tone={tone} />
|
||||
{MEMBER_DISPLAY[id] ?? id}
|
||||
</MemberChip>
|
||||
);
|
||||
})}
|
||||
</MemberChipRow>
|
||||
<MetaRow>
|
||||
{hqUnit.memberIds.map((id) => {
|
||||
const member = memberMap.get(id);
|
||||
return (
|
||||
<span key={id}>
|
||||
{MEMBER_DISPLAY[id] ?? id}: {member ? statusLabel(member.status) : 'unknown'}
|
||||
{LXC_ID[id] ? ` · LXC ${LXC_ID[id]}` : ''}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</MetaRow>
|
||||
</HQCard>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 자매 레벨 */}
|
||||
<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>
|
||||
))}
|
||||
{/* Functional Teams */}
|
||||
<SectionLabel>FUNCTIONAL TEAMS</SectionLabel>
|
||||
<TeamGrid>
|
||||
{functionalTeams.map((team) => (
|
||||
<TeamCard key={team.id}>
|
||||
<TeamTitle>{team.name}</TeamTitle>
|
||||
<TeamLead>
|
||||
{team.lead
|
||||
? `LEAD: ${MEMBER_DISPLAY[team.lead] ?? team.lead}`
|
||||
: 'LEAD 미정'}
|
||||
</TeamLead>
|
||||
<MemberChipRow>
|
||||
{team.memberIds.map((id) => {
|
||||
const member = memberMap.get(id);
|
||||
const tone = member ? statusTone(member.status) : 'unknown';
|
||||
return (
|
||||
<MemberChip key={id} $tone={tone}>
|
||||
<SisterAvatar name={id} size={20} />
|
||||
<StatusDot $tone={tone} />
|
||||
{MEMBER_DISPLAY[id] ?? id}
|
||||
</MemberChip>
|
||||
);
|
||||
})}
|
||||
</MemberChipRow>
|
||||
<MetaRow>
|
||||
{team.memberIds.map((id) => {
|
||||
const member = memberMap.get(id);
|
||||
return (
|
||||
<span key={id}>
|
||||
{member?.role ?? 'unknown'}
|
||||
{member ? ` · ${statusLabel(member.status)}` : ''}
|
||||
{member ? ` · ${member.source}` : ''}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
<span>freshness: {formatFreshness(
|
||||
team.memberIds
|
||||
.map((id) => memberMap.get(id)?.freshness.lastSnapshotAt)
|
||||
.filter(Boolean)
|
||||
.sort()
|
||||
.at(-1) ?? null
|
||||
)}</span>
|
||||
</MetaRow>
|
||||
</TeamCard>
|
||||
))}
|
||||
</TeamGrid>
|
||||
|
||||
{/* 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>
|
||||
{/* Independent Agents */}
|
||||
<SectionLabel>INDEPENDENT AGENTS</SectionLabel>
|
||||
<AgentSection>
|
||||
{independentAgents.length === 0 ? (
|
||||
<EmptyState>INDEPENDENT AGENTS 없음</EmptyState>
|
||||
) : (
|
||||
independentAgents.map((agent) => (
|
||||
<AgentCard key={agent.id}>
|
||||
<div>
|
||||
<AgentName>{agent.name}</AgentName>
|
||||
<AgentMeta>
|
||||
{agent.ownerSister
|
||||
? `owner: ${MEMBER_DISPLAY[agent.ownerSister] ?? agent.ownerSister}`
|
||||
: 'independent'}
|
||||
</AgentMeta>
|
||||
</div>
|
||||
<MetaRow>
|
||||
<span>source: {agent.source}</span>
|
||||
</MetaRow>
|
||||
</AgentCard>
|
||||
))
|
||||
)}
|
||||
</AgentSection>
|
||||
|
||||
<PageFooter>
|
||||
<div>TOTAL_NODES: {String(sisters.length + 1).padStart(2, '0')}</div>
|
||||
<div>STATUS: SYNC_COMPLETE</div>
|
||||
<div>UNITS: {String(totalUnits).padStart(2, '0')}</div>
|
||||
<div>MEMBERS: {String(totalMembers).padStart(2, '0')}</div>
|
||||
<div>AGENTS: {String(independentAgents.length).padStart(2, '0')}</div>
|
||||
<div>SOURCE: {dataSource.toUpperCase()}</div>
|
||||
</PageFooter>
|
||||
</MainViewport>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user