Files
hanarang-dashboard/frontend/components/office/OfficeFloor.tsx
이랑이 a7cb728602 feat(dashboard): SIEM log + escalations + office collaboration lines
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)
- 비활성 시 흐릿한 정적 점선
- 화살표 마커로 방향 표시
2026-04-10 18:28:07 +09:00

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>
);
}