-
Notifications
You must be signed in to change notification settings - Fork 67
Adds Terminal to Sandbox View #348
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 25 commits
a21aeaf
c954aee
171563e
dc9d1e9
7ec924d
ae2b295
e104b6c
0efbfb4
0672776
b6af83c
c4257da
5fcd614
9195326
e61ed34
2c9d146
56ae8bf
2b38566
19faacd
2165839
9d6b490
3c962d0
2777f17
d6f7003
5ce2360
320df06
3f4a7f7
e1a2b96
8d9f1dd
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,51 @@ | ||
| import 'server-cli-only' | ||
|
|
||
| import Sandbox from 'e2b' | ||
| import { z } from 'zod' | ||
| import { SUPABASE_AUTH_HEADERS } from '@/configs/api' | ||
| import { auth } from '@/core/server/auth' | ||
| import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' | ||
|
|
||
| const BodySchema = z.object({ | ||
| pid: z.number().int().positive(), | ||
| sandboxId: z.string().min(1), | ||
| teamId: z.string().min(1), | ||
| }) | ||
|
|
||
| export async function POST(request: Request) { | ||
| try { | ||
| const parsedBody = BodySchema.safeParse(await request.json()) | ||
|
|
||
| if (!parsedBody.success) { | ||
| return Response.json({ error: 'Invalid request' }, { status: 400 }) | ||
| } | ||
|
|
||
| const authContext = await auth.getAuthContext() | ||
|
|
||
| if (!authContext) { | ||
| return Response.json({ error: 'Unauthenticated' }, { status: 401 }) | ||
| } | ||
|
|
||
| const { pid, sandboxId, teamId } = parsedBody.data | ||
| const sandbox = await Sandbox.connect(sandboxId, { | ||
| domain: process.env.NEXT_PUBLIC_E2B_DOMAIN, | ||
| headers: { | ||
| ...SUPABASE_AUTH_HEADERS(authContext.accessToken, teamId), | ||
| }, | ||
| }) | ||
|
|
||
| await sandbox.pty.kill(pid) | ||
|
|
||
| return Response.json({ ok: true }) | ||
| } catch (error) { | ||
| l.error( | ||
| { | ||
| key: 'terminal_pty_kill_route:unexpected_error', | ||
| error: serializeErrorForLog(error), | ||
| }, | ||
| `${error instanceof Error ? error.message : 'Failed to kill terminal PTY'}` | ||
| ) | ||
|
|
||
| return Response.json({ error: 'Internal server error' }, { status: 500 }) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| 'use client' | ||
|
|
||
| import { DashboardRouteError } from '@/features/dashboard/shared/route-error' | ||
|
|
||
| export default function SandboxTerminalPageError({ | ||
| error, | ||
| reset, | ||
| }: { | ||
| error: Error & { digest?: string } | ||
| reset: () => void | ||
| }) { | ||
| return <DashboardRouteError error={error} reset={reset} /> | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export { default } from '@/features/dashboard/loading-layout' |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| import SandboxTerminalView from '@/features/dashboard/sandbox/terminal/view' | ||
|
|
||
| export default function SandboxTerminalPage() { | ||
| return <SandboxTerminalView /> | ||
| } | ||
|
cursor[bot] marked this conversation as resolved.
|
||
This file was deleted.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,32 +1,49 @@ | ||
| 'use client' | ||
|
|
||
| import Sandbox from 'e2b' | ||
| import { useParams, useRouter } from 'next/navigation' | ||
| import type { ReactNode } from 'react' | ||
| import { useCallback, useEffect, useState, useTransition } from 'react' | ||
| import { PROTECTED_URLS } from '@/configs/urls' | ||
| import { SUPABASE_AUTH_HEADERS } from '@/configs/api' | ||
| import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls' | ||
| import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' | ||
| import { useSandboxInspectAnalytics } from '@/lib/hooks/use-analytics' | ||
| import { supabase } from '@/core/shared/clients/supabase/client' | ||
| import { useDashboard } from '@/features/dashboard/context' | ||
| import { cn } from '@/lib/utils' | ||
| import { Button } from '@/ui/primitives/button' | ||
| import { | ||
| ArrowLeftIcon, | ||
| ArrowUpIcon, | ||
| HomeIcon, | ||
| RefreshIcon, | ||
| RunningIcon, | ||
| } from '@/ui/primitives/icons' | ||
| import { useSandboxContext } from '../context' | ||
| import SandboxInspectEmptyFrame from './empty' | ||
|
|
||
| export default function SandboxInspectNotFound() { | ||
| const SANDBOX_RESUME_TIMEOUT_MS = 5 * 60 * 1000 | ||
|
|
||
| interface SandboxInspectNotFoundProps { | ||
| onResumeSandbox?: () => void | ||
| resource?: 'filesystem' | 'terminal' | ||
| } | ||
|
|
||
| export default function SandboxInspectNotFound({ | ||
| onResumeSandbox, | ||
| resource = 'filesystem', | ||
| }: SandboxInspectNotFoundProps) { | ||
| const router = useRouter() | ||
| const { isRunning } = useSandboxContext() | ||
| const { team } = useDashboard() | ||
| const { isRunning, sandboxInfo, refetchSandboxInfo } = useSandboxContext() | ||
|
|
||
| const { teamSlug } = useParams() | ||
|
|
||
| const [pendingPath, setPendingPath] = useState<string | undefined>(undefined) | ||
| const [isResumePending, setIsResumePending] = useState(false) | ||
| const [isPending, startTransition] = useTransition() | ||
| const [isResetPending, resetTransition] = useTransition() | ||
|
|
||
| const save = async (newPath: string) => { | ||
| const save = useCallback(async (newPath: string) => { | ||
| try { | ||
| await fetch('/api/sandbox/inspect/root-path', { | ||
| method: 'POST', | ||
|
|
@@ -42,7 +59,7 @@ export default function SandboxInspectNotFound() { | |
| `${error instanceof Error ? error.message : 'Failed to save root path'}` | ||
| ) | ||
| } | ||
| } | ||
| }, []) | ||
|
|
||
| const setRootPath = useCallback( | ||
| (newPath: string) => { | ||
|
|
@@ -52,73 +69,143 @@ export default function SandboxInspectNotFound() { | |
| router.refresh() | ||
| }) | ||
| }, | ||
| [router, startTransition] | ||
| [router, save] | ||
| ) | ||
|
|
||
| const resumeSandbox = useCallback(async () => { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this should live inside the sandbox context. please check the state management here and implement accordingly
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. k |
||
| if (onResumeSandbox) { | ||
| onResumeSandbox() | ||
| return | ||
| } | ||
|
|
||
| if (!sandboxInfo) return | ||
|
|
||
| setIsResumePending(true) | ||
| try { | ||
| const { data } = await supabase.auth.getSession() | ||
|
|
||
| if (!data.session) { | ||
| router.replace(AUTH_URLS.SIGN_IN) | ||
| return | ||
| } | ||
|
|
||
| await Sandbox.connect(sandboxInfo.sandboxID, { | ||
| domain: process.env.NEXT_PUBLIC_E2B_DOMAIN, | ||
| timeoutMs: SANDBOX_RESUME_TIMEOUT_MS, | ||
| headers: { | ||
| ...SUPABASE_AUTH_HEADERS(data.session.access_token, team.id), | ||
| }, | ||
| }) | ||
|
|
||
| await refetchSandboxInfo() | ||
| } catch (error) { | ||
| l.error( | ||
| { | ||
| key: 'sandbox_inspect_not_found:resume_failed', | ||
| error: serializeErrorForLog(error), | ||
| sandbox_id: sandboxInfo.sandboxID, | ||
| }, | ||
| `${error instanceof Error ? error.message : 'Failed to resume sandbox'}` | ||
| ) | ||
| } finally { | ||
| setIsResumePending(false) | ||
| } | ||
| }, [onResumeSandbox, refetchSandboxInfo, router, sandboxInfo, team.id]) | ||
|
|
||
| useEffect(() => { | ||
| if (!isPending) { | ||
| setPendingPath(undefined) | ||
| } | ||
| }, [isPending]) | ||
|
|
||
| const isPaused = sandboxInfo?.state === 'paused' | ||
| const resourceName = resource === 'terminal' ? 'terminal' : 'filesystem' | ||
|
|
||
| const description = isRunning | ||
| ? 'This directory appears to be empty or does not exist. You can reset to the default state, navigate to root, or refresh to try again.' | ||
| : 'It seems like the sandbox is not connected anymore. We cannot access the filesystem at this time.' | ||
| : isPaused | ||
| ? `Resume this sandbox to access the ${resourceName}.` | ||
| : `It seems like the sandbox is not connected anymore. We cannot access the ${resourceName} at this time.` | ||
|
cursor[bot] marked this conversation as resolved.
Outdated
|
||
|
|
||
| const actions = isRunning ? ( | ||
| <> | ||
| <div className="flex w-full justify-between gap-4"> | ||
| <Button | ||
| variant="secondary" | ||
| className="flex-1 gap-2" | ||
| onClick={() => setRootPath('')} | ||
| disabled={isPending && pendingPath === ''} | ||
| > | ||
| <HomeIcon className="text-fg-tertiary h-4 w-4" /> | ||
| Reset | ||
| </Button> | ||
| let actions: ReactNode | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this should be a separate jsx component
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. k |
||
|
|
||
| if (isRunning) { | ||
| actions = ( | ||
| <> | ||
| <div className="flex w-full justify-between gap-4"> | ||
| <Button | ||
| variant="secondary" | ||
| className="flex-1 gap-2" | ||
| onClick={() => setRootPath('')} | ||
| disabled={isPending && pendingPath === ''} | ||
| > | ||
| <HomeIcon className="text-fg-tertiary h-4 w-4" /> | ||
| Reset | ||
| </Button> | ||
| <Button | ||
| variant="secondary" | ||
| className="flex-1 gap-2" | ||
| onClick={() => setRootPath('/')} | ||
| disabled={isPending && pendingPath === '/'} | ||
| > | ||
| <ArrowUpIcon className="text-fg-tertiary h-4 w-4" /> | ||
| To Root | ||
| </Button> | ||
| </div> | ||
| <Button | ||
| variant="secondary" | ||
| className="flex-1 gap-2" | ||
| onClick={() => setRootPath('/')} | ||
| disabled={isPending && pendingPath === '/'} | ||
| onClick={() => | ||
| resetTransition(async () => { | ||
| router.refresh() | ||
| }) | ||
| } | ||
| className="w-full gap-2" | ||
| disabled={isResetPending} | ||
| > | ||
| <ArrowUpIcon className="text-fg-tertiary h-4 w-4" /> | ||
| To Root | ||
| <RefreshIcon | ||
| className={cn('text-fg-tertiary h-4 w-4 transition-transform', { | ||
| 'animate-spin': isResetPending, | ||
| })} | ||
| /> | ||
| Refresh | ||
| </Button> | ||
| </div> | ||
| </> | ||
| ) | ||
| } else if (isPaused) { | ||
| actions = ( | ||
| <Button | ||
| className="w-full gap-2" | ||
| onClick={resumeSandbox} | ||
| disabled={isResumePending} | ||
| > | ||
| <RunningIcon className="h-4 w-4" /> | ||
| Resume sandbox | ||
| </Button> | ||
| ) | ||
| } else { | ||
| actions = ( | ||
| <Button | ||
| variant="secondary" | ||
| onClick={() => | ||
| resetTransition(async () => { | ||
| router.refresh() | ||
| }) | ||
| router.push(PROTECTED_URLS.SANDBOXES(teamSlug as string)) | ||
| } | ||
| className="w-full gap-2" | ||
| disabled={isResetPending} | ||
| > | ||
| <RefreshIcon | ||
| className={cn('text-fg-tertiary h-4 w-4 transition-transform', { | ||
| 'animate-spin': isResetPending, | ||
| })} | ||
| /> | ||
| Refresh | ||
| <ArrowLeftIcon className="text-fg-tertiary h-4 w-4" /> | ||
| Back to Sandboxes | ||
| </Button> | ||
| </> | ||
| ) : ( | ||
| <Button | ||
| variant="secondary" | ||
| onClick={() => router.push(PROTECTED_URLS.SANDBOXES(teamSlug as string))} | ||
| className="w-full gap-2" | ||
| > | ||
| <ArrowLeftIcon className="text-fg-tertiary h-4 w-4" /> | ||
| Back to Sandboxes | ||
| </Button> | ||
| ) | ||
| ) | ||
| } | ||
|
|
||
| return ( | ||
| <SandboxInspectEmptyFrame | ||
| title={isRunning ? 'Empty Directory' : 'Not Connected'} | ||
| title={ | ||
| isRunning | ||
| ? 'Empty Directory' | ||
| : isPaused | ||
| ? 'Sandbox Paused' | ||
| : 'Not Connected' | ||
| } | ||
| description={description} | ||
| actions={actions} | ||
| /> | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
why does this live on the server side? can we move this to client side? if not please inside the trpc sandbox router
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
k