diff --git a/src/app/dashboard/[teamIdOrSlug]/sandboxes/(tabs)/@list/page.tsx b/src/app/dashboard/[teamIdOrSlug]/sandboxes/(tabs)/@list/page.tsx
index 5b8217ec7..1c1a0703f 100644
--- a/src/app/dashboard/[teamIdOrSlug]/sandboxes/(tabs)/@list/page.tsx
+++ b/src/app/dashboard/[teamIdOrSlug]/sandboxes/(tabs)/@list/page.tsx
@@ -8,7 +8,7 @@ export default async function ListPage({
}: PageProps<'/dashboard/[teamIdOrSlug]/sandboxes'>) {
const { teamIdOrSlug } = await params
- await prefetch(
+ prefetch(
trpc.sandboxes.getSandboxes.queryOptions({
teamIdOrSlug,
})
diff --git a/src/app/dashboard/[teamIdOrSlug]/templates/loading.tsx b/src/app/dashboard/[teamIdOrSlug]/templates/loading.tsx
deleted file mode 100644
index 249f11404..000000000
--- a/src/app/dashboard/[teamIdOrSlug]/templates/loading.tsx
+++ /dev/null
@@ -1 +0,0 @@
-export { default } from '@/features/dashboard/loading-layout'
diff --git a/src/app/dashboard/[teamIdOrSlug]/templates/page.tsx b/src/app/dashboard/[teamIdOrSlug]/templates/page.tsx
index edeca56ff..ea21637fc 100644
--- a/src/app/dashboard/[teamIdOrSlug]/templates/page.tsx
+++ b/src/app/dashboard/[teamIdOrSlug]/templates/page.tsx
@@ -1,39 +1,25 @@
+import LoadingLayout from '@/features/dashboard/loading-layout'
import TemplatesTable from '@/features/dashboard/templates/table'
-import {
- getDefaultTemplates,
- getTeamTemplates,
-} from '@/server/templates/get-team-templates'
-import ErrorBoundary from '@/ui/error'
+import { HydrateClient, prefetch, trpc } from '@/trpc/server'
+import { Suspense } from 'react'
export default async function Page({
params,
}: PageProps<'/dashboard/[teamIdOrSlug]/templates'>) {
const { teamIdOrSlug } = await params
- const res = await getTeamTemplates({
- teamIdOrSlug,
- })
+ prefetch(
+ trpc.templates.getTemplates.queryOptions({
+ teamIdOrSlug,
+ })
+ )
+ prefetch(trpc.templates.getDefaultTemplatesCached.queryOptions())
- const defaultRes = await getDefaultTemplates()
-
- if (!res?.data?.templates || res?.serverError) {
- return (
-
- )
- }
-
- const templates = [
- ...res.data.templates,
- ...(defaultRes?.data?.templates ? defaultRes.data.templates : []),
- ]
-
- return
+ return (
+
+ }>
+
+
+
+ )
}
diff --git a/src/features/dashboard/templates/table-cells.tsx b/src/features/dashboard/templates/table-cells.tsx
index a655e2d16..31b3e0532 100644
--- a/src/features/dashboard/templates/table-cells.tsx
+++ b/src/features/dashboard/templates/table-cells.tsx
@@ -7,10 +7,7 @@ import {
} from '@/lib/hooks/use-toast'
import { cn } from '@/lib/utils'
import { isVersionCompatible } from '@/lib/utils/version'
-import {
- deleteTemplateAction,
- updateTemplateAction,
-} from '@/server/templates/templates-actions'
+import { useTRPC } from '@/trpc/client'
import { DefaultTemplate, Template } from '@/types/api.types'
import { AlertDialog } from '@/ui/alert-dialog'
import { E2BBadge } from '@/ui/brand'
@@ -26,9 +23,10 @@ import {
DropdownMenuTrigger,
} from '@/ui/primitives/dropdown-menu'
import { Loader } from '@/ui/primitives/loader_d'
+import { useMutation, useQueryClient } from '@tanstack/react-query'
import { CellContext } from '@tanstack/react-table'
import { Lock, LockOpen, MoreVertical } from 'lucide-react'
-import { useAction } from 'next-safe-action/hooks'
+import { useParams } from 'next/navigation'
import { useMemo, useState } from 'react'
import ResourceUsage from '../common/resource-usage'
import { useDashboard } from '../context'
@@ -49,60 +47,144 @@ export function ActionsCell({
}: CellContext) {
const template = row.original
const { team } = useDashboard()
+ const { teamIdOrSlug } =
+ useParams<
+ Awaited['params']>
+ >()
+
const { toast } = useToast()
+ const trpc = useTRPC()
+ const queryClient = useQueryClient()
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
- const { execute: executeUpdateTemplate, isExecuting: isUpdating } = useAction(
- updateTemplateAction,
- {
- onSuccess: ({ input }) => {
+ const updateTemplateMutation = useMutation(
+ trpc.templates.updateTemplate.mutationOptions({
+ onSuccess: async (data, variables) => {
+ const templateName = template.aliases[0] || template.templateID
+
toast(
defaultSuccessToast(
- `Template is now ${input.props.Public ? 'public' : 'private'}.`
+ <>
+ Template{' '}
+ {templateName} is
+ now {data.public ? 'public' : 'private'}.
+ >
)
)
+
+ await queryClient.cancelQueries({
+ queryKey: trpc.templates.getTemplates.queryKey({
+ teamIdOrSlug,
+ }),
+ })
+
+ queryClient.setQueryData(
+ trpc.templates.getTemplates.queryKey({
+ teamIdOrSlug,
+ }),
+ (old) => {
+ if (!old?.templates) return old
+
+ return {
+ ...old,
+ templates: old.templates.map((t: Template) =>
+ t.templateID === variables.templateId
+ ? { ...t, public: variables.public }
+ : t
+ ),
+ }
+ }
+ )
},
onError: (error) => {
+ const templateName = template.aliases[0] || template.templateID
toast(
defaultErrorToast(
- error.error.serverError || 'Failed to update template.'
+ error.message || `Failed to update template ${templateName}.`
)
)
},
- }
+ onSettled: () => {
+ queryClient.invalidateQueries({
+ queryKey: trpc.templates.getTemplates.queryKey({
+ teamIdOrSlug,
+ }),
+ })
+ },
+ })
)
- const { execute: executeDeleteTemplate, isExecuting: isDeleting } = useAction(
- deleteTemplateAction,
- {
- onSuccess: () => {
- toast(defaultSuccessToast('Template has been deleted.'))
+ const deleteTemplateMutation = useMutation(
+ trpc.templates.deleteTemplate.mutationOptions({
+ onSuccess: async (_, variables) => {
+ const templateName = template.aliases[0] || template.templateID
+ toast(
+ defaultSuccessToast(
+ <>
+ Template{' '}
+ {templateName} has
+ been deleted.
+ >
+ )
+ )
+
+ // stop ongoing invlaidations and remove template from state while refetch is going in the background
+
+ await queryClient.cancelQueries({
+ queryKey: trpc.templates.getTemplates.queryKey({
+ teamIdOrSlug,
+ }),
+ })
+
+ queryClient.setQueryData(
+ trpc.templates.getTemplates.queryKey({
+ teamIdOrSlug,
+ }),
+
+ (old) => {
+ if (!old?.templates) return old
+ return {
+ ...old,
+ templates: old.templates.filter(
+ (t: Template) => t.templateID !== variables.templateId
+ ),
+ }
+ }
+ )
},
- onError: (error) => {
+ onError: (error, _variables) => {
+ const templateName = template.aliases[0] || template.templateID
toast(
defaultErrorToast(
- error.error.serverError || 'Failed to delete template.'
+ error.message || `Failed to delete template ${templateName}.`
)
)
},
onSettled: () => {
setIsDeleteDialogOpen(false)
+
+ queryClient.invalidateQueries({
+ queryKey: trpc.templates.getTemplates.queryKey({
+ teamIdOrSlug,
+ }),
+ })
},
- }
+ })
)
- const togglePublish = async () => {
- executeUpdateTemplate({
+ const isUpdating = updateTemplateMutation.isPending
+ const isDeleting = deleteTemplateMutation.isPending
+
+ const togglePublish = () => {
+ updateTemplateMutation.mutate({
teamIdOrSlug: team.slug ?? team.id,
templateId: template.templateID,
- props: {
- Public: !template.public,
- },
+ public: !template.public,
})
}
- const deleteTemplate = async () => {
- executeDeleteTemplate({
+ const deleteTemplate = () => {
+ deleteTemplateMutation.mutate({
teamIdOrSlug: team.slug ?? team.id,
templateId: template.templateID,
})
@@ -114,7 +196,23 @@ export function ActionsCell({
open={isDeleteDialogOpen}
onOpenChange={setIsDeleteDialogOpen}
title="Delete Template"
- description="Are you sure you want to delete this template? This action cannot be undone."
+ description={
+ <>
+ You are about to delete the template{' '}
+ {template.aliases[0] && (
+ <>
+
+ {template.aliases[0]}
+ {' '}
+ (
+ >
+ )}
+
+ {template.templateID}
+
+ {template.aliases[0] && <>)>}. This action cannot be undone.
+ >
+ }
confirm="Delete"
onConfirm={() => deleteTemplate()}
confirmProps={{
diff --git a/src/features/dashboard/templates/table.tsx b/src/features/dashboard/templates/table.tsx
index 08519eb08..71a38afe9 100644
--- a/src/features/dashboard/templates/table.tsx
+++ b/src/features/dashboard/templates/table.tsx
@@ -3,7 +3,8 @@
import { useColumnSizeVars } from '@/lib/hooks/use-column-size-vars'
import { useVirtualRows } from '@/lib/hooks/use-virtual-rows'
import { cn } from '@/lib/utils'
-import { DefaultTemplate, Template } from '@/types/api.types'
+import { useTRPC } from '@/trpc/client'
+import { Template } from '@/types/api.types'
import ClientOnly from '@/ui/client-only'
import {
DataTable,
@@ -11,8 +12,10 @@ import {
DataTableHeader,
DataTableRow,
} from '@/ui/data-table'
+import ErrorBoundary from '@/ui/error'
import HelpTooltip from '@/ui/help-tooltip'
import { SIDEBAR_TRANSITION_CLASSNAMES } from '@/ui/primitives/sidebar'
+import { useSuspenseQuery } from '@tanstack/react-query'
import {
ColumnFiltersState,
ColumnSizingState,
@@ -20,23 +23,53 @@ import {
TableOptions,
useReactTable,
} from '@tanstack/react-table'
-import { useEffect, useRef, useState } from 'react'
+import { useParams } from 'next/navigation'
+import { useEffect, useMemo, useRef, useState } from 'react'
import { useLocalStorage } from 'usehooks-ts'
import TemplatesHeader from './header'
import { useTemplateTableStore } from './stores/table-store'
import { TemplatesTableBody as TableBody } from './table-body'
import { fallbackData, templatesTableConfig, useColumns } from './table-config'
-interface TemplatesTableProps {
- templates: (Template | DefaultTemplate)[]
-}
-
const ROW_HEIGHT_PX = 32
const VIRTUAL_OVERSCAN = 8
-export default function TemplatesTable({ templates }: TemplatesTableProps) {
+export default function TemplatesTable() {
'use no memo'
+ const trpc = useTRPC()
+ const { teamIdOrSlug } =
+ useParams<
+ Awaited['params']>
+ >()
+
+ const { data: templatesData, error: templatesError } = useSuspenseQuery(
+ trpc.templates.getTemplates.queryOptions(
+ { teamIdOrSlug },
+ {
+ refetchOnMount: false,
+ refetchOnWindowFocus: false,
+ refetchOnReconnect: false,
+ }
+ )
+ )
+
+ const { data: defaultTemplatesData } = useSuspenseQuery(
+ trpc.templates.getDefaultTemplatesCached.queryOptions(undefined, {
+ refetchOnMount: false,
+ refetchOnWindowFocus: false,
+ refetchOnReconnect: false,
+ })
+ )
+
+ const templates = useMemo(
+ () => [
+ ...(defaultTemplatesData?.templates ?? []),
+ ...(templatesData?.templates ?? []),
+ ],
+ [templatesData, defaultTemplatesData]
+ )
+
const scrollRef = useRef(null)
const { sorting, setSorting, globalFilter, setGlobalFilter } =
@@ -126,6 +159,18 @@ export default function TemplatesTable({ templates }: TemplatesTableProps) {
overscan: VIRTUAL_OVERSCAN,
})
+ if (templatesError) {
+ return (
+
+ )
+ }
+
return (
diff --git a/src/server/api/middlewares/telemetry.ts b/src/server/api/middlewares/telemetry.ts
index 37deb1773..99cfc58fc 100644
--- a/src/server/api/middlewares/telemetry.ts
+++ b/src/server/api/middlewares/telemetry.ts
@@ -61,7 +61,7 @@ const errorCounter = meter.createCounter('trpc.procedure.errors', {
* The span will be closed and logs written by endTelemetryMiddleware.
*/
export const startTelemetryMiddleware = t.middleware(
- async ({ ctx, next, path, type, input }) => {
+ async ({ ctx, next, path, type }) => {
const tracer = getTracer()
const procedurePath = path || 'unknown'
@@ -201,8 +201,8 @@ export const endTelemetryMiddleware = t.middleware(
})
span.recordException(error)
- // internal errors with causes are unexpected bugs - log as error and obfuscate
- if (error.code === 'INTERNAL_SERVER_ERROR' && error.cause) {
+ // internal errors are mostly unexpected - log as error and potentially obfuscate
+ if (error.code === 'INTERNAL_SERVER_ERROR') {
l.error(
{
key: 'trpc:unexpected_error',
@@ -215,12 +215,18 @@ export const endTelemetryMiddleware = t.middleware(
'trpc.procedure.input': rawInput,
'trpc.procedure.duration_ms': durationMs,
- error: serializeError(error.cause),
+ error: serializeError(error),
},
- `[tRPC] ${routerName}.${procedureName} failed unexpectedly: ${error.cause.message}`
+ `[tRPC] ${routerName}.${procedureName}: ${error.code} ${error?.cause?.message || error.message}`
)
- throw internalServerError()
+ // when it's internal error AND has a cause (unhandled errors), obfuscate
+ if (error.cause) {
+ throw internalServerError()
+ }
+
+ // otherwise return as is
+ return result
}
// expected errors (validation, not found, etc) - log as warning
@@ -238,7 +244,7 @@ export const endTelemetryMiddleware = t.middleware(
error: serializeError(error),
},
- `[tRPC] ${routerName}.${procedureName} failed: ${error.message}`
+ `[tRPC] ${routerName}.${procedureName}: ${error.code} ${error.message}`
)
return result
@@ -258,11 +264,12 @@ export const endTelemetryMiddleware = t.middleware(
'trpc.procedure.input': rawInput,
'trpc.procedure.duration_ms': durationMs,
},
- `[tRPC] ${routerName}.${procedureName} succeeded in ${durationMs}ms`
+ `[tRPC] ${routerName}.${procedureName}`
)
return result
} catch (error) {
+ // if the error is coming from a route (INTERNAL_SERVER_ERROR), rethrow
if (error instanceof TRPCError) {
throw error
}
@@ -275,9 +282,10 @@ export const endTelemetryMiddleware = t.middleware(
'trpc.router.name': routerName,
'trpc.procedure.name': procedureName,
+
error: serializeError(error),
},
- `[tRPC] Telemetry error in ${routerName}.${procedureName}`
+ `[tRPC] telemetry error in ${routerName}.${procedureName}${error && typeof error === 'object' && error !== null && 'message' in error && typeof error.message === 'string' ? `: ${error.message}` : ''}`
)
throw internalServerError()
diff --git a/src/server/api/routers/index.ts b/src/server/api/routers/index.ts
index 2db365337..3f5ea28e8 100644
--- a/src/server/api/routers/index.ts
+++ b/src/server/api/routers/index.ts
@@ -1,10 +1,12 @@
import { createCallerFactory, createTRPCRouter } from '../init'
import { sandboxesRouter } from './sandboxes'
import { teamsRouter } from './teams'
+import { templatesRouter } from './templates'
export const trpcAppRouter = createTRPCRouter({
sandboxes: sandboxesRouter,
teams: teamsRouter,
+ templates: templatesRouter,
})
export type TRPCAppRouter = typeof trpcAppRouter
diff --git a/src/server/api/routers/templates.ts b/src/server/api/routers/templates.ts
new file mode 100644
index 000000000..e7b9293e6
--- /dev/null
+++ b/src/server/api/routers/templates.ts
@@ -0,0 +1,320 @@
+import { SUPABASE_AUTH_HEADERS } from '@/configs/api'
+import { CACHE_TAGS } from '@/configs/cache'
+import { USE_MOCK_DATA } from '@/configs/flags'
+import {
+ MOCK_DEFAULT_TEMPLATES_DATA,
+ MOCK_TEMPLATES_DATA,
+} from '@/configs/mock-data'
+import { infra } from '@/lib/clients/api'
+import { l } from '@/lib/clients/logger/logger'
+import { supabaseAdmin } from '@/lib/clients/supabase/admin'
+import { DefaultTemplate } from '@/types/api.types'
+import { TRPCError } from '@trpc/server'
+import { cacheLife, cacheTag } from 'next/cache'
+import { z } from 'zod'
+import { apiError } from '../errors'
+import { createTRPCRouter } from '../init'
+import { protectedProcedure, protectedTeamProcedure } from '../procedures'
+
+export const templatesRouter = createTRPCRouter({
+ // QUERIES
+
+ getTemplates: protectedTeamProcedure.query(async ({ ctx }) => {
+ const { session, teamId } = ctx
+
+ if (USE_MOCK_DATA) {
+ await new Promise((resolve) => setTimeout(resolve, 500))
+ return {
+ templates: MOCK_TEMPLATES_DATA,
+ }
+ }
+
+ const res = await infra.GET('/templates', {
+ params: {
+ query: {
+ teamID: teamId,
+ },
+ },
+ headers: {
+ ...SUPABASE_AUTH_HEADERS(session.access_token),
+ },
+ })
+
+ if (!res.response.ok) {
+ const status = res.response.status
+
+ l.error(
+ {
+ key: 'trpc:templates:get_team_templates:infra_error',
+ error: res.error,
+ team_id: teamId,
+ user_id: session.user.id,
+ context: {
+ status,
+ body: await res.response.text(),
+ },
+ },
+ `Failed to get team templates: ${res.error?.message}`
+ )
+
+ throw apiError(status)
+ }
+
+ return {
+ templates: res.data,
+ }
+ }),
+
+ getDefaultTemplatesCached: protectedProcedure.query(async () => {
+ return getDefaultTemplatesCached()
+ }),
+
+ // MUTATIONS
+
+ deleteTemplate: protectedTeamProcedure
+ .input(
+ z.object({
+ templateId: z.string(),
+ })
+ )
+ .mutation(async ({ ctx, input }) => {
+ const { session, teamId } = ctx
+ const { templateId } = input
+
+ const res = await infra.DELETE('/templates/{templateID}', {
+ params: {
+ path: {
+ templateID: templateId,
+ },
+ },
+ headers: {
+ ...SUPABASE_AUTH_HEADERS(session.access_token),
+ },
+ })
+
+ if (!res.response.ok) {
+ const status = res.response.status
+
+ l.error(
+ {
+ key: 'trpc:templates:delete_template:infra_error',
+ error: res.error,
+ user_id: session.user.id,
+ team_id: teamId,
+ template_id: templateId,
+ context: {
+ status,
+ body: await res.response.text(),
+ },
+ },
+ `Failed to delete template: ${res.error?.message || 'Unknown error'}`
+ )
+
+ if (status === 404) {
+ throw new TRPCError({
+ code: 'NOT_FOUND',
+ message: 'Template not found',
+ })
+ }
+
+ if (
+ status === 400 &&
+ res.error?.message?.includes(
+ 'because there are paused sandboxes using it'
+ )
+ ) {
+ throw new TRPCError({
+ code: 'BAD_REQUEST',
+ message:
+ 'Cannot delete template because there are paused sandboxes using it',
+ })
+ }
+
+ throw apiError(status)
+ }
+
+ return { success: true }
+ }),
+
+ updateTemplate: protectedTeamProcedure
+ .input(
+ z.object({
+ templateId: z.string(),
+ public: z.boolean(),
+ })
+ )
+ .mutation(async ({ ctx, input }) => {
+ const { session, teamId } = ctx
+ const { templateId, public: isPublic } = input
+
+ const res = await infra.PATCH('/templates/{templateID}', {
+ body: {
+ public: isPublic,
+ },
+ params: {
+ path: {
+ templateID: templateId,
+ },
+ },
+ headers: {
+ ...SUPABASE_AUTH_HEADERS(session.access_token),
+ },
+ })
+
+ if (!res.response.ok) {
+ const status = res.response.status
+
+ l.error(
+ {
+ key: 'trpc:templates:update_template:infra_error',
+ error: res.error,
+ user_id: session.user.id,
+ team_id: teamId,
+ template_id: templateId,
+ context: {
+ status,
+ body: await res.response.text(),
+ },
+ },
+ `Failed to update template: ${res.error?.message || 'Unknown error'}`
+ )
+
+ if (status === 404) {
+ throw new TRPCError({
+ code: 'NOT_FOUND',
+ message: 'Template not found',
+ })
+ }
+
+ throw apiError(status)
+ }
+
+ return { success: true, public: isPublic }
+ }),
+})
+
+async function getDefaultTemplatesCached() {
+ 'use cache: remote'
+ cacheTag(CACHE_TAGS.DEFAULT_TEMPLATES)
+ cacheLife('hours')
+
+ if (USE_MOCK_DATA) {
+ await new Promise((resolve) => setTimeout(resolve, 500))
+ return {
+ templates: MOCK_DEFAULT_TEMPLATES_DATA,
+ }
+ }
+
+ const { data: defaultEnvs, error: defaultEnvsError } = await supabaseAdmin
+ .from('env_defaults')
+ .select('*')
+
+ if (defaultEnvsError) {
+ throw defaultEnvsError
+ }
+
+ if (!defaultEnvs || defaultEnvs.length === 0) {
+ return {
+ templates: [],
+ }
+ }
+
+ const envIds = defaultEnvs.map((env) => env.env_id)
+
+ const { data: envs, error: envsError } = await supabaseAdmin
+ .from('envs')
+ .select(
+ `
+ id,
+ created_at,
+ updated_at,
+ public,
+ build_count,
+ spawn_count,
+ last_spawned_at,
+ created_by
+ `
+ )
+ .in('id', envIds)
+
+ if (envsError) {
+ throw envsError
+ }
+
+ const templates: DefaultTemplate[] = []
+
+ for (const env of envs) {
+ const { data: latestBuild, error: buildError } = await supabaseAdmin
+ .from('env_builds')
+ .select('id, ram_mb, vcpu, total_disk_size_mb, envd_version')
+ .eq('env_id', env.id)
+ .eq('status', 'uploaded')
+ .order('created_at', { ascending: false })
+ .limit(1)
+ .single()
+
+ if (buildError) {
+ l.error(
+ {
+ key: 'trpc:templates:get_default_templates:env_builds_supabase_error',
+ error: buildError,
+ template_id: env.id,
+ },
+ `Failed to get template builds: ${buildError.message || 'Unknown error'}`
+ )
+ continue
+ }
+
+ const { data: aliases, error: aliasesError } = await supabaseAdmin
+ .from('env_aliases')
+ .select('alias')
+ .eq('env_id', env.id)
+
+ if (aliasesError) {
+ l.error(
+ {
+ key: 'trpc:templates:get_default_templates:env_aliases_supabase_error',
+ error: aliasesError,
+ template_id: env.id,
+ },
+ `Failed to get template aliases: ${aliasesError.message || 'Unknown error'}`
+ )
+ continue
+ }
+
+ if (!latestBuild.total_disk_size_mb || !latestBuild.envd_version) {
+ l.error(
+ {
+ key: 'trpc:templates:get_default_templates:env_builds_missing_values',
+ template_id: env.id,
+ },
+ `Template build missing required values: total_disk_size_mb or envd_version`
+ )
+ continue
+ }
+
+ templates.push({
+ templateID: env.id,
+ buildID: latestBuild.id,
+ cpuCount: latestBuild.vcpu,
+ memoryMB: latestBuild.ram_mb,
+ diskSizeMB: latestBuild.total_disk_size_mb,
+ envdVersion: latestBuild.envd_version,
+ public: env.public,
+ aliases: aliases.map((a) => a.alias),
+ createdAt: env.created_at,
+ updatedAt: env.updated_at,
+ createdBy: null,
+ lastSpawnedAt: env.last_spawned_at ?? env.created_at,
+ spawnCount: env.spawn_count,
+ buildCount: env.build_count,
+ isDefault: true,
+ defaultDescription:
+ defaultEnvs.find((e) => e.env_id === env.id)?.description ?? undefined,
+ })
+ }
+
+ return {
+ templates: templates,
+ }
+}
diff --git a/src/server/templates/get-team-templates-memo.ts b/src/server/templates/get-team-templates-memo.ts
deleted file mode 100644
index 0b2682e7d..000000000
--- a/src/server/templates/get-team-templates-memo.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-import { cache } from 'react'
-import { getTeamTemplatesPure } from './get-team-templates-pure'
-
-export default cache(getTeamTemplatesPure)
diff --git a/src/server/templates/get-team-templates-pure.ts b/src/server/templates/get-team-templates-pure.ts
deleted file mode 100644
index c2aab0bc1..000000000
--- a/src/server/templates/get-team-templates-pure.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-import { SUPABASE_AUTH_HEADERS } from '@/configs/api'
-import { infra } from '@/lib/clients/api'
-
-export const getTeamTemplatesPure = async (
- teamId: string,
- access_token: string
-) => {
- const res = await infra.GET('/templates', {
- params: {
- query: {
- teamID: teamId,
- },
- },
- headers: {
- ...SUPABASE_AUTH_HEADERS(access_token),
- },
- })
-
- return res
-}
diff --git a/src/server/templates/get-team-templates.ts b/src/server/templates/get-team-templates.ts
deleted file mode 100644
index 776c7299a..000000000
--- a/src/server/templates/get-team-templates.ts
+++ /dev/null
@@ -1,197 +0,0 @@
-import 'server-only'
-
-import { CACHE_TAGS } from '@/configs/cache'
-import { USE_MOCK_DATA } from '@/configs/flags'
-import {
- MOCK_DEFAULT_TEMPLATES_DATA,
- MOCK_TEMPLATES_DATA,
-} from '@/configs/mock-data'
-import {
- actionClient,
- authActionClient,
- withTeamIdResolution,
-} from '@/lib/clients/action'
-import { l } from '@/lib/clients/logger/logger'
-import { supabaseAdmin } from '@/lib/clients/supabase/admin'
-import { TeamIdOrSlugSchema } from '@/lib/schemas/team'
-import { handleDefaultInfraError } from '@/lib/utils/action'
-import { DefaultTemplate } from '@/types/api.types'
-import { cacheLife, cacheTag } from 'next/cache'
-import { z } from 'zod'
-import getTeamTemplatesMemo from './get-team-templates-memo'
-
-const GetTeamTemplatesSchema = z.object({
- teamIdOrSlug: TeamIdOrSlugSchema,
-})
-
-export const getTeamTemplates = authActionClient
- .metadata({ serverFunctionName: 'getTeamTemplates' })
- .schema(GetTeamTemplatesSchema)
- .use(withTeamIdResolution)
- .action(async ({ ctx }) => {
- const { session, teamId } = ctx
-
- if (USE_MOCK_DATA) {
- await new Promise((resolve) => setTimeout(resolve, 500))
- return {
- templates: MOCK_TEMPLATES_DATA,
- }
- }
-
- const userId = session.user.id
- const accessToken = session.access_token
-
- const res = await getTeamTemplatesMemo(teamId, accessToken)
-
- if (res.error) {
- const status = res.response.status
- l.error(
- {
- key: 'get_team_templates:infra_error',
- error: res.error,
- team_id: teamId,
- user_id: userId,
- context: {
- status,
- },
- },
- `Failed to get team templates: ${res.error.message}`
- )
-
- return handleDefaultInfraError(status)
- }
-
- return {
- templates: res.data,
- }
- })
-
-export const getDefaultTemplates = actionClient
- .metadata({ serverFunctionName: 'getDefaultTemplates' })
- .action(async () => {
- 'use cache'
- cacheLife('default')
- cacheTag(CACHE_TAGS.DEFAULT_TEMPLATES)
-
- if (USE_MOCK_DATA) {
- await new Promise((resolve) => setTimeout(resolve, 500))
- return {
- templates: MOCK_DEFAULT_TEMPLATES_DATA,
- }
- }
-
- const { data: defaultEnvs, error: defaultEnvsError } = await supabaseAdmin
- .from('env_defaults')
- .select('*')
-
- if (defaultEnvsError) {
- throw defaultEnvsError
- }
-
- if (!defaultEnvs || defaultEnvs.length === 0) {
- return {
- templates: [],
- }
- }
-
- const envIds = defaultEnvs.map((env) => env.env_id)
-
- const { data: envs, error: envsError } = await supabaseAdmin
- .from('envs')
- .select(
- `
- id,
- created_at,
- updated_at,
- public,
- build_count,
- spawn_count,
- last_spawned_at,
- created_by
- `
- )
- .in('id', envIds)
-
- if (envsError) {
- throw envsError
- }
-
- const templates: DefaultTemplate[] = []
-
- for (const env of envs) {
- const { data: latestBuild, error: buildError } = await supabaseAdmin
- .from('env_builds')
- .select('id, ram_mb, vcpu, total_disk_size_mb, envd_version')
- .eq('env_id', env.id)
- .eq('status', 'uploaded')
- .order('created_at', { ascending: false })
- .limit(1)
- .single()
-
- if (buildError) {
- l.error(
- {
- key: 'get_default_templates:env_builds_supabase_error',
- error: buildError,
- template_id: env.id,
- },
- `Failed to get template builds: ${buildError.message || 'Unknown error'}`
- )
- continue
- }
-
- const { data: aliases, error: aliasesError } = await supabaseAdmin
- .from('env_aliases')
- .select('alias')
- .eq('env_id', env.id)
-
- if (aliasesError) {
- l.error(
- {
- key: 'get_default_templates:env_aliases_supabase_error',
- error: aliasesError,
- template_id: env.id,
- },
- `Failed to get template aliases: ${aliasesError.message || 'Unknown error'}`
- )
- continue
- }
-
- // these values should never be null/undefined at this point, especially for default templates
- if (!latestBuild.total_disk_size_mb || !latestBuild.envd_version) {
- l.error(
- {
- key: 'get_default_templates:env_builds_missing_values',
- template_id: env.id,
- },
- `Template build missing required values: total_disk_size_mb or envd_version`
- )
- continue
- }
-
- templates.push({
- templateID: env.id,
- buildID: latestBuild.id,
- cpuCount: latestBuild.vcpu,
- memoryMB: latestBuild.ram_mb,
- diskSizeMB: latestBuild.total_disk_size_mb,
- envdVersion: latestBuild.envd_version,
- public: env.public,
- aliases: aliases.map((a) => a.alias),
- createdAt: env.created_at,
- updatedAt: env.updated_at,
- createdBy: null,
- lastSpawnedAt: env.last_spawned_at ?? env.created_at,
- spawnCount: env.spawn_count,
- buildCount: env.build_count,
- isDefault: true,
- defaultDescription:
- defaultEnvs.find((e) => e.env_id === env.id)?.description ??
- undefined,
- })
- }
-
- return {
- templates: templates,
- }
- })
diff --git a/src/server/templates/templates-actions.ts b/src/server/templates/templates-actions.ts
deleted file mode 100644
index 919ad7656..000000000
--- a/src/server/templates/templates-actions.ts
+++ /dev/null
@@ -1,131 +0,0 @@
-'use server'
-
-import { SUPABASE_AUTH_HEADERS } from '@/configs/api'
-import { authActionClient, withTeamIdResolution } from '@/lib/clients/action'
-import { infra } from '@/lib/clients/api'
-import { l } from '@/lib/clients/logger/logger'
-import { TeamIdOrSlugSchema } from '@/lib/schemas/team'
-import { handleDefaultInfraError, returnServerError } from '@/lib/utils/action'
-import { revalidatePath } from 'next/cache'
-import { z } from 'zod'
-
-// although team information is not required here, we still use the middleware
-// to handle this issue before it comes back from the infra.
-
-const DeleteTemplateParamsSchema = z.object({
- teamIdOrSlug: TeamIdOrSlugSchema,
- templateId: z.string(),
-})
-
-export const deleteTemplateAction = authActionClient
- .schema(DeleteTemplateParamsSchema)
- .metadata({ actionName: 'deleteTemplate' })
- .use(withTeamIdResolution)
- .action(async ({ parsedInput, ctx }) => {
- const { templateId } = parsedInput
-
- const res = await infra.DELETE('/templates/{templateID}', {
- params: {
- path: {
- templateID: templateId,
- },
- },
- headers: {
- ...SUPABASE_AUTH_HEADERS(ctx.session.access_token),
- },
- })
-
- if (res.error) {
- const status = res.response.status
-
- l.error(
- {
- key: 'DELETE_TEMPLATE_ACTION:INFRA_ERROR',
- error: res.error,
- user_id: ctx.session.user.id,
- template_id: templateId,
- context: {
- status,
- },
- },
- `Failed to delete template: ${res.error.message}`
- )
-
- if (status === 404) {
- return returnServerError('Template not found')
- }
-
- if (
- status === 400 &&
- res.error?.message?.includes(
- 'because there are paused sandboxes using it'
- )
- ) {
- return returnServerError(
- 'Cannot delete template because there are paused sandboxes using it'
- )
- }
-
- return handleDefaultInfraError(status)
- }
-
- revalidatePath(`/dashboard/[teamIdOrSlug]/templates`, 'page')
- })
-
-const UpdateTemplateParamsSchema = z.object({
- teamIdOrSlug: TeamIdOrSlugSchema,
- templateId: z.string(),
- props: z
- .object({
- Public: z.boolean(),
- })
- .partial(),
-})
-
-export const updateTemplateAction = authActionClient
- .schema(UpdateTemplateParamsSchema)
- .metadata({ actionName: 'updateTemplate' })
- .use(withTeamIdResolution)
- .action(async ({ parsedInput, ctx }) => {
- const { templateId, props } = parsedInput
- const { session } = ctx
-
- const res = await infra.PATCH('/templates/{templateID}', {
- body: {
- public: props.Public,
- },
- params: {
- path: {
- templateID: templateId,
- },
- },
- headers: {
- ...SUPABASE_AUTH_HEADERS(session.access_token),
- },
- })
-
- if (res.error) {
- const status = res.response.status
-
- l.error(
- {
- key: 'update_template_action:infra_error',
- error: res.error,
- user_id: ctx.session.user.id,
- template_id: templateId,
- context: {
- status,
- },
- },
- `Failed to update template: ${res.error.message}`
- )
-
- if (status === 404) {
- return returnServerError('Template not found')
- }
-
- return handleDefaultInfraError(status)
- }
-
- revalidatePath(`/dashboard/[teamIdOrSlug]/templates`, 'page')
- })
diff --git a/src/trpc/server.tsx b/src/trpc/server.tsx
index b4c84b4e8..af3fb97a8 100644
--- a/src/trpc/server.tsx
+++ b/src/trpc/server.tsx
@@ -73,10 +73,26 @@ export function HydrateClient(props: { children: React.ReactNode }) {
)
}
+// NOTE - prefetches do not have to be awaited. pending queries will be hydrated and streamed to the client
+// not awaiting the queries is useful for not blocking route trees
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
-export async function prefetch>>(
+export function prefetch>>(
queryOptions: T
) {
+ const queryClient = getQueryClient()
+ if (queryOptions.queryKey[1]?.type === 'infinite') {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ void queryClient.prefetchInfiniteQuery(queryOptions as any)
+ } else {
+ void queryClient.prefetchQuery(queryOptions)
+ }
+}
+
+export async function prefetchAsync<
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ T extends ReturnType>,
+>(queryOptions: T) {
const queryClient = getQueryClient()
if (queryOptions.queryKey[1]?.type === 'infinite') {
// eslint-disable-next-line @typescript-eslint/no-explicit-any