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({