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:
@@ -43,6 +43,30 @@ export interface SisterRuntimeSnapshot {
|
||||
subagents: RuntimeSubagent[];
|
||||
}
|
||||
|
||||
export interface SisterConnectionState {
|
||||
gateway: 'connected' | 'disconnected' | 'unknown';
|
||||
runtime: 'connected' | 'disconnected' | 'unknown';
|
||||
}
|
||||
|
||||
export interface SisterActivityState {
|
||||
state: 'active' | 'idle' | 'unknown';
|
||||
label: string | null;
|
||||
lastActiveAt: string | null;
|
||||
}
|
||||
|
||||
export interface SisterWorkingState {
|
||||
state: 'active' | 'idle' | 'unknown';
|
||||
confidence: 'confirmed' | 'inferred' | 'none';
|
||||
reason: string | null;
|
||||
label: string | null;
|
||||
}
|
||||
|
||||
export interface SisterFreshness {
|
||||
lastSnapshotAt: string | null;
|
||||
}
|
||||
|
||||
export type SisterSource = 'live' | 'snapshot' | 'fallback';
|
||||
|
||||
export interface SisterStatus {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -56,6 +80,11 @@ export interface SisterStatus {
|
||||
activeSessionLabel: string | null;
|
||||
gatewayConnected: boolean;
|
||||
subagents: RuntimeSubagent[];
|
||||
connection: SisterConnectionState;
|
||||
activity: SisterActivityState;
|
||||
working: SisterWorkingState;
|
||||
source: SisterSource;
|
||||
freshness: SisterFreshness;
|
||||
}
|
||||
|
||||
interface RuntimeProbeResult {
|
||||
@@ -299,6 +328,8 @@ export class SistersService {
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
const isActive = liveState === 'thinking' || liveState === 'tool_calling' || liveState === 'speaking';
|
||||
|
||||
return {
|
||||
id: sister.id,
|
||||
name: sister.name,
|
||||
@@ -312,6 +343,25 @@ export class SistersService {
|
||||
activeSessionLabel: runtime.activeSessionLabel ?? null,
|
||||
gatewayConnected,
|
||||
subagents: runtime.subagents ?? [],
|
||||
connection: {
|
||||
gateway: gatewayConnected ? 'connected' : 'disconnected',
|
||||
runtime: gatewayConnected ? 'connected' : 'disconnected',
|
||||
},
|
||||
activity: {
|
||||
state: isActive ? 'active' : 'idle',
|
||||
label: runtime.activeSessionLabel ?? (isActive ? 'Active session' : 'No recent operator event'),
|
||||
lastActiveAt: now.toISOString(),
|
||||
},
|
||||
working: {
|
||||
state: isActive ? 'active' : 'idle',
|
||||
confidence: isActive ? 'confirmed' : 'none',
|
||||
reason: isActive ? 'active_session' : null,
|
||||
label: runtime.currentTask ?? null,
|
||||
},
|
||||
source: 'live' as SisterSource,
|
||||
freshness: {
|
||||
lastSnapshotAt: now.toISOString(),
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
this.logger.warn(`Failed to check status for ${sister.name}`);
|
||||
@@ -381,6 +431,25 @@ export class SistersService {
|
||||
activeSessionLabel: null,
|
||||
gatewayConnected: false,
|
||||
subagents: [],
|
||||
connection: {
|
||||
gateway: 'disconnected',
|
||||
runtime: 'disconnected',
|
||||
},
|
||||
activity: {
|
||||
state: 'unknown',
|
||||
label: null,
|
||||
lastActiveAt: sister.lastSeen?.toISOString() ?? null,
|
||||
},
|
||||
working: {
|
||||
state: 'unknown',
|
||||
confidence: 'none',
|
||||
reason: null,
|
||||
label: null,
|
||||
},
|
||||
source: 'snapshot' as SisterSource,
|
||||
freshness: {
|
||||
lastSnapshotAt: sister.lastSeen?.toISOString() ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -128,13 +128,28 @@ const SISTER_ROLES: Record<SisterName, string> = {
|
||||
const SISTER_ORDER: SisterName[] = ['harang', 'narang', 'darang', 'erang'];
|
||||
const SUBAGENT_COUNT = Object.values(SUBAGENT_DEFS).reduce((total, defs) => total + defs.length, 0);
|
||||
|
||||
const FALLBACK_SISTERS: SisterApiItem[] = [
|
||||
{ name: 'harang', status: 'working', role: SISTER_ROLES.harang, currentTask: 'runtime snapshot 확인 중' },
|
||||
{ name: 'narang', status: 'working', role: SISTER_ROLES.narang, currentTask: '개발 세션 상태 동기화 중' },
|
||||
{ name: 'darang', status: 'online', role: SISTER_ROLES.darang, currentTask: '검증 상태 확인 중' },
|
||||
{ name: 'erang', status: 'online', role: SISTER_ROLES.erang, currentTask: '배포 런타임 확인 중' },
|
||||
const GROUP_DEFS: { id: string; label: string; members: SisterName[]; description: string }[] = [
|
||||
{ id: 'planning', label: 'Planning', members: ['harang'], description: 'Orchestration & Planning' },
|
||||
{ id: 'delivery', label: 'Delivery', members: ['narang'], description: 'Implementation' },
|
||||
{ id: 'review', label: 'Review', members: ['darang'], description: 'QA & Review' },
|
||||
{ id: 'deploy', label: 'Deploy', members: ['erang'], description: 'Infra & Deploy' },
|
||||
];
|
||||
|
||||
const FALLBACK_SISTERS: SisterApiItem[] = [
|
||||
{ name: 'harang', status: 'unknown', role: SISTER_ROLES.harang, currentTask: null },
|
||||
{ name: 'narang', status: 'unknown', role: SISTER_ROLES.narang, currentTask: null },
|
||||
{ name: 'darang', status: 'unknown', role: SISTER_ROLES.darang, currentTask: null },
|
||||
{ name: 'erang', status: 'unknown', role: SISTER_ROLES.erang, currentTask: null },
|
||||
];
|
||||
|
||||
function truthfulStateLabel(state: AgentState, source: 'live' | 'snapshot' | 'fallback'): string {
|
||||
if (source === 'fallback') return 'fallback';
|
||||
if (state === 'error') return 'offline';
|
||||
if (state === 'tool_calling' || state === 'thinking' || state === 'speaking') return 'active working';
|
||||
if (source === 'snapshot') return 'snapshot only';
|
||||
return 'connected idle';
|
||||
}
|
||||
|
||||
function statusToAgentState(status: SisterApiItem['status'], hasTask: boolean): AgentState {
|
||||
if (status === 'offline') return 'error';
|
||||
if (status === 'working') return hasTask ? 'tool_calling' : 'thinking';
|
||||
@@ -167,9 +182,23 @@ function mergeSisterData(
|
||||
|
||||
return SISTER_ORDER.map((name) => {
|
||||
const prev = previousMap.get(name);
|
||||
const status = statusMap.get(name) ?? prev ?? FALLBACK_SISTERS.find((item) => item.name === name)!;
|
||||
const status = statusMap.get(name) ?? prev;
|
||||
const live = runtimeMap.get(name);
|
||||
|
||||
// If we have no data at all for this sister, build a minimal honest entry
|
||||
if (!status) {
|
||||
return {
|
||||
name,
|
||||
status: 'unknown' as const,
|
||||
role: SISTER_ROLES[name],
|
||||
currentTask: live?.currentTask ?? null,
|
||||
liveState: live?.mainState,
|
||||
activeSessionLabel: live?.activeSessionLabel ?? null,
|
||||
gatewayConnected: live?.gatewayConnected ?? false,
|
||||
subagents: live?.subagents ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
...status,
|
||||
@@ -367,13 +396,9 @@ const MobileFlow = styled.div`
|
||||
`;
|
||||
|
||||
const MobileSisterGrid = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
|
||||
@media (max-width: 359px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
`;
|
||||
|
||||
const STATE_CARD_COLORS: Record<AgentState, string> = {
|
||||
@@ -497,6 +522,72 @@ const MobileActionBtn = styled.button`
|
||||
}
|
||||
`;
|
||||
|
||||
const MobileGroupBlock = styled.button<{ $hasActive: boolean; $expanded: boolean }>`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-md);
|
||||
border: 1px solid ${({ $hasActive }) => ($hasActive ? '#2979FF' : 'var(--border-color)')};
|
||||
background: ${({ $expanded }) => ($expanded ? 'rgba(255,255,255,0.04)' : 'var(--bg-surface)')};
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-family: inherit;
|
||||
color: var(--text-primary);
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const MobileGroupHeader = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
const MobileGroupLabel = styled.div`
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
`;
|
||||
|
||||
const MobileGroupMeta = styled.div`
|
||||
display: flex;
|
||||
gap: var(--space-xs);
|
||||
flex-wrap: wrap;
|
||||
`;
|
||||
|
||||
const MobileGroupChip = styled.span<{ $tone: 'active' | 'idle' | 'offline' | 'fallback' }>`
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
padding: 2px 6px;
|
||||
border: 1px solid ${({ $tone }) =>
|
||||
$tone === 'active' ? '#2979FF' : $tone === 'idle' ? '#00BFA5' : $tone === 'offline' ? '#FF1744' : '#555'};
|
||||
color: ${({ $tone }) =>
|
||||
$tone === 'active' ? '#2979FF' : $tone === 'idle' ? '#00BFA5' : $tone === 'offline' ? '#FF1744' : '#777'};
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
const MobileGroupDetail = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
padding-top: var(--space-sm);
|
||||
border-top: 1px solid var(--border-color);
|
||||
`;
|
||||
|
||||
const MobileOpsSummary = styled.div`
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-surface);
|
||||
padding: var(--space-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
`;
|
||||
|
||||
const MobileContextInline = styled.div`
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-surface);
|
||||
@@ -698,10 +789,10 @@ export default function OfficePage() {
|
||||
<PageMeta>4자매 · {SUBAGENT_COUNT} 서브에이전트 · 협업 관제</PageMeta>
|
||||
<FreshnessBar>
|
||||
<FreshnessBadge $type={sisterDataMode}>
|
||||
자매: {sisterDataMode === 'live' ? 'live · ws' : sisterDataMode === 'snapshot' ? 'snapshot · poll' : 'fallback · doc'}
|
||||
source: {sisterDataMode}
|
||||
</FreshnessBadge>
|
||||
<FreshnessBadge $type={subagentMode === 'live' ? 'live' : 'fallback'}>
|
||||
서브에이전트: {subagentMode === 'live' ? 'live · runtime' : 'fallback'}
|
||||
agents: {subagentMode}
|
||||
</FreshnessBadge>
|
||||
<FreshnessBadge $type="snapshot">pipeline: snapshot</FreshnessBadge>
|
||||
</FreshnessBar>
|
||||
@@ -736,97 +827,93 @@ export default function OfficePage() {
|
||||
|
||||
{/* ─── Mobile layout (<1280px) ─── */}
|
||||
<MobileFlow>
|
||||
{/* 1. Sister status cards */}
|
||||
<MobileSisterGrid>
|
||||
{sisterNodes.map((sister) => {
|
||||
const isSelected = selected?.type === 'sister' && selected.name === sister.name;
|
||||
return (
|
||||
<MobileSisterCard
|
||||
key={sister.name}
|
||||
$state={sister.state}
|
||||
$selected={isSelected}
|
||||
aria-pressed={isSelected}
|
||||
onClick={() => handleSelectSister(sister.name)}
|
||||
>
|
||||
<SisterAvatar name={sister.name} size={32} />
|
||||
<MobileCardMeta>
|
||||
<MobileCardName>{sister.displayName}</MobileCardName>
|
||||
<MobileCardState $state={sister.state}>{sister.state}</MobileCardState>
|
||||
{sister.currentTask && <MobileCardTask>{sister.currentTask}</MobileCardTask>}
|
||||
</MobileCardMeta>
|
||||
</MobileSisterCard>
|
||||
);
|
||||
})}
|
||||
</MobileSisterGrid>
|
||||
|
||||
{/* 2. Current focus + health summary */}
|
||||
<MobileFocusSection>
|
||||
<MobileSectionEyebrow>current focus</MobileSectionEyebrow>
|
||||
{/* 1. Operational summary — always first */}
|
||||
<MobileOpsSummary>
|
||||
<MobileSectionEyebrow>운영 요약</MobileSectionEyebrow>
|
||||
<MobileFocusTitle>{pipeline.activeTask}</MobileFocusTitle>
|
||||
<MobileFocusDetail>{pipeline.focus}</MobileFocusDetail>
|
||||
<MobileHealthRow>
|
||||
<MobileHealthChip $ok={sisterDataMode === 'live'}>
|
||||
ws: {sisterDataMode === 'live' ? 'connected' : 'disconnected'}
|
||||
{sisterDataMode === 'live' ? 'live' : sisterDataMode === 'snapshot' ? 'snapshot' : 'fallback'}
|
||||
</MobileHealthChip>
|
||||
<MobileHealthChip $ok={onlineCount >= 3}>
|
||||
{onlineCount}/{SISTER_ORDER.length} online
|
||||
{onlineCount}/{SISTER_ORDER.length} connected
|
||||
</MobileHealthChip>
|
||||
<MobileHealthChip $ok={pipeline.deployState !== 'blocked'}>
|
||||
deploy: {pipeline.deployState}
|
||||
</MobileHealthChip>
|
||||
</MobileHealthRow>
|
||||
</MobileFocusSection>
|
||||
|
||||
{/* 3. Quick actions */}
|
||||
<MobileQuickActions>
|
||||
{SISTER_ORDER.map((name) => (
|
||||
<MobileActionBtn key={name} onClick={() => handleOpenChat(name)}>
|
||||
{SISTER_DISPLAY[name]} 채팅
|
||||
</MobileActionBtn>
|
||||
))}
|
||||
</MobileQuickActions>
|
||||
|
||||
{/* 4. Inline context — always show default, expand on selection */}
|
||||
<MobileContextInline>
|
||||
<MobileContextHeader>
|
||||
<MobileContextTitle>
|
||||
{selected && selectedSisterNode
|
||||
? `${selected.type === 'sister' ? selectedSisterNode.displayName : selectedSubagent?.label ?? ''} · context`
|
||||
: '현재 포커스'}
|
||||
</MobileContextTitle>
|
||||
{selected && <MobileCloseBtn onClick={() => setSelected(null)}>✕</MobileCloseBtn>}
|
||||
</MobileContextHeader>
|
||||
<MobileSelectedSummary>
|
||||
<SisterAvatar name={(selectedSisterNode ?? sisterNodes[0])?.name ?? 'harang'} size={28} />
|
||||
<MobileSelectedMeta>
|
||||
<MobileSelectedName>
|
||||
{selected && selectedSisterNode
|
||||
? (selected.type === 'sister' ? selectedSisterNode.displayName : selectedSubagent?.label)
|
||||
: sisterNodes.find((s) => s.state === 'tool_calling' || s.state === 'thinking')?.displayName ?? sisterNodes[0]?.displayName ?? '—'}
|
||||
</MobileSelectedName>
|
||||
<MobileSelectedRole>
|
||||
{selectedSisterNode?.role ?? sisterNodes[0]?.role ?? ''}
|
||||
</MobileSelectedRole>
|
||||
</MobileSelectedMeta>
|
||||
</MobileSelectedSummary>
|
||||
{((selectedSisterNode?.currentTask ?? sisterNodes.find((s) => s.state === 'tool_calling' || s.state === 'thinking')?.currentTask)) && (
|
||||
<MobileTaskBox>
|
||||
{selectedSisterNode?.currentTask ?? sisterNodes.find((s) => s.state === 'tool_calling' || s.state === 'thinking')?.currentTask ?? ''}
|
||||
</MobileTaskBox>
|
||||
)}
|
||||
<MobileQuickActions>
|
||||
<MobileActionBtn onClick={() => handleOpenChat((selectedSisterNode ?? sisterNodes[0])?.name ?? 'harang')}>채팅</MobileActionBtn>
|
||||
{SISTER_ORDER.map((name) => (
|
||||
<MobileActionBtn key={name} onClick={() => handleOpenChat(name)}>
|
||||
{SISTER_DISPLAY[name]}
|
||||
</MobileActionBtn>
|
||||
))}
|
||||
</MobileQuickActions>
|
||||
</MobileContextInline>
|
||||
</MobileOpsSummary>
|
||||
|
||||
{/* 5. Chat (if open) */}
|
||||
{/* 2. Group blocks — group mental model, not 4 individual cards */}
|
||||
<MobileSisterGrid>
|
||||
{GROUP_DEFS.map((group) => {
|
||||
const groupSisters = group.members.map((m) => sisterNodes.find((s) => s.name === m)).filter(Boolean) as typeof sisterNodes;
|
||||
const hasActive = groupSisters.some((s) => s.state === 'tool_calling' || s.state === 'thinking' || s.state === 'speaking');
|
||||
const workingCount = groupSisters.filter((s) => s.state === 'tool_calling' || s.state === 'thinking' || s.state === 'speaking').length;
|
||||
const connectedCount = groupSisters.filter((s) => s.state !== 'error').length;
|
||||
const isExpanded = selected?.type === 'sister' && group.members.includes(selected.name);
|
||||
const groupState = truthfulStateLabel(
|
||||
groupSisters[0]?.state ?? 'idle',
|
||||
sisterDataMode,
|
||||
);
|
||||
|
||||
return (
|
||||
<MobileGroupBlock
|
||||
key={group.id}
|
||||
$hasActive={hasActive}
|
||||
$expanded={isExpanded}
|
||||
onClick={() => handleSelectSister(group.members[0])}
|
||||
>
|
||||
<MobileGroupHeader>
|
||||
<MobileGroupLabel>{group.label}</MobileGroupLabel>
|
||||
<MobileGroupMeta>
|
||||
<MobileGroupChip $tone={hasActive ? 'active' : connectedCount > 0 ? 'idle' : sisterDataMode === 'fallback' ? 'fallback' : 'offline'}>
|
||||
{groupState}
|
||||
</MobileGroupChip>
|
||||
{workingCount > 0 && <MobileGroupChip $tone="active">{workingCount} working</MobileGroupChip>}
|
||||
</MobileGroupMeta>
|
||||
</MobileGroupHeader>
|
||||
<MobileFocusDetail>{group.description}</MobileFocusDetail>
|
||||
{isExpanded && (
|
||||
<MobileGroupDetail>
|
||||
{groupSisters.map((sister) => (
|
||||
<MobileSelectedSummary key={sister.name}>
|
||||
<SisterAvatar name={sister.name} size={28} />
|
||||
<MobileSelectedMeta>
|
||||
<MobileSelectedName>{sister.displayName}</MobileSelectedName>
|
||||
<MobileSelectedRole>
|
||||
{truthfulStateLabel(sister.state, sisterDataMode)}
|
||||
{sister.currentTask ? ` · ${sister.currentTask}` : ''}
|
||||
</MobileSelectedRole>
|
||||
</MobileSelectedMeta>
|
||||
</MobileSelectedSummary>
|
||||
))}
|
||||
<MobileQuickActions>
|
||||
<MobileActionBtn onClick={(e) => { e.stopPropagation(); handleOpenChat(group.members[0]); }}>채팅</MobileActionBtn>
|
||||
</MobileQuickActions>
|
||||
</MobileGroupDetail>
|
||||
)}
|
||||
</MobileGroupBlock>
|
||||
);
|
||||
})}
|
||||
</MobileSisterGrid>
|
||||
|
||||
{/* 3. Chat (if open) */}
|
||||
{chatSister && (
|
||||
<MobileChatArea>
|
||||
<ChatWorkspace initialSister={chatSister} onClose={() => setChatSister(null)} />
|
||||
</MobileChatArea>
|
||||
)}
|
||||
|
||||
{/* 6. Pipeline panel */}
|
||||
{/* 4. Pipeline panel */}
|
||||
<PipelinePanel
|
||||
activeTask={pipeline.activeTask}
|
||||
focus={pipeline.focus}
|
||||
@@ -837,7 +924,7 @@ export default function OfficePage() {
|
||||
freshness={`generated ${formatGeneratedAt(freshness.generatedAt)}`}
|
||||
/>
|
||||
|
||||
{/* 7. Server health */}
|
||||
{/* 5. Server health */}
|
||||
<ServerHealthPanel
|
||||
servers={serverEntries}
|
||||
dataMode={sisterDataMode}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -268,7 +268,7 @@ const ConnectionDot = styled.span<{ $connected: boolean }>`
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: ${({ $connected }) => ($connected ? '#8dffb2' : '#ff8d7a')};
|
||||
background: ${({ $connected }) => ($connected ? '#8dffb2' : '#888')};
|
||||
box-shadow: ${({ $connected }) => ($connected ? '0 0 8px rgba(141,255,178,0.45)' : 'none')};
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
@@ -958,8 +958,8 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
<Summary>RUNTIME / EVENTS / DOCS</Summary>
|
||||
<InlineStats>
|
||||
<Chip $tone={connected ? 'active' : 'muted'}>{connected ? 'socket on' : 'socket off'}</Chip>
|
||||
<Chip $tone="default">snapshot {formatSyncLabel(derived.freshness.generatedAt)}</Chip>
|
||||
<Chip $tone={connected ? 'active' : 'default'}>{connected ? 'EVENT STREAM ON' : 'EVENT STREAM OFF'}</Chip>
|
||||
<Chip $tone="default">{formatSyncLabel(derived.freshness.generatedAt)}</Chip>
|
||||
<Chip $tone={derived.reviewProjects > 0 ? 'warning' : 'default'}>qa {String(derived.reviewProjects).padStart(2, '0')}</Chip>
|
||||
<Chip $tone={derived.readyDeploy > 0 ? 'active' : 'muted'}>deploy {String(derived.readyDeploy).padStart(2, '0')}</Chip>
|
||||
</InlineStats>
|
||||
@@ -999,21 +999,33 @@ export default function DashboardPage() {
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
|
||||
<ConnectionDot $connected={connected} />
|
||||
<div>
|
||||
<Eyebrow>Event stream</Eyebrow>
|
||||
<div style={{ fontSize: 14, color: 'var(--text-primary)', marginTop: 4 }}>{connected ? 'SOCKET ON / EVENT MIRRORED' : 'SOCKET OFF / POLLING'}</div>
|
||||
<Eyebrow>Connection</Eyebrow>
|
||||
<div style={{ fontSize: 14, color: 'var(--text-primary)', marginTop: 4 }}>{connected ? 'EVENT STREAM ON' : 'EVENT STREAM OFF'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<LabelMeta><span>SYNC:</span>{formatSyncLabel(derived.freshness.activityLatestAt)}</LabelMeta>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, alignItems: 'flex-end' }}>
|
||||
<LabelMeta><span>FRESHNESS:</span>{formatSyncLabel(derived.freshness.activityLatestAt)}</LabelMeta>
|
||||
<LabelMeta><span>SOURCE:</span>{connected ? 'MIRRORED EVENT' : 'SNAPSHOT'}</LabelMeta>
|
||||
</div>
|
||||
</ConnectionCard>
|
||||
|
||||
<OperatorRow>
|
||||
<SisterAvatar name={derived.activeProject?.ownerSister ?? 'narang'} size={44} />
|
||||
<OperatorText>
|
||||
<OperatorName>{SISTER_META[derived.activeProject?.ownerSister ?? 'narang']?.label ?? '나랑이'}</OperatorName>
|
||||
<OperatorMeta>
|
||||
{SISTER_META[derived.activeProject?.ownerSister ?? 'narang']?.role ?? 'Implementation'} / SNAPSHOT OWNER
|
||||
</OperatorMeta>
|
||||
</OperatorText>
|
||||
{derived.activeProject?.ownerSister ? (
|
||||
<>
|
||||
<SisterAvatar name={derived.activeProject.ownerSister} size={44} />
|
||||
<OperatorText>
|
||||
<OperatorName>{SISTER_META[derived.activeProject.ownerSister]?.label ?? derived.activeProject.ownerSister}</OperatorName>
|
||||
<OperatorMeta>
|
||||
{SISTER_META[derived.activeProject.ownerSister]?.role ?? 'unknown'} / SNAPSHOT OWNER
|
||||
</OperatorMeta>
|
||||
</OperatorText>
|
||||
</>
|
||||
) : (
|
||||
<OperatorText>
|
||||
<OperatorName>NO ACTIVE OWNER</OperatorName>
|
||||
<OperatorMeta>UNASSIGNED</OperatorMeta>
|
||||
</OperatorText>
|
||||
)}
|
||||
</OperatorRow>
|
||||
|
||||
<QuickLinks>
|
||||
@@ -1104,7 +1116,7 @@ export default function DashboardPage() {
|
||||
<PanelTitleBlock>
|
||||
<Eyebrow>Activity feed</Eyebrow>
|
||||
<PanelTitle>EVENT MIRROR</PanelTitle>
|
||||
<PanelDesc>WEBSOCKET + POLLING SNAPSHOT</PanelDesc>
|
||||
<PanelDesc>{connected ? 'SOURCE: MIRRORED EVENT' : 'SOURCE: SNAPSHOT'}</PanelDesc>
|
||||
</PanelTitleBlock>
|
||||
<LabelMeta><span>EVENT:</span>{String(activityItems.length).padStart(2, '0')}</LabelMeta>
|
||||
</PanelHeader>
|
||||
@@ -1141,7 +1153,7 @@ export default function DashboardPage() {
|
||||
<PanelTitleBlock>
|
||||
<Eyebrow>Sprint metrics</Eyebrow>
|
||||
<PanelTitle>SPRINT METRICS</PanelTitle>
|
||||
<PanelDesc>POLLING SNAPSHOT</PanelDesc>
|
||||
<PanelDesc>SOURCE: SNAPSHOT</PanelDesc>
|
||||
</PanelTitleBlock>
|
||||
<LabelMeta><span>SPRINT:</span>{String(derived.totalSprints).padStart(2, '0')}</LabelMeta>
|
||||
</PanelHeader>
|
||||
@@ -1187,7 +1199,7 @@ export default function DashboardPage() {
|
||||
<PanelTitleBlock>
|
||||
<Eyebrow>Infrastructure overview</Eyebrow>
|
||||
<PanelTitle>RUNTIME SURFACE</PanelTitle>
|
||||
<PanelDesc>LIVE / SNAPSHOT / DOC-DERIVED</PanelDesc>
|
||||
<PanelDesc>SOURCE: {connected ? 'LIVE' : 'SNAPSHOT'} / DOC-DERIVED</PanelDesc>
|
||||
</PanelTitleBlock>
|
||||
<LabelMeta><span>NODE:</span>{String(sisters.length).padStart(2, '0')}</LabelMeta>
|
||||
</PanelHeader>
|
||||
@@ -1208,7 +1220,7 @@ export default function DashboardPage() {
|
||||
<PanelTitleBlock>
|
||||
<Eyebrow>Mistake log & harness</Eyebrow>
|
||||
<PanelTitle>RULE / LOG BOARD</PanelTitle>
|
||||
<PanelDesc>DOC-DERIVED / ACTIVITY</PanelDesc>
|
||||
<PanelDesc>SOURCE: DOC-DERIVED / ACTIVITY</PanelDesc>
|
||||
</PanelTitleBlock>
|
||||
<LabelMeta><span>RULE:</span>{String(derived.board.length).padStart(2, '0')}</LabelMeta>
|
||||
</PanelHeader>
|
||||
|
||||
@@ -46,6 +46,13 @@ const GitItem = styled.a`border:1px solid var(--border-color);padding:12px var(-
|
||||
const GitPrimary = styled.div`display:flex;flex-direction:column;gap:4px;min-width:0;`;
|
||||
const GitTitle = styled.div`font-size:13px;color:var(--text-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;`;
|
||||
|
||||
const ParticipantSection = styled.div`display:flex;flex-direction:column;gap:var(--space-md);`;
|
||||
const ParticipantCard = styled.div`border:1px solid var(--border-color);padding:var(--space-sm) var(--space-md);display:flex;flex-direction:column;gap:6px;`;
|
||||
const ParticipantBadgeRow = styled.div`display:flex;gap:var(--space-xs);flex-wrap:wrap;`;
|
||||
const WhyBadge = styled.span`font-family:var(--font-mono);font-size:9px;text-transform:uppercase;letter-spacing:0.06em;padding:2px 6px;border:1px solid #2979FF;color:#2979FF;white-space:nowrap;`;
|
||||
const SourceBadge = styled.span`font-family:var(--font-mono);font-size:9px;text-transform:uppercase;letter-spacing:0.06em;padding:2px 6px;border:1px solid #777;color:#777;white-space:nowrap;`;
|
||||
const EmptyParticipant = 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 ROLE_LABEL: Record<string, string> = { harang: 'ORCHESTRATOR', narang: 'GENERATOR', darang: 'EVALUATOR', erang: 'INFRA' };
|
||||
const ROLE_NAME: Record<string, string> = { harang: '하랑', narang: '나랑', darang: '다랑', erang: '이랑' };
|
||||
|
||||
@@ -87,12 +94,37 @@ export default function ProjectDetailPage() {
|
||||
}, [tab, id, commits.length, branches.length, pulls.length]);
|
||||
|
||||
const allTasks = useMemo(() => tasks.flatMap((sprint) => sprint.tasks ?? []), [tasks]);
|
||||
const assignedNodes = useMemo(() => {
|
||||
const names = Array.from(new Set(allTasks.map((task) => task.assignee).filter(Boolean)));
|
||||
return (names.length > 0 ? names : ['harang', 'narang', 'darang', 'erang']).map((name) => ({
|
||||
name,
|
||||
status: sisters.find((sister) => sister.name === name)?.status ?? 'offline',
|
||||
}));
|
||||
const participantModel = useMemo(() => {
|
||||
const taskAssigneeNames = Array.from(new Set(allTasks.map((task) => task.assignee).filter(Boolean)));
|
||||
const taskCountByAssignee: Record<string, number> = {};
|
||||
for (const task of allTasks) {
|
||||
if (task.assignee) {
|
||||
taskCountByAssignee[task.assignee] = (taskCountByAssignee[task.assignee] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
const participants: { name: string; whyVisible: string[]; source: string; status: string }[] = [];
|
||||
for (const name of taskAssigneeNames) {
|
||||
const reasons: string[] = [];
|
||||
if (taskCountByAssignee[name]) reasons.push('task_assignee');
|
||||
const sisterStatus = sisters.find((s) => s.name === name)?.status ?? 'offline';
|
||||
if (sisterStatus === 'working' || sisterStatus === 'online') reasons.push('current_implementer');
|
||||
participants.push({
|
||||
name,
|
||||
whyVisible: reasons.length > 0 ? reasons : ['task_assignee'],
|
||||
source: 'snapshot',
|
||||
status: sisterStatus,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
currentOwners: [] as { name: string; source: string }[],
|
||||
participants,
|
||||
taskAssignees: taskAssigneeNames.map((name) => ({
|
||||
name,
|
||||
taskCount: taskCountByAssignee[name] ?? 0,
|
||||
})),
|
||||
};
|
||||
}, [allTasks, sisters]);
|
||||
const hotfixes = history.filter((entry) => entry.kind === 'hotfix');
|
||||
const currentSprintMeta = useMemo(() => {
|
||||
@@ -198,21 +230,72 @@ export default function ProjectDetailPage() {
|
||||
</Panel>
|
||||
|
||||
<Panel $column="side" $mobileOrder={6}>
|
||||
<SectionTitle><span>ASSIGNED NODES</span><LabelMeta>LIVE STATUS</LabelMeta></SectionTitle>
|
||||
<NodeStack>
|
||||
{assignedNodes.map((node) => (
|
||||
<NodeMiniCard key={node.name}>
|
||||
<NodeInfo>
|
||||
<SisterAvatar name={node.name} size={28} />
|
||||
<div>
|
||||
<div style={{ fontSize: '13px', color: 'var(--text-primary)', fontWeight: 600 }}>{ROLE_NAME[node.name] ?? node.name}</div>
|
||||
<Meta>{ROLE_LABEL[node.name] ?? 'NODE'}</Meta>
|
||||
</div>
|
||||
</NodeInfo>
|
||||
<NodeDot $status={node.status} />
|
||||
</NodeMiniCard>
|
||||
))}
|
||||
</NodeStack>
|
||||
<SectionTitle><span>CURRENT OWNERS</span><LabelMeta>SNAPSHOT</LabelMeta></SectionTitle>
|
||||
<ParticipantSection>
|
||||
{participantModel.currentOwners.length === 0 ? (
|
||||
<EmptyParticipant>NO ACTIVE OWNER</EmptyParticipant>
|
||||
) : (
|
||||
participantModel.currentOwners.map((owner) => (
|
||||
<NodeMiniCard key={owner.name}>
|
||||
<NodeInfo>
|
||||
<SisterAvatar name={owner.name} size={28} />
|
||||
<div>
|
||||
<div style={{ fontSize: '13px', color: 'var(--text-primary)', fontWeight: 600 }}>{ROLE_NAME[owner.name] ?? owner.name}</div>
|
||||
<Meta>{ROLE_LABEL[owner.name] ?? 'OWNER'}</Meta>
|
||||
</div>
|
||||
</NodeInfo>
|
||||
<SourceBadge>{owner.source}</SourceBadge>
|
||||
</NodeMiniCard>
|
||||
))
|
||||
)}
|
||||
</ParticipantSection>
|
||||
|
||||
<SectionTitle style={{ marginTop: 'var(--space-lg)' }}><span>PARTICIPANTS</span><LabelMeta>SNAPSHOT</LabelMeta></SectionTitle>
|
||||
<ParticipantSection>
|
||||
{participantModel.participants.length === 0 ? (
|
||||
<EmptyParticipant>NO CONFIRMED ACTIVE PARTICIPANT</EmptyParticipant>
|
||||
) : (
|
||||
participantModel.participants.map((p) => (
|
||||
<ParticipantCard key={p.name}>
|
||||
<NodeInfo>
|
||||
<SisterAvatar name={p.name} size={28} />
|
||||
<div>
|
||||
<div style={{ fontSize: '13px', color: 'var(--text-primary)', fontWeight: 600 }}>{ROLE_NAME[p.name] ?? p.name}</div>
|
||||
<Meta>{ROLE_LABEL[p.name] ?? 'PARTICIPANT'}</Meta>
|
||||
</div>
|
||||
</NodeInfo>
|
||||
<ParticipantBadgeRow>
|
||||
{p.whyVisible.map((reason) => (
|
||||
<WhyBadge key={reason}>{reason}</WhyBadge>
|
||||
))}
|
||||
<SourceBadge>{p.source}</SourceBadge>
|
||||
</ParticipantBadgeRow>
|
||||
</ParticipantCard>
|
||||
))
|
||||
)}
|
||||
</ParticipantSection>
|
||||
|
||||
<SectionTitle style={{ marginTop: 'var(--space-lg)' }}><span>TASK ASSIGNEES</span><LabelMeta>{participantModel.taskAssignees.length} ASSIGNEES</LabelMeta></SectionTitle>
|
||||
<ParticipantSection>
|
||||
{participantModel.taskAssignees.length === 0 ? (
|
||||
<EmptyParticipant>NO TASK ASSIGNEE DATA</EmptyParticipant>
|
||||
) : (
|
||||
<NodeStack>
|
||||
{participantModel.taskAssignees.map((a) => (
|
||||
<NodeMiniCard key={a.name}>
|
||||
<NodeInfo>
|
||||
<SisterAvatar name={a.name} size={28} />
|
||||
<div>
|
||||
<div style={{ fontSize: '13px', color: 'var(--text-primary)', fontWeight: 600 }}>{ROLE_NAME[a.name] ?? a.name}</div>
|
||||
<Meta>{a.taskCount} TASKS</Meta>
|
||||
</div>
|
||||
</NodeInfo>
|
||||
<SourceBadge>snapshot</SourceBadge>
|
||||
</NodeMiniCard>
|
||||
))}
|
||||
</NodeStack>
|
||||
)}
|
||||
</ParticipantSection>
|
||||
</Panel>
|
||||
</ProjectGrid>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user