merge: rails integration into dashboard

This commit is contained in:
2026-04-10 17:30:18 +09:00
17 changed files with 13237 additions and 0 deletions

View File

@@ -0,0 +1 @@
1775809670

View File

@@ -0,0 +1 @@
1 1775809467

7705
backend/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -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],

View File

@@ -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;

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

View 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 {}

View 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
}
}
}
}

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

View File

@@ -0,0 +1 @@
1775809622

View File

@@ -0,0 +1 @@
1 1775809627

243
frontend/app/rails/page.tsx Normal file
View 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>
);
}

View File

@@ -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: '활동' },

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

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

View 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

File diff suppressed because it is too large Load Diff