buildmymcpserver/apps/web/components/streaming-logs.tsx

96 lines
3.1 KiB
TypeScript
Raw Normal View History

'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<LogLine[]>([]);
const [status, setStatus] = useState<BuildStatus>('queued');
const [connected, setConnected] = useState(false);
const scrollRef = useRef<HTMLDivElement>(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 (
<div className={cn('panel', className)}>
<div className="flex items-center justify-between border-b border-[--color-border] px-3 py-2">
<div className="flex items-center gap-2">
<span className="mono text-[11px] uppercase tracking-wider text-[--color-fg-subtle]">
build.log
</span>
<StatusPill status={status} />
</div>
<span className="mono text-[10.5px] text-[--color-fg-subtle]">
{connected ? 'ws · connected' : 'ws · …'}
</span>
</div>
<div ref={scrollRef} className="mono max-h-80 overflow-y-auto p-3 text-[12px] leading-relaxed">
{logs.length === 0 && (
<div className="text-[--color-fg-subtle]">Waiting for build events</div>
)}
{logs.map((l, i) => (
<div
key={i}
className={cn(
'whitespace-pre-wrap',
l.level === 'error' && 'text-[--color-danger]',
l.level === 'warn' && 'text-[--color-warn]',
l.level === 'info' && 'text-[--color-fg-muted]',
)}
>
<span className="text-[--color-fg-subtle]">
{new Date(l.at).toLocaleTimeString()}
</span>
{' '}
{l.message}
</div>
))}
</div>
</div>
);
}