feat(preview): auto-restart dev server on session open; WarmingUpState with elapsed timer
- New POST /api/projects/[id]/dev-server/ensure: checks if dev server is running, queries last known config from fs_dev_servers, fires startDevServer + probeDevServerReadiness in background, returns immediately - Preview pane calls ensure on mount when anatomy is loaded but no server is running - Distinguishes state='running' (show iframe) from state='starting' (show warming-up UI) - WarmingUpState: indigo spinner, elapsed timer, 'View last deployed version' link if available - ensureCalledRef prevents duplicate calls per mount - The 5s anatomy poll handles the starting→running transition automatically
This commit is contained in:
@@ -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<string | null>(null);
|
||||
const iframeDomRef = useRef<HTMLIFrameElement | null>(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 <InitialLoader />;
|
||||
if (inFlightApp) return <BuildingState app={inFlightApp} />;
|
||||
if (failedApp) return <FailedState app={failedApp} />;
|
||||
// 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 (
|
||||
<WarmingUpState
|
||||
startedAt={primaryStarting.startedAt}
|
||||
fallbackUrl={fallbackUrl}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (ensureStatus === "calling" || ensureStatus === "starting") {
|
||||
return <WarmingUpState startedAt={undefined} fallbackUrl={fallbackUrl} />;
|
||||
}
|
||||
// Never had a dev server — needs the AI to start one.
|
||||
return <NotRunningState />;
|
||||
})();
|
||||
|
||||
@@ -144,6 +209,58 @@ function InitialLoader() {
|
||||
);
|
||||
}
|
||||
|
||||
function WarmingUpState({
|
||||
startedAt,
|
||||
fallbackUrl,
|
||||
}: {
|
||||
startedAt: string | undefined;
|
||||
fallbackUrl: string | null;
|
||||
}) {
|
||||
const elapsed = useElapsed(startedAt);
|
||||
|
||||
return (
|
||||
<div style={{ textAlign: "center", maxWidth: 280 }}>
|
||||
<div
|
||||
style={{ display: "flex", justifyContent: "center", marginBottom: 16 }}
|
||||
>
|
||||
<div style={buildRingOuter}>
|
||||
<div style={buildRingInner} className="animate-spin" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p style={{ ...emptyTitle, color: "#6366f1" }}>
|
||||
Dev server warming up
|
||||
{elapsed ? ` · ${elapsed}` : ""}
|
||||
</p>
|
||||
<p style={emptySubtext}>
|
||||
Your preview will appear here automatically once it's ready.
|
||||
Usually under 15 seconds.
|
||||
</p>
|
||||
|
||||
{fallbackUrl && (
|
||||
<a
|
||||
href={fallbackUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
display: "inline-block",
|
||||
marginTop: 14,
|
||||
padding: "5px 12px",
|
||||
borderRadius: 20,
|
||||
background: "#f5f3ff",
|
||||
border: "1px solid #c4b5fd",
|
||||
fontSize: "0.72rem",
|
||||
color: "#7c3aed",
|
||||
textDecoration: "none",
|
||||
}}
|
||||
>
|
||||
View last deployed version →
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BuildingState({
|
||||
app,
|
||||
}: {
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
}>(
|
||||
`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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user