'use client'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import styled, { keyframes } from 'styled-components'; import Link from 'next/link'; import { API_URL, POLL_INTERVAL_MS } from '@/lib/config'; import { useSocket } from '@/lib/useSocket'; import SisterAvatar from '@/components/common/SisterAvatar'; import ActivePipeline, { type PipelineNode } from '@/components/dashboard/ActivePipeline'; import { LabelMeta, TechBar, TechBarFill } from '@/components/ui/base'; interface SisterItem { id?: number; name: string; status: 'online' | 'offline' | 'working' | 'unknown'; role?: string; currentTask?: string | null; lastSeen?: string | null; lxcId?: number; uptime?: string; cpu?: number; memory?: { used?: number; total?: number }; disk?: { used?: string; total?: string }; } interface ProjectItem { id: number; name: string; description: string | null; phase: 'PLANNING' | 'IMPLEMENT' | 'QA' | 'READY FOR DEPLOY' | 'DEPLOYED'; progress: number; deployStatus: string; ownerSister?: string; currentSprint?: string | null; totalSprints?: number; doneSprints?: number; sprintCount?: number; openPRs?: number; latestQaStatus?: 'passed' | 'failed' | 'unknown'; blockerCount?: number; updatedAt: string; } interface ActivityItem { id: number; createdAt: string; detail?: string | null; action?: string; sister?: { name: string } | null; project?: { name: string } | null; } interface BoardItem { id: string; title: string; body: string; tone: 'default' | 'warn' | 'ok' | 'active'; author: string; time: string; category: string; source: string; } interface DashboardOpsData { focusProject: { name: string; ownerSister?: string | null; phase: string; currentSprint?: string | null; progress: number; deployStatus: string; } | null; pipeline: { activeTask: string; focus: string; reviewLoopCount: number; escalationCount: number; deployState: string; nodes: PipelineNode[]; }; board: BoardItem[]; freshness: { generatedAt: string; activityLatestAt: string | null; sistersLatestAt: string | null; qaDocLatestAt: string | null; }; } const pulse = keyframes`0%{opacity:.35}50%{opacity:.7}100%{opacity:.35}`; const Shell = styled.div` display: flex; flex-direction: column; gap: var(--space-xl); `; const TopBar = styled.section` border: 1px solid var(--border-color); background: var(--bg-surface); padding: var(--space-lg); display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr) minmax(260px, 0.9fr); gap: var(--space-lg); align-items: stretch; @media (max-width: 1199px) { grid-template-columns: 1fr 1fr; } @media (max-width: 767px) { grid-template-columns: 1fr; } `; const TopBlock = styled.div` display: flex; flex-direction: column; gap: 12px; min-width: 0; `; const Eyebrow = styled.div` font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.08em; color: var(--text-secondary); text-transform: uppercase; `; const Headline = styled.h1` font-size: 30px; line-height: 1.1; letter-spacing: -0.03em; color: var(--text-primary); font-weight: 600; @media (max-width: 767px) { font-size: 24px; } `; const Summary = styled.p` font-size: 14px; line-height: 1.7; color: var(--text-secondary); max-width: 720px; `; const InlineStats = styled.div` display: flex; gap: var(--space-sm); flex-wrap: wrap; `; const Chip = styled.div<{ $tone?: 'default' | 'active' | 'warning' | 'muted' }>` border: 1px solid ${({ $tone }) => $tone === 'active' ? 'rgba(111,195,255,0.4)' : $tone === 'warning' ? 'rgba(255,141,122,0.4)' : $tone === 'muted' ? 'rgba(138,138,138,0.35)' : 'var(--border-color)'}; color: ${({ $tone }) => $tone === 'active' ? '#a9dcff' : $tone === 'warning' ? '#ffb5a9' : $tone === 'muted' ? 'var(--text-secondary)' : 'var(--text-primary)'}; background: rgba(255,255,255,0.02); padding: 7px 10px; font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; `; const FocusPanel = styled.div` border-left: 1px solid var(--border-color); border-right: 1px solid var(--border-color); padding: 0 var(--space-lg); display: flex; flex-direction: column; gap: 12px; min-width: 0; @media (max-width: 1199px) { border-right: none; padding-right: 0; } @media (max-width: 767px) { border-left: none; padding: 0; border-top: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color); padding-top: var(--space-lg); padding-bottom: var(--space-lg); } `; const FocusTitle = styled.div` font-size: 18px; font-weight: 600; color: var(--text-primary); `; const FocusBody = styled.div` font-size: 13px; line-height: 1.7; color: var(--text-secondary); `; const FocusMetaGrid = styled.div` display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; `; const MetaCard = styled.div` border: 1px solid var(--border-color); padding: 12px; display: flex; flex-direction: column; gap: 6px; min-width: 0; `; const MetaLabel = styled.span` font-family: var(--font-mono); font-size: 10px; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.08em; `; const MetaValue = styled.span` font-size: 15px; color: var(--text-primary); font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; `; const OperatorPanel = styled.div` display: flex; flex-direction: column; gap: 12px; min-width: 0; `; const ConnectionCard = styled.div` border: 1px solid var(--border-color); padding: 12px; display: flex; justify-content: space-between; align-items: center; gap: 12px; `; const ConnectionDot = styled.span<{ $connected: boolean }>` width: 8px; height: 8px; border-radius: 50%; background: ${({ $connected }) => ($connected ? '#8dffb2' : '#ff8d7a')}; box-shadow: ${({ $connected }) => ($connected ? '0 0 8px rgba(141,255,178,0.45)' : 'none')}; flex-shrink: 0; `; const OperatorRow = styled.div` display: flex; align-items: center; gap: 12px; `; const OperatorText = styled.div` display: flex; flex-direction: column; gap: 4px; min-width: 0; `; const OperatorName = styled.div` font-size: 15px; color: var(--text-primary); font-weight: 600; `; const OperatorMeta = styled.div` font-size: 12px; color: var(--text-secondary); line-height: 1.5; `; const QuickLinks = styled.div` display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; `; const QuickLink = styled(Link)` border: 1px solid var(--border-color); padding: 12px; text-decoration: none; display: flex; flex-direction: column; gap: 4px; color: inherit; transition: border-color 0.15s ease, background 0.15s ease; &:hover { border-color: var(--border-hover); background: rgba(255,255,255,0.02); } `; const QuickLabel = styled.div` font-family: var(--font-mono); font-size: 10px; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.08em; `; const QuickTitle = styled.div` font-size: 14px; color: var(--text-primary); font-weight: 600; `; const StatusGrid = styled.section` display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--space-lg); @media (min-width: 768px) and (max-width: 1199px) { grid-template-columns: repeat(2, minmax(0, 1fr)); } @media (max-width: 767px) { grid-template-columns: 1fr; gap: var(--space-md); } `; const SisterCard = styled(Link)<{ $accent: string; $muted?: boolean }>` border: 1px solid ${({ $muted, $accent }) => ($muted ? 'var(--border-color)' : `${$accent}55`)}; background: var(--bg-surface); padding: var(--space-lg); display: flex; flex-direction: column; gap: 14px; text-decoration: none; color: inherit; min-height: 264px; transition: border-color 0.15s ease, background 0.15s ease; &:hover { border-color: ${({ $accent }) => `${$accent}88`}; background: rgba(255,255,255,0.015); } `; const SisterHeader = styled.div` display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; `; const SisterIdentity = styled.div` display: flex; gap: 12px; min-width: 0; `; const SisterName = styled.div` font-size: 18px; color: var(--text-primary); font-weight: 600; `; const SisterRole = styled.div` font-size: 12px; color: var(--text-secondary); `; const SisterStatusBadge = styled.span<{ $accent: string; $status: SisterItem['status'] }>` border: 1px solid ${({ $accent, $status }) => ($status === 'offline' ? 'rgba(255,141,122,0.45)' : `${$accent}55`)}; color: ${({ $accent, $status }) => ($status === 'offline' ? '#ffb5a9' : $accent)}; font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase; padding: 5px 7px; white-space: nowrap; `; const SisterSummary = styled.div` font-size: 13px; color: var(--text-primary); line-height: 1.7; min-height: 44px; `; const ResourceGrid = styled.div` display: grid; grid-template-columns: 1fr; gap: 10px; `; const ResourceRow = styled.div` display: flex; flex-direction: column; gap: 6px; `; const ResourceTop = styled.div` display: flex; justify-content: space-between; gap: 10px; align-items: center; font-family: var(--font-mono); font-size: 11px; color: var(--text-secondary); `; const FooterMeta = styled.div` margin-top: auto; padding-top: 2px; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; `; const FooterMetaBox = styled.div` border-top: 1px solid var(--border-color); padding-top: 10px; display: flex; flex-direction: column; gap: 4px; `; const SectionGrid = styled.section` display: grid; grid-template-columns: minmax(0, 1.3fr) minmax(320px, 0.9fr); gap: var(--space-xl); @media (max-width: 1199px) { grid-template-columns: 1fr; } `; const Panel = styled.section` border: 1px solid var(--border-color); background: var(--bg-surface); padding: var(--space-lg); display: flex; flex-direction: column; gap: var(--space-lg); min-width: 0; `; const PanelHeader = styled.div` display: flex; justify-content: space-between; gap: var(--space-md); align-items: flex-start; flex-wrap: wrap; `; const PanelTitleBlock = styled.div` display: flex; flex-direction: column; gap: 6px; `; const PanelTitle = styled.h2` font-size: 18px; color: var(--text-primary); font-weight: 600; `; const PanelDesc = styled.p` font-size: 13px; line-height: 1.6; color: var(--text-secondary); `; const ActivityList = styled.div` display: flex; flex-direction: column; gap: 12px; `; const ActivityCard = styled.div` border: 1px solid var(--border-color); padding: 12px; display: grid; grid-template-columns: 88px minmax(0, 1fr); gap: 12px; align-items: flex-start; @media (max-width: 767px) { grid-template-columns: 1fr; } `; const ActivityTime = styled.div` font-family: var(--font-mono); font-size: 11px; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.08em; `; const ActivityBody = styled.div` display: flex; flex-direction: column; gap: 6px; min-width: 0; `; const ActivityHeadline = styled.div` font-size: 13px; color: var(--text-primary); line-height: 1.6; `; const ActivityMeta = styled.div` display: flex; gap: 8px; flex-wrap: wrap; `; const EventTag = styled.span<{ $tone?: 'default' | 'warn' | 'ok' | 'active' }>` padding: 4px 7px; border: 1px solid ${({ $tone }) => $tone === 'warn' ? 'rgba(255,141,122,0.35)' : $tone === 'ok' ? 'rgba(141,255,178,0.35)' : $tone === 'active' ? 'rgba(111,195,255,0.35)' : 'var(--border-color)'}; color: ${({ $tone }) => $tone === 'warn' ? '#ffb5a9' : $tone === 'ok' ? '#b8ffd0' : $tone === 'active' ? '#a9dcff' : 'var(--text-secondary)'}; font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; `; const MetricsGrid = styled.div` display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; @media (max-width: 767px) { grid-template-columns: 1fr; } `; const MetricCard = styled.div` border: 1px solid var(--border-color); padding: 14px; display: flex; flex-direction: column; gap: 8px; min-width: 0; `; const MetricValue = styled.div` font-size: 28px; color: var(--text-primary); font-weight: 600; letter-spacing: -0.03em; `; const MetricHelp = styled.div` font-size: 12px; color: var(--text-secondary); line-height: 1.6; `; const MetricBars = styled.div` display: flex; flex-direction: column; gap: 10px; `; const DistRow = styled.div` display: grid; grid-template-columns: 86px minmax(0, 1fr) 42px; gap: 10px; align-items: center; font-family: var(--font-mono); font-size: 11px; color: var(--text-secondary); `; const InfraMap = styled.div` display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; @media (max-width: 767px) { grid-template-columns: 1fr; } `; const InfraCard = styled.div` border: 1px solid var(--border-color); padding: 14px; display: flex; flex-direction: column; gap: 8px; `; const InfraTitle = styled.div` font-size: 14px; color: var(--text-primary); font-weight: 600; `; const InfraMeta = styled.div` font-size: 12px; line-height: 1.6; color: var(--text-secondary); `; const LessonList = styled.div` display: flex; flex-direction: column; gap: 12px; `; const LessonCard = styled.div<{ $tone?: 'warn' | 'ok' | 'active' | 'default' }>` border: 1px solid ${({ $tone }) => $tone === 'warn' ? 'rgba(255,141,122,0.35)' : $tone === 'ok' ? 'rgba(141,255,178,0.35)' : $tone === 'active' ? 'rgba(111,195,255,0.35)' : 'var(--border-color)'}; padding: 14px; display: flex; flex-direction: column; gap: 8px; `; const LessonTitle = styled.div` font-size: 14px; color: var(--text-primary); font-weight: 600; `; const LessonMeta = styled.div` display: flex; gap: 8px; flex-wrap: wrap; `; const LessonBody = styled.div` font-size: 12px; line-height: 1.7; color: var(--text-secondary); `; const EmptyState = styled.div` border: 1px solid var(--border-color); padding: 18px; font-size: 13px; color: var(--text-secondary); line-height: 1.6; `; const SkeletonCard = styled.div` height: 220px; border: 1px solid var(--border-color); background: #111; animation: ${pulse} 1.4s ease-in-out infinite; `; const SectionSkeleton = styled.div` height: 320px; border: 1px solid var(--border-color); background: #111; animation: ${pulse} 1.4s ease-in-out infinite; `; const SISTER_META: Record = { harang: { label: '하랑이', role: 'Planning / Orchestrator', accent: '#f6b26b' }, narang: { label: '나랑이', role: 'Implementation / Generator', accent: '#7dcfff' }, darang: { label: '다랑이', role: 'Review / Evaluator', accent: '#ff7ac6' }, erang: { label: '이랑이', role: 'Deploy / Infra Manager', accent: '#b18cff' }, }; function formatRelativeTime(iso?: string | null): string { if (!iso) return '기록 없음'; const diff = Date.now() - new Date(iso).getTime(); if (!Number.isFinite(diff)) return '기록 없음'; const min = Math.floor(diff / 60000); if (min < 1) return '방금'; if (min < 60) return `${min}분 전`; const hr = Math.floor(min / 60); if (hr < 24) return `${hr}시간 전`; return `${Math.floor(hr / 24)}일 전`; } function formatTime(iso?: string | null): string { if (!iso) return '--:--'; const date = new Date(iso); return date.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', hour12: false }); } function formatSyncLabel(iso?: string | null): string { if (!iso) return 'NO SYNC'; const diff = Date.now() - new Date(iso).getTime(); if (!Number.isFinite(diff) || diff < 0) return 'SYNC UNKNOWN'; const sec = Math.floor(diff / 1000); if (sec < 60) return `SYNC ${sec}s AGO`; const min = Math.floor(sec / 60); if (min < 60) return `SYNC ${min}m AGO`; const hr = Math.floor(min / 60); return `SYNC ${hr}h AGO`; } function normalizePercent(value?: number | null): number { if (typeof value !== 'number' || Number.isNaN(value)) return 0; return Math.max(0, Math.min(100, value)); } function memoryPercent(memory?: { used?: number; total?: number }): number { const used = memory?.used ?? 0; const total = memory?.total ?? 0; if (!total) return 0; return normalizePercent((used / total) * 100); } function displayStatus(status: SisterItem['status']): string { if (status === 'working') return 'WORKING'; if (status === 'online') return 'READY'; if (status === 'offline') return 'OFFLINE'; return 'UNKNOWN'; } function buildSisterSummary(sister: SisterItem, activityItems: ActivityItem[], projects: ProjectItem[]): string { if (sister.currentTask) return sister.currentTask; const latestActivity = activityItems.find((item) => item.sister?.name === sister.name); if (latestActivity?.detail) return latestActivity.detail; const ownerProject = projects.find((project) => project.ownerSister === sister.name && project.phase !== 'DEPLOYED'); if (ownerProject) return `${ownerProject.name} · ${ownerProject.phase}`; if (sister.status === 'offline') return 'RUNTIME OFFLINE'; if (sister.status === 'working') return 'ACTIVE SNAPSHOT'; if (sister.status === 'online') return 'RUNTIME READY'; return 'SNAPSHOT ONLY'; } function actionTone(action?: string, detail?: string | null): 'default' | 'warn' | 'ok' | 'active' { const text = `${action ?? ''} ${detail ?? ''}`.toLowerCase(); if (text.includes('fail') || text.includes('error') || text.includes('blocker') || text.includes('offline')) return 'warn'; if (text.includes('deploy') || text.includes('passed') || text.includes('merged')) return 'ok'; if (text.includes('review') || text.includes('sync') || text.includes('handoff')) return 'active'; return 'default'; } function actionLabel(action?: string): string { if (!action) return 'event'; return action.replace(/_/g, ' '); } export default function DashboardPage() { const [sisters, setSisters] = useState([]); const [projects, setProjects] = useState([]); const [activityItems, setActivityItems] = useState([]); const [opsData, setOpsData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const handleSistersUpdate = useCallback((items: SisterItem[]) => { setSisters((prev) => items.map((item) => prev.find((old) => old.name === item.name) ? { ...prev.find((old) => old.name === item.name), ...item } : item)); }, []); const handleActivityNew = useCallback((item: ActivityItem) => setActivityItems((prev) => [item, ...prev].slice(0, 12)), []); const { connected } = useSocket({ onSistersUpdate: handleSistersUpdate, onActivityNew: handleActivityNew }); useEffect(() => { let active = true; const fetchAll = async () => { try { const [sRes, pRes, aRes, dRes] = await Promise.all([ fetch(`${API_URL}/api/sisters`), fetch(`${API_URL}/api/projects`), fetch(`${API_URL}/api/activity?limit=10`), fetch(`${API_URL}/api/dashboard/ops`), ]); if (!active) return; if (!sRes.ok || !pRes.ok || !aRes.ok || !dRes.ok) throw new Error('fetch_failed'); const sistersBase = (await sRes.json()) as SisterItem[]; const projectsData = (await pRes.json()) as ProjectItem[]; const activityData = (await aRes.json()) as { items?: ActivityItem[] }; const dashboardData = (await dRes.json()) as DashboardOpsData; const sistersWithSystem = await Promise.all( sistersBase.map(async (sister) => { try { const sysRes = await fetch(`${API_URL}/api/sisters/${sister.name}/system`); if (!sysRes.ok) return sister; const system = await sysRes.json(); return { ...sister, ...system } as SisterItem; } catch { return sister; } }), ); if (!active) return; setSisters(sistersWithSystem); setProjects(projectsData); setActivityItems(activityData.items ?? []); setOpsData(dashboardData); setError(null); } catch { if (active) setError('대시보드 데이터를 불러오지 못했어.'); } finally { if (active) setLoading(false); } }; fetchAll(); const iv = setInterval(fetchAll, POLL_INTERVAL_MS); return () => { active = false; clearInterval(iv); }; }, []); const derived = useMemo(() => { const freshness = opsData?.freshness ?? { generatedAt: null, activityLatestAt: null, sistersLatestAt: null, qaDocLatestAt: null, }; const onlineCount = sisters.filter((item) => item.status === 'online' || item.status === 'working').length; const offlineCount = sisters.filter((item) => item.status === 'offline').length; const activeProject = opsData?.focusProject ? { ...opsData.focusProject, ownerSister: opsData.focusProject.ownerSister ?? undefined, } : projects.find((project) => ['IMPLEMENT', 'QA', 'READY FOR DEPLOY'].includes(project.phase)) ?? projects[0] ?? null; const totalSprints = projects.reduce((sum, project) => sum + (project.sprintCount ?? project.totalSprints ?? 0), 0); const doneSprints = projects.reduce((sum, project) => sum + (project.doneSprints ?? 0), 0); const reviewProjects = projects.filter((project) => project.phase === 'QA').length; const readyDeploy = projects.filter((project) => project.phase === 'READY FOR DEPLOY').length; const deployed = projects.filter((project) => project.phase === 'DEPLOYED').length; const firstPassBase = projects.filter((project) => project.latestQaStatus === 'passed' || project.latestQaStatus === 'failed'); const firstPassWins = firstPassBase.filter((project) => project.latestQaStatus === 'passed' && (project.blockerCount ?? 0) === 0).length; const firstPassRate = firstPassBase.length ? Math.round((firstPassWins / firstPassBase.length) * 100) : 0; const escalationCount = opsData?.pipeline.escalationCount ?? projects.reduce((sum, project) => sum + ((project.blockerCount ?? 0) > 0 ? 1 : 0), 0); const reviewLoopCount = opsData?.pipeline.reviewLoopCount ?? activityItems.filter((item) => /review|qa/i.test(`${item.action ?? ''} ${item.detail ?? ''}`)).length; const focusSummary = opsData?.pipeline.focus ?? (activeProject ? `${activeProject.name} 기준으로 ${activeProject.phase} 구간을 보고 있어. ${activeProject.currentSprint ? `${activeProject.currentSprint} 진행 중이고,` : ''} deploy 상태는 ${activeProject.deployStatus}야.` : '지금은 활성 프로젝트가 없어서 전체 운영 상태만 조용히 감시 중이야.'); const projectDistribution = [ { label: 'planning', value: projects.filter((project) => project.phase === 'PLANNING').length }, { label: 'implement', value: projects.filter((project) => project.phase === 'IMPLEMENT').length }, { label: 'qa', value: reviewProjects }, { label: 'deploy', value: readyDeploy + deployed }, ]; const pipelineNodes: PipelineNode[] = opsData?.pipeline.nodes ?? [ { id: 'user', label: 'User', role: 'Request / Approval', state: 'idle', detail: '최근 handoff 로그 없음' }, { id: 'harang', label: 'Harang', role: 'Plan & Assign', state: 'idle', detail: '최근 planning 로그 없음' }, { id: 'narang', label: 'Narang', role: 'Implement', state: 'idle', detail: '최근 implementation 로그 없음' }, { id: 'darang', label: 'Darang', role: 'Review / QA', state: 'idle', detail: '최근 QA 로그 없음' }, { id: 'erang', label: 'Irang', role: 'Deploy / Infra', state: 'idle', detail: '최근 deploy 로그 없음' }, ]; const infraCards = [ { title: 'Runtime', meta: connected ? 'SOCKET / EVENT MIRRORED' : 'POLLING SNAPSHOT', detail: `${onlineCount}/${Math.max(sisters.length, 4)} ACTIVE · ${formatSyncLabel(freshness.sistersLatestAt)}`, }, { title: 'Projects', meta: `SNAPSHOT · ${projects.length} PROJECTS`, detail: readyDeploy > 0 ? `${readyDeploy} READY FOR DEPLOY` : 'NO DEPLOY QUEUE', }, { title: 'QA', meta: `DOC-DERIVED · ${firstPassRate}%`, detail: escalationCount > 0 ? `${escalationCount} BLOCKER PROJECT` : 'NO BLOCKER', }, { title: 'Events', meta: `EVENTS ${activityItems.length}`, detail: formatSyncLabel(freshness.activityLatestAt), }, ]; return { onlineCount, offlineCount, activeProject, totalSprints, doneSprints, reviewProjects, readyDeploy, deployed, firstPassRate, escalationCount, reviewLoopCount, focusSummary, projectDistribution, pipelineNodes, infraCards, board: opsData?.board ?? [], activeTask: opsData?.pipeline.activeTask ?? (activeProject ? `${activeProject.name} · ${activeProject.currentSprint ?? activeProject.phase}` : 'NO ACTIVE PIPELINE'), deployState: opsData?.pipeline.deployState ?? (activeProject?.deployStatus ?? 'STANDBY'), freshness, }; }, [activityItems, connected, opsData, projects, sisters]); return ( {error && {error}}
Master dashboard MASTER PIPELINE BOARD
RUNTIME / EVENTS / DOCS {connected ? 'socket on' : 'socket off'} snapshot {formatSyncLabel(derived.freshness.generatedAt)} 0 ? 'warning' : 'default'}>qa {String(derived.reviewProjects).padStart(2, '0')} 0 ? 'active' : 'muted'}>deploy {String(derived.readyDeploy).padStart(2, '0')}
Current focus {derived.activeProject?.name ?? 'NO ACTIVE SPRINT'}
{derived.focusSummary} sum + normalizePercent(project.progress), 0) / projects.length : 0))} /> current sprint {derived.activeProject?.currentSprint ?? 'SNAPSHOT'} phase {derived.activeProject?.phase ?? 'SNAPSHOT'} sprint progress {normalizePercent(derived.activeProject?.progress ?? 0)}% deploy state {derived.activeProject?.deployStatus ?? 'NO SIGNAL'}
Event stream
{connected ? 'SOCKET ON / EVENT MIRRORED' : 'SOCKET OFF / POLLING'}
SYNC:{formatSyncLabel(derived.freshness.activityLatestAt)}
{SISTER_META[derived.activeProject?.ownerSister ?? 'narang']?.label ?? '나랑이'} {SISTER_META[derived.activeProject?.ownerSister ?? 'narang']?.role ?? 'Implementation'} / SNAPSHOT OWNER route Projects route Activity Log route Sisters route Admin
{loading ? Array.from({ length: 4 }).map((_, index) => ) : sisters.length === 0 ? NO RUNTIME DATA. : sisters.map((sister) => { const sisterMeta = SISTER_META[sister.name] ?? { label: sister.name, role: sister.role ?? 'unknown', accent: '#888888' }; return (
{sisterMeta.label} {sisterMeta.role}
{displayStatus(sister.status)}
{buildSisterSummary(sister, activityItems, projects)} CPU{normalizePercent(sister.cpu).toFixed(1)}% RAM{sister.memory?.used ?? 0}/{sister.memory?.total ?? 0}MB last seen {formatRelativeTime(sister.lastSeen)} uptime {sister.uptime ?? '--:--:--'}
); })}
{loading ? ( ) : ( )} Activity feed EVENT MIRROR WEBSOCKET + POLLING SNAPSHOT EVENT:{String(activityItems.length).padStart(2, '0')} {loading ? ( ) : activityItems.length === 0 ? ( NO RECENT EVENT. ) : ( {activityItems.slice(0, 6).map((item) => ( {formatTime(item.createdAt)} {item.sister?.name ? {SISTER_META[item.sister.name]?.label ?? item.sister.name} : 'System'} {' · '} {item.detail ?? actionLabel(item.action)} {actionLabel(item.action)} {item.project?.name && {item.project.name}} {formatRelativeTime(item.createdAt)} ))} )} Sprint metrics SPRINT METRICS POLLING SNAPSHOT SPRINT:{String(derived.totalSprints).padStart(2, '0')} total tasks view {derived.totalSprints} INDEXED SPRINT COUNT done sprint {derived.doneSprints} DONE SPRINTS first-pass rate {derived.firstPassRate}% QA DOC DERIVED escalations {derived.escalationCount} BLOCKER PROJECTS {derived.projectDistribution.map((item) => ( {item.label} {item.value} ))} Infrastructure overview RUNTIME SURFACE LIVE / SNAPSHOT / DOC-DERIVED NODE:{String(sisters.length).padStart(2, '0')} {derived.infraCards.map((card) => ( {card.title} {card.meta} {card.detail} ))} Mistake log & harness RULE / LOG BOARD DOC-DERIVED / ACTIVITY RULE:{String(derived.board.length).padStart(2, '0')} {derived.board.length === 0 ? ( NO RULE LOG. ) : ( {derived.board.map((item) => ( {item.title} {item.category} {item.author} {formatRelativeTime(item.time)} {item.body} SRC {item.source} {formatTime(item.time)} ))} )}
); }