diff --git a/src/app/api/v1/dev-servers/logs/route.ts b/src/app/api/v1/dev-servers/logs/route.ts new file mode 100644 index 0000000..7a85747 --- /dev/null +++ b/src/app/api/v1/dev-servers/logs/route.ts @@ -0,0 +1,91 @@ +import { NextResponse } from "next/server"; +import { stackServerApp } from "@/lib/stack/server"; +import { db } from "@/lib/db/db"; +import { projectsTable } from "@/lib/db/schema"; +import { and, eq } from "drizzle-orm"; +import { freestyleService } from "@/lib/freestyle"; + +export async function POST(request: Request) { + try { + const user = await stackServerApp.getUser(); + + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + if (!body || typeof body !== "object") { + return NextResponse.json( + { error: "Invalid request body" }, + { status: 400 }, + ); + } + + const { projectId, lines, gitRef } = body as { + projectId?: unknown; + lines?: unknown; + gitRef?: unknown; + }; + + if (typeof projectId !== "string" || projectId.length === 0) { + return NextResponse.json( + { error: "Missing or invalid projectId" }, + { status: 400 }, + ); + } + + let resolvedLines: number | null = null; + if (typeof lines === "number") { + if (!Number.isInteger(lines) || lines <= 0) { + return NextResponse.json( + { error: "lines must be a positive integer" }, + { status: 400 }, + ); + } + resolvedLines = lines; + } else if (lines !== null && lines !== undefined) { + return NextResponse.json( + { error: "lines must be a positive integer, null, or undefined" }, + { status: 400 }, + ); + } + + const resolvedGitRef = + typeof gitRef === "string" && gitRef.trim().length > 0 ? gitRef : null; + + const [project] = await db + .select({ + id: projectsTable.id, + repoId: projectsTable.repoId, + }) + .from(projectsTable) + .where( + and(eq(projectsTable.id, projectId), eq(projectsTable.userId, user.id)), + ) + .limit(1); + + if (!project) { + return NextResponse.json({ error: "Project not found" }, { status: 404 }); + } + + const { logs } = await freestyleService.getDevServerLogs({ + repoId: project.repoId, + gitRef: resolvedGitRef, + lines: resolvedLines, + }); + + return NextResponse.json({ logs }); + } catch (error) { + console.error("[API] Error fetching dev server logs:", error); + return NextResponse.json( + { error: "Failed to fetch dev server logs" }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/v1/dev-servers/restart/route.ts b/src/app/api/v1/dev-servers/restart/route.ts new file mode 100644 index 0000000..8739aab --- /dev/null +++ b/src/app/api/v1/dev-servers/restart/route.ts @@ -0,0 +1,73 @@ +import { NextResponse } from "next/server"; +import { stackServerApp } from "@/lib/stack/server"; +import { db } from "@/lib/db/db"; +import { projectsTable } from "@/lib/db/schema"; +import { and, eq } from "drizzle-orm"; +import { freestyleService } from "@/lib/freestyle"; + +export async function POST(request: Request) { + try { + const user = await stackServerApp.getUser(); + + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + if (!body || typeof body !== "object") { + return NextResponse.json( + { error: "Invalid request body" }, + { status: 400 }, + ); + } + + const { projectId, gitRef } = body as { + projectId?: unknown; + gitRef?: unknown; + }; + + if (typeof projectId !== "string" || projectId.length === 0) { + return NextResponse.json( + { error: "Missing or invalid projectId" }, + { status: 400 }, + ); + } + + const resolvedGitRef = + typeof gitRef === "string" && gitRef.trim().length > 0 ? gitRef : null; + + const [project] = await db + .select({ + id: projectsTable.id, + repoId: projectsTable.repoId, + }) + .from(projectsTable) + .where( + and(eq(projectsTable.id, projectId), eq(projectsTable.userId, user.id)), + ) + .limit(1); + + if (!project) { + return NextResponse.json({ error: "Project not found" }, { status: 404 }); + } + + const { restarted } = await freestyleService.restartDevServer({ + repoId: project.repoId, + gitRef: resolvedGitRef, + }); + + return NextResponse.json({ restarted }); + } catch (error) { + console.error("[API] Error restarting dev server:", error); + return NextResponse.json( + { error: "Failed to restart dev server" }, + { status: 500 }, + ); + } +} diff --git a/src/components/dev-server-logs.tsx b/src/components/dev-server-logs.tsx new file mode 100644 index 0000000..a5b7d86 --- /dev/null +++ b/src/components/dev-server-logs.tsx @@ -0,0 +1,241 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { useDevServerData } from "@/components/dev-server-context"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardAction, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyTitle, +} from "@/components/ui/empty"; +import { Spinner } from "@/components/ui/spinner"; +import { RotateCcw } from "lucide-react"; + +interface DevServerLogsProps { + projectId: string; + isActive: boolean; + pollIntervalMs?: number; +} + +const DEFAULT_POLL_INTERVAL_MS = 10_000; + +export function DevServerLogs({ + projectId, + isActive, + pollIntervalMs = DEFAULT_POLL_INTERVAL_MS, +}: DevServerLogsProps) { + const { + devServerUrl, + devCommandRunning, + installCommandRunning, + isLoading: isDevServerStatusesLoading, + } = useDevServerData(); + + const [logs, setLogs] = useState(""); + const [isLoading, setIsLoading] = useState(true); + const [isRefreshing, setIsRefreshing] = useState(false); + const [error, setError] = useState(null); + const hasFetchedRef = useRef(false); + const abortControllerRef = useRef(null); + const logContainerRef = useRef(null); + + const fetchLogs = useCallback(async () => { + if (!projectId) { + return; + } + + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + + const controller = new AbortController(); + abortControllerRef.current = controller; + + if (!hasFetchedRef.current) { + setIsLoading(true); + } else { + setIsRefreshing(true); + } + + try { + setError(null); + + const response = await fetch("/api/v1/dev-servers/logs", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ projectId }), + cache: "no-store", + signal: controller.signal, + }); + + if (!response.ok) { + const message = await response.text(); + throw new Error(message || "Request failed"); + } + + const data: unknown = await response.json(); + if ( + !data || + typeof data !== "object" || + typeof (data as { logs?: unknown }).logs !== "string" + ) { + throw new Error("Unexpected response from server"); + } + + setLogs((data as { logs: string }).logs); + hasFetchedRef.current = true; + } catch (fetchError) { + if (controller.signal.aborted) { + return; + } + + console.error("[DevServerLogs] Failed to fetch logs:", fetchError); + setError("Unable to load dev server logs right now."); + } finally { + if (!controller.signal.aborted) { + setIsLoading(false); + setIsRefreshing(false); + } + } + }, [projectId]); + + useEffect(() => { + if (!isActive) { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + + setIsRefreshing(false); + return; + } + + fetchLogs(); + + const interval = window.setInterval(() => { + fetchLogs(); + }, pollIntervalMs); + + return () => { + window.clearInterval(interval); + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + }; + }, [fetchLogs, isActive, pollIntervalMs]); + + useEffect(() => { + const logElement = logContainerRef.current; + if (!logElement) { + return; + } + + const scrollParent = logElement.parentElement; + if (scrollParent) { + scrollParent.scrollTop = scrollParent.scrollHeight; + } + }, [logs]); + + useEffect(() => { + return () => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + }; + }, []); + + const statusMessage = (() => { + if (isDevServerStatusesLoading) { + return "Checking dev server status…"; + } + + if (!devServerUrl && !devCommandRunning && !installCommandRunning) { + return "Dev server is not running yet. Start it to see logs."; + } + + if (devCommandRunning || installCommandRunning) { + return "Dev server is starting. Logs update automatically."; + } + + return null; + })(); + + const isFetching = isLoading || isRefreshing; + + return ( + + +
+ Latest logs + {statusMessage && {statusMessage}} +
+ + + +
+ + {isLoading && !hasFetchedRef.current ? ( +
+ + Loading logs… +
+ ) : error ? ( +
+ + Failed to load dev server logs + +

{error}

+ +
+
+
+ ) : logs.trim().length === 0 ? ( + + + No logs yet + + We’ll show output from the dev server as soon as it’s available. + + + + ) : ( + +
+ {logs} +
+
+ )} +
+
+ ); +} diff --git a/src/components/project-chat.tsx b/src/components/project-chat.tsx index 64831e0..e7af77e 100644 --- a/src/components/project-chat.tsx +++ b/src/components/project-chat.tsx @@ -14,6 +14,7 @@ import { useProjectData, } from "@/components/project-context"; import { useDevServerData } from "@/components/dev-server-context"; +import { DevServerLogs } from "@/components/dev-server-logs"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; import { @@ -32,11 +33,13 @@ import { ArrowLeft, MoreVertical, ChevronDown, + RotateCcw, } from "lucide-react"; import { ModelSelectorModal } from "@/components/model-selector-modal"; import { useModelSelection } from "@/lib/model-selection/hooks"; import { useEffect, useState } from "react"; import { FreestyleDevServer } from "freestyle-sandboxes/react/dev-server"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { requestDevServer } from "@/actions/preview-actions"; import Link from "next/link"; @@ -56,7 +59,8 @@ const ProjectChatContent = ({ accessToken, }: ProjectChatProps) => { const { currentVersionId } = useProjectData(); - const { devServerUrl, codeServerUrl, deploymentUrl } = useDevServerData(); + const { devServerUrl, codeServerUrl, deploymentUrl, refreshUrls } = + useDevServerData(); const [isDeploying, setIsDeploying] = useState(false); const [runtimeError, setRuntimeError] = useState(null); const { modelSelection, updateModelSelection } = useModelSelection({ @@ -64,12 +68,44 @@ const ProjectChatContent = ({ validatePersonalProvider: true, }); const [isModelSelectorOpen, setIsModelSelectorOpen] = useState(false); + const [isRestartingDevServer, setIsRestartingDevServer] = useState(false); + const [activeDevServerTab, setActiveDevServerTab] = useState< + "preview" | "logs" + >("preview"); // Wrap the action to include projectId const wrappedRequestDevServer = async (args: { repoId: string }) => { return await requestDevServer({ projectId }); }; + const handleRestartDevServer = async () => { + if (isRestartingDevServer) { + return; + } + + setIsRestartingDevServer(true); + try { + const response = await fetch("/api/v1/dev-servers/restart", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ projectId }), + }); + + if (!response.ok) { + const message = await response.text(); + throw new Error(message || "Failed to restart dev server"); + } + + await refreshUrls(); + } catch (error) { + console.error("[ProjectChat] Failed to restart dev server:", error); + } finally { + setIsRestartingDevServer(false); + } + }; + const handleDeploy = async () => { setIsDeploying(true); try { @@ -199,6 +235,17 @@ const ProjectChatContent = ({ View in VS Code )} + + + + {isRestartingDevServer + ? "Restarting Dev Server..." + : "Restart Dev Server"} + + Production @@ -267,7 +314,7 @@ const ProjectChatContent = ({ {isThreadReady && isVersionReady ? ( ) : ( -
+
@@ -279,19 +326,49 @@ const ProjectChatContent = ({ )}
{/* Preview side */} -
+
{isVersionReady ? ( - + + setActiveDevServerTab(value as "preview" | "logs") + } + className="flex h-full min-h-0 flex-col" + > +
+ + Dev Server + Dev Server Logs + +
+ +
+ +
+
+ + + +
) : ( -
-
+
+
Initializing project...
- +
)} diff --git a/src/lib/freestyle.ts b/src/lib/freestyle.ts index 44299e5..00d464f 100644 --- a/src/lib/freestyle.ts +++ b/src/lib/freestyle.ts @@ -15,6 +15,25 @@ interface RequestDevServerParams { environmentVariables: Record; } +interface GetDevServerLogsParams { + repoId: string; + gitRef?: string | null; + lines?: number | null; +} + +interface GetDevServerLogsResponse { + logs: string; +} + +interface RestartDevServerParams { + repoId: string; + gitRef?: string | null; +} + +interface RestartDevServerResponse { + restarted: boolean; +} + interface CommitResponse { commits: Array<{ sha: string; @@ -233,6 +252,105 @@ export class FreestyleService { ); } } + + async getDevServerLogs({ + repoId, + gitRef = null, + lines = null, + }: GetDevServerLogsParams): Promise { + console.log("[Freestyle] Fetching dev server logs for repo:", repoId, { + gitRef, + lines, + }); + + try { + const response = await fetch( + `${this.apiBaseUrl}/ephemeral/v1/dev-servers/logs`, + { + method: "POST", + headers: { + Authorization: `Bearer ${this.apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + devServer: { + repoId, + gitRef, + }, + lines, + }), + }, + ); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + `Failed to fetch dev server logs: ${response.status} ${response.statusText} - ${errorText}`, + ); + } + + const data = (await response.json()) as GetDevServerLogsResponse; + + if (typeof data.logs !== "string") { + throw new Error("Malformed response from Freestyle logs API"); + } + + return data; + } catch (error) { + console.error("[Freestyle] Error fetching dev server logs:", error); + throw new Error( + `Failed to fetch dev server logs: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + async restartDevServer({ + repoId, + gitRef = null, + }: RestartDevServerParams): Promise { + console.log("[Freestyle] Restarting dev server for repo:", repoId, { + gitRef, + }); + + try { + const response = await fetch( + `${this.apiBaseUrl}/ephemeral/v1/dev-servers/restart`, + { + method: "POST", + headers: { + Authorization: `Bearer ${this.apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + devServer: { + repoId, + gitRef, + }, + }), + }, + ); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + `Failed to restart dev server: ${response.status} ${response.statusText} - ${errorText}`, + ); + } + + const data = (await response.json()) as RestartDevServerResponse; + + if (typeof data.restarted !== "boolean") { + throw new Error("Malformed response from Freestyle restart API"); + } + + return data; + } catch (error) { + console.error("[Freestyle] Error restarting dev server:", error); + throw new Error( + `Failed to restart dev server: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } } export const freestyleService = new FreestyleService(