Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/features/dashboard/sandbox/header/controls.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,20 @@
'use client'

import { useSandboxContext } from '../context'
import KillButton from './kill-button'
import PauseButton from './pause-button'
import ResumeButton from './resume-button'

export default function SandboxDetailsControls() {
const { sandboxInfo } = useSandboxContext()

const isPaused = sandboxInfo?.state === 'paused'
const isRunning = sandboxInfo?.state === 'running'

return (
<div className="flex items-center gap-2 md:pb-2">
{isRunning && <PauseButton />}
{isPaused && <ResumeButton />}
<KillButton />
</div>
)
Expand Down
55 changes: 55 additions & 0 deletions src/features/dashboard/sandbox/header/pause-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
'use client'

import { useAction } from 'next-safe-action/hooks'
import { toast } from 'sonner'
import { cn } from '@/lib/utils/ui'
import { pauseSandboxAction } from '@/server/sandboxes/sandbox-actions'
import { Button } from '@/ui/primitives/button'
import { PausedIcon } from '@/ui/primitives/icons'
import { useDashboard } from '../../context'
import { useSandboxContext } from '../context'

interface PauseButtonProps {
className?: string
}

export default function PauseButton({ className }: PauseButtonProps) {
const { sandboxInfo, refetchSandboxInfo } = useSandboxContext()
const { team } = useDashboard()
const canPause = sandboxInfo?.state === 'running'

const { execute, isExecuting } = useAction(pauseSandboxAction, {
onSuccess: async () => {
toast.success('Sandbox paused successfully')
refetchSandboxInfo()
},
onError: ({ error }) => {
toast.error(
error.serverError || 'Failed to pause sandbox. Please try again.'
)
},
})

const handlePause = () => {
if (!canPause || !sandboxInfo?.sandboxID) return

execute({
teamIdOrSlug: team.id,
sandboxId: sandboxInfo.sandboxID,
})
}

return (
<Button
variant="ghost"
size="slate"
className={cn(className)}
disabled={!canPause || isExecuting}
onClick={handlePause}
loading={isExecuting}
>
<PausedIcon className="size-3.5" />
Pause
</Button>
)
}
56 changes: 56 additions & 0 deletions src/features/dashboard/sandbox/header/resume-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
'use client'

import { useAction } from 'next-safe-action/hooks'
import { toast } from 'sonner'
import { cn } from '@/lib/utils/ui'
import { resumeSandboxAction } from '@/server/sandboxes/sandbox-actions'
import { Button } from '@/ui/primitives/button'
import { PlayIcon } from '@/ui/primitives/icons'
import { useDashboard } from '../../context'
import { useSandboxContext } from '../context'

interface ResumeButtonProps {
className?: string
}

export default function ResumeButton({ className }: ResumeButtonProps) {
const { sandboxInfo, refetchSandboxInfo } = useSandboxContext()
const { team } = useDashboard()
const canResume = sandboxInfo?.state === 'paused'

const { execute, isExecuting } = useAction(resumeSandboxAction, {
onSuccess: async () => {
toast.success('Sandbox resumed successfully')
refetchSandboxInfo()
},
onError: ({ error }) => {
toast.error(
error.serverError || 'Failed to resume sandbox. Please try again.'
)
},
})

const handleResume = () => {
if (!canResume || !sandboxInfo?.sandboxID) return

execute({
teamIdOrSlug: team.id,
sandboxId: sandboxInfo.sandboxID,
timeout: 60,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid forcing a 60s TTL on resume

The resume handler always sends timeout: 60, which makes every resumed sandbox expire after roughly one minute from the resume call instead of preserving its previous/session timeout. Because /sandboxes/{sandboxID}/connect treats timeout as the new expiry window from now, users resuming long-lived sandboxes can have them terminated almost immediately after resuming, which is a functional regression in normal pause→resume workflows.

Useful? React with 👍 / 👎.

})
}

return (
<Button
variant="ghost"
size="slate"
className={cn('text-accent-positive-highlight', className)}
disabled={!canResume || isExecuting}
onClick={handleResume}
loading={isExecuting}
>
<PlayIcon className="size-3.5" />
Resume
</Button>
)
}
108 changes: 108 additions & 0 deletions src/server/sandboxes/sandbox-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,114 @@ export const killSandboxAction = authActionClient
}
})

const PauseSandboxSchema = z.object({
teamIdOrSlug: TeamIdOrSlugSchema,
sandboxId: z.string().min(1, 'Sandbox ID is required'),
})

export const pauseSandboxAction = authActionClient
.schema(PauseSandboxSchema)
.metadata({ actionName: 'pauseSandbox' })
.use(withTeamIdResolution)
.action(async ({ parsedInput, ctx }) => {
const { sandboxId } = parsedInput
const { session, teamId } = ctx

const res = await infra.POST('/sandboxes/{sandboxID}/pause', {
headers: {
...SUPABASE_AUTH_HEADERS(session.access_token, teamId),
},
params: {
path: {
sandboxID: sandboxId,
},
},
})

if (res.error) {
const status = res.response.status

l.error(
{
key: 'pause_sandbox_action:infra_error',
error: res.error,
user_id: session.user.id,
team_id: teamId,
sandbox_id: sandboxId,
context: {
status,
},
},
`Failed to pause sandbox: ${res.error.message}`
)

if (status === 404) {
return returnServerError('Sandbox not found')
}

if (status === 409) {
return returnServerError(
'Sandbox cannot be paused in its current state'
)
}

return returnServerError('Failed to pause sandbox')
}
})

const ResumeSandboxSchema = z.object({
teamIdOrSlug: TeamIdOrSlugSchema,
sandboxId: z.string().min(1, 'Sandbox ID is required'),
timeout: z.number().int().positive().default(15),
})

export const resumeSandboxAction = authActionClient
.schema(ResumeSandboxSchema)
.metadata({ actionName: 'resumeSandbox' })
.use(withTeamIdResolution)
.action(async ({ parsedInput, ctx }) => {
const { sandboxId, timeout } = parsedInput
const { session, teamId } = ctx

const res = await infra.POST('/sandboxes/{sandboxID}/connect', {
headers: {
...SUPABASE_AUTH_HEADERS(session.access_token, teamId),
},
params: {
path: {
sandboxID: sandboxId,
},
},
body: {
timeout,
},
})

if (res.error) {
const status = res.response.status

l.error(
{
key: 'resume_sandbox_action:infra_error',
error: res.error,
user_id: session.user.id,
team_id: teamId,
sandbox_id: sandboxId,
context: {
status,
},
},
`Failed to resume sandbox: ${res.error.message}`
)

if (status === 404) {
return returnServerError('Sandbox not found')
}

return returnServerError('Failed to resume sandbox')
}
})

const RevalidateSandboxesSchema = z.object({
teamIdOrSlug: TeamIdOrSlugSchema,
})
Expand Down
17 changes: 17 additions & 0 deletions src/ui/primitives/icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1344,6 +1344,23 @@ export const PausedIcon = ({ className, ...props }: IconProps) => (
</svg>
)

export const PlayIcon = ({ className, ...props }: IconProps) => (
<svg
className={cn(DEFAULT_CLASS_NAMES, className)}
fill="none"
viewBox="0 0 12 12"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M3 1.5L10 6L3 10.5V1.5Z"
stroke="currentColor"
strokeWidth="1.33333"
strokeLinejoin="round"
/>
</svg>
)

export const DotIcon = ({ className, ...props }: IconProps) => (
<svg
className={cn(DEFAULT_CLASS_NAMES, className)}
Expand Down
Loading