diff --git a/vibn-frontend/app/[workspace]/project/[projectId]/(home)/preview/page.tsx b/vibn-frontend/app/[workspace]/project/[projectId]/(home)/preview/page.tsx index 78e9dea..99ea784 100644 --- a/vibn-frontend/app/[workspace]/project/[projectId]/(home)/preview/page.tsx +++ b/vibn-frontend/app/[workspace]/project/[projectId]/(home)/preview/page.tsx @@ -19,12 +19,10 @@ function sandboxIframe(src: string, origin: string): boolean { } } -/** How long a deployment has been running, formatted as "1m 23s" */ +/** Elapsed time since an ISO string, formatted as "1m 23s". */ function useElapsed(sinceIso: string | undefined) { const [elapsed, setElapsed] = useState(""); useEffect(() => { - // No ISO timestamp — nothing to tick. The caller won't render - // the elapsed string when sinceIso is undefined anyway. if (!sinceIso) return; const update = () => { const ms = Date.now() - new Date(sinceIso).getTime(); @@ -47,11 +45,20 @@ export default function PreviewTab() { const params = useParams(); const projectId = params.projectId as string; - // Poll every 5 s so build-state transitions surface without a manual refresh. + // Poll every 5s so state transitions (starting→running, build complete, etc.) + // surface without a manual refresh. const { anatomy, loading } = useAnatomy(projectId, { pollMs: 5000 }); const previews = anatomy?.hosting.previews ?? []; - const primaryPreview = previews.find((p) => p.port === 3000); + + // Only load the iframe for a server that is fully running. + const primaryRunning = previews.find( + (p) => p.port === 3000 && p.state === "running", + ); + // Also track a starting entry so we show the warm-up state instead of blank. + const primaryStarting = !primaryRunning + ? previews.find((p) => p.port === 3000 && p.state === "starting") + : undefined; // Derive in-flight / recently-failed build from prod apps. const liveApps = anatomy?.hosting.live ?? []; @@ -60,6 +67,50 @@ export default function PreviewTab() { ? liveApps.find((a) => a.lastBuild?.status === "failed") : undefined; + // Fallback URL — the last deployed production app. Shown as a link while the + // dev server is warming up so the user has something to interact with. + const fallbackFqdn = + liveApps.find((a) => a.fqdn && a.status === "running")?.fqdn ?? null; + const fallbackUrl = fallbackFqdn + ? fallbackFqdn.startsWith("http") + ? fallbackFqdn + : `https://${fallbackFqdn}` + : null; + + // ── Auto-ensure: fire a background restart when the pane loads and finds + // no running dev server, but there's a previous config to restart from. + const ensureCalledRef = useRef(false); + const [ensureStatus, setEnsureStatus] = useState< + "idle" | "calling" | "starting" | "no_history" | "error" + >("idle"); + + useEffect(() => { + // Only trigger once per mount, and only when anatomy has loaded with no running server. + if (ensureCalledRef.current) return; + if (loading || !anatomy) return; + if (primaryRunning || primaryStarting) return; // already up or already starting + + ensureCalledRef.current = true; + + fetch(`/api/projects/${projectId}/dev-server/ensure`, { + method: "POST", + credentials: "include", + }) + .then((r) => r.json()) + .then((data: { status?: string }) => { + if (data.status === "no_history" || data.status === "no_container") { + setEnsureStatus("no_history"); + } else if (data.status === "starting" || data.status === "running") { + setEnsureStatus("starting"); + // The 5s anatomy poll will pick up the new 'starting' row and + // transition the pane automatically — no extra work needed here. + } else { + setEnsureStatus("idle"); + } + }) + .catch(() => setEnsureStatus("error")); + }, [loading, anatomy, primaryRunning, primaryStarting, projectId]); + const [iframeSrc, setIframeSrc] = useState(null); const iframeDomRef = useRef(null); const bridge = usePreviewBridge(); @@ -69,19 +120,33 @@ export default function PreviewTab() { const refreshKey = usePreviewToolbarStore((s) => s.refreshKey); useLayoutEffect(() => { - setIframeSrc(primaryPreview?.url ?? null); - }, [primaryPreview?.url]); + setIframeSrc(primaryRunning?.url ?? null); + }, [primaryRunning?.url]); useEffect(() => { if (!bridge || !iframeSrc || !iframeDomRef.current) return; bridge.registerPreviewIframe(iframeDomRef.current, iframeSrc); }, [bridge, iframeSrc]); - // Derive content for the empty state. + // Determine which empty state to show. const emptyContent = (() => { if (loading && !anatomy) return ; if (inFlightApp) return ; if (failedApp) return ; + // Dev server is in the process of booting (either picked up from anatomy + // or we just fired the ensure endpoint and are waiting for the DB row). + if (primaryStarting) { + return ( + + ); + } + if (ensureStatus === "calling" || ensureStatus === "starting") { + return ; + } + // Never had a dev server — needs the AI to start one. return ; })(); @@ -144,6 +209,58 @@ function InitialLoader() { ); } +function WarmingUpState({ + startedAt, + fallbackUrl, +}: { + startedAt: string | undefined; + fallbackUrl: string | null; +}) { + const elapsed = useElapsed(startedAt); + + return ( +
+
+
+
+
+
+ +

+ Dev server warming up + {elapsed ? ` · ${elapsed}` : ""} +

+

+ Your preview will appear here automatically once it's ready. + Usually under 15 seconds. +

+ + {fallbackUrl && ( + + View last deployed version → + + )} +
+ ); +} + function BuildingState({ app, }: { diff --git a/vibn-frontend/app/api/projects/[projectId]/dev-server/ensure/route.ts b/vibn-frontend/app/api/projects/[projectId]/dev-server/ensure/route.ts new file mode 100644 index 0000000..baa8221 --- /dev/null +++ b/vibn-frontend/app/api/projects/[projectId]/dev-server/ensure/route.ts @@ -0,0 +1,143 @@ +/** + * POST /api/projects/[projectId]/dev-server/ensure + * + * Lightweight endpoint called by the preview pane when it loads and finds + * no running dev server. Checks for a previous server config and restarts + * it in the background, returning immediately so the UI isn't blocked. + * + * Response shapes: + * { status: 'running', previewUrl } — already up, nothing to do + * { status: 'starting', previewUrl } — was down, restart fired + * { status: 'no_history' } — never started before, AI needs to do it + * { status: 'no_container' } — dev container doesn't exist yet + */ + +import { NextResponse } from "next/server"; +import { authSession } from "@/lib/auth/session-server"; +import { queryOne } from "@/lib/db-postgres"; +import { getWorkspaceById } from "@/lib/workspaces"; +import { + ensureDevContainer, + startDevServer, + probeDevServerReadiness, +} from "@/lib/dev-container"; + +export async function POST( + _req: Request, + { params }: { params: Promise<{ projectId: string }> }, +) { + const { projectId } = await params; + + const session = await authSession(); + if (!session?.user?.email) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + // Load project — verify ownership + const project = await queryOne<{ + id: string; + slug: string; + name: string; + vibn_workspace_id: string | null; + data: Record; + }>( + `SELECT p.id, p.slug, p.name, p.vibn_workspace_id, p.data + FROM fs_projects p + JOIN fs_users u ON u.id = p.user_id + WHERE p.id = $1 AND u.data->>'email' = $2 + LIMIT 1`, + [projectId, session.user.email], + ); + + if (!project) { + return NextResponse.json({ error: "Project not found" }, { status: 404 }); + } + + // 1. Is a dev server already running or starting? + const running = await queryOne<{ + id: string; + state: string; + preview_url: string; + command: string; + port: number; + }>( + `SELECT id, state, preview_url, command, port + FROM fs_dev_servers + WHERE project_id = $1 AND state IN ('running', 'starting') + ORDER BY started_at DESC LIMIT 1`, + [projectId], + ); + + if (running) { + return NextResponse.json({ + status: running.state === "running" ? "running" : "starting", + previewUrl: running.preview_url, + command: running.command, + port: running.port, + }); + } + + // 2. Do we have a previous config to restart from? + const last = await queryOne<{ + command: string; + port: number; + preview_url: string; + }>( + `SELECT command, port, preview_url + FROM fs_dev_servers + WHERE project_id = $1 + ORDER BY started_at DESC LIMIT 1`, + [projectId], + ); + + if (!last) { + return NextResponse.json({ status: "no_history" }); + } + + // 3. Load workspace + if (!project.vibn_workspace_id) { + return NextResponse.json({ status: "no_container" }); + } + + const workspace = await getWorkspaceById(project.vibn_workspace_id); + if (!workspace) { + return NextResponse.json({ status: "no_container" }); + } + + // 4. Fire restart in background — don't block the response. + // The probe (up to 300s) runs in background; anatomy polling at 5s + // will surface state='starting' immediately, then 'running' when ready. + const restartOpts = { + projectId: project.id, + projectSlug: project.slug, + command: last.command, + port: last.port, + workspace, + }; + + void (async () => { + try { + await ensureDevContainer({ + projectId: project.id, + projectSlug: project.slug, + projectName: project.name, + workspace, + }); + const row = await startDevServer(restartOpts); + // Run the readiness probe in background so state transitions + // from 'starting' → 'running' (or 'failed') in the DB. + probeDevServerReadiness(project.id, row.id, row.port).catch((err) => { + console.error("[dev-server/ensure] probe failed:", err?.message); + }); + } catch (err) { + console.error("[dev-server/ensure] restart failed:", err); + } + })(); + + return NextResponse.json({ + status: "starting", + previewUrl: last.preview_url, + command: last.command, + port: last.port, + }); +}