96 lines
3.1 KiB
TypeScript
96 lines
3.1 KiB
TypeScript
|
|
'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>
|
||
|
|
);
|
||
|
|
}
|