diff --git a/spec/openapi.dashboard-api.yaml b/spec/openapi.dashboard-api.yaml
index 453c94c8b..465226803 100644
--- a/spec/openapi.dashboard-api.yaml
+++ b/spec/openapi.dashboard-api.yaml
@@ -10,6 +10,9 @@ components:
type: apiKey
in: header
name: X-Admin-Token
+ # Generated code uses security schemas in the alphabetical order.
+ # In order to check first the token, and then the team (so we can already use the user),
+ # there is a 1 and 2 present in the names of the security schemas.
Supabase1TokenAuth:
type: apiKey
in: header
@@ -18,6 +21,16 @@ components:
type: apiKey
in: header
name: X-Supabase-Team
+ # AuthProviderBearerAuth / AuthProviderTeamAuth: B before T in the name
+ # so Bearer is validated before Team (same reason as Supabase1/2 above).
+ AuthProviderBearerAuth:
+ type: http
+ scheme: bearer
+ bearerFormat: access_token
+ AuthProviderTeamAuth:
+ type: apiKey
+ in: header
+ name: X-Team-ID
parameters:
build_id:
@@ -108,6 +121,94 @@ components:
description: Team slug to resolve.
schema:
type: string
+ templateID:
+ name: templateID
+ in: path
+ required: true
+ description: Identifier of the template.
+ schema:
+ type: string
+ tag:
+ name: tag
+ in: query
+ required: true
+ description: Template tag name to check.
+ schema:
+ type: string
+ minLength: 1
+ tag_assignment_limit:
+ name: assignmentLimit
+ in: query
+ required: false
+ description: Maximum number of ready assignment rows to return per tag.
+ schema:
+ type: integer
+ format: int32
+ minimum: 1
+ maximum: 25
+ default: 6
+ tag_path:
+ name: tag
+ in: path
+ required: true
+ description: Template tag name.
+ schema:
+ type: string
+ minLength: 1
+ tag_assignments_limit:
+ name: limit
+ in: query
+ required: false
+ description: Maximum number of assignment rows to return per page.
+ schema:
+ type: integer
+ format: int32
+ minimum: 1
+ maximum: 100
+ default: 50
+ tag_assignments_cursor:
+ name: cursor
+ in: query
+ required: false
+ description: Cursor returned by the previous list response in `assigned_at|assignment_id` format.
+ schema:
+ type: string
+ tag_groups_limit:
+ name: tagsLimit
+ in: query
+ required: false
+ description: Maximum number of distinct tags to return per page.
+ schema:
+ type: integer
+ format: int32
+ minimum: 1
+ maximum: 100
+ default: 25
+ tag_groups_cursor:
+ name: tagsCursor
+ in: query
+ required: false
+ description: Cursor returned by the previous list response in `{sort}|{latest_assigned_at}|{tag}` format (sort-tagged, RFC3339Nano).
+ schema:
+ type: string
+ tag_groups_search:
+ name: search
+ in: query
+ required: false
+ description: Case-insensitive substring filter on tag name. Allowed characters are `a-z`, `0-9`, `.`, `_`, `-`.
+ schema:
+ type: string
+ maxLength: 64
+ pattern: "^[a-z0-9._-]*$"
+ tag_groups_sort:
+ name: sort
+ in: query
+ required: false
+ description: Sort order for the returned tag groups.
+ schema:
+ type: string
+ enum: [latest_desc, latest_asc, name_asc, name_desc]
+ default: latest_desc
responses:
"400":
@@ -140,6 +241,12 @@ components:
application/json:
schema:
$ref: "#/components/schemas/Error"
+ "502":
+ description: Upstream error
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Error"
schemas:
Error:
@@ -156,6 +263,69 @@ components:
type: string
description: Error message.
+ AdminAuthProviderProfile:
+ type: object
+ required:
+ - userId
+ - email
+ properties:
+ userId:
+ type: string
+ format: uuid
+ description: Internal E2B user identifier.
+ email:
+ type: string
+ nullable: true
+ description: Email address from the configured auth provider.
+
+ AdminAuthProviderProfilesResponse:
+ type: object
+ required:
+ - profiles
+ properties:
+ profiles:
+ type: array
+ items:
+ $ref: "#/components/schemas/AdminAuthProviderProfile"
+
+ AdminAuthProviderProfilesResolveRequest:
+ type: object
+ required:
+ - userIds
+ properties:
+ userIds:
+ type: array
+ minItems: 1
+ maxItems: 100
+ uniqueItems: true
+ items:
+ type: string
+ format: uuid
+
+ AdminAuthProviderProfilesLookupEmailRequest:
+ type: object
+ required:
+ - email
+ properties:
+ email:
+ type: string
+ format: email
+
+ AdminTeamBootstrapRequest:
+ type: object
+ required:
+ - name
+ - email
+ properties:
+ name:
+ type: string
+ minLength: 1
+ description: Team name.
+ email:
+ type: string
+ format: email
+ description: Billing/contact email for the team.
+
BuildStatus:
type: string
description: Build status mapped for dashboard clients.
@@ -438,12 +608,24 @@ components:
- email
- isDefault
- createdAt
+ - providers
properties:
id:
type: string
format: uuid
email:
type: string
+ name:
+ type: string
+ nullable: true
+ profilePictureUrl:
+ type: string
+ format: uri
+ nullable: true
+ providers:
+ type: array
+ items:
+ type: string
isDefault:
type: boolean
addedBy:
@@ -580,6 +762,190 @@ components:
items:
$ref: "#/components/schemas/DefaultTemplate"
+ TemplateDetail:
+ type: object
+ description: Dashboard-shaped single-template read. Mirrors the infra-api `Template` schema fields the dashboard renders.
+ required:
+ - templateID
+ - buildID
+ - cpuCount
+ - memoryMB
+ - diskSizeMB
+ - public
+ - aliases
+ - names
+ - createdAt
+ - updatedAt
+ - lastSpawnedAt
+ - spawnCount
+ - buildCount
+ - envdVersion
+ properties:
+ templateID:
+ type: string
+ description: Identifier of the template.
+ buildID:
+ type: string
+ description: Identifier of the latest ready build for the template, or the zero UUID when none.
+ cpuCount:
+ allOf:
+ - $ref: "#/components/schemas/CPUCount"
+ nullable: true
+ description: vCPU count of the latest ready default build, or `null` when none.
+ memoryMB:
+ allOf:
+ - $ref: "#/components/schemas/MemoryMB"
+ nullable: true
+ description: Memory in MiB of the latest ready default build, or `null` when none.
+ diskSizeMB:
+ allOf:
+ - $ref: "#/components/schemas/DiskSizeMB"
+ nullable: true
+ description: Disk size in MiB of the latest ready default build, or `null` when none.
+ public:
+ type: boolean
+ description: Whether the template is public or only accessible by the team.
+ aliases:
+ type: array
+ description: Aliases of the template.
+ deprecated: true
+ items:
+ type: string
+ names:
+ type: array
+ description: Names of the template (namespace/alias format when namespaced).
+ items:
+ type: string
+ createdAt:
+ type: string
+ format: date-time
+ description: Time when the template was created.
+ updatedAt:
+ type: string
+ format: date-time
+ description: Time when the template was last updated.
+ lastSpawnedAt:
+ type: string
+ format: date-time
+ nullable: true
+ description: Time when the template was last used.
+ spawnCount:
+ type: integer
+ format: int64
+ description: Number of times the template was used.
+ buildCount:
+ type: integer
+ format: int32
+ description: Number of times the template was built.
+ envdVersion:
+ type: string
+ nullable: true
+ description: envd version of the latest ready default build, or `null` when none.
+
+ TemplateTagAssignment:
+ type: object
+ required:
+ - assignmentId
+ - buildId
+ - assignedAt
+ - buildCreatedAt
+ - buildFinishedAt
+ properties:
+ assignmentId:
+ type: string
+ format: uuid
+ description: Identifier of the tag assignment event.
+ buildId:
+ type: string
+ format: uuid
+ description: Identifier of the assigned build.
+ assignedAt:
+ type: string
+ format: date-time
+ description: Time when the tag was assigned to the build.
+ buildCreatedAt:
+ type: string
+ format: date-time
+ description: Time when the assigned build was created.
+ buildFinishedAt:
+ type: string
+ format: date-time
+ nullable: true
+ description: Time when the assigned build finished.
+
+ TemplateTagGroup:
+ type: object
+ required:
+ - tag
+ - assignments
+ - hasMore
+ properties:
+ tag:
+ type: string
+ description: Template tag name.
+ assignments:
+ type: array
+ description: Ready assignment events for this tag, sorted latest first.
+ items:
+ $ref: "#/components/schemas/TemplateTagAssignment"
+ hasMore:
+ type: boolean
+ description: Whether more ready assignment events exist beyond the requested assignment limit.
+
+ TemplateTagGroupsResponse:
+ type: object
+ required:
+ - tags
+ - nextCursor
+ properties:
+ tags:
+ type: array
+ items:
+ $ref: "#/components/schemas/TemplateTagGroup"
+ nextCursor:
+ type: string
+ nullable: true
+ description: Cursor to pass as `tagsCursor` for the next page, or `null` if there is no next page.
+
+ TemplateTagsCountResponse:
+ type: object
+ required:
+ - total
+ properties:
+ total:
+ type: integer
+ format: int64
+ description: Total distinct ready tags for the template.
+
+ TemplateTagExistsResponse:
+ type: object
+ required:
+ - exists
+ - normalizedTag
+ properties:
+ exists:
+ type: boolean
+ description: Whether the template tag has at least one ready assignment.
+ normalizedTag:
+ type: string
+ description: Normalized template tag name.
+
+ TemplateTagAssignmentsResponse:
+ type: object
+ required:
+ - data
+ - nextCursor
+ properties:
+ data:
+ type: array
+ description: Ready assignment events for the tag, sorted latest first.
+ items:
+ $ref: "#/components/schemas/TemplateTagAssignment"
+ nextCursor:
+ type: string
+ nullable: true
+ description: Cursor to pass to the next list request, or `null` if there is no next page.
+
TeamResolveResponse:
type: object
required:
@@ -617,6 +983,8 @@ paths:
security:
- Supabase1TokenAuth: []
Supabase2TeamAuth: []
+ - AuthProviderBearerAuth: []
+ AuthProviderTeamAuth: []
parameters:
- $ref: "#/components/parameters/build_id_or_template"
- $ref: "#/components/parameters/build_statuses"
@@ -645,6 +1013,8 @@ paths:
security:
- Supabase1TokenAuth: []
Supabase2TeamAuth: []
+ - AuthProviderBearerAuth: []
+ AuthProviderTeamAuth: []
parameters:
- $ref: "#/components/parameters/build_ids"
@@ -671,6 +1041,8 @@ paths:
security:
- Supabase1TokenAuth: []
Supabase2TeamAuth: []
+ - AuthProviderBearerAuth: []
+ AuthProviderTeamAuth: []
parameters:
- $ref: "#/components/parameters/build_id"
responses:
@@ -696,6 +1068,8 @@ paths:
security:
- Supabase1TokenAuth: []
Supabase2TeamAuth: []
+ - AuthProviderBearerAuth: []
+ AuthProviderTeamAuth: []
parameters:
- $ref: "#/components/parameters/sandboxID"
responses:
@@ -721,6 +1095,7 @@ paths:
tags: [teams]
security:
- Supabase1TokenAuth: []
+ - AuthProviderBearerAuth: []
responses:
"200":
description: Successfully returned user teams.
@@ -737,6 +1112,7 @@ paths:
tags: [teams]
security:
- Supabase1TokenAuth: []
+ - AuthProviderBearerAuth: []
requestBody:
required: true
content:
@@ -777,6 +1153,109 @@ paths:
"500":
$ref: "#/components/responses/500"
+ /admin/teams/bootstrap:
+ post:
+ summary: Bootstrap team
+ description: Creates and provisions a team for an admin-authenticated bootstrap workflow.
+ tags: [admin]
+ security:
+ - AdminTokenAuth: []
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/AdminTeamBootstrapRequest"
+ responses:
+ "200":
+ description: Successfully bootstrapped team.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/TeamResolveResponse"
+ "400":
+ $ref: "#/components/responses/400"
+ "401":
+ $ref: "#/components/responses/401"
+ "502":
+ $ref: "#/components/responses/502"
+ "500":
+ $ref: "#/components/responses/500"
+
+ /admin/user-profiles/resolve:
+ post:
+ summary: Resolve user profiles
+ tags: [admin]
+ security:
+ - AdminTokenAuth: []
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/AdminAuthProviderProfilesResolveRequest"
+ responses:
+ "200":
+ description: Successfully resolved profiles.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/AdminAuthProviderProfilesResponse"
+ "400":
+ $ref: "#/components/responses/400"
+ "401":
+ $ref: "#/components/responses/401"
+ "500":
+ $ref: "#/components/responses/500"
+
+ /admin/user-profiles/by-email:
+ post:
+ summary: Lookup user profiles by email
+ tags: [admin]
+ security:
+ - AdminTokenAuth: []
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/AdminAuthProviderProfilesLookupEmailRequest"
+ responses:
+ "200":
+ description: Successfully found matching profiles.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/AdminAuthProviderProfilesResponse"
+ "400":
+ $ref: "#/components/responses/400"
+ "401":
+ $ref: "#/components/responses/401"
+ "500":
+ $ref: "#/components/responses/500"
+
+ /admin/user-profiles/{userId}:
+ get:
+ summary: Get user profile
+ tags: [admin]
+ security:
+ - AdminTokenAuth: []
+ parameters:
+ - $ref: "#/components/parameters/userId"
+ responses:
+ "200":
+ description: Successfully found profile.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/AdminAuthProviderProfilesResponse"
+ "400":
+ $ref: "#/components/responses/400"
+ "401":
+ $ref: "#/components/responses/401"
+ "500":
+ $ref: "#/components/responses/500"
+
/teams/resolve:
get:
summary: Resolve team identity
@@ -784,6 +1263,7 @@ paths:
tags: [teams]
security:
- Supabase1TokenAuth: []
+ - AuthProviderBearerAuth: []
parameters:
- $ref: "#/components/parameters/teamSlug"
responses:
@@ -809,6 +1289,8 @@ paths:
security:
- Supabase1TokenAuth: []
Supabase2TeamAuth: []
+ - AuthProviderBearerAuth: []
+ AuthProviderTeamAuth: []
parameters:
- $ref: "#/components/parameters/teamID"
requestBody:
@@ -840,6 +1322,8 @@ paths:
security:
- Supabase1TokenAuth: []
Supabase2TeamAuth: []
+ - AuthProviderBearerAuth: []
+ AuthProviderTeamAuth: []
parameters:
- $ref: "#/components/parameters/teamID"
responses:
@@ -861,6 +1345,8 @@ paths:
security:
- Supabase1TokenAuth: []
Supabase2TeamAuth: []
+ - AuthProviderBearerAuth: []
+ AuthProviderTeamAuth: []
parameters:
- $ref: "#/components/parameters/teamID"
requestBody:
@@ -890,6 +1376,8 @@ paths:
security:
- Supabase1TokenAuth: []
Supabase2TeamAuth: []
+ - AuthProviderBearerAuth: []
+ AuthProviderTeamAuth: []
parameters:
- $ref: "#/components/parameters/teamID"
- $ref: "#/components/parameters/userId"
@@ -912,6 +1400,7 @@ paths:
tags: [templates]
security:
- Supabase1TokenAuth: []
+ - AuthProviderBearerAuth: []
responses:
"200":
description: Successfully returned default templates.
@@ -923,3 +1412,158 @@ paths:
$ref: "#/components/responses/401"
"500":
$ref: "#/components/responses/500"
+
+ /templates/{templateID}:
+ get:
+ summary: Get template
+ description: Returns a single template owned by the current team. Dashboard-shaped read, indexed by template ID.
+ tags: [templates]
+ security:
+ - Supabase1TokenAuth: []
+ Supabase2TeamAuth: []
+ - AuthProviderBearerAuth: []
+ AuthProviderTeamAuth: []
+ parameters:
+ - $ref: "#/components/parameters/templateID"
+ responses:
+ "200":
+ description: Successfully returned the template.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/TemplateDetail"
+ "401":
+ $ref: "#/components/responses/401"
+ "403":
+ $ref: "#/components/responses/403"
+ "404":
+ $ref: "#/components/responses/404"
+ "500":
+ $ref: "#/components/responses/500"
+
+ /templates/{templateID}/tags/groups:
+ get:
+ summary: List template tag groups
+ description: Returns ready template tag assignment groups with bounded per-tag history, paginated over tags with keyset cursor.
+ tags: [templates]
+ security:
+ - Supabase1TokenAuth: []
+ Supabase2TeamAuth: []
+ - AuthProviderBearerAuth: []
+ AuthProviderTeamAuth: []
+ parameters:
+ - $ref: "#/components/parameters/templateID"
+ - $ref: "#/components/parameters/tag_assignment_limit"
+ - $ref: "#/components/parameters/tag_groups_limit"
+ - $ref: "#/components/parameters/tag_groups_cursor"
+ - $ref: "#/components/parameters/tag_groups_search"
+ - $ref: "#/components/parameters/tag_groups_sort"
+ responses:
+ "200":
+ description: Successfully returned template tag groups.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/TemplateTagGroupsResponse"
+ "400":
+ $ref: "#/components/responses/400"
+ "401":
+ $ref: "#/components/responses/401"
+ "403":
+ $ref: "#/components/responses/403"
+ "404":
+ $ref: "#/components/responses/404"
+ "500":
+ $ref: "#/components/responses/500"
+
+ /templates/{templateID}/tags/count:
+ get:
+ summary: Count template tags
+ description: Returns the total number of distinct ready tags for the template.
+ tags: [templates]
+ security:
+ - Supabase1TokenAuth: []
+ Supabase2TeamAuth: []
+ - AuthProviderBearerAuth: []
+ AuthProviderTeamAuth: []
+ parameters:
+ - $ref: "#/components/parameters/templateID"
+ responses:
+ "200":
+ description: Successfully returned tag count.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/TemplateTagsCountResponse"
+ "401":
+ $ref: "#/components/responses/401"
+ "403":
+ $ref: "#/components/responses/403"
+ "404":
+ $ref: "#/components/responses/404"
+ "500":
+ $ref: "#/components/responses/500"
+
+ /templates/{templateID}/tags/exists:
+ get:
+ summary: Check ready template tag existence
+ description: Checks whether a template tag has at least one ready assignment.
+ tags: [templates]
+ security:
+ - Supabase1TokenAuth: []
+ Supabase2TeamAuth: []
+ - AuthProviderBearerAuth: []
+ AuthProviderTeamAuth: []
+ parameters:
+ - $ref: "#/components/parameters/templateID"
+ - $ref: "#/components/parameters/tag"
+ responses:
+ "200":
+ description: Successfully checked template tag existence.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/TemplateTagExistsResponse"
+ "400":
+ $ref: "#/components/responses/400"
+ "401":
+ $ref: "#/components/responses/401"
+ "403":
+ $ref: "#/components/responses/403"
+ "404":
+ $ref: "#/components/responses/404"
+ "500":
+ $ref: "#/components/responses/500"
+
+ /templates/{templateID}/tags/{tag}/assignments:
+ get:
+ summary: List ready assignments for a single template tag
+ description: Returns ready tag assignment events for a single tag, ordered newest first, with keyset cursor pagination.
+ tags: [templates]
+ security:
+ - Supabase1TokenAuth: []
+ Supabase2TeamAuth: []
+ - AuthProviderBearerAuth: []
+ AuthProviderTeamAuth: []
+ parameters:
+ - $ref: "#/components/parameters/templateID"
+ - $ref: "#/components/parameters/tag_path"
+ - $ref: "#/components/parameters/tag_assignments_cursor"
+ - $ref: "#/components/parameters/tag_assignments_limit"
+ responses:
+ "200":
+ description: Successfully returned tag assignment page.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/TemplateTagAssignmentsResponse"
+ "400":
+ $ref: "#/components/responses/400"
+ "401":
+ $ref: "#/components/responses/401"
+ "403":
+ $ref: "#/components/responses/403"
+ "404":
+ $ref: "#/components/responses/404"
+ "500":
+ $ref: "#/components/responses/500"
diff --git a/src/app/dashboard/[teamSlug]/templates/(tabs)/builds/page.tsx b/src/app/dashboard/[teamSlug]/templates/(tabs)/builds/page.tsx
index 971ead502..362545aaf 100644
--- a/src/app/dashboard/[teamSlug]/templates/(tabs)/builds/page.tsx
+++ b/src/app/dashboard/[teamSlug]/templates/(tabs)/builds/page.tsx
@@ -1,11 +1,16 @@
-import BuildsHeader from '@/features/dashboard/templates/builds/header'
+'use client'
+
+import { AllBuildsHeader } from '@/features/dashboard/templates/builds/all-builds-header'
import BuildsTable from '@/features/dashboard/templates/builds/table'
+import useFilters from '@/features/dashboard/templates/builds/use-filters'
export default function TemplateBuildsPage() {
+ const { statuses, buildIdOrTemplate } = useFilters()
+
return (
)
}
diff --git a/src/app/dashboard/[teamSlug]/templates/[templateId]/(detail-tabs)/builds/page.tsx b/src/app/dashboard/[teamSlug]/templates/[templateId]/(detail-tabs)/builds/page.tsx
new file mode 100644
index 000000000..827348479
--- /dev/null
+++ b/src/app/dashboard/[teamSlug]/templates/[templateId]/(detail-tabs)/builds/page.tsx
@@ -0,0 +1,40 @@
+'use client'
+
+import { use, useCallback } from 'react'
+import type { ListedBuildModel } from '@/core/modules/builds/models'
+import BuildsTable from '@/features/dashboard/templates/builds/table'
+import { TemplateBuildsHeader } from '@/features/dashboard/templates/builds/template-builds-header'
+import useTemplateBuildsFilters from '@/features/dashboard/templates/builds/use-template-builds-filters'
+import { isValidUuid } from '@/features/dashboard/templates/tags/helpers'
+
+export default function TemplateDetailBuildsPage({
+ params,
+}: PageProps<'/dashboard/[teamSlug]/templates/[templateId]'>) {
+ const { templateId } = use(params)
+ const { statuses, q } = useTemplateBuildsFilters()
+
+ const trimmed = q?.trim() ?? ''
+ const isSearching = trimmed.length > 0
+ const isValidSearch = !isSearching || isValidUuid(trimmed)
+
+ const postFilter = useCallback(
+ (build: ListedBuildModel) => build.templateId === templateId,
+ [templateId]
+ )
+
+ return (
+
+
+
+
+ )
+}
diff --git a/src/app/dashboard/[teamSlug]/templates/[templateId]/(detail-tabs)/layout.tsx b/src/app/dashboard/[teamSlug]/templates/[templateId]/(detail-tabs)/layout.tsx
new file mode 100644
index 000000000..00c6ea84c
--- /dev/null
+++ b/src/app/dashboard/[teamSlug]/templates/[templateId]/(detail-tabs)/layout.tsx
@@ -0,0 +1,25 @@
+import { Suspense } from 'react'
+import TemplateDetailTabs from '@/features/dashboard/templates/detail/tabs'
+import TemplateTitleBinder from '@/features/dashboard/templates/detail/title-binder'
+import { HydrateClient, prefetch, trpc } from '@/trpc/server'
+
+export default async function TemplateDetailLayout({
+ children,
+ params,
+}: LayoutProps<'/dashboard/[teamSlug]/templates/[templateId]'>) {
+ const { teamSlug, templateId } = await params
+
+ prefetch(trpc.templates.getTemplate.queryOptions({ teamSlug, templateId }))
+
+ return (
+
+
+
+
+
+
+ {children}
+
+
+ )
+}
diff --git a/src/app/dashboard/[teamSlug]/templates/[templateId]/(detail-tabs)/loading.tsx b/src/app/dashboard/[teamSlug]/templates/[templateId]/(detail-tabs)/loading.tsx
new file mode 100644
index 000000000..2b9dc7302
--- /dev/null
+++ b/src/app/dashboard/[teamSlug]/templates/[templateId]/(detail-tabs)/loading.tsx
@@ -0,0 +1,5 @@
+import LoadingLayout from '@/features/dashboard/loading-layout'
+
+export default function TemplateDetailLoading() {
+ return
+}
diff --git a/src/app/dashboard/[teamSlug]/templates/[templateId]/(detail-tabs)/overview/page.tsx b/src/app/dashboard/[teamSlug]/templates/[templateId]/(detail-tabs)/overview/page.tsx
new file mode 100644
index 000000000..65ed5fd48
--- /dev/null
+++ b/src/app/dashboard/[teamSlug]/templates/[templateId]/(detail-tabs)/overview/page.tsx
@@ -0,0 +1,22 @@
+import { Suspense } from 'react'
+import TemplateOverview from '@/features/dashboard/templates/detail/overview'
+import { TemplateOverviewSkeleton } from '@/features/dashboard/templates/detail/overview/skeleton'
+import { HydrateClient, prefetch, trpc } from '@/trpc/server'
+
+export default async function TemplateOverviewPage({
+ params,
+}: PageProps<'/dashboard/[teamSlug]/templates/[templateId]'>) {
+ const { teamSlug, templateId } = await params
+
+ prefetch(trpc.templates.getTemplate.queryOptions({ teamSlug, templateId }))
+
+ return (
+
+
+ }>
+
+
+
+
+ )
+}
diff --git a/src/app/dashboard/[teamSlug]/templates/[templateId]/(detail-tabs)/tags/[tag]/page.tsx b/src/app/dashboard/[teamSlug]/templates/[templateId]/(detail-tabs)/tags/[tag]/page.tsx
new file mode 100644
index 000000000..88399092f
--- /dev/null
+++ b/src/app/dashboard/[teamSlug]/templates/[templateId]/(detail-tabs)/tags/[tag]/page.tsx
@@ -0,0 +1,35 @@
+import { Suspense } from 'react'
+import LoadingLayout from '@/features/dashboard/loading-layout'
+import { TAG_HISTORY_PAGE_LIMIT } from '@/features/dashboard/templates/tags/constants'
+import TagHistoryView from '@/features/dashboard/templates/tags/history/tag-history-view'
+import { HydrateClient, prefetch, trpc } from '@/trpc/server'
+
+export default async function TemplateTagHistoryPage({
+ params,
+}: PageProps<'/dashboard/[teamSlug]/templates/[templateId]/tags/[tag]'>) {
+ const { teamSlug, templateId, tag } = await params
+ const decodedTag = decodeURIComponent(tag)
+
+ prefetch(
+ trpc.templates.getTagAssignments.infiniteQueryOptions({
+ teamSlug,
+ templateId,
+ tag: decodedTag,
+ limit: TAG_HISTORY_PAGE_LIMIT,
+ })
+ )
+
+ return (
+
+
+ }>
+
+
+
+
+ )
+}
diff --git a/src/app/dashboard/[teamSlug]/templates/[templateId]/(detail-tabs)/tags/page.tsx b/src/app/dashboard/[teamSlug]/templates/[templateId]/(detail-tabs)/tags/page.tsx
new file mode 100644
index 000000000..2ada92047
--- /dev/null
+++ b/src/app/dashboard/[teamSlug]/templates/[templateId]/(detail-tabs)/tags/page.tsx
@@ -0,0 +1,32 @@
+import { Suspense } from 'react'
+import LoadingLayout from '@/features/dashboard/loading-layout'
+import { TAGS_PAGE_LIMIT } from '@/features/dashboard/templates/tags/constants'
+import TagsTable from '@/features/dashboard/templates/tags/table'
+import { HydrateClient, prefetch, trpc } from '@/trpc/server'
+
+export default async function TemplateTagsPage({
+ params,
+}: PageProps<'/dashboard/[teamSlug]/templates/[templateId]'>) {
+ const { teamSlug, templateId } = await params
+
+ prefetch(
+ trpc.templates.getTagGroups.infiniteQueryOptions({
+ teamSlug,
+ templateId,
+ limit: TAGS_PAGE_LIMIT,
+ search: undefined,
+ sort: undefined,
+ })
+ )
+ prefetch(trpc.templates.getTagCount.queryOptions({ teamSlug, templateId }))
+
+ return (
+
+
+ }>
+
+
+
+
+ )
+}
diff --git a/src/app/dashboard/[teamSlug]/templates/[templateId]/error.tsx b/src/app/dashboard/[teamSlug]/templates/[templateId]/error.tsx
index de47bcc1e..0dc66d8aa 100644
--- a/src/app/dashboard/[teamSlug]/templates/[templateId]/error.tsx
+++ b/src/app/dashboard/[teamSlug]/templates/[templateId]/error.tsx
@@ -1,5 +1,7 @@
'use client'
+import { TRPCClientError } from '@trpc/client'
+import { notFound } from 'next/navigation'
import { DashboardRouteError } from '@/features/dashboard/shared/route-error'
export default function TemplateDetailsError({
@@ -9,5 +11,9 @@ export default function TemplateDetailsError({
error: Error & { digest?: string }
reset: () => void
}) {
+ if (error instanceof TRPCClientError && error.data?.code === 'NOT_FOUND') {
+ notFound()
+ }
+
return
}
diff --git a/src/app/dashboard/[teamSlug]/templates/[templateId]/not-found.tsx b/src/app/dashboard/[teamSlug]/templates/[templateId]/not-found.tsx
new file mode 100644
index 000000000..85e73d054
--- /dev/null
+++ b/src/app/dashboard/[teamSlug]/templates/[templateId]/not-found.tsx
@@ -0,0 +1,37 @@
+'use client'
+
+import { Button } from '@/ui/primitives/button'
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardFooter,
+ CardHeader,
+} from '@/ui/primitives/card'
+import { ArrowLeftIcon } from '@/ui/primitives/icons'
+
+export default function TemplateNotFound() {
+ return (
+
+
+
+ 404
+ Template not found
+
+
+ We couldn’t find this template in your team.
+
+
+
+
+
+
+ )
+}
diff --git a/src/app/dashboard/[teamSlug]/templates/[templateId]/page.tsx b/src/app/dashboard/[teamSlug]/templates/[templateId]/page.tsx
new file mode 100644
index 000000000..8cf4ea965
--- /dev/null
+++ b/src/app/dashboard/[teamSlug]/templates/[templateId]/page.tsx
@@ -0,0 +1,10 @@
+import { redirect } from 'next/navigation'
+import { PROTECTED_URLS } from '@/configs/urls'
+
+export default async function TemplateDetailPage({
+ params,
+}: PageProps<'/dashboard/[teamSlug]/templates/[templateId]'>) {
+ const { teamSlug, templateId } = await params
+
+ redirect(PROTECTED_URLS.TEMPLATE_OVERVIEW(teamSlug, templateId))
+}
diff --git a/src/configs/layout.ts b/src/configs/layout.ts
index 0f29ab80c..f8087009c 100644
--- a/src/configs/layout.ts
+++ b/src/configs/layout.ts
@@ -89,6 +89,15 @@ const DASHBOARD_LAYOUT_CONFIGS: Record<
},
}
},
+ '/dashboard/*/templates/*/overview': (pathname) =>
+ templateDetailLayoutConfig(pathname),
+ '/dashboard/*/templates/*/tags': (pathname) =>
+ templateDetailLayoutConfig(pathname),
+ '/dashboard/*/templates/*/tags/*': (pathname) =>
+ templateDetailLayoutConfig(pathname),
+ // Keep this more specific glob ahead of /templates/*/builds/* (build detail).
+ '/dashboard/*/templates/*/builds': (pathname) =>
+ templateDetailLayoutConfig(pathname),
// integrations
'/dashboard/*/webhooks': () => ({
@@ -163,6 +172,29 @@ const DASHBOARD_LAYOUT_CONFIGS: Record<
}),
}
+// 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]!
+ const templateId = parts[4]!
+ const templateIdSliced =
+ templateId.length > 14
+ ? `${templateId.slice(0, 6)}...${templateId.slice(-6)}`
+ : templateId
+
+ return {
+ title: [
+ {
+ label: 'Templates',
+ href: PROTECTED_URLS.TEMPLATES_LIST(teamSlug),
+ },
+ { label: templateIdSliced },
+ ],
+ type: 'custom',
+ copyValue: templateId,
+ }
+}
+
/**
* Returns the layout config for a given dashboard pathname.
* @param pathname - The current route pathname
diff --git a/src/configs/mock-data.ts b/src/configs/mock-data.ts
index bd8d40b57..4d886d6b3 100644
--- a/src/configs/mock-data.ts
+++ b/src/configs/mock-data.ts
@@ -20,10 +20,6 @@ const DEFAULT_TEMPLATES: DefaultTemplate[] = [
templateID: 'code-interpreter-v1',
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
- createdBy: {
- email: 'admin@example.com',
- id: 'user_001',
- },
isDefault: true,
defaultDescription: 'Code Interpreter',
lastSpawnedAt: '2024-01-01T00:00:00Z',
@@ -42,7 +38,6 @@ const DEFAULT_TEMPLATES: DefaultTemplate[] = [
templateID: 'web-starter-v1',
createdAt: '2024-01-05T00:00:00Z',
updatedAt: '2024-01-05T00:00:00Z',
- createdBy: null,
isDefault: true,
defaultDescription: 'Web Development Environment',
lastSpawnedAt: '2024-01-05T00:00:00Z',
@@ -61,10 +56,6 @@ const DEFAULT_TEMPLATES: DefaultTemplate[] = [
templateID: 'data-science-v1',
createdAt: '2024-01-06T00:00:00Z',
updatedAt: '2024-01-06T00:00:00Z',
- createdBy: {
- email: 'datascience@example.com',
- id: 'user_002',
- },
isDefault: true,
defaultDescription: 'Data Science Environment with ML Libraries',
lastSpawnedAt: '2024-01-06T00:00:00Z',
@@ -86,10 +77,6 @@ const TEMPLATES: Template[] = [
templateID: 'node-typescript-v1',
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
- createdBy: {
- email: 'admin@example.com',
- id: 'user_001',
- },
lastSpawnedAt: '2024-01-01T00:00:00Z',
spawnCount: 10,
buildCount: 1,
@@ -106,7 +93,6 @@ const TEMPLATES: Template[] = [
templateID: 'react-vite-v2',
createdAt: '2024-01-02T00:00:00Z',
updatedAt: '2024-01-02T00:00:00Z',
- createdBy: null,
lastSpawnedAt: '2024-01-02T00:00:00Z',
spawnCount: 10,
buildCount: 1,
@@ -123,7 +109,6 @@ const TEMPLATES: Template[] = [
templateID: 'postgres-v15',
createdAt: '2024-01-03T00:00:00Z',
updatedAt: '2024-01-03T00:00:00Z',
- createdBy: null,
lastSpawnedAt: '2024-01-03T00:00:00Z',
spawnCount: 10,
buildCount: 1,
@@ -140,7 +125,6 @@ const TEMPLATES: Template[] = [
templateID: 'redis-v7',
createdAt: '2024-01-04T00:00:00Z',
updatedAt: '2024-01-04T00:00:00Z',
- createdBy: null,
lastSpawnedAt: '2024-01-04T00:00:00Z',
spawnCount: 10,
buildCount: 1,
@@ -157,7 +141,6 @@ const TEMPLATES: Template[] = [
templateID: 'python-ml-v1',
createdAt: '2024-01-05T00:00:00Z',
updatedAt: '2024-01-05T00:00:00Z',
- createdBy: null,
lastSpawnedAt: '2024-01-05T00:00:00Z',
spawnCount: 10,
buildCount: 1,
@@ -174,7 +157,6 @@ const TEMPLATES: Template[] = [
templateID: 'elastic-v8',
createdAt: '2024-01-06T00:00:00Z',
updatedAt: '2024-01-06T00:00:00Z',
- createdBy: null,
lastSpawnedAt: '2024-01-06T00:00:00Z',
spawnCount: 10,
buildCount: 1,
@@ -191,7 +173,6 @@ const TEMPLATES: Template[] = [
templateID: 'grafana-v9',
createdAt: '2024-01-07T00:00:00Z',
updatedAt: '2024-01-07T00:00:00Z',
- createdBy: null,
lastSpawnedAt: '2024-01-07T00:00:00Z',
spawnCount: 10,
buildCount: 1,
@@ -208,7 +189,6 @@ const TEMPLATES: Template[] = [
templateID: 'nginx-v1',
createdAt: '2024-01-08T00:00:00Z',
updatedAt: '2024-01-08T00:00:00Z',
- createdBy: null,
lastSpawnedAt: '2024-01-08T00:00:00Z',
spawnCount: 10,
buildCount: 1,
@@ -225,7 +205,6 @@ const TEMPLATES: Template[] = [
templateID: 'mongodb-v6',
createdAt: '2024-01-09T00:00:00Z',
updatedAt: '2024-01-09T00:00:00Z',
- createdBy: null,
lastSpawnedAt: '2024-01-09T00:00:00Z',
spawnCount: 10,
buildCount: 1,
@@ -242,7 +221,6 @@ const TEMPLATES: Template[] = [
templateID: 'mysql-v8',
createdAt: '2024-01-10T00:00:00Z',
updatedAt: '2024-01-10T00:00:00Z',
- createdBy: null,
lastSpawnedAt: '2024-01-10T00:00:00Z',
spawnCount: 10,
buildCount: 1,
@@ -259,10 +237,6 @@ const TEMPLATES: Template[] = [
templateID: 'nextjs-v14',
createdAt: '2024-01-11T00:00:00Z',
updatedAt: '2024-01-11T00:00:00Z',
- createdBy: {
- email: 'frontend@example.com',
- id: 'user_003',
- },
lastSpawnedAt: '2024-01-11T00:00:00Z',
spawnCount: 15,
buildCount: 1,
@@ -279,7 +253,6 @@ const TEMPLATES: Template[] = [
templateID: 'vue-v3',
createdAt: '2024-01-12T00:00:00Z',
updatedAt: '2024-01-12T00:00:00Z',
- createdBy: null,
lastSpawnedAt: '2024-01-12T00:00:00Z',
spawnCount: 8,
buildCount: 1,
@@ -296,10 +269,6 @@ const TEMPLATES: Template[] = [
templateID: 'django-v4',
createdAt: '2024-01-13T00:00:00Z',
updatedAt: '2024-01-13T00:00:00Z',
- createdBy: {
- email: 'python@example.com',
- id: 'user_004',
- },
lastSpawnedAt: '2024-01-13T00:00:00Z',
spawnCount: 12,
buildCount: 1,
@@ -316,7 +285,6 @@ const TEMPLATES: Template[] = [
templateID: 'flask-v2',
createdAt: '2024-01-14T00:00:00Z',
updatedAt: '2024-01-14T00:00:00Z',
- createdBy: null,
lastSpawnedAt: '2024-01-14T00:00:00Z',
spawnCount: 6,
buildCount: 1,
@@ -333,10 +301,6 @@ const TEMPLATES: Template[] = [
templateID: 'golang-v1.21',
createdAt: '2024-01-15T00:00:00Z',
updatedAt: '2024-01-15T00:00:00Z',
- createdBy: {
- email: 'go@example.com',
- id: 'user_005',
- },
lastSpawnedAt: '2024-01-15T00:00:00Z',
spawnCount: 14,
buildCount: 1,
@@ -353,7 +317,6 @@ const TEMPLATES: Template[] = [
templateID: 'rust-v1.75',
createdAt: '2024-01-16T00:00:00Z',
updatedAt: '2024-01-16T00:00:00Z',
- createdBy: null,
lastSpawnedAt: '2024-01-16T00:00:00Z',
spawnCount: 7,
buildCount: 1,
@@ -370,10 +333,6 @@ const TEMPLATES: Template[] = [
templateID: 'java-spring-v3',
createdAt: '2024-01-17T00:00:00Z',
updatedAt: '2024-01-17T00:00:00Z',
- createdBy: {
- email: 'java@example.com',
- id: 'user_006',
- },
lastSpawnedAt: '2024-01-17T00:00:00Z',
spawnCount: 11,
buildCount: 1,
@@ -390,7 +349,6 @@ const TEMPLATES: Template[] = [
templateID: 'dotnet-v8',
createdAt: '2024-01-18T00:00:00Z',
updatedAt: '2024-01-18T00:00:00Z',
- createdBy: null,
lastSpawnedAt: '2024-01-18T00:00:00Z',
spawnCount: 9,
buildCount: 1,
@@ -407,10 +365,6 @@ const TEMPLATES: Template[] = [
templateID: 'php-laravel-v10',
createdAt: '2024-01-19T00:00:00Z',
updatedAt: '2024-01-19T00:00:00Z',
- createdBy: {
- email: 'php@example.com',
- id: 'user_007',
- },
lastSpawnedAt: '2024-01-19T00:00:00Z',
spawnCount: 5,
buildCount: 1,
@@ -427,7 +381,6 @@ const TEMPLATES: Template[] = [
templateID: 'ruby-rails-v7',
createdAt: '2024-01-20T00:00:00Z',
updatedAt: '2024-01-20T00:00:00Z',
- createdBy: null,
lastSpawnedAt: '2024-01-20T00:00:00Z',
spawnCount: 4,
buildCount: 1,
@@ -444,10 +397,6 @@ const TEMPLATES: Template[] = [
templateID: 'jupyter-v6',
createdAt: '2024-01-21T00:00:00Z',
updatedAt: '2024-01-21T00:00:00Z',
- createdBy: {
- email: 'datascience@example.com',
- id: 'user_008',
- },
lastSpawnedAt: '2024-01-21T00:00:00Z',
spawnCount: 13,
buildCount: 1,
@@ -464,10 +413,6 @@ const TEMPLATES: Template[] = [
templateID: 'tensorflow-v2.15',
createdAt: '2024-01-22T00:00:00Z',
updatedAt: '2024-01-22T00:00:00Z',
- createdBy: {
- email: 'ml@example.com',
- id: 'user_009',
- },
lastSpawnedAt: '2024-01-22T00:00:00Z',
spawnCount: 18,
buildCount: 1,
@@ -484,10 +429,6 @@ const TEMPLATES: Template[] = [
templateID: 'pytorch-v2.1',
createdAt: '2024-01-23T00:00:00Z',
updatedAt: '2024-01-23T00:00:00Z',
- createdBy: {
- email: 'ml@example.com',
- id: 'user_009',
- },
lastSpawnedAt: '2024-01-23T00:00:00Z',
spawnCount: 16,
buildCount: 1,
@@ -504,7 +445,6 @@ const TEMPLATES: Template[] = [
templateID: 'cassandra-v4',
createdAt: '2024-01-24T00:00:00Z',
updatedAt: '2024-01-24T00:00:00Z',
- createdBy: null,
lastSpawnedAt: '2024-01-24T00:00:00Z',
spawnCount: 3,
buildCount: 1,
@@ -521,10 +461,6 @@ const TEMPLATES: Template[] = [
templateID: 'docker-v24',
createdAt: '2024-01-25T00:00:00Z',
updatedAt: '2024-01-25T00:00:00Z',
- createdBy: {
- email: 'devops@example.com',
- id: 'user_010',
- },
lastSpawnedAt: '2024-01-25T00:00:00Z',
spawnCount: 20,
buildCount: 1,
@@ -541,10 +477,6 @@ const TEMPLATES: Template[] = [
templateID: 'kubernetes-v1.28',
createdAt: '2024-01-26T00:00:00Z',
updatedAt: '2024-01-26T00:00:00Z',
- createdBy: {
- email: 'devops@example.com',
- id: 'user_010',
- },
lastSpawnedAt: '2024-01-26T00:00:00Z',
spawnCount: 8,
buildCount: 1,
@@ -561,7 +493,6 @@ const TEMPLATES: Template[] = [
templateID: 'terraform-v1.6',
createdAt: '2024-01-27T00:00:00Z',
updatedAt: '2024-01-27T00:00:00Z',
- createdBy: null,
lastSpawnedAt: '2024-01-27T00:00:00Z',
spawnCount: 6,
buildCount: 1,
@@ -577,10 +508,6 @@ const TEMPLATES: Template[] = [
templateID: 'ansible-v2.16',
createdAt: '2024-01-28T00:00:00Z',
updatedAt: '2024-01-28T00:00:00Z',
- createdBy: {
- email: 'devops@example.com',
- id: 'user_010',
- },
lastSpawnedAt: '2024-01-28T00:00:00Z',
envdVersion: '0.1.0',
spawnCount: 4,
@@ -598,7 +525,6 @@ const TEMPLATES: Template[] = [
envdVersion: '0.1.0',
createdAt: '2024-01-29T00:00:00Z',
updatedAt: '2024-01-29T00:00:00Z',
- createdBy: null,
lastSpawnedAt: '2024-01-29T00:00:00Z',
spawnCount: 7,
buildCount: 1,
@@ -615,10 +541,6 @@ const TEMPLATES: Template[] = [
templateID: 'jenkins-v2.426',
createdAt: '2024-01-30T00:00:00Z',
updatedAt: '2024-01-30T00:00:00Z',
- createdBy: {
- email: 'ci@example.com',
- id: 'user_011',
- },
lastSpawnedAt: '2024-01-30T00:00:00Z',
spawnCount: 12,
buildCount: 1,
@@ -635,7 +557,6 @@ const TEMPLATES: Template[] = [
templateID: 'gitlab-ci-v16',
createdAt: '2024-01-31T00:00:00Z',
updatedAt: '2024-01-31T00:00:00Z',
- createdBy: null,
lastSpawnedAt: '2024-01-31T00:00:00Z',
spawnCount: 9,
buildCount: 1,
@@ -652,10 +573,6 @@ const TEMPLATES: Template[] = [
templateID: 'apache-spark-v3.5',
createdAt: '2024-02-01T00:00:00Z',
updatedAt: '2024-02-01T00:00:00Z',
- createdBy: {
- email: 'bigdata@example.com',
- id: 'user_012',
- },
lastSpawnedAt: '2024-02-01T00:00:00Z',
spawnCount: 5,
buildCount: 1,
@@ -672,7 +589,6 @@ const TEMPLATES: Template[] = [
templateID: 'kafka-v3.6',
createdAt: '2024-02-02T00:00:00Z',
updatedAt: '2024-02-02T00:00:00Z',
- createdBy: null,
lastSpawnedAt: '2024-02-02T00:00:00Z',
spawnCount: 8,
buildCount: 1,
@@ -689,10 +605,6 @@ const TEMPLATES: Template[] = [
templateID: 'rabbitmq-v3.12',
createdAt: '2024-02-03T00:00:00Z',
updatedAt: '2024-02-03T00:00:00Z',
- createdBy: {
- email: 'messaging@example.com',
- id: 'user_013',
- },
lastSpawnedAt: '2024-02-03T00:00:00Z',
spawnCount: 6,
buildCount: 1,
@@ -709,7 +621,6 @@ const TEMPLATES: Template[] = [
templateID: 'zookeeper-v3.9',
createdAt: '2024-02-04T00:00:00Z',
updatedAt: '2024-02-04T00:00:00Z',
- createdBy: null,
lastSpawnedAt: '2024-02-04T00:00:00Z',
spawnCount: 4,
buildCount: 1,
@@ -726,10 +637,6 @@ const TEMPLATES: Template[] = [
templateID: 'solr-v9.4',
createdAt: '2024-02-05T00:00:00Z',
updatedAt: '2024-02-05T00:00:00Z',
- createdBy: {
- email: 'search@example.com',
- id: 'user_014',
- },
lastSpawnedAt: '2024-02-05T00:00:00Z',
spawnCount: 3,
buildCount: 1,
@@ -746,7 +653,6 @@ const TEMPLATES: Template[] = [
createdAt: '2024-02-06T00:00:00Z',
envdVersion: '0.1.0',
updatedAt: '2024-02-06T00:00:00Z',
- createdBy: null,
lastSpawnedAt: '2024-02-06T00:00:00Z',
spawnCount: 5,
buildCount: 1,
@@ -762,7 +668,6 @@ const TEMPLATES: Template[] = [
templateID: 'kibana-v8.11',
createdAt: '2024-02-07T00:00:00Z',
updatedAt: '2024-02-07T00:00:00Z',
- createdBy: null,
lastSpawnedAt: '2024-02-07T00:00:00Z',
spawnCount: 7,
buildCount: 1,
@@ -780,10 +685,6 @@ const TEMPLATES: Template[] = [
createdAt: '2024-02-08T00:00:00Z',
envdVersion: '0.1.0',
updatedAt: '2024-02-08T00:00:00Z',
- createdBy: {
- email: 'storage@example.com',
- id: 'user_015',
- },
lastSpawnedAt: '2024-02-08T00:00:00Z',
spawnCount: 6,
buildCount: 1,
@@ -800,10 +701,6 @@ const TEMPLATES: Template[] = [
templateID: 'vault-v1.15',
createdAt: '2024-02-09T00:00:00Z',
updatedAt: '2024-02-09T00:00:00Z',
- createdBy: {
- email: 'security@example.com',
- id: 'user_016',
- },
lastSpawnedAt: '2024-02-09T00:00:00Z',
spawnCount: 4,
buildCount: 1,
@@ -836,9 +733,10 @@ function generateMockSandboxes(count: number): Sandboxes {
const startDate = subHours(baseDate, Math.floor(Math.random() * 30))
const endDate = addHours(startDate, 24)
- // Random memory and CPU from template's allowed values
- const memory = template.memoryMB
- const cpu = template.cpuCount
+ // Random memory and CPU from template's allowed values; mock templates
+ // always carry real specs, so default the (now-nullable) fields here.
+ const memory = template.memoryMB ?? 2048
+ const cpu = template.cpuCount ?? 2
sandboxes.push({
alias: `${env}-${component}-${nanoid(4)}`,
@@ -846,8 +744,8 @@ function generateMockSandboxes(count: number): Sandboxes {
cpuCount: cpu,
endAt: endDate.toISOString(),
memoryMB: memory,
- diskSizeMB: template.diskSizeMB,
- envdVersion: template.envdVersion,
+ diskSizeMB: template.diskSizeMB ?? 1024,
+ envdVersion: template.envdVersion ?? '0.1.0',
metadata: {
lastUpdate: new Date(
startDate.getTime() + 2 * 60 * 60 * 1000
diff --git a/src/configs/urls.ts b/src/configs/urls.ts
index 9412b2a97..da6de8a26 100644
--- a/src/configs/urls.ts
+++ b/src/configs/urls.ts
@@ -44,6 +44,14 @@ export const PROTECTED_URLS = {
TEMPLATES_LIST: (teamSlug: string) => `/dashboard/${teamSlug}/templates/list`,
TEMPLATES_BUILDS: (teamSlug: string) =>
`/dashboard/${teamSlug}/templates/builds`,
+ TEMPLATE_OVERVIEW: (teamSlug: string, templateId: string) =>
+ `/dashboard/${teamSlug}/templates/${templateId}/overview`,
+ TEMPLATE_DETAIL_BUILDS: (teamSlug: string, templateId: string) =>
+ `/dashboard/${teamSlug}/templates/${templateId}/builds`,
+ TEMPLATE_TAGS: (teamSlug: string, templateId: string) =>
+ `/dashboard/${teamSlug}/templates/${templateId}/tags`,
+ TEMPLATE_TAG_HISTORY: (teamSlug: string, templateId: string, tag: string) =>
+ `/dashboard/${teamSlug}/templates/${templateId}/tags/${encodeURIComponent(tag)}`,
TEMPLATE_BUILD: (teamSlug: string, templateId: string, buildId: string) =>
`/dashboard/${teamSlug}/templates/${templateId}/builds/${buildId}`,
diff --git a/src/core/modules/templates/models.ts b/src/core/modules/templates/models.ts
index aa08bebab..085adb116 100644
--- a/src/core/modules/templates/models.ts
+++ b/src/core/modules/templates/models.ts
@@ -1,3 +1,4 @@
+import type { components as DashboardComponents } from '@/contracts/dashboard-api'
import type { components as InfraComponents } from '@/contracts/infra-api'
export type Template = Pick<
@@ -12,7 +13,24 @@ export type Template = Pick<
| 'names'
| 'createdAt'
| 'updatedAt'
- | 'createdBy'
+ | 'lastSpawnedAt'
+ | 'spawnCount'
+ | 'buildCount'
+ | 'envdVersion'
+>
+
+export type TemplateDetail = Pick<
+ DashboardComponents['schemas']['TemplateDetail'],
+ | 'templateID'
+ | 'buildID'
+ | 'cpuCount'
+ | 'memoryMB'
+ | 'diskSizeMB'
+ | 'public'
+ | 'aliases'
+ | 'names'
+ | 'createdAt'
+ | 'updatedAt'
| 'lastSpawnedAt'
| 'spawnCount'
| 'buildCount'
@@ -23,3 +41,12 @@ export type DefaultTemplate = Template & {
isDefault: true
defaultDescription?: string
}
+
+export type TemplateTag = InfraComponents['schemas']['TemplateTag']
+
+export type TemplateTagAssignment =
+ DashboardComponents['schemas']['TemplateTagAssignment']
+export type TemplateTagGroup =
+ DashboardComponents['schemas']['TemplateTagGroup']
+export type TemplateTagExistsResult =
+ DashboardComponents['schemas']['TemplateTagExistsResponse']
diff --git a/src/core/modules/templates/repository.server.ts b/src/core/modules/templates/repository.server.ts
index 9d282af3e..7f50815af 100644
--- a/src/core/modules/templates/repository.server.ts
+++ b/src/core/modules/templates/repository.server.ts
@@ -7,7 +7,14 @@ import {
MOCK_DEFAULT_TEMPLATES_DATA,
MOCK_TEMPLATES_DATA,
} from '@/configs/mock-data'
-import type { DefaultTemplate, Template } from '@/core/modules/templates/models'
+import type {
+ DefaultTemplate,
+ Template,
+ TemplateDetail,
+ TemplateTagAssignment,
+ TemplateTagExistsResult,
+ TemplateTagGroup,
+} from '@/core/modules/templates/models'
import {
type AuthUserEmailResolver,
getAuthUserEmailsById,
@@ -30,7 +37,46 @@ type TemplatesRepositoryDeps = {
export interface TeamTemplatesRepository {
getTeamTemplates(): Promise>
+ getTemplate(
+ templateId: string
+ ): Promise>
+ getTagGroups(
+ templateId: string,
+ options?: {
+ assignmentLimit?: number
+ tagsLimit?: number
+ tagsCursor?: string
+ search?: string
+ sort?: 'latest_desc' | 'latest_asc' | 'name_asc' | 'name_desc'
+ }
+ ): Promise<
+ RepoResult<{ tags: TemplateTagGroup[]; nextCursor: string | null }>
+ >
+ getTagCount(templateId: string): Promise>
+ getTagAssignments(
+ templateId: string,
+ tag: string,
+ options?: { cursor?: string; limit?: number }
+ ): Promise<
+ RepoResult<{
+ data: TemplateTagAssignment[]
+ nextCursor: string | null
+ }>
+ >
+ checkTagExists(
+ templateId: string,
+ tag: string
+ ): Promise>
+ assignTag(input: {
+ templateName: string
+ buildId: string
+ tag: string
+ }): Promise>
deleteTemplate(templateId: string): Promise>
+ deleteTags(
+ templateName: string,
+ tags: string[]
+ ): Promise>
updateTemplateVisibility(
templateId: string,
isPublic: boolean
@@ -53,6 +99,197 @@ export function createTemplatesRepository(
}
): TeamTemplatesRepository {
return {
+ async getTemplate(templateId) {
+ if (USE_MOCK_DATA) {
+ const template = MOCK_TEMPLATES_DATA.find(
+ (t) => t.templateID === templateId
+ )
+ if (!template) {
+ return err(
+ repoErrorFromHttp(404, 'Template not found in this team', undefined)
+ )
+ }
+
+ return ok({ template })
+ }
+
+ const res = await deps.apiClient.GET('/templates/{templateID}', {
+ params: {
+ path: {
+ templateID: templateId,
+ },
+ },
+ headers: {
+ ...deps.authHeaders(scope.accessToken, scope.teamId),
+ },
+ })
+
+ if (!res.response.ok || res.error) {
+ return err(
+ repoErrorFromHttp(
+ res.response.status,
+ res.error?.message ?? 'Failed to fetch template',
+ res.error
+ )
+ )
+ }
+
+ if (!res.data) {
+ return err(
+ repoErrorFromHttp(404, 'Template not found in this team', undefined)
+ )
+ }
+
+ return ok({ template: res.data })
+ },
+ async getTagGroups(templateId, options) {
+ if (USE_MOCK_DATA) {
+ return ok({ tags: [], nextCursor: null })
+ }
+
+ const res = await deps.apiClient.GET(
+ '/templates/{templateID}/tags/groups',
+ {
+ params: {
+ path: {
+ templateID: templateId,
+ },
+ query: {
+ assignmentLimit: options?.assignmentLimit,
+ tagsLimit: options?.tagsLimit,
+ tagsCursor: options?.tagsCursor,
+ search: options?.search,
+ sort: options?.sort,
+ },
+ },
+ headers: {
+ ...deps.authHeaders(scope.accessToken, scope.teamId),
+ },
+ }
+ )
+
+ if (!res.response.ok || res.error) {
+ return err(
+ repoErrorFromHttp(
+ res.response.status,
+ res.error?.message ?? 'Failed to fetch template tag groups',
+ res.error
+ )
+ )
+ }
+
+ return ok({
+ tags: res.data?.tags ?? [],
+ nextCursor: res.data?.nextCursor ?? null,
+ })
+ },
+ async getTagCount(templateId) {
+ if (USE_MOCK_DATA) {
+ return ok({ total: 0 })
+ }
+
+ const res = await deps.apiClient.GET(
+ '/templates/{templateID}/tags/count',
+ {
+ params: {
+ path: {
+ templateID: templateId,
+ },
+ },
+ headers: {
+ ...deps.authHeaders(scope.accessToken, scope.teamId),
+ },
+ }
+ )
+
+ if (!res.response.ok || res.error) {
+ return err(
+ repoErrorFromHttp(
+ res.response.status,
+ res.error?.message ?? 'Failed to fetch template tag count',
+ res.error
+ )
+ )
+ }
+
+ return ok({ total: res.data?.total ?? 0 })
+ },
+ async getTagAssignments(templateId, tag, options) {
+ if (USE_MOCK_DATA) {
+ return ok({ data: [], nextCursor: null })
+ }
+
+ const res = await deps.apiClient.GET(
+ '/templates/{templateID}/tags/{tag}/assignments',
+ {
+ params: {
+ path: {
+ templateID: templateId,
+ tag,
+ },
+ query: {
+ cursor: options?.cursor,
+ limit: options?.limit,
+ },
+ },
+ headers: {
+ ...deps.authHeaders(scope.accessToken, scope.teamId),
+ },
+ }
+ )
+
+ if (!res.response.ok || res.error) {
+ return err(
+ repoErrorFromHttp(
+ res.response.status,
+ res.error?.message ?? 'Failed to fetch template tag assignments',
+ res.error
+ )
+ )
+ }
+
+ return ok({
+ data: res.data?.data ?? [],
+ nextCursor: res.data?.nextCursor ?? null,
+ })
+ },
+ async checkTagExists(templateId, tag) {
+ if (USE_MOCK_DATA) {
+ return ok({ exists: false, normalizedTag: tag })
+ }
+
+ const res = await deps.apiClient.GET(
+ '/templates/{templateID}/tags/exists',
+ {
+ params: {
+ path: {
+ templateID: templateId,
+ },
+ query: {
+ tag,
+ },
+ },
+ headers: {
+ ...deps.authHeaders(scope.accessToken, scope.teamId),
+ },
+ }
+ )
+
+ if (!res.response.ok || res.error) {
+ return err(
+ repoErrorFromHttp(
+ res.response.status,
+ res.error?.message ?? 'Failed to check template tag existence',
+ res.error
+ )
+ )
+ }
+
+ return ok({
+ exists: res.data?.exists ?? false,
+ normalizedTag: res.data?.normalizedTag ?? tag,
+ })
+ },
async getTeamTemplates() {
if (USE_MOCK_DATA) {
return ok({
@@ -87,6 +324,55 @@ export function createTemplatesRepository(
),
})
},
+ async assignTag({ templateName, buildId, tag }) {
+ const res = await deps.infraClient.POST('/templates/tags', {
+ body: {
+ target: `${templateName}:${buildId}`,
+ tags: [tag],
+ },
+ headers: {
+ ...deps.authHeaders(scope.accessToken, scope.teamId),
+ },
+ })
+
+ if (!res.response.ok || res.error) {
+ return err(
+ repoErrorFromHttp(
+ res.response.status,
+ res.error?.message ?? 'Failed to assign template tag',
+ res.error
+ )
+ )
+ }
+
+ return ok({
+ tags: res.data?.tags ?? [tag],
+ buildID: res.data?.buildID ?? buildId,
+ })
+ },
+ async deleteTags(templateName, tags) {
+ const res = await deps.infraClient.DELETE('/templates/tags', {
+ body: {
+ name: templateName,
+ tags,
+ },
+ headers: {
+ ...deps.authHeaders(scope.accessToken, scope.teamId),
+ },
+ })
+
+ if (!res.response.ok || res.error) {
+ return err(
+ repoErrorFromHttp(
+ res.response.status,
+ res.error?.message ?? 'Failed to delete template tags',
+ res.error
+ )
+ )
+ }
+
+ return ok({ success: true as const })
+ },
async deleteTemplate(templateId) {
const res = await deps.infraClient.DELETE('/templates/{templateID}', {
params: {
diff --git a/src/core/server/api/routers/templates.ts b/src/core/server/api/routers/templates.ts
index d063c251c..09cb262f1 100644
--- a/src/core/server/api/routers/templates.ts
+++ b/src/core/server/api/routers/templates.ts
@@ -42,6 +42,97 @@ export const templatesRouter = createTRPCRouter({
return result.data
}),
+ getTemplate: teamTemplatesRepositoryProcedure
+ .input(
+ z.object({
+ templateId: z.string(),
+ })
+ )
+ .query(async ({ ctx, input }) => {
+ const result = await ctx.templatesRepository.getTemplate(input.templateId)
+ if (!result.ok) throwTRPCErrorFromRepoError(result.error)
+ return result.data
+ }),
+
+ getTagGroups: teamTemplatesRepositoryProcedure
+ .input(
+ z.object({
+ templateId: z.string(),
+ assignmentLimit: z.number().int().min(1).max(25).optional(),
+ limit: z.number().int().min(1).max(100).optional(),
+ cursor: z.string().optional(),
+ search: z
+ .string()
+ .max(64)
+ .regex(/^[a-z0-9._-]*$/)
+ .optional(),
+ sort: z
+ .enum(['latest_desc', 'latest_asc', 'name_asc', 'name_desc'])
+ .optional(),
+ })
+ )
+ .query(async ({ ctx, input }) => {
+ const result = await ctx.templatesRepository.getTagGroups(
+ input.templateId,
+ {
+ assignmentLimit: input.assignmentLimit,
+ tagsLimit: input.limit,
+ tagsCursor: input.cursor,
+ search: input.search,
+ sort: input.sort,
+ }
+ )
+ if (!result.ok) throwTRPCErrorFromRepoError(result.error)
+ return result.data
+ }),
+
+ getTagCount: teamTemplatesRepositoryProcedure
+ .input(
+ z.object({
+ templateId: z.string(),
+ })
+ )
+ .query(async ({ ctx, input }) => {
+ const result = await ctx.templatesRepository.getTagCount(input.templateId)
+ if (!result.ok) throwTRPCErrorFromRepoError(result.error)
+ return result.data
+ }),
+
+ getTagAssignments: teamTemplatesRepositoryProcedure
+ .input(
+ z.object({
+ templateId: z.string(),
+ tag: z.string().min(1),
+ cursor: z.string().optional(),
+ limit: z.number().int().min(1).max(100).default(50),
+ })
+ )
+ .query(async ({ ctx, input }) => {
+ const result = await ctx.templatesRepository.getTagAssignments(
+ input.templateId,
+ input.tag,
+ { cursor: input.cursor, limit: input.limit }
+ )
+ if (!result.ok) throwTRPCErrorFromRepoError(result.error)
+ return result.data
+ }),
+
+ checkTagExists: teamTemplatesRepositoryProcedure
+ .input(
+ z.object({
+ templateId: z.string(),
+ tag: z.string().min(1),
+ })
+ )
+ .query(async ({ ctx, input }) => {
+ const result = await ctx.templatesRepository.checkTagExists(
+ input.templateId,
+ input.tag
+ )
+ if (!result.ok) throwTRPCErrorFromRepoError(result.error)
+ return result.data
+ }),
+
getDefaultTemplatesCached: templatesRepositoryProcedure.query(
async ({ ctx }) => {
const result = await ctx.templatesRepository.getDefaultTemplatesCached()
@@ -100,4 +191,44 @@ export const templatesRouter = createTRPCRouter({
return result.data
}),
+
+ deleteTags: teamTemplatesRepositoryProcedure
+ .input(
+ z.object({
+ templateId: z.string(),
+ templateName: z.string(),
+ tags: z.array(z.string()).min(1),
+ })
+ )
+ .mutation(async ({ ctx, input }) => {
+ const result = await ctx.templatesRepository.deleteTags(
+ input.templateName,
+ input.tags
+ )
+ if (!result.ok) throwTRPCErrorFromRepoError(result.error)
+ return result.data
+ }),
+
+ assignTag: teamTemplatesRepositoryProcedure
+ .input(
+ z.object({
+ templateId: z.string(),
+ templateName: z.string(),
+ buildId: z.string().uuid(),
+ tag: z
+ .string()
+ .min(1)
+ .max(128)
+ .regex(/^[a-z0-9._-]+$/),
+ })
+ )
+ .mutation(async ({ ctx, input }) => {
+ const result = await ctx.templatesRepository.assignTag({
+ templateName: input.templateName,
+ buildId: input.buildId,
+ tag: input.tag,
+ })
+ if (!result.ok) throwTRPCErrorFromRepoError(result.error)
+ return result.data
+ }),
})
diff --git a/src/core/shared/contracts/dashboard-api.types.ts b/src/core/shared/contracts/dashboard-api.types.ts
index 6cf043667..eadf97843 100644
--- a/src/core/shared/contracts/dashboard-api.types.ts
+++ b/src/core/shared/contracts/dashboard-api.types.ts
@@ -327,6 +327,224 @@ export interface paths {
patch?: never
trace?: never
}
+ '/admin/users/bootstrap': {
+ parameters: {
+ query?: never
+ header?: never
+ path?: never
+ cookie?: never
+ }
+ get?: never
+ put?: never
+ /** Bootstrap auth provider user */
+ post: {
+ parameters: {
+ query?: never
+ header?: never
+ path?: never
+ cookie?: never
+ }
+ requestBody: {
+ content: {
+ 'application/json': components['schemas']['AdminAuthProviderUserBootstrapRequest']
+ }
+ }
+ responses: {
+ /** @description Successfully bootstrapped user. */
+ 200: {
+ headers: {
+ [name: string]: unknown
+ }
+ content: {
+ 'application/json': components['schemas']['TeamResolveResponse']
+ }
+ }
+ 400: components['responses']['400']
+ 401: components['responses']['401']
+ 500: components['responses']['500']
+ }
+ }
+ delete?: never
+ options?: never
+ head?: never
+ patch?: never
+ trace?: never
+ }
+ '/admin/teams/bootstrap': {
+ parameters: {
+ query?: never
+ header?: never
+ path?: never
+ cookie?: never
+ }
+ get?: never
+ put?: never
+ /**
+ * Bootstrap team
+ * @description Creates and provisions a team for an admin-authenticated bootstrap workflow.
+ */
+ post: {
+ parameters: {
+ query?: never
+ header?: never
+ path?: never
+ cookie?: never
+ }
+ requestBody: {
+ content: {
+ 'application/json': components['schemas']['AdminTeamBootstrapRequest']
+ }
+ }
+ responses: {
+ /** @description Successfully bootstrapped team. */
+ 200: {
+ headers: {
+ [name: string]: unknown
+ }
+ content: {
+ 'application/json': components['schemas']['TeamResolveResponse']
+ }
+ }
+ 400: components['responses']['400']
+ 401: components['responses']['401']
+ 500: components['responses']['500']
+ 502: components['responses']['502']
+ }
+ }
+ delete?: never
+ options?: never
+ head?: never
+ patch?: never
+ trace?: never
+ }
+ '/admin/user-profiles/resolve': {
+ parameters: {
+ query?: never
+ header?: never
+ path?: never
+ cookie?: never
+ }
+ get?: never
+ put?: never
+ /** Resolve user profiles */
+ post: {
+ parameters: {
+ query?: never
+ header?: never
+ path?: never
+ cookie?: never
+ }
+ requestBody: {
+ content: {
+ 'application/json': components['schemas']['AdminAuthProviderProfilesResolveRequest']
+ }
+ }
+ responses: {
+ /** @description Successfully resolved profiles. */
+ 200: {
+ headers: {
+ [name: string]: unknown
+ }
+ content: {
+ 'application/json': components['schemas']['AdminAuthProviderProfilesResponse']
+ }
+ }
+ 400: components['responses']['400']
+ 401: components['responses']['401']
+ 500: components['responses']['500']
+ }
+ }
+ delete?: never
+ options?: never
+ head?: never
+ patch?: never
+ trace?: never
+ }
+ '/admin/user-profiles/by-email': {
+ parameters: {
+ query?: never
+ header?: never
+ path?: never
+ cookie?: never
+ }
+ get?: never
+ put?: never
+ /** Lookup user profiles by email */
+ post: {
+ parameters: {
+ query?: never
+ header?: never
+ path?: never
+ cookie?: never
+ }
+ requestBody: {
+ content: {
+ 'application/json': components['schemas']['AdminAuthProviderProfilesLookupEmailRequest']
+ }
+ }
+ responses: {
+ /** @description Successfully found matching profiles. */
+ 200: {
+ headers: {
+ [name: string]: unknown
+ }
+ content: {
+ 'application/json': components['schemas']['AdminAuthProviderProfilesResponse']
+ }
+ }
+ 400: components['responses']['400']
+ 401: components['responses']['401']
+ 500: components['responses']['500']
+ }
+ }
+ delete?: never
+ options?: never
+ head?: never
+ patch?: never
+ trace?: never
+ }
+ '/admin/user-profiles/{userId}': {
+ parameters: {
+ query?: never
+ header?: never
+ path?: never
+ cookie?: never
+ }
+ /** Get user profile */
+ get: {
+ parameters: {
+ query?: never
+ header?: never
+ path: {
+ /** @description Identifier of the user. */
+ userId: components['parameters']['userId']
+ }
+ cookie?: never
+ }
+ requestBody?: never
+ responses: {
+ /** @description Successfully found profile. */
+ 200: {
+ headers: {
+ [name: string]: unknown
+ }
+ content: {
+ 'application/json': components['schemas']['AdminAuthProviderProfilesResponse']
+ }
+ }
+ 400: components['responses']['400']
+ 401: components['responses']['401']
+ 500: components['responses']['500']
+ }
+ }
+ put?: never
+ post?: never
+ delete?: never
+ options?: never
+ head?: never
+ patch?: never
+ trace?: never
+ }
'/teams/resolve': {
parameters: {
query?: never
@@ -576,6 +794,260 @@ export interface paths {
patch?: never
trace?: never
}
+ '/templates/{templateID}': {
+ parameters: {
+ query?: never
+ header?: never
+ path?: never
+ cookie?: never
+ }
+ /**
+ * Get template
+ * @description Returns a single template owned by the current team. Dashboard-shaped read, indexed by template ID.
+ */
+ get: {
+ parameters: {
+ query?: never
+ header?: never
+ path: {
+ /** @description Identifier of the template. */
+ templateID: components['parameters']['templateID']
+ }
+ cookie?: never
+ }
+ requestBody?: never
+ responses: {
+ /** @description Successfully returned the template. */
+ 200: {
+ headers: {
+ [name: string]: unknown
+ }
+ content: {
+ 'application/json': components['schemas']['TemplateDetail']
+ }
+ }
+ 401: components['responses']['401']
+ 403: components['responses']['403']
+ 404: components['responses']['404']
+ 500: components['responses']['500']
+ }
+ }
+ put?: never
+ post?: never
+ delete?: never
+ options?: never
+ head?: never
+ patch?: never
+ trace?: never
+ }
+ '/templates/{templateID}/tags/groups': {
+ parameters: {
+ query?: never
+ header?: never
+ path?: never
+ cookie?: never
+ }
+ /**
+ * List template tag groups
+ * @description Returns ready template tag assignment groups with bounded per-tag history, paginated over tags with keyset cursor.
+ */
+ get: {
+ parameters: {
+ query?: {
+ /** @description Maximum number of ready assignment rows to return per tag. */
+ assignmentLimit?: components['parameters']['tag_assignment_limit']
+ /** @description Maximum number of distinct tags to return per page. */
+ tagsLimit?: components['parameters']['tag_groups_limit']
+ /** @description Cursor returned by the previous list response in `{sort}|{latest_assigned_at}|{tag}` format (sort-tagged, RFC3339Nano). */
+ tagsCursor?: components['parameters']['tag_groups_cursor']
+ /** @description Case-insensitive substring filter on tag name. Allowed characters are `a-z`, `0-9`, `.`, `_`, `-`. */
+ search?: components['parameters']['tag_groups_search']
+ /** @description Sort order for the returned tag groups. */
+ sort?: components['parameters']['tag_groups_sort']
+ }
+ header?: never
+ path: {
+ /** @description Identifier of the template. */
+ templateID: components['parameters']['templateID']
+ }
+ cookie?: never
+ }
+ requestBody?: never
+ responses: {
+ /** @description Successfully returned template tag groups. */
+ 200: {
+ headers: {
+ [name: string]: unknown
+ }
+ content: {
+ 'application/json': components['schemas']['TemplateTagGroupsResponse']
+ }
+ }
+ 400: components['responses']['400']
+ 401: components['responses']['401']
+ 403: components['responses']['403']
+ 404: components['responses']['404']
+ 500: components['responses']['500']
+ }
+ }
+ put?: never
+ post?: never
+ delete?: never
+ options?: never
+ head?: never
+ patch?: never
+ trace?: never
+ }
+ '/templates/{templateID}/tags/count': {
+ parameters: {
+ query?: never
+ header?: never
+ path?: never
+ cookie?: never
+ }
+ /**
+ * Count template tags
+ * @description Returns the total number of distinct ready tags for the template.
+ */
+ get: {
+ parameters: {
+ query?: never
+ header?: never
+ path: {
+ /** @description Identifier of the template. */
+ templateID: components['parameters']['templateID']
+ }
+ cookie?: never
+ }
+ requestBody?: never
+ responses: {
+ /** @description Successfully returned tag count. */
+ 200: {
+ headers: {
+ [name: string]: unknown
+ }
+ content: {
+ 'application/json': components['schemas']['TemplateTagsCountResponse']
+ }
+ }
+ 401: components['responses']['401']
+ 403: components['responses']['403']
+ 404: components['responses']['404']
+ 500: components['responses']['500']
+ }
+ }
+ put?: never
+ post?: never
+ delete?: never
+ options?: never
+ head?: never
+ patch?: never
+ trace?: never
+ }
+ '/templates/{templateID}/tags/exists': {
+ parameters: {
+ query?: never
+ header?: never
+ path?: never
+ cookie?: never
+ }
+ /**
+ * Check ready template tag existence
+ * @description Checks whether a template tag has at least one ready assignment.
+ */
+ get: {
+ parameters: {
+ query: {
+ /** @description Template tag name to check. */
+ tag: components['parameters']['tag']
+ }
+ header?: never
+ path: {
+ /** @description Identifier of the template. */
+ templateID: components['parameters']['templateID']
+ }
+ cookie?: never
+ }
+ requestBody?: never
+ responses: {
+ /** @description Successfully checked template tag existence. */
+ 200: {
+ headers: {
+ [name: string]: unknown
+ }
+ content: {
+ 'application/json': components['schemas']['TemplateTagExistsResponse']
+ }
+ }
+ 400: components['responses']['400']
+ 401: components['responses']['401']
+ 403: components['responses']['403']
+ 404: components['responses']['404']
+ 500: components['responses']['500']
+ }
+ }
+ put?: never
+ post?: never
+ delete?: never
+ options?: never
+ head?: never
+ patch?: never
+ trace?: never
+ }
+ '/templates/{templateID}/tags/{tag}/assignments': {
+ parameters: {
+ query?: never
+ header?: never
+ path?: never
+ cookie?: never
+ }
+ /**
+ * List ready assignments for a single template tag
+ * @description Returns ready tag assignment events for a single tag, ordered newest first, with keyset cursor pagination.
+ */
+ get: {
+ parameters: {
+ query?: {
+ /** @description Cursor returned by the previous list response in `assigned_at|assignment_id` format. */
+ cursor?: components['parameters']['tag_assignments_cursor']
+ /** @description Maximum number of assignment rows to return per page. */
+ limit?: components['parameters']['tag_assignments_limit']
+ }
+ header?: never
+ path: {
+ /** @description Identifier of the template. */
+ templateID: components['parameters']['templateID']
+ /** @description Template tag name. */
+ tag: components['parameters']['tag_path']
+ }
+ cookie?: never
+ }
+ requestBody?: never
+ responses: {
+ /** @description Successfully returned tag assignment page. */
+ 200: {
+ headers: {
+ [name: string]: unknown
+ }
+ content: {
+ 'application/json': components['schemas']['TemplateTagAssignmentsResponse']
+ }
+ }
+ 400: components['responses']['400']
+ 401: components['responses']['401']
+ 403: components['responses']['403']
+ 404: components['responses']['404']
+ 500: components['responses']['500']
+ }
+ }
+ put?: never
+ post?: never
+ delete?: never
+ options?: never
+ head?: never
+ patch?: never
+ trace?: never
+ }
}
export type webhooks = Record
export interface components {
@@ -589,6 +1061,41 @@ export interface components {
/** @description Error message. */
message: string
}
+ AdminAuthProviderProfile: {
+ /**
+ * Format: uuid
+ * @description Internal E2B user identifier.
+ */
+ userId: string
+ /** @description Email address from the configured auth provider. */
+ email: string | null
+ }
+ AdminAuthProviderProfilesResponse: {
+ profiles: components['schemas']['AdminAuthProviderProfile'][]
+ }
+ AdminAuthProviderProfilesResolveRequest: {
+ userIds: string[]
+ }
+ AdminAuthProviderProfilesLookupEmailRequest: {
+ /** Format: email */
+ email: string
+ }
+ AdminAuthProviderUserBootstrapRequest: {
+ oidc_issuer: string
+ oidc_user_id: string
+ /** Format: email */
+ oidc_user_email: string
+ oidc_user_name?: string | null
+ }
+ AdminTeamBootstrapRequest: {
+ /** @description Team name. */
+ name: string
+ /**
+ * Format: email
+ * @description Billing/contact email for the team.
+ */
+ email: string
+ }
/**
* @description Build status mapped for dashboard clients.
* @enum {string}
@@ -738,6 +1245,10 @@ export interface components {
/** Format: uuid */
id: string
email: string
+ name?: string | null
+ /** Format: uri */
+ profilePictureUrl?: string | null
+ providers: string[]
isDefault: boolean
/** Format: uuid */
addedBy?: string | null
@@ -791,6 +1302,114 @@ export interface components {
DefaultTemplatesResponse: {
templates: components['schemas']['DefaultTemplate'][]
}
+ /** @description Dashboard-shaped single-template read. Mirrors the infra-api `Template` schema fields the dashboard renders. */
+ TemplateDetail: {
+ /** @description Identifier of the template. */
+ templateID: string
+ /** @description Identifier of the latest ready build for the template, or the zero UUID when none. */
+ buildID: string
+ /** @description vCPU count of the latest ready default build, or `null` when none. */
+ cpuCount: components['schemas']['CPUCount'] | null
+ /** @description Memory in MiB of the latest ready default build, or `null` when none. */
+ memoryMB: components['schemas']['MemoryMB'] | null
+ /** @description Disk size in MiB of the latest ready default build, or `null` when none. */
+ diskSizeMB: components['schemas']['DiskSizeMB'] | null
+ /** @description Whether the template is public or only accessible by the team. */
+ public: boolean
+ /**
+ * @deprecated
+ * @description Aliases of the template.
+ */
+ aliases: string[]
+ /** @description Names of the template (namespace/alias format when namespaced). */
+ names: string[]
+ /**
+ * Format: date-time
+ * @description Time when the template was created.
+ */
+ createdAt: string
+ /**
+ * Format: date-time
+ * @description Time when the template was last updated.
+ */
+ updatedAt: string
+ /**
+ * Format: date-time
+ * @description Time when the template was last used.
+ */
+ lastSpawnedAt: string | null
+ /**
+ * Format: int64
+ * @description Number of times the template was used.
+ */
+ spawnCount: number
+ /**
+ * Format: int32
+ * @description Number of times the template was built.
+ */
+ buildCount: number
+ /** @description envd version of the latest ready default build, or `null` when none. */
+ envdVersion: string | null
+ }
+ TemplateTagAssignment: {
+ /**
+ * Format: uuid
+ * @description Identifier of the tag assignment event.
+ */
+ assignmentId: string
+ /**
+ * Format: uuid
+ * @description Identifier of the assigned build.
+ */
+ buildId: string
+ /**
+ * Format: date-time
+ * @description Time when the tag was assigned to the build.
+ */
+ assignedAt: string
+ /**
+ * Format: date-time
+ * @description Time when the assigned build was created.
+ */
+ buildCreatedAt: string
+ /**
+ * Format: date-time
+ * @description Time when the assigned build finished.
+ */
+ buildFinishedAt: string | null
+ }
+ TemplateTagGroup: {
+ /** @description Template tag name. */
+ tag: string
+ /** @description Ready assignment events for this tag, sorted latest first. */
+ assignments: components['schemas']['TemplateTagAssignment'][]
+ /** @description Whether more ready assignment events exist beyond the requested assignment limit. */
+ hasMore: boolean
+ }
+ TemplateTagGroupsResponse: {
+ tags: components['schemas']['TemplateTagGroup'][]
+ /** @description Cursor to pass as `tagsCursor` for the next page, or `null` if there is no next page. */
+ nextCursor: string | null
+ }
+ TemplateTagsCountResponse: {
+ /**
+ * Format: int64
+ * @description Total distinct ready tags for the template.
+ */
+ total: number
+ }
+ TemplateTagExistsResponse: {
+ /** @description Whether the template tag has at least one ready assignment. */
+ exists: boolean
+ /** @description Normalized template tag name. */
+ normalizedTag: string
+ }
+ TemplateTagAssignmentsResponse: {
+ /** @description Ready assignment events for the tag, sorted latest first. */
+ data: components['schemas']['TemplateTagAssignment'][]
+ /** @description Cursor to pass to the next list request, or `null` if there is no next page. */
+ nextCursor: string | null
+ }
TeamResolveResponse: {
/** Format: uuid */
id: string
@@ -843,6 +1462,15 @@ export interface components {
'application/json': components['schemas']['Error']
}
}
+ /** @description Upstream error */
+ 502: {
+ headers: {
+ [name: string]: unknown
+ }
+ content: {
+ 'application/json': components['schemas']['Error']
+ }
+ }
}
parameters: {
/** @description Identifier of the build. */
@@ -865,6 +1493,26 @@ export interface components {
userId: string
/** @description Team slug to resolve. */
teamSlug: string
+ /** @description Identifier of the template. */
+ templateID: string
+ /** @description Template tag name to check. */
+ tag: string
+ /** @description Maximum number of ready assignment rows to return per tag. */
+ tag_assignment_limit: number
+ /** @description Template tag name. */
+ tag_path: string
+ /** @description Maximum number of assignment rows to return per page. */
+ tag_assignments_limit: number
+ /** @description Cursor returned by the previous list response in `assigned_at|assignment_id` format. */
+ tag_assignments_cursor: string
+ /** @description Maximum number of distinct tags to return per page. */
+ tag_groups_limit: number
+ /** @description Cursor returned by the previous list response in `{sort}|{latest_assigned_at}|{tag}` format (sort-tagged, RFC3339Nano). */
+ tag_groups_cursor: string
+ /** @description Case-insensitive substring filter on tag name. Allowed characters are `a-z`, `0-9`, `.`, `_`, `-`. */
+ tag_groups_search: string
+ /** @description Sort order for the returned tag groups. */
+ tag_groups_sort: 'latest_desc' | 'latest_asc' | 'name_asc' | 'name_desc'
}
requestBodies: never
headers: never
diff --git a/src/features/dashboard/build/header-cells.tsx b/src/features/dashboard/build/header-cells.tsx
index 423013c16..5e9418ac4 100644
--- a/src/features/dashboard/build/header-cells.tsx
+++ b/src/features/dashboard/build/header-cells.tsx
@@ -1,4 +1,4 @@
-import { useRouter } from 'next/navigation'
+import Link from 'next/link'
import { useEffect, useState } from 'react'
import { PROTECTED_URLS } from '@/configs/urls'
import { useRouteParams } from '@/lib/hooks/use-route-params'
@@ -10,7 +10,6 @@ import {
import { cn } from '@/lib/utils/ui'
import CopyButtonInline from '@/ui/copy-button-inline'
import { Button } from '@/ui/primitives/button'
-import { useTemplateTableStore } from '../templates/list/stores/table-store'
export function Template({
template,
@@ -21,23 +20,21 @@ export function Template({
templateId: string
className?: string
}) {
- const router = useRouter()
const { teamSlug } = useRouteParams<'/dashboard/[teamSlug]/templates'>()
return (
)
}
diff --git a/src/features/dashboard/layouts/header.tsx b/src/features/dashboard/layouts/header.tsx
index e1a780ee7..bf5996c6b 100644
--- a/src/features/dashboard/layouts/header.tsx
+++ b/src/features/dashboard/layouts/header.tsx
@@ -4,6 +4,7 @@ import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { Fragment } from 'react'
import { getDashboardLayoutConfig, type TitleSegment } from '@/configs/layout'
+import { usePageTitleStore } from '@/lib/hooks/use-page-title'
import { cn } from '@/lib/utils'
import ClientOnly from '@/ui/client-only'
import CopyButton from '@/ui/copy-button'
@@ -21,7 +22,12 @@ export default function DashboardLayoutHeader({
}: DashboardLayoutHeaderProps) {
const pathname = usePathname()
const config = getDashboardLayoutConfig(pathname)
- const copyableValue = config.copyValue ?? null
+ const override = usePageTitleStore((state) => state.override)
+
+ const title = override?.title ?? config.title
+ const copyableValue = override
+ ? (override.copyValue ?? null)
+ : (config.copyValue ?? null)
return (
-
+
{copyableValue && (
) {
const templateIdentifier = (getValue() as string | undefined) ?? '--'
const { team } = useDashboard()
- const router = useRouter()
const templateId = row.original.templateID
+ if (!templateId) {
+ return (
+
+ {templateIdentifier}
+
+ )
+ }
+
return (
-