'use client'; import { useEffect, useRef, useState } from 'react'; import { apiWebSocketURL } from '@/lib/api'; import { cn } from '@/lib/cn'; import type { BuildEvent, BuildStatus } from '@bmm/types'; import { StatusPill } from './status-pill'; export interface StreamingLogsProps { buildId: string; onDone?: (status: BuildStatus, publicUrl: string | null) => void; className?: string; } interface LogLine { level: 'info' | 'warn' | 'error'; message: string; at: string; } export function StreamingLogs({ buildId, onDone, className }: StreamingLogsProps) { const [logs, setLogs] = useState([]); const [status, setStatus] = useState('queued'); const [connected, setConnected] = useState(false); const scrollRef = useRef(null); const onDoneRef = useRef(onDone); onDoneRef.current = onDone; useEffect(() => { const url = apiWebSocketURL(`/v1/builds/${buildId}/stream`); const ws = new WebSocket(url); ws.onopen = () => setConnected(true); ws.onclose = () => setConnected(false); ws.onmessage = (ev) => { try { const evt = JSON.parse(ev.data) as BuildEvent; if (evt.type === 'log') { setLogs((prev) => [...prev, { level: evt.level, message: evt.message, at: evt.at }]); } else if (evt.type === 'status') { setStatus(evt.status); } else if (evt.type === 'done') { setStatus(evt.status); onDoneRef.current?.(evt.status, evt.publicUrl); } else if (evt.type === 'error') { setLogs((prev) => [...prev, { level: 'error', message: evt.message, at: evt.at }]); } } catch {} }; return () => ws.close(); }, [buildId]); useEffect(() => { if (scrollRef.current) { scrollRef.current.scrollTop = scrollRef.current.scrollHeight; } }, [logs.length]); return (
build.log
{connected ? 'ws · connected' : 'ws · …'}
{logs.length === 0 && (
Waiting for build events…
)} {logs.map((l, i) => (
{new Date(l.at).toLocaleTimeString()} {' '} {l.message}
))}
); }