Files
hanarang-dashboard/frontend/app/rails/page.tsx
이랑이 5ec1287792 feat(rails): node detail drawer — click any sub-task to inspect
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.
2026-04-10 17:59:16 +09:00

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