diff --git a/src/app/dashboard/[teamSlug]/sandboxes/(tabs)/layout.tsx b/src/app/dashboard/[teamSlug]/sandboxes/(tabs)/layout.tsx index ce0da2f2b..074de5997 100644 --- a/src/app/dashboard/[teamSlug]/sandboxes/(tabs)/layout.tsx +++ b/src/app/dashboard/[teamSlug]/sandboxes/(tabs)/layout.tsx @@ -1,4 +1,5 @@ import { PROTECTED_URLS } from '@/configs/urls' +import { isNewSandboxListEnabled } from '@/features/dashboard/sandboxes/list/feature-flag.server' import { DashboardTabsList } from '@/ui/dashboard-tabs' import { ListIcon, TrendIcon } from '@/ui/primitives/icons' @@ -7,6 +8,9 @@ export default async function SandboxesTabsLayout({ params, }: LayoutProps<'/dashboard/[teamSlug]/sandboxes'>) { const { teamSlug } = await params + const listHref = (await isNewSandboxListEnabled(teamSlug)) + ? PROTECTED_URLS.SANDBOXES_LIST2(teamSlug) + : PROTECTED_URLS.SANDBOXES_LIST(teamSlug) return (
@@ -22,7 +26,7 @@ export default async function SandboxesTabsLayout({ { id: 'list', label: 'List', - href: PROTECTED_URLS.SANDBOXES_LIST(teamSlug), + href: listHref, icon: , }, ]} diff --git a/src/app/dashboard/[teamSlug]/sandboxes/(tabs)/list/page.tsx b/src/app/dashboard/[teamSlug]/sandboxes/(tabs)/list/page.tsx index 28eeef355..fae1f1970 100644 --- a/src/app/dashboard/[teamSlug]/sandboxes/(tabs)/list/page.tsx +++ b/src/app/dashboard/[teamSlug]/sandboxes/(tabs)/list/page.tsx @@ -1,5 +1,8 @@ +import { redirect } from 'next/navigation' import { Suspense } from 'react' +import { PROTECTED_URLS } from '@/configs/urls' import LoadingLayout from '@/features/dashboard/loading-layout' +import { isNewSandboxListEnabled } from '@/features/dashboard/sandboxes/list/feature-flag.server' import SandboxesTable from '@/features/dashboard/sandboxes/list/table' import { HydrateClient, prefetch, trpc } from '@/trpc/server' @@ -8,6 +11,10 @@ export default async function SandboxesListPage({ }: PageProps<'/dashboard/[teamSlug]/sandboxes/list'>) { const { teamSlug } = await params + if (await isNewSandboxListEnabled(teamSlug)) { + redirect(PROTECTED_URLS.SANDBOXES_LIST2(teamSlug)) + } + prefetch( trpc.sandboxes.getSandboxes.queryOptions({ teamSlug, diff --git a/src/app/dashboard/[teamSlug]/sandboxes/(tabs)/list2/error.tsx b/src/app/dashboard/[teamSlug]/sandboxes/(tabs)/list2/error.tsx new file mode 100644 index 000000000..8346f7ae0 --- /dev/null +++ b/src/app/dashboard/[teamSlug]/sandboxes/(tabs)/list2/error.tsx @@ -0,0 +1,13 @@ +'use client' + +import { DashboardRouteError } from '@/features/dashboard/shared/route-error' + +export default function NewSandboxesListError({ + error, + reset, +}: { + error: Error & { digest?: string } + reset: () => void +}) { + return +} diff --git a/src/app/dashboard/[teamSlug]/sandboxes/(tabs)/list2/page.tsx b/src/app/dashboard/[teamSlug]/sandboxes/(tabs)/list2/page.tsx new file mode 100644 index 000000000..d9cafecff --- /dev/null +++ b/src/app/dashboard/[teamSlug]/sandboxes/(tabs)/list2/page.tsx @@ -0,0 +1,34 @@ +import { redirect } from 'next/navigation' +import { Suspense } from 'react' +import { PROTECTED_URLS } from '@/configs/urls' +import LoadingLayout from '@/features/dashboard/loading-layout' +import { isNewSandboxListEnabled } from '@/features/dashboard/sandboxes/list/feature-flag.server' +import { NewSandboxesTable } from '@/features/dashboard/sandboxes/list/table' +import { HydrateClient, prefetch, trpc } from '@/trpc/server' + +export default async function NewSandboxesListPage({ + params, +}: { + params: Promise<{ teamSlug: string }> +}) { + const { teamSlug } = await params + + if (!(await isNewSandboxListEnabled(teamSlug))) { + redirect(PROTECTED_URLS.SANDBOXES_LIST(teamSlug)) + } + + prefetch( + trpc.sandboxes.listSandboxesPaginated.infiniteQueryOptions({ + teamSlug, + limit: 50, + }) + ) + + return ( + + }> + + + + ) +} diff --git a/src/configs/layout.ts b/src/configs/layout.ts index f9c8c5fbb..51ca916ee 100644 --- a/src/configs/layout.ts +++ b/src/configs/layout.ts @@ -36,6 +36,10 @@ const DASHBOARD_LAYOUT_CONFIGS: Record< title: 'Sandboxes', type: 'custom', }), + '/dashboard/*/sandboxes/list2': () => ({ + title: 'Sandboxes', + type: 'custom', + }), '/dashboard/*/sandboxes/*/*': (pathname) => { const parts = pathname.split('/') const teamSlug = parts[2]! diff --git a/src/configs/urls.ts b/src/configs/urls.ts index 7cdb339a3..ddaee4410 100644 --- a/src/configs/urls.ts +++ b/src/configs/urls.ts @@ -32,6 +32,8 @@ export const PROTECTED_URLS = { SANDBOXES_MONITORING: (teamSlug: string) => `/dashboard/${teamSlug}/sandboxes/monitoring`, SANDBOXES_LIST: (teamSlug: string) => `/dashboard/${teamSlug}/sandboxes/list`, + SANDBOXES_LIST2: (teamSlug: string) => + `/dashboard/${teamSlug}/sandboxes/list2`, SANDBOX: (teamSlug: string, sandboxId: string) => `/dashboard/${teamSlug}/sandboxes/${sandboxId}/monitoring`, diff --git a/src/core/modules/feature-flags/definitions.ts b/src/core/modules/feature-flags/definitions.ts index cb5b546aa..c858ceca7 100644 --- a/src/core/modules/feature-flags/definitions.ts +++ b/src/core/modules/feature-flags/definitions.ts @@ -15,6 +15,14 @@ export const FEATURE_FLAGS = { description: 'Enables dashboard admin-only surfaces.', exposure: 'server', }, + newSandboxList: { + kind: 'boolean', + key: 'new_sandbox_list', + defaultValue: false, + description: + 'Enables the new sandbox list with pagination and paused sandbox coverage.', + exposure: 'both', + }, disableE2BAccessTokenProvisioning: { kind: 'boolean', key: 'disable_e2b_access_token_provisioning', diff --git a/src/core/modules/sandboxes/models.ts b/src/core/modules/sandboxes/models.ts index 36044f9b3..253eb1c94 100644 --- a/src/core/modules/sandboxes/models.ts +++ b/src/core/modules/sandboxes/models.ts @@ -5,6 +5,7 @@ import type { components as InfraComponents } from '@/contracts/infra-api' export type SandboxLogLevel = InfraComponents['schemas']['LogLevel'] export type Sandbox = InfraComponents['schemas']['ListedSandbox'] export type Sandboxes = InfraComponents['schemas']['ListedSandbox'][] +export type SandboxState = InfraComponents['schemas']['SandboxState'] export type SandboxesMetricsRecord = InfraComponents['schemas']['SandboxesWithMetrics']['sandboxes'] export type TeamMetric = InfraComponents['schemas']['TeamMetric'] diff --git a/src/core/modules/sandboxes/repository.server.ts b/src/core/modules/sandboxes/repository.server.ts index 0d69d4e34..624b35f48 100644 --- a/src/core/modules/sandboxes/repository.server.ts +++ b/src/core/modules/sandboxes/repository.server.ts @@ -7,6 +7,7 @@ import type { SandboxEventModel, Sandboxes, SandboxesMetricsRecord, + SandboxState, TeamMetric, } from '@/core/modules/sandboxes/models' import { api, infra } from '@/core/shared/clients/api' @@ -36,6 +37,19 @@ export interface GetSandboxMetricsOptions { endUnixMs: number } +export interface ListSandboxesOptions { + cursor?: string + limit: number + states?: SandboxState[] +} + +export interface ListSandboxesResult { + sandboxes: Sandboxes + nextCursor: string | null +} + +const DEFAULT_SANDBOX_STATES: SandboxState[] = ['running', 'paused'] + export interface SandboxesRepository { getSandboxLogs( sandboxId: string, @@ -61,6 +75,9 @@ export interface SandboxesRepository { options: GetSandboxMetricsOptions ): Promise> listSandboxes(): Promise> + listSandboxesPaginated( + options: ListSandboxesOptions + ): Promise> getSandboxesMetrics( sandboxIds: string[] ): Promise> @@ -383,7 +400,46 @@ export function createSandboxesRepository( ) } - return ok(result.data) + return ok(result.data ?? []) + }, + async listSandboxesPaginated(options) { + const result = await deps.infraClient.GET('/v2/sandboxes', { + params: { + query: { + state: options.states ?? DEFAULT_SANDBOX_STATES, + nextToken: options.cursor, + limit: options.limit, + }, + }, + headers: { + ...deps.authHeaders(scope.accessToken, scope.teamId), + }, + cache: 'no-store', + }) + + if (!result.response.ok || result.error) { + l.error({ + key: 'repositories:sandboxes:list_sandboxes_paginated:infra_error', + error: result.error, + team_id: scope.teamId, + context: { + status: result.response.status, + path: '/v2/sandboxes', + }, + }) + return err( + repoErrorFromHttp( + result.response.status, + result.error?.message ?? 'Failed to list sandboxes', + result.error + ) + ) + } + + return ok({ + sandboxes: result.data ?? [], + nextCursor: result.response.headers.get('x-next-token') || null, + }) }, async getSandboxesMetrics(sandboxIds) { const result = await deps.infraClient.GET('/sandboxes/metrics', { diff --git a/src/core/server/api/routers/sandboxes.ts b/src/core/server/api/routers/sandboxes.ts index 781f0d70d..c529088ac 100644 --- a/src/core/server/api/routers/sandboxes.ts +++ b/src/core/server/api/routers/sandboxes.ts @@ -35,10 +35,8 @@ export const sandboxesRouter = createTRPCRouter({ if (USE_MOCK_DATA) { await new Promise((resolve) => setTimeout(resolve, 200)) - const sandboxes = MOCK_SANDBOXES_DATA() - return { - sandboxes, + sandboxes: MOCK_SANDBOXES_DATA(), } } @@ -52,6 +50,44 @@ export const sandboxesRouter = createTRPCRouter({ } }), + listSandboxesPaginated: sandboxesRepositoryProcedure + .input( + z.object({ + cursor: z.string().optional(), + limit: z.number().int().min(1).max(100).default(50), + states: z.array(z.enum(['running', 'paused'])).optional(), + }) + ) + .query(async ({ ctx, input }) => { + if (USE_MOCK_DATA) { + await new Promise((resolve) => setTimeout(resolve, 200)) + + return { + sandboxes: input.states + ? MOCK_SANDBOXES_DATA().filter((sandbox) => + input.states?.includes(sandbox.state) + ) + : MOCK_SANDBOXES_DATA(), + nextCursor: null, + } + } + + const sandboxesResult = + await ctx.sandboxesRepository.listSandboxesPaginated({ + cursor: input.cursor, + limit: input.limit, + states: input.states, + }) + if (!sandboxesResult.ok) { + throwTRPCErrorFromRepoError(sandboxesResult.error) + } + + return { + sandboxes: sandboxesResult.data.sandboxes, + nextCursor: sandboxesResult.data.nextCursor, + } + }), + getSandboxesMetrics: sandboxesRepositoryProcedure .input( z.object({ diff --git a/src/features/dashboard/common/resource-usage.tsx b/src/features/dashboard/common/resource-usage.tsx index e88df4571..3466536de 100644 --- a/src/features/dashboard/common/resource-usage.tsx +++ b/src/features/dashboard/common/resource-usage.tsx @@ -29,7 +29,12 @@ const ResourceUsage: React.FC = ({ const hasValue = total !== null && total !== undefined && total !== 0 const displayTotal = hasValue ? formatNumber(total) : '--' return ( -

+

= ({ : 'text-fg' ) - const displayValue = hasMetrics ? formatNumber(metrics) : 'n/a' - const totalValue = total ? formatNumber(total) : 'n/a' + const displayValue = hasMetrics ? formatNumber(metrics) : '--' + const totalValue = total ? formatNumber(total) : '--' return ( = ({ ) : ( <> - n/a + -- ยท )} diff --git a/src/features/dashboard/sandboxes/list/feature-flag.server.ts b/src/features/dashboard/sandboxes/list/feature-flag.server.ts new file mode 100644 index 000000000..2115999cd --- /dev/null +++ b/src/features/dashboard/sandboxes/list/feature-flag.server.ts @@ -0,0 +1,27 @@ +import 'server-only' + +import { featureFlags } from '@/core/modules/feature-flags/feature-flags.server' +import { getAuthContext } from '@/core/server/auth' +import { getTeamIdFromSlug } from '@/core/server/functions/team/get-team-id-from-slug' + +export async function isNewSandboxListEnabled(teamSlug: string) { + const authContext = await getAuthContext() + + if (!authContext) { + return false + } + + const teamIdResult = await getTeamIdFromSlug( + teamSlug, + authContext.accessToken + ) + const teamId = teamIdResult.ok ? teamIdResult.data : null + + return featureFlags.isEnabled('newSandboxList', { + user: { + id: authContext.user.id, + email: authContext.user.email ?? undefined, + }, + team: teamId ? { id: teamId, slug: teamSlug } : undefined, + }) +} diff --git a/src/features/dashboard/sandboxes/list/table-body.tsx b/src/features/dashboard/sandboxes/list/table-body.tsx index ec6b92073..a07233bd1 100644 --- a/src/features/dashboard/sandboxes/list/table-body.tsx +++ b/src/features/dashboard/sandboxes/list/table-body.tsx @@ -1,6 +1,7 @@ -import type { RefObject } from 'react' +import { type RefObject, useEffect } from 'react' import { useVirtualRows } from '@/lib/hooks/use-virtual-rows' import { DataTableBody } from '@/ui/data-table' +import { LoadMoreButton } from '@/ui/pagination-buttons' import { Button } from '@/ui/primitives/button' import { AddIcon, CloseIcon } from '@/ui/primitives/icons' import SandboxesListEmpty from './empty' @@ -11,15 +12,22 @@ import { SandboxesTableRow } from './table-row' const ROW_HEIGHT_PX = 32 const VIRTUAL_OVERSCAN = 8 +const PREFETCH_THRESHOLD = 8 interface SandboxesTableBodyProps { table: SandboxListTable scrollRef: RefObject + hasNextPage?: boolean + isFetchingNextPage?: boolean + fetchNextPage?: () => void } export const SandboxesTableBody = ({ table, scrollRef, + hasNextPage = false, + isFetchingNextPage = false, + fetchNextPage, }: SandboxesTableBodyProps) => { 'use no memo' @@ -54,15 +62,38 @@ export const SandboxesTableBody = ({ // even when centerRows already has data. const rows = virtualRows.length > 0 ? virtualRows : centerRows - const visibleSandboxes = rows.map((row) => row.original) const isListScrolling = virtualizer.isScrolling + // Live metrics only exist for running sandboxes. + const runningVisibleSandboxes = rows + .map((row) => row.original) + .filter((sandbox) => sandbox.state === 'running') + useSandboxesMetrics({ - sandboxes: visibleSandboxes, + sandboxes: runningVisibleSandboxes, pollingIntervalMs: pollingInterval === 0 ? 0 : pollingInterval * 1_000, isListScrolling, }) + const lastVisibleIndex = virtualizer.getVirtualItems().at(-1)?.index ?? -1 + + useEffect(() => { + if ( + hasNextPage && + !isFetchingNextPage && + fetchNextPage && + lastVisibleIndex >= centerRows.length - PREFETCH_THRESHOLD + ) { + fetchNextPage() + } + }, [ + hasNextPage, + isFetchingNextPage, + lastVisibleIndex, + centerRows.length, + fetchNextPage, + ]) + const isEmpty = centerRows.length === 0 if (isEmpty) { @@ -80,7 +111,7 @@ export const SandboxesTableBody = ({ ) : ( @@ -101,11 +132,22 @@ export const SandboxesTableBody = ({ } return ( - - {virtualPaddingTop > 0 &&

} - {rows.map((row) => ( - - ))} - + <> + + {virtualPaddingTop > 0 &&
} + {rows.map((row) => ( + + ))} + + + {hasNextPage && ( +
+ {})} + /> +
+ )} + ) } diff --git a/src/features/dashboard/sandboxes/list/table-cells.tsx b/src/features/dashboard/sandboxes/list/table-cells.tsx index 615206969..ff61a40eb 100644 --- a/src/features/dashboard/sandboxes/list/table-cells.tsx +++ b/src/features/dashboard/sandboxes/list/table-cells.tsx @@ -7,8 +7,9 @@ import { PROTECTED_URLS } from '@/configs/urls' import ResourceUsage from '@/features/dashboard/common/resource-usage' import { formatLocalLogStyleTimestamp } from '@/lib/utils/formatting' import { JsonPopover } from '@/ui/json-popover' +import { Badge } from '@/ui/primitives/badge' import { Button } from '@/ui/primitives/button' -import { ExternalLinkIcon } from '@/ui/primitives/icons' +import { DotIcon, ExternalLinkIcon, PausedIcon } from '@/ui/primitives/icons' import { useDashboard } from '../../context' import { useSandboxMetricsStore } from './stores/metrics-store' import type { SandboxListRow } from './table-config' @@ -16,9 +17,21 @@ import type { SandboxListRow } from './table-config' const USAGE_TEXT_CLASSNAME = 'prose-table-numeric text-right' const MONO_NUMERIC_TEXT_CLASSNAME = 'overflow-x-hidden whitespace-nowrap font-mono prose-table-numeric' - -type CpuUsageCellProps = { sandboxId: string; totalCpu?: number } -const CpuUsageCellView = ({ sandboxId, totalCpu }: CpuUsageCellProps) => { +// Started At needs to fit the date, time, and timezone (e.g. "GMT+2") within a +// fixed-width column, so it drops font-mono for the narrower tabular figures. +const TIMESTAMP_TEXT_CLASSNAME = + 'overflow-hidden whitespace-nowrap prose-table-numeric' + +// Live usage is only available for running sandboxes; paused sandboxes fall +// back to their allocated specs. + +const CpuUsageCellView = ({ + sandboxId, + totalCpu, +}: { + sandboxId: string + totalCpu?: number +}) => { const cpuUsedPct = useSandboxMetricsStore( (state) => state.metrics?.[sandboxId]?.cpuUsedPct ) @@ -33,8 +46,13 @@ const CpuUsageCellView = ({ sandboxId, totalCpu }: CpuUsageCellProps) => { ) } -type RamUsageCellProps = { sandboxId: string; totalMem?: number } -const RamUsageCellView = ({ sandboxId, totalMem }: RamUsageCellProps) => { +const RamUsageCellView = ({ + sandboxId, + totalMem, +}: { + sandboxId: string + totalMem?: number +}) => { const memUsedMb = useSandboxMetricsStore( (state) => state.metrics?.[sandboxId]?.memUsedMb ) @@ -49,8 +67,13 @@ const RamUsageCellView = ({ sandboxId, totalMem }: RamUsageCellProps) => { ) } -type DiskUsageCellProps = { sandboxId: string; totalDiskGb: number } -const DiskUsageCellView = ({ sandboxId, totalDiskGb }: DiskUsageCellProps) => { +const DiskUsageCellView = ({ + sandboxId, + totalDiskGb, +}: { + sandboxId: string + totalDiskGb: number +}) => { const diskUsedGb = useSandboxMetricsStore( (state) => state.metrics?.[sandboxId]?.diskUsedGb ) @@ -67,34 +90,77 @@ const DiskUsageCellView = ({ sandboxId, totalDiskGb }: DiskUsageCellProps) => { export const CpuUsageCell = ({ row }: CellContext) => (
- + {row.original.state === 'running' ? ( + + ) : ( + + )}
) export const RamUsageCell = ({ row }: CellContext) => (
- + {row.original.state === 'running' ? ( + + ) : ( + + )}
) export const DiskUsageCell = ({ row, -}: CellContext) => { - const diskSizeGB = row.original.diskSizeMB / 1024 - - return ( -
+}: CellContext) => ( +
+ {row.original.state === 'running' ? ( -
+ ) : ( + + )} +
+) + +export function StateCell({ row }: CellContext) { + const state = row.original.state + + if (state === 'paused') { + return ( + + + Paused + + ) + } + + return ( + + + Running + ) } @@ -151,7 +217,7 @@ export function MetadataCell({ }, [value]) if (!parsedValue || value.trim() === '{}') { - return n/a + return -- } return ( @@ -174,7 +240,7 @@ export function StartedAtCell({ }, [dateValue]) return ( -
+
{formattedTimestamp?.datePart ?? '--'} {' '} diff --git a/src/features/dashboard/sandboxes/list/table-config.tsx b/src/features/dashboard/sandboxes/list/table-config.tsx index c317ea3fb..706b85271 100644 --- a/src/features/dashboard/sandboxes/list/table-config.tsx +++ b/src/features/dashboard/sandboxes/list/table-config.tsx @@ -13,6 +13,7 @@ import { MetadataCell, RamUsageCell, StartedAtCell, + StateCell, TemplateCell, } from './table-cells' @@ -102,6 +103,100 @@ export const templateIdentifierFilter: FilterFn = ( // TABLE CONFIG export const sandboxListColumns: ColumnDef[] = [ + { + id: 'startedAt', + accessorKey: 'startedAt', + header: 'Started At', + cell: StartedAtCell, + size: 150, + enableResizing: false, + filterFn: startedAtDateRangeFilter, + enableColumnFilter: true, + enableGlobalFilter: false, + sortingFn: (rowA, rowB) => { + return rowA.original.startedAt.localeCompare(rowB.original.startedAt) + }, + }, + { + accessorKey: 'sandboxID', + header: 'Instance', + cell: IdCell, + size: 165, + minSize: 100, + enableResizing: false, + enableColumnFilter: false, + enableSorting: false, + enableGlobalFilter: true, + }, + { + accessorKey: 'state', + id: 'state', + header: 'State', + cell: StateCell, + size: 90, + minSize: 80, + enableResizing: false, + enableSorting: false, + enableColumnFilter: true, + filterFn: 'equalsString', + enableGlobalFilter: false, + }, + { + accessorFn: (row) => row.alias || row.templateID, + id: 'template', + header: 'TEMPLATE', + cell: TemplateCell, + size: 250, + minSize: 100, + maxSize: 350, + enableResizing: true, + filterFn: templateIdentifierFilter, + enableGlobalFilter: false, + }, + { + id: 'cpuUsage', + header: 'CPU', + cell: (props) => , + size: 100, + enableResizing: false, + enableSorting: false, + enableColumnFilter: true, + filterFn: resourceEqualsFilter, + }, + { + id: 'ramUsage', + header: 'Memory', + cell: (props) => , + size: 140, + enableResizing: false, + enableSorting: false, + enableColumnFilter: true, + filterFn: resourceEqualsFilter, + }, + { + id: 'diskUsage', + header: 'Disk', + cell: (props) => , + size: 100, + enableResizing: false, + enableSorting: false, + enableColumnFilter: false, + }, + { + id: 'metadata', + accessorFn: (row) => JSON.stringify(row.metadata ?? {}), + header: 'Metadata', + cell: MetadataCell, + filterFn: 'includesStringSensitive', + enableGlobalFilter: false, + size: 200, + minSize: 160, + enableResizing: true, + enableSorting: false, + }, +] + +export const legacySandboxListColumns: ColumnDef[] = [ { accessorKey: 'sandboxID', header: 'ID', diff --git a/src/features/dashboard/sandboxes/list/table.tsx b/src/features/dashboard/sandboxes/list/table.tsx index 3ff356aa7..21c0e6e56 100644 --- a/src/features/dashboard/sandboxes/list/table.tsx +++ b/src/features/dashboard/sandboxes/list/table.tsx @@ -1,7 +1,12 @@ 'use client' -import { keepPreviousData, useSuspenseQuery } from '@tanstack/react-query' import { + keepPreviousData, + useSuspenseInfiniteQuery, + useSuspenseQuery, +} from '@tanstack/react-query' +import { + type ColumnDef, type ColumnFiltersState, type ColumnSizingState, flexRender, @@ -13,6 +18,7 @@ import { import { subHours } from 'date-fns' import { useEffect, useMemo, useRef } from 'react' import { useLocalStorage } from 'usehooks-ts' +import type { Sandboxes } from '@/core/modules/sandboxes/models' import { useSandboxListTableStore } from '@/features/dashboard/sandboxes/list/stores/table-store' import { useColumnSizeVars } from '@/lib/hooks/use-column-size-vars' import { useRouteParams } from '@/lib/hooks/use-route-params' @@ -31,7 +37,11 @@ import type { SandboxStartedAtFilter } from './stores/table-store' import { getSandboxListEffectiveSorting } from './stores/table-store' import { SandboxesTableBody } from './table-body' import type { SandboxListRow } from './table-config' -import { sandboxIdFuzzyFilter, sandboxListColumns } from './table-config' +import { + legacySandboxListColumns, + sandboxIdFuzzyFilter, + sandboxListColumns, +} from './table-config' const STARTED_AT_FILTER_HOURS: Record< Exclude, @@ -77,13 +87,30 @@ function buildColumnFilters({ return filters } -export default function SandboxesTable() { +const SANDBOXES_PAGE_SIZE = 50 + +interface SandboxesTableViewProps { + sandboxes: Sandboxes + columns: ColumnDef[] + refetch: () => void + isFetching: boolean + hasNextPage?: boolean + isFetchingNextPage?: boolean + fetchNextPage?: () => void +} + +function SandboxesTableView({ + sandboxes, + columns, + refetch, + isFetching, + hasNextPage, + isFetchingNextPage, + fetchNextPage, +}: SandboxesTableViewProps) { 'use no memo' const scrollRef = useRef(null) - const { teamSlug } = useRouteParams<'/dashboard/[teamSlug]/sandboxes'>() - - const trpc = useTRPC() const [columnSizing, setColumnSizing] = useLocalStorage( 'sandboxes:columnSizing:v2', @@ -94,17 +121,6 @@ export default function SandboxesTable() { } ) - const { data, refetch, isFetching } = useSuspenseQuery( - trpc.sandboxes.getSandboxes.queryOptions( - { teamSlug }, - { - refetchOnMount: 'always', - refetchOnWindowFocus: true, - placeholderData: keepPreviousData, - } - ) - ) - const { startedAtFilter, templateFilters, @@ -130,8 +146,8 @@ export default function SandboxesTable() { const activeSorting = getSandboxListEffectiveSorting(sorting) const table = useReactTable({ - columns: sandboxListColumns, - data: data.sandboxes, + columns, + data: sandboxes, state: { globalFilter, sorting: activeSorting, @@ -154,13 +170,19 @@ export default function SandboxesTable() { }) const columnSizeVars = useColumnSizeVars(table) + const scrollResetKey = useMemo( + () => JSON.stringify({ activeSorting, globalFilter, columnFilters }), + [activeSorting, globalFilter, columnFilters] + ) useEffect(() => { + void scrollResetKey + if (scrollRef.current) { scrollRef.current.scrollTop = 0 scrollRef.current.scrollLeft = 0 } - }, [activeSorting, globalFilter, columnFilters]) + }, [scrollResetKey]) const tableSorting = table.getState().sorting @@ -215,9 +237,88 @@ export default function SandboxesTable() { ))} - +
) } + +export function NewSandboxesTable() { + const { teamSlug } = useRouteParams<'/dashboard/[teamSlug]/sandboxes'>() + const trpc = useTRPC() + const pollingInterval = useSandboxListTableStore( + (state) => state.pollingInterval + ) + + const { + data, + refetch, + isFetching, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = useSuspenseInfiniteQuery( + trpc.sandboxes.listSandboxesPaginated.infiniteQueryOptions( + { teamSlug, limit: SANDBOXES_PAGE_SIZE }, + { + getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, + initialCursor: undefined, + refetchOnMount: 'always', + refetchOnWindowFocus: true, + refetchInterval: pollingInterval > 0 ? pollingInterval * 1_000 : false, + placeholderData: keepPreviousData, + } + ) + ) + + const sandboxes = useMemo( + () => data.pages.flatMap((page) => page.sandboxes), + [data] + ) + + return ( + + ) +} + +export function LegacySandboxesTable() { + const { teamSlug } = useRouteParams<'/dashboard/[teamSlug]/sandboxes'>() + const trpc = useTRPC() + + const { data, refetch, isFetching } = useSuspenseQuery( + trpc.sandboxes.getSandboxes.queryOptions( + { teamSlug }, + { + refetchOnMount: 'always', + refetchOnWindowFocus: true, + placeholderData: keepPreviousData, + } + ) + ) + + return ( + + ) +} + +export default LegacySandboxesTable diff --git a/tests/unit/feature-flags.test.ts b/tests/unit/feature-flags.test.ts index a99fd139a..8f28651b7 100644 --- a/tests/unit/feature-flags.test.ts +++ b/tests/unit/feature-flags.test.ts @@ -66,6 +66,7 @@ describe('createFeatureFlagService', () => { expect(provider.evaluate).toHaveBeenCalledWith(context, [ FEATURE_FLAGS.agentsEnabled, FEATURE_FLAGS.isAdmin, + FEATURE_FLAGS.newSandboxList, FEATURE_FLAGS.disableE2BAccessTokenProvisioning, ]) expect(result).toEqual([ @@ -85,6 +86,15 @@ describe('createFeatureFlagService', () => { defaultValue: false, value: true, }, + { + id: 'newSandboxList', + key: 'new_sandbox_list', + kind: 'boolean', + description: + 'Enables the new sandbox list with pagination and paused sandbox coverage.', + defaultValue: false, + value: true, + }, { id: 'disableE2BAccessTokenProvisioning', key: 'disable_e2b_access_token_provisioning',