diff --git a/src/features/dashboard/sandbox/context.tsx b/src/features/dashboard/sandbox/context.tsx index 71d029f71..22cecfa01 100644 --- a/src/features/dashboard/sandbox/context.tsx +++ b/src/features/dashboard/sandbox/context.tsx @@ -54,18 +54,18 @@ export function SandboxProvider({ isLoading: isSandboxInfoLoading, isValidating: isSandboxInfoValidating, } = useSWR( - !serverSandboxInfo?.sandboxID + !lastFallbackData?.sandboxID ? null - : [`/api/sandbox/details`, serverSandboxInfo?.sandboxID], + : [`/api/sandbox/details`, lastFallbackData?.sandboxID], async ([url]) => { - if (!serverSandboxInfo?.sandboxID) return + if (!lastFallbackData?.sandboxID) return const origin = document.location.origin const requestUrl = new URL(url, origin) requestUrl.searchParams.set('teamId', teamId) - requestUrl.searchParams.set('sandboxId', serverSandboxInfo.sandboxID) + requestUrl.searchParams.set('sandboxId', lastFallbackData.sandboxID) const response = await fetch(requestUrl.toString(), { method: 'GET', @@ -102,21 +102,18 @@ export function SandboxProvider({ ) const { data: metricsData } = useSWR( - !serverSandboxInfo?.sandboxID + !lastFallbackData?.sandboxID ? null - : [ - `/api/teams/${teamId}/sandboxes/metrics`, - serverSandboxInfo?.sandboxID, - ], + : [`/api/teams/${teamId}/sandboxes/metrics`, lastFallbackData?.sandboxID], async ([url]) => { - if (!serverSandboxInfo?.sandboxID || !isRunning) return null + if (!lastFallbackData?.sandboxID || !isRunningState) return null const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify({ sandboxIds: [serverSandboxInfo.sandboxID] }), + body: JSON.stringify({ sandboxIds: [lastFallbackData.sandboxID] }), cache: 'no-store', }) @@ -128,7 +125,7 @@ export function SandboxProvider({ const data = (await response.json()) as MetricsResponse - return data.metrics[serverSandboxInfo.sandboxID] + return data.metrics[lastFallbackData.sandboxID] }, { errorRetryInterval: 1000, @@ -136,7 +133,9 @@ export function SandboxProvider({ revalidateIfStale: true, revalidateOnFocus: true, revalidateOnReconnect: true, - refreshInterval: SANDBOXES_DETAILS_METRICS_POLLING_MS, + refreshInterval: isRunningState + ? SANDBOXES_DETAILS_METRICS_POLLING_MS + : 0, refreshWhenHidden: false, refreshWhenOffline: false, } diff --git a/src/features/dashboard/sandbox/header/header.tsx b/src/features/dashboard/sandbox/header/header.tsx index 37ec589a7..2a313a4b5 100644 --- a/src/features/dashboard/sandbox/header/header.tsx +++ b/src/features/dashboard/sandbox/header/header.tsx @@ -4,6 +4,7 @@ import { SandboxInfo } from '@/types/api' import { ChevronLeftIcon } from 'lucide-react' import { cookies } from 'next/headers' import Link from 'next/link' +import KillButton from './kill-button' import Metadata from './metadata' import RanFor from './ran-for' import RefreshControl from './refresh' @@ -81,14 +82,17 @@ export default async function SandboxDetailsHeader({ - +
+ + +
diff --git a/src/features/dashboard/sandbox/header/kill-button.tsx b/src/features/dashboard/sandbox/header/kill-button.tsx new file mode 100644 index 000000000..f786ef0b7 --- /dev/null +++ b/src/features/dashboard/sandbox/header/kill-button.tsx @@ -0,0 +1,70 @@ +'use client' + +import { useSelectedTeam } from '@/lib/hooks/use-teams' +import { killSandboxAction } from '@/server/sandboxes/sandbox-actions' +import { AlertPopover } from '@/ui/alert-popover' +import { Button } from '@/ui/primitives/button' +import { TrashIcon } from '@/ui/primitives/icons' +import { useAction } from 'next-safe-action/hooks' +import { useState } from 'react' +import { toast } from 'sonner' +import { useSandboxContext } from '../context' + +interface KillButtonProps { + className?: string +} + +export default function KillButton({ className }: KillButtonProps) { + const [open, setOpen] = useState(false) + const { sandboxInfo, refetchSandboxInfo, isRunning } = useSandboxContext() + const selectedTeam = useSelectedTeam() + + const { execute, isExecuting } = useAction(killSandboxAction, { + onSuccess: async () => { + toast.success('Sandbox killed successfully') + setOpen(false) + refetchSandboxInfo() + }, + onError: ({ error }) => { + toast.error( + error.serverError || 'Failed to kill sandbox. Please try again.' + ) + }, + }) + + const handleKill = () => { + if (!sandboxInfo?.sandboxID || !isRunning || !selectedTeam?.id) return + + execute({ + teamId: selectedTeam.id, + sandboxId: sandboxInfo.sandboxID, + }) + } + + return ( + + + Kill + + } + confirmProps={{ + disabled: isExecuting, + loading: isExecuting, + }} + onConfirm={handleKill} + onCancel={() => setOpen(false)} + /> + ) +} diff --git a/src/features/dashboard/sandbox/header/refresh.tsx b/src/features/dashboard/sandbox/header/refresh.tsx index 8fe479e8e..de10d67e9 100644 --- a/src/features/dashboard/sandbox/header/refresh.tsx +++ b/src/features/dashboard/sandbox/header/refresh.tsx @@ -29,7 +29,8 @@ export default function RefreshControl({ initialPollingInterval ?? pollingIntervals[2]!.value ) - const { refetchSandboxInfo, isSandboxInfoLoading } = useSandboxContext() + const { refetchSandboxInfo, isSandboxInfoLoading, isRunning } = + useSandboxContext() const handleIntervalChange = useCallback( async (interval: PollingInterval) => { @@ -54,7 +55,7 @@ export default function RefreshControl({ return ( ) { + const sandbox = row.original + const selectedTeam = useSelectedTeam() + const router = useRouter() + const { toast } = useToast() + + const { execute: executeKillSandbox, isExecuting: isKilling } = useAction( + killSandboxAction, + { + onSuccess: () => { + toast( + defaultSuccessToast(`Sandbox ${sandbox.sandboxID} has been killed.`) + ) + router.refresh() + }, + onError: ({ error }) => { + toast( + defaultErrorToast( + error.serverError || 'Failed to kill sandbox. Please try again.' + ) + ) + }, + } + ) + + const handleKill = () => { + if (!selectedTeam?.id) return + + executeKillSandbox({ + teamId: selectedTeam.id, + sandboxId: sandbox.sandboxID, + }) + } + + return ( + + { + e.stopPropagation() + e.preventDefault() + }} + > + + + + + Danger Zone + + + + Kill + + + +
+
+

Confirm Kill

+

+ Are you sure you want to kill this sandbox? This action + cannot be undone. +

+
+
+ +
+
+
+
+
+
+
+
+ ) +} + type CpuUsageProps = { sandboxId: string; totalCpu?: number } export const CpuUsageCellView = React.memo(function CpuUsageCellView({ sandboxId, diff --git a/src/features/dashboard/sandboxes/list/table-config.tsx b/src/features/dashboard/sandboxes/list/table-config.tsx index 1cb702053..20643b384 100644 --- a/src/features/dashboard/sandboxes/list/table-config.tsx +++ b/src/features/dashboard/sandboxes/list/table-config.tsx @@ -124,6 +124,15 @@ export const resourceRangeFilter: FilterFn = ( export const fallbackData: SandboxWithMetrics[] = [] export const COLUMNS: ColumnDef[] = [ + // TODO: add actions column back in as soon as performance is stabilized + // { + // id: 'actions', + // enableSorting: false, + // enableGlobalFilter: false, + // enableResizing: false, + // size: 35, + // cell: ActionsCell, + // }, { accessorKey: 'sandboxID', header: 'ID', diff --git a/src/features/dashboard/sandboxes/list/table-row.tsx b/src/features/dashboard/sandboxes/list/table-row.tsx index 32384bff1..ae3da3df5 100644 --- a/src/features/dashboard/sandboxes/list/table-row.tsx +++ b/src/features/dashboard/sandboxes/list/table-row.tsx @@ -24,6 +24,7 @@ export const TableRow = memo(function TableRow({ row }: TableRowProps) { row.original.sandboxID )} prefetch={false} + passHref > { + const { teamId, sandboxId } = parsedInput + const { session } = ctx + + const res = await infra.DELETE('/sandboxes/{sandboxID}', { + headers: { + ...SUPABASE_AUTH_HEADERS(session.access_token, teamId), + }, + params: { + path: { + sandboxID: sandboxId, + }, + }, + }) + + if (res.error) { + const status = res.response.status + + l.error( + { + key: 'kill_sandbox_action:infra_error', + error: res.error, + user_id: session.user.id, + team_id: teamId, + sandbox_id: sandboxId, + context: { + status, + }, + }, + `Failed to kill sandbox: ${res.error.message}` + ) + + if (status === 404) { + return returnServerError('Sandbox not found') + } + + return returnServerError('Failed to kill sandbox') + } + }) diff --git a/src/ui/alert-popover.tsx b/src/ui/alert-popover.tsx new file mode 100644 index 000000000..107bf017c --- /dev/null +++ b/src/ui/alert-popover.tsx @@ -0,0 +1,63 @@ +'use client' + +import { FC } from 'react' +import { Button } from './primitives/button' +import { Popover, PopoverContent, PopoverTrigger } from './primitives/popover' + +interface AlertPopoverProps + extends React.ComponentPropsWithoutRef { + title: React.ReactNode + description: React.ReactNode + children?: React.ReactNode + confirm: React.ReactNode + cancel?: React.ReactNode + trigger?: React.ReactNode + confirmProps?: React.ComponentPropsWithoutRef + popoverContentProps?: React.ComponentPropsWithoutRef + onConfirm: () => void + onCancel: () => void +} + +export const AlertPopover: FC = ({ + title, + description, + children, + confirm, + cancel = 'Cancel', + trigger, + confirmProps, + popoverContentProps, + onConfirm, + onCancel, + ...props +}) => { + return ( + + {trigger && {trigger}} + +
+
+

{title}

+

{description}

+
+ + {children &&
{children}
} + +
+ + +
+
+
+
+ ) +}