Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions spec/openapi.infra.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 1 addition & 5 deletions src/app/dashboard/[teamSlug]/sandboxes/(tabs)/layout.tsx
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -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 (
<div className="mt-2 md:mt-3 min-h-0 h-full flex flex-col">
Expand All @@ -26,7 +22,7 @@ export default async function SandboxesTabsLayout({
{
id: 'list',
label: 'List',
href: listHref,
href: PROTECTED_URLS.SANDBOXES_LIST(teamSlug),
icon: <ListIcon className="size-4" />,
},
]}
Expand Down
13 changes: 4 additions & 9 deletions src/app/dashboard/[teamSlug]/sandboxes/(tabs)/list/page.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,18 @@
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({
params,
}: 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',
})
)

Expand Down
13 changes: 0 additions & 13 deletions src/app/dashboard/[teamSlug]/sandboxes/(tabs)/list2/error.tsx

This file was deleted.

34 changes: 0 additions & 34 deletions src/app/dashboard/[teamSlug]/sandboxes/(tabs)/list2/page.tsx

This file was deleted.

4 changes: 0 additions & 4 deletions src/configs/layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,10 @@
title: 'Sandboxes',
type: 'custom',
}),
'/dashboard/*/sandboxes/list2': () => ({
title: 'Sandboxes',
type: 'custom',
}),
'/dashboard/*/sandboxes/*/*': (pathname) => {
const parts = pathname.split('/')
const teamSlug = parts[2]!

Check warning on line 41 in src/configs/layout.ts

View workflow job for this annotation

GitHub Actions / Lint

lint/style/noNonNullAssertion

Forbidden non-null assertion.
const sandboxId = parts[4]!

Check warning on line 42 in src/configs/layout.ts

View workflow job for this annotation

GitHub Actions / Lint

lint/style/noNonNullAssertion

Forbidden non-null assertion.

return {
title: [
Expand Down Expand Up @@ -74,8 +70,8 @@
}),
'/dashboard/*/templates/*/builds/*': (pathname) => {
const parts = pathname.split('/')
const teamSlug = parts[2]!

Check warning on line 73 in src/configs/layout.ts

View workflow job for this annotation

GitHub Actions / Lint

lint/style/noNonNullAssertion

Forbidden non-null assertion.
const buildId = parts.pop()!

Check warning on line 74 in src/configs/layout.ts

View workflow job for this annotation

GitHub Actions / Lint

lint/style/noNonNullAssertion

Forbidden non-null assertion.
const buildIdSliced = `${buildId.slice(0, 6)}...${buildId.slice(-6)}`

return {
Expand Down Expand Up @@ -153,7 +149,7 @@
}),
'/dashboard/*/billing/plan': (pathname) => {
const parts = pathname.split('/')
const teamSlug = parts[2]!

Check warning on line 152 in src/configs/layout.ts

View workflow job for this annotation

GitHub Actions / Lint

lint/style/noNonNullAssertion

Forbidden non-null assertion.

return {
title: [
Expand All @@ -167,7 +163,7 @@
},
'/dashboard/*/billing/plan/select': (pathname) => {
const parts = pathname.split('/')
const teamSlug = parts[2]!

Check warning on line 166 in src/configs/layout.ts

View workflow job for this annotation

GitHub Actions / Lint

lint/style/noNonNullAssertion

Forbidden non-null assertion.

return {
title: [
Expand All @@ -191,8 +187,8 @@
// Pathname fallback for detail tabs; usePageTitle replaces with the friendly template name once data loads.
function templateDetailLayoutConfig(pathname: string): DashboardLayoutConfig {
const parts = pathname.split('/')
const teamSlug = parts[2]!

Check warning on line 190 in src/configs/layout.ts

View workflow job for this annotation

GitHub Actions / Lint

lint/style/noNonNullAssertion

Forbidden non-null assertion.
const templateId = parts[4]!

Check warning on line 191 in src/configs/layout.ts

View workflow job for this annotation

GitHub Actions / Lint

lint/style/noNonNullAssertion

Forbidden non-null assertion.
const templateIdSliced =
templateId.length > 14
? `${templateId.slice(0, 6)}...${templateId.slice(-6)}`
Expand Down
83 changes: 79 additions & 4 deletions src/configs/mock-data.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -720,6 +721,11 @@
'monitoring',
] as const

const sandboxIdSuffix = customAlphabet(
'abcdefghijklmnopqrstuvwxyz0123456789',
20
)

function generateMockSandboxes(count: number): Sandboxes {
const sandboxes: Sandboxes = []
const baseDate = new Date()
Expand Down Expand Up @@ -813,17 +819,29 @@
),
}),
},
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: [],
})
}

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
} {
Expand Down Expand Up @@ -894,13 +912,17 @@
}

for (const sandbox of sandboxes) {
if (sandbox.state !== 'running' || !reportsMetrics(sandbox.sandboxID)) {
continue
}

const pattern = templatePatterns[sandbox.templateID] || {
memoryProfile: 'web',
cpuIntensity: 0.5,
diskGb: 20,
}

const memBaseline = memoryBaselines[pattern.memoryProfile]!

Check warning on line 925 in src/configs/mock-data.ts

View workflow job for this annotation

GitHub Actions / Lint

lint/style/noNonNullAssertion

Forbidden non-null assertion.
const memVolatility = memoryVolatility[pattern.memoryProfile]!

// Generate current load based on time of day
Expand Down Expand Up @@ -1167,9 +1189,62 @@
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
Expand Down
2 changes: 0 additions & 2 deletions src/configs/urls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
7 changes: 7 additions & 0 deletions src/core/modules/billing/repository.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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`,
{
Expand Down
8 changes: 0 additions & 8 deletions src/core/modules/feature-flags/definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
32 changes: 2 additions & 30 deletions src/core/modules/sandboxes/repository.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export interface ListSandboxesOptions {
cursor?: string
limit: number
states?: SandboxState[]
order?: 'asc' | 'desc'
}

export interface ListSandboxesResult {
Expand Down Expand Up @@ -74,7 +75,6 @@ export interface SandboxesRepository {
sandboxId: string,
options: GetSandboxMetricsOptions
): Promise<RepoResult<InfraComponents['schemas']['SandboxMetric'][]>>
listSandboxes(): Promise<RepoResult<Sandboxes>>
listSandboxesPaginated(
options: ListSandboxesOptions
): Promise<RepoResult<ListSandboxesResult>>
Expand Down Expand Up @@ -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,
},
Expand Down
Loading
Loading