Skip to content
Merged
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
6 changes: 5 additions & 1 deletion src/app/dashboard/[teamSlug]/sandboxes/(tabs)/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { PROTECTED_URLS } from '@/configs/urls'
import { isNewSandboxListEnabled } from '@/features/dashboard/sandboxes/list/feature-flag.server'
import { DashboardTabsList } from '@/ui/dashboard-tabs'
import { ListIcon, TrendIcon } from '@/ui/primitives/icons'

Expand All @@ -7,6 +8,9 @@ export default async function SandboxesTabsLayout({
params,
}: LayoutProps<'/dashboard/[teamSlug]/sandboxes'>) {
const { teamSlug } = await params
const listHref = (await isNewSandboxListEnabled(teamSlug))
? PROTECTED_URLS.SANDBOXES_LIST2(teamSlug)
: PROTECTED_URLS.SANDBOXES_LIST(teamSlug)

return (
<div className="mt-2 md:mt-3 min-h-0 h-full flex flex-col">
Expand All @@ -22,7 +26,7 @@ export default async function SandboxesTabsLayout({
{
id: 'list',
label: 'List',
href: PROTECTED_URLS.SANDBOXES_LIST(teamSlug),
href: listHref,
icon: <ListIcon className="size-4" />,
},
]}
Expand Down
7 changes: 7 additions & 0 deletions src/app/dashboard/[teamSlug]/sandboxes/(tabs)/list/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { redirect } from 'next/navigation'
import { Suspense } from 'react'
import { PROTECTED_URLS } from '@/configs/urls'
import LoadingLayout from '@/features/dashboard/loading-layout'
import { isNewSandboxListEnabled } from '@/features/dashboard/sandboxes/list/feature-flag.server'
import SandboxesTable from '@/features/dashboard/sandboxes/list/table'
import { HydrateClient, prefetch, trpc } from '@/trpc/server'

Expand All @@ -8,6 +11,10 @@ export default async function SandboxesListPage({
}: PageProps<'/dashboard/[teamSlug]/sandboxes/list'>) {
const { teamSlug } = await params

if (await isNewSandboxListEnabled(teamSlug)) {
redirect(PROTECTED_URLS.SANDBOXES_LIST2(teamSlug))
}

prefetch(
trpc.sandboxes.getSandboxes.queryOptions({
teamSlug,
Expand Down
13 changes: 13 additions & 0 deletions src/app/dashboard/[teamSlug]/sandboxes/(tabs)/list2/error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
'use client'

import { DashboardRouteError } from '@/features/dashboard/shared/route-error'

export default function NewSandboxesListError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return <DashboardRouteError error={error} reset={reset} />
}
34 changes: 34 additions & 0 deletions src/app/dashboard/[teamSlug]/sandboxes/(tabs)/list2/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { redirect } from 'next/navigation'
import { Suspense } from 'react'
import { PROTECTED_URLS } from '@/configs/urls'
import LoadingLayout from '@/features/dashboard/loading-layout'
import { isNewSandboxListEnabled } from '@/features/dashboard/sandboxes/list/feature-flag.server'
import { NewSandboxesTable } from '@/features/dashboard/sandboxes/list/table'
import { HydrateClient, prefetch, trpc } from '@/trpc/server'

export default async function NewSandboxesListPage({
params,
}: {
params: Promise<{ teamSlug: string }>
}) {
const { teamSlug } = await params

if (!(await isNewSandboxListEnabled(teamSlug))) {
redirect(PROTECTED_URLS.SANDBOXES_LIST(teamSlug))
}

prefetch(
trpc.sandboxes.listSandboxesPaginated.infiniteQueryOptions({
teamSlug,
limit: 50,
})
)

return (
<HydrateClient>
<Suspense fallback={<LoadingLayout />}>
<NewSandboxesTable />
</Suspense>
</HydrateClient>
)
}
4 changes: 4 additions & 0 deletions src/configs/layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,14 @@
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 45 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 46 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 @@ -70,8 +74,8 @@
}),
'/dashboard/*/templates/*/builds/*': (pathname) => {
const parts = pathname.split('/')
const teamSlug = parts[2]!

Check warning on line 77 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 78 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 @@ -149,7 +153,7 @@
}),
'/dashboard/*/billing/plan': (pathname) => {
const parts = pathname.split('/')
const teamSlug = parts[2]!

Check warning on line 156 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 @@ -163,7 +167,7 @@
},
'/dashboard/*/billing/plan/select': (pathname) => {
const parts = pathname.split('/')
const teamSlug = parts[2]!

Check warning on line 170 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 @@ -187,8 +191,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 194 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 195 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
2 changes: 2 additions & 0 deletions src/configs/urls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ export const PROTECTED_URLS = {
SANDBOXES_MONITORING: (teamSlug: string) =>
`/dashboard/${teamSlug}/sandboxes/monitoring`,
SANDBOXES_LIST: (teamSlug: string) => `/dashboard/${teamSlug}/sandboxes/list`,
SANDBOXES_LIST2: (teamSlug: string) =>
`/dashboard/${teamSlug}/sandboxes/list2`,

SANDBOX: (teamSlug: string, sandboxId: string) =>
`/dashboard/${teamSlug}/sandboxes/${sandboxId}/monitoring`,
Expand Down
8 changes: 8 additions & 0 deletions src/core/modules/feature-flags/definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@ export const FEATURE_FLAGS = {
description: 'Enables dashboard admin-only surfaces.',
exposure: 'server',
},
newSandboxList: {
kind: 'boolean',
key: 'new_sandbox_list',
defaultValue: false,
description:
'Enables the new sandbox list with pagination and paused sandbox coverage.',
exposure: 'both',
},
disableE2BAccessTokenProvisioning: {
kind: 'boolean',
key: 'disable_e2b_access_token_provisioning',
Expand Down
1 change: 1 addition & 0 deletions src/core/modules/sandboxes/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { components as InfraComponents } from '@/contracts/infra-api'
export type SandboxLogLevel = InfraComponents['schemas']['LogLevel']
export type Sandbox = InfraComponents['schemas']['ListedSandbox']
export type Sandboxes = InfraComponents['schemas']['ListedSandbox'][]
export type SandboxState = InfraComponents['schemas']['SandboxState']
export type SandboxesMetricsRecord =
InfraComponents['schemas']['SandboxesWithMetrics']['sandboxes']
export type TeamMetric = InfraComponents['schemas']['TeamMetric']
Expand Down
58 changes: 57 additions & 1 deletion src/core/modules/sandboxes/repository.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
SandboxEventModel,
Sandboxes,
SandboxesMetricsRecord,
SandboxState,
TeamMetric,
} from '@/core/modules/sandboxes/models'
import { api, infra } from '@/core/shared/clients/api'
Expand Down Expand Up @@ -36,6 +37,19 @@ export interface GetSandboxMetricsOptions {
endUnixMs: number
}

export interface ListSandboxesOptions {
cursor?: string
limit: number
states?: SandboxState[]
}

export interface ListSandboxesResult {
sandboxes: Sandboxes
nextCursor: string | null
}

const DEFAULT_SANDBOX_STATES: SandboxState[] = ['running', 'paused']

export interface SandboxesRepository {
getSandboxLogs(
sandboxId: string,
Expand All @@ -61,6 +75,9 @@ export interface SandboxesRepository {
options: GetSandboxMetricsOptions
): Promise<RepoResult<InfraComponents['schemas']['SandboxMetric'][]>>
listSandboxes(): Promise<RepoResult<Sandboxes>>
listSandboxesPaginated(
options: ListSandboxesOptions
): Promise<RepoResult<ListSandboxesResult>>
getSandboxesMetrics(
sandboxIds: string[]
): Promise<RepoResult<SandboxesMetricsRecord>>
Expand Down Expand Up @@ -383,7 +400,46 @@ export function createSandboxesRepository(
)
}

return ok(result.data)
return ok(result.data ?? [])
},
async listSandboxesPaginated(options) {
const result = await deps.infraClient.GET('/v2/sandboxes', {
params: {
query: {
state: options.states ?? DEFAULT_SANDBOX_STATES,
nextToken: options.cursor,
limit: options.limit,
},
},
headers: {
...deps.authHeaders(scope.accessToken, scope.teamId),
},
cache: 'no-store',
})

if (!result.response.ok || result.error) {
l.error({
key: 'repositories:sandboxes:list_sandboxes_paginated:infra_error',
error: result.error,
team_id: scope.teamId,
context: {
status: result.response.status,
path: '/v2/sandboxes',
},
})
return err(
repoErrorFromHttp(
result.response.status,
result.error?.message ?? 'Failed to list sandboxes',
result.error
)
)
}

return ok({
sandboxes: result.data ?? [],
nextCursor: result.response.headers.get('x-next-token') || null,
})
},
async getSandboxesMetrics(sandboxIds) {
const result = await deps.infraClient.GET('/sandboxes/metrics', {
Expand Down
42 changes: 39 additions & 3 deletions src/core/server/api/routers/sandboxes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,8 @@ export const sandboxesRouter = createTRPCRouter({
if (USE_MOCK_DATA) {
await new Promise((resolve) => setTimeout(resolve, 200))

const sandboxes = MOCK_SANDBOXES_DATA()

return {
sandboxes,
sandboxes: MOCK_SANDBOXES_DATA(),
}
}

Expand All @@ -52,6 +50,44 @@ export const sandboxesRouter = createTRPCRouter({
}
}),

listSandboxesPaginated: sandboxesRepositoryProcedure
.input(
z.object({
cursor: z.string().optional(),
limit: z.number().int().min(1).max(100).default(50),
states: z.array(z.enum(['running', 'paused'])).optional(),
})
)
.query(async ({ ctx, input }) => {
if (USE_MOCK_DATA) {
await new Promise((resolve) => setTimeout(resolve, 200))

return {
sandboxes: input.states
? MOCK_SANDBOXES_DATA().filter((sandbox) =>
input.states?.includes(sandbox.state)
)
: MOCK_SANDBOXES_DATA(),
nextCursor: null,
}
}

const sandboxesResult =
await ctx.sandboxesRepository.listSandboxesPaginated({
cursor: input.cursor,
limit: input.limit,
states: input.states,
})
if (!sandboxesResult.ok) {
throwTRPCErrorFromRepoError(sandboxesResult.error)
}

return {
sandboxes: sandboxesResult.data.sandboxes,
nextCursor: sandboxesResult.data.nextCursor,
}
}),

getSandboxesMetrics: sandboxesRepositoryProcedure
.input(
z.object({
Expand Down
13 changes: 9 additions & 4 deletions src/features/dashboard/common/resource-usage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,12 @@ const ResourceUsage: React.FC<ResourceUsageProps> = ({
const hasValue = total !== null && total !== undefined && total !== 0
const displayTotal = hasValue ? formatNumber(total) : '--'
return (
<p className="flex justify-end gap-1 prose-table">
<p
className={cn(
'flex justify-end gap-1 prose-table',
classNames?.wrapper
)}
>
<span
className={cn(
'prose-table-numeric',
Expand Down Expand Up @@ -63,8 +68,8 @@ const ResourceUsage: React.FC<ResourceUsageProps> = ({
: 'text-fg'
)

const displayValue = hasMetrics ? formatNumber(metrics) : 'n/a'
const totalValue = total ? formatNumber(total) : 'n/a'
const displayValue = hasMetrics ? formatNumber(metrics) : '--'
const totalValue = total ? formatNumber(total) : '--'

return (
<span
Expand All @@ -85,7 +90,7 @@ const ResourceUsage: React.FC<ResourceUsageProps> = ({
</>
) : (
<>
<span className="text-fg-tertiary">n/a </span>
<span className="text-fg-tertiary">-- </span>
<span className="text-fg-tertiary mx-1">·</span>
</>
)}
Expand Down
27 changes: 27 additions & 0 deletions src/features/dashboard/sandboxes/list/feature-flag.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import 'server-only'

import { featureFlags } from '@/core/modules/feature-flags/feature-flags.server'
import { getAuthContext } from '@/core/server/auth'
import { getTeamIdFromSlug } from '@/core/server/functions/team/get-team-id-from-slug'

export async function isNewSandboxListEnabled(teamSlug: string) {
const authContext = await getAuthContext()

if (!authContext) {
return false
}

const teamIdResult = await getTeamIdFromSlug(
teamSlug,
authContext.accessToken
)
const teamId = teamIdResult.ok ? teamIdResult.data : null

return featureFlags.isEnabled('newSandboxList', {
user: {
id: authContext.user.id,
email: authContext.user.email ?? undefined,
},
team: teamId ? { id: teamId, slug: teamSlug } : undefined,
})
}
Loading
Loading