diff --git a/spec/openapi.infra.yaml b/spec/openapi.infra.yaml index ee4fcb539..cbc8e062c 100644 --- a/spec/openapi.infra.yaml +++ b/spec/openapi.infra.yaml @@ -2192,6 +2192,14 @@ paths: $ref: "#/components/schemas/SandboxState" style: form explode: false + - name: order + in: query + description: Sort direction by sandbox start time. Defaults to desc (newest first). + required: false + schema: + type: string + enum: [asc, desc] + default: desc - $ref: "#/components/parameters/paginationNextToken" - $ref: "#/components/parameters/paginationLimit" responses: diff --git a/src/app/dashboard/[teamSlug]/sandboxes/(tabs)/layout.tsx b/src/app/dashboard/[teamSlug]/sandboxes/(tabs)/layout.tsx index 074de5997..ce0da2f2b 100644 --- a/src/app/dashboard/[teamSlug]/sandboxes/(tabs)/layout.tsx +++ b/src/app/dashboard/[teamSlug]/sandboxes/(tabs)/layout.tsx @@ -1,5 +1,4 @@ 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' @@ -8,9 +7,6 @@ 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 (
@@ -26,7 +22,7 @@ export default async function SandboxesTabsLayout({ { id: 'list', label: 'List', - href: listHref, + href: PROTECTED_URLS.SANDBOXES_LIST(teamSlug), icon: , }, ]} diff --git a/src/app/dashboard/[teamSlug]/sandboxes/(tabs)/list/page.tsx b/src/app/dashboard/[teamSlug]/sandboxes/(tabs)/list/page.tsx index fae1f1970..4ad4c6d5d 100644 --- a/src/app/dashboard/[teamSlug]/sandboxes/(tabs)/list/page.tsx +++ b/src/app/dashboard/[teamSlug]/sandboxes/(tabs)/list/page.tsx @@ -1,9 +1,6 @@ -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 { SandboxesTable } from '@/features/dashboard/sandboxes/list/table' import { HydrateClient, prefetch, trpc } from '@/trpc/server' export default async function SandboxesListPage({ @@ -11,13 +8,11 @@ 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({ + trpc.sandboxes.listSandboxesPaginated.infiniteQueryOptions({ teamSlug, + limit: 50, + order: 'desc', }) ) diff --git a/src/app/dashboard/[teamSlug]/sandboxes/(tabs)/list2/error.tsx b/src/app/dashboard/[teamSlug]/sandboxes/(tabs)/list2/error.tsx deleted file mode 100644 index 8346f7ae0..000000000 --- a/src/app/dashboard/[teamSlug]/sandboxes/(tabs)/list2/error.tsx +++ /dev/null @@ -1,13 +0,0 @@ -'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 deleted file mode 100644 index d9cafecff..000000000 --- a/src/app/dashboard/[teamSlug]/sandboxes/(tabs)/list2/page.tsx +++ /dev/null @@ -1,34 +0,0 @@ -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 51ca916ee..f9c8c5fbb 100644 --- a/src/configs/layout.ts +++ b/src/configs/layout.ts @@ -36,10 +36,6 @@ 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/mock-data.ts b/src/configs/mock-data.ts index 4d886d6b3..cf51ea78d 100644 --- a/src/configs/mock-data.ts +++ b/src/configs/mock-data.ts @@ -1,5 +1,6 @@ import { addHours, subHours } from 'date-fns' -import { nanoid } from 'nanoid' +import { customAlphabet, nanoid } from 'nanoid' +import type { UsageResponse } from '@/core/modules/billing/models' import type { Sandbox, Sandboxes } from '@/core/modules/sandboxes/models' import type { ClientSandboxesMetrics, @@ -720,6 +721,11 @@ const COMPONENTS = [ 'monitoring', ] as const +const sandboxIdSuffix = customAlphabet( + 'abcdefghijklmnopqrstuvwxyz0123456789', + 20 +) + function generateMockSandboxes(count: number): Sandboxes { const sandboxes: Sandboxes = [] const baseDate = new Date() @@ -813,10 +819,12 @@ function generateMockSandboxes(count: number): Sandboxes { ), }), }, - sandboxID: nanoid(8), + sandboxID: `i${sandboxIdSuffix()}`, startedAt: startDate.toISOString(), templateID: template.templateID, - state: 'running', + // every fifth sandbox is paused so state filters and paused-only UI + // states are exercisable in mock mode + state: i % 5 === 0 ? 'paused' : 'running', volumeMounts: [], }) } @@ -824,6 +832,16 @@ function generateMockSandboxes(count: number): Sandboxes { return sandboxes } +// Deterministic per-ID so a sandbox consistently reports (or lacks) metrics +// across polls; ~20% report none. +function reportsMetrics(sandboxID: string): boolean { + let hash = 0 + for (let i = 0; i < sandboxID.length; i++) { + hash = (hash * 31 + sandboxID.charCodeAt(i)) % 997 + } + return hash % 5 !== 0 +} + function generateMockMetrics(sandboxes: Sandbox[]): { metrics: ClientSandboxesMetrics } { @@ -894,6 +912,10 @@ function generateMockMetrics(sandboxes: Sandbox[]): { } for (const sandbox of sandboxes) { + if (sandbox.state !== 'running' || !reportsMetrics(sandbox.sandboxID)) { + continue + } + const pattern = templatePatterns[sandbox.templateID] || { memoryProfile: 'web', cpuIntensity: 0.5, @@ -1167,9 +1189,62 @@ export function generateMockTeamMetrics( return { metrics, step } } +/** + * Generate mock team usage with hourly granularity for the past 90 days. + * Activity peaks on weekdays between 8:00 and 18:00 UTC, so the day-boundary + * shift is clearly visible when toggling the usage page between timezones. + */ +function generateMockUsage(): UsageResponse { + const hourMs = 60 * 60 * 1000 + const currentHour = Math.floor(Date.now() / hourMs) * hourMs + const start = currentHour - 90 * 24 * hourMs + + const hourUsages: UsageResponse['hour_usages'] = [] + + for (let timestamp = start; timestamp <= currentHour; timestamp += hourMs) { + const date = new Date(timestamp) + const utcHour = date.getUTCHours() + const dayOfWeek = date.getUTCDay() + const isWeekday = dayOfWeek >= 1 && dayOfWeek <= 5 + + let loadFactor = 0.05 + Math.random() * 0.05 + if (utcHour >= 8 && utcHour <= 18) { + const peakShape = Math.sin(((utcHour - 8) * Math.PI) / 10) + loadFactor = isWeekday ? 0.5 + peakShape * 0.5 : 0.2 + peakShape * 0.1 + } + loadFactor *= 0.85 + Math.random() * 0.3 + + const sandboxCount = Math.round(120 * loadFactor) + const cpuHours = Number((sandboxCount * 0.7).toFixed(3)) + const ramGibHours = Number((sandboxCount * 1.4).toFixed(3)) + + hourUsages.push({ + timestamp, + sandbox_count: sandboxCount, + cpu_hours: cpuHours, + ram_gib_hours: ramGibHours, + price_for_cpu: Number((cpuHours * 0.000231).toFixed(6)), + price_for_ram: Number((ramGibHours * 0.000231).toFixed(6)), + }) + } + + return { + credits: 87.5, + day_usages: [], + hour_usages: hourUsages, + } +} + export const MOCK_METRICS_DATA = (sandboxes: Sandbox[]) => generateMockMetrics(sandboxes) -export const MOCK_SANDBOXES_DATA = () => generateMockSandboxes(120) +export const MOCK_USAGE_DATA = generateMockUsage +// Memoized so sandbox IDs stay stable across requests, allowing metrics +// lookups to join by ID. +let mockSandboxesCache: Sandboxes | null = null +export const MOCK_SANDBOXES_DATA = () => { + mockSandboxesCache ??= generateMockSandboxes(120) + return mockSandboxesCache +} export const MOCK_TEMPLATES_DATA = TEMPLATES export const MOCK_DEFAULT_TEMPLATES_DATA = DEFAULT_TEMPLATES export const MOCK_TEAM_METRICS_DATA = generateMockTeamMetrics diff --git a/src/configs/urls.ts b/src/configs/urls.ts index 334bcb9c3..c232bf111 100644 --- a/src/configs/urls.ts +++ b/src/configs/urls.ts @@ -32,8 +32,6 @@ 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/billing/repository.server.ts b/src/core/modules/billing/repository.server.ts index 8d2561bb1..282a21484 100644 --- a/src/core/modules/billing/repository.server.ts +++ b/src/core/modules/billing/repository.server.ts @@ -2,6 +2,8 @@ import 'server-only' import { z } from 'zod' import { authHeaders } from '@/configs/api' +import { USE_MOCK_DATA } from '@/configs/env-flags' +import { MOCK_USAGE_DATA } from '@/configs/mock-data' import type { AddOnOrderConfirmResponse, AddOnOrderCreateResponse, @@ -122,6 +124,11 @@ export function createBillingRepository( return ok((await res.json()) as TeamItems) }, async getUsage() { + if (USE_MOCK_DATA) { + // Mock timestamps are already in milliseconds — skip the API mapping. + return ok(MOCK_USAGE_DATA()) + } + const res = await fetch( `${deps.billingApiUrl}/v2/teams/${scope.teamId}/usage`, { diff --git a/src/core/modules/feature-flags/definitions.ts b/src/core/modules/feature-flags/definitions.ts index ee5bc82ec..9349c76ec 100644 --- a/src/core/modules/feature-flags/definitions.ts +++ b/src/core/modules/feature-flags/definitions.ts @@ -15,14 +15,6 @@ 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', - }, newUsagePage: { kind: 'boolean', key: 'new-usage-page', diff --git a/src/core/modules/sandboxes/repository.server.ts b/src/core/modules/sandboxes/repository.server.ts index 624b35f48..4b127d611 100644 --- a/src/core/modules/sandboxes/repository.server.ts +++ b/src/core/modules/sandboxes/repository.server.ts @@ -41,6 +41,7 @@ export interface ListSandboxesOptions { cursor?: string limit: number states?: SandboxState[] + order?: 'asc' | 'desc' } export interface ListSandboxesResult { @@ -74,7 +75,6 @@ export interface SandboxesRepository { sandboxId: string, options: GetSandboxMetricsOptions ): Promise> - listSandboxes(): Promise> listSandboxesPaginated( options: ListSandboxesOptions ): Promise> @@ -373,40 +373,12 @@ 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 listSandboxesPaginated(options) { const result = await deps.infraClient.GET('/v2/sandboxes', { params: { query: { state: options.states ?? DEFAULT_SANDBOX_STATES, + order: options.order, 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 c529088ac..44152d12a 100644 --- a/src/core/server/api/routers/sandboxes.ts +++ b/src/core/server/api/routers/sandboxes.ts @@ -2,10 +2,12 @@ import { z } from 'zod' import { USE_MOCK_DATA } from '@/configs/env-flags' import { calculateTeamMetricsStep, + MOCK_METRICS_DATA, MOCK_SANDBOXES_DATA, MOCK_TEAM_METRICS_DATA, MOCK_TEAM_METRICS_MAX_DATA, } from '@/configs/mock-data' +import type { Sandbox } from '@/core/modules/sandboxes/models' import { createSandboxesRepository } from '@/core/modules/sandboxes/repository.server' import { GetTeamMetricsMaxSchema, @@ -19,6 +21,7 @@ import { } from '@/core/server/functions/sandboxes/utils' import { createTRPCRouter } from '@/core/server/trpc/init' import { protectedTeamProcedure } from '@/core/server/trpc/procedures' +import { SandboxIdSchema } from '@/core/shared/schemas/api' const sandboxesRepositoryProcedure = protectedTeamProcedure.use( withTeamAuthedRequestRepository( @@ -31,31 +34,13 @@ 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)) - - 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(), limit: z.number().int().min(1).max(100).default(50), states: z.array(z.enum(['running', 'paused'])).optional(), + order: z.enum(['asc', 'desc']).optional(), }) ) .query(async ({ ctx, input }) => { @@ -77,6 +62,7 @@ export const sandboxesRouter = createTRPCRouter({ cursor: input.cursor, limit: input.limit, states: input.states, + order: input.order, }) if (!sandboxesResult.ok) { throwTRPCErrorFromRepoError(sandboxesResult.error) @@ -88,6 +74,60 @@ export const sandboxesRouter = createTRPCRouter({ } }), + // Exact-ID lookup backing the list search: finds a sandbox regardless of + // which pages the infinite list has loaded. Returns null instead of + // throwing on 404 so search-as-you-type misses stay silent. + findSandboxById: sandboxesRepositoryProcedure + .input( + z.object({ + sandboxId: SandboxIdSchema, + }) + ) + .query(async ({ ctx, input }): Promise => { + if (USE_MOCK_DATA) { + await new Promise((resolve) => setTimeout(resolve, 200)) + + return ( + MOCK_SANDBOXES_DATA().find( + (sandbox) => sandbox.sandboxID === input.sandboxId + ) ?? null + ) + } + + const detailsResult = await ctx.sandboxesRepository.getSandboxDetails( + input.sandboxId + ) + if (!detailsResult.ok) { + if (detailsResult.error.status === 404) { + return null + } + throwTRPCErrorFromRepoError(detailsResult.error) + } + + // The database-record fallback only resolves killed sandboxes, which + // don't belong in the running/paused list. + if (detailsResult.data.source !== 'infra') { + return null + } + + const details = detailsResult.data.details + + return { + sandboxID: details.sandboxID, + clientID: details.clientID, + templateID: details.templateID, + alias: details.alias, + startedAt: details.startedAt, + endAt: details.endAt, + cpuCount: details.cpuCount, + memoryMB: details.memoryMB, + diskSizeMB: details.diskSizeMB, + metadata: details.metadata, + state: details.state, + envdVersion: details.envdVersion, + } + }), + getSandboxesMetrics: sandboxesRepositoryProcedure .input( z.object({ @@ -97,12 +137,23 @@ export const sandboxesRouter = createTRPCRouter({ .query(async ({ ctx, input }) => { const { sandboxIds } = input - if (sandboxIds.length === 0 || USE_MOCK_DATA) { + if (sandboxIds.length === 0) { return { metrics: {}, } } + if (USE_MOCK_DATA) { + await new Promise((resolve) => setTimeout(resolve, 200)) + + const requestedIds = new Set(sandboxIds) + return MOCK_METRICS_DATA( + MOCK_SANDBOXES_DATA().filter((sandbox) => + requestedIds.has(sandbox.sandboxID) + ) + ) + } + const metricsDataResult = await ctx.sandboxesRepository.getSandboxesMetrics(sandboxIds) if (!metricsDataResult.ok) { diff --git a/src/core/shared/contracts/infra-api.types.ts b/src/core/shared/contracts/infra-api.types.ts index f3f508a50..b4aba1e73 100644 --- a/src/core/shared/contracts/infra-api.types.ts +++ b/src/core/shared/contracts/infra-api.types.ts @@ -277,6 +277,8 @@ export interface paths { metadata?: string /** @description Filter sandboxes by one or more states */ state?: components['schemas']['SandboxState'][] + /** @description Sort direction by sandbox start time. Defaults to desc (newest first). */ + order?: 'asc' | 'desc' /** @description Cursor to start the list from */ nextToken?: components['parameters']['paginationNextToken'] /** @description Maximum number of items to return per page */ diff --git a/src/features/dashboard/common/resource-usage.tsx b/src/features/dashboard/common/resource-usage.tsx index 3466536de..55d616970 100644 --- a/src/features/dashboard/common/resource-usage.tsx +++ b/src/features/dashboard/common/resource-usage.tsx @@ -1,103 +1,138 @@ -import type React from 'react' +import type { ComponentType } from 'react' import { cn } from '@/lib/utils' -import { formatNumber } from '@/lib/utils/formatting' +import { formatDecimal, formatNumber } from '@/lib/utils/formatting' -export interface ResourceUsageProps { - type: 'cpu' | 'mem' | 'disk' - metrics?: number | null - total?: number | null - /** Display mode: 'usage' shows metrics/total, 'simple' shows only total */ - mode?: 'usage' | 'simple' - classNames?: { - wrapper?: string - } +const CPU_ERROR_PCT = 90 +const CAPACITY_ERROR_PCT = 95 +const WARNING_PCT = 70 + +const USAGE_WRAPPER_CLASSNAME = + 'text-fg-tertiary inline w-full overflow-x-hidden whitespace-nowrap' + +const usageToneClassName = (pct: number, errorAt: number) => + pct >= errorAt + ? 'text-accent-error-highlight' + : pct >= WARNING_PCT + ? 'text-accent-warning-highlight' + : 'text-fg' + +const INDICATOR_ICON_CLASSNAME = 'mt-px mr-1 size-3 self-center' + +interface CpuUsageProps { + usedPct?: number | null + cores?: number | null + className?: string + indicatorIcon?: ComponentType<{ className?: string }> } -const ResourceUsage: React.FC = ({ - type, - metrics, - total, - mode = 'usage', - classNames, -}) => { - const isCpu = type === 'cpu' - const isDisk = type === 'disk' - const unit = isCpu ? 'Core' : isDisk ? 'GB' : 'MB' - const hasMetrics = metrics !== null && metrics !== undefined +// 38% · 2 Core +export function CpuUsage({ + usedPct, + cores, + className, + indicatorIcon: IndicatorIcon, +}: CpuUsageProps) { + const hasUsage = usedPct !== null && usedPct !== undefined + const pct = Math.round(usedPct ?? 0) + const toneClassName = usageToneClassName(pct, CPU_ERROR_PCT) - if (mode === 'simple') { - const hasValue = total !== null && total !== undefined && total !== 0 - const displayTotal = hasValue ? formatNumber(total) : '--' - return ( -

- - {displayTotal} + return ( + + {hasUsage ? ( + + {IndicatorIcon && pct >= WARNING_PCT ? ( + + ) : null} + {pct}%{' '} - {hasValue && ( - - {unit} - {isCpu && total && total > 1 ? 's' : ''} - - )} -

- ) - } + ) : ( + -- + )} + · + + {cores ? formatNumber(cores) : '--'} Core + + + ) +} - const percentage = isCpu - ? (metrics ?? 0) - : metrics && total - ? (metrics / total) * 100 - : 0 - const roundedPercentage = Math.round(percentage) +const truncateToOneDecimal = (value: number) => Math.trunc(value * 10) / 10 - const textClassName = cn( - roundedPercentage >= (isCpu ? 90 : 95) - ? 'text-accent-error-highlight' - : roundedPercentage >= 70 - ? 'text-accent-warning-highlight' - : 'text-fg' - ) +const formatAmount = (value: number) => + formatNumber(truncateToOneDecimal(value), 'en-US', 1) + +interface CapacityUsageProps { + usedGb?: number | null + totalGb?: number | null + className?: string + indicatorIcon?: ComponentType<{ className?: string }> +} - const displayValue = hasMetrics ? formatNumber(metrics) : '--' - const totalValue = total ? formatNumber(total) : '--' +// 16% · 0.5 / 4 GB +export function CapacityUsage({ + usedGb, + totalGb, + className, + indicatorIcon: IndicatorIcon, +}: CapacityUsageProps) { + const hasUsage = usedGb !== null && usedGb !== undefined + const pct = Math.round(hasUsage && totalGb ? (usedGb / totalGb) * 100 : 0) + const toneClassName = usageToneClassName(pct, CAPACITY_ERROR_PCT) return ( - - {hasMetrics ? ( + + {hasUsage ? ( <> - {roundedPercentage}% - · - {!isCpu && ( - <> - {displayValue} / - - )} + + {IndicatorIcon && pct >= WARNING_PCT ? ( + + ) : null} + {pct}%{' '} + + · + + {formatDecimal(truncateToOneDecimal(usedGb), 1)} + {' '} + /{' '} ) : ( <> - -- - · + -- + · )} - {totalValue} {unit} - {isCpu && total && total > 1 ? 's' : ''} + + {totalGb ? formatAmount(totalGb) : '--'} GB + ) } -export default ResourceUsage +interface ResourceSpecProps { + value?: number | null + unit: 'Core' | 'MB' | 'GB' + className?: string +} + +// Allocation only, e.g. 2 Core / 4 GB — for paused sandboxes and build specs. +export function ResourceSpec({ value, unit, className }: ResourceSpecProps) { + const hasValue = value !== null && value !== undefined && value !== 0 + + return ( +

+ + {hasValue ? formatAmount(value) : '--'} + + {hasValue && {unit}} +

+ ) +} diff --git a/src/features/dashboard/sandboxes/list/feature-flag.server.ts b/src/features/dashboard/sandboxes/list/feature-flag.server.ts deleted file mode 100644 index 2115999cd..000000000 --- a/src/features/dashboard/sandboxes/list/feature-flag.server.ts +++ /dev/null @@ -1,27 +0,0 @@ -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/header.tsx b/src/features/dashboard/sandboxes/list/header.tsx index 3c77820c3..28bd651cf 100644 --- a/src/features/dashboard/sandboxes/list/header.tsx +++ b/src/features/dashboard/sandboxes/list/header.tsx @@ -13,12 +13,14 @@ interface SandboxesHeaderProps { table: SandboxListTable onRefresh: () => void isRefreshing: boolean + hasNextPage?: boolean } export function SandboxesHeader({ table, onRefresh, isRefreshing, + hasNextPage = false, }: SandboxesHeaderProps) { 'use no memo' @@ -31,10 +33,15 @@ export function SandboxesHeader({ const tableState = table.getState() const { columnFilters, globalFilter } = tableState - const showFilteredRowCount = columnFilters.length > 0 || Boolean(globalFilter) + const isFiltered = columnFilters.length > 0 || Boolean(globalFilter) - const filteredCount = table.getFilteredRowModel().rows.length - const totalCount = table.getCoreRowModel().rows.length + // With server-side pagination the row models only hold the loaded pages, + // so "N+" marks the count as a lower bound until all pages are in. + const visibleCount = isFiltered + ? table.getFilteredRowModel().rows.length + : table.getCoreRowModel().rows.length + const countLabel = `${visibleCount.toLocaleString('en-US')}${hasNextPage ? '+' : ''}` + const countNoun = visibleCount === 1 && !hasNextPage ? 'sandbox' : 'sandboxes' return (
@@ -44,23 +51,21 @@ export function SandboxesHeader({
- + diff --git a/src/features/dashboard/sandboxes/list/stores/table-store.ts b/src/features/dashboard/sandboxes/list/stores/table-store.ts index 8ae62ac09..d49784924 100644 --- a/src/features/dashboard/sandboxes/list/stores/table-store.ts +++ b/src/features/dashboard/sandboxes/list/stores/table-store.ts @@ -3,6 +3,7 @@ import type { OnChangeFn, SortingState } from '@tanstack/react-table' import { create } from 'zustand' import { createJSONStorage, persist } from 'zustand/middleware' +import type { SandboxState } from '@/core/modules/sandboxes/models' import { areStringArraysEqual } from '@/lib/utils/array' import { createHashStorage } from '@/lib/utils/store' import { trackSandboxListInteraction } from '../tracking' @@ -20,6 +21,14 @@ type SandboxListPollingInterval = export type SandboxStartedAtFilter = '1h ago' | '6h ago' | '12h ago' | undefined +export const sandboxListDefaultStatusFilters: SandboxState[] = [ + 'running', + 'paused', +] + +export const isStatusFilterActive = (statusFilters: SandboxState[]) => + statusFilters.length === 1 + export const sandboxListDefaultSorting: SortingState = [ { id: 'startedAt', desc: true }, ] @@ -48,6 +57,7 @@ interface SandboxListTableState { templateFilters: string[] cpuCount: number | undefined memoryMB: number | undefined + statusFilters: SandboxState[] } interface SandboxListTableActions { @@ -60,6 +70,7 @@ interface SandboxListTableActions { setTemplateFilters: (filters: string[]) => void setCpuCount: (count: number | undefined) => void setMemoryMB: (mb: number | undefined) => void + toggleStatusFilter: (state: SandboxState) => void resetFilters: () => void // Page actions @@ -81,6 +92,7 @@ const initialState: SandboxListTableState = { templateFilters: [], cpuCount: undefined, memoryMB: undefined, + statusFilters: sandboxListDefaultStatusFilters, } export const useSandboxListTableStore = create()( @@ -239,6 +251,41 @@ export const useSandboxListTableStore = create()( }) }, + toggleStatusFilter: (statusToToggle) => { + let nextStatusFilters: SandboxState[] | null = null + + set((state) => { + const isSelected = state.statusFilters.includes(statusToToggle) + + // Keep at least one state selected — deselecting the last one + // would make the list always empty. + if (isSelected && state.statusFilters.length === 1) { + return state + } + + // Rebuild from the defaults to keep a canonical order, so the + // resulting query key is stable regardless of toggle order. + const statusFilters = isSelected + ? state.statusFilters.filter((s) => s !== statusToToggle) + : sandboxListDefaultStatusFilters.filter( + (s) => state.statusFilters.includes(s) || s === statusToToggle + ) + + nextStatusFilters = statusFilters + + return { statusFilters } + }) + + if (!nextStatusFilters) { + return + } + + trackSandboxListInteraction('filtered', { + type: 'status', + value: (nextStatusFilters as SandboxState[]).join(','), + }) + }, + resetFilters: () => { let didResetFilters = false @@ -248,7 +295,8 @@ export const useSandboxListTableStore = create()( state.templateFilters.length > 0 || state.cpuCount !== initialState.cpuCount || state.memoryMB !== initialState.memoryMB || - state.globalFilter !== initialState.globalFilter + state.globalFilter !== initialState.globalFilter || + isStatusFilterActive(state.statusFilters) if (!hasFilterChanges) { return state @@ -262,6 +310,7 @@ export const useSandboxListTableStore = create()( cpuCount: initialState.cpuCount, memoryMB: initialState.memoryMB, globalFilter: initialState.globalFilter, + statusFilters: initialState.statusFilters, } }) diff --git a/src/features/dashboard/sandboxes/list/table-body.tsx b/src/features/dashboard/sandboxes/list/table-body.tsx index a07233bd1..ba3bd6a34 100644 --- a/src/features/dashboard/sandboxes/list/table-body.tsx +++ b/src/features/dashboard/sandboxes/list/table-body.tsx @@ -6,7 +6,10 @@ 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 { + isStatusFilterActive, + useSandboxListTableStore, +} from './stores/table-store' import type { SandboxListRow, SandboxListTable } from './table-config' import { SandboxesTableRow } from './table-row' @@ -41,6 +44,7 @@ export const SandboxesTableBody = ({ state.templateFilters.length > 0 || state.cpuCount !== undefined || state.memoryMB !== undefined || + isStatusFilterActive(state.statusFilters) || Boolean(state.globalFilter) ) }) @@ -77,8 +81,12 @@ export const SandboxesTableBody = ({ const lastVisibleIndex = virtualizer.getVirtualItems().at(-1)?.index ?? -1 + // Client-side filters shrink centerRows, which would keep this condition + // true and drain every page; filtered views only cover loaded pages, with + // the load-more button as the manual escape hatch. useEffect(() => { if ( + !hasFilter && hasNextPage && !isFetchingNextPage && fetchNextPage && @@ -87,6 +95,7 @@ export const SandboxesTableBody = ({ fetchNextPage() } }, [ + hasFilter, hasNextPage, isFetchingNextPage, lastVisibleIndex, diff --git a/src/features/dashboard/sandboxes/list/table-cells.tsx b/src/features/dashboard/sandboxes/list/table-cells.tsx index 47557fc92..faad39a50 100644 --- a/src/features/dashboard/sandboxes/list/table-cells.tsx +++ b/src/features/dashboard/sandboxes/list/table-cells.tsx @@ -4,45 +4,46 @@ import type { CellContext } from '@tanstack/react-table' import Link from 'next/link' import { useMemo } from 'react' import { PROTECTED_URLS } from '@/configs/urls' -import ResourceUsage from '@/features/dashboard/common/resource-usage' +import { + CapacityUsage, + CpuUsage, +} from '@/features/dashboard/common/resource-usage' import { useTimezone } from '@/features/dashboard/timezone' -import { formatDateParts } from '@/lib/utils/formatting' +import { useNow } from '@/lib/hooks/use-now' +import { formatDateParts, formatDurationPadded } from '@/lib/utils/formatting' import { JsonPopover } from '@/ui/json-popover' import { Badge } from '@/ui/primitives/badge' import { Button } from '@/ui/primitives/button' -import { DotIcon, ExternalLinkIcon, PausedIcon } from '@/ui/primitives/icons' +import { CpuIcon, DotIcon, MemoryIcon, 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' -// 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. +// Live usage only applies to running sandboxes; the store may still hold a +// stale sample for a sandbox that paused while visible, so non-live reads +// are skipped and the cells render `-- · `. const CpuUsageCellView = ({ sandboxId, totalCpu, + live, }: { sandboxId: string totalCpu?: number + live: boolean }) => { - const cpuUsedPct = useSandboxMetricsStore( - (state) => state.metrics?.[sandboxId]?.cpuUsedPct + const cpuUsedPct = useSandboxMetricsStore((state) => + live ? state.metrics?.[sandboxId]?.cpuUsedPct : undefined ) return ( - ) } @@ -50,20 +51,22 @@ const CpuUsageCellView = ({ const RamUsageCellView = ({ sandboxId, totalMem, + live, }: { sandboxId: string totalMem?: number + live: boolean }) => { - const memUsedMb = useSandboxMetricsStore( - (state) => state.metrics?.[sandboxId]?.memUsedMb + const memUsedMb = useSandboxMetricsStore((state) => + live ? state.metrics?.[sandboxId]?.memUsedMb : undefined ) return ( - ) } @@ -71,86 +74,93 @@ const RamUsageCellView = ({ const DiskUsageCellView = ({ sandboxId, totalDiskGb, + live, }: { sandboxId: string totalDiskGb: number + live: boolean }) => { - const diskUsedGb = useSandboxMetricsStore( - (state) => state.metrics?.[sandboxId]?.diskUsedGb + const diskUsedGb = useSandboxMetricsStore((state) => + live ? state.metrics?.[sandboxId]?.diskUsedGb : undefined ) return ( - ) } export const CpuUsageCell = ({ row }: CellContext) => (
- {row.original.state === 'running' ? ( - - ) : ( - - )} +
) export const RamUsageCell = ({ row }: CellContext) => (
- {row.original.state === 'running' ? ( - - ) : ( - - )} +
) export const DiskUsageCell = ({ row, }: CellContext) => ( +
+ +
+) + +const HOUR_MS = 60 * 60 * 1000 + +// Seconds are only rendered under an hour; older sandboxes tick per minute. +const RunningDurationView = ({ startedAt }: { startedAt: string }) => { + const startedAtMs = useMemo(() => new Date(startedAt).getTime(), [startedAt]) + const minuteNow = useNow(60_000) + const showsSeconds = minuteNow - startedAtMs < HOUR_MS + const secondNow = useNow(1_000, showsSeconds) + const elapsedMs = Math.max( + (showsSeconds ? secondNow : minuteNow) - startedAtMs, + 0 + ) + + return ( + + {formatDurationPadded(elapsedMs)} + + ) +} + +export const RunningCell = ({ row }: CellContext) => (
{row.original.state === 'running' ? ( - + ) : ( - + -- )}
) -export function StateCell({ row }: CellContext) { +export function StatusCell({ row }: CellContext) { const state = row.original.state if (state === 'paused') { return ( - + Paused @@ -158,7 +168,7 @@ export function StateCell({ row }: CellContext) { } return ( - + Running @@ -167,9 +177,7 @@ export function StateCell({ row }: CellContext) { export function IdCell({ getValue }: CellContext) { return ( -
+
{getValue() as string}
) @@ -196,9 +204,9 @@ export function TemplateCell({ e.stopPropagation()} + className="relative z-10" > {templateIdentifier} - ) @@ -223,7 +231,7 @@ export function MetadataCell({ return ( {value} @@ -237,45 +245,24 @@ export function StartedAtCell({ const { timezone } = useTimezone() const dateValue = (getValue() as string | undefined) ?? '' - const formattedTimestamp = useMemo(() => { - return formatDateParts(dateValue, { timezone }) - }, [dateValue, timezone]) - - return ( -
- - {formattedTimestamp?.datePart ?? '--'} - {' '} - {formattedTimestamp?.timePart ?? '--'}{' '} - - {formattedTimestamp?.timezonePart ?? ''} - -
- ) -} - -export function LegacyStartedAtCell({ - getValue, -}: CellContext) { - const { timezone } = useTimezone() - const dateValue = (getValue() as string | undefined) ?? '' - const formattedTimestamp = useMemo(() => { return formatDateParts(dateValue, { timezone, - format: 'date-time-no-seconds', + format: 'date-time-with-centiseconds', }) }, [dateValue, timezone]) return ( -
+
{formattedTimestamp?.datePart ?? '--'} {' '} - {formattedTimestamp?.timePart ?? '--'}{' '} - - {formattedTimestamp?.timezonePart ?? ''} - + {formattedTimestamp?.timePart ?? '--'} + {formattedTimestamp && ( + + .{formattedTimestamp.subsecondPart} + + )}
) } diff --git a/src/features/dashboard/sandboxes/list/table-config.tsx b/src/features/dashboard/sandboxes/list/table-config.tsx index a217449b2..8214c9cb0 100644 --- a/src/features/dashboard/sandboxes/list/table-config.tsx +++ b/src/features/dashboard/sandboxes/list/table-config.tsx @@ -3,21 +3,18 @@ import { rankItem } from '@tanstack/match-sorter-utils' import type { ColumnDef, FilterFn, Table } from '@tanstack/react-table' import { isWithinInterval } from 'date-fns' -import { useMemo } from 'react' import type { DateRange } from 'react-day-picker' import type { Sandbox } from '@/core/modules/sandboxes/models' -import { getTimestampColumnSize } from '@/features/dashboard/common/timestamp-column' -import { useTimezone } from '@/features/dashboard/timezone' import { CpuUsageCell, DiskUsageCell, IdCell, - LegacyStartedAtCell, MetadataCell, RamUsageCell, + RunningCell, StartedAtCell, - StateCell, + StatusCell, TemplateCell, } from './table-cells' @@ -112,7 +109,7 @@ export const sandboxListColumns: ColumnDef[] = [ accessorKey: 'startedAt', header: 'Started At', cell: StartedAtCell, - size: 150, + size: 152, enableResizing: false, filterFn: startedAtDateRangeFilter, enableColumnFilter: true, @@ -133,10 +130,11 @@ export const sandboxListColumns: ColumnDef[] = [ enableGlobalFilter: true, }, { + // The column is presented as "Status", but the API field is `state`. accessorKey: 'state', - id: 'state', - header: 'State', - cell: StateCell, + id: 'status', + header: 'Status', + cell: StatusCell, size: 90, minSize: 80, enableResizing: false, @@ -157,11 +155,21 @@ export const sandboxListColumns: ColumnDef[] = [ filterFn: templateIdentifierFilter, enableGlobalFilter: false, }, + { + id: 'running', + header: 'Running', + cell: RunningCell, + size: 80, + enableResizing: false, + enableSorting: false, + enableColumnFilter: false, + enableGlobalFilter: false, + }, { id: 'cpuUsage', header: 'CPU', cell: (props) => , - size: 100, + size: 104, enableResizing: false, enableSorting: false, enableColumnFilter: true, @@ -171,7 +179,7 @@ export const sandboxListColumns: ColumnDef[] = [ id: 'ramUsage', header: 'Memory', cell: (props) => , - size: 140, + size: 150, enableResizing: false, enableSorting: false, enableColumnFilter: true, @@ -181,7 +189,7 @@ export const sandboxListColumns: ColumnDef[] = [ id: 'diskUsage', header: 'Disk', cell: (props) => , - size: 100, + size: 138, enableResizing: false, enableSorting: false, enableColumnFilter: false, @@ -199,98 +207,3 @@ export const sandboxListColumns: ColumnDef[] = [ enableSorting: false, }, ] - -// Templates' base (172) minus the ", 2026" year segment the legacy format drops. -const LEGACY_TIMESTAMP_COLUMN_BASE_SIZE = 124 - -export const useLegacySandboxListColumns = (): ColumnDef[] => { - const { timezone } = useTimezone() - const timestampColumnSize = getTimestampColumnSize( - timezone, - LEGACY_TIMESTAMP_COLUMN_BASE_SIZE - ) - - return useMemo( - () => [ - { - 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: LegacyStartedAtCell, - size: timestampColumnSize, - enableResizing: false, - filterFn: startedAtDateRangeFilter, - enableColumnFilter: true, - enableGlobalFilter: false, - sortingFn: (rowA, rowB) => { - return rowA.original.startedAt.localeCompare(rowB.original.startedAt) - }, - }, - ], - [timestampColumnSize] - ) -} diff --git a/src/features/dashboard/sandboxes/list/table-filters.tsx b/src/features/dashboard/sandboxes/list/table-filters.tsx index b127d3a99..bf3a037fc 100644 --- a/src/features/dashboard/sandboxes/list/table-filters.tsx +++ b/src/features/dashboard/sandboxes/list/table-filters.tsx @@ -1,14 +1,20 @@ import * as React from 'react' import { memo, useCallback } from 'react' import { useDebounceValue } from 'usehooks-ts' +import type { SandboxState } from '@/core/modules/sandboxes/models' import type { SandboxStartedAtFilter } from '@/features/dashboard/sandboxes/list/stores/table-store' -import { useSandboxListTableStore } from '@/features/dashboard/sandboxes/list/stores/table-store' +import { + isStatusFilterActive, + useSandboxListTableStore, +} from '@/features/dashboard/sandboxes/list/stores/table-store' import { cn } from '@/lib/utils' import { formatCPUCores, formatMemory } from '@/lib/utils/formatting' import { NumberInput } from '@/ui/number-input' +import { Badge } from '@/ui/primitives/badge' import { Button } from '@/ui/primitives/button' import { DropdownMenu, + DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, @@ -19,7 +25,7 @@ import { DropdownMenuSubTrigger, DropdownMenuTrigger, } from '@/ui/primitives/dropdown-menu' -import { AddIcon, FilterIcon } from '@/ui/primitives/icons' +import { AddIcon, DotIcon, FilterIcon, PausedIcon } from '@/ui/primitives/icons' import { Input } from '@/ui/primitives/input' import { Label } from '@/ui/primitives/label' import { Separator } from '@/ui/primitives/separator' @@ -138,6 +144,54 @@ const TemplateFilter = memo(function TemplateFilter() { ) }) +const STATUS_FILTER_OPTIONS: { + state: SandboxState + badge: React.ReactNode +}[] = [ + { + state: 'running', + badge: ( + + + Running + + ), + }, + { + state: 'paused', + badge: ( + + + Paused + + ), + }, +] + +const StatusFilter = memo(function StatusFilter() { + const statusFilters = useSandboxListTableStore((state) => state.statusFilters) + const toggleStatusFilter = useSandboxListTableStore( + (state) => state.toggleStatusFilter + ) + + return ( +
+ {STATUS_FILTER_OPTIONS.map(({ state, badge }) => ( + { + e.preventDefault() + toggleStatusFilter(state) + }} + > + {badge} + + ))} +
+ ) +}) + const ResourcesFilter = memo(function ResourcesFilter() { const cpuCount = useSandboxListTableStore((state) => state.cpuCount) const setCpuCount = useSandboxListTableStore((state) => state.setCpuCount) @@ -265,10 +319,13 @@ const ResourcesFilter = memo(function ResourcesFilter() { // Main component export interface SandboxesTableFiltersProps - extends React.HTMLAttributes {} + extends React.HTMLAttributes { + showStatusFilter?: boolean +} const SandboxesTableFilters = memo(function SandboxesTableFilters({ className, + showStatusFilter = false, ...props }: SandboxesTableFiltersProps) { const startedAtFilter = useSandboxListTableStore( @@ -279,6 +336,7 @@ const SandboxesTableFilters = memo(function SandboxesTableFilters({ ) const cpuCount = useSandboxListTableStore((state) => state.cpuCount) const memoryMB = useSandboxListTableStore((state) => state.memoryMB) + const statusFilters = useSandboxListTableStore((state) => state.statusFilters) const setStartedAtFilter = useSandboxListTableStore( (state) => state.setStartedAtFilter ) @@ -287,6 +345,9 @@ const SandboxesTableFilters = memo(function SandboxesTableFilters({ ) const setCpuCount = useSandboxListTableStore((state) => state.setCpuCount) const setMemoryMB = useSandboxListTableStore((state) => state.setMemoryMB) + const toggleStatusFilter = useSandboxListTableStore( + (state) => state.toggleStatusFilter + ) const handleTemplateFilterClick = useCallback( (filter: string) => { @@ -333,6 +394,16 @@ const SandboxesTableFilters = memo(function SandboxesTableFilters({ + {showStatusFilter && ( + + Status + + + + + + + )} @@ -367,6 +438,21 @@ const SandboxesTableFilters = memo(function SandboxesTableFilters({ onClick={() => setMemoryMB(undefined)} /> )} + {showStatusFilter && + isStatusFilterActive(statusFilters) && + statusFilters.map((status) => ( + o.state === status)?.badge ?? + status + } + onClick={() => { + toggleStatusFilter(status === 'running' ? 'paused' : 'running') + }} + /> + ))}
) }) diff --git a/src/features/dashboard/sandboxes/list/table-row.tsx b/src/features/dashboard/sandboxes/list/table-row.tsx index 9eefe220a..3a1d07fec 100644 --- a/src/features/dashboard/sandboxes/list/table-row.tsx +++ b/src/features/dashboard/sandboxes/list/table-row.tsx @@ -3,7 +3,9 @@ import Link from 'next/link' import { memo } from 'react' import { PROTECTED_URLS } from '@/configs/urls' import { useRouteParams } from '@/lib/hooks/use-route-params' +import { cn } from '@/lib/utils' import { DataTableCell, DataTableRow } from '@/ui/data-table' +import { RowHoverFrame } from '@/ui/row-hover-frame' import type { SandboxListRow } from './table-config' interface SandboxesTableRowProps { @@ -16,21 +18,32 @@ export const SandboxesTableRow = memo(function SandboxesTableRow({ const { teamSlug } = useRouteParams<'/dashboard/[teamSlug]/sandboxes'>() return ( - - - {row.getVisibleCells().map((cell) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} - - + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} +
+ + ) }) diff --git a/src/features/dashboard/sandboxes/list/table.tsx b/src/features/dashboard/sandboxes/list/table.tsx index dfc8bd480..7e7d2fbef 100644 --- a/src/features/dashboard/sandboxes/list/table.tsx +++ b/src/features/dashboard/sandboxes/list/table.tsx @@ -2,8 +2,8 @@ import { keepPreviousData, + useQuery, useSuspenseInfiniteQuery, - useSuspenseQuery, } from '@tanstack/react-query' import { type ColumnDef, @@ -18,8 +18,12 @@ 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 type { Sandboxes, SandboxState } from '@/core/modules/sandboxes/models' +import { SandboxIdSchema } from '@/core/shared/schemas/api' +import { + isStatusFilterActive, + 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' import { cn } from '@/lib/utils' @@ -37,11 +41,7 @@ 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, - useLegacySandboxListColumns, -} from './table-config' +import { sandboxIdFuzzyFilter, sandboxListColumns } from './table-config' const STARTED_AT_FILTER_HOURS: Record< Exclude, @@ -57,11 +57,13 @@ function buildColumnFilters({ templateFilters, cpuCount, memoryMB, + statusFilters, }: { startedAtFilter: SandboxStartedAtFilter templateFilters: string[] cpuCount?: number memoryMB?: number + statusFilters: SandboxState[] }): ColumnFiltersState { const filters: ColumnFiltersState = [] @@ -84,19 +86,38 @@ function buildColumnFilters({ filters.push({ id: 'ramUsage', value: memoryMB }) } + if (isStatusFilterActive(statusFilters)) { + filters.push({ id: 'status', value: statusFilters[0] }) + } + return filters } const SANDBOXES_PAGE_SIZE = 50 +const SANDBOX_ID_EXACT_SEARCH_MIN_LENGTH = 16 + +function getSearchedSandboxId(globalFilter: string): string | null { + const query = globalFilter.trim() + + if ( + query.length < SANDBOX_ID_EXACT_SEARCH_MIN_LENGTH || + !SandboxIdSchema.safeParse(query).success + ) { + return null + } + + return query +} + interface SandboxesTableViewProps { sandboxes: Sandboxes columns: ColumnDef[] refetch: () => void isFetching: boolean - hasNextPage?: boolean - isFetchingNextPage?: boolean - fetchNextPage?: () => void + hasNextPage: boolean + isFetchingNextPage: boolean + fetchNextPage: () => void } function SandboxesTableView({ @@ -126,6 +147,7 @@ function SandboxesTableView({ templateFilters, cpuCount, memoryMB, + statusFilters, sorting, globalFilter, setSorting, @@ -139,8 +161,9 @@ function SandboxesTableView({ templateFilters, cpuCount, memoryMB, + statusFilters, }), - [startedAtFilter, templateFilters, cpuCount, memoryMB] + [startedAtFilter, templateFilters, cpuCount, memoryMB, statusFilters] ) const activeSorting = getSandboxListEffectiveSorting(sorting) @@ -158,6 +181,9 @@ function SandboxesTableView({ getFilteredRowModel: getFilteredRowModel(), getSortedRowModel: getSortedRowModel(), enableSorting: true, + // Rows arrive already sorted from the server, so the table only reflects + // the sort indicator instead of re-sorting client-side. + manualSorting: true, enableMultiSort: false, enableSortingRemoval: false, columnResizeMode: 'onChange', @@ -192,31 +218,36 @@ function SandboxesTableView({ table={table} onRefresh={refetch} isRefreshing={isFetching} + hasNextPage={hasNextPage} />
- + {table.getHeaderGroups().map((headerGroup) => ( - + {headerGroup.headers.map((header) => ( s.id === header.id)?.desc} align={ + header.id === 'running' || header.id === 'cpuUsage' || header.id === 'ramUsage' || header.id === 'diskUsage' @@ -250,12 +281,20 @@ function SandboxesTableView({ ) } -export function NewSandboxesTable() { +export function SandboxesTable() { const { teamSlug } = useRouteParams<'/dashboard/[teamSlug]/sandboxes'>() const trpc = useTRPC() const pollingInterval = useSandboxListTableStore( (state) => state.pollingInterval ) + const statusFilters = useSandboxListTableStore((state) => state.statusFilters) + const globalFilter = useSandboxListTableStore((state) => state.globalFilter) + const sorting = useSandboxListTableStore((state) => state.sorting) + + // The only sortable column is startedAt; map its direction to the server order + // so sorting happens across the whole dataset, not just the loaded pages. + const order: 'asc' | 'desc' = + getSandboxListEffectiveSorting(sorting)[0]?.desc === false ? 'asc' : 'desc' const { data, @@ -266,7 +305,12 @@ export function NewSandboxesTable() { isFetchingNextPage, } = useSuspenseInfiniteQuery( trpc.sandboxes.listSandboxesPaginated.infiniteQueryOptions( - { teamSlug, limit: SANDBOXES_PAGE_SIZE }, + { + teamSlug, + limit: SANDBOXES_PAGE_SIZE, + states: isStatusFilterActive(statusFilters) ? statusFilters : undefined, + order, + }, { getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, initialCursor: undefined, @@ -278,11 +322,35 @@ export function NewSandboxesTable() { ) ) - const sandboxes = useMemo( - () => data.pages.flatMap((page) => page.sandboxes), - [data] + // The search input is debounced into globalFilter, so this fires at most + // once per pause in typing. + const searchedSandboxId = getSearchedSandboxId(globalFilter) + const { data: exactMatch } = useQuery( + trpc.sandboxes.findSandboxById.queryOptions( + { teamSlug, sandboxId: searchedSandboxId ?? '' }, + { + enabled: searchedSandboxId !== null, + staleTime: 30_000, + } + ) ) + const sandboxes = useMemo(() => { + const loaded = data.pages.flatMap((page) => page.sandboxes) + + // Guard against results retained from a previous query after the search + // was cleared or edited. + if ( + !exactMatch || + exactMatch.sandboxID !== searchedSandboxId || + loaded.some((sandbox) => sandbox.sandboxID === exactMatch.sandboxID) + ) { + return loaded + } + + return [...loaded, exactMatch] + }, [data, exactMatch, searchedSandboxId]) + return ( ) } - -export function LegacySandboxesTable() { - const { teamSlug } = useRouteParams<'/dashboard/[teamSlug]/sandboxes'>() - const trpc = useTRPC() - const legacySandboxListColumns = useLegacySandboxListColumns() - - const { data, refetch, isFetching } = useSuspenseQuery( - trpc.sandboxes.getSandboxes.queryOptions( - { teamSlug }, - { - refetchOnMount: 'always', - refetchOnWindowFocus: true, - placeholderData: keepPreviousData, - } - ) - ) - - return ( - - ) -} - -export default LegacySandboxesTable diff --git a/src/features/dashboard/templates/builds/table-body.tsx b/src/features/dashboard/templates/builds/table-body.tsx index 17acd31d3..fccc26dac 100644 --- a/src/features/dashboard/templates/builds/table-body.tsx +++ b/src/features/dashboard/templates/builds/table-body.tsx @@ -77,7 +77,7 @@ export function BuildsTableBody({ 'group-hover/row:hidden group-focus-visible/row:hidden' )} /> - + ) })} diff --git a/src/features/dashboard/templates/builds/table-cells.tsx b/src/features/dashboard/templates/builds/table-cells.tsx index 4f561c0f8..bc26a662e 100644 --- a/src/features/dashboard/templates/builds/table-cells.tsx +++ b/src/features/dashboard/templates/builds/table-cells.tsx @@ -23,7 +23,7 @@ import { TooltipTrigger, } from '@/ui/primitives/tooltip' import { EnvdVersion } from '../../common/envd-version' -import ResourceUsage from '../../common/resource-usage' +import { ResourceSpec } from '../../common/resource-usage' export function BuildId({ id }: { id: string }) { return ( @@ -168,7 +168,7 @@ export function Status({ status, statusMessage }: StatusProps) { export function Cpu({ cpuCount }: { cpuCount: number }) { return (
- +
) } @@ -176,7 +176,7 @@ export function Cpu({ cpuCount }: { cpuCount: number }) { export function Memory({ memoryMB }: { memoryMB: number }) { return (
- +
) } @@ -185,7 +185,7 @@ export function Storage({ diskSizeMB }: { diskSizeMB: number | null }) { const diskSizeGB = diskSizeMB != null ? diskSizeMB / 1024 : null return (
- +
) } diff --git a/src/features/dashboard/templates/list/table-body.tsx b/src/features/dashboard/templates/list/table-body.tsx index 6e42ebad6..8467ba168 100644 --- a/src/features/dashboard/templates/list/table-body.tsx +++ b/src/features/dashboard/templates/list/table-body.tsx @@ -175,6 +175,7 @@ export function TemplatesTableBody({ /> 0) { + const remainingMinutes = String(minutes % 60).padStart(2, '0') + return `${hours}h ${remainingMinutes}m` + } + if (minutes > 0) { + const remainingSeconds = String(seconds % 60).padStart(2, '0') + return `${minutes}m ${remainingSeconds}s` + } + return `${seconds}s` +} + function formatTimeAgoCompact(ms: number): string { const minutes = Math.floor(ms / 1000 / 60) const hours = Math.floor(minutes / 60) @@ -614,6 +632,7 @@ export { formatDateWithSpaces, formatDuration, formatDurationCompact, + formatDurationPadded, formatRelativeAgo, formatTimeAgoCompact, formatTimeAxisLabel, diff --git a/src/ui/primitives/badge.tsx b/src/ui/primitives/badge.tsx index 2962057b7..21ec60f87 100644 --- a/src/ui/primitives/badge.tsx +++ b/src/ui/primitives/badge.tsx @@ -9,8 +9,8 @@ const badgeVariants = cva( 'inline-flex items-center justify-center w-fit', // Text and cursor 'prose-label uppercase cursor-default whitespace-nowrap shrink-0 overflow-hidden', - // Icon styles - ' [&>svg]:pointer-events-none ![&>svg]:pl-0.75 [&>svg]:size-3', + // Icon styles — important, so ancestor [&_svg] rules (e.g. Button's) can't restyle badge-owned icons + ' [&>svg]:pointer-events-none ![&>svg]:pl-0.75 [&>svg]:size-3! [&>svg]:text-current!', // Interactive states 'focus-visible:ring-1', // Error state diff --git a/src/ui/table-filter-button.tsx b/src/ui/table-filter-button.tsx index 2adb37f1a..cecf54419 100644 --- a/src/ui/table-filter-button.tsx +++ b/src/ui/table-filter-button.tsx @@ -3,9 +3,9 @@ import { Button } from './primitives/button' import { CloseIcon } from './primitives/icons' interface TableFilterButtonProps - extends React.ButtonHTMLAttributes { + extends Omit, 'value'> { label: string - value: string + value: React.ReactNode } export const TableFilterButton = React.forwardRef< @@ -24,7 +24,11 @@ export const TableFilterButton = React.forwardRef< {value && ( <> · - {value} + {typeof value === 'string' ? ( + {value} + ) : ( + value + )} )} diff --git a/tests/unit/feature-flags.test.ts b/tests/unit/feature-flags.test.ts index d39aa438c..f3812fdbe 100644 --- a/tests/unit/feature-flags.test.ts +++ b/tests/unit/feature-flags.test.ts @@ -66,7 +66,6 @@ describe('createFeatureFlagService', () => { expect(provider.evaluate).toHaveBeenCalledWith(context, [ FEATURE_FLAGS.agentsEnabled, FEATURE_FLAGS.isAdmin, - FEATURE_FLAGS.newSandboxList, FEATURE_FLAGS.newUsagePage, FEATURE_FLAGS.disableE2BAccessTokenProvisioning, ]) @@ -87,15 +86,6 @@ 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: 'newUsagePage', key: 'new-usage-page',