Files
hanarang-dashboard/frontend/app/page.tsx

1257 lines
38 KiB
TypeScript

'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 OperatorGrid = styled.div`
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
`;
const OperatorCard = styled.div`
border: 1px solid var(--border-color);
padding: 12px;
display: flex;
flex-direction: column;
gap: 6px;
`;
const OperatorValue = styled.div`
font-size: 14px;
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<string, { label: string; role: string; accent: string }> = {
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<SisterItem[]>([]);
const [projects, setProjects] = useState<ProjectItem[]>([]);
const [activityItems, setActivityItems] = useState<ActivityItem[]>([]);
const [opsData, setOpsData] = useState<DashboardOpsData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<Shell>
{error && <EmptyState>{error}</EmptyState>}
<TopBar>
<TopBlock>
<div>
<Eyebrow>Master dashboard</Eyebrow>
<Headline>MASTER PIPELINE BOARD</Headline>
</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={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>
</TopBlock>
<FocusPanel>
<div>
<Eyebrow>Current focus</Eyebrow>
<FocusTitle>{derived.activeProject?.name ?? 'NO ACTIVE SPRINT'}</FocusTitle>
</div>
<FocusBody>{derived.focusSummary}</FocusBody>
<TechBar>
<TechBarFill $width={normalizePercent(derived.activeProject?.progress ?? (projects.length ? projects.reduce((sum, project) => sum + normalizePercent(project.progress), 0) / projects.length : 0))} />
</TechBar>
<FocusMetaGrid>
<MetaCard>
<MetaLabel>current sprint</MetaLabel>
<MetaValue>{derived.activeProject?.currentSprint ?? 'SNAPSHOT'}</MetaValue>
</MetaCard>
<MetaCard>
<MetaLabel>phase</MetaLabel>
<MetaValue>{derived.activeProject?.phase ?? 'SNAPSHOT'}</MetaValue>
</MetaCard>
<MetaCard>
<MetaLabel>sprint progress</MetaLabel>
<MetaValue>{normalizePercent(derived.activeProject?.progress ?? 0)}%</MetaValue>
</MetaCard>
<MetaCard>
<MetaLabel>deploy state</MetaLabel>
<MetaValue>{derived.activeProject?.deployStatus ?? 'NO SIGNAL'}</MetaValue>
</MetaCard>
</FocusMetaGrid>
</FocusPanel>
<OperatorPanel>
<ConnectionCard>
<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>
</div>
</div>
<LabelMeta><span>SYNC:</span>{formatSyncLabel(derived.freshness.activityLatestAt)}</LabelMeta>
</ConnectionCard>
<OperatorGrid>
<OperatorCard>
<MetaLabel>focus owner</MetaLabel>
<OperatorValue>{derived.activeProject?.ownerSister ? (SISTER_META[derived.activeProject.ownerSister]?.label ?? derived.activeProject.ownerSister) : 'UNASSIGNED'}</OperatorValue>
<OperatorMeta>SNAPSHOT OWNER</OperatorMeta>
</OperatorCard>
<OperatorCard>
<MetaLabel>runtime lag</MetaLabel>
<OperatorValue>{formatSyncLabel(derived.freshness.sistersLatestAt)}</OperatorValue>
<OperatorMeta>RUNTIME SNAPSHOT</OperatorMeta>
</OperatorCard>
<OperatorCard>
<MetaLabel>activity lag</MetaLabel>
<OperatorValue>{formatSyncLabel(derived.freshness.activityLatestAt)}</OperatorValue>
<OperatorMeta>EVENT MIRROR</OperatorMeta>
</OperatorCard>
<OperatorCard>
<MetaLabel>qa doc lag</MetaLabel>
<OperatorValue>{formatSyncLabel(derived.freshness.qaDocLatestAt)}</OperatorValue>
<OperatorMeta>DOC-DERIVED</OperatorMeta>
</OperatorCard>
</OperatorGrid>
<QuickLinks>
<QuickLink href="/projects">
<QuickLabel>route</QuickLabel>
<QuickTitle>Projects</QuickTitle>
</QuickLink>
<QuickLink href="/activities">
<QuickLabel>route</QuickLabel>
<QuickTitle>Activity Log</QuickTitle>
</QuickLink>
<QuickLink href="/sisters">
<QuickLabel>route</QuickLabel>
<QuickTitle>Sisters</QuickTitle>
</QuickLink>
<QuickLink href="/admin">
<QuickLabel>route</QuickLabel>
<QuickTitle>Admin</QuickTitle>
</QuickLink>
</QuickLinks>
</OperatorPanel>
</TopBar>
<StatusGrid>
{loading
? Array.from({ length: 4 }).map((_, index) => <SkeletonCard key={index} />)
: sisters.length === 0
? <EmptyState>NO RUNTIME DATA.</EmptyState>
: sisters.map((sister) => {
const sisterMeta = SISTER_META[sister.name] ?? { label: sister.name, role: sister.role ?? 'unknown', accent: '#888888' };
return (
<SisterCard key={sister.id ?? sister.name} href={`/sisters/${sister.name}`} $accent={sisterMeta.accent} $muted={sister.status === 'offline'}>
<SisterHeader>
<SisterIdentity>
<SisterAvatar name={sister.name} size={40} />
<div style={{ minWidth: 0 }}>
<SisterName>{sisterMeta.label}</SisterName>
<SisterRole>{sisterMeta.role}</SisterRole>
</div>
</SisterIdentity>
<SisterStatusBadge $accent={sisterMeta.accent} $status={sister.status}>{displayStatus(sister.status)}</SisterStatusBadge>
</SisterHeader>
<SisterSummary>{buildSisterSummary(sister, activityItems, projects)}</SisterSummary>
<ResourceGrid>
<ResourceRow>
<ResourceTop><span>CPU</span><span>{normalizePercent(sister.cpu).toFixed(1)}%</span></ResourceTop>
<TechBar><TechBarFill $width={normalizePercent(sister.cpu)} /></TechBar>
</ResourceRow>
<ResourceRow>
<ResourceTop><span>RAM</span><span>{sister.memory?.used ?? 0}/{sister.memory?.total ?? 0}MB</span></ResourceTop>
<TechBar><TechBarFill $width={memoryPercent(sister.memory)} /></TechBar>
</ResourceRow>
</ResourceGrid>
<FooterMeta>
<FooterMetaBox>
<MetaLabel>last seen</MetaLabel>
<MetaValue>{formatRelativeTime(sister.lastSeen)}</MetaValue>
</FooterMetaBox>
<FooterMetaBox>
<MetaLabel>uptime</MetaLabel>
<MetaValue>{sister.uptime ?? '--:--:--'}</MetaValue>
</FooterMetaBox>
</FooterMeta>
</SisterCard>
);
})}
</StatusGrid>
{loading ? (
<SectionSkeleton />
) : (
<ActivePipeline
activeTask={derived.activeTask}
focus={derived.focusSummary}
reviewLoopCount={derived.reviewLoopCount}
escalationCount={derived.escalationCount}
deployState={derived.deployState}
nodes={derived.pipelineNodes}
/>
)}
<SectionGrid>
<Panel>
<PanelHeader>
<PanelTitleBlock>
<Eyebrow>Activity feed</Eyebrow>
<PanelTitle>EVENT MIRROR</PanelTitle>
<PanelDesc>WEBSOCKET + POLLING SNAPSHOT</PanelDesc>
</PanelTitleBlock>
<LabelMeta><span>EVENT:</span>{String(activityItems.length).padStart(2, '0')}</LabelMeta>
</PanelHeader>
{loading ? (
<SkeletonCard />
) : activityItems.length === 0 ? (
<EmptyState>NO RECENT EVENT.</EmptyState>
) : (
<ActivityList>
{activityItems.slice(0, 6).map((item) => (
<ActivityCard key={item.id}>
<ActivityTime>{formatTime(item.createdAt)}</ActivityTime>
<ActivityBody>
<ActivityHeadline>
{item.sister?.name ? <strong>{SISTER_META[item.sister.name]?.label ?? item.sister.name}</strong> : 'System'}
{' · '}
{item.detail ?? actionLabel(item.action)}
</ActivityHeadline>
<ActivityMeta>
<EventTag $tone={actionTone(item.action, item.detail)}>{actionLabel(item.action)}</EventTag>
{item.project?.name && <EventTag>{item.project.name}</EventTag>}
<EventTag>{formatRelativeTime(item.createdAt)}</EventTag>
</ActivityMeta>
</ActivityBody>
</ActivityCard>
))}
</ActivityList>
)}
</Panel>
<Panel>
<PanelHeader>
<PanelTitleBlock>
<Eyebrow>Sprint metrics</Eyebrow>
<PanelTitle>SPRINT METRICS</PanelTitle>
<PanelDesc>POLLING SNAPSHOT</PanelDesc>
</PanelTitleBlock>
<LabelMeta><span>SPRINT:</span>{String(derived.totalSprints).padStart(2, '0')}</LabelMeta>
</PanelHeader>
<MetricsGrid>
<MetricCard>
<MetaLabel>total tasks view</MetaLabel>
<MetricValue>{derived.totalSprints}</MetricValue>
<MetricHelp>INDEXED SPRINT COUNT</MetricHelp>
</MetricCard>
<MetricCard>
<MetaLabel>done sprint</MetaLabel>
<MetricValue>{derived.doneSprints}</MetricValue>
<MetricHelp>DONE SPRINTS</MetricHelp>
</MetricCard>
<MetricCard>
<MetaLabel>first-pass rate</MetaLabel>
<MetricValue>{derived.firstPassRate}%</MetricValue>
<MetricHelp>QA DOC DERIVED</MetricHelp>
</MetricCard>
<MetricCard>
<MetaLabel>escalations</MetaLabel>
<MetricValue>{derived.escalationCount}</MetricValue>
<MetricHelp>BLOCKER PROJECTS</MetricHelp>
</MetricCard>
</MetricsGrid>
<MetricBars>
{derived.projectDistribution.map((item) => (
<DistRow key={item.label}>
<span>{item.label}</span>
<TechBar><TechBarFill $width={projects.length ? (item.value / projects.length) * 100 : 0} /></TechBar>
<span>{item.value}</span>
</DistRow>
))}
</MetricBars>
</Panel>
</SectionGrid>
<SectionGrid>
<Panel>
<PanelHeader>
<PanelTitleBlock>
<Eyebrow>Infrastructure overview</Eyebrow>
<PanelTitle>RUNTIME SURFACE</PanelTitle>
<PanelDesc>LIVE / SNAPSHOT / DOC-DERIVED</PanelDesc>
</PanelTitleBlock>
<LabelMeta><span>NODE:</span>{String(sisters.length).padStart(2, '0')}</LabelMeta>
</PanelHeader>
<InfraMap>
{derived.infraCards.map((card) => (
<InfraCard key={card.title}>
<InfraTitle>{card.title}</InfraTitle>
<InfraMeta>{card.meta}</InfraMeta>
<EventTag $tone={card.title === 'Runtime' && connected ? 'ok' : card.detail.startsWith('NO ') ? 'default' : 'active'}>{card.detail}</EventTag>
</InfraCard>
))}
</InfraMap>
</Panel>
<Panel>
<PanelHeader>
<PanelTitleBlock>
<Eyebrow>Mistake log & harness</Eyebrow>
<PanelTitle>RULE / LOG BOARD</PanelTitle>
<PanelDesc>DOC-DERIVED / ACTIVITY</PanelDesc>
</PanelTitleBlock>
<LabelMeta><span>RULE:</span>{String(derived.board.length).padStart(2, '0')}</LabelMeta>
</PanelHeader>
{derived.board.length === 0 ? (
<EmptyState>NO RULE LOG.</EmptyState>
) : (
<LessonList>
{derived.board.map((item) => (
<LessonCard key={item.id} $tone={item.tone}>
<LessonTitle>{item.title}</LessonTitle>
<LessonMeta>
<EventTag $tone={item.tone}>{item.category}</EventTag>
<EventTag>{item.author}</EventTag>
<EventTag>{formatRelativeTime(item.time)}</EventTag>
</LessonMeta>
<LessonBody>{item.body}</LessonBody>
<LessonMeta>
<EventTag>SRC</EventTag>
<EventTag>{item.source}</EventTag>
<EventTag>{formatTime(item.time)}</EventTag>
</LessonMeta>
</LessonCard>
))}
</LessonList>
)}
</Panel>
</SectionGrid>
</Shell>
);
}