Skip to content

Commit 643fefc

Browse files
Feat: enhance sandbox management with kill button (#175)
<!-- CURSOR_SUMMARY --> > [!NOTE] > Adds sandbox kill actions (details header + list table), introduces confirmation popover, and refines sandbox info/metrics polling tied to running state. > > - **Sandbox Details (Inspect)**: > - Add `KillButton` with confirmation via `AlertPopover`; disabled when not running. > - `RefreshControl` now shows 0s polling when sandbox not running. > - Header layout updated to include Kill + Refresh controls. > - **Sandboxes List**: > - New `actions` column with dropdown to kill a running sandbox; shows loader and confirmation sub-menu. > - Row link updated with `passHref`. > - **State/Data Fetching**: > - `SandboxContext` now tracks `isRunningState` and `lastFallbackData`. > - SWR keys/requests switched from `serverSandboxInfo` to `lastFallbackData`. > - Metrics polling and refresh interval dynamically pause when not running. > - **Server Actions**: > - New `killSandboxAction` to DELETE `/sandboxes/{sandboxID}` with auth; maps errors (404 → "Sandbox not found"). > - **UI Components**: > - New `AlertPopover` primitive for confirm/cancel flows. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit d8a1473. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent e0bc70c commit 643fefc

File tree

9 files changed

+345
-24
lines changed

9 files changed

+345
-24
lines changed

src/features/dashboard/sandbox/context.tsx

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -54,18 +54,18 @@ export function SandboxProvider({
5454
isLoading: isSandboxInfoLoading,
5555
isValidating: isSandboxInfoValidating,
5656
} = useSWR<SandboxInfo | void>(
57-
!serverSandboxInfo?.sandboxID
57+
!lastFallbackData?.sandboxID
5858
? null
59-
: [`/api/sandbox/details`, serverSandboxInfo?.sandboxID],
59+
: [`/api/sandbox/details`, lastFallbackData?.sandboxID],
6060
async ([url]) => {
61-
if (!serverSandboxInfo?.sandboxID) return
61+
if (!lastFallbackData?.sandboxID) return
6262

6363
const origin = document.location.origin
6464

6565
const requestUrl = new URL(url, origin)
6666

6767
requestUrl.searchParams.set('teamId', teamId)
68-
requestUrl.searchParams.set('sandboxId', serverSandboxInfo.sandboxID)
68+
requestUrl.searchParams.set('sandboxId', lastFallbackData.sandboxID)
6969

7070
const response = await fetch(requestUrl.toString(), {
7171
method: 'GET',
@@ -102,21 +102,18 @@ export function SandboxProvider({
102102
)
103103

104104
const { data: metricsData } = useSWR(
105-
!serverSandboxInfo?.sandboxID
105+
!lastFallbackData?.sandboxID
106106
? null
107-
: [
108-
`/api/teams/${teamId}/sandboxes/metrics`,
109-
serverSandboxInfo?.sandboxID,
110-
],
107+
: [`/api/teams/${teamId}/sandboxes/metrics`, lastFallbackData?.sandboxID],
111108
async ([url]) => {
112-
if (!serverSandboxInfo?.sandboxID || !isRunning) return null
109+
if (!lastFallbackData?.sandboxID || !isRunningState) return null
113110

114111
const response = await fetch(url, {
115112
method: 'POST',
116113
headers: {
117114
'Content-Type': 'application/json',
118115
},
119-
body: JSON.stringify({ sandboxIds: [serverSandboxInfo.sandboxID] }),
116+
body: JSON.stringify({ sandboxIds: [lastFallbackData.sandboxID] }),
120117
cache: 'no-store',
121118
})
122119

@@ -128,15 +125,17 @@ export function SandboxProvider({
128125

129126
const data = (await response.json()) as MetricsResponse
130127

131-
return data.metrics[serverSandboxInfo.sandboxID]
128+
return data.metrics[lastFallbackData.sandboxID]
132129
},
133130
{
134131
errorRetryInterval: 1000,
135132
errorRetryCount: 3,
136133
revalidateIfStale: true,
137134
revalidateOnFocus: true,
138135
revalidateOnReconnect: true,
139-
refreshInterval: SANDBOXES_DETAILS_METRICS_POLLING_MS,
136+
refreshInterval: isRunningState
137+
? SANDBOXES_DETAILS_METRICS_POLLING_MS
138+
: 0,
140139
refreshWhenHidden: false,
141140
refreshWhenOffline: false,
142141
}

src/features/dashboard/sandbox/header/header.tsx

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { SandboxInfo } from '@/types/api'
44
import { ChevronLeftIcon } from 'lucide-react'
55
import { cookies } from 'next/headers'
66
import Link from 'next/link'
7+
import KillButton from './kill-button'
78
import Metadata from './metadata'
89
import RanFor from './ran-for'
910
import RefreshControl from './refresh'
@@ -81,14 +82,17 @@ export default async function SandboxDetailsHeader({
8182
</Link>
8283
<SandboxDetailsTitle />
8384
</div>
84-
<RefreshControl
85-
initialPollingInterval={
86-
initialPollingInterval
87-
? parseInt(initialPollingInterval)
88-
: undefined
89-
}
90-
className="pt-4 sm:pt-0"
91-
/>
85+
<div className="flex items-center gap-2 pt-4 sm:pt-0">
86+
<RefreshControl
87+
initialPollingInterval={
88+
initialPollingInterval
89+
? parseInt(initialPollingInterval)
90+
: undefined
91+
}
92+
className="order-2 sm:order-1"
93+
/>
94+
<KillButton className="order-1 sm:order-2" />
95+
</div>
9296
</div>
9397

9498
<div className="flex flex-wrap items-center gap-5 md:gap-7">
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
'use client'
2+
3+
import { useSelectedTeam } from '@/lib/hooks/use-teams'
4+
import { killSandboxAction } from '@/server/sandboxes/sandbox-actions'
5+
import { AlertPopover } from '@/ui/alert-popover'
6+
import { Button } from '@/ui/primitives/button'
7+
import { TrashIcon } from '@/ui/primitives/icons'
8+
import { useAction } from 'next-safe-action/hooks'
9+
import { useState } from 'react'
10+
import { toast } from 'sonner'
11+
import { useSandboxContext } from '../context'
12+
13+
interface KillButtonProps {
14+
className?: string
15+
}
16+
17+
export default function KillButton({ className }: KillButtonProps) {
18+
const [open, setOpen] = useState(false)
19+
const { sandboxInfo, refetchSandboxInfo, isRunning } = useSandboxContext()
20+
const selectedTeam = useSelectedTeam()
21+
22+
const { execute, isExecuting } = useAction(killSandboxAction, {
23+
onSuccess: async () => {
24+
toast.success('Sandbox killed successfully')
25+
setOpen(false)
26+
refetchSandboxInfo()
27+
},
28+
onError: ({ error }) => {
29+
toast.error(
30+
error.serverError || 'Failed to kill sandbox. Please try again.'
31+
)
32+
},
33+
})
34+
35+
const handleKill = () => {
36+
if (!sandboxInfo?.sandboxID || !isRunning || !selectedTeam?.id) return
37+
38+
execute({
39+
teamId: selectedTeam.id,
40+
sandboxId: sandboxInfo.sandboxID,
41+
})
42+
}
43+
44+
return (
45+
<AlertPopover
46+
open={open}
47+
onOpenChange={setOpen}
48+
title="Kill Sandbox"
49+
description="Are you sure you want to kill this sandbox? The sandbox state will be lost and cannot be recovered."
50+
confirm="Kill Sandbox"
51+
trigger={
52+
<Button
53+
variant="error"
54+
size="sm"
55+
className={className}
56+
disabled={!isRunning}
57+
>
58+
<TrashIcon className="size-4" />
59+
Kill
60+
</Button>
61+
}
62+
confirmProps={{
63+
disabled: isExecuting,
64+
loading: isExecuting,
65+
}}
66+
onConfirm={handleKill}
67+
onCancel={() => setOpen(false)}
68+
/>
69+
)
70+
}

src/features/dashboard/sandbox/header/refresh.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@ export default function RefreshControl({
2929
initialPollingInterval ?? pollingIntervals[2]!.value
3030
)
3131

32-
const { refetchSandboxInfo, isSandboxInfoLoading } = useSandboxContext()
32+
const { refetchSandboxInfo, isSandboxInfoLoading, isRunning } =
33+
useSandboxContext()
3334

3435
const handleIntervalChange = useCallback(
3536
async (interval: PollingInterval) => {
@@ -54,7 +55,7 @@ export default function RefreshControl({
5455
return (
5556
<PollingButton
5657
intervals={pollingIntervals}
57-
pollingInterval={pollingInterval}
58+
pollingInterval={isRunning ? pollingInterval : 0}
5859
onIntervalChange={handleIntervalChange}
5960
isPolling={isSandboxInfoLoading}
6061
onRefresh={refetchSandboxInfo}

src/features/dashboard/sandboxes/list/table-cells.tsx

Lines changed: 119 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,32 @@ import { PROTECTED_URLS } from '@/configs/urls'
44
import ResourceUsage from '@/features/dashboard/common/resource-usage'
55
import { useServerContext } from '@/features/dashboard/server-context'
66
import { useTemplateTableStore } from '@/features/dashboard/templates/stores/table-store'
7+
import { useSelectedTeam } from '@/lib/hooks/use-teams'
8+
import {
9+
defaultErrorToast,
10+
defaultSuccessToast,
11+
useToast,
12+
} from '@/lib/hooks/use-toast'
713
import { parseUTCDateComponents } from '@/lib/utils/formatting'
14+
import { killSandboxAction } from '@/server/sandboxes/sandbox-actions'
815
import { Template } from '@/types/api'
916
import { JsonPopover } from '@/ui/json-popover'
1017
import { Button } from '@/ui/primitives/button'
18+
import {
19+
DropdownMenu,
20+
DropdownMenuContent,
21+
DropdownMenuGroup,
22+
DropdownMenuLabel,
23+
DropdownMenuPortal,
24+
DropdownMenuSub,
25+
DropdownMenuSubContent,
26+
DropdownMenuSubTrigger,
27+
DropdownMenuTrigger,
28+
} from '@/ui/primitives/dropdown-menu'
29+
import { Loader } from '@/ui/primitives/loader'
1130
import { CellContext } from '@tanstack/react-table'
12-
import { ArrowUpRight } from 'lucide-react'
31+
import { ArrowUpRight, MoreVertical, Trash2 } from 'lucide-react'
32+
import { useAction } from 'next-safe-action/hooks'
1333
import { useRouter } from 'next/navigation'
1434
import React, { useMemo } from 'react'
1535
import { useSandboxMetricsStore } from './stores/metrics-store'
@@ -21,6 +41,104 @@ declare module '@tanstack/react-table' {
2141
}
2242
}
2343

44+
export function ActionsCell({ row }: CellContext<SandboxWithMetrics, unknown>) {
45+
const sandbox = row.original
46+
const selectedTeam = useSelectedTeam()
47+
const router = useRouter()
48+
const { toast } = useToast()
49+
50+
const { execute: executeKillSandbox, isExecuting: isKilling } = useAction(
51+
killSandboxAction,
52+
{
53+
onSuccess: () => {
54+
toast(
55+
defaultSuccessToast(`Sandbox ${sandbox.sandboxID} has been killed.`)
56+
)
57+
router.refresh()
58+
},
59+
onError: ({ error }) => {
60+
toast(
61+
defaultErrorToast(
62+
error.serverError || 'Failed to kill sandbox. Please try again.'
63+
)
64+
)
65+
},
66+
}
67+
)
68+
69+
const handleKill = () => {
70+
if (!selectedTeam?.id) return
71+
72+
executeKillSandbox({
73+
teamId: selectedTeam.id,
74+
sandboxId: sandbox.sandboxID,
75+
})
76+
}
77+
78+
return (
79+
<DropdownMenu modal={false}>
80+
<DropdownMenuTrigger
81+
asChild
82+
onClick={(e) => {
83+
e.stopPropagation()
84+
e.preventDefault()
85+
}}
86+
>
87+
<Button
88+
variant="ghost"
89+
size="icon"
90+
className="text-fg-tertiary size-5"
91+
disabled={isKilling || sandbox.state !== 'running'}
92+
>
93+
{isKilling ? (
94+
<Loader className="size-4" />
95+
) : (
96+
<MoreVertical className="size-4" />
97+
)}
98+
</Button>
99+
</DropdownMenuTrigger>
100+
<DropdownMenuContent align="end">
101+
<DropdownMenuGroup>
102+
<DropdownMenuLabel>Danger Zone</DropdownMenuLabel>
103+
<DropdownMenuSub>
104+
<DropdownMenuSubTrigger variant="error">
105+
<Trash2 className="!size-3" />
106+
Kill
107+
</DropdownMenuSubTrigger>
108+
<DropdownMenuPortal>
109+
<DropdownMenuSubContent>
110+
<div className="space-y-3 p-3 max-w-xs">
111+
<div className="space-y-1">
112+
<h4>Confirm Kill</h4>
113+
<p className="prose-body text-fg-tertiary">
114+
Are you sure you want to kill this sandbox? This action
115+
cannot be undone.
116+
</p>
117+
</div>
118+
<div className="flex items-center gap-2 justify-end">
119+
<Button
120+
variant="error"
121+
size="sm"
122+
onClick={(e) => {
123+
e.stopPropagation()
124+
handleKill()
125+
}}
126+
disabled={isKilling}
127+
loading={isKilling}
128+
>
129+
Kill Sandbox
130+
</Button>
131+
</div>
132+
</div>
133+
</DropdownMenuSubContent>
134+
</DropdownMenuPortal>
135+
</DropdownMenuSub>
136+
</DropdownMenuGroup>
137+
</DropdownMenuContent>
138+
</DropdownMenu>
139+
)
140+
}
141+
24142
type CpuUsageProps = { sandboxId: string; totalCpu?: number }
25143
export const CpuUsageCellView = React.memo(function CpuUsageCellView({
26144
sandboxId,

src/features/dashboard/sandboxes/list/table-config.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,15 @@ export const resourceRangeFilter: FilterFn<SandboxWithMetrics> = (
124124
export const fallbackData: SandboxWithMetrics[] = []
125125

126126
export const COLUMNS: ColumnDef<SandboxWithMetrics>[] = [
127+
// TODO: add actions column back in as soon as performance is stabilized
128+
// {
129+
// id: 'actions',
130+
// enableSorting: false,
131+
// enableGlobalFilter: false,
132+
// enableResizing: false,
133+
// size: 35,
134+
// cell: ActionsCell,
135+
// },
127136
{
128137
accessorKey: 'sandboxID',
129138
header: 'ID',

src/features/dashboard/sandboxes/list/table-row.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export const TableRow = memo(function TableRow({ row }: TableRowProps) {
2424
row.original.sandboxID
2525
)}
2626
prefetch={false}
27+
passHref
2728
>
2829
<DataTableRow
2930
key={row.id}

0 commit comments

Comments
 (0)