feat(web): dashboard, wizard, server detail, WS build stream, install snippets
This commit is contained in:
48
apps/web/components/install-snippets.tsx
Normal file
48
apps/web/components/install-snippets.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
95
apps/web/components/streaming-logs.tsx
Normal file
95
apps/web/components/streaming-logs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user