|
6 | 6 |
|
7 | 7 | 'use client'; |
8 | 8 |
|
9 | | -import { useState } from 'react'; |
| 9 | +import { useEffect, useState } from 'react'; |
10 | 10 | import type { Prisma } from '@prisma/client'; |
11 | | -import { Copy, Eye, EyeOff, Network, Plus, Terminal as TerminalIcon, X } from 'lucide-react'; |
| 11 | +import { Copy, Eye, EyeOff, Loader2, Network, Play, Plus, Square, Terminal as TerminalIcon, X } from 'lucide-react'; |
| 12 | +import { toast } from 'sonner'; |
12 | 13 |
|
13 | 14 | import { |
14 | 15 | Dialog, |
@@ -72,6 +73,26 @@ export function TerminalToolbar({ |
72 | 73 | const [showNetworkDialog, setShowNetworkDialog] = useState(false); |
73 | 74 | const [showPassword, setShowPassword] = useState(false); |
74 | 75 | const [copiedField, setCopiedField] = useState<string | null>(null); |
| 76 | + const [isStartingApp, setIsStartingApp] = useState(false); |
| 77 | + const [isStoppingApp, setIsStoppingApp] = useState(false); |
| 78 | + const [isAppRunning, setIsAppRunning] = useState(false); |
| 79 | + |
| 80 | + // Check app status on mount |
| 81 | + useEffect(() => { |
| 82 | + if (!sandbox?.id) return; |
| 83 | + |
| 84 | + const checkStatus = async () => { |
| 85 | + try { |
| 86 | + const response = await fetch(`/api/sandbox/${sandbox.id}/app-status`); |
| 87 | + const data = await response.json(); |
| 88 | + setIsAppRunning(data.running); |
| 89 | + } catch (error) { |
| 90 | + console.error('Failed to check app status:', error); |
| 91 | + } |
| 92 | + }; |
| 93 | + |
| 94 | + checkStatus(); |
| 95 | + }, [sandbox?.id]); |
75 | 96 |
|
76 | 97 | // Build network endpoints list, filtering out any without URLs |
77 | 98 | const allEndpoints = [ |
@@ -99,6 +120,107 @@ export function TerminalToolbar({ |
99 | 120 | } |
100 | 121 | }; |
101 | 122 |
|
| 123 | + // Start application in background |
| 124 | + const handleStartApp = async () => { |
| 125 | + if (!sandbox?.id || isStartingApp) return; |
| 126 | + |
| 127 | + setIsStartingApp(true); |
| 128 | + |
| 129 | + // Send exec command (fire and forget, don't wait for response) |
| 130 | + fetch(`/api/sandbox/${sandbox.id}/exec`, { |
| 131 | + method: 'POST', |
| 132 | + headers: { 'Content-Type': 'application/json' }, |
| 133 | + body: JSON.stringify({ |
| 134 | + command: 'pnpm run build && pnpm run start', |
| 135 | + workdir: '/home/fulling/next', |
| 136 | + }), |
| 137 | + }).catch(() => { |
| 138 | + // Ignore errors, we'll detect success via port polling |
| 139 | + }); |
| 140 | + |
| 141 | + toast.info('Deploying...', { |
| 142 | + description: 'Building and starting your app. This may take a few minutes.', |
| 143 | + }); |
| 144 | + |
| 145 | + // Poll for app status every 10 seconds, max 5 minutes |
| 146 | + const maxAttempts = 30; // 30 * 10s = 5 minutes |
| 147 | + let attempts = 0; |
| 148 | + |
| 149 | + const pollStatus = async (): Promise<boolean> => { |
| 150 | + try { |
| 151 | + const response = await fetch(`/api/sandbox/${sandbox.id}/app-status`); |
| 152 | + const data = await response.json(); |
| 153 | + return data.running; |
| 154 | + } catch { |
| 155 | + return false; |
| 156 | + } |
| 157 | + }; |
| 158 | + |
| 159 | + const poll = async () => { |
| 160 | + while (attempts < maxAttempts) { |
| 161 | + attempts++; |
| 162 | + const running = await pollStatus(); |
| 163 | + if (running) { |
| 164 | + setIsAppRunning(true); |
| 165 | + setIsStartingApp(false); |
| 166 | + toast.success('Deployed', { |
| 167 | + description: 'Your app is now live', |
| 168 | + }); |
| 169 | + return; |
| 170 | + } |
| 171 | + // Wait 10 seconds before next check |
| 172 | + await new Promise((resolve) => setTimeout(resolve, 10000)); |
| 173 | + } |
| 174 | + |
| 175 | + // Timeout after max attempts |
| 176 | + setIsStartingApp(false); |
| 177 | + toast.error('Deploy Timeout', { |
| 178 | + description: 'App did not start within 5 minutes. Check terminal for errors.', |
| 179 | + }); |
| 180 | + }; |
| 181 | + |
| 182 | + poll(); |
| 183 | + }; |
| 184 | + |
| 185 | + // Stop application |
| 186 | + const handleStopApp = async () => { |
| 187 | + if (!sandbox?.id || isStoppingApp) return; |
| 188 | + |
| 189 | + setIsStoppingApp(true); |
| 190 | + try { |
| 191 | + const response = await fetch(`/api/sandbox/${sandbox.id}/app-status`, { |
| 192 | + method: 'DELETE', |
| 193 | + }); |
| 194 | + |
| 195 | + const result = await response.json(); |
| 196 | + |
| 197 | + if (result.success) { |
| 198 | + setIsAppRunning(false); |
| 199 | + toast.success('App Stopped'); |
| 200 | + } else { |
| 201 | + toast.error('Stop Failed', { |
| 202 | + description: result.error || 'Unknown error', |
| 203 | + }); |
| 204 | + } |
| 205 | + } catch (error) { |
| 206 | + console.error('Failed to stop app:', error); |
| 207 | + toast.error('Stop Failed', { |
| 208 | + description: 'Network error, please try again', |
| 209 | + }); |
| 210 | + } finally { |
| 211 | + setIsStoppingApp(false); |
| 212 | + } |
| 213 | + }; |
| 214 | + |
| 215 | + // Toggle app start/stop |
| 216 | + const handleToggleApp = () => { |
| 217 | + if (isAppRunning) { |
| 218 | + handleStopApp(); |
| 219 | + } else { |
| 220 | + handleStartApp(); |
| 221 | + } |
| 222 | + }; |
| 223 | + |
102 | 224 | return ( |
103 | 225 | <> |
104 | 226 | <div className="h-12 bg-[#2d2d30] border-b border-[#3e3e42] flex items-center justify-between"> |
@@ -150,10 +272,38 @@ export function TerminalToolbar({ |
150 | 272 | {/* Action Buttons */} |
151 | 273 | <div className="flex items-center gap-2"> |
152 | 274 | {/* Status Badge */} |
153 | | - <div className="flex items-center gap-1.5 px-2 py-1 text-xs text-gray-300"> |
| 275 | + {/* <div className="flex items-center gap-1.5 px-2 py-1 text-xs text-gray-300"> |
154 | 276 | <div className={cn('h-1.5 w-1.5 rounded-full', getStatusBgClasses(project.status))} /> |
155 | 277 | <span>{project.status}</span> |
156 | | - </div> |
| 278 | + </div> */} |
| 279 | + |
| 280 | + {/* Deploy Button */} |
| 281 | + <button |
| 282 | + onClick={handleToggleApp} |
| 283 | + disabled={isStartingApp || isStoppingApp || !sandbox} |
| 284 | + className={cn( |
| 285 | + 'px-2 py-1 text-xs rounded transition-colors flex items-center gap-1 disabled:cursor-not-allowed', |
| 286 | + isAppRunning |
| 287 | + ? 'text-green-400 hover:text-red-400 hover:bg-red-400/10 bg-green-400/10' |
| 288 | + : 'text-gray-300 hover:text-white hover:bg-[#37373d] disabled:opacity-50' |
| 289 | + )} |
| 290 | + title={ |
| 291 | + isAppRunning |
| 292 | + ? 'Click to stop. Your app will no longer be accessible.' |
| 293 | + : 'Build and run your app in production mode. It will keep running even if you close this terminal.' |
| 294 | + } |
| 295 | + > |
| 296 | + {isStartingApp || isStoppingApp ? ( |
| 297 | + <Loader2 className="h-3 w-3 animate-spin" /> |
| 298 | + ) : isAppRunning ? ( |
| 299 | + <Square className="h-3 w-3" /> |
| 300 | + ) : ( |
| 301 | + <Play className="h-3 w-3" /> |
| 302 | + )} |
| 303 | + <span> |
| 304 | + {isStartingApp ? 'Deploying...' : isStoppingApp ? 'Stopping...' : isAppRunning ? 'Live' : 'Deploy'} |
| 305 | + </span> |
| 306 | + </button> |
157 | 307 |
|
158 | 308 | {/* Network Button */} |
159 | 309 | <button |
|
0 commit comments