Skip to content

Commit 754c54d

Browse files
authored
feat: add deploy button for persistent background app execution (#122)
- Add execCommandInBackground method in sandbox-manager.ts for nohup execution - Add isPortListening and killProcessOnPort methods for app status detection - Create /api/sandbox/[id]/exec endpoint for background command execution - Create /api/sandbox/[id]/app-status endpoint for status check and stop - Add Deploy button in terminal-toolbar with polling-based status detection - Support start/stop toggle with visual feedback (Deploy -> Live -> Stop) Closes #116
1 parent ac78a0c commit 754c54d

File tree

5 files changed

+567
-4
lines changed

5 files changed

+567
-4
lines changed
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
/**
2+
* GET /api/sandbox/[id]/app-status
3+
* Check if application is running (port 3000 listening)
4+
*
5+
* DELETE /api/sandbox/[id]/app-status
6+
* Stop the application (kill process on port 3000)
7+
*/
8+
9+
import { NextResponse } from 'next/server'
10+
11+
import { verifySandboxAccess, withAuth } from '@/lib/api-auth'
12+
import { getK8sServiceForUser } from '@/lib/k8s/k8s-service-helper'
13+
import { logger as baseLogger } from '@/lib/logger'
14+
15+
const logger = baseLogger.child({ module: 'api/sandbox/[id]/app-status' })
16+
const APP_PORT = 3000
17+
18+
export const GET = withAuth<{ running: boolean }>(async (req, context, session) => {
19+
const resolvedParams = await context.params
20+
const sandboxId = Array.isArray(resolvedParams.id) ? resolvedParams.id[0] : resolvedParams.id
21+
22+
try {
23+
const sandbox = await verifySandboxAccess(sandboxId, session.user.id)
24+
const k8sService = await getK8sServiceForUser(session.user.id)
25+
26+
const running = await k8sService.isPortListening(
27+
sandbox.k8sNamespace,
28+
sandbox.sandboxName,
29+
APP_PORT
30+
)
31+
32+
return NextResponse.json({ running })
33+
} catch (error) {
34+
logger.error(`Failed to check app status: ${error}`)
35+
return NextResponse.json({ running: false })
36+
}
37+
})
38+
39+
export const DELETE = withAuth<{ success: boolean; error?: string }>(
40+
async (req, context, session) => {
41+
const resolvedParams = await context.params
42+
const sandboxId = Array.isArray(resolvedParams.id) ? resolvedParams.id[0] : resolvedParams.id
43+
44+
try {
45+
const sandbox = await verifySandboxAccess(sandboxId, session.user.id)
46+
const k8sService = await getK8sServiceForUser(session.user.id)
47+
48+
logger.info(`Stopping app in sandbox ${sandboxId} (${sandbox.sandboxName})`)
49+
50+
const result = await k8sService.killProcessOnPort(
51+
sandbox.k8sNamespace,
52+
sandbox.sandboxName,
53+
APP_PORT
54+
)
55+
56+
if (result.success) {
57+
logger.info(`App stopped in sandbox ${sandboxId}`)
58+
} else {
59+
logger.warn(`Failed to stop app in sandbox ${sandboxId}: ${result.error}`)
60+
}
61+
62+
return NextResponse.json(result)
63+
} catch (error) {
64+
logger.error(`Failed to stop app: ${error}`)
65+
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
66+
return NextResponse.json({ success: false, error: errorMessage }, { status: 500 })
67+
}
68+
}
69+
)

app/api/sandbox/[id]/exec/route.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
/**
2+
* POST /api/sandbox/[id]/exec
3+
*
4+
* Execute a command in the sandbox background.
5+
* Runs command with nohup, returns PID immediately.
6+
*
7+
* Request Body:
8+
* - command: Command to execute (required)
9+
* - workdir: Working directory (optional, default: /home/fulling)
10+
*
11+
* Returns:
12+
* - success: Whether execution was successful
13+
* - pid: Process ID
14+
* - error: Error message if failed
15+
*
16+
* Security:
17+
* - Requires authentication
18+
* - Verifies user owns the sandbox
19+
*/
20+
21+
import { NextResponse } from 'next/server'
22+
23+
import { verifySandboxAccess, withAuth } from '@/lib/api-auth'
24+
import { getK8sServiceForUser } from '@/lib/k8s/k8s-service-helper'
25+
import { logger as baseLogger } from '@/lib/logger'
26+
27+
const logger = baseLogger.child({ module: 'api/sandbox/[id]/exec' })
28+
29+
interface ExecRequestBody {
30+
command: string
31+
workdir?: string
32+
}
33+
34+
interface ExecResponse {
35+
success: boolean
36+
pid?: number
37+
error?: string
38+
}
39+
40+
export const POST = withAuth<ExecResponse>(async (req, context, session) => {
41+
const resolvedParams = await context.params
42+
const sandboxId = Array.isArray(resolvedParams.id) ? resolvedParams.id[0] : resolvedParams.id
43+
44+
try {
45+
// Parse request body
46+
const body: ExecRequestBody = await req.json()
47+
48+
if (!body.command) {
49+
logger.warn(`Missing command in request body for sandbox ${sandboxId}`)
50+
return NextResponse.json({ success: false, error: 'command is required' }, { status: 400 })
51+
}
52+
53+
// Verify user owns this sandbox
54+
const sandbox = await verifySandboxAccess(sandboxId, session.user.id)
55+
56+
logger.info(
57+
`Executing background command in sandbox ${sandboxId} (${sandbox.sandboxName}): "${body.command}"`
58+
)
59+
60+
// Get K8s service for user
61+
const k8sService = await getK8sServiceForUser(session.user.id)
62+
63+
// Execute command in sandbox background
64+
const result = await k8sService.execCommandInBackground(
65+
sandbox.k8sNamespace,
66+
sandbox.sandboxName,
67+
body.command,
68+
body.workdir
69+
)
70+
71+
if (result.success) {
72+
logger.info(`Command started in sandbox ${sandboxId} (PID: ${result.pid})`)
73+
} else {
74+
logger.warn(`Command execution failed in sandbox ${sandboxId}: ${result.error}`)
75+
}
76+
77+
return NextResponse.json(result, { status: result.success ? 200 : 500 })
78+
} catch (error) {
79+
logger.error(`Failed to execute command in sandbox: ${error}`)
80+
81+
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
82+
return NextResponse.json(
83+
{ success: false, error: `Failed to execute command: ${errorMessage}` },
84+
{ status: 500 }
85+
)
86+
}
87+
})

components/terminal/terminal-toolbar.tsx

Lines changed: 154 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,10 @@
66

77
'use client';
88

9-
import { useState } from 'react';
9+
import { useEffect, useState } from 'react';
1010
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';
1213

1314
import {
1415
Dialog,
@@ -72,6 +73,26 @@ export function TerminalToolbar({
7273
const [showNetworkDialog, setShowNetworkDialog] = useState(false);
7374
const [showPassword, setShowPassword] = useState(false);
7475
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]);
7596

7697
// Build network endpoints list, filtering out any without URLs
7798
const allEndpoints = [
@@ -99,6 +120,107 @@ export function TerminalToolbar({
99120
}
100121
};
101122

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+
102224
return (
103225
<>
104226
<div className="h-12 bg-[#2d2d30] border-b border-[#3e3e42] flex items-center justify-between">
@@ -150,10 +272,38 @@ export function TerminalToolbar({
150272
{/* Action Buttons */}
151273
<div className="flex items-center gap-2">
152274
{/* 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">
154276
<div className={cn('h-1.5 w-1.5 rounded-full', getStatusBgClasses(project.status))} />
155277
<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>
157307

158308
{/* Network Button */}
159309
<button

0 commit comments

Comments
 (0)