1257 lines
38 KiB
TypeScript
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>
|
|
);
|
|
}
|