487 lines
14 KiB
TypeScript
487 lines
14 KiB
TypeScript
'use client';
|
|
|
|
import React, { useCallback, useEffect, useState } from 'react';
|
|
import styled from 'styled-components';
|
|
import { API_URL, POLL_INTERVAL_MS } from '@/lib/config';
|
|
import { useSocket } from '@/lib/useSocket';
|
|
import OfficeScene, {
|
|
type AgentState,
|
|
type SisterName,
|
|
type SisterNode,
|
|
type SubAgent,
|
|
type SelectedAgent,
|
|
} from '@/components/office/OfficeScene';
|
|
import ContextPanel from '@/components/office/ContextPanel';
|
|
import ChatWorkspace from '@/components/office/ChatWorkspace';
|
|
import PipelinePanel from '@/components/office/PipelinePanel';
|
|
import ServerHealthPanel, { type ServerEntry } from '@/components/office/ServerHealthPanel';
|
|
import type { PipelineNode } from '@/components/dashboard/ActivePipeline';
|
|
|
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
|
|
interface SisterApiItem {
|
|
id?: number;
|
|
name: string;
|
|
status: 'online' | 'offline' | 'working' | 'unknown';
|
|
role?: string;
|
|
currentTask?: string | null;
|
|
lastSeen?: string | null;
|
|
cpu?: number;
|
|
memory?: { used?: number; total?: number };
|
|
}
|
|
|
|
interface DashboardOpsData {
|
|
focusProject: {
|
|
name: string;
|
|
phase: string;
|
|
progress: number;
|
|
deployStatus: string;
|
|
currentSprint?: string | null;
|
|
ownerSister?: string | null;
|
|
} | null;
|
|
pipeline: {
|
|
activeTask: string;
|
|
focus: string;
|
|
reviewLoopCount: number;
|
|
escalationCount: number;
|
|
deployState: string;
|
|
nodes: PipelineNode[];
|
|
};
|
|
freshness: {
|
|
generatedAt: string;
|
|
activityLatestAt: string | null;
|
|
sistersLatestAt: string | null;
|
|
qaDocLatestAt: string | null;
|
|
};
|
|
}
|
|
|
|
// ─── Static subagent definitions ─────────────────────────────────────────────
|
|
|
|
const SUBAGENT_DEFS: Record<SisterName, { id: string; name: string; label: string }[]> = {
|
|
harang: [
|
|
{ id: 'harang-planner', name: 'planner', label: 'planner' },
|
|
{ id: 'harang-task-tracker', name: 'task-tracker', label: 'task-tracker' },
|
|
{ id: 'harang-prd-writer', name: 'prd-writer', label: 'prd-writer' },
|
|
],
|
|
narang: [
|
|
{ id: 'narang-worker', name: 'worker', label: 'worker' },
|
|
{ id: 'narang-db-designer', name: 'db-designer', label: 'db-designer' },
|
|
{ id: 'narang-test-writer', name: 'test-writer', label: 'test-writer' },
|
|
{ id: 'narang-refactorer', name: 'refactorer', label: 'refactorer' },
|
|
],
|
|
darang: [
|
|
{ id: 'darang-reviewer', name: 'reviewer', label: 'reviewer' },
|
|
{ id: 'darang-code-reviewer', name: 'code-reviewer', label: 'code-rvw' },
|
|
{ id: 'darang-qa-tester', name: 'qa-tester', label: 'qa-tester' },
|
|
{ id: 'darang-security-auditor', name: 'security-auditor', label: 'security' },
|
|
{ id: 'darang-ux-reviewer', name: 'ux-reviewer', label: 'ux-reviewer' },
|
|
],
|
|
erang: [
|
|
{ id: 'erang-deploy-manager', name: 'deploy-manager', label: 'deploy' },
|
|
{ id: 'erang-db-manager', name: 'db-manager', label: 'db-mgr' },
|
|
{ id: 'erang-nginx-manager', name: 'nginx-manager', label: 'nginx' },
|
|
{ id: 'erang-monitoring', name: 'monitoring', label: 'monitor' },
|
|
{ id: 'erang-dns-manager', name: 'dns-manager', label: 'dns' },
|
|
],
|
|
};
|
|
|
|
const SISTER_DISPLAY: Record<SisterName, string> = {
|
|
harang: '하랑이',
|
|
narang: '나랑이',
|
|
darang: '다랑이',
|
|
erang: '이랑이',
|
|
};
|
|
|
|
const SISTER_ROLES: Record<SisterName, string> = {
|
|
harang: 'Planning & Orchestration',
|
|
narang: 'Development',
|
|
darang: 'QA & Review',
|
|
erang: 'Infra & Deploy',
|
|
};
|
|
|
|
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: 'SPRINT-016 scope lock',
|
|
},
|
|
{
|
|
name: 'narang',
|
|
status: 'working',
|
|
role: SISTER_ROLES.narang,
|
|
currentTask: 'Office scene dynamic movement',
|
|
},
|
|
{
|
|
name: 'darang',
|
|
status: 'online',
|
|
role: SISTER_ROLES.darang,
|
|
currentTask: 'Review loop standby',
|
|
},
|
|
{
|
|
name: 'erang',
|
|
status: 'online',
|
|
role: SISTER_ROLES.erang,
|
|
currentTask: 'Gateway health watch',
|
|
},
|
|
];
|
|
|
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
function statusToAgentState(status: SisterApiItem['status'], hasTask: boolean): AgentState {
|
|
if (status === 'offline') return 'error';
|
|
if (status === 'working') return hasTask ? 'tool_calling' : 'thinking';
|
|
if (status === 'online') return hasTask ? 'speaking' : 'idle';
|
|
return 'idle';
|
|
}
|
|
|
|
function derivedSubagentState(parentState: AgentState, index: number): AgentState {
|
|
// Derive subagent state from parent with some variety
|
|
if (parentState === 'error') return 'idle'; // subagents idle when parent is offline
|
|
if (parentState === 'tool_calling' && index === 0) return 'thinking';
|
|
if (parentState === 'speaking' && index <= 1) return 'thinking';
|
|
return 'idle';
|
|
}
|
|
|
|
function buildSisterNodes(sisters: SisterApiItem[]): SisterNode[] {
|
|
return SISTER_ORDER.map((name) => {
|
|
const apiItem = sisters.find((s) => s.name === name);
|
|
const status = apiItem?.status ?? 'unknown';
|
|
const hasTask = Boolean(apiItem?.currentTask);
|
|
const parentState = statusToAgentState(status, hasTask);
|
|
|
|
const subDefs = SUBAGENT_DEFS[name];
|
|
const subagents: SubAgent[] = subDefs.map((def, i) => ({
|
|
...def,
|
|
sister: name,
|
|
state: derivedSubagentState(parentState, i),
|
|
}));
|
|
|
|
return {
|
|
name,
|
|
displayName: SISTER_DISPLAY[name],
|
|
role: apiItem?.role ?? SISTER_ROLES[name],
|
|
emoji: name === 'harang' || name === 'narang' ? '🦊' : name === 'darang' ? '🐱' : '🐺',
|
|
state: parentState,
|
|
currentTask: apiItem?.currentTask ?? null,
|
|
subagents,
|
|
};
|
|
});
|
|
}
|
|
|
|
function buildServerEntries(sisters: SisterApiItem[], sisterSource: 'live' | 'snapshot' | 'fallback'): ServerEntry[] {
|
|
const sisterEntries: ServerEntry[] = SISTER_ORDER.map((name) => {
|
|
const s = sisters.find((x) => x.name === name);
|
|
return {
|
|
id: `server-${name}`,
|
|
label: SISTER_DISPLAY[name],
|
|
type: 'sister' as const,
|
|
status: s?.status ?? 'unknown',
|
|
detail: s?.currentTask ?? null,
|
|
source: s ? sisterSource : 'fallback',
|
|
};
|
|
});
|
|
|
|
// Dev server and Docker are fallback (no direct API yet)
|
|
const extraEntries: ServerEntry[] = [
|
|
{
|
|
id: 'server-dev',
|
|
label: 'Dev Server',
|
|
type: 'dev',
|
|
status: 'unknown',
|
|
detail: null,
|
|
source: 'fallback',
|
|
},
|
|
{
|
|
id: 'server-docker',
|
|
label: 'Docker / Infra',
|
|
type: 'docker',
|
|
status: 'unknown',
|
|
detail: null,
|
|
source: 'fallback',
|
|
},
|
|
];
|
|
|
|
return [...sisterEntries, ...extraEntries];
|
|
}
|
|
|
|
function formatGeneratedAt(ts: string): string {
|
|
try {
|
|
return new Date(ts).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
|
} catch {
|
|
return ts;
|
|
}
|
|
}
|
|
|
|
// ─── Styled Components ────────────────────────────────────────────────────────
|
|
|
|
const Shell = styled.div`
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--space-lg);
|
|
min-height: 0;
|
|
`;
|
|
|
|
const PageHeader = styled.div`
|
|
display: flex;
|
|
align-items: baseline;
|
|
gap: var(--space-lg);
|
|
flex-wrap: wrap;
|
|
`;
|
|
|
|
const PageTitle = styled.h1`
|
|
font-size: 20px;
|
|
font-weight: 600;
|
|
color: var(--text-primary);
|
|
letter-spacing: -0.01em;
|
|
`;
|
|
|
|
const PageMeta = styled.div`
|
|
font-family: var(--font-mono);
|
|
font-size: 11px;
|
|
color: var(--text-secondary);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.06em;
|
|
`;
|
|
|
|
const FreshnessBar = styled.div`
|
|
display: flex;
|
|
gap: var(--space-md);
|
|
flex-wrap: wrap;
|
|
margin-left: auto;
|
|
`;
|
|
|
|
const FreshnessBadge = styled.span<{ $type: 'live' | 'snapshot' | 'fallback' }>`
|
|
font-family: var(--font-mono);
|
|
font-size: 10px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.06em;
|
|
color: ${({ $type }) =>
|
|
$type === 'live' ? '#00BFA5'
|
|
: $type === 'snapshot' ? '#FF9800'
|
|
: '#555'};
|
|
border: 1px solid currentColor;
|
|
padding: 2px 6px;
|
|
opacity: 0.8;
|
|
`;
|
|
|
|
const MainArea = styled.div`
|
|
display: flex;
|
|
gap: 0;
|
|
min-height: 0;
|
|
flex: 1;
|
|
|
|
@media (max-width: 767px) {
|
|
flex-direction: column;
|
|
}
|
|
`;
|
|
|
|
const SceneColumn = styled.div`
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--space-lg);
|
|
min-width: 0;
|
|
`;
|
|
|
|
const ChatArea = styled.div`
|
|
height: 520px;
|
|
flex-shrink: 0;
|
|
|
|
@media (max-width: 767px) {
|
|
height: 460px;
|
|
}
|
|
`;
|
|
|
|
const BottomPanels = styled.div`
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--space-lg);
|
|
`;
|
|
|
|
const LoadingOverlay = styled.div`
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
min-height: 400px;
|
|
font-family: var(--font-mono);
|
|
font-size: 12px;
|
|
color: var(--text-secondary);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.08em;
|
|
`;
|
|
|
|
// ─── Main Component ───────────────────────────────────────────────────────────
|
|
|
|
export default function OfficePage() {
|
|
const [sisters, setSisters] = useState<SisterApiItem[]>([]);
|
|
const [opsData, setOpsData] = useState<DashboardOpsData | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
const [selected, setSelected] = useState<SelectedAgent | null>(null);
|
|
const [chatSister, setChatSister] = useState<SisterName | null>(null);
|
|
|
|
// Live WebSocket connection
|
|
const { connected } = useSocket<SisterApiItem>({
|
|
onSistersUpdate: (updatedSisters) => {
|
|
setSisters(updatedSisters);
|
|
},
|
|
});
|
|
|
|
// Initial data fetch
|
|
useEffect(() => {
|
|
const fetchAll = async () => {
|
|
try {
|
|
const [sistersRes, opsRes] = await Promise.allSettled([
|
|
fetch(`${API_URL}/api/sisters`).then((r) => r.json()),
|
|
fetch(`${API_URL}/api/dashboard/ops`).then((r) => r.json()),
|
|
]);
|
|
|
|
if (sistersRes.status === 'fulfilled') {
|
|
setSisters(Array.isArray(sistersRes.value) ? sistersRes.value : []);
|
|
}
|
|
if (opsRes.status === 'fulfilled') {
|
|
setOpsData(opsRes.value);
|
|
}
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
void fetchAll();
|
|
|
|
// Polling fallback
|
|
const interval = setInterval(() => {
|
|
fetch(`${API_URL}/api/sisters`)
|
|
.then((r) => r.json())
|
|
.then((data: SisterApiItem[]) => {
|
|
if (Array.isArray(data)) setSisters(data);
|
|
})
|
|
.catch(() => {/* ignore polling errors */});
|
|
}, POLL_INTERVAL_MS);
|
|
|
|
return () => clearInterval(interval);
|
|
}, []);
|
|
|
|
const hasSisterSnapshot = sisters.length > 0;
|
|
const sisterDataMode: 'live' | 'snapshot' | 'fallback' = connected
|
|
? 'live'
|
|
: hasSisterSnapshot
|
|
? 'snapshot'
|
|
: 'fallback';
|
|
const effectiveSisters = hasSisterSnapshot ? sisters : FALLBACK_SISTERS;
|
|
|
|
const sisterNodes = buildSisterNodes(effectiveSisters);
|
|
const serverEntries = buildServerEntries(effectiveSisters, sisterDataMode);
|
|
|
|
const handleSelectSister = useCallback((name: SisterName) => {
|
|
setSelected({ type: 'sister', name });
|
|
setChatSister(null);
|
|
}, []);
|
|
|
|
const handleSelectSubagent = useCallback((id: string, sister: SisterName) => {
|
|
setSelected({ type: 'subagent', id, sister });
|
|
setChatSister(null);
|
|
}, []);
|
|
|
|
const handleOpenChat = useCallback((sisterName: SisterName) => {
|
|
setChatSister(sisterName);
|
|
}, []);
|
|
|
|
const handleCloseChat = useCallback(() => {
|
|
setChatSister(null);
|
|
}, []);
|
|
|
|
const handleClearSelection = useCallback(() => {
|
|
setSelected(null);
|
|
}, []);
|
|
|
|
const pipeline = opsData?.pipeline ?? {
|
|
activeTask: 'SPRINT-016',
|
|
focus: 'Isometric Office Dashboard',
|
|
reviewLoopCount: 0,
|
|
escalationCount: 0,
|
|
deployState: '—',
|
|
nodes: [],
|
|
};
|
|
|
|
const freshness = opsData?.freshness ?? {
|
|
generatedAt: new Date().toISOString(),
|
|
activityLatestAt: null,
|
|
sistersLatestAt: null,
|
|
qaDocLatestAt: null,
|
|
};
|
|
|
|
if (loading) {
|
|
return <LoadingOverlay>오피스 데이터 로딩 중...</LoadingOverlay>;
|
|
}
|
|
|
|
return (
|
|
<Shell>
|
|
<PageHeader>
|
|
<PageTitle>🏢 오피스 대시보드</PageTitle>
|
|
<PageMeta>4자매 · {SUBAGENT_COUNT} 서브에이전트 · 협업 관제</PageMeta>
|
|
<FreshnessBar>
|
|
<FreshnessBadge $type={sisterDataMode}>
|
|
자매: {sisterDataMode === 'live' ? 'live · ws' : sisterDataMode === 'snapshot' ? 'snapshot · poll' : 'fallback · doc'}
|
|
</FreshnessBadge>
|
|
<FreshnessBadge $type="fallback">
|
|
서브에이전트: fallback
|
|
</FreshnessBadge>
|
|
<FreshnessBadge $type="snapshot">
|
|
pipeline: snapshot
|
|
</FreshnessBadge>
|
|
</FreshnessBar>
|
|
</PageHeader>
|
|
|
|
<MainArea>
|
|
<SceneColumn>
|
|
<OfficeScene
|
|
sisters={sisterNodes}
|
|
selected={selected}
|
|
onSelectSister={handleSelectSister}
|
|
onSelectSubagent={handleSelectSubagent}
|
|
dataMode={sisterDataMode}
|
|
/>
|
|
|
|
{chatSister && (
|
|
<ChatArea>
|
|
<ChatWorkspace
|
|
initialSister={chatSister}
|
|
onClose={handleCloseChat}
|
|
/>
|
|
</ChatArea>
|
|
)}
|
|
</SceneColumn>
|
|
|
|
<ContextPanel
|
|
selected={selected}
|
|
sisters={sisterNodes}
|
|
onOpenChat={handleOpenChat}
|
|
onClear={handleClearSelection}
|
|
/>
|
|
</MainArea>
|
|
|
|
<BottomPanels>
|
|
<PipelinePanel
|
|
activeTask={pipeline.activeTask}
|
|
focus={pipeline.focus}
|
|
reviewLoopCount={pipeline.reviewLoopCount}
|
|
escalationCount={pipeline.escalationCount}
|
|
deployState={pipeline.deployState}
|
|
nodes={pipeline.nodes}
|
|
freshness={`generated ${formatGeneratedAt(freshness.generatedAt)}`}
|
|
/>
|
|
<ServerHealthPanel
|
|
servers={serverEntries}
|
|
dataMode={sisterDataMode}
|
|
generatedAt={freshness.generatedAt}
|
|
/>
|
|
</BottomPanels>
|
|
</Shell>
|
|
);
|
|
}
|