feat(rails): integrate hanarang-rails orchestrator into dashboard
Backend:
- src/rails/rails.service.ts — HTTP client for rails API (read-only)
- src/rails/rails.controller.ts — REST under /api/rails/* (JwtGuard 보호)
GET /pipelines, /pipelines/:id, /pipelines/:id/sub-tasks, /health
POST /pipelines/start, /pipelines/:id/abort
- src/rails/rails.scheduler.ts — 2초마다 rails poll → EventEmitter2 broadcast
- src/rails/rails.module.ts — module + DI
- events.gateway.ts — rails.* 이벤트 핸들러 3종 (Socket.IO 'rails:*')
- app.module.ts — RailsModule 등록
Frontend:
- lib/useRailsSocket.ts — Socket.IO 훅 (rails:pipelines / pipeline:updated / subtasks)
- components/rails/PipelineList.tsx — 좌측 파이프라인 카드 목록
- components/rails/SubTaskTree.tsx — 우측 계층 트리 뷰
role 별 색상 (manager/principal/lead/junior)
state 별 dot + running pulse 애니메이션
duration / model 표시
- app/rails/page.tsx — 라이브 대시보드 페이지
좌: 파이프라인 목록 + Start 폼
우: 선택된 파이프라인의 sub-task 트리
- components/common/Sidebar.tsx — '레일' 메뉴 추가
기존 hanarang-dashboard 의 UX/테마 그대로 재사용 (CSS variables).
인증은 기존 JwtGuard 그대로 적용.
검증: backend nest build ✓ | frontend next build ✓ (turbopack)
다음: Dev 서버에 배포 + 첫 라이브 파이프라인 구동 확인.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
1
.claude/sessions/.last_inbox_check
Normal file
1
.claude/sessions/.last_inbox_check
Normal file
@@ -0,0 +1 @@
|
||||
1775809670
|
||||
1
backend/.claude/state/tool-failure-counter.txt
Normal file
1
backend/.claude/state/tool-failure-counter.txt
Normal file
@@ -0,0 +1 @@
|
||||
1 1775809467
|
||||
7705
backend/pnpm-lock.yaml
generated
Normal file
7705
backend/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -19,6 +19,7 @@ import { CostsModule } from './costs/costs.module';
|
||||
import { GiteaSyncModule } from './gitea-sync/gitea-sync.module';
|
||||
import { SettingsModule } from './settings/settings.module';
|
||||
import { DashboardModule } from './dashboard/dashboard.module';
|
||||
import { RailsModule } from './rails/rails.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -40,6 +41,7 @@ import { DashboardModule } from './dashboard/dashboard.module';
|
||||
GiteaSyncModule,
|
||||
SettingsModule,
|
||||
DashboardModule,
|
||||
RailsModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
|
||||
@@ -114,6 +114,33 @@ export class EventsGateway
|
||||
this.broadcastActivity(record);
|
||||
}
|
||||
|
||||
// ── Rails orchestrator events ──────────────────────────────────────
|
||||
|
||||
@OnEvent('rails.pipelines.snapshot')
|
||||
handleRailsPipelinesSnapshot(payload: { pipelines: unknown[] }) {
|
||||
this.server.emit('rails:pipelines', {
|
||||
pipelines: payload.pipelines,
|
||||
ts: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
@OnEvent('rails.pipeline.updated')
|
||||
handleRailsPipelineUpdated(payload: { pipeline: unknown }) {
|
||||
this.server.emit('rails:pipeline:updated', {
|
||||
pipeline: payload.pipeline,
|
||||
ts: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
@OnEvent('rails.subtasks.updated')
|
||||
handleRailsSubTasksUpdated(payload: { pipelineId: string; tree: unknown }) {
|
||||
this.server.emit('rails:subtasks', {
|
||||
pipelineId: payload.pipelineId,
|
||||
tree: payload.tree,
|
||||
ts: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
/** 연결된 클라이언트 수 */
|
||||
getClientCount(): number {
|
||||
return this.server?.sockets?.sockets?.size ?? 0;
|
||||
|
||||
67
backend/src/rails/rails.controller.ts
Normal file
67
backend/src/rails/rails.controller.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Param,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { RailsService } from './rails.service';
|
||||
import { JwtGuard } from '../auth/jwt.guard';
|
||||
|
||||
@Controller('api/rails')
|
||||
@UseGuards(JwtGuard)
|
||||
export class RailsController {
|
||||
constructor(private readonly rails: RailsService) {}
|
||||
|
||||
@Get('health')
|
||||
health() {
|
||||
return this.rails.health();
|
||||
}
|
||||
|
||||
@Get('pipelines')
|
||||
async listPipelines(@Query('limit') limit?: string) {
|
||||
const n = limit ? parseInt(limit, 10) : 20;
|
||||
const pipelines = await this.rails.listPipelines(Number.isFinite(n) ? n : 20);
|
||||
return { pipelines };
|
||||
}
|
||||
|
||||
@Get('pipelines/:id')
|
||||
async pipelineDetail(@Param('id') id: string) {
|
||||
const detail = await this.rails.getPipeline(id);
|
||||
if (!detail) {
|
||||
throw new HttpException('pipeline not found', HttpStatus.NOT_FOUND);
|
||||
}
|
||||
return detail;
|
||||
}
|
||||
|
||||
@Get('pipelines/:id/sub-tasks')
|
||||
async subTaskTree(@Param('id') id: string) {
|
||||
const tree = await this.rails.getSubTaskTree(id);
|
||||
return { pipelineId: id, tree };
|
||||
}
|
||||
|
||||
@Post('pipelines/start')
|
||||
async start(
|
||||
@Body() body: { project: string; requirements: string },
|
||||
) {
|
||||
if (!body?.project || typeof body.project !== 'string') {
|
||||
throw new HttpException('project required', HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
return this.rails.startPipeline({
|
||||
project: body.project,
|
||||
requirements: body.requirements ?? '',
|
||||
});
|
||||
}
|
||||
|
||||
@Post('pipelines/:id/abort')
|
||||
async abort(
|
||||
@Param('id') id: string,
|
||||
@Body() body: { reason?: string },
|
||||
) {
|
||||
return this.rails.abortPipeline(id, body?.reason ?? 'aborted via dashboard');
|
||||
}
|
||||
}
|
||||
14
backend/src/rails/rails.module.ts
Normal file
14
backend/src/rails/rails.module.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { RailsService } from './rails.service';
|
||||
import { RailsController } from './rails.controller';
|
||||
import { RailsScheduler } from './rails.scheduler';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
|
||||
@Module({
|
||||
imports: [ConfigModule, AuthModule],
|
||||
controllers: [RailsController],
|
||||
providers: [RailsService, RailsScheduler],
|
||||
exports: [RailsService],
|
||||
})
|
||||
export class RailsModule {}
|
||||
84
backend/src/rails/rails.scheduler.ts
Normal file
84
backend/src/rails/rails.scheduler.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { RailsService, type RailsPipelineSummary } from './rails.service';
|
||||
|
||||
/**
|
||||
* Polls rails every N seconds for active pipelines and emits events
|
||||
* that the EventsGateway broadcasts over Socket.IO.
|
||||
*
|
||||
* Events emitted (via EventEmitter2):
|
||||
* rails.pipeline.updated — single pipeline changed state
|
||||
* rails.pipelines.snapshot — full list snapshot
|
||||
* rails.subtasks.updated — sub-task tree for an active pipeline
|
||||
*/
|
||||
@Injectable()
|
||||
export class RailsScheduler implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(RailsScheduler.name);
|
||||
private readonly intervalMs = 2000;
|
||||
private timer: NodeJS.Timeout | null = null;
|
||||
private lastSnapshot = new Map<string, string>(); // id → state
|
||||
private activePipelines = new Set<string>();
|
||||
|
||||
constructor(
|
||||
private readonly rails: RailsService,
|
||||
private readonly emitter: EventEmitter2,
|
||||
) {}
|
||||
|
||||
onModuleInit(): void {
|
||||
this.logger.log(`Rails poller starting (interval ${this.intervalMs}ms)`);
|
||||
this.start();
|
||||
}
|
||||
|
||||
onModuleDestroy(): void {
|
||||
if (this.timer) clearInterval(this.timer);
|
||||
}
|
||||
|
||||
private start(): void {
|
||||
this.timer = setInterval(() => {
|
||||
this.tick().catch((err) => {
|
||||
this.logger.warn(`poll error: ${(err as Error).message}`);
|
||||
});
|
||||
}, this.intervalMs);
|
||||
}
|
||||
|
||||
private async tick(): Promise<void> {
|
||||
const pipelines = await this.rails.listPipelines(50);
|
||||
|
||||
// Detect changes
|
||||
const changed: RailsPipelineSummary[] = [];
|
||||
for (const p of pipelines) {
|
||||
const last = this.lastSnapshot.get(p.id);
|
||||
if (last !== p.currentState) {
|
||||
changed.push(p);
|
||||
this.lastSnapshot.set(p.id, p.currentState);
|
||||
}
|
||||
// Track active (non-terminal)
|
||||
if (!['done', 'aborted'].includes(p.currentState)) {
|
||||
this.activePipelines.add(p.id);
|
||||
} else {
|
||||
this.activePipelines.delete(p.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Emit full snapshot every tick (cheap, dashboards love fresh data)
|
||||
this.emitter.emit('rails.pipelines.snapshot', { pipelines });
|
||||
|
||||
// Emit per-pipeline updates for changed ones
|
||||
for (const p of changed) {
|
||||
this.emitter.emit('rails.pipeline.updated', { pipeline: p });
|
||||
}
|
||||
|
||||
// Fetch sub-task trees for active pipelines (throttled)
|
||||
for (const id of this.activePipelines) {
|
||||
try {
|
||||
const tree = await this.rails.getSubTaskTree(id);
|
||||
this.emitter.emit('rails.subtasks.updated', {
|
||||
pipelineId: id,
|
||||
tree,
|
||||
});
|
||||
} catch {
|
||||
// ignore transient errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
134
backend/src/rails/rails.service.ts
Normal file
134
backend/src/rails/rails.service.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
export interface RailsPipelineSummary {
|
||||
id: string;
|
||||
projectName: string;
|
||||
currentState: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface RailsSubTaskNode {
|
||||
id: string;
|
||||
parentId: string | null;
|
||||
role: string;
|
||||
agentName: string;
|
||||
title: string;
|
||||
state: string;
|
||||
complexityScore: number | null;
|
||||
complexityTier: string | null;
|
||||
model: string;
|
||||
startedAt: string | null;
|
||||
completedAt: string | null;
|
||||
createdAt: string;
|
||||
children: RailsSubTaskNode[];
|
||||
}
|
||||
|
||||
export interface RailsPipelineDetail {
|
||||
state: string;
|
||||
context: Record<string, unknown>;
|
||||
transitions: Array<{
|
||||
fromState: string;
|
||||
toState: string;
|
||||
eventType: string;
|
||||
timestamp: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Thin HTTP client that reads from hanarang-rails orchestrator API.
|
||||
* The dashboard is a read-only consumer — it never writes to rails DB directly.
|
||||
*/
|
||||
@Injectable()
|
||||
export class RailsService {
|
||||
private readonly logger = new Logger(RailsService.name);
|
||||
private readonly baseUrl: string;
|
||||
|
||||
constructor(private readonly config: ConfigService) {
|
||||
this.baseUrl = this.config.get<string>('RAILS_API_URL') ?? 'http://127.0.0.1:18800';
|
||||
}
|
||||
|
||||
async listPipelines(limit = 20): Promise<RailsPipelineSummary[]> {
|
||||
const data = await this.fetchJson<{ pipelines: RailsPipelineSummary[] }>(
|
||||
`/pipelines?limit=${limit}`,
|
||||
);
|
||||
return data.pipelines ?? [];
|
||||
}
|
||||
|
||||
async getPipeline(id: string): Promise<RailsPipelineDetail | null> {
|
||||
try {
|
||||
return await this.fetchJson<RailsPipelineDetail>(`/pipelines/${id}`);
|
||||
} catch (err) {
|
||||
this.logger.warn(`pipeline ${id} fetch failed: ${(err as Error).message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getSubTaskTree(pipelineId: string): Promise<RailsSubTaskNode[]> {
|
||||
const data = await this.fetchJson<{ tree: RailsSubTaskNode[] }>(
|
||||
`/api/pipelines/${pipelineId}/sub-tasks`,
|
||||
);
|
||||
return data.tree ?? [];
|
||||
}
|
||||
|
||||
async startPipeline(input: {
|
||||
project: string;
|
||||
requirements: string;
|
||||
}): Promise<{ pipelineId: string; finalState: string; transitions: number }> {
|
||||
return this.postJson('/pipelines/start', input);
|
||||
}
|
||||
|
||||
async abortPipeline(id: string, reason: string): Promise<{ id: string; state: string }> {
|
||||
return this.postJson(`/pipelines/${id}/abort`, { reason });
|
||||
}
|
||||
|
||||
async health(): Promise<{ ok: boolean; service?: string }> {
|
||||
try {
|
||||
return await this.fetchJson('/health');
|
||||
} catch {
|
||||
return { ok: false };
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchJson<T>(path: string): Promise<T> {
|
||||
const url = `${this.baseUrl}${path}`;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 8000);
|
||||
try {
|
||||
const res = await fetch(url, { signal: controller.signal });
|
||||
clearTimeout(timer);
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`rails GET ${path} → ${res.status}: ${text.slice(0, 200)}`);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
} catch (err) {
|
||||
clearTimeout(timer);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private async postJson<T>(path: string, body: unknown): Promise<T> {
|
||||
const url = `${this.baseUrl}${path}`;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 600_000);
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timer);
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`rails POST ${path} → ${res.status}: ${text.slice(0, 200)}`);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
} catch (err) {
|
||||
clearTimeout(timer);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
1
frontend/.claude/sessions/.last_inbox_check
Normal file
1
frontend/.claude/sessions/.last_inbox_check
Normal file
@@ -0,0 +1 @@
|
||||
1775809622
|
||||
1
frontend/.claude/state/tool-failure-counter.txt
Normal file
1
frontend/.claude/state/tool-failure-counter.txt
Normal file
@@ -0,0 +1 @@
|
||||
1 1775809627
|
||||
243
frontend/app/rails/page.tsx
Normal file
243
frontend/app/rails/page.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
'use client';
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { API_URL } from '@/lib/config';
|
||||
import {
|
||||
useRailsSocket,
|
||||
type RailsPipelineSummary,
|
||||
type RailsSubTaskNode,
|
||||
} from '@/lib/useRailsSocket';
|
||||
import PipelineList from '@/components/rails/PipelineList';
|
||||
import SubTaskTree from '@/components/rails/SubTaskTree';
|
||||
import { LabelMeta } from '@/components/ui/base';
|
||||
|
||||
const Layout = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: minmax(320px, 380px) 1fr;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
min-height: calc(100vh - 120px);
|
||||
|
||||
@media (max-width: 900px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
`;
|
||||
|
||||
const Pane = styled.section`
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
overflow: auto;
|
||||
`;
|
||||
|
||||
const PaneHeader = styled.header`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
`;
|
||||
|
||||
const Dot = styled.span<{ $connected: boolean }>`
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-right: 6px;
|
||||
background: ${({ $connected }) => ($connected ? '#22c55e' : '#6b7280')};
|
||||
`;
|
||||
|
||||
const StartBar = styled.div`
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
`;
|
||||
|
||||
const Input = styled.input`
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #5fafff;
|
||||
}
|
||||
`;
|
||||
|
||||
const Button = styled.button`
|
||||
padding: 8px 16px;
|
||||
background: #5fafff;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
`;
|
||||
|
||||
const DetailHeader = styled.div`
|
||||
padding: 8px 0 12px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
margin-bottom: 12px;
|
||||
`;
|
||||
|
||||
const Meta = styled.div`
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
margin-top: 4px;
|
||||
`;
|
||||
|
||||
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 { connected } = useRailsSocket({
|
||||
onPipelinesSnapshot: (next) => {
|
||||
setPipelines(next);
|
||||
if (!selectedId && next.length > 0) {
|
||||
setSelectedId(next[0]!.id);
|
||||
}
|
||||
},
|
||||
onSubTasksUpdated: (pipelineId, nextTree) => {
|
||||
if (pipelineId === selectedId) {
|
||||
setTree(nextTree);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Initial fetch
|
||||
useEffect(() => {
|
||||
fetch(`${API_URL}/api/rails/pipelines`, { credentials: 'include' })
|
||||
.then((r) => r.json())
|
||||
.then((data: { pipelines: RailsPipelineSummary[] }) => {
|
||||
setPipelines(data.pipelines);
|
||||
if (!selectedId && data.pipelines.length > 0) {
|
||||
setSelectedId(data.pipelines[0]!.id);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
/* ignore */
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// When selection changes, fetch tree once
|
||||
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 = pipelines.find((p) => p.id === selectedId) ?? null;
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<Pane>
|
||||
<PaneHeader>
|
||||
<LabelMeta>
|
||||
<Dot $connected={connected} />
|
||||
PIPELINES
|
||||
</LabelMeta>
|
||||
<span style={{ fontSize: 11, opacity: 0.6 }}>
|
||||
{pipelines.length} total
|
||||
</span>
|
||||
</PaneHeader>
|
||||
|
||||
<StartBar>
|
||||
<Input
|
||||
placeholder="project"
|
||||
value={projectInput}
|
||||
onChange={(e) => setProjectInput(e.target.value)}
|
||||
/>
|
||||
</StartBar>
|
||||
<StartBar>
|
||||
<Input
|
||||
placeholder="requirements..."
|
||||
value={reqInput}
|
||||
onChange={(e) => setReqInput(e.target.value)}
|
||||
/>
|
||||
<Button disabled={starting || !projectInput} onClick={handleStart}>
|
||||
Start
|
||||
</Button>
|
||||
</StartBar>
|
||||
|
||||
<PipelineList
|
||||
pipelines={pipelines}
|
||||
selectedId={selectedId}
|
||||
onSelect={setSelectedId}
|
||||
/>
|
||||
</Pane>
|
||||
|
||||
<Pane>
|
||||
{selected ? (
|
||||
<>
|
||||
<DetailHeader>
|
||||
<LabelMeta>PIPELINE DETAIL</LabelMeta>
|
||||
<div style={{ fontWeight: 700, fontSize: 18, marginTop: 4 }}>
|
||||
{selected.projectName}
|
||||
</div>
|
||||
<Meta>
|
||||
<span>id: {selected.id}</span>
|
||||
<span>state: {selected.currentState}</span>
|
||||
<span>created: {new Date(selected.createdAt).toLocaleString()}</span>
|
||||
</Meta>
|
||||
</DetailHeader>
|
||||
|
||||
<SubTaskTree tree={tree} />
|
||||
</>
|
||||
) : (
|
||||
<div style={{ opacity: 0.6, padding: 40, textAlign: 'center' }}>
|
||||
Select a pipeline to view its sub-task tree.
|
||||
</div>
|
||||
)}
|
||||
</Pane>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { useAuth } from '@/lib/AuthContext';
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ href: '/', label: '대시' },
|
||||
{ href: '/rails', label: '레일' },
|
||||
{ href: '/office', label: '오피스' },
|
||||
{ href: '/projects', label: '프로' },
|
||||
{ href: '/activities', label: '활동' },
|
||||
|
||||
118
frontend/components/rails/PipelineList.tsx
Normal file
118
frontend/components/rails/PipelineList.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import type { RailsPipelineSummary } from '@/lib/useRailsSocket';
|
||||
|
||||
const Wrap = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
const Card = styled.button<{ $active: boolean }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
background: ${({ $active }) =>
|
||||
$active ? 'var(--bg-input)' : 'var(--bg-surface)'};
|
||||
border: 1px solid ${({ $active }) =>
|
||||
$active ? '#5fafff' : 'var(--border-color)'};
|
||||
border-radius: 8px;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
border-color: #5fafff;
|
||||
}
|
||||
`;
|
||||
|
||||
const StateBadge = styled.span<{ $state: string }>`
|
||||
display: inline-block;
|
||||
padding: 2px 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
border-radius: 12px;
|
||||
color: #fff;
|
||||
background: ${({ $state }) => {
|
||||
switch ($state) {
|
||||
case 'done':
|
||||
return '#22c55e';
|
||||
case 'escalated':
|
||||
return '#ef4444';
|
||||
case 'aborted':
|
||||
return '#6b7280';
|
||||
case 'planning':
|
||||
case 'implementing':
|
||||
case 'reviewing':
|
||||
case 'deploying':
|
||||
return '#f97316';
|
||||
default:
|
||||
return '#6b7280';
|
||||
}
|
||||
}};
|
||||
`;
|
||||
|
||||
const Id = styled.span`
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 11px;
|
||||
opacity: 0.6;
|
||||
`;
|
||||
|
||||
const Name = styled.span`
|
||||
font-weight: 600;
|
||||
flex: 1;
|
||||
`;
|
||||
|
||||
const Time = styled.span`
|
||||
font-size: 11px;
|
||||
opacity: 0.6;
|
||||
`;
|
||||
|
||||
interface Props {
|
||||
pipelines: RailsPipelineSummary[];
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
}
|
||||
|
||||
export default function PipelineList({ pipelines, selectedId, onSelect }: Props) {
|
||||
if (pipelines.length === 0) {
|
||||
return <Wrap>No pipelines yet.</Wrap>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Wrap>
|
||||
{pipelines.map((p) => (
|
||||
<Card
|
||||
key={p.id}
|
||||
$active={p.id === selectedId}
|
||||
onClick={() => onSelect(p.id)}
|
||||
>
|
||||
<Id>{p.id.slice(0, 8)}</Id>
|
||||
<Name>{p.projectName}</Name>
|
||||
<StateBadge $state={p.currentState}>{p.currentState}</StateBadge>
|
||||
<Time>{formatTime(p.updatedAt)}</Time>
|
||||
</Card>
|
||||
))}
|
||||
</Wrap>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
const now = Date.now();
|
||||
const diffMs = now - d.getTime();
|
||||
const sec = Math.floor(diffMs / 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;
|
||||
}
|
||||
}
|
||||
156
frontend/components/rails/SubTaskTree.tsx
Normal file
156
frontend/components/rails/SubTaskTree.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import type { RailsSubTaskNode } from '@/lib/useRailsSocket';
|
||||
|
||||
const Wrap = styled.div`
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
`;
|
||||
|
||||
const Node = styled.div<{ $state: string }>`
|
||||
padding: 4px 8px;
|
||||
border-left: 3px solid ${({ $state }) => stateColor($state)};
|
||||
margin: 2px 0;
|
||||
background: var(--bg-surface);
|
||||
border-radius: 4px;
|
||||
`;
|
||||
|
||||
const RoleBadge = styled.span<{ $role: string }>`
|
||||
display: inline-block;
|
||||
padding: 1px 8px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
border-radius: 10px;
|
||||
color: #fff;
|
||||
margin-right: 8px;
|
||||
background: ${({ $role }) => roleColor($role)};
|
||||
`;
|
||||
|
||||
const StateDot = styled.span<{ $state: string }>`
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-right: 8px;
|
||||
background: ${({ $state }) => stateColor($state)};
|
||||
animation: ${({ $state }) => ($state === 'running' ? 'pulse 1.2s infinite' : 'none')};
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
`;
|
||||
|
||||
const Model = styled.span`
|
||||
font-size: 10px;
|
||||
opacity: 0.6;
|
||||
margin-left: 8px;
|
||||
`;
|
||||
|
||||
const Duration = styled.span`
|
||||
font-size: 10px;
|
||||
opacity: 0.6;
|
||||
margin-left: auto;
|
||||
`;
|
||||
|
||||
const Row = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const Children = styled.div`
|
||||
margin-left: 24px;
|
||||
`;
|
||||
|
||||
function stateColor(state: string): string {
|
||||
switch (state) {
|
||||
case 'done':
|
||||
return '#22c55e';
|
||||
case 'running':
|
||||
return '#f97316';
|
||||
case 'failed':
|
||||
return '#ef4444';
|
||||
case 'escalated':
|
||||
return '#ef4444';
|
||||
case 'queued':
|
||||
return '#6b7280';
|
||||
default:
|
||||
return '#333333';
|
||||
}
|
||||
}
|
||||
|
||||
function roleColor(role: string): string {
|
||||
switch (role) {
|
||||
case 'manager':
|
||||
return '#8b5cf6';
|
||||
case 'principal':
|
||||
return '#3b82f6';
|
||||
case 'lead':
|
||||
return '#f97316';
|
||||
case 'junior':
|
||||
return '#6b7280';
|
||||
default:
|
||||
return '#6b7280';
|
||||
}
|
||||
}
|
||||
|
||||
function duration(startedAt: string | null, completedAt: string | null): string {
|
||||
if (!startedAt) return '—';
|
||||
const start = new Date(startedAt).getTime();
|
||||
const end = completedAt ? new Date(completedAt).getTime() : Date.now();
|
||||
const ms = end - start;
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
||||
return `${Math.floor(ms / 60000)}m ${Math.floor((ms % 60000) / 1000)}s`;
|
||||
}
|
||||
|
||||
function NodeRow({ node }: { node: RailsSubTaskNode }) {
|
||||
return (
|
||||
<>
|
||||
<Node $state={node.state}>
|
||||
<Row>
|
||||
<StateDot $state={node.state} />
|
||||
<RoleBadge $role={node.role}>{node.role}</RoleBadge>
|
||||
<span>{node.title.slice(0, 80)}</span>
|
||||
<Model>{node.model || '—'}</Model>
|
||||
<Duration>{duration(node.startedAt, node.completedAt)}</Duration>
|
||||
</Row>
|
||||
{node.complexityTier && (
|
||||
<Row>
|
||||
<span style={{ marginLeft: 22, fontSize: 10, opacity: 0.6 }}>
|
||||
complexity: {node.complexityTier} ({node.complexityScore})
|
||||
</span>
|
||||
</Row>
|
||||
)}
|
||||
</Node>
|
||||
{node.children.length > 0 && (
|
||||
<Children>
|
||||
{node.children.map((c) => (
|
||||
<NodeRow key={c.id} node={c} />
|
||||
))}
|
||||
</Children>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
tree: RailsSubTaskNode[];
|
||||
}
|
||||
|
||||
export default function SubTaskTree({ tree }: Props) {
|
||||
if (tree.length === 0) {
|
||||
return <Wrap>No sub-tasks yet.</Wrap>;
|
||||
}
|
||||
return (
|
||||
<Wrap>
|
||||
{tree.map((node) => (
|
||||
<NodeRow key={node.id} node={node} />
|
||||
))}
|
||||
</Wrap>
|
||||
);
|
||||
}
|
||||
93
frontend/lib/useRailsSocket.ts
Normal file
93
frontend/lib/useRailsSocket.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { io, Socket } from 'socket.io-client';
|
||||
|
||||
export interface RailsPipelineSummary {
|
||||
id: string;
|
||||
projectName: string;
|
||||
currentState: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface RailsSubTaskNode {
|
||||
id: string;
|
||||
parentId: string | null;
|
||||
role: string;
|
||||
agentName: string;
|
||||
title: string;
|
||||
state: string;
|
||||
complexityScore: number | null;
|
||||
complexityTier: string | null;
|
||||
model: string;
|
||||
startedAt: string | null;
|
||||
completedAt: string | null;
|
||||
createdAt: string;
|
||||
children: RailsSubTaskNode[];
|
||||
}
|
||||
|
||||
interface UseRailsSocketOptions {
|
||||
onPipelinesSnapshot?: (pipelines: RailsPipelineSummary[]) => void;
|
||||
onPipelineUpdated?: (pipeline: RailsPipelineSummary) => void;
|
||||
onSubTasksUpdated?: (pipelineId: string, tree: RailsSubTaskNode[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Listens to rails.* events emitted by the backend EventsGateway.
|
||||
* Piggybacks on the existing /ws namespace — no new socket connection.
|
||||
*/
|
||||
export function useRailsSocket(options: UseRailsSocketOptions = {}) {
|
||||
const socketRef = useRef<Socket | null>(null);
|
||||
const [connected, setConnected] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const wsUrl = process.env.NEXT_PUBLIC_WS_URL ?? window.location.origin;
|
||||
const token =
|
||||
typeof window !== 'undefined'
|
||||
? localStorage.getItem('hanarang_access_token') ?? ''
|
||||
: '';
|
||||
|
||||
const socket = io(`${wsUrl}/ws`, {
|
||||
path: '/socket.io',
|
||||
transports: ['websocket', 'polling'],
|
||||
reconnectionAttempts: 5,
|
||||
reconnectionDelay: 3000,
|
||||
auth: { token },
|
||||
});
|
||||
|
||||
socket.on('connect', () => setConnected(true));
|
||||
socket.on('disconnect', () => setConnected(false));
|
||||
|
||||
if (options.onPipelinesSnapshot) {
|
||||
socket.on('rails:pipelines', (payload: { pipelines: RailsPipelineSummary[] }) => {
|
||||
options.onPipelinesSnapshot?.(payload.pipelines);
|
||||
});
|
||||
}
|
||||
|
||||
if (options.onPipelineUpdated) {
|
||||
socket.on('rails:pipeline:updated', (payload: { pipeline: RailsPipelineSummary }) => {
|
||||
options.onPipelineUpdated?.(payload.pipeline);
|
||||
});
|
||||
}
|
||||
|
||||
if (options.onSubTasksUpdated) {
|
||||
socket.on(
|
||||
'rails:subtasks',
|
||||
(payload: { pipelineId: string; tree: RailsSubTaskNode[] }) => {
|
||||
options.onSubTasksUpdated?.(payload.pipelineId, payload.tree);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
socketRef.current = socket;
|
||||
|
||||
return () => {
|
||||
socket.disconnect();
|
||||
socketRef.current = null;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return { connected, socket: socketRef.current };
|
||||
}
|
||||
4589
frontend/pnpm-lock.yaml
generated
Normal file
4589
frontend/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user