Backend:
- rails.service.ts: getSubTaskDetail(id) - calls rails GET /api/sub-tasks/:id
- rails.controller.ts: GET /api/rails/sub-tasks/:id
Frontend:
- components/rails/SubTaskDetailDrawer.tsx — slide-in drawer
Header: role badge, title, close button (ESC)
Body: state/agent/model/duration/complexity grid,
description, error, result JSON
children list
event log timeline (color-coded by event type)
- components/rails/SubTaskTree.tsx: clickable Node, hover state, onSelectNode prop
- app/rails/page.tsx: detailNodeId state, drawer mount
- app/office/page.tsx: same drawer wired to its sub-tree
ESC key closes drawer. Backdrop click closes.
476 lines
12 KiB
TypeScript
476 lines
12 KiB
TypeScript
'use client';
|
|
|
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
|
import styled from 'styled-components';
|
|
import { API_URL } from '@/lib/config';
|
|
import {
|
|
useRailsSocket,
|
|
type RailsPipelineSummary,
|
|
type RailsSubTaskNode,
|
|
} from '@/lib/useRailsSocket';
|
|
import SubTaskTree from '@/components/rails/SubTaskTree';
|
|
import SubTaskDetailDrawer from '@/components/rails/SubTaskDetailDrawer';
|
|
|
|
const Page = styled.main`
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 28px;
|
|
padding: 32px 36px;
|
|
max-width: 1600px;
|
|
margin: 0 auto;
|
|
width: 100%;
|
|
`;
|
|
|
|
const Header = styled.header`
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: flex-end;
|
|
gap: 24px;
|
|
flex-wrap: wrap;
|
|
`;
|
|
|
|
const TitleBlock = styled.div``;
|
|
|
|
const Title = styled.h1`
|
|
font-size: 28px;
|
|
font-weight: 700;
|
|
margin: 0 0 4px;
|
|
letter-spacing: -0.01em;
|
|
`;
|
|
|
|
const Subtitle = styled.p`
|
|
font-size: 13px;
|
|
color: var(--text-secondary);
|
|
margin: 0;
|
|
`;
|
|
|
|
const Live = styled.span<{ $on: boolean }>`
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
font-size: 11px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.06em;
|
|
color: ${({ $on }) => ($on ? '#22c55e' : 'var(--text-secondary)')};
|
|
|
|
&::before {
|
|
content: '';
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 50%;
|
|
background: ${({ $on }) => ($on ? '#22c55e' : '#525252')};
|
|
box-shadow: ${({ $on }) =>
|
|
$on ? '0 0 0 4px rgba(34, 197, 94, 0.15)' : 'none'};
|
|
}
|
|
`;
|
|
|
|
const StartCard = styled.section`
|
|
background: var(--bg-input);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 16px;
|
|
padding: 24px 28px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 16px;
|
|
`;
|
|
|
|
const StartLabel = styled.div`
|
|
font-size: 11px;
|
|
font-weight: 700;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.08em;
|
|
color: var(--text-secondary);
|
|
`;
|
|
|
|
const StartRow = styled.div`
|
|
display: grid;
|
|
grid-template-columns: minmax(180px, 240px) 1fr auto;
|
|
gap: 12px;
|
|
|
|
@media (max-width: 700px) {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
`;
|
|
|
|
const Input = styled.input`
|
|
padding: 12px 16px;
|
|
background: var(--bg-surface);
|
|
border: 1px solid var(--border-color);
|
|
color: var(--text-primary);
|
|
border-radius: 10px;
|
|
font-size: 14px;
|
|
font-family: var(--font-sans);
|
|
|
|
&::placeholder {
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
&:focus {
|
|
outline: none;
|
|
border-color: #5fafff;
|
|
box-shadow: 0 0 0 3px rgba(95, 175, 255, 0.15);
|
|
}
|
|
`;
|
|
|
|
const StartButton = styled.button`
|
|
padding: 12px 28px;
|
|
background: #5fafff;
|
|
color: #0a0a0a;
|
|
border: none;
|
|
border-radius: 10px;
|
|
font-weight: 700;
|
|
font-size: 14px;
|
|
cursor: pointer;
|
|
transition: opacity 0.15s;
|
|
|
|
&:hover {
|
|
opacity: 0.85;
|
|
}
|
|
|
|
&:disabled {
|
|
opacity: 0.4;
|
|
cursor: not-allowed;
|
|
}
|
|
`;
|
|
|
|
const Layout = styled.div`
|
|
display: grid;
|
|
grid-template-columns: minmax(360px, 440px) 1fr;
|
|
gap: 24px;
|
|
|
|
@media (max-width: 1100px) {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
`;
|
|
|
|
const Pane = styled.section`
|
|
background: var(--bg-input);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 16px;
|
|
padding: 24px 28px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 18px;
|
|
min-height: 420px;
|
|
`;
|
|
|
|
const PaneHeader = styled.div`
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding-bottom: 14px;
|
|
border-bottom: 1px solid var(--border-color);
|
|
`;
|
|
|
|
const PaneTitle = styled.h2`
|
|
font-size: 12px;
|
|
font-weight: 700;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.08em;
|
|
color: var(--text-secondary);
|
|
margin: 0;
|
|
`;
|
|
|
|
const Counter = styled.span`
|
|
font-family: var(--font-mono);
|
|
font-size: 11px;
|
|
color: var(--text-secondary);
|
|
`;
|
|
|
|
const PipelineList = styled.div`
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 12px;
|
|
`;
|
|
|
|
const PipelineCard = styled.button<{ $selected: boolean; $state: string }>`
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
padding: 18px 20px;
|
|
background: ${({ $selected }) =>
|
|
$selected ? 'var(--bg-surface)' : 'transparent'};
|
|
border: 1px solid ${({ $selected }) =>
|
|
$selected ? '#5fafff' : 'var(--border-color)'};
|
|
border-radius: 12px;
|
|
cursor: pointer;
|
|
text-align: left;
|
|
color: var(--text-primary);
|
|
transition: all 0.15s;
|
|
|
|
&:hover {
|
|
border-color: #5fafff;
|
|
background: var(--bg-surface);
|
|
}
|
|
`;
|
|
|
|
const CardTop = styled.div`
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
`;
|
|
|
|
const ProjectName = styled.span`
|
|
font-size: 15px;
|
|
font-weight: 700;
|
|
letter-spacing: -0.01em;
|
|
`;
|
|
|
|
const StateBadge = styled.span<{ $state: string }>`
|
|
padding: 4px 12px;
|
|
font-size: 10px;
|
|
font-weight: 700;
|
|
text-transform: uppercase;
|
|
border-radius: 20px;
|
|
color: #fff;
|
|
background: ${({ $state }) => stateBg($state)};
|
|
letter-spacing: 0.04em;
|
|
flex-shrink: 0;
|
|
`;
|
|
|
|
const CardMeta = styled.div`
|
|
display: flex;
|
|
gap: 12px;
|
|
font-size: 11px;
|
|
color: var(--text-secondary);
|
|
font-family: var(--font-mono);
|
|
`;
|
|
|
|
const Empty = styled.div`
|
|
padding: 60px 20px;
|
|
text-align: center;
|
|
color: var(--text-secondary);
|
|
font-size: 13px;
|
|
`;
|
|
|
|
const DetailHeader = styled.div`
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
padding-bottom: 18px;
|
|
border-bottom: 1px solid var(--border-color);
|
|
`;
|
|
|
|
const DetailTitle = styled.h3`
|
|
font-size: 22px;
|
|
font-weight: 700;
|
|
margin: 0;
|
|
letter-spacing: -0.01em;
|
|
`;
|
|
|
|
const DetailMeta = styled.div`
|
|
display: flex;
|
|
gap: 18px;
|
|
font-size: 12px;
|
|
color: var(--text-secondary);
|
|
font-family: var(--font-mono);
|
|
`;
|
|
|
|
function stateBg(state: string): string {
|
|
switch (state) {
|
|
case 'done':
|
|
return '#22c55e';
|
|
case 'escalated':
|
|
return '#ef4444';
|
|
case 'aborted':
|
|
return '#525252';
|
|
case 'planning':
|
|
case 'implementing':
|
|
case 'reviewing':
|
|
case 'deploying':
|
|
return '#f97316';
|
|
default:
|
|
return '#525252';
|
|
}
|
|
}
|
|
|
|
function relTime(iso: string): string {
|
|
try {
|
|
const ms = Date.now() - new Date(iso).getTime();
|
|
const sec = Math.floor(ms / 1000);
|
|
if (sec < 60) return `${sec}s 전`;
|
|
if (sec < 3600) return `${Math.floor(sec / 60)}m 전`;
|
|
if (sec < 86400) return `${Math.floor(sec / 3600)}h 전`;
|
|
return `${Math.floor(sec / 86400)}d 전`;
|
|
} catch {
|
|
return iso;
|
|
}
|
|
}
|
|
|
|
export default function RailsPage() {
|
|
const [pipelines, setPipelines] = useState<RailsPipelineSummary[]>([]);
|
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
const [tree, setTree] = useState<RailsSubTaskNode[]>([]);
|
|
const [projectInput, setProjectInput] = useState('');
|
|
const [reqInput, setReqInput] = useState('');
|
|
const [starting, setStarting] = useState(false);
|
|
const [detailNodeId, setDetailNodeId] = useState<string | null>(null);
|
|
|
|
const { connected } = useRailsSocket({
|
|
onPipelinesSnapshot: (next) => {
|
|
setPipelines(next);
|
|
setSelectedId((prev) => prev ?? (next[0]?.id ?? null));
|
|
},
|
|
onSubTasksUpdated: (pipelineId, nextTree) => {
|
|
setSelectedId((prev) => {
|
|
if (pipelineId === prev) setTree(nextTree);
|
|
return prev;
|
|
});
|
|
},
|
|
});
|
|
|
|
useEffect(() => {
|
|
fetch(`${API_URL}/api/rails/pipelines`, { credentials: 'include' })
|
|
.then((r) => r.json())
|
|
.then((data: { pipelines: RailsPipelineSummary[] }) => {
|
|
setPipelines(data.pipelines);
|
|
if (data.pipelines.length > 0) setSelectedId(data.pipelines[0]!.id);
|
|
})
|
|
.catch(() => undefined);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!selectedId) return;
|
|
fetch(`${API_URL}/api/rails/pipelines/${selectedId}/sub-tasks`, {
|
|
credentials: 'include',
|
|
})
|
|
.then((r) => r.json())
|
|
.then((data: { tree: RailsSubTaskNode[] }) => setTree(data.tree))
|
|
.catch(() => setTree([]));
|
|
}, [selectedId]);
|
|
|
|
const handleStart = useCallback(async () => {
|
|
if (!projectInput.trim()) return;
|
|
setStarting(true);
|
|
try {
|
|
const res = await fetch(`${API_URL}/api/rails/pipelines/start`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
credentials: 'include',
|
|
body: JSON.stringify({
|
|
project: projectInput.trim(),
|
|
requirements: reqInput.trim(),
|
|
}),
|
|
});
|
|
if (res.ok) {
|
|
const data = (await res.json()) as { pipelineId: string };
|
|
setSelectedId(data.pipelineId);
|
|
setProjectInput('');
|
|
setReqInput('');
|
|
}
|
|
} finally {
|
|
setStarting(false);
|
|
}
|
|
}, [projectInput, reqInput]);
|
|
|
|
const selected = useMemo(
|
|
() => pipelines.find((p) => p.id === selectedId) ?? null,
|
|
[pipelines, selectedId],
|
|
);
|
|
|
|
return (
|
|
<Page>
|
|
<Header>
|
|
<TitleBlock>
|
|
<Title>Rails Orchestrator</Title>
|
|
<Subtitle>결정론적 4자매 파이프라인 관제</Subtitle>
|
|
</TitleBlock>
|
|
<Live $on={connected}>{connected ? 'LIVE' : 'OFFLINE'}</Live>
|
|
</Header>
|
|
|
|
<StartCard>
|
|
<StartLabel>새 파이프라인 시작</StartLabel>
|
|
<StartRow>
|
|
<Input
|
|
placeholder="프로젝트 이름"
|
|
value={projectInput}
|
|
onChange={(e) => setProjectInput(e.target.value)}
|
|
/>
|
|
<Input
|
|
placeholder="요구사항 (예: TODO 앱 MVP, 로그인 추가)"
|
|
value={reqInput}
|
|
onChange={(e) => setReqInput(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') void handleStart();
|
|
}}
|
|
/>
|
|
<StartButton
|
|
disabled={starting || !projectInput.trim()}
|
|
onClick={handleStart}
|
|
>
|
|
{starting ? '시작 중...' : 'Start'}
|
|
</StartButton>
|
|
</StartRow>
|
|
</StartCard>
|
|
|
|
<Layout>
|
|
<Pane>
|
|
<PaneHeader>
|
|
<PaneTitle>Pipelines</PaneTitle>
|
|
<Counter>{pipelines.length}건</Counter>
|
|
</PaneHeader>
|
|
|
|
{pipelines.length === 0 ? (
|
|
<Empty>파이프라인이 없어. 위에서 시작해봐.</Empty>
|
|
) : (
|
|
<PipelineList>
|
|
{pipelines.map((p) => (
|
|
<PipelineCard
|
|
key={p.id}
|
|
$selected={p.id === selectedId}
|
|
$state={p.currentState}
|
|
onClick={() => setSelectedId(p.id)}
|
|
>
|
|
<CardTop>
|
|
<ProjectName>{p.projectName}</ProjectName>
|
|
<StateBadge $state={p.currentState}>
|
|
{p.currentState}
|
|
</StateBadge>
|
|
</CardTop>
|
|
<CardMeta>
|
|
<span>{p.id.slice(0, 12)}</span>
|
|
<span>·</span>
|
|
<span>{relTime(p.updatedAt)}</span>
|
|
</CardMeta>
|
|
</PipelineCard>
|
|
))}
|
|
</PipelineList>
|
|
)}
|
|
</Pane>
|
|
|
|
<Pane>
|
|
{selected ? (
|
|
<>
|
|
<DetailHeader>
|
|
<PaneTitle>Pipeline Detail</PaneTitle>
|
|
<DetailTitle>{selected.projectName}</DetailTitle>
|
|
<DetailMeta>
|
|
<span>{selected.id}</span>
|
|
<span>state: {selected.currentState}</span>
|
|
<span>{new Date(selected.createdAt).toLocaleString('ko-KR')}</span>
|
|
</DetailMeta>
|
|
</DetailHeader>
|
|
|
|
{tree.length > 0 ? (
|
|
<SubTaskTree tree={tree} onSelectNode={setDetailNodeId} />
|
|
) : (
|
|
<Empty>아직 sub-task 가 생성 안 됐어.</Empty>
|
|
)}
|
|
</>
|
|
) : (
|
|
<Empty>왼쪽에서 파이프라인을 선택해.</Empty>
|
|
)}
|
|
</Pane>
|
|
</Layout>
|
|
|
|
{detailNodeId && (
|
|
<SubTaskDetailDrawer
|
|
subTaskId={detailNodeId}
|
|
onClose={() => setDetailNodeId(null)}
|
|
/>
|
|
)}
|
|
</Page>
|
|
);
|
|
}
|