B - SIEM 로그 + 경보:
- backend/rails: GET /api/rails/transitions (filter by pipelineId, eventType)
- backend/rails: GET /api/rails/escalations (filter by resolved)
- frontend/app/rails/log/page.tsx — 결정론적 이벤트 스트림
필터: pipelineId / eventType / 초기화
timestamp / event badge / pipeline pill / state transition / 클릭 → 필터링
이벤트 타입별 색상 (REQUEST_CHANGES=주황, ERROR=빨강, 등)
- frontend/app/rails/escalations/page.tsx — 경보 카드 뷰
탭: 전체 / 미해결 / 해결됨
카드: reason, category 태그, attempts, stage, 시간
context snapshot 펼침 (JSON pretty)
- sidebar: 로그 / 경보 메뉴 추가
C - Office collaboration lines:
- OfficeFloor 의 4자매 책상 위에 SVG overlay
- harang→narang→narang→darang→darang→erang 흐름선
- active stage 가 있으면 점선 애니메이션 (flowDash keyframe)
- 비활성 시 흐릿한 정적 점선
- 화살표 마커로 방향 표시
428 lines
11 KiB
TypeScript
428 lines
11 KiB
TypeScript
'use client';
|
|
|
|
import React, { useMemo } from 'react';
|
|
import styled, { keyframes, css } from 'styled-components';
|
|
import type { RailsSubTaskNode, RailsPipelineSummary } from '@/lib/useRailsSocket';
|
|
import SisterAvatar from '@/components/common/SisterAvatar';
|
|
|
|
const SISTERS = [
|
|
{ key: 'harang', label: '하랑', role: 'Planner', color: '#3b82f6', accent: '#60a5fa', x: 0, y: 0 },
|
|
{ key: 'narang', label: '나랑', role: 'Generator', color: '#22c55e', accent: '#4ade80', x: 1, y: 0 },
|
|
{ key: 'darang', label: '다랑', role: 'Evaluator', color: '#f43f5e', accent: '#fb7185', x: 0, y: 1 },
|
|
{ key: 'erang', label: '이랑', role: 'Infra', color: '#f97316', accent: '#fb923c', x: 1, y: 1 },
|
|
] as const;
|
|
|
|
type SisterKey = (typeof SISTERS)[number]['key'];
|
|
|
|
interface OfficeFloorProps {
|
|
pipelines: RailsPipelineSummary[];
|
|
treesByPipeline: Map<string, RailsSubTaskNode[]>;
|
|
selectedSister: SisterKey | null;
|
|
onSelectSister: (key: SisterKey | null) => void;
|
|
}
|
|
|
|
interface SisterStats {
|
|
total: number;
|
|
running: number;
|
|
done: number;
|
|
failed: number;
|
|
models: Set<string>;
|
|
}
|
|
|
|
const Wrap = styled.div`
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 16px;
|
|
`;
|
|
|
|
const Floor = styled.div`
|
|
display: grid;
|
|
grid-template-columns: repeat(2, 1fr);
|
|
grid-template-rows: repeat(2, 1fr);
|
|
gap: 24px;
|
|
background: var(--bg-surface);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 16px;
|
|
padding: 28px;
|
|
min-height: 540px;
|
|
position: relative;
|
|
`;
|
|
|
|
const Overlay = styled.svg`
|
|
position: absolute;
|
|
inset: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
pointer-events: none;
|
|
z-index: 0;
|
|
`;
|
|
|
|
const flowDash = keyframes`
|
|
to { stroke-dashoffset: -8; }
|
|
`;
|
|
|
|
const FlowLine = styled.line<{ $flowing: boolean }>`
|
|
stroke: ${({ $flowing }) =>
|
|
$flowing ? '#5fafff' : 'rgba(95, 175, 255, 0.15)'};
|
|
stroke-width: 0.4;
|
|
stroke-dasharray: ${({ $flowing }) => ($flowing ? '1.6 1.2' : '0.6 0.6')};
|
|
animation: ${({ $flowing }) =>
|
|
$flowing
|
|
? css`
|
|
${flowDash} 1.2s linear infinite
|
|
`
|
|
: 'none'};
|
|
`;
|
|
|
|
const pulse = keyframes`
|
|
0%, 100% { box-shadow: 0 0 0 0 rgba(255, 165, 0, 0.4); }
|
|
50% { box-shadow: 0 0 0 8px rgba(255, 165, 0, 0); }
|
|
`;
|
|
|
|
const blink = keyframes`
|
|
0%, 100% { opacity: 1; }
|
|
50% { opacity: 0.4; }
|
|
`;
|
|
|
|
const Desk = styled.button<{ $color: string; $active: boolean; $running: boolean; $selected: boolean }>`
|
|
position: relative;
|
|
z-index: 1;
|
|
background: ${({ $color }) => `${$color}10`};
|
|
border: 1.5px solid ${({ $color, $selected }) => ($selected ? $color : `${$color}50`)};
|
|
border-radius: 12px;
|
|
padding: 24px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 14px;
|
|
cursor: pointer;
|
|
transition: all 0.2s ease;
|
|
min-height: 220px;
|
|
text-align: left;
|
|
color: var(--text-primary);
|
|
animation: ${({ $running }) => ($running ? pulse : 'none')} 1.6s ease-in-out infinite;
|
|
|
|
&:hover {
|
|
transform: translateY(-2px);
|
|
border-color: ${({ $color }) => $color};
|
|
}
|
|
`;
|
|
|
|
const DeskHead = styled.div`
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
`;
|
|
|
|
const SisterBadge = styled.div<{ $color: string }>`
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
`;
|
|
|
|
const AvatarFrame = styled.div<{ $color: string; $running: boolean }>`
|
|
width: 60px;
|
|
height: 60px;
|
|
border-radius: 50%;
|
|
padding: 3px;
|
|
background: linear-gradient(135deg, ${({ $color }) => $color}, ${({ $color }) => `${$color}60`});
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex-shrink: 0;
|
|
box-shadow: ${({ $color, $running }) =>
|
|
$running ? `0 4px 16px ${$color}60` : `0 2px 8px ${$color}30`};
|
|
`;
|
|
|
|
const NameBlock = styled.div`
|
|
display: flex;
|
|
flex-direction: column;
|
|
`;
|
|
|
|
const Name = styled.span`
|
|
font-size: 18px;
|
|
font-weight: 700;
|
|
`;
|
|
|
|
const Role = styled.span`
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
color: var(--text-secondary);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.06em;
|
|
`;
|
|
|
|
const StatusDot = styled.div<{ $running: boolean }>`
|
|
width: 10px;
|
|
height: 10px;
|
|
border-radius: 50%;
|
|
background: ${({ $running }) => ($running ? '#22c55e' : '#525252')};
|
|
flex-shrink: 0;
|
|
${({ $running }) =>
|
|
$running &&
|
|
`
|
|
box-shadow: 0 0 0 4px rgba(34, 197, 94, 0.2);
|
|
animation: ${blink} 1.4s ease-in-out infinite;
|
|
`}
|
|
`;
|
|
|
|
const Stats = styled.div`
|
|
display: flex;
|
|
gap: 18px;
|
|
padding-top: 12px;
|
|
border-top: 1px dashed var(--border-color);
|
|
`;
|
|
|
|
const Stat = styled.div`
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 2px;
|
|
`;
|
|
|
|
const StatNum = styled.span`
|
|
font-family: var(--font-mono);
|
|
font-size: 20px;
|
|
font-weight: 700;
|
|
`;
|
|
|
|
const StatLabel = styled.span`
|
|
font-size: 10px;
|
|
color: var(--text-secondary);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
`;
|
|
|
|
const Workers = styled.div`
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 6px;
|
|
min-height: 28px;
|
|
`;
|
|
|
|
const Worker = styled.div<{ $role: string; $state: string }>`
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
padding: 3px 8px;
|
|
border-radius: 12px;
|
|
font-size: 10px;
|
|
font-weight: 600;
|
|
background: ${({ $role }) => roleColor($role)}20;
|
|
border: 1px solid ${({ $role }) => roleColor($role)}60;
|
|
color: ${({ $role }) => roleColor($role)};
|
|
${({ $state }) =>
|
|
$state === 'running' &&
|
|
`animation: ${blink} 1.2s ease-in-out infinite;`}
|
|
`;
|
|
|
|
const Empty = styled.span`
|
|
font-size: 11px;
|
|
color: var(--text-secondary);
|
|
font-style: italic;
|
|
`;
|
|
|
|
function roleColor(role: string): string {
|
|
switch (role) {
|
|
case 'manager':
|
|
return '#8b5cf6';
|
|
case 'principal':
|
|
return '#3b82f6';
|
|
case 'lead':
|
|
return '#f97316';
|
|
case 'junior':
|
|
return '#22c55e';
|
|
default:
|
|
return '#6b7280';
|
|
}
|
|
}
|
|
|
|
function flattenWorkers(tree: RailsSubTaskNode[]): RailsSubTaskNode[] {
|
|
const out: RailsSubTaskNode[] = [];
|
|
const walk = (nodes: RailsSubTaskNode[]) => {
|
|
for (const n of nodes) {
|
|
out.push(n);
|
|
if (n.children?.length) walk(n.children);
|
|
}
|
|
};
|
|
walk(tree);
|
|
return out;
|
|
}
|
|
|
|
function statsFor(workers: RailsSubTaskNode[]): SisterStats {
|
|
const stats: SisterStats = {
|
|
total: workers.length,
|
|
running: 0,
|
|
done: 0,
|
|
failed: 0,
|
|
models: new Set(),
|
|
};
|
|
for (const w of workers) {
|
|
if (w.state === 'running' || w.state === 'queued') stats.running += 1;
|
|
else if (w.state === 'done') stats.done += 1;
|
|
else if (w.state === 'failed' || w.state === 'escalated') stats.failed += 1;
|
|
if (w.model) stats.models.add(w.model);
|
|
}
|
|
return stats;
|
|
}
|
|
|
|
export default function OfficeFloor({
|
|
pipelines,
|
|
treesByPipeline,
|
|
selectedSister,
|
|
onSelectSister,
|
|
}: OfficeFloorProps) {
|
|
// Aggregate workers per sister across active pipelines
|
|
const workersBySister = useMemo(() => {
|
|
const map = new Map<SisterKey, RailsSubTaskNode[]>();
|
|
for (const sister of SISTERS) map.set(sister.key, []);
|
|
|
|
const activePipelines = pipelines.filter(
|
|
(p) => !['done', 'aborted'].includes(p.currentState),
|
|
);
|
|
const sourcePipelines = activePipelines.length > 0 ? activePipelines : pipelines.slice(0, 4);
|
|
|
|
for (const pipeline of sourcePipelines) {
|
|
const tree = treesByPipeline.get(pipeline.id) ?? [];
|
|
const flat = flattenWorkers(tree);
|
|
for (const node of flat) {
|
|
const sisterKey = node.agentName as SisterKey;
|
|
const target = map.get(sisterKey);
|
|
if (target) target.push(node);
|
|
}
|
|
}
|
|
return map;
|
|
}, [pipelines, treesByPipeline]);
|
|
|
|
// Determine which stages are currently running for animation
|
|
const activeStages = useMemo(() => {
|
|
const set = new Set<string>();
|
|
for (const sister of SISTERS) {
|
|
const stats = statsFor(workersBySister.get(sister.key) ?? []);
|
|
if (stats.running > 0) set.add(sister.key);
|
|
}
|
|
return set;
|
|
}, [workersBySister]);
|
|
|
|
// SVG flow lines: harang→narang→darang→erang following the pipeline order
|
|
// Coordinates are normalized 0-100 (viewBox 100x100)
|
|
const POS: Record<string, { x: number; y: number }> = {
|
|
harang: { x: 25, y: 25 },
|
|
narang: { x: 75, y: 25 },
|
|
darang: { x: 25, y: 75 },
|
|
erang: { x: 75, y: 75 },
|
|
};
|
|
// Order: plan→implement→review→deploy
|
|
const FLOW: Array<[keyof typeof POS, keyof typeof POS]> = [
|
|
['harang', 'narang'],
|
|
['narang', 'darang'],
|
|
['darang', 'erang'],
|
|
];
|
|
|
|
return (
|
|
<Wrap>
|
|
<Floor>
|
|
<Overlay viewBox="0 0 100 100" preserveAspectRatio="none">
|
|
<defs>
|
|
<marker
|
|
id="arrowhead"
|
|
markerWidth="10"
|
|
markerHeight="10"
|
|
refX="9"
|
|
refY="5"
|
|
orient="auto"
|
|
>
|
|
<polygon points="0 0, 10 5, 0 10" fill="#5fafff" />
|
|
</marker>
|
|
</defs>
|
|
{FLOW.map(([from, to]) => {
|
|
const fromActive = activeStages.has(from);
|
|
const toActive = activeStages.has(to);
|
|
const flowing = fromActive || toActive;
|
|
const a = POS[from]!;
|
|
const b = POS[to]!;
|
|
return (
|
|
<FlowLine
|
|
key={`${from}-${to}`}
|
|
x1={a.x}
|
|
y1={a.y}
|
|
x2={b.x}
|
|
y2={b.y}
|
|
$flowing={flowing}
|
|
markerEnd="url(#arrowhead)"
|
|
/>
|
|
);
|
|
})}
|
|
</Overlay>
|
|
{SISTERS.map((sister) => {
|
|
const workers = workersBySister.get(sister.key) ?? [];
|
|
const stats = statsFor(workers);
|
|
const running = stats.running > 0;
|
|
const isSelected = selectedSister === sister.key;
|
|
|
|
return (
|
|
<Desk
|
|
key={sister.key}
|
|
$color={sister.color}
|
|
$active={stats.total > 0}
|
|
$running={running}
|
|
$selected={isSelected}
|
|
onClick={() => onSelectSister(isSelected ? null : sister.key)}
|
|
>
|
|
<DeskHead>
|
|
<SisterBadge $color={sister.color}>
|
|
<AvatarFrame $color={sister.color} $running={running}>
|
|
<SisterAvatar name={sister.key} size={54} />
|
|
</AvatarFrame>
|
|
<NameBlock>
|
|
<Name>{sister.label}</Name>
|
|
<Role>{sister.role}</Role>
|
|
</NameBlock>
|
|
</SisterBadge>
|
|
<StatusDot $running={running} />
|
|
</DeskHead>
|
|
|
|
<Workers>
|
|
{workers.length === 0 ? (
|
|
<Empty>대기 중</Empty>
|
|
) : (
|
|
workers.slice(0, 12).map((w) => (
|
|
<Worker key={w.id} $role={w.role} $state={w.state}>
|
|
{w.role}
|
|
</Worker>
|
|
))
|
|
)}
|
|
{workers.length > 12 && (
|
|
<Worker $role="" $state="">
|
|
+{workers.length - 12}
|
|
</Worker>
|
|
)}
|
|
</Workers>
|
|
|
|
<Stats>
|
|
<Stat>
|
|
<StatNum>{stats.total}</StatNum>
|
|
<StatLabel>workers</StatLabel>
|
|
</Stat>
|
|
<Stat>
|
|
<StatNum style={{ color: '#22c55e' }}>{stats.running}</StatNum>
|
|
<StatLabel>active</StatLabel>
|
|
</Stat>
|
|
<Stat>
|
|
<StatNum>{stats.done}</StatNum>
|
|
<StatLabel>done</StatLabel>
|
|
</Stat>
|
|
{stats.failed > 0 && (
|
|
<Stat>
|
|
<StatNum style={{ color: '#ef4444' }}>{stats.failed}</StatNum>
|
|
<StatLabel>fail</StatLabel>
|
|
</Stat>
|
|
)}
|
|
</Stats>
|
|
</Desk>
|
|
);
|
|
})}
|
|
</Floor>
|
|
</Wrap>
|
|
);
|
|
}
|