From c978681dee86019d2ffe0122bf3233f3aabd4528 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Huvar?= Date: Tue, 23 Jun 2026 10:32:14 +0200 Subject: [PATCH 01/12] feat: paginate sandboxes list and cover paused + running states Migrate the sandboxes list page from the legacy running-only GET /sandboxes to GET /v2/sandboxes with state=[running,paused] and cursor pagination (X-Next-Token). The list now infinite-scrolls, shows a State column, and renders allocated specs instead of the live per-row metrics overlay (removed). EN-1030 --- .../[teamSlug]/sandboxes/(tabs)/list/page.tsx | 3 +- src/configs/mock-data.ts | 131 +----------------- src/core/modules/sandboxes/models.client.ts | 12 -- src/core/modules/sandboxes/models.ts | 3 +- .../modules/sandboxes/repository.server.ts | 70 ++++------ src/core/server/api/routers/sandboxes.ts | 53 +++---- src/core/server/functions/sandboxes/utils.ts | 25 +--- .../list/hooks/use-sandboxes-metrics.tsx | 81 ----------- .../sandboxes/list/stores/metrics-store.ts | 53 ------- .../dashboard/sandboxes/list/table-body.tsx | 62 ++++++--- .../dashboard/sandboxes/list/table-cells.tsx | 96 ++++--------- .../dashboard/sandboxes/list/table-config.tsx | 14 ++ .../dashboard/sandboxes/list/table.tsx | 55 ++++++-- 13 files changed, 176 insertions(+), 482 deletions(-) delete mode 100644 src/features/dashboard/sandboxes/list/hooks/use-sandboxes-metrics.tsx delete mode 100644 src/features/dashboard/sandboxes/list/stores/metrics-store.ts diff --git a/src/app/dashboard/[teamSlug]/sandboxes/(tabs)/list/page.tsx b/src/app/dashboard/[teamSlug]/sandboxes/(tabs)/list/page.tsx index 28eeef355..2b3ce5ef7 100644 --- a/src/app/dashboard/[teamSlug]/sandboxes/(tabs)/list/page.tsx +++ b/src/app/dashboard/[teamSlug]/sandboxes/(tabs)/list/page.tsx @@ -9,8 +9,9 @@ export default async function SandboxesListPage({ const { teamSlug } = await params prefetch( - trpc.sandboxes.getSandboxes.queryOptions({ + trpc.sandboxes.getSandboxes.infiniteQueryOptions({ teamSlug, + limit: 50, }) ) diff --git a/src/configs/mock-data.ts b/src/configs/mock-data.ts index 4d886d6b3..cb0e265bb 100644 --- a/src/configs/mock-data.ts +++ b/src/configs/mock-data.ts @@ -1,10 +1,7 @@ import { addHours, subHours } from 'date-fns' import { nanoid } from 'nanoid' import type { Sandbox, Sandboxes } from '@/core/modules/sandboxes/models' -import type { - ClientSandboxesMetrics, - ClientTeamMetrics, -} from '@/core/modules/sandboxes/models.client' +import type { ClientTeamMetrics } from '@/core/modules/sandboxes/models.client' import type { DefaultTemplate, Template } from '@/core/modules/templates/models' const DEFAULT_TEMPLATES: DefaultTemplate[] = [ @@ -824,130 +821,6 @@ function generateMockSandboxes(count: number): Sandboxes { return sandboxes } -function generateMockMetrics(sandboxes: Sandbox[]): { - metrics: ClientSandboxesMetrics -} { - const metrics: ClientSandboxesMetrics = {} - - // Define characteristics by template type - const templatePatterns: Record< - string, - { memoryProfile: string; cpuIntensity: number; diskGb: number } - > = { - 'node-typescript-v1': { - memoryProfile: 'web', - cpuIntensity: 0.4, - diskGb: 0, - }, - 'react-vite-v2': { memoryProfile: 'web', cpuIntensity: 0.5, diskGb: 10 }, - 'postgres-v15': { - memoryProfile: 'database', - cpuIntensity: 0.6, - diskGb: 100, - }, - 'redis-v7': { memoryProfile: 'cache', cpuIntensity: 0.2, diskGb: 20 }, - 'python-ml-v1': { memoryProfile: 'ml', cpuIntensity: 0.9, diskGb: 50 }, - 'elastic-v8': { memoryProfile: 'search', cpuIntensity: 0.7, diskGb: 80 }, - 'grafana-v9': { - memoryProfile: 'visualization', - cpuIntensity: 0.3, - diskGb: 15, - }, - 'nginx-v1': { memoryProfile: 'web', cpuIntensity: 0.2, diskGb: 0 }, - 'mongodb-v6': { memoryProfile: 'database', cpuIntensity: 0.5, diskGb: 100 }, - 'mysql-v8': { memoryProfile: 'database', cpuIntensity: 0.6, diskGb: 100 }, - } - - const memoryBaselines: Record = { - web: 0.15, - database: 0.4, - cache: 0.2, - ml: 0.6, - search: 0.45, - visualization: 0.25, - } - - const memoryVolatility: Record = { - web: 0.15, - database: 0.1, - cache: 0.3, - ml: 0.35, - search: 0.2, - visualization: 0.15, - } - - const diskBaselines: Record = { - web: 0.1, - database: 0.5, - cache: 0.05, - ml: 0.4, - search: 0.3, - visualization: 0.2, - } - const diskVolatility: Record = { - web: 0.2, - database: 0.15, - cache: 0.1, - ml: 0.3, - search: 0.25, - visualization: 0.15, - } - - for (const sandbox of sandboxes) { - const pattern = templatePatterns[sandbox.templateID] || { - memoryProfile: 'web', - cpuIntensity: 0.5, - diskGb: 20, - } - - const memBaseline = memoryBaselines[pattern.memoryProfile]! - const memVolatility = memoryVolatility[pattern.memoryProfile]! - - // Generate current load based on time of day - const hourOfDay = new Date().getHours() - const isBusinessHours = hourOfDay >= 8 && hourOfDay <= 18 - const baseLoad = isBusinessHours - ? 0.5 + Math.random() * 0.3 - : 0.2 + Math.random() * 0.2 - - // CPU calculation - const cpuSpike = Math.random() < 0.1 ? Math.random() * 0.5 : 0 - const cpuLoad = Math.max( - 0, - Math.min(1, (baseLoad + cpuSpike) * pattern.cpuIntensity) - ) - const cpuUsedPct = Math.min(100, Math.max(0, cpuLoad * 100)) - - // Memory calculation - const memoryNoise = (Math.random() - 0.5) * memVolatility - const memPct = memBaseline + baseLoad * memVolatility + memoryNoise - const memUsedMb = Math.floor(sandbox.memoryMB * Math.min(1.0, memPct)) - const diskBaseline = diskBaselines[pattern.memoryProfile]! - const diskVolatilityVal = diskVolatility[pattern.memoryProfile]! - const diskNoise = (Math.random() - 0.5) * 0.1 - const diskPct = diskBaseline + baseLoad * diskVolatilityVal + diskNoise - // Use sandbox's declared disk size (in MB) as the total capacity - const sandboxDiskTotalGb = (sandbox.diskSizeMB ?? 0) / 1024 - const clampedDiskPct = Math.min(1, Math.max(0, diskPct)) - const diskUsedGb = Number((sandboxDiskTotalGb * clampedDiskPct).toFixed(2)) - const diskTotalGb = Number(sandboxDiskTotalGb.toFixed(2)) - - metrics[sandbox.sandboxID] = { - cpuCount: sandbox.cpuCount, - cpuUsedPct, - memTotalMb: sandbox.memoryMB, - memUsedMb: memUsedMb, - timestamp: new Date().toISOString(), - diskUsedGb, - diskTotalGb, - } - } - - return { - metrics, - } -} - /** * This function replicates the back-end step calculation logic from e2b-dev/infra. * https://github.com/e2b-dev/infra/blob/19778a715e8df3adea83858c798582d289bd7159/packages/api/internal/handlers/sandbox_metrics.go#L90 @@ -1167,8 +1040,6 @@ export function generateMockTeamMetrics( return { metrics, step } } -export const MOCK_METRICS_DATA = (sandboxes: Sandbox[]) => - generateMockMetrics(sandboxes) export const MOCK_SANDBOXES_DATA = () => generateMockSandboxes(120) export const MOCK_TEMPLATES_DATA = TEMPLATES export const MOCK_DEFAULT_TEMPLATES_DATA = DEFAULT_TEMPLATES diff --git a/src/core/modules/sandboxes/models.client.ts b/src/core/modules/sandboxes/models.client.ts index 37049db03..60ba930ac 100644 --- a/src/core/modules/sandboxes/models.client.ts +++ b/src/core/modules/sandboxes/models.client.ts @@ -1,17 +1,5 @@ import type { TeamMetric } from './models' -export type ClientSandboxMetric = { - cpuCount: number - cpuUsedPct: number - memUsedMb: number - memTotalMb: number - timestamp: string - diskUsedGb: number - diskTotalGb: number -} - -export type ClientSandboxesMetrics = Record - export type ClientTeamMetric = Pick< TeamMetric, 'concurrentSandboxes' | 'sandboxStartRate' diff --git a/src/core/modules/sandboxes/models.ts b/src/core/modules/sandboxes/models.ts index 36044f9b3..a2fc56418 100644 --- a/src/core/modules/sandboxes/models.ts +++ b/src/core/modules/sandboxes/models.ts @@ -5,8 +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 SandboxesMetricsRecord = - InfraComponents['schemas']['SandboxesWithMetrics']['sandboxes'] +export type SandboxState = InfraComponents['schemas']['SandboxState'] export type TeamMetric = InfraComponents['schemas']['TeamMetric'] export type SandboxInfo = InfraComponents['schemas']['SandboxDetail'] diff --git a/src/core/modules/sandboxes/repository.server.ts b/src/core/modules/sandboxes/repository.server.ts index 0d69d4e34..734672f08 100644 --- a/src/core/modules/sandboxes/repository.server.ts +++ b/src/core/modules/sandboxes/repository.server.ts @@ -6,7 +6,7 @@ import type { components as InfraComponents } from '@/contracts/infra-api' import type { SandboxEventModel, Sandboxes, - SandboxesMetricsRecord, + SandboxState, TeamMetric, } from '@/core/modules/sandboxes/models' import { api, infra } from '@/core/shared/clients/api' @@ -36,6 +36,18 @@ export interface GetSandboxMetricsOptions { endUnixMs: number } +export interface ListSandboxesOptions { + cursor?: string + limit: number +} + +export interface ListSandboxesResult { + sandboxes: Sandboxes + nextCursor: string | null +} + +const DEFAULT_SANDBOX_STATES: SandboxState[] = ['running', 'paused'] + export interface SandboxesRepository { getSandboxLogs( sandboxId: string, @@ -60,10 +72,9 @@ export interface SandboxesRepository { sandboxId: string, options: GetSandboxMetricsOptions ): Promise> - listSandboxes(): Promise> - getSandboxesMetrics( - sandboxIds: string[] - ): Promise> + listSandboxes( + options: ListSandboxesOptions + ): Promise> getTeamMetricsRange( startUnixSeconds: number, endUnixSeconds: number @@ -356,40 +367,13 @@ export function createSandboxesRepository( return ok(result.data) }, - async listSandboxes() { - const result = await deps.infraClient.GET('/sandboxes', { - headers: { - ...deps.authHeaders(scope.accessToken, scope.teamId), - }, - cache: 'no-store', - }) - - if (!result.response.ok || result.error) { - l.error({ - key: 'repositories:sandboxes:list_sandboxes:infra_error', - error: result.error, - team_id: scope.teamId, - context: { - status: result.response.status, - path: '/sandboxes', - }, - }) - return err( - repoErrorFromHttp( - result.response.status, - result.error?.message ?? 'Failed to list sandboxes', - result.error - ) - ) - } - - return ok(result.data) - }, - async getSandboxesMetrics(sandboxIds) { - const result = await deps.infraClient.GET('/sandboxes/metrics', { + async listSandboxes(options) { + const result = await deps.infraClient.GET('/v2/sandboxes', { params: { query: { - sandbox_ids: sandboxIds, + state: DEFAULT_SANDBOX_STATES, + nextToken: options.cursor, + limit: options.limit, }, }, headers: { @@ -400,25 +384,27 @@ export function createSandboxesRepository( if (!result.response.ok || result.error) { l.error({ - key: 'repositories:sandboxes:get_sandboxes_metrics:infra_error', + key: 'repositories:sandboxes:list_sandboxes:infra_error', error: result.error, team_id: scope.teamId, context: { status: result.response.status, - path: '/sandboxes/metrics', - sandbox_ids: sandboxIds, + path: '/v2/sandboxes', }, }) return err( repoErrorFromHttp( result.response.status, - result.error?.message ?? 'Failed to fetch sandboxes metrics', + result.error?.message ?? 'Failed to list sandboxes', result.error ) ) } - return ok(result.data.sandboxes) + return ok({ + sandboxes: result.data ?? [], + nextCursor: result.response.headers.get('x-next-token') || null, + }) }, async getTeamMetricsRange(startUnixSeconds, endUnixSeconds) { const result = await deps.infraClient.GET('/teams/{teamID}/metrics', { diff --git a/src/core/server/api/routers/sandboxes.ts b/src/core/server/api/routers/sandboxes.ts index 781f0d70d..02abab56b 100644 --- a/src/core/server/api/routers/sandboxes.ts +++ b/src/core/server/api/routers/sandboxes.ts @@ -13,10 +13,7 @@ import { } from '@/core/modules/sandboxes/schemas' import { throwTRPCErrorFromRepoError } from '@/core/server/adapters/errors' import { withTeamAuthedRequestRepository } from '@/core/server/api/middlewares/repository' -import { - fillTeamMetricsWithZeros, - transformMetricsToClientMetrics, -} from '@/core/server/functions/sandboxes/utils' +import { fillTeamMetricsWithZeros } from '@/core/server/functions/sandboxes/utils' import { createTRPCRouter } from '@/core/server/trpc/init' import { protectedTeamProcedure } from '@/core/server/trpc/procedures' @@ -31,52 +28,34 @@ const sandboxesRepositoryProcedure = protectedTeamProcedure.use( export const sandboxesRouter = createTRPCRouter({ // QUERIES - getSandboxes: sandboxesRepositoryProcedure.query(async ({ ctx }) => { - if (USE_MOCK_DATA) { - await new Promise((resolve) => setTimeout(resolve, 200)) - - const sandboxes = MOCK_SANDBOXES_DATA() - - return { - sandboxes, - } - } - - const sandboxesResult = await ctx.sandboxesRepository.listSandboxes() - if (!sandboxesResult.ok) { - throwTRPCErrorFromRepoError(sandboxesResult.error) - } - - return { - sandboxes: sandboxesResult.data, - } - }), - - getSandboxesMetrics: sandboxesRepositoryProcedure + getSandboxes: sandboxesRepositoryProcedure .input( z.object({ - sandboxIds: z.array(z.string()), + cursor: z.string().optional(), + limit: z.number().int().min(1).max(100).default(50), }) ) .query(async ({ ctx, input }) => { - const { sandboxIds } = input + if (USE_MOCK_DATA) { + await new Promise((resolve) => setTimeout(resolve, 200)) - if (sandboxIds.length === 0 || USE_MOCK_DATA) { return { - metrics: {}, + sandboxes: MOCK_SANDBOXES_DATA(), + nextCursor: null, } } - const metricsDataResult = - await ctx.sandboxesRepository.getSandboxesMetrics(sandboxIds) - if (!metricsDataResult.ok) { - throwTRPCErrorFromRepoError(metricsDataResult.error) + const sandboxesResult = await ctx.sandboxesRepository.listSandboxes({ + cursor: input.cursor, + limit: input.limit, + }) + if (!sandboxesResult.ok) { + throwTRPCErrorFromRepoError(sandboxesResult.error) } - const metricsData = metricsDataResult.data - const metrics = transformMetricsToClientMetrics(metricsData) return { - metrics, + sandboxes: sandboxesResult.data.sandboxes, + nextCursor: sandboxesResult.data.nextCursor, } }), diff --git a/src/core/server/functions/sandboxes/utils.ts b/src/core/server/functions/sandboxes/utils.ts index 4d0d2fda0..271262424 100644 --- a/src/core/server/functions/sandboxes/utils.ts +++ b/src/core/server/functions/sandboxes/utils.ts @@ -1,28 +1,5 @@ import { TEAM_METRICS_BACKEND_COLLECTION_INTERVAL_MS } from '@/configs/intervals' -import type { SandboxesMetricsRecord } from '@/core/modules/sandboxes/models' -import type { - ClientSandboxesMetrics, - ClientTeamMetrics, -} from '@/core/modules/sandboxes/models.client' - -export function transformMetricsToClientMetrics( - metrics: SandboxesMetricsRecord -): ClientSandboxesMetrics { - return Object.fromEntries( - Object.entries(metrics).map(([sandboxID, metric]) => [ - sandboxID, - { - cpuCount: metric.cpuCount, - cpuUsedPct: Number(metric.cpuUsedPct.toFixed(2)), - memUsedMb: Number((metric.memUsed / 1024 / 1024).toFixed(2)), - memTotalMb: Number((metric.memTotal / 1024 / 1024).toFixed(2)), - diskUsedGb: Number((metric.diskUsed / 1024 / 1024 / 1024).toFixed(2)), - diskTotalGb: Number((metric.diskTotal / 1024 / 1024 / 1024).toFixed(2)), - timestamp: metric.timestamp, - }, - ]) - ) -} +import type { ClientTeamMetrics } from '@/core/modules/sandboxes/models.client' export function calculateStepForRange(startMs: number, endMs: number): number { const duration = endMs - startMs diff --git a/src/features/dashboard/sandboxes/list/hooks/use-sandboxes-metrics.tsx b/src/features/dashboard/sandboxes/list/hooks/use-sandboxes-metrics.tsx deleted file mode 100644 index 87c207a16..000000000 --- a/src/features/dashboard/sandboxes/list/hooks/use-sandboxes-metrics.tsx +++ /dev/null @@ -1,81 +0,0 @@ -'use client' - -import { useQuery } from '@tanstack/react-query' -import { useEffect, useMemo, useRef } from 'react' -import { SANDBOXES_METRICS_POLLING_MS } from '@/configs/intervals' -import type { Sandboxes } from '@/core/modules/sandboxes/models' -import { areStringArraysEqual } from '@/lib/utils/array' -import { useTRPC } from '@/trpc/client' -import { useDashboard } from '../../../context' -import { useSandboxMetricsStore } from '../stores/metrics-store' - -interface UseSandboxesMetricsProps { - sandboxes: Sandboxes - pollingIntervalMs?: number - isListScrolling?: boolean -} - -function useStableSandboxIdsWhileScrolling( - sandboxIds: string[], - isListScrolling: boolean -) { - const activeSandboxIdsRef = useRef(sandboxIds) - - if ( - !isListScrolling && - !areStringArraysEqual(activeSandboxIdsRef.current, sandboxIds) - ) { - activeSandboxIdsRef.current = sandboxIds - } - - return activeSandboxIdsRef.current -} - -export function useSandboxesMetrics({ - sandboxes, - pollingIntervalMs = SANDBOXES_METRICS_POLLING_MS, - isListScrolling = false, -}: UseSandboxesMetricsProps) { - const { team } = useDashboard() - const trpc = useTRPC() - - const sandboxIds = useMemo( - () => sandboxes.map((sbx) => sbx.sandboxID), - [sandboxes] - ) - const activeSandboxIds = useStableSandboxIdsWhileScrolling( - sandboxIds, - isListScrolling - ) - - const setMetrics = useSandboxMetricsStore((s) => s.setMetrics) - const shouldEnableMetricsQuery = - !isListScrolling && activeSandboxIds.length > 0 - const metricsRefetchInterval = - pollingIntervalMs > 0 ? pollingIntervalMs : false - - const metricsQueryInput = useMemo( - () => ({ - teamSlug: team.slug, - sandboxIds: activeSandboxIds, - }), - [activeSandboxIds, team.slug] - ) - - const { data } = useQuery( - trpc.sandboxes.getSandboxesMetrics.queryOptions(metricsQueryInput, { - enabled: shouldEnableMetricsQuery, - refetchInterval: metricsRefetchInterval, - refetchOnWindowFocus: false, - refetchOnMount: false, - refetchOnReconnect: true, - refetchIntervalInBackground: false, - }) - ) - - useEffect(() => { - if (data?.metrics) { - setMetrics(data.metrics) - } - }, [data, setMetrics]) -} diff --git a/src/features/dashboard/sandboxes/list/stores/metrics-store.ts b/src/features/dashboard/sandboxes/list/stores/metrics-store.ts deleted file mode 100644 index a7ed7fdbd..000000000 --- a/src/features/dashboard/sandboxes/list/stores/metrics-store.ts +++ /dev/null @@ -1,53 +0,0 @@ -'use client' - -import { create } from 'zustand' -import type { ClientSandboxesMetrics } from '@/core/modules/sandboxes/models.client' - -// maximum number of sandbox metrics to keep in memory -// this is to prevent the store from growing too large and causing performance issues -const MAX_METRICS_ENTRIES = 500 - -interface SandboxMetricsState { - metrics: ClientSandboxesMetrics -} - -interface SandboxMetricsActions { - setMetrics: (metrics: ClientSandboxesMetrics) => void -} - -type Store = SandboxMetricsState & SandboxMetricsActions - -const initialState: SandboxMetricsState = { - metrics: {}, -} - -export const useSandboxMetricsStore = create()((set) => ({ - ...initialState, - setMetrics: (metrics) => { - set((state) => { - const mergedMetrics = { ...state.metrics, ...metrics } - const entries = Object.entries(mergedMetrics) - - // if we're under the cap, just return the merged metrics - if (entries.length <= MAX_METRICS_ENTRIES) { - return { metrics: mergedMetrics } - } - - // sort entries by timestamp (oldest first) - entries.sort((a, b) => { - const timestampA = new Date(a[1].timestamp).getTime() - const timestampB = new Date(b[1].timestamp).getTime() - return timestampA - timestampB - }) - - // remove oldest entries to make room for new ones - const entriesToRemove = entries.length - MAX_METRICS_ENTRIES - const keptEntries = entries.slice(entriesToRemove) - - // convert back to object - const cappedMetrics = Object.fromEntries(keptEntries) - - return { metrics: cappedMetrics } - }) - }, -})) diff --git a/src/features/dashboard/sandboxes/list/table-body.tsx b/src/features/dashboard/sandboxes/list/table-body.tsx index ec6b92073..ef7dcaf1a 100644 --- a/src/features/dashboard/sandboxes/list/table-body.tsx +++ b/src/features/dashboard/sandboxes/list/table-body.tsx @@ -1,32 +1,36 @@ -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' -import { useSandboxesMetrics } from './hooks/use-sandboxes-metrics' import { useSandboxListTableStore } from './stores/table-store' import type { SandboxListRow, SandboxListTable } from './table-config' 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, + isFetchingNextPage, + fetchNextPage, }: SandboxesTableBodyProps) => { 'use no memo' const resetFilters = useSandboxListTableStore((state) => state.resetFilters) - const pollingInterval = useSandboxListTableStore( - (state) => state.pollingInterval - ) const hasFilter = useSandboxListTableStore((state) => { return ( state.startedAtFilter !== undefined || @@ -54,14 +58,23 @@ 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 + const lastVisibleIndex = virtualizer.getVirtualItems().at(-1)?.index ?? -1 - useSandboxesMetrics({ - sandboxes: visibleSandboxes, - pollingIntervalMs: pollingInterval === 0 ? 0 : pollingInterval * 1_000, - isListScrolling, - }) + useEffect(() => { + if ( + hasNextPage && + !isFetchingNextPage && + lastVisibleIndex >= centerRows.length - PREFETCH_THRESHOLD + ) { + fetchNextPage() + } + }, [ + hasNextPage, + isFetchingNextPage, + lastVisibleIndex, + centerRows.length, + fetchNextPage, + ]) const isEmpty = centerRows.length === 0 @@ -80,7 +93,7 @@ export const SandboxesTableBody = ({ ) : ( @@ -101,11 +114,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..3852f54c3 100644 --- a/src/features/dashboard/sandboxes/list/table-cells.tsx +++ b/src/features/dashboard/sandboxes/list/table-cells.tsx @@ -7,94 +7,56 @@ 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' -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) => { - const cpuUsedPct = useSandboxMetricsStore( - (state) => state.metrics?.[sandboxId]?.cpuUsedPct - ) - - return ( - - ) -} - -type RamUsageCellProps = { sandboxId: string; totalMem?: number } -const RamUsageCellView = ({ sandboxId, totalMem }: RamUsageCellProps) => { - const memUsedMb = useSandboxMetricsStore( - (state) => state.metrics?.[sandboxId]?.memUsedMb - ) - - return ( - - ) -} - -type DiskUsageCellProps = { sandboxId: string; totalDiskGb: number } -const DiskUsageCellView = ({ sandboxId, totalDiskGb }: DiskUsageCellProps) => { - const diskUsedGb = useSandboxMetricsStore( - (state) => state.metrics?.[sandboxId]?.diskUsedGb - ) - - return ( - - ) -} - export const CpuUsageCell = ({ row }: CellContext) => (
- +
) export const RamUsageCell = ({ row }: CellContext) => (
- +
) export const DiskUsageCell = ({ row, -}: CellContext) => { - const diskSizeGB = row.original.diskSizeMB / 1024 +}: CellContext) => ( +
+ +
+) + +export function StateCell({ row }: CellContext) { + const state = row.original.state + + if (state === 'paused') { + return ( + + + Paused + + ) + } return ( -
- -
+ + + Running + ) } diff --git a/src/features/dashboard/sandboxes/list/table-config.tsx b/src/features/dashboard/sandboxes/list/table-config.tsx index c317ea3fb..f8323b641 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' @@ -125,6 +126,19 @@ export const sandboxListColumns: ColumnDef[] = [ filterFn: templateIdentifierFilter, enableGlobalFilter: false, }, + { + accessorKey: 'state', + id: 'state', + header: 'State', + cell: StateCell, + size: 120, + minSize: 100, + enableResizing: false, + enableSorting: false, + enableColumnFilter: true, + filterFn: 'equalsString', + enableGlobalFilter: false, + }, { id: 'cpuUsage', header: 'CPU', diff --git a/src/features/dashboard/sandboxes/list/table.tsx b/src/features/dashboard/sandboxes/list/table.tsx index 3ff356aa7..458e2ef33 100644 --- a/src/features/dashboard/sandboxes/list/table.tsx +++ b/src/features/dashboard/sandboxes/list/table.tsx @@ -1,6 +1,9 @@ 'use client' -import { keepPreviousData, useSuspenseQuery } from '@tanstack/react-query' +import { + keepPreviousData, + useSuspenseInfiniteQuery, +} from '@tanstack/react-query' import { type ColumnFiltersState, type ColumnSizingState, @@ -77,6 +80,8 @@ function buildColumnFilters({ return filters } +const SANDBOXES_PAGE_SIZE = 50 + export default function SandboxesTable() { 'use no memo' @@ -94,17 +99,6 @@ export default function SandboxesTable() { } ) - const { data, refetch, isFetching } = useSuspenseQuery( - trpc.sandboxes.getSandboxes.queryOptions( - { teamSlug }, - { - refetchOnMount: 'always', - refetchOnWindowFocus: true, - placeholderData: keepPreviousData, - } - ) - ) - const { startedAtFilter, templateFilters, @@ -112,10 +106,37 @@ export default function SandboxesTable() { memoryMB, sorting, globalFilter, + pollingInterval, setSorting, setGlobalFilter, } = useSandboxListTableStore() + const { + data, + refetch, + isFetching, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = useSuspenseInfiniteQuery( + trpc.sandboxes.getSandboxes.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] + ) + const columnFilters = useMemo( () => buildColumnFilters({ @@ -131,7 +152,7 @@ export default function SandboxesTable() { const table = useReactTable({ columns: sandboxListColumns, - data: data.sandboxes, + data: sandboxes, state: { globalFilter, sorting: activeSorting, @@ -215,7 +236,13 @@ export default function SandboxesTable() { ))} - +
From 515bef4718f1ed46f55b8395f818677e8bfcbc4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Huvar?= Date: Tue, 23 Jun 2026 14:09:48 +0200 Subject: [PATCH 02/12] feat: align sandboxes list columns with Dashboard 2.0 design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Order: Started At · Instance · State · Template · CPU · Memory · Disk · Metadata. Move Started At to the first (sort) column, rename the ID column to Instance, and move State up next to Instance. --- .../dashboard/sandboxes/list/table-config.tsx | 54 +++++++++---------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/src/features/dashboard/sandboxes/list/table-config.tsx b/src/features/dashboard/sandboxes/list/table-config.tsx index f8323b641..1ab3ed4b7 100644 --- a/src/features/dashboard/sandboxes/list/table-config.tsx +++ b/src/features/dashboard/sandboxes/list/table-config.tsx @@ -103,9 +103,23 @@ 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: 'ID', + header: 'Instance', cell: IdCell, size: 165, minSize: 100, @@ -114,18 +128,6 @@ export const sandboxListColumns: ColumnDef[] = [ enableSorting: false, enableGlobalFilter: true, }, - { - accessorFn: (row) => row.alias || row.templateID, - id: 'template', - header: 'TEMPLATE', - cell: TemplateCell, - size: 250, - minSize: 100, - maxSize: 350, - enableResizing: true, - filterFn: templateIdentifierFilter, - enableGlobalFilter: false, - }, { accessorKey: 'state', id: 'state', @@ -139,6 +141,18 @@ export const sandboxListColumns: ColumnDef[] = [ 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', @@ -180,18 +194,4 @@ export const sandboxListColumns: ColumnDef[] = [ enableResizing: true, enableSorting: false, }, - { - 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) - }, - }, ] From 94daf758e46b21736e23cf2852bd978ea4309d20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Huvar?= Date: Tue, 23 Jun 2026 14:38:01 +0200 Subject: [PATCH 03/12] feat: show live CPU/memory/disk usage for running sandboxes Restore the per-row live-metrics overlay (from /sandboxes/metrics) for running sandboxes. Paused sandboxes have no live metrics, so they fall back to their allocated specs. Metrics are only requested for running visible rows. --- src/configs/mock-data.ts | 131 +++++++++++++++++- src/core/modules/sandboxes/models.client.ts | 12 ++ src/core/modules/sandboxes/models.ts | 2 + .../modules/sandboxes/repository.server.ts | 39 ++++++ src/core/server/api/routers/sandboxes.ts | 33 ++++- src/core/server/functions/sandboxes/utils.ts | 25 +++- .../list/hooks/use-sandboxes-metrics.tsx | 81 +++++++++++ .../sandboxes/list/stores/metrics-store.ts | 53 +++++++ .../dashboard/sandboxes/list/table-body.tsx | 17 +++ .../dashboard/sandboxes/list/table-cells.tsx | 103 +++++++++++++- 10 files changed, 486 insertions(+), 10 deletions(-) create mode 100644 src/features/dashboard/sandboxes/list/hooks/use-sandboxes-metrics.tsx create mode 100644 src/features/dashboard/sandboxes/list/stores/metrics-store.ts diff --git a/src/configs/mock-data.ts b/src/configs/mock-data.ts index cb0e265bb..4d886d6b3 100644 --- a/src/configs/mock-data.ts +++ b/src/configs/mock-data.ts @@ -1,7 +1,10 @@ import { addHours, subHours } from 'date-fns' import { nanoid } from 'nanoid' import type { Sandbox, Sandboxes } from '@/core/modules/sandboxes/models' -import type { ClientTeamMetrics } from '@/core/modules/sandboxes/models.client' +import type { + ClientSandboxesMetrics, + ClientTeamMetrics, +} from '@/core/modules/sandboxes/models.client' import type { DefaultTemplate, Template } from '@/core/modules/templates/models' const DEFAULT_TEMPLATES: DefaultTemplate[] = [ @@ -821,6 +824,130 @@ function generateMockSandboxes(count: number): Sandboxes { return sandboxes } +function generateMockMetrics(sandboxes: Sandbox[]): { + metrics: ClientSandboxesMetrics +} { + const metrics: ClientSandboxesMetrics = {} + + // Define characteristics by template type + const templatePatterns: Record< + string, + { memoryProfile: string; cpuIntensity: number; diskGb: number } + > = { + 'node-typescript-v1': { + memoryProfile: 'web', + cpuIntensity: 0.4, + diskGb: 0, + }, + 'react-vite-v2': { memoryProfile: 'web', cpuIntensity: 0.5, diskGb: 10 }, + 'postgres-v15': { + memoryProfile: 'database', + cpuIntensity: 0.6, + diskGb: 100, + }, + 'redis-v7': { memoryProfile: 'cache', cpuIntensity: 0.2, diskGb: 20 }, + 'python-ml-v1': { memoryProfile: 'ml', cpuIntensity: 0.9, diskGb: 50 }, + 'elastic-v8': { memoryProfile: 'search', cpuIntensity: 0.7, diskGb: 80 }, + 'grafana-v9': { + memoryProfile: 'visualization', + cpuIntensity: 0.3, + diskGb: 15, + }, + 'nginx-v1': { memoryProfile: 'web', cpuIntensity: 0.2, diskGb: 0 }, + 'mongodb-v6': { memoryProfile: 'database', cpuIntensity: 0.5, diskGb: 100 }, + 'mysql-v8': { memoryProfile: 'database', cpuIntensity: 0.6, diskGb: 100 }, + } + + const memoryBaselines: Record = { + web: 0.15, + database: 0.4, + cache: 0.2, + ml: 0.6, + search: 0.45, + visualization: 0.25, + } + + const memoryVolatility: Record = { + web: 0.15, + database: 0.1, + cache: 0.3, + ml: 0.35, + search: 0.2, + visualization: 0.15, + } + + const diskBaselines: Record = { + web: 0.1, + database: 0.5, + cache: 0.05, + ml: 0.4, + search: 0.3, + visualization: 0.2, + } + const diskVolatility: Record = { + web: 0.2, + database: 0.15, + cache: 0.1, + ml: 0.3, + search: 0.25, + visualization: 0.15, + } + + for (const sandbox of sandboxes) { + const pattern = templatePatterns[sandbox.templateID] || { + memoryProfile: 'web', + cpuIntensity: 0.5, + diskGb: 20, + } + + const memBaseline = memoryBaselines[pattern.memoryProfile]! + const memVolatility = memoryVolatility[pattern.memoryProfile]! + + // Generate current load based on time of day + const hourOfDay = new Date().getHours() + const isBusinessHours = hourOfDay >= 8 && hourOfDay <= 18 + const baseLoad = isBusinessHours + ? 0.5 + Math.random() * 0.3 + : 0.2 + Math.random() * 0.2 + + // CPU calculation + const cpuSpike = Math.random() < 0.1 ? Math.random() * 0.5 : 0 + const cpuLoad = Math.max( + 0, + Math.min(1, (baseLoad + cpuSpike) * pattern.cpuIntensity) + ) + const cpuUsedPct = Math.min(100, Math.max(0, cpuLoad * 100)) + + // Memory calculation + const memoryNoise = (Math.random() - 0.5) * memVolatility + const memPct = memBaseline + baseLoad * memVolatility + memoryNoise + const memUsedMb = Math.floor(sandbox.memoryMB * Math.min(1.0, memPct)) + const diskBaseline = diskBaselines[pattern.memoryProfile]! + const diskVolatilityVal = diskVolatility[pattern.memoryProfile]! + const diskNoise = (Math.random() - 0.5) * 0.1 + const diskPct = diskBaseline + baseLoad * diskVolatilityVal + diskNoise + // Use sandbox's declared disk size (in MB) as the total capacity + const sandboxDiskTotalGb = (sandbox.diskSizeMB ?? 0) / 1024 + const clampedDiskPct = Math.min(1, Math.max(0, diskPct)) + const diskUsedGb = Number((sandboxDiskTotalGb * clampedDiskPct).toFixed(2)) + const diskTotalGb = Number(sandboxDiskTotalGb.toFixed(2)) + + metrics[sandbox.sandboxID] = { + cpuCount: sandbox.cpuCount, + cpuUsedPct, + memTotalMb: sandbox.memoryMB, + memUsedMb: memUsedMb, + timestamp: new Date().toISOString(), + diskUsedGb, + diskTotalGb, + } + } + + return { + metrics, + } +} + /** * This function replicates the back-end step calculation logic from e2b-dev/infra. * https://github.com/e2b-dev/infra/blob/19778a715e8df3adea83858c798582d289bd7159/packages/api/internal/handlers/sandbox_metrics.go#L90 @@ -1040,6 +1167,8 @@ export function generateMockTeamMetrics( return { metrics, step } } +export const MOCK_METRICS_DATA = (sandboxes: Sandbox[]) => + generateMockMetrics(sandboxes) export const MOCK_SANDBOXES_DATA = () => generateMockSandboxes(120) export const MOCK_TEMPLATES_DATA = TEMPLATES export const MOCK_DEFAULT_TEMPLATES_DATA = DEFAULT_TEMPLATES diff --git a/src/core/modules/sandboxes/models.client.ts b/src/core/modules/sandboxes/models.client.ts index 60ba930ac..37049db03 100644 --- a/src/core/modules/sandboxes/models.client.ts +++ b/src/core/modules/sandboxes/models.client.ts @@ -1,5 +1,17 @@ import type { TeamMetric } from './models' +export type ClientSandboxMetric = { + cpuCount: number + cpuUsedPct: number + memUsedMb: number + memTotalMb: number + timestamp: string + diskUsedGb: number + diskTotalGb: number +} + +export type ClientSandboxesMetrics = Record + export type ClientTeamMetric = Pick< TeamMetric, 'concurrentSandboxes' | 'sandboxStartRate' diff --git a/src/core/modules/sandboxes/models.ts b/src/core/modules/sandboxes/models.ts index a2fc56418..253eb1c94 100644 --- a/src/core/modules/sandboxes/models.ts +++ b/src/core/modules/sandboxes/models.ts @@ -6,6 +6,8 @@ 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'] export type SandboxInfo = InfraComponents['schemas']['SandboxDetail'] diff --git a/src/core/modules/sandboxes/repository.server.ts b/src/core/modules/sandboxes/repository.server.ts index 734672f08..6796e0970 100644 --- a/src/core/modules/sandboxes/repository.server.ts +++ b/src/core/modules/sandboxes/repository.server.ts @@ -6,6 +6,7 @@ import type { components as InfraComponents } from '@/contracts/infra-api' import type { SandboxEventModel, Sandboxes, + SandboxesMetricsRecord, SandboxState, TeamMetric, } from '@/core/modules/sandboxes/models' @@ -75,6 +76,9 @@ export interface SandboxesRepository { listSandboxes( options: ListSandboxesOptions ): Promise> + getSandboxesMetrics( + sandboxIds: string[] + ): Promise> getTeamMetricsRange( startUnixSeconds: number, endUnixSeconds: number @@ -406,6 +410,41 @@ export function createSandboxesRepository( nextCursor: result.response.headers.get('x-next-token') || null, }) }, + async getSandboxesMetrics(sandboxIds) { + const result = await deps.infraClient.GET('/sandboxes/metrics', { + params: { + query: { + sandbox_ids: sandboxIds, + }, + }, + headers: { + ...deps.authHeaders(scope.accessToken, scope.teamId), + }, + cache: 'no-store', + }) + + if (!result.response.ok || result.error) { + l.error({ + key: 'repositories:sandboxes:get_sandboxes_metrics:infra_error', + error: result.error, + team_id: scope.teamId, + context: { + status: result.response.status, + path: '/sandboxes/metrics', + sandbox_ids: sandboxIds, + }, + }) + return err( + repoErrorFromHttp( + result.response.status, + result.error?.message ?? 'Failed to fetch sandboxes metrics', + result.error + ) + ) + } + + return ok(result.data.sandboxes) + }, async getTeamMetricsRange(startUnixSeconds, endUnixSeconds) { const result = await deps.infraClient.GET('/teams/{teamID}/metrics', { params: { diff --git a/src/core/server/api/routers/sandboxes.ts b/src/core/server/api/routers/sandboxes.ts index 02abab56b..277f2fa58 100644 --- a/src/core/server/api/routers/sandboxes.ts +++ b/src/core/server/api/routers/sandboxes.ts @@ -13,7 +13,10 @@ import { } from '@/core/modules/sandboxes/schemas' import { throwTRPCErrorFromRepoError } from '@/core/server/adapters/errors' import { withTeamAuthedRequestRepository } from '@/core/server/api/middlewares/repository' -import { fillTeamMetricsWithZeros } from '@/core/server/functions/sandboxes/utils' +import { + fillTeamMetricsWithZeros, + transformMetricsToClientMetrics, +} from '@/core/server/functions/sandboxes/utils' import { createTRPCRouter } from '@/core/server/trpc/init' import { protectedTeamProcedure } from '@/core/server/trpc/procedures' @@ -59,6 +62,34 @@ export const sandboxesRouter = createTRPCRouter({ } }), + getSandboxesMetrics: sandboxesRepositoryProcedure + .input( + z.object({ + sandboxIds: z.array(z.string()), + }) + ) + .query(async ({ ctx, input }) => { + const { sandboxIds } = input + + if (sandboxIds.length === 0 || USE_MOCK_DATA) { + return { + metrics: {}, + } + } + + const metricsDataResult = + await ctx.sandboxesRepository.getSandboxesMetrics(sandboxIds) + if (!metricsDataResult.ok) { + throwTRPCErrorFromRepoError(metricsDataResult.error) + } + const metricsData = metricsDataResult.data + const metrics = transformMetricsToClientMetrics(metricsData) + + return { + metrics, + } + }), + getTeamMetrics: sandboxesRepositoryProcedure .input(GetTeamMetricsSchema) .query(async ({ ctx, input }) => { diff --git a/src/core/server/functions/sandboxes/utils.ts b/src/core/server/functions/sandboxes/utils.ts index 271262424..4d0d2fda0 100644 --- a/src/core/server/functions/sandboxes/utils.ts +++ b/src/core/server/functions/sandboxes/utils.ts @@ -1,5 +1,28 @@ import { TEAM_METRICS_BACKEND_COLLECTION_INTERVAL_MS } from '@/configs/intervals' -import type { ClientTeamMetrics } from '@/core/modules/sandboxes/models.client' +import type { SandboxesMetricsRecord } from '@/core/modules/sandboxes/models' +import type { + ClientSandboxesMetrics, + ClientTeamMetrics, +} from '@/core/modules/sandboxes/models.client' + +export function transformMetricsToClientMetrics( + metrics: SandboxesMetricsRecord +): ClientSandboxesMetrics { + return Object.fromEntries( + Object.entries(metrics).map(([sandboxID, metric]) => [ + sandboxID, + { + cpuCount: metric.cpuCount, + cpuUsedPct: Number(metric.cpuUsedPct.toFixed(2)), + memUsedMb: Number((metric.memUsed / 1024 / 1024).toFixed(2)), + memTotalMb: Number((metric.memTotal / 1024 / 1024).toFixed(2)), + diskUsedGb: Number((metric.diskUsed / 1024 / 1024 / 1024).toFixed(2)), + diskTotalGb: Number((metric.diskTotal / 1024 / 1024 / 1024).toFixed(2)), + timestamp: metric.timestamp, + }, + ]) + ) +} export function calculateStepForRange(startMs: number, endMs: number): number { const duration = endMs - startMs diff --git a/src/features/dashboard/sandboxes/list/hooks/use-sandboxes-metrics.tsx b/src/features/dashboard/sandboxes/list/hooks/use-sandboxes-metrics.tsx new file mode 100644 index 000000000..87c207a16 --- /dev/null +++ b/src/features/dashboard/sandboxes/list/hooks/use-sandboxes-metrics.tsx @@ -0,0 +1,81 @@ +'use client' + +import { useQuery } from '@tanstack/react-query' +import { useEffect, useMemo, useRef } from 'react' +import { SANDBOXES_METRICS_POLLING_MS } from '@/configs/intervals' +import type { Sandboxes } from '@/core/modules/sandboxes/models' +import { areStringArraysEqual } from '@/lib/utils/array' +import { useTRPC } from '@/trpc/client' +import { useDashboard } from '../../../context' +import { useSandboxMetricsStore } from '../stores/metrics-store' + +interface UseSandboxesMetricsProps { + sandboxes: Sandboxes + pollingIntervalMs?: number + isListScrolling?: boolean +} + +function useStableSandboxIdsWhileScrolling( + sandboxIds: string[], + isListScrolling: boolean +) { + const activeSandboxIdsRef = useRef(sandboxIds) + + if ( + !isListScrolling && + !areStringArraysEqual(activeSandboxIdsRef.current, sandboxIds) + ) { + activeSandboxIdsRef.current = sandboxIds + } + + return activeSandboxIdsRef.current +} + +export function useSandboxesMetrics({ + sandboxes, + pollingIntervalMs = SANDBOXES_METRICS_POLLING_MS, + isListScrolling = false, +}: UseSandboxesMetricsProps) { + const { team } = useDashboard() + const trpc = useTRPC() + + const sandboxIds = useMemo( + () => sandboxes.map((sbx) => sbx.sandboxID), + [sandboxes] + ) + const activeSandboxIds = useStableSandboxIdsWhileScrolling( + sandboxIds, + isListScrolling + ) + + const setMetrics = useSandboxMetricsStore((s) => s.setMetrics) + const shouldEnableMetricsQuery = + !isListScrolling && activeSandboxIds.length > 0 + const metricsRefetchInterval = + pollingIntervalMs > 0 ? pollingIntervalMs : false + + const metricsQueryInput = useMemo( + () => ({ + teamSlug: team.slug, + sandboxIds: activeSandboxIds, + }), + [activeSandboxIds, team.slug] + ) + + const { data } = useQuery( + trpc.sandboxes.getSandboxesMetrics.queryOptions(metricsQueryInput, { + enabled: shouldEnableMetricsQuery, + refetchInterval: metricsRefetchInterval, + refetchOnWindowFocus: false, + refetchOnMount: false, + refetchOnReconnect: true, + refetchIntervalInBackground: false, + }) + ) + + useEffect(() => { + if (data?.metrics) { + setMetrics(data.metrics) + } + }, [data, setMetrics]) +} diff --git a/src/features/dashboard/sandboxes/list/stores/metrics-store.ts b/src/features/dashboard/sandboxes/list/stores/metrics-store.ts new file mode 100644 index 000000000..a7ed7fdbd --- /dev/null +++ b/src/features/dashboard/sandboxes/list/stores/metrics-store.ts @@ -0,0 +1,53 @@ +'use client' + +import { create } from 'zustand' +import type { ClientSandboxesMetrics } from '@/core/modules/sandboxes/models.client' + +// maximum number of sandbox metrics to keep in memory +// this is to prevent the store from growing too large and causing performance issues +const MAX_METRICS_ENTRIES = 500 + +interface SandboxMetricsState { + metrics: ClientSandboxesMetrics +} + +interface SandboxMetricsActions { + setMetrics: (metrics: ClientSandboxesMetrics) => void +} + +type Store = SandboxMetricsState & SandboxMetricsActions + +const initialState: SandboxMetricsState = { + metrics: {}, +} + +export const useSandboxMetricsStore = create()((set) => ({ + ...initialState, + setMetrics: (metrics) => { + set((state) => { + const mergedMetrics = { ...state.metrics, ...metrics } + const entries = Object.entries(mergedMetrics) + + // if we're under the cap, just return the merged metrics + if (entries.length <= MAX_METRICS_ENTRIES) { + return { metrics: mergedMetrics } + } + + // sort entries by timestamp (oldest first) + entries.sort((a, b) => { + const timestampA = new Date(a[1].timestamp).getTime() + const timestampB = new Date(b[1].timestamp).getTime() + return timestampA - timestampB + }) + + // remove oldest entries to make room for new ones + const entriesToRemove = entries.length - MAX_METRICS_ENTRIES + const keptEntries = entries.slice(entriesToRemove) + + // convert back to object + const cappedMetrics = Object.fromEntries(keptEntries) + + return { metrics: cappedMetrics } + }) + }, +})) diff --git a/src/features/dashboard/sandboxes/list/table-body.tsx b/src/features/dashboard/sandboxes/list/table-body.tsx index ef7dcaf1a..b5cccf6c5 100644 --- a/src/features/dashboard/sandboxes/list/table-body.tsx +++ b/src/features/dashboard/sandboxes/list/table-body.tsx @@ -5,6 +5,7 @@ import { LoadMoreButton } from '@/ui/pagination-buttons' import { Button } from '@/ui/primitives/button' import { AddIcon, CloseIcon } from '@/ui/primitives/icons' import SandboxesListEmpty from './empty' +import { useSandboxesMetrics } from './hooks/use-sandboxes-metrics' import { useSandboxListTableStore } from './stores/table-store' import type { SandboxListRow, SandboxListTable } from './table-config' import { SandboxesTableRow } from './table-row' @@ -31,6 +32,9 @@ export const SandboxesTableBody = ({ 'use no memo' const resetFilters = useSandboxListTableStore((state) => state.resetFilters) + const pollingInterval = useSandboxListTableStore( + (state) => state.pollingInterval + ) const hasFilter = useSandboxListTableStore((state) => { return ( state.startedAtFilter !== undefined || @@ -58,6 +62,19 @@ export const SandboxesTableBody = ({ // even when centerRows already has data. const rows = virtualRows.length > 0 ? virtualRows : centerRows + 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: runningVisibleSandboxes, + pollingIntervalMs: pollingInterval === 0 ? 0 : pollingInterval * 1_000, + isListScrolling, + }) + const lastVisibleIndex = virtualizer.getVirtualItems().at(-1)?.index ?? -1 useEffect(() => { diff --git a/src/features/dashboard/sandboxes/list/table-cells.tsx b/src/features/dashboard/sandboxes/list/table-cells.tsx index 3852f54c3..fd5c29aa6 100644 --- a/src/features/dashboard/sandboxes/list/table-cells.tsx +++ b/src/features/dashboard/sandboxes/list/table-cells.tsx @@ -11,20 +11,102 @@ import { Badge } from '@/ui/primitives/badge' import { Button } from '@/ui/primitives/button' import { DotIcon, ExternalLinkIcon, PausedIcon } from '@/ui/primitives/icons' import { useDashboard } from '../../context' +import { useSandboxMetricsStore } from './stores/metrics-store' 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' +// 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 + ) + + return ( + + ) +} + +const RamUsageCellView = ({ + sandboxId, + totalMem, +}: { + sandboxId: string + totalMem?: number +}) => { + const memUsedMb = useSandboxMetricsStore( + (state) => state.metrics?.[sandboxId]?.memUsedMb + ) + + return ( + + ) +} + +const DiskUsageCellView = ({ + sandboxId, + totalDiskGb, +}: { + sandboxId: string + totalDiskGb: number +}) => { + const diskUsedGb = useSandboxMetricsStore( + (state) => state.metrics?.[sandboxId]?.diskUsedGb + ) + + return ( + + ) +} + export const CpuUsageCell = ({ row }: CellContext) => (
- + {row.original.state === 'running' ? ( + + ) : ( + + )}
) export const RamUsageCell = ({ row }: CellContext) => (
- + {row.original.state === 'running' ? ( + + ) : ( + + )}
) @@ -32,11 +114,18 @@ export const DiskUsageCell = ({ row, }: CellContext) => (
- + {row.original.state === 'running' ? ( + + ) : ( + + )}
) From 0957ee325a1833bfbc38d1cfc1ccfd19d5340f9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Huvar?= Date: Tue, 23 Jun 2026 14:55:17 +0200 Subject: [PATCH 04/12] fix: tighten State column and fit timezone in Started At Narrow the State column (badge-sized), and render Started At with tabular (non-mono) figures so the timezone offset (e.g. GMT+2) fits within the fixed-width column instead of being clipped. --- src/features/dashboard/sandboxes/list/table-cells.tsx | 6 +++++- src/features/dashboard/sandboxes/list/table-config.tsx | 4 ++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/features/dashboard/sandboxes/list/table-cells.tsx b/src/features/dashboard/sandboxes/list/table-cells.tsx index fd5c29aa6..4f8058ced 100644 --- a/src/features/dashboard/sandboxes/list/table-cells.tsx +++ b/src/features/dashboard/sandboxes/list/table-cells.tsx @@ -17,6 +17,10 @@ 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' +// 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. @@ -225,7 +229,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 1ab3ed4b7..b0feaa6c6 100644 --- a/src/features/dashboard/sandboxes/list/table-config.tsx +++ b/src/features/dashboard/sandboxes/list/table-config.tsx @@ -133,8 +133,8 @@ export const sandboxListColumns: ColumnDef[] = [ id: 'state', header: 'State', cell: StateCell, - size: 120, - minSize: 100, + size: 90, + minSize: 80, enableResizing: false, enableSorting: false, enableColumnFilter: true, From ee9a3fcafdef1e6d00d0c97129e5dd6a5bdc2a51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Huvar?= Date: Tue, 23 Jun 2026 16:36:01 +0200 Subject: [PATCH 05/12] fix: align sandbox usage placeholders --- .../dashboard/common/resource-usage.tsx | 13 +++++++++---- .../dashboard/sandboxes/list/table-cells.tsx | 17 ++++++++++++++--- 2 files changed, 23 insertions(+), 7 deletions(-) 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/table-cells.tsx b/src/features/dashboard/sandboxes/list/table-cells.tsx index 4f8058ced..ff61a40eb 100644 --- a/src/features/dashboard/sandboxes/list/table-cells.tsx +++ b/src/features/dashboard/sandboxes/list/table-cells.tsx @@ -96,7 +96,12 @@ export const CpuUsageCell = ({ row }: CellContext) => ( totalCpu={row.original.cpuCount} /> ) : ( - + )}

) @@ -109,7 +114,12 @@ export const RamUsageCell = ({ row }: CellContext) => ( totalMem={row.original.memoryMB} /> ) : ( - + )}
) @@ -128,6 +138,7 @@ export const DiskUsageCell = ({ type="disk" mode="simple" total={row.original.diskSizeMB / 1024} + classNames={{ wrapper: USAGE_TEXT_CLASSNAME }} /> )}
@@ -206,7 +217,7 @@ export function MetadataCell({ }, [value]) if (!parsedValue || value.trim() === '{}') { - return n/a + return -- } return ( From a785f515e033993e4318d83e45adb107c4923d7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Huvar?= Date: Wed, 24 Jun 2026 10:09:25 +0200 Subject: [PATCH 06/12] feat: gate new sandbox list with feature flag --- .../[teamSlug]/sandboxes/(tabs)/list/page.tsx | 39 ++++- src/core/modules/feature-flags/definitions.ts | 8 + .../modules/sandboxes/repository.server.ts | 3 +- src/core/server/api/routers/sandboxes.ts | 8 +- .../dashboard/sandboxes/list/table-body.tsx | 13 +- .../dashboard/sandboxes/list/table-config.tsx | 81 ++++++++++ .../dashboard/sandboxes/list/table.tsx | 151 ++++++++++++++---- 7 files changed, 255 insertions(+), 48 deletions(-) diff --git a/src/app/dashboard/[teamSlug]/sandboxes/(tabs)/list/page.tsx b/src/app/dashboard/[teamSlug]/sandboxes/(tabs)/list/page.tsx index 2b3ce5ef7..459aa62bc 100644 --- a/src/app/dashboard/[teamSlug]/sandboxes/(tabs)/list/page.tsx +++ b/src/app/dashboard/[teamSlug]/sandboxes/(tabs)/list/page.tsx @@ -1,4 +1,7 @@ import { Suspense } from 'react' +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' import LoadingLayout from '@/features/dashboard/loading-layout' import SandboxesTable from '@/features/dashboard/sandboxes/list/table' import { HydrateClient, prefetch, trpc } from '@/trpc/server' @@ -7,13 +10,37 @@ export default async function SandboxesListPage({ params, }: PageProps<'/dashboard/[teamSlug]/sandboxes/list'>) { const { teamSlug } = await params + const authContext = await getAuthContext() + const teamIdResult = authContext + ? await getTeamIdFromSlug(teamSlug, authContext.accessToken) + : null + const teamId = teamIdResult?.ok ? teamIdResult.data : null + const newSandboxListEnabled = authContext + ? await featureFlags.isEnabled('newSandboxList', { + user: { + id: authContext.user.id, + email: authContext.user.email ?? undefined, + }, + team: teamId ? { id: teamId, slug: teamSlug } : undefined, + }) + : false - prefetch( - trpc.sandboxes.getSandboxes.infiniteQueryOptions({ - teamSlug, - limit: 50, - }) - ) + if (newSandboxListEnabled) { + prefetch( + trpc.sandboxes.getSandboxes.infiniteQueryOptions({ + teamSlug, + limit: 50, + }) + ) + } else { + prefetch( + trpc.sandboxes.getSandboxes.queryOptions({ + teamSlug, + limit: 50, + states: ['running'], + }) + ) + } return ( diff --git a/src/core/modules/feature-flags/definitions.ts b/src/core/modules/feature-flags/definitions.ts index a8ee50680..d081304a6 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', + }, } as const satisfies Record export type FeatureFlagId = keyof typeof FEATURE_FLAGS diff --git a/src/core/modules/sandboxes/repository.server.ts b/src/core/modules/sandboxes/repository.server.ts index 6796e0970..4d849336f 100644 --- a/src/core/modules/sandboxes/repository.server.ts +++ b/src/core/modules/sandboxes/repository.server.ts @@ -40,6 +40,7 @@ export interface GetSandboxMetricsOptions { export interface ListSandboxesOptions { cursor?: string limit: number + states?: SandboxState[] } export interface ListSandboxesResult { @@ -375,7 +376,7 @@ export function createSandboxesRepository( const result = await deps.infraClient.GET('/v2/sandboxes', { params: { query: { - state: DEFAULT_SANDBOX_STATES, + state: options.states ?? DEFAULT_SANDBOX_STATES, nextToken: options.cursor, limit: options.limit, }, diff --git a/src/core/server/api/routers/sandboxes.ts b/src/core/server/api/routers/sandboxes.ts index 277f2fa58..edd19c73c 100644 --- a/src/core/server/api/routers/sandboxes.ts +++ b/src/core/server/api/routers/sandboxes.ts @@ -36,6 +36,7 @@ export const sandboxesRouter = createTRPCRouter({ 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 }) => { @@ -43,7 +44,11 @@ export const sandboxesRouter = createTRPCRouter({ await new Promise((resolve) => setTimeout(resolve, 200)) return { - sandboxes: MOCK_SANDBOXES_DATA(), + sandboxes: input.states + ? MOCK_SANDBOXES_DATA().filter((sandbox) => + input.states?.includes(sandbox.state) + ) + : MOCK_SANDBOXES_DATA(), nextCursor: null, } } @@ -51,6 +56,7 @@ export const sandboxesRouter = createTRPCRouter({ const sandboxesResult = await ctx.sandboxesRepository.listSandboxes({ cursor: input.cursor, limit: input.limit, + states: input.states, }) if (!sandboxesResult.ok) { throwTRPCErrorFromRepoError(sandboxesResult.error) diff --git a/src/features/dashboard/sandboxes/list/table-body.tsx b/src/features/dashboard/sandboxes/list/table-body.tsx index b5cccf6c5..a07233bd1 100644 --- a/src/features/dashboard/sandboxes/list/table-body.tsx +++ b/src/features/dashboard/sandboxes/list/table-body.tsx @@ -17,16 +17,16 @@ const PREFETCH_THRESHOLD = 8 interface SandboxesTableBodyProps { table: SandboxListTable scrollRef: RefObject - hasNextPage: boolean - isFetchingNextPage: boolean - fetchNextPage: () => void + hasNextPage?: boolean + isFetchingNextPage?: boolean + fetchNextPage?: () => void } export const SandboxesTableBody = ({ table, scrollRef, - hasNextPage, - isFetchingNextPage, + hasNextPage = false, + isFetchingNextPage = false, fetchNextPage, }: SandboxesTableBodyProps) => { 'use no memo' @@ -81,6 +81,7 @@ export const SandboxesTableBody = ({ if ( hasNextPage && !isFetchingNextPage && + fetchNextPage && lastVisibleIndex >= centerRows.length - PREFETCH_THRESHOLD ) { fetchNextPage() @@ -143,7 +144,7 @@ export const SandboxesTableBody = ({
{})} />
)} diff --git a/src/features/dashboard/sandboxes/list/table-config.tsx b/src/features/dashboard/sandboxes/list/table-config.tsx index b0feaa6c6..706b85271 100644 --- a/src/features/dashboard/sandboxes/list/table-config.tsx +++ b/src/features/dashboard/sandboxes/list/table-config.tsx @@ -195,3 +195,84 @@ export const sandboxListColumns: ColumnDef[] = [ enableSorting: false, }, ] + +export const legacySandboxListColumns: ColumnDef[] = [ + { + accessorKey: 'sandboxID', + header: 'ID', + cell: IdCell, + size: 165, + minSize: 100, + enableResizing: false, + enableColumnFilter: false, + enableSorting: false, + enableGlobalFilter: true, + }, + { + 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, + }, + { + 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) + }, + }, +] diff --git a/src/features/dashboard/sandboxes/list/table.tsx b/src/features/dashboard/sandboxes/list/table.tsx index 458e2ef33..50ef82a15 100644 --- a/src/features/dashboard/sandboxes/list/table.tsx +++ b/src/features/dashboard/sandboxes/list/table.tsx @@ -3,8 +3,10 @@ import { keepPreviousData, useSuspenseInfiniteQuery, + useSuspenseQuery, } from '@tanstack/react-query' import { + type ColumnDef, type ColumnFiltersState, type ColumnSizingState, flexRender, @@ -16,6 +18,8 @@ import { import { subHours } from 'date-fns' import { useEffect, useMemo, useRef } from 'react' import { useLocalStorage } from 'usehooks-ts' +import { useFeatureFlag } from '@/core/modules/feature-flags/feature-flags.client' +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' @@ -34,7 +38,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, @@ -82,13 +90,28 @@ function buildColumnFilters({ const SANDBOXES_PAGE_SIZE = 50 -export default function SandboxesTable() { +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', @@ -106,37 +129,10 @@ export default function SandboxesTable() { memoryMB, sorting, globalFilter, - pollingInterval, setSorting, setGlobalFilter, } = useSandboxListTableStore() - const { - data, - refetch, - isFetching, - fetchNextPage, - hasNextPage, - isFetchingNextPage, - } = useSuspenseInfiniteQuery( - trpc.sandboxes.getSandboxes.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] - ) - const columnFilters = useMemo( () => buildColumnFilters({ @@ -151,7 +147,7 @@ export default function SandboxesTable() { const activeSorting = getSandboxListEffectiveSorting(sorting) const table = useReactTable({ - columns: sandboxListColumns, + columns, data: sandboxes, state: { globalFilter, @@ -175,13 +171,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 @@ -248,3 +250,84 @@ export default function SandboxesTable() { ) } + +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.getSandboxes.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 ( + + ) +} + +function LegacySandboxesTable() { + const { teamSlug } = useRouteParams<'/dashboard/[teamSlug]/sandboxes'>() + const trpc = useTRPC() + + const { data, refetch, isFetching } = useSuspenseQuery( + trpc.sandboxes.getSandboxes.queryOptions( + { teamSlug, limit: SANDBOXES_PAGE_SIZE, states: ['running'] }, + { + refetchOnMount: 'always', + refetchOnWindowFocus: true, + placeholderData: keepPreviousData, + } + ) + ) + + return ( + + ) +} + +export default function SandboxesTable() { + const newSandboxListEnabled = useFeatureFlag('newSandboxList') + + return newSandboxListEnabled ? ( + + ) : ( + + ) +} From 949e60fc5c01d485e043c99eff58aee1c65219e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Huvar?= Date: Wed, 24 Jun 2026 10:15:24 +0200 Subject: [PATCH 07/12] feat: route new sandbox list behind flag --- .../[teamSlug]/sandboxes/(tabs)/layout.tsx | 6 ++- .../[teamSlug]/sandboxes/(tabs)/list/page.tsx | 40 ++++--------------- .../sandboxes/(tabs)/list2/error.tsx | 13 ++++++ .../sandboxes/(tabs)/list2/page.tsx | 34 ++++++++++++++++ src/configs/layout.ts | 4 ++ src/configs/urls.ts | 2 + .../sandboxes/list/feature-flag.server.ts | 27 +++++++++++++ .../dashboard/sandboxes/list/table.tsx | 15 ++----- 8 files changed, 95 insertions(+), 46 deletions(-) create mode 100644 src/app/dashboard/[teamSlug]/sandboxes/(tabs)/list2/error.tsx create mode 100644 src/app/dashboard/[teamSlug]/sandboxes/(tabs)/list2/page.tsx create mode 100644 src/features/dashboard/sandboxes/list/feature-flag.server.ts 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 459aa62bc..92fe54ab4 100644 --- a/src/app/dashboard/[teamSlug]/sandboxes/(tabs)/list/page.tsx +++ b/src/app/dashboard/[teamSlug]/sandboxes/(tabs)/list/page.tsx @@ -1,7 +1,4 @@ import { Suspense } from 'react' -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' import LoadingLayout from '@/features/dashboard/loading-layout' import SandboxesTable from '@/features/dashboard/sandboxes/list/table' import { HydrateClient, prefetch, trpc } from '@/trpc/server' @@ -10,37 +7,14 @@ export default async function SandboxesListPage({ params, }: PageProps<'/dashboard/[teamSlug]/sandboxes/list'>) { const { teamSlug } = await params - const authContext = await getAuthContext() - const teamIdResult = authContext - ? await getTeamIdFromSlug(teamSlug, authContext.accessToken) - : null - const teamId = teamIdResult?.ok ? teamIdResult.data : null - const newSandboxListEnabled = authContext - ? await featureFlags.isEnabled('newSandboxList', { - user: { - id: authContext.user.id, - email: authContext.user.email ?? undefined, - }, - team: teamId ? { id: teamId, slug: teamSlug } : undefined, - }) - : false - if (newSandboxListEnabled) { - prefetch( - trpc.sandboxes.getSandboxes.infiniteQueryOptions({ - teamSlug, - limit: 50, - }) - ) - } else { - prefetch( - trpc.sandboxes.getSandboxes.queryOptions({ - teamSlug, - limit: 50, - states: ['running'], - }) - ) - } + prefetch( + trpc.sandboxes.getSandboxes.queryOptions({ + teamSlug, + limit: 50, + states: ['running'], + }) + ) return ( 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..d7003ad1c --- /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.getSandboxes.infiniteQueryOptions({ + teamSlug, + limit: 50, + }) + ) + + return ( + + }> + + + + ) +} diff --git a/src/configs/layout.ts b/src/configs/layout.ts index 15cd4032f..490dc32a8 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 8fad936af..7c1c21913 100644 --- a/src/configs/urls.ts +++ b/src/configs/urls.ts @@ -24,6 +24,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/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.tsx b/src/features/dashboard/sandboxes/list/table.tsx index 50ef82a15..58e8befe1 100644 --- a/src/features/dashboard/sandboxes/list/table.tsx +++ b/src/features/dashboard/sandboxes/list/table.tsx @@ -18,7 +18,6 @@ import { import { subHours } from 'date-fns' import { useEffect, useMemo, useRef } from 'react' import { useLocalStorage } from 'usehooks-ts' -import { useFeatureFlag } from '@/core/modules/feature-flags/feature-flags.client' 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' @@ -251,7 +250,7 @@ function SandboxesTableView({ ) } -function NewSandboxesTable() { +export function NewSandboxesTable() { const { teamSlug } = useRouteParams<'/dashboard/[teamSlug]/sandboxes'>() const trpc = useTRPC() const pollingInterval = useSandboxListTableStore( @@ -297,7 +296,7 @@ function NewSandboxesTable() { ) } -function LegacySandboxesTable() { +export function LegacySandboxesTable() { const { teamSlug } = useRouteParams<'/dashboard/[teamSlug]/sandboxes'>() const trpc = useTRPC() @@ -322,12 +321,4 @@ function LegacySandboxesTable() { ) } -export default function SandboxesTable() { - const newSandboxListEnabled = useFeatureFlag('newSandboxList') - - return newSandboxListEnabled ? ( - - ) : ( - - ) -} +export default LegacySandboxesTable From 5c82183442f80bdcbb29d65816acc0c0d04e2ccf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Huvar?= Date: Wed, 24 Jun 2026 11:29:41 +0200 Subject: [PATCH 08/12] test: cover new_sandbox_list flag in feature-flags registry test --- tests/unit/feature-flags.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/unit/feature-flags.test.ts b/tests/unit/feature-flags.test.ts index 239f82788..c74c806f3 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, ]) expect(result).toEqual([ { @@ -84,6 +85,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, + }, ]) }) }) From 1f904a1f0d50510a7e5f198f717297945d68a1a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Huvar?= Date: Wed, 24 Jun 2026 16:24:30 +0200 Subject: [PATCH 09/12] fix: load full sandbox set before client-side sort/filter in new list --- src/features/dashboard/sandboxes/list/table.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/features/dashboard/sandboxes/list/table.tsx b/src/features/dashboard/sandboxes/list/table.tsx index 58e8befe1..b3beb247f 100644 --- a/src/features/dashboard/sandboxes/list/table.tsx +++ b/src/features/dashboard/sandboxes/list/table.tsx @@ -278,6 +278,15 @@ export function NewSandboxesTable() { ) ) + // Client-side sorting, filtering, and search run over the full result set, so + // drain every page before the table applies them — otherwise those operations + // (and the row counts) would only ever see the pages fetched so far. + useEffect(() => { + if (hasNextPage && !isFetchingNextPage) { + void fetchNextPage() + } + }, [hasNextPage, isFetchingNextPage, fetchNextPage]) + const sandboxes = useMemo( () => data.pages.flatMap((page) => page.sandboxes), [data] From 5dba60e600d2b2c56205ac0d1aac6957e5c64a3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Huvar?= Date: Wed, 24 Jun 2026 17:04:03 +0200 Subject: [PATCH 10/12] refactor: use old /sandboxes for legacy list, v2 for flagged list Revert legacy sandbox list to the non-paginated /sandboxes endpoint and route the new flag-gated list through a dedicated listSandboxesPaginated procedure backed by /v2/sandboxes. --- .../[teamSlug]/sandboxes/(tabs)/list/page.tsx | 2 -- .../sandboxes/(tabs)/list2/page.tsx | 2 +- .../modules/sandboxes/repository.server.ts | 36 +++++++++++++++++-- src/core/server/api/routers/sandboxes.ts | 32 +++++++++++++---- .../dashboard/sandboxes/list/table.tsx | 13 ++----- 5 files changed, 62 insertions(+), 23 deletions(-) diff --git a/src/app/dashboard/[teamSlug]/sandboxes/(tabs)/list/page.tsx b/src/app/dashboard/[teamSlug]/sandboxes/(tabs)/list/page.tsx index 92fe54ab4..28eeef355 100644 --- a/src/app/dashboard/[teamSlug]/sandboxes/(tabs)/list/page.tsx +++ b/src/app/dashboard/[teamSlug]/sandboxes/(tabs)/list/page.tsx @@ -11,8 +11,6 @@ export default async function SandboxesListPage({ prefetch( trpc.sandboxes.getSandboxes.queryOptions({ teamSlug, - limit: 50, - states: ['running'], }) ) diff --git a/src/app/dashboard/[teamSlug]/sandboxes/(tabs)/list2/page.tsx b/src/app/dashboard/[teamSlug]/sandboxes/(tabs)/list2/page.tsx index d7003ad1c..d9cafecff 100644 --- a/src/app/dashboard/[teamSlug]/sandboxes/(tabs)/list2/page.tsx +++ b/src/app/dashboard/[teamSlug]/sandboxes/(tabs)/list2/page.tsx @@ -18,7 +18,7 @@ export default async function NewSandboxesListPage({ } prefetch( - trpc.sandboxes.getSandboxes.infiniteQueryOptions({ + trpc.sandboxes.listSandboxesPaginated.infiniteQueryOptions({ teamSlug, limit: 50, }) diff --git a/src/core/modules/sandboxes/repository.server.ts b/src/core/modules/sandboxes/repository.server.ts index 4d849336f..624b35f48 100644 --- a/src/core/modules/sandboxes/repository.server.ts +++ b/src/core/modules/sandboxes/repository.server.ts @@ -74,7 +74,8 @@ export interface SandboxesRepository { sandboxId: string, options: GetSandboxMetricsOptions ): Promise> - listSandboxes( + listSandboxes(): Promise> + listSandboxesPaginated( options: ListSandboxesOptions ): Promise> getSandboxesMetrics( @@ -372,7 +373,36 @@ export function createSandboxesRepository( return ok(result.data) }, - async listSandboxes(options) { + async listSandboxes() { + const result = await deps.infraClient.GET('/sandboxes', { + headers: { + ...deps.authHeaders(scope.accessToken, scope.teamId), + }, + cache: 'no-store', + }) + + if (!result.response.ok || result.error) { + l.error({ + key: 'repositories:sandboxes:list_sandboxes:infra_error', + error: result.error, + team_id: scope.teamId, + context: { + status: result.response.status, + path: '/sandboxes', + }, + }) + return err( + repoErrorFromHttp( + result.response.status, + result.error?.message ?? 'Failed to list sandboxes', + result.error + ) + ) + } + + return ok(result.data ?? []) + }, + async listSandboxesPaginated(options) { const result = await deps.infraClient.GET('/v2/sandboxes', { params: { query: { @@ -389,7 +419,7 @@ export function createSandboxesRepository( if (!result.response.ok || result.error) { l.error({ - key: 'repositories:sandboxes:list_sandboxes:infra_error', + key: 'repositories:sandboxes:list_sandboxes_paginated:infra_error', error: result.error, team_id: scope.teamId, context: { diff --git a/src/core/server/api/routers/sandboxes.ts b/src/core/server/api/routers/sandboxes.ts index edd19c73c..c529088ac 100644 --- a/src/core/server/api/routers/sandboxes.ts +++ b/src/core/server/api/routers/sandboxes.ts @@ -31,7 +31,26 @@ const sandboxesRepositoryProcedure = protectedTeamProcedure.use( export const sandboxesRouter = createTRPCRouter({ // QUERIES - getSandboxes: sandboxesRepositoryProcedure + getSandboxes: sandboxesRepositoryProcedure.query(async ({ ctx }) => { + if (USE_MOCK_DATA) { + await new Promise((resolve) => setTimeout(resolve, 200)) + + return { + sandboxes: MOCK_SANDBOXES_DATA(), + } + } + + const sandboxesResult = await ctx.sandboxesRepository.listSandboxes() + if (!sandboxesResult.ok) { + throwTRPCErrorFromRepoError(sandboxesResult.error) + } + + return { + sandboxes: sandboxesResult.data, + } + }), + + listSandboxesPaginated: sandboxesRepositoryProcedure .input( z.object({ cursor: z.string().optional(), @@ -53,11 +72,12 @@ export const sandboxesRouter = createTRPCRouter({ } } - const sandboxesResult = await ctx.sandboxesRepository.listSandboxes({ - cursor: input.cursor, - limit: input.limit, - states: input.states, - }) + const sandboxesResult = + await ctx.sandboxesRepository.listSandboxesPaginated({ + cursor: input.cursor, + limit: input.limit, + states: input.states, + }) if (!sandboxesResult.ok) { throwTRPCErrorFromRepoError(sandboxesResult.error) } diff --git a/src/features/dashboard/sandboxes/list/table.tsx b/src/features/dashboard/sandboxes/list/table.tsx index b3beb247f..21c0e6e56 100644 --- a/src/features/dashboard/sandboxes/list/table.tsx +++ b/src/features/dashboard/sandboxes/list/table.tsx @@ -265,7 +265,7 @@ export function NewSandboxesTable() { hasNextPage, isFetchingNextPage, } = useSuspenseInfiniteQuery( - trpc.sandboxes.getSandboxes.infiniteQueryOptions( + trpc.sandboxes.listSandboxesPaginated.infiniteQueryOptions( { teamSlug, limit: SANDBOXES_PAGE_SIZE }, { getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, @@ -278,15 +278,6 @@ export function NewSandboxesTable() { ) ) - // Client-side sorting, filtering, and search run over the full result set, so - // drain every page before the table applies them — otherwise those operations - // (and the row counts) would only ever see the pages fetched so far. - useEffect(() => { - if (hasNextPage && !isFetchingNextPage) { - void fetchNextPage() - } - }, [hasNextPage, isFetchingNextPage, fetchNextPage]) - const sandboxes = useMemo( () => data.pages.flatMap((page) => page.sandboxes), [data] @@ -311,7 +302,7 @@ export function LegacySandboxesTable() { const { data, refetch, isFetching } = useSuspenseQuery( trpc.sandboxes.getSandboxes.queryOptions( - { teamSlug, limit: SANDBOXES_PAGE_SIZE, states: ['running'] }, + { teamSlug }, { refetchOnMount: 'always', refetchOnWindowFocus: true, From 6e8d38e2ea9c6262712c7b09239d51b0256bc7d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Huvar?= Date: Thu, 25 Jun 2026 13:19:19 +0200 Subject: [PATCH 11/12] feat: redirect /sandboxes/list to /list2 when new list flag is enabled --- .../dashboard/[teamSlug]/sandboxes/(tabs)/list/page.tsx | 7 +++++++ { | 8 ++++++++ 2 files changed, 15 insertions(+) create mode 100644 { 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/{ b/{ new file mode 100644 index 000000000..a11f0f3e5 --- /dev/null +++ b/{ @@ -0,0 +1,8 @@ +{ + "results": [], + "paggination": { + "size": 20, + "next": "abc", + "prev": "abc" + } +} From 165c7a493114d30649f7ae76677726d8e99e88e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Huvar?= Date: Thu, 25 Jun 2026 13:19:39 +0200 Subject: [PATCH 12/12] chore: remove stray scratch file committed by accident --- { | 8 -------- 1 file changed, 8 deletions(-) delete mode 100644 { diff --git a/{ b/{ deleted file mode 100644 index a11f0f3e5..000000000 --- a/{ +++ /dev/null @@ -1,8 +0,0 @@ -{ - "results": [], - "paggination": { - "size": 20, - "next": "abc", - "prev": "abc" - } -}