diff --git a/actions/sandbox.ts b/actions/sandbox.ts new file mode 100644 index 0000000..49653fe --- /dev/null +++ b/actions/sandbox.ts @@ -0,0 +1,65 @@ +'use server' + +/** + * Sandbox Server Actions + * + * Server Actions for sandbox operations. Frontend components call these + * instead of API Routes directly. + * + * TODO: Migrate from app/api/sandbox/: + * - app-status (GET/DELETE) + * - exec (POST) + * - cwd (GET/PUT) + */ + +import { auth } from '@/lib/auth' +import { getSandboxTtydContext } from '@/lib/util/ttyd-context' +import { execCommand, TtydExecError } from '@/lib/util/ttyd-exec' + +import type { ExecResult } from './types' + +/** + * Execute a command in the sandbox and wait for output. + * + * @param sandboxId - The sandbox ID + * @param command - The command to execute + * @param timeoutMs - Optional timeout in milliseconds (default: 30000) + */ +export async function runCommand( + sandboxId: string, + command: string, + timeoutMs?: number +): Promise { + const session = await auth() + + if (!session) { + return { success: false, error: 'Unauthorized' } + } + + try { + const { ttyd } = await getSandboxTtydContext(sandboxId, session.user.id) + const { baseUrl, accessToken, authorization } = ttyd + + const output = await execCommand(baseUrl, accessToken, command, timeoutMs, authorization) + + return { success: true, output } + } catch (error) { + console.error('Failed to execute command in sandbox:', error) + const errorMessage = error instanceof TtydExecError ? error.message : 'Unknown error' + return { success: false, error: errorMessage } + } +} + +/** + * Execute a command in the sandbox without waiting for output. + * + * @param sandboxId - The sandbox ID + * @param command - The command to execute + */ +export async function runCommandDetached( + _sandboxId: string, + _command: string +): Promise { + // TODO: Implement detached command execution + throw new Error('Not implemented') +} diff --git a/lib/actions/sealos-auth.ts b/actions/sealos-auth.ts similarity index 100% rename from lib/actions/sealos-auth.ts rename to actions/sealos-auth.ts diff --git a/actions/types.ts b/actions/types.ts new file mode 100644 index 0000000..b1f8012 --- /dev/null +++ b/actions/types.ts @@ -0,0 +1,13 @@ +/** + * Type definitions for Server Actions + */ + +// ============================================================================= +// Sandbox Actions +// ============================================================================= + +export type ExecResult = { + success: boolean + output?: string + error?: string +} diff --git a/components/home-page.tsx b/components/home-page.tsx index b23f608..849eb21 100644 --- a/components/home-page.tsx +++ b/components/home-page.tsx @@ -6,9 +6,9 @@ import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { useSession } from 'next-auth/react'; +import { authenticateWithSealos } from '@/actions/sealos-auth'; import { MatrixRain } from '@/components/MatrixRain'; import { Button } from '@/components/ui/button'; -import { authenticateWithSealos } from '@/lib/actions/sealos-auth'; import { useSealos } from '@/provider/sealos'; /** diff --git a/components/terminal/terminal-container.tsx b/components/terminal/terminal-container.tsx index 17bb233..e6884c5 100644 --- a/components/terminal/terminal-container.tsx +++ b/components/terminal/terminal-container.tsx @@ -16,8 +16,8 @@ import { useState } from 'react'; import type { Prisma } from '@prisma/client'; +import { type Tab, TerminalToolbar } from './toolbar/toolbar'; import { TerminalDisplay } from './terminal-display'; -import { type Tab, TerminalToolbar } from './terminal-toolbar'; // ============================================================================ // Types diff --git a/components/terminal/toolbar/app-runner-dialog.tsx b/components/terminal/toolbar/app-runner-dialog.tsx new file mode 100644 index 0000000..9da3265 --- /dev/null +++ b/components/terminal/toolbar/app-runner-dialog.tsx @@ -0,0 +1,91 @@ +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; + +interface AppRunnerDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: () => void; + sandboxUrl: string | null | undefined; +} + +export function AppRunnerDialog({ + open, + onOpenChange, + onConfirm, + sandboxUrl, +}: AppRunnerDialogProps) { + return ( + + + + Run Application & Keep Active? + +
+
+ This will build and start your application by running: +
+ + pnpm build && pnpm start + +
+ +
+
+
+ + App runs continuously in the background +
+
+ + Remains active even if you leave this page +
+
+ + + Can be stopped anytime by clicking this button again + +
+
+ + {sandboxUrl && ( +
+
+ Once running, your application will be available at: +
+ + {sandboxUrl} + +
+ )} +
+
+
+
+ + + Cancel + + + Confirm & Run + + +
+
+ ); +} diff --git a/components/terminal/toolbar/app-runner.tsx b/components/terminal/toolbar/app-runner.tsx new file mode 100644 index 0000000..5d06726 --- /dev/null +++ b/components/terminal/toolbar/app-runner.tsx @@ -0,0 +1,97 @@ +'use client'; + +import { useState } from 'react'; +import type { Prisma } from '@prisma/client'; +import { Loader2, Play, Square } from 'lucide-react'; + +import { useAppRunner } from '@/hooks/use-app-runner'; +import { cn } from '@/lib/utils'; + +import { AppRunnerDialog } from './app-runner-dialog'; +import { DirectorySelector } from './directory-selector'; + +type Sandbox = Prisma.SandboxGetPayload; + +interface AppRunnerProps { + sandbox: Sandbox | undefined; +} + +export function AppRunner({ sandbox }: AppRunnerProps) { + const [showStartConfirm, setShowStartConfirm] = useState(false); + const [deployDirectory, setDeployDirectory] = useState('./'); + const { + isStartingApp, + isStoppingApp, + isAppRunning, + startApp, + stopApp, + } = useAppRunner(sandbox?.id, deployDirectory); + + // Toggle app start/stop + const handleToggleApp = () => { + if (isAppRunning) { + stopApp(); + } else { + setShowStartConfirm(true); // Open confirmation modal + } + }; + + const handleConfirmStart = () => { + setShowStartConfirm(false); + startApp(); + }; + + return ( + <> +
+ {/* Directory Selector */} + + + + + {/* Run App Button */} + +
+ + {/* Separator */} +
+ + {/* Confirmation Alert Dialog */} + + + ); +} diff --git a/components/terminal/toolbar/directory-selector.tsx b/components/terminal/toolbar/directory-selector.tsx new file mode 100644 index 0000000..0eeb864 --- /dev/null +++ b/components/terminal/toolbar/directory-selector.tsx @@ -0,0 +1,123 @@ +'use client'; + +import { useEffect,useState } from 'react'; +import { ChevronDown, Folder, Loader2 } from 'lucide-react'; + +import { runCommand } from '@/actions/sandbox'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; + +interface DirectorySelectorProps { + sandboxId?: string; + value?: string; + onChange?: (value: string) => void; +} + +// Directories to exclude from the list +const EXCLUDED_DIRS = [ + 'node_modules', + '.git', + '.next', + '.cache', + 'dist', + 'build', + '.turbo', + '.vercel', +]; + +export function DirectorySelector({ + sandboxId, + value: controlledValue, + onChange, +}: DirectorySelectorProps) { + const [internalValue, setInternalValue] = useState('./'); + const [directories, setDirectories] = useState(['./']); + const [isLoading, setIsLoading] = useState(false); + + const value = controlledValue ?? internalValue; + + // Fetch directories from sandbox on mount + useEffect(() => { + if (!sandboxId) return; + + const fetchDirectories = async () => { + setIsLoading(true); + try { + const result = await runCommand(sandboxId, 'find . -type d -maxdepth 1'); + if (result.success && result.output) { + const dirs = result.output + .split('\n') + .map((dir) => dir.trim()) + .filter((dir) => { + if (!dir) return false; + // Only keep lines that look like directory paths (start with . or /) + if (!dir.startsWith('.') && !dir.startsWith('/')) return false; + // Exclude hidden dirs and common build outputs + return !EXCLUDED_DIRS.some((excluded) => dir.includes(excluded)); + }) + .slice(0, 20); // Limit to 20 directories + + setDirectories(dirs.length > 0 ? dirs : ['./']); + } + } catch (error) { + console.error('Failed to fetch directories:', error); + // Keep default on error + } finally { + setIsLoading(false); + } + }; + + fetchDirectories(); + }, [sandboxId]); + + const handleSelect = (newValue: string) => { + if (onChange) { + onChange(newValue); + } else { + setInternalValue(newValue); + } + }; + + return ( + + + + + + {directories.map((dir) => ( + handleSelect(dir)} + className="text-xs font-mono text-[#cccccc] hover:bg-[#37373d] hover:text-white focus:bg-[#37373d] focus:text-white cursor-pointer" + > + + {dir} + + ))} + + + ); +} diff --git a/components/terminal/terminal-toolbar.tsx b/components/terminal/toolbar/toolbar.tsx similarity index 54% rename from components/terminal/terminal-toolbar.tsx rename to components/terminal/toolbar/toolbar.tsx index 52ae9ed..23523fb 100644 --- a/components/terminal/terminal-toolbar.tsx +++ b/components/terminal/toolbar/toolbar.tsx @@ -8,19 +8,8 @@ import { useEffect, useState } from 'react'; import type { Prisma } from '@prisma/client'; -import { Copy, Eye, EyeOff, Loader2, Network, Play, Plus, Square, Terminal as TerminalIcon, X } from 'lucide-react'; -import { toast } from 'sonner'; +import { Copy, Eye, EyeOff, Network, Plus, Terminal as TerminalIcon, X } from 'lucide-react'; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from '@/components/ui/alert-dialog'; import { Dialog, DialogContent, @@ -30,6 +19,8 @@ import { } from '@/components/ui/dialog'; import { cn } from '@/lib/utils'; +import { AppRunner } from './app-runner'; + type Project = Prisma.ProjectGetPayload<{ include: { sandboxes: true; @@ -79,29 +70,8 @@ export function TerminalToolbar({ fileBrowserCredentials, }: TerminalToolbarProps) { const [showNetworkDialog, setShowNetworkDialog] = useState(false); - const [showStartConfirm, setShowStartConfirm] = useState(false); const [showPassword, setShowPassword] = useState(false); const [copiedField, setCopiedField] = useState(null); - const [isStartingApp, setIsStartingApp] = useState(false); - const [isStoppingApp, setIsStoppingApp] = useState(false); - const [isAppRunning, setIsAppRunning] = useState(false); - - // Check app status on mount - useEffect(() => { - if (!sandbox?.id) return; - - const checkStatus = async () => { - try { - const response = await fetch(`/api/sandbox/${sandbox.id}/app-status`); - const data = await response.json(); - setIsAppRunning(data.running); - } catch (error) { - console.error('Failed to check app status:', error); - } - }; - - checkStatus(); - }, [sandbox?.id]); // Build network endpoints list, filtering out any without URLs const allEndpoints = [ @@ -129,108 +99,6 @@ export function TerminalToolbar({ } }; - // Start application in background - const handleStartApp = async () => { - if (!sandbox?.id || isStartingApp) return; - - setIsStartingApp(true); - setShowStartConfirm(false); // Close modal - - // Send exec command (fire and forget, don't wait for response) - fetch(`/api/sandbox/${sandbox.id}/exec`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - command: 'pnpm run build && pnpm run start', - workdir: '/home/fulling/next', - }), - }).catch(() => { - // Ignore errors, we'll detect success via port polling - }); - - toast.info('Starting...', { - description: 'Building and starting your app. This may take a few minutes.', - }); - - // Poll for app status every 10 seconds, max 5 minutes - const maxAttempts = 30; // 30 * 10s = 5 minutes - let attempts = 0; - - const pollStatus = async (): Promise => { - try { - const response = await fetch(`/api/sandbox/${sandbox.id}/app-status`); - const data = await response.json(); - return data.running; - } catch { - return false; - } - }; - - const poll = async () => { - while (attempts < maxAttempts) { - attempts++; - const running = await pollStatus(); - if (running) { - setIsAppRunning(true); - setIsStartingApp(false); - toast.success('App Running', { - description: 'Your app is live in the background', - }); - return; - } - // Wait 10 seconds before next check - await new Promise((resolve) => setTimeout(resolve, 10000)); - } - - // Timeout after max attempts - setIsStartingApp(false); - toast.error('Start Timeout', { - description: 'App did not start within 5 minutes. Check terminal for errors.', - }); - }; - - poll(); - }; - - // Stop application - const handleStopApp = async () => { - if (!sandbox?.id || isStoppingApp) return; - - setIsStoppingApp(true); - try { - const response = await fetch(`/api/sandbox/${sandbox.id}/app-status`, { - method: 'DELETE', - }); - - const result = await response.json(); - - if (result.success) { - setIsAppRunning(false); - toast.success('App Stopped'); - } else { - toast.error('Stop Failed', { - description: result.error || 'Unknown error', - }); - } - } catch (error) { - console.error('Failed to stop app:', error); - toast.error('Stop Failed', { - description: 'Network error, please try again', - }); - } finally { - setIsStoppingApp(false); - } - }; - - // Toggle app start/stop - const handleToggleApp = () => { - if (isAppRunning) { - handleStopApp(); - } else { - setShowStartConfirm(true); // Open confirmation modal - } - }; - return ( <>
@@ -281,39 +149,7 @@ export function TerminalToolbar({ {/* Action Buttons */}
- {/* Status Badge */} - {/*
-
- {project.status} -
*/} - - {/* Run App Button (was Deploy) */} - + {/* Network Button */}
- {/* Confirmation Alert Dialog */} - - - - Run Application & Keep Active? - -
-
- This will build and start your application by running: -
- pnpm build && pnpm start -
- -
-
-
- - App runs continuously in the background -
-
- - Remains active even if you leave this page -
-
- - Can be stopped anytime by clicking this button again -
-
- - {sandbox?.publicUrl && ( -
-
Once running, your application will be available at:
- - {sandbox.publicUrl} - -
- )} -
-
-
-
- - Cancel - - Confirm & Run - - -
-
- {/* Network Dialog */} diff --git a/hooks/use-app-runner.ts b/hooks/use-app-runner.ts new file mode 100644 index 0000000..8d8ac47 --- /dev/null +++ b/hooks/use-app-runner.ts @@ -0,0 +1,137 @@ +import { useCallback, useEffect, useMemo,useState } from 'react'; +import { toast } from 'sonner'; + +const BASE_DIR = '/home/fulling/next'; + +export function useAppRunner(sandboxId: string | undefined, deployDir: string = './') { + const [isStartingApp, setIsStartingApp] = useState(false); + const [isStoppingApp, setIsStoppingApp] = useState(false); + const [isAppRunning, setIsAppRunning] = useState(false); + + // Calculate workdir based on deployDir + const workdir = useMemo(() => { + if (deployDir === './' || deployDir === '.') { + return BASE_DIR; + } + // Remove leading ./ if present and join with base dir + const relativePath = deployDir.replace(/^\.\//, ''); + return `${BASE_DIR}/${relativePath}`; + }, [deployDir]); + + // Check app status on mount + useEffect(() => { + if (!sandboxId) return; + + const checkStatus = async () => { + try { + const response = await fetch(`/api/sandbox/${sandboxId}/app-status`); + const data = await response.json(); + setIsAppRunning(data.running); + } catch (error) { + console.error('Failed to check app status:', error); + } + }; + + checkStatus(); + }, [sandboxId]); + + // Start application in background + const startApp = useCallback(async () => { + if (!sandboxId || isStartingApp) return; + + setIsStartingApp(true); + + // Send exec command (fire and forget, don't wait for response) + fetch(`/api/sandbox/${sandboxId}/exec`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + command: 'pnpm run build && pnpm run start', + workdir, + }), + }).catch(() => { + // Ignore errors, we'll detect success via port polling + }); + + toast.info('Starting...', { + description: 'Building and starting your app. This may take a few minutes.', + }); + + // Poll for app status every 10 seconds, max 5 minutes + const maxAttempts = 30; // 30 * 10s = 5 minutes + let attempts = 0; + + const pollStatus = async (): Promise => { + try { + const response = await fetch(`/api/sandbox/${sandboxId}/app-status`); + const data = await response.json(); + return data.running; + } catch { + return false; + } + }; + + const poll = async () => { + while (attempts < maxAttempts) { + attempts++; + const running = await pollStatus(); + if (running) { + setIsAppRunning(true); + setIsStartingApp(false); + toast.success('App Running', { + description: 'Your app is live in the background', + }); + return; + } + // Wait 10 seconds before next check + await new Promise((resolve) => setTimeout(resolve, 10000)); + } + + // Timeout after max attempts + setIsStartingApp(false); + toast.error('Start Timeout', { + description: 'App did not start within 5 minutes. Check terminal for errors.', + }); + }; + + poll(); + }, [sandboxId, isStartingApp, workdir]); + + // Stop application + const stopApp = useCallback(async () => { + if (!sandboxId || isStoppingApp) return; + + setIsStoppingApp(true); + try { + const response = await fetch(`/api/sandbox/${sandboxId}/app-status`, { + method: 'DELETE', + }); + + const result = await response.json(); + + if (result.success) { + setIsAppRunning(false); + toast.success('App Stopped'); + } else { + toast.error('Stop Failed', { + description: result.error || 'Unknown error', + }); + } + } catch (error) { + console.error('Failed to stop app:', error); + toast.error('Stop Failed', { + description: 'Network error, please try again', + }); + } finally { + setIsStoppingApp(false); + } + }, [sandboxId, isStoppingApp]); + + return { + isStartingApp, + isStoppingApp, + isAppRunning, + startApp, + stopApp, + }; +} diff --git a/lib/util/ttyd-context.ts b/lib/util/ttyd-context.ts new file mode 100644 index 0000000..5b32d80 --- /dev/null +++ b/lib/util/ttyd-context.ts @@ -0,0 +1,87 @@ +/** + * TTYD Context Utilities + * + * Helper functions to get TTYD connection context for sandboxes. + */ + +import { prisma } from '@/lib/db' + +// ============================================================================= +// Types +// ============================================================================= + +export interface TtydContext { + baseUrl: string + accessToken: string + authorization?: string +} + +export interface SandboxTtydContext { + ttyd: TtydContext + sandbox: Awaited> +} + +// ============================================================================= +// Internal Helpers +// ============================================================================= + +async function getSandboxWithTtyd(sandboxId: string, userId: string) { + const sandbox = await prisma.sandbox.findFirst({ + where: { + id: sandboxId, + project: { + userId: userId, + }, + }, + include: { + project: { + include: { + environments: true, + }, + }, + }, + }) + + if (!sandbox) { + throw new Error('Sandbox not found') + } + + return sandbox +} + +// ============================================================================= +// Public Functions +// ============================================================================= + +/** + * Get TTYD connection context for a sandbox. + * Verifies the sandbox belongs to the specified user. + * + * @param sandboxId - The sandbox ID + * @param userId - The user ID (for ownership verification) + * @returns TTYD connection context with baseUrl, accessToken, and sandbox + */ +export async function getSandboxTtydContext( + sandboxId: string, + userId: string +): Promise { + const sandbox = await getSandboxWithTtyd(sandboxId, userId) + + const accessToken = sandbox.project.environments.find( + (env) => env.key === 'TTYD_ACCESS_TOKEN' + )?.value + + if (!sandbox.ttydUrl || !accessToken) { + throw new Error('Sandbox TTYD not configured') + } + + // Parse the ttydUrl to get base URL (without query params) + const ttydBaseUrl = new URL(sandbox.ttydUrl) + const authorization = ttydBaseUrl.searchParams.get('authorization') || undefined + ttydBaseUrl.search = '' + const baseUrl = ttydBaseUrl.toString().replace(/\/$/, '') + + const ttyd = { baseUrl, accessToken, authorization } + + return { ttyd, sandbox } +}