feat(web): dashboard, wizard, server detail, WS build stream, install snippets

This commit is contained in:
Marco Sadjadi
2026-05-19 00:32:53 +02:00
parent f2238f2e6b
commit b07de86db6
8 changed files with 998 additions and 0 deletions

View File

@@ -0,0 +1,48 @@
'use client';
import { useState } from 'react';
import { buildSnippet, type SnippetInput } from '@/lib/install-snippets';
import { CodeBlock } from './code-block';
import { cn } from '@/lib/cn';
import type { InstallTarget } from '@bmm/types';
const TABS: { id: InstallTarget; label: string }[] = [
{ id: 'claude-desktop', label: 'Claude Desktop' },
{ id: 'cursor', label: 'Cursor' },
{ id: 'chatgpt', label: 'ChatGPT' },
];
export function InstallSnippets({ input }: { input: SnippetInput }) {
const [tab, setTab] = useState<InstallTarget>('claude-desktop');
const snippet = buildSnippet(tab, input);
return (
<div>
<div className="flex gap-1 border-b border-[--color-border]">
{TABS.map((t) => (
<button
key={t.id}
type="button"
onClick={() => setTab(t.id)}
className={cn(
'relative px-3 py-2 text-[12.5px] transition-colors duration-200 ease-out',
tab === t.id
? 'text-[--color-fg]'
: 'text-[--color-fg-muted] hover:text-[--color-fg]',
)}
>
{t.label}
{tab === t.id && (
<span className="absolute inset-x-0 -bottom-px h-px bg-[--color-fg]" />
)}
</button>
))}
</div>
<div className="pt-4">
<CodeBlock label={snippet.label} code={snippet.code} />
{snippet.note && (
<p className="mt-2 text-[12px] leading-relaxed text-[--color-fg-muted]">{snippet.note}</p>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,95 @@
'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>
);
}