diff --git a/bun.lock b/bun.lock
index 0bafefd0f..5b3de5f68 100644
--- a/bun.lock
+++ b/bun.lock
@@ -44,6 +44,7 @@
"@supabase/supabase-js": "^2.48.1",
"@tanstack/match-sorter-utils": "^8.19.4",
"@tanstack/react-query": "^5.90.7",
+ "@tanstack/react-query-devtools": "^5.91.1",
"@tanstack/react-table": "^8.20.6",
"@tanstack/react-virtual": "^3.13.12",
"@theguild/remark-mermaid": "^0.2.0",
@@ -940,8 +941,12 @@
"@tanstack/query-core": ["@tanstack/query-core@5.90.7", "", {}, "sha512-6PN65csiuTNfBMXqQUxQhCNdtm1rV+9kC9YwWAIKcaxAauq3Wu7p18j3gQY3YIBJU70jT/wzCCZ2uqto/vQgiQ=="],
+ "@tanstack/query-devtools": ["@tanstack/query-devtools@5.91.1", "", {}, "sha512-l8bxjk6BMsCaVQH6NzQEE/bEgFy1hAs5qbgXl0xhzezlaQbPk6Mgz9BqEg2vTLPOHD8N4k+w/gdgCbEzecGyNg=="],
+
"@tanstack/react-query": ["@tanstack/react-query@5.90.7", "", { "dependencies": { "@tanstack/query-core": "5.90.7" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-wAHc/cgKzW7LZNFloThyHnV/AX9gTg3w5yAv0gvQHPZoCnepwqCMtzbuPbb2UvfvO32XZ46e8bPOYbfZhzVnnQ=="],
+ "@tanstack/react-query-devtools": ["@tanstack/react-query-devtools@5.91.1", "", { "dependencies": { "@tanstack/query-devtools": "5.91.1" }, "peerDependencies": { "@tanstack/react-query": "^5.90.10", "react": "^18 || ^19" } }, "sha512-tRnJYwEbH0kAOuToy8Ew7bJw1lX3AjkkgSlf/vzb+NpnqmHPdWM+lA2DSdGQSLi1SU0PDRrrCI1vnZnci96CsQ=="],
+
"@tanstack/react-table": ["@tanstack/react-table@8.21.3", "", { "dependencies": { "@tanstack/table-core": "8.21.3" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww=="],
"@tanstack/react-virtual": ["@tanstack/react-virtual@3.13.12", "", { "dependencies": { "@tanstack/virtual-core": "3.13.12" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Gd13QdxPSukP8ZrkbgS2RwoZseTTbQPLnQEn7HY/rqtM+8Zt95f7xKC7N0EsKs7aoz0WzZ+fditZux+F8EzYxA=="],
diff --git a/next.config.mjs b/next.config.mjs
index a6b163d5a..ff014fd72 100644
--- a/next.config.mjs
+++ b/next.config.mjs
@@ -12,7 +12,6 @@ const config = {
bodySizeLimit: '5mb',
},
authInterrupts: true,
- clientSegmentCache: true,
},
turbopack: {
resolveAlias: {
diff --git a/package.json b/package.json
index ee5bedfb1..e579091b5 100644
--- a/package.json
+++ b/package.json
@@ -81,6 +81,7 @@
"@supabase/supabase-js": "^2.48.1",
"@tanstack/match-sorter-utils": "^8.19.4",
"@tanstack/react-query": "^5.90.7",
+ "@tanstack/react-query-devtools": "^5.91.1",
"@tanstack/react-table": "^8.20.6",
"@tanstack/react-virtual": "^3.13.12",
"@theguild/remark-mermaid": "^0.2.0",
diff --git a/spec/openapi.infra.yaml b/spec/openapi.infra.yaml
index cfddfc80e..8979f5ee5 100644
--- a/spec/openapi.infra.yaml
+++ b/spec/openapi.infra.yaml
@@ -75,6 +75,24 @@ components:
required: true
schema:
type: string
+ paginationLimit:
+ name: limit
+ in: query
+ description: Maximum number of items to return per page
+ required: false
+ schema:
+ type: integer
+ format: int32
+ minimum: 1
+ default: 100
+ maximum: 100
+ paginationNextToken:
+ name: nextToken
+ in: query
+ description: Cursor to start the list from
+ required: false
+ schema:
+ type: string
responses:
"400":
@@ -193,6 +211,33 @@ components:
type: string
description: Environment variables for the sandbox
+ Mcp:
+ type: object
+ description: MCP configuration for the sandbox
+ additionalProperties: {}
+ nullable: true
+
+ SandboxNetworkConfig:
+ type: object
+ properties:
+ allowPublicTraffic:
+ type: boolean
+ default: true
+ description: Specify if the sandbox URLs should be accessible only with authentication.
+ allowOut:
+ type: array
+ description: List of allowed CIDR blocks or IP addresses for egress traffic. Allowed addresses always take precedence over blocked addresses.
+ items:
+ type: string
+ denyOut:
+ type: array
+ description: List of denied CIDR blocks or IP addresses for egress traffic
+ items:
+ type: string
+ maskRequestHost:
+ type: string
+ description: Specify host mask which will be used for all sandbox requests
+
SandboxLog:
description: Log entry with timestamp and line
required:
@@ -315,6 +360,10 @@ components:
envdAccessToken:
type: string
description: Access token used for envd communication
+ trafficAccessToken:
+ type: string
+ nullable: true
+ description: Token required for accessing sandbox via proxy.
domain:
type: string
nullable: true
@@ -451,11 +500,17 @@ components:
description: Secure all system communication with sandbox
allow_internet_access:
type: boolean
- description: Allow sandbox to access the internet
+ description:
+ Allow sandbox to access the internet. When set to false, it behaves the same as specifying denyOut
+ to 0.0.0.0/0 in the network config.
+ network:
+ $ref: "#/components/schemas/SandboxNetworkConfig"
metadata:
$ref: "#/components/schemas/SandboxMetadata"
envVars:
$ref: "#/components/schemas/EnvVars"
+ mcp:
+ $ref: "#/components/schemas/Mcp"
ResumedSandbox:
properties:
@@ -470,6 +525,17 @@ components:
deprecated: true
description: Automatically pauses the sandbox after the timeout
+ ConnectSandbox:
+ type: object
+ required:
+ - timeout
+ properties:
+ timeout:
+ description: Timeout in seconds from the current time after which the sandbox should expire
+ type: integer
+ format: int32
+ minimum: 0
+
TeamMetric:
description: Team metric with timestamp
required:
@@ -516,7 +582,109 @@ components:
type: number
description: The maximum value of the requested metric in the given interval
+ AdminSandboxKillResult:
+ required:
+ - killedCount
+ - failedCount
+ properties:
+ killedCount:
+ type: integer
+ description: Number of sandboxes successfully killed
+ failedCount:
+ type: integer
+ description: Number of sandboxes that failed to kill
+
Template:
+ required:
+ - templateID
+ - buildID
+ - cpuCount
+ - memoryMB
+ - diskSizeMB
+ - public
+ - createdAt
+ - updatedAt
+ - createdBy
+ - lastSpawnedAt
+ - spawnCount
+ - buildCount
+ - envdVersion
+ - aliases
+ - buildStatus
+ properties:
+ templateID:
+ type: string
+ description: Identifier of the template
+ buildID:
+ type: string
+ description: Identifier of the last successful build for given template
+ cpuCount:
+ $ref: "#/components/schemas/CPUCount"
+ memoryMB:
+ $ref: "#/components/schemas/MemoryMB"
+ diskSizeMB:
+ $ref: "#/components/schemas/DiskSizeMB"
+ public:
+ type: boolean
+ description: Whether the template is public or only accessible by the team
+ aliases:
+ type: array
+ description: Aliases of the template
+ 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
+ createdBy:
+ allOf:
+ - $ref: "#/components/schemas/TeamUser"
+ nullable: true
+ lastSpawnedAt:
+ type: string
+ nullable: true
+ format: date-time
+ 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:
+ $ref: "#/components/schemas/EnvdVersion"
+ buildStatus:
+ $ref: "#/components/schemas/TemplateBuildStatus"
+
+ TemplateRequestResponseV3:
+ required:
+ - templateID
+ - buildID
+ - public
+ - aliases
+ properties:
+ templateID:
+ type: string
+ description: Identifier of the template
+ buildID:
+ type: string
+ description: Identifier of the last successful build for given template
+ public:
+ type: boolean
+ description: Whether the template is public or only accessible by the team
+ aliases:
+ type: array
+ description: Aliases of the template
+ items:
+ type: string
+
+ TemplateLegacy:
required:
- templateID
- buildID
@@ -581,6 +749,87 @@ components:
envdVersion:
$ref: "#/components/schemas/EnvdVersion"
+ TemplateBuild:
+ required:
+ - buildID
+ - status
+ - createdAt
+ - updatedAt
+ - cpuCount
+ - memoryMB
+ properties:
+ buildID:
+ type: string
+ format: uuid
+ description: Identifier of the build
+ status:
+ $ref: "#/components/schemas/TemplateBuildStatus"
+ createdAt:
+ type: string
+ format: date-time
+ description: Time when the build was created
+ updatedAt:
+ type: string
+ format: date-time
+ description: Time when the build was last updated
+ finishedAt:
+ type: string
+ format: date-time
+ description: Time when the build was finished
+ cpuCount:
+ $ref: "#/components/schemas/CPUCount"
+ memoryMB:
+ $ref: "#/components/schemas/MemoryMB"
+ diskSizeMB:
+ $ref: "#/components/schemas/DiskSizeMB"
+ envdVersion:
+ $ref: "#/components/schemas/EnvdVersion"
+
+ TemplateWithBuilds:
+ required:
+ - templateID
+ - public
+ - aliases
+ - createdAt
+ - updatedAt
+ - lastSpawnedAt
+ - spawnCount
+ - builds
+ properties:
+ templateID:
+ type: string
+ description: Identifier of the template
+ public:
+ type: boolean
+ description: Whether the template is public or only accessible by the team
+ aliases:
+ type: array
+ description: Aliases of the template
+ 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
+ nullable: true
+ format: date-time
+ description: Time when the template was last used
+ spawnCount:
+ type: integer
+ format: int64
+ description: Number of times the template was used
+ builds:
+ type: array
+ description: List of builds for the template
+ items:
+ $ref: "#/components/schemas/TemplateBuild"
+
TemplateBuildRequest:
required:
- dockerfile
@@ -627,6 +876,21 @@ components:
type: boolean
description: Whether the step should be forced to run regardless of the cache
+ TemplateBuildRequestV3:
+ required:
+ - alias
+ properties:
+ alias:
+ description: Alias of the template
+ type: string
+ teamID:
+ type: string
+ description: Identifier of the team
+ cpuCount:
+ $ref: "#/components/schemas/CPUCount"
+ memoryMB:
+ $ref: "#/components/schemas/MemoryMB"
+
TemplateBuildRequestV2:
required:
- alias
@@ -644,15 +908,15 @@ components:
FromImageRegistry:
oneOf:
- - $ref: '#/components/schemas/AWSRegistry'
- - $ref: '#/components/schemas/GCPRegistry'
- - $ref: '#/components/schemas/GeneralRegistry'
+ - $ref: "#/components/schemas/AWSRegistry"
+ - $ref: "#/components/schemas/GCPRegistry"
+ - $ref: "#/components/schemas/GeneralRegistry"
discriminator:
propertyName: type
mapping:
- aws: '#/components/schemas/AWSRegistry'
- gcp: '#/components/schemas/GCPRegistry'
- registry: '#/components/schemas/GeneralRegistry'
+ aws: "#/components/schemas/AWSRegistry"
+ gcp: "#/components/schemas/GCPRegistry"
+ registry: "#/components/schemas/GeneralRegistry"
AWSRegistry:
type: object
@@ -771,6 +1035,9 @@ components:
description: Log message content
level:
$ref: "#/components/schemas/LogLevel"
+ step:
+ type: string
+ description: Step in the build process related to the log entry
BuildStatusReason:
required:
@@ -782,8 +1049,23 @@ components:
step:
type: string
description: Step that failed
+ logEntries:
+ default: []
+ description: Log entries related to the status reason
+ type: array
+ items:
+ $ref: "#/components/schemas/BuildLogEntry"
- TemplateBuild:
+ TemplateBuildStatus:
+ type: string
+ description: Status of the template build
+ enum:
+ - building
+ - waiting
+ - ready
+ - error
+
+ TemplateBuildInfo:
required:
- templateID
- buildID
@@ -810,16 +1092,41 @@ components:
type: string
description: Identifier of the build
status:
- type: string
- description: Status of the template
- enum:
- - building
- - waiting
- - ready
- - error
+ $ref: "#/components/schemas/TemplateBuildStatus"
reason:
$ref: "#/components/schemas/BuildStatusReason"
+ TemplateBuildLogsResponse:
+ required:
+ - logs
+ properties:
+ logs:
+ default: []
+ description: Build logs structured
+ type: array
+ items:
+ $ref: "#/components/schemas/BuildLogEntry"
+
+ LogsDirection:
+ type: string
+ description: Direction of the logs that should be returned
+ enum:
+ - forward
+ - backward
+ x-enum-varnames:
+ - LogsDirectionForward
+ - LogsDirectionBackward
+
+ LogsSource:
+ type: string
+ description: Source of the logs that should be returned
+ enum:
+ - temporary
+ - persistent
+ x-enum-varnames:
+ - LogsSourceTemporary
+ - LogsSourcePersistent
+
NodeStatus:
type: string
description: Status of the node
@@ -911,6 +1218,25 @@ components:
description: Detailed metrics for each disk/mount point
items:
$ref: "#/components/schemas/DiskMetrics"
+ MachineInfo:
+ required:
+ - cpuFamily
+ - cpuModel
+ - cpuModelName
+ - cpuArchitecture
+ properties:
+ cpuFamily:
+ type: string
+ description: CPU family of the node
+ cpuModel:
+ type: string
+ description: CPU model of the node
+ cpuModelName:
+ type: string
+ description: CPU model name of the node
+ cpuArchitecture:
+ type: string
+ description: CPU architecture of the node
Node:
required:
@@ -926,6 +1252,7 @@ components:
- sandboxStartingCount
- version
- commit
+ - machineInfo
properties:
version:
type: string
@@ -941,11 +1268,13 @@ components:
type: string
description: Identifier of the node
serviceInstanceID:
- type: string
- description: Service instance identifier of the node
+ type: string
+ description: Service instance identifier of the node
clusterID:
type: string
description: Identifier of the cluster
+ machineInfo:
+ $ref: "#/components/schemas/MachineInfo"
status:
$ref: "#/components/schemas/NodeStatus"
sandboxCount:
@@ -981,6 +1310,7 @@ components:
- version
- commit
- metrics
+ - machineInfo
properties:
clusterID:
type: string
@@ -995,12 +1325,14 @@ components:
type: string
description: Identifier of the node
serviceInstanceID:
- type: string
- description: Service instance identifier of the node
+ type: string
+ description: Service instance identifier of the node
nodeID:
type: string
deprecated: true
description: Identifier of the nomad node
+ machineInfo:
+ $ref: "#/components/schemas/MachineInfo"
status:
$ref: "#/components/schemas/NodeStatus"
sandboxes:
@@ -1381,21 +1713,8 @@ paths:
$ref: "#/components/schemas/SandboxState"
style: form
explode: false
- - name: nextToken
- in: query
- description: Cursor to start the list from
- required: false
- schema:
- type: string
- - name: limit
- in: query
- description: Maximum number of items to return per page
- required: false
- schema:
- type: integer
- format: int32
- minimum: 1
- default: 100
+ - $ref: "#/components/parameters/paginationNextToken"
+ - $ref: "#/components/parameters/paginationLimit"
responses:
"200":
description: Successfully returned all running sandboxes
@@ -1597,6 +1916,7 @@ paths:
/sandboxes/{sandboxID}/resume:
post:
+ deprecated: true
description: Resume the sandbox
tags: [sandboxes]
security:
@@ -1627,6 +1947,44 @@ paths:
"500":
$ref: "#/components/responses/500"
+ /sandboxes/{sandboxID}/connect:
+ post:
+ description: Returns sandbox details. If the sandbox is paused, it will be resumed. TTL is only extended.
+ tags: [sandboxes]
+ security:
+ - ApiKeyAuth: []
+ - Supabase1TokenAuth: []
+ Supabase2TeamAuth: []
+ parameters:
+ - $ref: "#/components/parameters/sandboxID"
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ConnectSandbox"
+ responses:
+ "200":
+ description: The sandbox was already running
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Sandbox"
+ "201":
+ description: The sandbox was resumed successfully
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Sandbox"
+ "400":
+ $ref: "#/components/responses/400"
+ "401":
+ $ref: "#/components/responses/401"
+ "404":
+ $ref: "#/components/responses/404"
+ "500":
+ $ref: "#/components/responses/500"
+
/sandboxes/{sandboxID}/timeout:
post:
description: Set the timeout for the sandbox. The sandbox will expire x seconds from the time of the request. Calling this method multiple times overwrites the TTL, each time using the current timestamp as the starting point to measure the timeout duration.
@@ -1689,9 +2047,38 @@ paths:
"404":
$ref: "#/components/responses/404"
+ /v3/templates:
+ post:
+ description: Create a new template
+ tags: [templates]
+ security:
+ - ApiKeyAuth: []
+ - Supabase1TokenAuth: []
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/TemplateBuildRequestV3"
+
+ responses:
+ "202":
+ description: The build was requested successfully
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/TemplateRequestResponseV3"
+ "400":
+ $ref: "#/components/responses/400"
+ "401":
+ $ref: "#/components/responses/401"
+ "500":
+ $ref: "#/components/responses/500"
+
/v2/templates:
post:
description: Create a new template
+ deprecated: true
tags: [templates]
security:
- ApiKeyAuth: []
@@ -1709,7 +2096,7 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/Template"
+ $ref: "#/components/schemas/TemplateLegacy"
"400":
$ref: "#/components/responses/400"
"401":
@@ -1755,6 +2142,7 @@ paths:
description: List all templates
tags: [templates]
security:
+ - ApiKeyAuth: []
- AccessTokenAuth: []
- Supabase1TokenAuth: []
parameters:
@@ -1780,6 +2168,7 @@ paths:
$ref: "#/components/responses/500"
post:
description: Create a new template
+ deprecated: true
tags: [templates]
security:
- AccessTokenAuth: []
@@ -1797,7 +2186,7 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/Template"
+ $ref: "#/components/schemas/TemplateLegacy"
"400":
$ref: "#/components/responses/400"
"401":
@@ -1806,8 +2195,31 @@ paths:
$ref: "#/components/responses/500"
/templates/{templateID}:
+ get:
+ description: List all builds for a template
+ tags: [templates]
+ security:
+ - ApiKeyAuth: []
+ - Supabase1TokenAuth: []
+ Supabase2TeamAuth: []
+ parameters:
+ - $ref: "#/components/parameters/templateID"
+ - $ref: "#/components/parameters/paginationNextToken"
+ - $ref: "#/components/parameters/paginationLimit"
+ responses:
+ "200":
+ description: Successfully returned the template with its builds
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/TemplateWithBuilds"
+ "401":
+ $ref: "#/components/responses/401"
+ "500":
+ $ref: "#/components/responses/500"
post:
description: Rebuild an template
+ deprecated: true
tags: [templates]
security:
- AccessTokenAuth: []
@@ -1827,7 +2239,7 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/Template"
+ $ref: "#/components/schemas/TemplateLegacy"
"401":
$ref: "#/components/responses/401"
"500":
@@ -1852,6 +2264,7 @@ paths:
description: Update template
tags: [templates]
security:
+ - ApiKeyAuth: []
- AccessTokenAuth: []
- Supabase1TokenAuth: []
parameters:
@@ -1875,6 +2288,7 @@ paths:
/templates/{templateID}/builds/{buildID}:
post:
description: Start the build
+ deprecated: true
tags: [templates]
security:
- AccessTokenAuth: []
@@ -1933,6 +2347,15 @@ paths:
format: int32
minimum: 0
description: Index of the starting build log that should be returned with the template
+ - in: query
+ name: limit
+ schema:
+ default: 100
+ type: integer
+ format: int32
+ minimum: 0
+ maximum: 100
+ description: Maximum number of logs that should be returned
- in: query
name: level
schema:
@@ -1943,7 +2366,61 @@ paths:
content:
application/json:
schema:
- $ref: "#/components/schemas/TemplateBuild"
+ $ref: "#/components/schemas/TemplateBuildInfo"
+ "401":
+ $ref: "#/components/responses/401"
+ "404":
+ $ref: "#/components/responses/404"
+ "500":
+ $ref: "#/components/responses/500"
+
+ /templates/{templateID}/builds/{buildID}/logs:
+ get:
+ description: Get template build logs
+ tags: [templates]
+ security:
+ - AccessTokenAuth: []
+ - ApiKeyAuth: []
+ - Supabase1TokenAuth: []
+ parameters:
+ - $ref: "#/components/parameters/templateID"
+ - $ref: "#/components/parameters/buildID"
+ - in: query
+ name: cursor
+ schema:
+ type: integer
+ format: int64
+ minimum: 0
+ description: Starting timestamp of the logs that should be returned in milliseconds
+ - in: query
+ name: limit
+ schema:
+ default: 100
+ type: integer
+ format: int32
+ minimum: 0
+ maximum: 100
+ description: Maximum number of logs that should be returned
+ - in: query
+ name: direction
+ schema:
+ $ref: "#/components/schemas/LogsDirection"
+ - in: query
+ name: level
+ schema:
+ $ref: "#/components/schemas/LogLevel"
+ - in: query
+ name: source
+ schema:
+ $ref: "#/components/schemas/LogsSource"
+ description: Source of the logs that should be returned from
+ responses:
+ "200":
+ description: Successfully returned the template build logs
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/TemplateBuildLogsResponse"
"401":
$ref: "#/components/responses/401"
"404":
@@ -2022,6 +2499,35 @@ paths:
"500":
$ref: "#/components/responses/500"
+ /admin/teams/{teamID}/sandboxes/kill:
+ post:
+ summary: Kill all sandboxes for a team
+ description: Kills all sandboxes for the specified team
+ tags: [admin]
+ security:
+ - AdminTokenAuth: []
+ parameters:
+ - name: teamID
+ in: path
+ required: true
+ schema:
+ type: string
+ format: uuid
+ description: Team ID
+ responses:
+ "200":
+ description: Successfully killed sandboxes
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/AdminSandboxKillResult"
+ "401":
+ $ref: "#/components/responses/401"
+ "404":
+ $ref: "#/components/responses/404"
+ "500":
+ $ref: "#/components/responses/500"
+
/access-tokens:
post:
description: Create a new access token
@@ -2148,4 +2654,4 @@ paths:
"404":
$ref: "#/components/responses/404"
"500":
- $ref: "#/components/responses/500"
\ No newline at end of file
+ $ref: "#/components/responses/500"
diff --git a/src/app/dashboard/[teamIdOrSlug]/layout.tsx b/src/app/dashboard/[teamIdOrSlug]/layout.tsx
index 32d2da0d8..e6b8282b3 100644
--- a/src/app/dashboard/[teamIdOrSlug]/layout.tsx
+++ b/src/app/dashboard/[teamIdOrSlug]/layout.tsx
@@ -2,7 +2,7 @@ import { COOKIE_KEYS } from '@/configs/cookies'
import { METADATA } from '@/configs/metadata'
import { AUTH_URLS } from '@/configs/urls'
import { DashboardContextProvider } from '@/features/dashboard/context'
-import DashboardLayoutView from '@/features/dashboard/layout/layout'
+import DashboardLayoutView from '@/features/dashboard/layouts/layout'
import Sidebar from '@/features/dashboard/sidebar/sidebar'
import { l } from '@/lib/clients/logger/logger'
import { getSessionInsecure } from '@/server/auth/get-session'
diff --git a/src/app/dashboard/[teamIdOrSlug]/templates/[templateId]/builds/[buildId]/loading.tsx b/src/app/dashboard/[teamIdOrSlug]/templates/[templateId]/builds/[buildId]/loading.tsx
new file mode 100644
index 000000000..249f11404
--- /dev/null
+++ b/src/app/dashboard/[teamIdOrSlug]/templates/[templateId]/builds/[buildId]/loading.tsx
@@ -0,0 +1 @@
+export { default } from '@/features/dashboard/loading-layout'
diff --git a/src/app/dashboard/[teamIdOrSlug]/templates/[templateId]/builds/[buildId]/page.tsx b/src/app/dashboard/[teamIdOrSlug]/templates/[templateId]/builds/[buildId]/page.tsx
new file mode 100644
index 000000000..4e966adf6
--- /dev/null
+++ b/src/app/dashboard/[teamIdOrSlug]/templates/[templateId]/builds/[buildId]/page.tsx
@@ -0,0 +1,64 @@
+'use client'
+
+import BuildHeader from '@/features/dashboard/build/header'
+import Logs from '@/features/dashboard/build/logs'
+import { useTRPC } from '@/trpc/client'
+import { useQuery } from '@tanstack/react-query'
+import { TRPCClientError } from '@trpc/client'
+import { notFound } from 'next/navigation'
+import { use } from 'react'
+
+const REFETCH_INTERVAL_MS = 1_500
+
+export default function BuildPage({
+ params,
+}: PageProps<'/dashboard/[teamIdOrSlug]/templates/[templateId]/builds/[buildId]'>) {
+ const { teamIdOrSlug, templateId, buildId } = use(params)
+ const trpc = useTRPC()
+
+ const {
+ data: buildDetails,
+ error,
+ isPending,
+ } = useQuery(
+ trpc.builds.buildDetails.queryOptions(
+ { teamIdOrSlug, templateId, buildId },
+ {
+ refetchIntervalInBackground: false,
+ refetchOnWindowFocus: ({ state }) =>
+ state.data?.status === 'building' ? 'always' : false,
+ refetchInterval: ({ state }) =>
+ state.data?.status === 'building' ? REFETCH_INTERVAL_MS : false,
+ retry: (failureCount, error) => {
+ if (
+ error instanceof TRPCClientError &&
+ error.data?.code === 'NOT_FOUND'
+ ) {
+ return false
+ }
+ return failureCount < 3
+ },
+ }
+ )
+ )
+
+ if (error instanceof TRPCClientError && error.data?.code === 'NOT_FOUND') {
+ notFound()
+ }
+
+ return (
+
+
+
+
+ )
+}
diff --git a/src/configs/layout.ts b/src/configs/layout.ts
index 02f7095db..804f793b9 100644
--- a/src/configs/layout.ts
+++ b/src/configs/layout.ts
@@ -1,73 +1,101 @@
import { l } from '@/lib/clients/logger/logger'
import micromatch from 'micromatch'
+import { PROTECTED_URLS } from './urls'
+
+export interface TitleSegment {
+ label: string
+ href?: string
+}
/**
* Layout configuration for dashboard pages.
*/
export interface DashboardLayoutConfig {
- title: string
+ title: string | TitleSegment[]
type: 'default' | 'custom'
custom?: {
includeHeaderBottomStyles: boolean
}
}
-const DASHBOARD_LAYOUT_CONFIGS: Record = {
+const DASHBOARD_LAYOUT_CONFIGS: Record<
+ string,
+ (pathname: string) => DashboardLayoutConfig
+> = {
// base
- '/dashboard/*/sandboxes': {
+ '/dashboard/*/sandboxes': () => ({
title: 'Sandboxes',
type: 'custom',
- },
- '/dashboard/*/sandboxes/**/*': {
+ }),
+ '/dashboard/*/sandboxes/**/*': () => ({
title: 'Sandbox',
type: 'custom',
- },
- '/dashboard/*/templates': {
+ }),
+ '/dashboard/*/templates': () => ({
title: 'Templates',
type: 'custom',
+ }),
+ '/dashboard/*/templates/*/builds/*': (pathname) => {
+ const parts = pathname.split('/')
+ const teamIdOrSlug = parts[2]!
+ const buildId = parts.pop()
+
+ return {
+ title: [
+ {
+ label: 'Templates',
+ href: PROTECTED_URLS.TEMPLATES_BUILDS(teamIdOrSlug),
+ },
+ { label: `Build ${buildId}` },
+ ],
+ type: 'custom',
+ custom: {
+ includeHeaderBottomStyles: true,
+ },
+ }
},
// integrations
- '/dashboard/*/webhooks': {
+ '/dashboard/*/webhooks': () => ({
title: 'Webhooks',
type: 'default',
- },
+ }),
// team
- '/dashboard/*/general': {
+ '/dashboard/*/general': () => ({
title: 'General',
type: 'default',
- },
- '/dashboard/*/keys': {
+ }),
+ '/dashboard/*/keys': () => ({
title: 'API Keys',
type: 'default',
- },
- '/dashboard/*/members': {
+ }),
+ '/dashboard/*/members': () => ({
title: 'Members',
type: 'default',
- },
+ }),
// billing
- '/dashboard/*/usage': {
+ '/dashboard/*/usage': () => ({
title: 'Usage',
type: 'custom',
custom: {
includeHeaderBottomStyles: true,
},
- },
- '/dashboard/*/budget': {
+ }),
+ '/dashboard/*/budget': () => ({
title: 'Budget',
type: 'default',
- },
- '/dashboard/*/billing': {
+ }),
+ '/dashboard/*/billing': () => ({
title: 'Billing',
type: 'default',
- },
+ }),
- '/dashboard/*/account': {
+ '/dashboard/*/account': () => ({
title: 'Account',
type: 'default',
- },
+ }),
}
/**
@@ -79,7 +107,7 @@ export const getDashboardLayoutConfig = (
): DashboardLayoutConfig => {
for (const [pattern, config] of Object.entries(DASHBOARD_LAYOUT_CONFIGS)) {
if (micromatch.isMatch(pathname, pattern)) {
- return config
+ return config(pathname)
}
}
diff --git a/src/configs/sidebar.ts b/src/configs/sidebar.ts
index fce1d3b50..7cd1226c5 100644
--- a/src/configs/sidebar.ts
+++ b/src/configs/sidebar.ts
@@ -44,7 +44,7 @@ export const SIDEBAR_MAIN_LINKS: SidebarNavItem[] = [
label: 'Templates',
href: (args) => PROTECTED_URLS.TEMPLATES(args.teamIdOrSlug!),
icon: Container,
- activeMatch: `/dashboard/*/templates`,
+ activeMatch: `/dashboard/*/templates/**`,
},
// Integrations
diff --git a/src/configs/urls.ts b/src/configs/urls.ts
index a5d6352e9..924746238 100644
--- a/src/configs/urls.ts
+++ b/src/configs/urls.ts
@@ -40,6 +40,8 @@ export const PROTECTED_URLS = {
`/dashboard/${teamIdOrSlug}/templates?tab=list`,
TEMPLATES_BUILDS: (teamIdOrSlug: string) =>
`/dashboard/${teamIdOrSlug}/templates?tab=builds`,
+ TEMPLATE_BUILD: (teamIdOrSlug: string, templateId: string, buildId: string) =>
+ `/dashboard/${teamIdOrSlug}/templates/${templateId}/builds/${buildId}`,
USAGE: (teamIdOrSlug: string) => `/dashboard/${teamIdOrSlug}/usage`,
BILLING: (teamIdOrSlug: string) => `/dashboard/${teamIdOrSlug}/billing`,
diff --git a/src/features/dashboard/build/build-logs-store.ts b/src/features/dashboard/build/build-logs-store.ts
new file mode 100644
index 000000000..7d85d6b3e
--- /dev/null
+++ b/src/features/dashboard/build/build-logs-store.ts
@@ -0,0 +1,275 @@
+'use client'
+
+import type { BuildLogDTO } from '@/server/api/models/builds.models'
+import type { useTRPCClient } from '@/trpc/client'
+import { create } from 'zustand'
+import { immer } from 'zustand/middleware/immer'
+import type { LogLevelFilter } from './logs-filter-params'
+
+const FORWARD_CURSOR_PADDING_MS = 1
+
+interface BuildLogsParams {
+ teamIdOrSlug: string
+ templateId: string
+ buildId: string
+}
+
+type TRPCClient = ReturnType
+
+interface BuildLogsState {
+ logs: BuildLogDTO[]
+ hasMoreBackwards: boolean
+ isLoadingBackwards: boolean
+ isLoadingForwards: boolean
+ backwardsCursor: number | null
+ level: LogLevelFilter | null
+ isInitialized: boolean
+
+ _trpcClient: TRPCClient | null
+ _params: BuildLogsParams | null
+ _initVersion: number
+}
+
+interface BuildLogsMutations {
+ init: (
+ trpcClient: TRPCClient,
+ params: BuildLogsParams,
+ level: LogLevelFilter | null
+ ) => Promise
+ fetchMoreBackwards: () => Promise
+ fetchMoreForwards: () => Promise<{ logsCount: number }>
+ reset: () => void
+}
+
+interface BuildLogsComputed {
+ getNewestTimestamp: () => number | undefined
+ getOldestTimestamp: () => number | undefined
+}
+
+export type BuildLogsStoreData = BuildLogsState &
+ BuildLogsMutations &
+ BuildLogsComputed
+
+function getLogKey(log: BuildLogDTO): string {
+ return `${log.timestampUnix}:${log.level}:${log.message}`
+}
+
+function deduplicateLogs(
+ existingLogs: BuildLogDTO[],
+ newLogs: BuildLogDTO[]
+): BuildLogDTO[] {
+ const existingKeys = new Set(existingLogs.map(getLogKey))
+ return newLogs.filter((log) => !existingKeys.has(getLogKey(log)))
+}
+
+const initialState: BuildLogsState = {
+ logs: [],
+ hasMoreBackwards: true,
+ isLoadingBackwards: false,
+ isLoadingForwards: false,
+ backwardsCursor: null,
+ level: null,
+ isInitialized: false,
+ _trpcClient: null,
+ _params: null,
+ _initVersion: 0,
+}
+
+export const createBuildLogsStore = () =>
+ create()(
+ immer((set, get) => ({
+ ...initialState,
+
+ reset: () => {
+ set((state) => {
+ state.logs = []
+ state.hasMoreBackwards = true
+ state.isLoadingBackwards = false
+ state.isLoadingForwards = false
+ state.backwardsCursor = null
+ state.isInitialized = false
+ })
+ },
+
+ init: async (trpcClient, params, level) => {
+ const state = get()
+
+ // Reset if params or level changed
+ const paramsChanged =
+ state._params?.buildId !== params.buildId ||
+ state._params?.templateId !== params.templateId ||
+ state._params?.teamIdOrSlug !== params.teamIdOrSlug
+ const levelChanged = state.level !== level
+
+ if (paramsChanged || levelChanged || !state.isInitialized) {
+ get().reset()
+ }
+
+ // Increment version to invalidate any in-flight requests
+ const requestVersion = state._initVersion + 1
+
+ set((s) => {
+ s._trpcClient = trpcClient
+ s._params = params
+ s.level = level
+ s.isLoadingBackwards = true
+ s._initVersion = requestVersion
+ })
+
+ try {
+ const result = await trpcClient.builds.buildLogsBackwards.query({
+ teamIdOrSlug: params.teamIdOrSlug,
+ templateId: params.templateId,
+ buildId: params.buildId,
+ level: level ?? undefined,
+ cursor: Date.now(),
+ })
+
+ // Ignore stale response if a newer init was called
+ if (get()._initVersion !== requestVersion) {
+ return
+ }
+
+ set((s) => {
+ s.logs = result.logs
+ s.hasMoreBackwards = result.nextCursor !== null
+ s.backwardsCursor = result.nextCursor
+ s.isLoadingBackwards = false
+ s.isInitialized = true
+ })
+ } catch {
+ // Ignore errors from stale requests
+ if (get()._initVersion !== requestVersion) {
+ return
+ }
+
+ set((s) => {
+ s.isLoadingBackwards = false
+ })
+ }
+ },
+
+ fetchMoreBackwards: async () => {
+ const state = get()
+
+ if (
+ !state._trpcClient ||
+ !state._params ||
+ !state.hasMoreBackwards ||
+ state.isLoadingBackwards
+ ) {
+ return
+ }
+
+ const requestVersion = state._initVersion
+
+ set((s) => {
+ s.isLoadingBackwards = true
+ })
+
+ try {
+ const cursor =
+ state.backwardsCursor ?? state.getOldestTimestamp() ?? Date.now()
+
+ const result =
+ await state._trpcClient.builds.buildLogsBackwards.query({
+ teamIdOrSlug: state._params.teamIdOrSlug,
+ templateId: state._params.templateId,
+ buildId: state._params.buildId,
+ level: state.level ?? undefined,
+ cursor,
+ })
+
+ // Ignore stale response if init was called during fetch
+ if (get()._initVersion !== requestVersion) {
+ return
+ }
+
+ set((s) => {
+ const uniqueNewLogs = deduplicateLogs(s.logs, result.logs)
+ s.logs = [...uniqueNewLogs, ...s.logs]
+ s.hasMoreBackwards = result.nextCursor !== null
+ s.backwardsCursor = result.nextCursor
+ s.isLoadingBackwards = false
+ })
+ } catch {
+ if (get()._initVersion !== requestVersion) {
+ return
+ }
+
+ set((s) => {
+ s.isLoadingBackwards = false
+ })
+ }
+ },
+
+ fetchMoreForwards: async () => {
+ const state = get()
+
+ if (!state._trpcClient || !state._params || state.isLoadingForwards) {
+ return { logsCount: 0 }
+ }
+
+ const requestVersion = state._initVersion
+
+ set((s) => {
+ s.isLoadingForwards = true
+ })
+
+ try {
+ const newestTimestamp = state.getNewestTimestamp()
+ const cursor = newestTimestamp
+ ? newestTimestamp + FORWARD_CURSOR_PADDING_MS
+ : Date.now()
+
+ const result = await state._trpcClient.builds.buildLogsForward.query({
+ teamIdOrSlug: state._params.teamIdOrSlug,
+ templateId: state._params.templateId,
+ buildId: state._params.buildId,
+ level: state.level ?? undefined,
+ cursor,
+ })
+
+ // Ignore stale response if init was called during fetch
+ if (get()._initVersion !== requestVersion) {
+ return { logsCount: 0 }
+ }
+
+ let uniqueLogsCount = 0
+
+ set((s) => {
+ const uniqueNewLogs = deduplicateLogs(s.logs, result.logs)
+ uniqueLogsCount = uniqueNewLogs.length
+ if (uniqueLogsCount > 0) {
+ s.logs = [...s.logs, ...uniqueNewLogs]
+ }
+ s.isLoadingForwards = false
+ })
+
+ return { logsCount: uniqueLogsCount }
+ } catch {
+ if (get()._initVersion !== requestVersion) {
+ return { logsCount: 0 }
+ }
+
+ set((s) => {
+ s.isLoadingForwards = false
+ })
+
+ return { logsCount: 0 }
+ }
+ },
+
+ getNewestTimestamp: () => {
+ const state = get()
+ return state.logs[state.logs.length - 1]?.timestampUnix
+ },
+
+ getOldestTimestamp: () => {
+ const state = get()
+ return state.logs[0]?.timestampUnix
+ },
+ }))
+ )
+
+export type BuildLogsStore = ReturnType
diff --git a/src/features/dashboard/build/header-cells.tsx b/src/features/dashboard/build/header-cells.tsx
new file mode 100644
index 000000000..37b1fa211
--- /dev/null
+++ b/src/features/dashboard/build/header-cells.tsx
@@ -0,0 +1,111 @@
+import { PROTECTED_URLS } from '@/configs/urls'
+import {
+ formatCompactDate,
+ formatDurationCompact,
+ formatTimeAgoCompact,
+} from '@/lib/utils/formatting'
+import { cn } from '@/lib/utils/ui'
+import CopyButtonInline from '@/ui/copy-button-inline'
+import { Button } from '@/ui/primitives/button'
+import { ArrowUpRight } from 'lucide-react'
+import { useParams, useRouter } from 'next/navigation'
+import { useEffect, useState } from 'react'
+import { useTemplateTableStore } from '../templates/list/stores/table-store'
+
+export function Template({
+ template,
+ templateId,
+ className,
+}: {
+ template: string
+ templateId: string
+ className?: string
+}) {
+ const router = useRouter()
+ const { teamIdOrSlug } =
+ useParams<
+ Awaited['params']>
+ >()
+
+ return (
+
+ )
+}
+
+export function RanFor({
+ startedAt,
+ finishedAt,
+ isBuilding,
+}: {
+ startedAt: number
+ finishedAt: number | null
+ isBuilding: boolean
+}) {
+ const [now, setNow] = useState(() => Date.now())
+
+ useEffect(() => {
+ if (!isBuilding) return
+
+ const interval = setInterval(() => {
+ setNow(Date.now())
+ }, 1000)
+
+ return () => clearInterval(interval)
+ }, [isBuilding])
+
+ const duration = isBuilding
+ ? now - startedAt
+ : (finishedAt ?? now) - startedAt
+
+ // no timestamp to copy - just show duration
+ if (isBuilding || !finishedAt) {
+ return (
+
+ {formatDurationCompact(duration)}
+
+ )
+ }
+
+ const iso = new Date(finishedAt).toISOString()
+ const formattedTimestamp = formatCompactDate(finishedAt)
+
+ return (
+
+ {formatDurationCompact(duration)}{' '}
+
+ · {formattedTimestamp}
+
+
+ )
+}
+
+export function StartedAt({ timestamp }: { timestamp: number }) {
+ const iso = new Date(timestamp).toISOString()
+ const elapsed = Date.now() - timestamp
+ const formattedTimestamp = formatCompactDate(timestamp)
+
+ return (
+
+ {formatTimeAgoCompact(elapsed)}{' '}
+
+ · {formattedTimestamp}
+
+
+ )
+}
diff --git a/src/features/dashboard/build/header.tsx b/src/features/dashboard/build/header.tsx
new file mode 100644
index 000000000..f90f4d61c
--- /dev/null
+++ b/src/features/dashboard/build/header.tsx
@@ -0,0 +1,135 @@
+'use client'
+
+import { cn } from '@/lib/utils/ui'
+import { BuildDetailsDTO } from '@/server/api/models/builds.models'
+import CopyButton from '@/ui/copy-button'
+import CopyButtonInline from '@/ui/copy-button-inline'
+import { CheckIcon, CloseIcon } from '@/ui/primitives/icons'
+import { Loader } from '@/ui/primitives/loader'
+import { Skeleton } from '@/ui/primitives/skeleton'
+import { DetailsItem, DetailsRow } from '../layouts/details-row'
+import { RanFor, StartedAt, Template } from './header-cells'
+
+interface BuildHeaderProps {
+ buildDetails: BuildDetailsDTO | undefined
+ buildId: string
+ templateId: string
+}
+
+export default function BuildHeader({
+ buildDetails,
+ buildId,
+ templateId,
+}: BuildHeaderProps) {
+ const isLoading = !buildDetails
+ const isBuilding = buildDetails?.status === 'building'
+
+ return (
+
+
+
+
+ {buildId.slice(0, 6)}...{buildId.slice(-6)}
+
+
+
+ {isLoading ? (
+
+ ) : (
+
+ )}
+
+
+ {isLoading ? (
+
+ ) : (
+
+ )}
+
+
+ {isLoading ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ )
+}
+
+interface StatusBannerProps {
+ status: BuildDetailsDTO['status'] | undefined
+ statusMessage?: BuildDetailsDTO['statusMessage']
+}
+
+function StatusBanner({ status, statusMessage }: StatusBannerProps) {
+ return (
+
+
+ {!status ? (
+ <>
+
+
Getting Build
+
+ >
+ ) : status === 'failed' ? (
+ <>
+
+
+ >
+ ) : status === 'success' ? (
+ <>
+
+
Build Successful
+ >
+ ) : (
+ <>
+
+
Building
+
+ >
+ )}
+
+
+ {status === 'failed' && statusMessage && (
+ <>
+
+
+ >
+ )}
+
+ )
+}
diff --git a/src/features/dashboard/build/logs-cells.tsx b/src/features/dashboard/build/logs-cells.tsx
new file mode 100644
index 000000000..8511107b0
--- /dev/null
+++ b/src/features/dashboard/build/logs-cells.tsx
@@ -0,0 +1,64 @@
+import { formatDurationCompact } from '@/lib/utils/formatting'
+import { BuildLogDTO } from '@/server/api/models/builds.models'
+import CopyButtonInline from '@/ui/copy-button-inline'
+import { Badge, BadgeProps } from '@/ui/primitives/badge'
+import { format } from 'date-fns'
+
+interface LogLevelProps {
+ level: BuildLogDTO['level']
+}
+
+const mapLogLevelToBadgeProps: Record = {
+ debug: {
+ variant: 'default',
+ },
+ info: {
+ variant: 'positive',
+ },
+ warn: {
+ variant: 'warning',
+ },
+ error: {
+ variant: 'error',
+ },
+}
+
+export const LogLevel = ({ level }: LogLevelProps) => {
+ return (
+
+ {level}
+
+ )
+}
+
+interface TimestampProps {
+ timestampUnix: number
+ millisAfterStart: number
+}
+
+export const Timestamp = ({
+ timestampUnix,
+ millisAfterStart,
+}: TimestampProps) => {
+ const date = new Date(timestampUnix)
+
+ return (
+
+ {formatDurationCompact(millisAfterStart, true)}{' '}
+
+ {format(date, 'HH:mm:ss.SS')}
+
+
+ )
+}
+
+interface MessageProps {
+ message: BuildLogDTO['message']
+}
+
+export const Message = ({ message }: MessageProps) => {
+ return {message}
+}
diff --git a/src/features/dashboard/build/logs-filter-params.ts b/src/features/dashboard/build/logs-filter-params.ts
new file mode 100644
index 000000000..75e9095a4
--- /dev/null
+++ b/src/features/dashboard/build/logs-filter-params.ts
@@ -0,0 +1,12 @@
+import { BuildLogDTO } from '@/server/api/models/builds.models'
+import { createLoader, parseAsStringEnum } from 'nuqs/server'
+
+export type LogLevelFilter = BuildLogDTO['level']
+
+export const LOG_LEVELS: LogLevelFilter[] = ['debug', 'info', 'warn', 'error']
+
+export const buildLogsFilterParams = {
+ level: parseAsStringEnum(['debug', 'info', 'warn', 'error'] as const),
+}
+
+export const loadBuildLogsFilters = createLoader(buildLogsFilterParams)
diff --git a/src/features/dashboard/build/logs.tsx b/src/features/dashboard/build/logs.tsx
new file mode 100644
index 000000000..218c78bfb
--- /dev/null
+++ b/src/features/dashboard/build/logs.tsx
@@ -0,0 +1,668 @@
+'use client'
+
+import { cn } from '@/lib/utils'
+import type {
+ BuildDetailsDTO,
+ BuildLogDTO,
+} from '@/server/api/models/builds.models'
+import { Button } from '@/ui/primitives/button'
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuTrigger,
+} from '@/ui/primitives/dropdown-menu'
+import { ArrowDownIcon, ListIcon } from '@/ui/primitives/icons'
+import { Loader } from '@/ui/primitives/loader'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/ui/primitives/table'
+import {
+ useVirtualizer,
+ VirtualItem,
+ Virtualizer,
+} from '@tanstack/react-virtual'
+import {
+ RefObject,
+ useCallback,
+ useEffect,
+ useReducer,
+ useRef,
+ useState,
+} from 'react'
+import { LOG_RETENTION_MS } from '../templates/builds/constants'
+import { LogLevel, Message, Timestamp } from './logs-cells'
+import { type LogLevelFilter } from './logs-filter-params'
+import { useBuildLogs } from './use-build-logs'
+import useLogFilters from './use-log-filters'
+
+const COLUMN_WIDTHS_PX = { timestamp: 164, level: 92 } as const
+const ROW_HEIGHT_PX = 26
+const VIRTUAL_OVERSCAN = 16
+const SCROLL_LOAD_THRESHOLD_PX = 200
+
+const LEVEL_OPTIONS: Array<{ value: LogLevelFilter; label: string }> = [
+ { value: 'debug', label: 'Debug' },
+ { value: 'info', label: 'Info' },
+ { value: 'warn', label: 'Warn' },
+ { value: 'error', label: 'Error' },
+]
+
+interface LogsProps {
+ buildDetails: BuildDetailsDTO | undefined
+ teamIdOrSlug: string
+ templateId: string
+ buildId: string
+}
+
+export default function Logs({
+ buildDetails,
+ teamIdOrSlug,
+ templateId,
+ buildId,
+}: LogsProps) {
+ 'use no memo'
+
+ const { level, setLevel } = useLogFilters()
+
+ if (!buildDetails) {
+ return (
+
+ )
+ }
+
+ return (
+
+ )
+}
+
+interface LogsContentProps {
+ buildDetails: BuildDetailsDTO
+ teamIdOrSlug: string
+ templateId: string
+ buildId: string
+ level: LogLevelFilter | null
+ setLevel: (level: LogLevelFilter | null) => void
+}
+
+function LogsContent({
+ buildDetails,
+ teamIdOrSlug,
+ templateId,
+ buildId,
+ level,
+ setLevel,
+}: LogsContentProps) {
+ const scrollContainerRef = useRef(null)
+
+ const { isRefetchingFromFilterChange, onFetchComplete } =
+ useFilterRefetchTracking(level)
+
+ const {
+ logs,
+ isInitialized,
+ hasNextPage,
+ isFetchingNextPage,
+ isFetching,
+ fetchNextPage,
+ } = useBuildLogs({
+ teamIdOrSlug,
+ templateId,
+ buildId,
+ level,
+ buildStatus: buildDetails.status,
+ })
+
+ useEffect(() => {
+ if (!isFetching && isRefetchingFromFilterChange) {
+ onFetchComplete()
+ }
+ }, [isFetching, isRefetchingFromFilterChange, onFetchComplete])
+
+ const hasLogs = logs.length > 0
+ const showLoader = (isFetching || isRefetchingFromFilterChange) && !hasLogs
+ const showEmpty = !isFetching && !hasLogs && !isRefetchingFromFilterChange
+ const showRefetchOverlay = isRefetchingFromFilterChange && hasLogs
+
+ const handleLoadMore = useCallback(() => {
+ if (hasNextPage && !isFetchingNextPage) {
+ fetchNextPage()
+ }
+ }, [fetchNextPage, hasNextPage, isFetchingNextPage])
+
+ return (
+
+
+
+
+
+
+
+ {showLoader && }
+ {showEmpty && (
+
+ )}
+ {hasLogs && (
+
+ )}
+
+
+
+ )
+}
+
+function useFilterRefetchTracking(level: LogLevelFilter | null) {
+ const [isRefetchingFromFilterChange, setIsRefetching] = useState(false)
+ const isInitialRender = useRef(true)
+
+ useEffect(() => {
+ if (isInitialRender.current) {
+ isInitialRender.current = false
+ return
+ }
+ setIsRefetching(true)
+ }, [level])
+
+ const onFetchComplete = useCallback(() => setIsRefetching(false), [])
+
+ return { isRefetchingFromFilterChange, onFetchComplete }
+}
+
+function LogsTableHeader() {
+ return (
+
+
+
+ Timestamp
+
+
+ Level
+
+ Message
+
+
+ )
+}
+
+function LoaderBody() {
+ return (
+
+
+
+
+
+
+
+
+
+ )
+}
+
+interface EmptyBodyProps {
+ hasRetainedLogs: boolean
+}
+
+function EmptyBody({ hasRetainedLogs }: EmptyBodyProps) {
+ return (
+
+
+
+
+
+ {!hasRetainedLogs && (
+
+ This build has exceeded the{' '}
+ {LOG_RETENTION_MS / 24 / 60 / 60 / 1000} day retention limit.
+
+ )}
+
+
+
+
+ )
+}
+
+interface LevelFilterProps {
+ level: LogLevelFilter | null
+ onLevelChange: (level: LogLevelFilter | null) => void
+}
+
+function LevelFilter({ level, onLevelChange }: LevelFilterProps) {
+ const selectedLevel = level ?? 'debug'
+ const selectedLabel = LEVEL_OPTIONS.find(
+ (o) => o.value === selectedLevel
+ )?.label
+
+ return (
+
+
+
+
+
+
+ onLevelChange(value as LogLevelFilter)}
+ >
+ {LEVEL_OPTIONS.map((option) => (
+
+
+
+ ))}
+
+
+
+
+ )
+}
+
+function LevelIndicator({ level }: { level: LogLevelFilter }) {
+ return (
+
+ )
+}
+
+interface VirtualizedLogsBodyProps {
+ logs: BuildLogDTO[]
+ scrollContainerRef: RefObject
+ startedAt: number
+ onLoadMore: () => void
+ hasNextPage: boolean
+ isFetchingNextPage: boolean
+ showRefetchOverlay: boolean
+ isInitialized: boolean
+ level: LogLevelFilter | null
+}
+
+function VirtualizedLogsBody({
+ logs,
+ scrollContainerRef,
+ startedAt,
+ onLoadMore,
+ hasNextPage,
+ isFetchingNextPage,
+ showRefetchOverlay,
+ isInitialized,
+ level,
+}: VirtualizedLogsBodyProps) {
+ const tbodyRef = useRef(null)
+ const maxWidthRef = useRef(0)
+ const [, forceRerender] = useReducer(() => ({}), {})
+
+ useEffect(() => {
+ if (scrollContainerRef.current) forceRerender()
+ }, [scrollContainerRef])
+
+ useScrollLoadMore({
+ scrollContainerRef,
+ hasNextPage,
+ isFetchingNextPage,
+ onLoadMore,
+ })
+
+ useAutoScrollToBottom({
+ scrollContainerRef,
+ logsCount: logs.length,
+ isInitialized,
+ level,
+ })
+
+ useMaintainScrollOnPrepend({
+ scrollContainerRef,
+ logsCount: logs.length,
+ isFetchingNextPage,
+ })
+
+ const showStatusRow = hasNextPage || isFetchingNextPage
+
+ const virtualizer = useVirtualizer({
+ count: logs.length + (showStatusRow ? 1 : 0),
+ estimateSize: () => ROW_HEIGHT_PX,
+ getScrollElement: () => scrollContainerRef.current,
+ overscan: VIRTUAL_OVERSCAN,
+ })
+
+ const containerWidth = scrollContainerRef.current?.clientWidth ?? 0
+ const contentWidth = scrollContainerRef.current?.scrollWidth ?? 0
+ const SCROLLBAR_BUFFER_PX = 20
+ const hasHorizontalOverflow =
+ contentWidth > containerWidth + SCROLLBAR_BUFFER_PX
+
+ if (hasHorizontalOverflow && contentWidth > maxWidthRef.current) {
+ maxWidthRef.current = contentWidth
+ }
+
+ return (
+
+ {virtualizer.getVirtualItems().map((virtualRow) => {
+ const isStatusRow = showStatusRow && virtualRow.index === 0
+
+ if (isStatusRow) {
+ return (
+
+ )
+ }
+
+ const logIndex = showStatusRow ? virtualRow.index - 1 : virtualRow.index
+
+ return (
+
+ )
+ })}
+
+ )
+}
+
+interface UseScrollLoadMoreParams {
+ scrollContainerRef: RefObject
+ hasNextPage: boolean
+ isFetchingNextPage: boolean
+ onLoadMore: () => void
+}
+
+function useScrollLoadMore({
+ scrollContainerRef,
+ hasNextPage,
+ isFetchingNextPage,
+ onLoadMore,
+}: UseScrollLoadMoreParams) {
+ useEffect(() => {
+ const scrollContainer = scrollContainerRef.current
+ if (!scrollContainer) return
+
+ const handleScroll = () => {
+ if (
+ scrollContainer.scrollTop < SCROLL_LOAD_THRESHOLD_PX &&
+ hasNextPage &&
+ !isFetchingNextPage
+ ) {
+ onLoadMore()
+ }
+ }
+
+ scrollContainer.addEventListener('scroll', handleScroll)
+ return () => scrollContainer.removeEventListener('scroll', handleScroll)
+ }, [scrollContainerRef, hasNextPage, isFetchingNextPage, onLoadMore])
+}
+
+interface UseMaintainScrollOnPrependParams {
+ scrollContainerRef: RefObject
+ logsCount: number
+ isFetchingNextPage: boolean
+}
+
+function useMaintainScrollOnPrepend({
+ scrollContainerRef,
+ logsCount,
+ isFetchingNextPage,
+}: UseMaintainScrollOnPrependParams) {
+ const prevLogsCountRef = useRef(logsCount)
+ const wasFetchingRef = useRef(false)
+
+ useEffect(() => {
+ const el = scrollContainerRef.current
+ if (!el) return
+
+ const justFinishedFetching = wasFetchingRef.current && !isFetchingNextPage
+ const logsWerePrepended = logsCount > prevLogsCountRef.current
+
+ if (justFinishedFetching && logsWerePrepended) {
+ const addedCount = logsCount - prevLogsCountRef.current
+ el.scrollTop += addedCount * ROW_HEIGHT_PX
+ }
+
+ wasFetchingRef.current = isFetchingNextPage
+ prevLogsCountRef.current = logsCount
+ }, [scrollContainerRef, logsCount, isFetchingNextPage])
+}
+
+interface UseAutoScrollToBottomParams {
+ scrollContainerRef: RefObject
+ logsCount: number
+ isInitialized: boolean
+ level: LogLevelFilter | null
+}
+
+function useAutoScrollToBottom({
+ scrollContainerRef,
+ logsCount,
+ isInitialized,
+ level,
+}: UseAutoScrollToBottomParams) {
+ const isAutoScrollEnabledRef = useRef(true)
+ const prevLogsCountRef = useRef(0)
+ const prevLevelRef = useRef(level)
+ const hasInitialScrolled = useRef(false)
+
+ useEffect(() => {
+ const el = scrollContainerRef.current
+ if (!el) return
+
+ const handleScroll = () => {
+ const distanceFromBottom =
+ el.scrollHeight - el.scrollTop - el.clientHeight
+ isAutoScrollEnabledRef.current = distanceFromBottom < ROW_HEIGHT_PX * 2
+ }
+
+ el.addEventListener('scroll', handleScroll)
+ return () => el.removeEventListener('scroll', handleScroll)
+ }, [scrollContainerRef])
+
+ useEffect(() => {
+ if (isInitialized && !hasInitialScrolled.current && logsCount > 0) {
+ hasInitialScrolled.current = true
+ prevLogsCountRef.current = logsCount
+ requestAnimationFrame(() => {
+ const el = scrollContainerRef.current
+ if (el) el.scrollTop = el.scrollHeight
+ })
+ }
+ }, [isInitialized, logsCount, scrollContainerRef])
+
+ useEffect(() => {
+ if (prevLevelRef.current !== level) {
+ prevLevelRef.current = level
+ hasInitialScrolled.current = false
+ prevLogsCountRef.current = 0
+ }
+ }, [level])
+
+ useEffect(() => {
+ if (!hasInitialScrolled.current) return
+
+ const newLogsCount = logsCount - prevLogsCountRef.current
+
+ if (newLogsCount > 0 && isAutoScrollEnabledRef.current) {
+ const el = scrollContainerRef.current
+ if (el) el.scrollTop += newLogsCount * ROW_HEIGHT_PX
+ }
+
+ prevLogsCountRef.current = logsCount
+ }, [logsCount, scrollContainerRef])
+}
+
+interface LogRowProps {
+ log: BuildLogDTO
+ virtualRow: VirtualItem
+ virtualizer: Virtualizer
+ startedAt: number
+}
+
+function LogRow({ log, virtualRow, virtualizer, startedAt }: LogRowProps) {
+ const millisAfterStart = log.timestampUnix - startedAt
+
+ return (
+ virtualizer.measureElement(node)}
+ style={{
+ display: 'flex',
+ position: 'absolute',
+ left: 0,
+ transform: `translateY(${virtualRow.start}px)`,
+ minWidth: '100%',
+ height: ROW_HEIGHT_PX,
+ }}
+ >
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+interface StatusRowProps {
+ virtualRow: VirtualItem
+ virtualizer: Virtualizer
+ isFetchingNextPage: boolean
+}
+
+function StatusRow({
+ virtualRow,
+ virtualizer,
+ isFetchingNextPage,
+}: StatusRowProps) {
+ return (
+ virtualizer.measureElement(node)}
+ className="animate-pulse"
+ style={{
+ display: 'flex',
+ position: 'absolute',
+ left: 0,
+ transform: `translateY(${virtualRow.start}px)`,
+ minWidth: '100%',
+ height: ROW_HEIGHT_PX,
+ }}
+ >
+
+
+ {isFetchingNextPage ? (
+
+ Loading more logs
+
+
+ ) : (
+ 'Scroll to load more'
+ )}
+
+
+
+ )
+}
diff --git a/src/features/dashboard/build/use-build-logs.ts b/src/features/dashboard/build/use-build-logs.ts
new file mode 100644
index 000000000..632d70e43
--- /dev/null
+++ b/src/features/dashboard/build/use-build-logs.ts
@@ -0,0 +1,89 @@
+'use client'
+
+import type { BuildStatusDTO } from '@/server/api/models/builds.models'
+import { useTRPCClient } from '@/trpc/client'
+import { useQuery } from '@tanstack/react-query'
+import { useCallback, useEffect, useRef } from 'react'
+import { useStore } from 'zustand'
+import { createBuildLogsStore, type BuildLogsStore } from './build-logs-store'
+import { type LogLevelFilter } from './logs-filter-params'
+
+const REFETCH_INTERVAL_MS = 1_500
+
+interface UseBuildLogsParams {
+ teamIdOrSlug: string
+ templateId: string
+ buildId: string
+ level: LogLevelFilter | null
+ buildStatus: BuildStatusDTO
+}
+
+export function useBuildLogs({
+ teamIdOrSlug,
+ templateId,
+ buildId,
+ level,
+ buildStatus,
+}: UseBuildLogsParams) {
+ const trpcClient = useTRPCClient()
+ const storeRef = useRef(null)
+
+ if (!storeRef.current) {
+ storeRef.current = createBuildLogsStore()
+ }
+
+ const store = storeRef.current
+
+ const logs = useStore(store, (s) => s.logs)
+ const isInitialized = useStore(store, (s) => s.isInitialized)
+ const hasMoreBackwards = useStore(store, (s) => s.hasMoreBackwards)
+ const isLoadingBackwards = useStore(store, (s) => s.isLoadingBackwards)
+ const isLoadingForwards = useStore(store, (s) => s.isLoadingForwards)
+
+ useEffect(() => {
+ store
+ .getState()
+ .init(trpcClient, { teamIdOrSlug, templateId, buildId }, level)
+ }, [store, trpcClient, teamIdOrSlug, templateId, buildId, level])
+
+ const isBuilding = buildStatus === 'building'
+ const isDraining = useRef(false)
+
+ useEffect(() => {
+ if (isBuilding) {
+ isDraining.current = true
+ }
+ }, [isBuilding])
+
+ const shouldPoll = isInitialized && (isBuilding || isDraining.current)
+
+ const { isFetching: isPolling } = useQuery({
+ queryKey: ['buildLogsForward', teamIdOrSlug, templateId, buildId, level],
+ queryFn: async () => {
+ const { logsCount } = await store.getState().fetchMoreForwards()
+
+ if (!isBuilding && logsCount === 0) {
+ isDraining.current = false
+ }
+
+ return { logsCount }
+ },
+ enabled: shouldPoll,
+ refetchInterval: shouldPoll ? REFETCH_INTERVAL_MS : false,
+ refetchIntervalInBackground: false,
+ refetchOnWindowFocus: 'always',
+ })
+
+ const fetchNextPage = useCallback(() => {
+ store.getState().fetchMoreBackwards()
+ }, [store])
+
+ return {
+ logs,
+ isInitialized,
+ hasNextPage: hasMoreBackwards,
+ isFetchingNextPage: isLoadingBackwards,
+ isFetching: isLoadingBackwards || isLoadingForwards || isPolling,
+ fetchNextPage,
+ }
+}
diff --git a/src/features/dashboard/build/use-log-filters.ts b/src/features/dashboard/build/use-log-filters.ts
new file mode 100644
index 000000000..2ef079de9
--- /dev/null
+++ b/src/features/dashboard/build/use-log-filters.ts
@@ -0,0 +1,25 @@
+'use client'
+
+import { useQueryStates } from 'nuqs'
+import { useCallback } from 'react'
+import { buildLogsFilterParams, LogLevelFilter } from './logs-filter-params'
+
+export default function useLogFilters() {
+ const [filters, setFilters] = useQueryStates(buildLogsFilterParams, {
+ shallow: true,
+ })
+
+ const level = filters.level as LogLevelFilter | null
+
+ const setLevel = useCallback(
+ (level: LogLevelFilter | null) => {
+ setFilters({ level })
+ },
+ [setFilters]
+ )
+
+ return {
+ level,
+ setLevel,
+ }
+}
diff --git a/src/features/dashboard/layouts/details-row.tsx b/src/features/dashboard/layouts/details-row.tsx
new file mode 100644
index 000000000..336636fd9
--- /dev/null
+++ b/src/features/dashboard/layouts/details-row.tsx
@@ -0,0 +1,25 @@
+import { cn } from '@/lib/utils/ui'
+import { ReactNode } from 'react'
+
+interface DetailItemProps extends React.HTMLAttributes {
+ label: string
+}
+
+export function DetailsItem({ label, children, ...props }: DetailItemProps) {
+ return (
+
+ {label}
+ {children}
+
+ )
+}
+
+interface DetailsRowProps {
+ children: ReactNode
+}
+
+export function DetailsRow({ children }: DetailsRowProps) {
+ return (
+ {children}
+ )
+}
diff --git a/src/features/dashboard/layout/header.tsx b/src/features/dashboard/layouts/header.tsx
similarity index 55%
rename from src/features/dashboard/layout/header.tsx
rename to src/features/dashboard/layouts/header.tsx
index d1afbf544..8d3f7ece1 100644
--- a/src/features/dashboard/layout/header.tsx
+++ b/src/features/dashboard/layouts/header.tsx
@@ -1,11 +1,13 @@
'use client'
-import { getDashboardLayoutConfig } from '@/configs/layout'
+import { getDashboardLayoutConfig, TitleSegment } from '@/configs/layout'
import { cn } from '@/lib/utils'
import ClientOnly from '@/ui/client-only'
import { SidebarTrigger } from '@/ui/primitives/sidebar'
import { ThemeSwitcher } from '@/ui/theme-switcher'
+import Link from 'next/link'
import { usePathname } from 'next/navigation'
+import { Fragment } from 'react'
interface DashboardLayoutHeaderProps {
className?: string
@@ -34,10 +36,12 @@ export default function DashboardLayoutHeader({
className
)}
>
-
+
-
{config.title}
+
+
+
{children}
@@ -48,3 +52,29 @@ export default function DashboardLayoutHeader({
)
}
+
+function HeaderTitle({ title }: { title: string | TitleSegment[] }) {
+ if (typeof title === 'string') {
+ return title
+ }
+
+ return (
+
+ {title.map((segment, index) => (
+
+ {index > 0 && /}
+ {segment.href ? (
+
+ {segment.label}
+
+ ) : (
+ {segment.label}
+ )}
+
+ ))}
+
+ )
+}
diff --git a/src/features/dashboard/layout/layout.tsx b/src/features/dashboard/layouts/layout.tsx
similarity index 100%
rename from src/features/dashboard/layout/layout.tsx
rename to src/features/dashboard/layouts/layout.tsx
diff --git a/src/features/dashboard/layout/wrapper.tsx b/src/features/dashboard/layouts/wrapper.tsx
similarity index 100%
rename from src/features/dashboard/layout/wrapper.tsx
rename to src/features/dashboard/layouts/wrapper.tsx
diff --git a/src/features/dashboard/sandbox/header/header.tsx b/src/features/dashboard/sandbox/header/header.tsx
index 4542b174b..64b384bc3 100644
--- a/src/features/dashboard/sandbox/header/header.tsx
+++ b/src/features/dashboard/sandbox/header/header.tsx
@@ -4,6 +4,7 @@ import { SandboxInfo } from '@/types/api.types'
import { ChevronLeftIcon } from 'lucide-react'
import { cookies } from 'next/headers'
import Link from 'next/link'
+import { DetailsItem, DetailsRow } from '../../layouts/details-row'
import KillButton from './kill-button'
import Metadata from './metadata'
import RanFor from './ran-for'
@@ -28,45 +29,6 @@ export default async function SandboxDetailsHeader({
COOKIE_KEYS.SANDBOX_INSPECT_POLLING_INTERVAL
)?.value
- const headerItems = {
- state: {
- label: 'status',
- value:
,
- },
- templateID: {
- label: 'template',
- value:
,
- },
- metadata: {
- label: 'metadata',
- value:
,
- },
- remainingTime: {
- label: 'timeout in',
- value:
,
- },
- startedAt: {
- label: 'created at',
- value:
,
- },
- endAt: {
- label: state === 'running' ? 'running for' : 'ran for',
- value:
,
- },
- cpuCount: {
- label: 'CPU Usage',
- value:
,
- },
- memoryMB: {
- label: 'Memory Usage',
- value:
,
- },
- diskGB: {
- label: 'Disk Usage',
- value:
,
- },
- }
-
return (
-
- {Object.entries(headerItems).map(([key, { label, value }]) => (
-
- ))}
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
)
}
-
-interface HeaderItemProps {
- label: string
- value: string | React.ReactNode
-}
-
-function HeaderItem({ label, value }: HeaderItemProps) {
- return (
-
-
{label}
- {typeof value === 'string' ?
{value}
: value}
-
- )
-}
diff --git a/src/features/dashboard/templates/builds/constants.ts b/src/features/dashboard/templates/builds/constants.ts
index c7bd04dca..5759af715 100644
--- a/src/features/dashboard/templates/builds/constants.ts
+++ b/src/features/dashboard/templates/builds/constants.ts
@@ -1,5 +1,7 @@
import { BuildStatusDTO } from '@/server/api/models/builds.models'
+export const LOG_RETENTION_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
+
export const INITIAL_BUILD_STATUSES: BuildStatusDTO[] = [
'building',
'failed',
diff --git a/src/features/dashboard/templates/builds/table-cells.tsx b/src/features/dashboard/templates/builds/table-cells.tsx
index 6a841b74d..8c0c97f2c 100644
--- a/src/features/dashboard/templates/builds/table-cells.tsx
+++ b/src/features/dashboard/templates/builds/table-cells.tsx
@@ -2,7 +2,6 @@
import { PROTECTED_URLS } from '@/configs/urls'
import { useTemplateTableStore } from '@/features/dashboard/templates/list/stores/table-store'
-import { useClipboard } from '@/lib/hooks/use-clipboard'
import { cn } from '@/lib/utils'
import {
formatDurationCompact,
@@ -12,6 +11,7 @@ import type {
BuildStatusDTO,
ListedBuildDTO,
} from '@/server/api/models/builds.models'
+import CopyButtonInline from '@/ui/copy-button-inline'
import { Badge } from '@/ui/primitives/badge'
import { Button } from '@/ui/primitives/button'
import { CheckIcon, CloseIcon } from '@/ui/primitives/icons'
@@ -20,50 +20,25 @@ import { ArrowUpRight } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
-function CopyableCell({
- value,
- children,
- className,
-}: {
- value: string
- children: React.ReactNode
- className?: string
-}) {
- const [wasCopied, copy] = useClipboard()
-
- return (
-
- )
-}
-
export function BuildId({ id }: { id: string }) {
return (
-
- {id}
-
+
+ {id.slice(0, 6)}...{id.slice(-6)}
+
)
}
export function Template({
- name,
+ template,
templateId,
+ className,
}: {
- name: string
+ template: string
templateId: string
+ className?: string
}) {
const router = useRouter()
const { teamIdOrSlug } =
@@ -74,7 +49,10 @@ export function Template({
return (
)
@@ -151,18 +129,10 @@ export function Duration({
: (finishedAt ?? now) - createdAt
const iso = finishedAt ? new Date(finishedAt).toISOString() : null
- if (isBuilding || !iso) {
- return (
-
- {formatDurationCompact(duration)}
-
- )
- }
-
return (
-
+
{formatDurationCompact(duration)}
-
+
)
}
@@ -171,9 +141,9 @@ export function StartedAt({ timestamp }: { timestamp: number }) {
const elapsed = Date.now() - timestamp
return (
-
+
{formatTimeAgoCompact(elapsed)}
-
+
)
}
@@ -213,7 +183,7 @@ export function Status({ status }: StatusProps) {
@@ -232,11 +202,8 @@ export function Reason({
if (!statusMessage) return null
return (
-
+
{statusMessage}
-
+
)
}
diff --git a/src/features/dashboard/templates/builds/table.tsx b/src/features/dashboard/templates/builds/table.tsx
index 37b9f0135..569bbb9b1 100644
--- a/src/features/dashboard/templates/builds/table.tsx
+++ b/src/features/dashboard/templates/builds/table.tsx
@@ -1,5 +1,7 @@
'use client'
+import { PROTECTED_URLS } from '@/configs/urls'
+import { cn } from '@/lib/utils/ui'
import type {
ListedBuildDTO,
RunningBuildStatusDTO,
@@ -21,9 +23,8 @@ import {
useQuery,
useQueryClient,
} from '@tanstack/react-query'
-import { useParams } from 'next/navigation'
+import { useParams, useRouter } from 'next/navigation'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
-
import BuildsEmpty from './empty'
import {
BackToTopButton,
@@ -42,16 +43,17 @@ const RUNNING_BUILD_POLL_INTERVAL_MS = 3_000
const MAX_CACHED_PAGES = 3
const COLUMN_WIDTHS = {
- id: 132,
+ id: 152,
status: 96,
template: 192,
- started: 156,
+ started: 126,
duration: 96,
} as const
const BuildsTable = () => {
const trpc = useTRPC()
const queryClient = useQueryClient()
+ const router = useRouter()
const scrollContainerRef = useRef(null)
const { teamIdOrSlug } =
@@ -162,26 +164,26 @@ const BuildsTable = () => {
>
-
+
- Build ID
Status
Template
-
+
Started
Duration
+ ID
|
@@ -228,14 +230,22 @@ const BuildsTable = () => {
return (
{
+ router.push(
+ PROTECTED_URLS.TEMPLATE_BUILD(
+ teamIdOrSlug,
+ build.templateId,
+ build.id
+ )
+ )
+ }}
>
-
-
-
{
style={{ maxWidth: COLUMN_WIDTHS.template }}
>
-
+
@@ -261,6 +271,12 @@ const BuildsTable = () => {
isBuilding={isBuilding}
/>
+
+
+
diff --git a/src/lib/utils/formatting.ts b/src/lib/utils/formatting.ts
index f8faf1ca9..4ef270b86 100644
--- a/src/lib/utils/formatting.ts
+++ b/src/lib/utils/formatting.ts
@@ -204,7 +204,10 @@ export function formatDuration(durationMs: number): string {
}
}
-export function formatDurationCompact(ms: number): string {
+export function formatDurationCompact(
+ ms: number,
+ showDecimalSeconds = false
+): string {
const seconds = Math.floor(ms / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
@@ -217,7 +220,9 @@ export function formatDurationCompact(ms: number): string {
const remainingSeconds = seconds % 60
return `${minutes}m ${remainingSeconds}s`
}
- return `${seconds}s`
+ return showDecimalSeconds
+ ? `${seconds}.${Math.floor((ms % 1000) / 100)}s`
+ : `${seconds}s`
}
export function formatTimeAgoCompact(ms: number): string {
diff --git a/src/server/api/models/builds.models.ts b/src/server/api/models/builds.models.ts
index 0cd3239c4..48023c77f 100644
--- a/src/server/api/models/builds.models.ts
+++ b/src/server/api/models/builds.models.ts
@@ -1,3 +1,5 @@
+import { LOG_RETENTION_MS } from '@/features/dashboard/templates/builds/constants'
+import type { components } from '@/types/infra-api.types'
import z from 'zod'
export const BuildStatusDTOSchema = z.enum(['building', 'failed', 'success'])
@@ -7,7 +9,9 @@ export type BuildStatusDB = 'waiting' | 'building' | 'uploaded' | 'failed'
export interface ListedBuildDTO {
id: string
+ // id or alias
template: string
+ templateId: string
status: BuildStatusDTO
statusMessage: string | null
createdAt: number
@@ -21,6 +25,27 @@ export interface RunningBuildStatusDTO {
statusMessage: string | null
}
+export interface BuildLogDTO {
+ timestampUnix: number
+ level: components['schemas']['LogLevel']
+ message: string
+}
+
+export interface BuildLogsDTO {
+ logs: BuildLogDTO[]
+ nextCursor: number | null
+}
+
+export interface BuildDetailsDTO {
+ // id or alias
+ template: string
+ startedAt: number
+ finishedAt: number | null
+ status: BuildStatusDTO
+ statusMessage: string | null
+ hasRetainedLogs: boolean
+}
+
// database queries
type RawListedBuildWithEnvAndAliasesDB = {
@@ -39,6 +64,10 @@ type RawListedBuildWithEnvAndAliasesDB = {
// mappings
+export function checkIfBuildStillHasLogs(createdAt: number): boolean {
+ return new Date().getTime() - createdAt < LOG_RETENTION_MS
+}
+
export function mapDatabaseBuildReasonToListedBuildDTOStatusMessage(
status: string,
reason: unknown
@@ -58,6 +87,7 @@ export function mapDatabaseBuildToListedBuildDTO(
return {
id: build.id,
template: alias ?? build.env_id,
+ templateId: build.env_id,
status: mapDatabaseBuildStatusToBuildStatusDTO(
build.status as BuildStatusDB
),
@@ -98,3 +128,18 @@ export function mapDatabaseBuildStatusToBuildStatusDTO(
return 'failed'
}
}
+
+export function mapInfraBuildStatusToBuildStatusDTO(
+ status: components['schemas']['TemplateBuild']['status']
+): BuildStatusDTO {
+ switch (status) {
+ case 'building':
+ return 'building'
+ case 'waiting':
+ return 'building'
+ case 'ready':
+ return 'success'
+ case 'error':
+ return 'failed'
+ }
+}
diff --git a/src/server/api/repositories/builds.repository.ts b/src/server/api/repositories/builds.repository.ts
index fe1d7697a..7dc0d2740 100644
--- a/src/server/api/repositories/builds.repository.ts
+++ b/src/server/api/repositories/builds.repository.ts
@@ -1,5 +1,10 @@
+import { SUPABASE_AUTH_HEADERS } from '@/configs/api'
+import { infra } from '@/lib/clients/api'
+import { l } from '@/lib/clients/logger/logger'
import { supabaseAdmin } from '@/lib/clients/supabase/admin'
+import { TRPCError } from '@trpc/server'
import z from 'zod'
+import { apiError } from '../errors'
import {
ListedBuildDTO,
mapDatabaseBuildReasonToListedBuildDTOStatusMessage,
@@ -162,7 +167,185 @@ async function getRunningStatuses(
}))
}
+// get build details
+
+export async function getBuildInfo(buildId: string, teamId: string) {
+ const { data, error } = await supabaseAdmin
+ .from('env_builds')
+ .select(
+ 'created_at, finished_at, status, reason, envs!inner(team_id, env_aliases(alias))'
+ )
+ .eq('id', buildId)
+ .eq('envs.team_id', teamId)
+ .maybeSingle()
+
+ if (error) {
+ l.error(
+ {
+ key: 'repositories:builds:get_build_info:supabase_error',
+ error: error,
+ team_id: teamId,
+ context: {
+ build_id: buildId,
+ },
+ },
+ `failed to query env_builds: ${error?.message || 'Unknown error'}`
+ )
+
+ throw new TRPCError({
+ code: 'NOT_FOUND',
+ message: "Build not found or you don't have access to it",
+ })
+ }
+
+ if (!data) {
+ throw new TRPCError({
+ code: 'NOT_FOUND',
+ message: "Build not found or you don't have access to it",
+ })
+ }
+
+ const alias = data.envs.env_aliases?.[0]?.alias
+
+ return {
+ alias,
+ createdAt: new Date(data.created_at).getTime(),
+ finishedAt: data.finished_at ? new Date(data.finished_at).getTime() : null,
+ status: mapDatabaseBuildStatusToBuildStatusDTO(
+ data.status as BuildStatusDB
+ ),
+ statusMessage: mapDatabaseBuildReasonToListedBuildDTOStatusMessage(
+ data.status,
+ data.reason
+ ),
+ }
+}
+
+// get build status (without logs)
+
+export async function getInfraBuildStatus(
+ accessToken: string,
+ teamId: string,
+ templateId: string,
+ buildId: string
+) {
+ const result = await infra.GET(
+ `/templates/{templateID}/builds/{buildID}/status`,
+ {
+ params: {
+ path: {
+ templateID: templateId,
+ buildID: buildId,
+ },
+ query: {
+ limit: 0,
+ },
+ },
+ headers: {
+ ...SUPABASE_AUTH_HEADERS(accessToken, teamId),
+ },
+ }
+ )
+
+ if (!result.response.ok || result.error) {
+ const status = result.response.status
+
+ l.error(
+ {
+ key: 'repositories:builds:get_build_status:infra_error',
+ error: result.error,
+ team_id: teamId,
+ context: {
+ status,
+ path: '/templates/{templateID}/builds/{buildID}/status',
+ },
+ },
+ `failed to fetch /templates/{templateID}/builds/{buildID}/status: ${result.error?.message || 'Unknown error'}`
+ )
+
+ if (status === 404) {
+ throw new TRPCError({
+ code: 'NOT_FOUND',
+ message: "Build not found or you don't have access to it",
+ })
+ }
+
+ throw apiError(status)
+ }
+
+ return result.data
+}
+
+// get build logs
+
+export interface GetInfraBuildLogsOptions {
+ cursor?: number
+ limit?: number
+ direction?: 'forward' | 'backward'
+ level?: 'debug' | 'info' | 'warn' | 'error'
+}
+
+export async function getInfraBuildLogs(
+ accessToken: string,
+ teamId: string,
+ templateId: string,
+ buildId: string,
+ options: GetInfraBuildLogsOptions = {}
+) {
+ const result = await infra.GET(
+ `/templates/{templateID}/builds/{buildID}/logs`,
+ {
+ params: {
+ path: {
+ templateID: templateId,
+ buildID: buildId,
+ },
+ query: {
+ cursor: options.cursor,
+ limit: options.limit,
+ direction: options.direction,
+ level: options.level,
+ },
+ },
+ headers: {
+ ...SUPABASE_AUTH_HEADERS(accessToken, teamId),
+ },
+ }
+ )
+
+ if (!result.response.ok || result.error) {
+ const status = result.response.status
+
+ l.error(
+ {
+ key: 'repositories:builds:get_build_logs:infra_error',
+ error: result.error,
+ team_id: teamId,
+ context: {
+ status,
+ path: '/templates/{templateID}/builds/{buildID}/logs',
+ },
+ },
+ `failed to fetch /templates/{templateID}/builds/{buildID}/logs: ${result.error?.message || 'Unknown error'}`
+ )
+
+ if (status === 404) {
+ throw new TRPCError({
+ code: 'NOT_FOUND',
+ message: "Build not found or you don't have access to it",
+ })
+ }
+
+ throw apiError(status)
+ }
+
+ return result.data
+}
+
export const buildsRepo = {
listBuilds,
getRunningStatuses,
+ getBuildInfo,
+ getInfraBuildStatus,
+ getInfraBuildLogs,
}
diff --git a/src/server/api/routers/builds.ts b/src/server/api/routers/builds.ts
index 5156daaad..aa2c94dcc 100644
--- a/src/server/api/routers/builds.ts
+++ b/src/server/api/routers/builds.ts
@@ -1,13 +1,12 @@
-import { SUPABASE_AUTH_HEADERS } from '@/configs/api'
-import { infra } from '@/lib/clients/api'
-import { l } from '@/lib/clients/logger/logger'
import { buildsRepo } from '@/server/api/repositories/builds.repository'
-import { TRPCError } from '@trpc/server'
import { z } from 'zod'
-import { apiError } from '../errors'
import { createTRPCRouter } from '../init'
import {
+ BuildDetailsDTO,
+ BuildLogDTO,
+ BuildLogsDTO,
BuildStatusDTOSchema,
+ checkIfBuildStillHasLogs,
mapBuildStatusDTOToDatabaseBuildStatus,
} from '../models/builds.models'
import { protectedTeamProcedure } from '../procedures'
@@ -53,7 +52,7 @@ export const buildsRouter = createTRPCRouter({
return await buildsRepo.getRunningStatuses(teamId, buildIds)
}),
- getBuildStatus: protectedTeamProcedure
+ buildDetails: protectedTeamProcedure
.input(
z.object({
templateId: z.string(),
@@ -61,53 +60,111 @@ export const buildsRouter = createTRPCRouter({
})
)
.query(async ({ ctx, input }) => {
- const { session, teamId } = ctx
- const { templateId, buildId } = input
-
- const res = await infra.GET(
- '/templates/{templateID}/builds/{buildID}/status',
- {
- params: {
- path: {
- templateID: templateId,
- buildID: buildId,
- },
- },
- headers: {
- ...SUPABASE_AUTH_HEADERS(session.access_token),
- },
- }
+ const { teamId } = ctx
+ const { buildId, templateId } = input
+
+ const buildInfo = await buildsRepo.getBuildInfo(buildId, teamId)
+
+ const result: BuildDetailsDTO = {
+ template: buildInfo.alias ?? templateId,
+ startedAt: buildInfo.createdAt,
+ finishedAt: buildInfo.finishedAt,
+ status: buildInfo.status,
+ statusMessage: buildInfo.statusMessage,
+ hasRetainedLogs: checkIfBuildStillHasLogs(buildInfo.createdAt),
+ }
+
+ return result
+ }),
+
+ buildLogsBackwards: protectedTeamProcedure
+ .input(
+ z.object({
+ templateId: z.string(),
+ buildId: z.string(),
+ cursor: z.number().optional(),
+ level: z.enum(['debug', 'info', 'warn', 'error']).optional(),
+ })
+ )
+ .query(async ({ ctx, input }) => {
+ const { teamId } = ctx
+ const { buildId, templateId, level } = input
+ let { cursor } = input
+
+ cursor ??= new Date().getTime()
+
+ const direction = 'backward'
+ const limit = 100
+
+ const buildLogs = await buildsRepo.getInfraBuildLogs(
+ ctx.session.access_token,
+ teamId,
+ templateId,
+ buildId,
+ { cursor, limit, direction, level }
+ )
+
+ const logsToReturn = buildLogs.logs
+
+ const logs: BuildLogDTO[] = logsToReturn.map((log) => ({
+ timestampUnix: new Date(log.timestamp).getTime(),
+ level: log.level,
+ message: log.message,
+ }))
+
+ const hasMore = logs.length === limit
+ const cursorLog = logs[0]
+ const nextCursor = hasMore ? (cursorLog?.timestampUnix ?? null) : null
+
+ const result: BuildLogsDTO = {
+ logs,
+ nextCursor,
+ }
+
+ return result
+ }),
+
+ buildLogsForward: protectedTeamProcedure
+ .input(
+ z.object({
+ templateId: z.string(),
+ buildId: z.string(),
+ cursor: z.number().optional(),
+ level: z.enum(['debug', 'info', 'warn', 'error']).optional(),
+ })
+ )
+ .query(async ({ ctx, input }) => {
+ const { teamId } = ctx
+ const { buildId, templateId, level } = input
+ let { cursor } = input
+
+ cursor ??= new Date().getTime()
+
+ const direction = 'forward'
+ const limit = 100
+
+ const buildLogs = await buildsRepo.getInfraBuildLogs(
+ ctx.session.access_token,
+ teamId,
+ templateId,
+ buildId,
+ { cursor, limit, direction, level }
)
- if (!res.response.ok) {
- const status = res.response.status
-
- l.error(
- {
- key: 'trpc:builds:get_build_status:infra_error',
- error: res.error,
- user_id: session.user.id,
- team_id: teamId,
- template_id: templateId,
- build_id: buildId,
- context: {
- status,
- body: await res.response.text(),
- },
- },
- `Failed to get build status: ${res.error?.message || 'Unknown error'}`
- )
-
- if (status === 404) {
- throw new TRPCError({
- code: 'NOT_FOUND',
- message: 'Build not found',
- })
- }
-
- throw apiError(status)
+ const logs: BuildLogDTO[] = buildLogs.logs.map((log) => ({
+ timestampUnix: new Date(log.timestamp).getTime(),
+ level: log.level,
+ message: log.message,
+ }))
+
+ const newestLog = logs[logs.length - 1]
+ const nextCursor = newestLog?.timestampUnix ?? null
+
+ const result: BuildLogsDTO = {
+ logs,
+ nextCursor,
}
- return res.data
+ return result
}),
})
diff --git a/src/trpc/client.tsx b/src/trpc/client.tsx
index e9417681f..40ba72c3d 100644
--- a/src/trpc/client.tsx
+++ b/src/trpc/client.tsx
@@ -3,6 +3,7 @@
import type { TRPCAppRouter } from '@/server/api/routers'
import type { QueryClient } from '@tanstack/react-query'
import { QueryClientProvider } from '@tanstack/react-query'
+import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import { createTRPCClient, httpBatchStreamLink, loggerLink } from '@trpc/client'
import { inferRouterInputs, inferRouterOutputs } from '@trpc/server'
import { createTRPCContext } from '@trpc/tanstack-react-query'
@@ -10,7 +11,8 @@ import { useState } from 'react'
import SuperJSON from 'superjson'
import { createQueryClient } from './query-client'
-export const { TRPCProvider, useTRPC } = createTRPCContext()
+export const { TRPCProvider, useTRPC, useTRPCClient } =
+ createTRPCContext()
/**
* Inference helper for inputs.
@@ -86,6 +88,7 @@ export function TRPCReactProvider(
{props.children}
+
)
}
diff --git a/src/types/api.types.ts b/src/types/api.types.ts
index d102c7f0d..9bf5cc44a 100644
--- a/src/types/api.types.ts
+++ b/src/types/api.types.ts
@@ -11,7 +11,23 @@ type SandboxesMetricsRecord =
type TeamMetric = InfraComponents['schemas']['TeamMetric']
-type Template = InfraComponents['schemas']['Template']
+type Template = Pick<
+ InfraComponents['schemas']['Template'],
+ | 'templateID'
+ | 'buildID'
+ | 'cpuCount'
+ | 'memoryMB'
+ | 'diskSizeMB'
+ | 'public'
+ | 'aliases'
+ | 'createdAt'
+ | 'updatedAt'
+ | 'createdBy'
+ | 'lastSpawnedAt'
+ | 'spawnCount'
+ | 'buildCount'
+ | 'envdVersion'
+>
type DefaultTemplate = Template & {
isDefault: true
diff --git a/src/types/infra-api.types.ts b/src/types/infra-api.types.ts
index d990bafe1..c86ee374f 100644
--- a/src/types/infra-api.types.ts
+++ b/src/types/infra-api.types.ts
@@ -256,9 +256,9 @@ export interface paths {
/** @description Filter sandboxes by one or more states */
state?: components["schemas"]["SandboxState"][];
/** @description Cursor to start the list from */
- nextToken?: string;
+ nextToken?: components["parameters"]["paginationNextToken"];
/** @description Maximum number of items to return per page */
- limit?: number;
+ limit?: components["parameters"]["paginationLimit"];
};
header?: never;
path?: never;
@@ -535,7 +535,10 @@ export interface paths {
};
get?: never;
put?: never;
- /** @description Resume the sandbox */
+ /**
+ * @deprecated
+ * @description Resume the sandbox
+ */
post: {
parameters: {
query?: never;
@@ -572,6 +575,61 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/sandboxes/{sandboxID}/connect": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /** @description Returns sandbox details. If the sandbox is paused, it will be resumed. TTL is only extended. */
+ post: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ sandboxID: components["parameters"]["sandboxID"];
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["ConnectSandbox"];
+ };
+ };
+ responses: {
+ /** @description The sandbox was already running */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Sandbox"];
+ };
+ };
+ /** @description The sandbox was resumed successfully */
+ 201: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Sandbox"];
+ };
+ };
+ 400: components["responses"]["400"];
+ 401: components["responses"]["401"];
+ 404: components["responses"]["404"];
+ 500: components["responses"]["500"];
+ };
+ };
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/sandboxes/{sandboxID}/timeout": {
parameters: {
query?: never;
@@ -666,7 +724,7 @@ export interface paths {
patch?: never;
trace?: never;
};
- "/v2/templates": {
+ "/v3/templates": {
parameters: {
query?: never;
header?: never;
@@ -676,6 +734,52 @@ export interface paths {
get?: never;
put?: never;
/** @description Create a new template */
+ post: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["TemplateBuildRequestV3"];
+ };
+ };
+ responses: {
+ /** @description The build was requested successfully */
+ 202: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["TemplateRequestResponseV3"];
+ };
+ };
+ 400: components["responses"]["400"];
+ 401: components["responses"]["401"];
+ 500: components["responses"]["500"];
+ };
+ };
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/v2/templates": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * @deprecated
+ * @description Create a new template
+ */
post: {
parameters: {
query?: never;
@@ -695,7 +799,7 @@ export interface paths {
[name: string]: unknown;
};
content: {
- "application/json": components["schemas"]["Template"];
+ "application/json": components["schemas"]["TemplateLegacy"];
};
};
400: components["responses"]["400"];
@@ -785,7 +889,10 @@ export interface paths {
};
};
put?: never;
- /** @description Create a new template */
+ /**
+ * @deprecated
+ * @description Create a new template
+ */
post: {
parameters: {
query?: never;
@@ -805,7 +912,7 @@ export interface paths {
[name: string]: unknown;
};
content: {
- "application/json": components["schemas"]["Template"];
+ "application/json": components["schemas"]["TemplateLegacy"];
};
};
400: components["responses"]["400"];
@@ -826,9 +933,41 @@ export interface paths {
path?: never;
cookie?: never;
};
- get?: never;
+ /** @description List all builds for a template */
+ get: {
+ parameters: {
+ query?: {
+ /** @description Cursor to start the list from */
+ nextToken?: components["parameters"]["paginationNextToken"];
+ /** @description Maximum number of items to return per page */
+ limit?: components["parameters"]["paginationLimit"];
+ };
+ header?: never;
+ path: {
+ templateID: components["parameters"]["templateID"];
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successfully returned the template with its builds */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["TemplateWithBuilds"];
+ };
+ };
+ 401: components["responses"]["401"];
+ 500: components["responses"]["500"];
+ };
+ };
put?: never;
- /** @description Rebuild an template */
+ /**
+ * @deprecated
+ * @description Rebuild an template
+ */
post: {
parameters: {
query?: never;
@@ -850,7 +989,7 @@ export interface paths {
[name: string]: unknown;
};
content: {
- "application/json": components["schemas"]["Template"];
+ "application/json": components["schemas"]["TemplateLegacy"];
};
};
401: components["responses"]["401"];
@@ -921,7 +1060,10 @@ export interface paths {
};
get?: never;
put?: never;
- /** @description Start the build */
+ /**
+ * @deprecated
+ * @description Start the build
+ */
post: {
parameters: {
query?: never;
@@ -1007,6 +1149,8 @@ export interface paths {
query?: {
/** @description Index of the starting build log that should be returned with the template */
logsOffset?: number;
+ /** @description Maximum number of logs that should be returned */
+ limit?: number;
level?: components["schemas"]["LogLevel"];
};
header?: never;
@@ -1024,7 +1168,58 @@ export interface paths {
[name: string]: unknown;
};
content: {
- "application/json": components["schemas"]["TemplateBuild"];
+ "application/json": components["schemas"]["TemplateBuildInfo"];
+ };
+ };
+ 401: components["responses"]["401"];
+ 404: components["responses"]["404"];
+ 500: components["responses"]["500"];
+ };
+ };
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/templates/{templateID}/builds/{buildID}/logs": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** @description Get template build logs */
+ get: {
+ parameters: {
+ query?: {
+ /** @description Starting timestamp of the logs that should be returned in milliseconds */
+ cursor?: number;
+ /** @description Maximum number of logs that should be returned */
+ limit?: number;
+ direction?: components["schemas"]["LogsDirection"];
+ level?: components["schemas"]["LogLevel"];
+ /** @description Source of the logs that should be returned from */
+ source?: components["schemas"]["LogsSource"];
+ };
+ header?: never;
+ path: {
+ templateID: components["parameters"]["templateID"];
+ buildID: components["parameters"]["buildID"];
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successfully returned the template build logs */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["TemplateBuildLogsResponse"];
};
};
401: components["responses"]["401"];
@@ -1149,6 +1344,51 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/admin/teams/{teamID}/sandboxes/kill": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * Kill all sandboxes for a team
+ * @description Kills all sandboxes for the specified team
+ */
+ post: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ /** @description Team ID */
+ teamID: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successfully killed sandboxes */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["AdminSandboxKillResult"];
+ };
+ };
+ 401: components["responses"]["401"];
+ 404: components["responses"]["404"];
+ 500: components["responses"]["500"];
+ };
+ };
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/access-tokens": {
parameters: {
query?: never;
@@ -1415,6 +1655,23 @@ export interface components {
EnvVars: {
[key: string]: string;
};
+ /** @description MCP configuration for the sandbox */
+ Mcp: {
+ [key: string]: unknown;
+ } | null;
+ SandboxNetworkConfig: {
+ /**
+ * @description Specify if the sandbox URLs should be accessible only with authentication.
+ * @default true
+ */
+ allowPublicTraffic: boolean;
+ /** @description List of allowed CIDR blocks or IP addresses for egress traffic. Allowed addresses always take precedence over blocked addresses. */
+ allowOut?: string[];
+ /** @description List of denied CIDR blocks or IP addresses for egress traffic */
+ denyOut?: string[];
+ /** @description Specify host mask which will be used for all sandbox requests */
+ maskRequestHost?: string;
+ };
/** @description Log entry with timestamp and line */
SandboxLog: {
/**
@@ -1503,6 +1760,8 @@ export interface components {
envdVersion: components["schemas"]["EnvdVersion"];
/** @description Access token used for envd communication */
envdAccessToken?: string;
+ /** @description Token required for accessing sandbox via proxy. */
+ trafficAccessToken?: string | null;
/** @description Base domain where the sandbox traffic is accessible */
domain?: string | null;
};
@@ -1589,10 +1848,12 @@ export interface components {
autoPause: boolean;
/** @description Secure all system communication with sandbox */
secure?: boolean;
- /** @description Allow sandbox to access the internet */
+ /** @description Allow sandbox to access the internet. When set to false, it behaves the same as specifying denyOut to 0.0.0.0/0 in the network config. */
allow_internet_access?: boolean;
+ network?: components["schemas"]["SandboxNetworkConfig"];
metadata?: components["schemas"]["SandboxMetadata"];
envVars?: components["schemas"]["EnvVars"];
+ mcp?: components["schemas"]["Mcp"];
};
ResumedSandbox: {
/**
@@ -1607,6 +1868,13 @@ export interface components {
*/
autoPause?: boolean;
};
+ ConnectSandbox: {
+ /**
+ * Format: int32
+ * @description Timeout in seconds from the current time after which the sandbox should expire
+ */
+ timeout: number;
+ };
/** @description Team metric with timestamp */
TeamMetric: {
/**
@@ -1647,6 +1915,12 @@ export interface components {
/** @description The maximum value of the requested metric in the given interval */
value: number;
};
+ AdminSandboxKillResult: {
+ /** @description Number of sandboxes successfully killed */
+ killedCount: number;
+ /** @description Number of sandboxes that failed to kill */
+ failedCount: number;
+ };
Template: {
/** @description Identifier of the template */
templateID: string;
@@ -1686,6 +1960,114 @@ export interface components {
*/
buildCount: number;
envdVersion: components["schemas"]["EnvdVersion"];
+ buildStatus: components["schemas"]["TemplateBuildStatus"];
+ };
+ TemplateRequestResponseV3: {
+ /** @description Identifier of the template */
+ templateID: string;
+ /** @description Identifier of the last successful build for given template */
+ buildID: string;
+ /** @description Whether the template is public or only accessible by the team */
+ public: boolean;
+ /** @description Aliases of the template */
+ aliases: string[];
+ };
+ TemplateLegacy: {
+ /** @description Identifier of the template */
+ templateID: string;
+ /** @description Identifier of the last successful build for given template */
+ buildID: string;
+ cpuCount: components["schemas"]["CPUCount"];
+ memoryMB: components["schemas"]["MemoryMB"];
+ diskSizeMB: components["schemas"]["DiskSizeMB"];
+ /** @description Whether the template is public or only accessible by the team */
+ public: boolean;
+ /** @description Aliases of the template */
+ aliases: 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;
+ createdBy: components["schemas"]["TeamUser"] | null;
+ /**
+ * 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;
+ envdVersion: components["schemas"]["EnvdVersion"];
+ };
+ TemplateBuild: {
+ /**
+ * Format: uuid
+ * @description Identifier of the build
+ */
+ buildID: string;
+ status: components["schemas"]["TemplateBuildStatus"];
+ /**
+ * Format: date-time
+ * @description Time when the build was created
+ */
+ createdAt: string;
+ /**
+ * Format: date-time
+ * @description Time when the build was last updated
+ */
+ updatedAt: string;
+ /**
+ * Format: date-time
+ * @description Time when the build was finished
+ */
+ finishedAt?: string;
+ cpuCount: components["schemas"]["CPUCount"];
+ memoryMB: components["schemas"]["MemoryMB"];
+ diskSizeMB?: components["schemas"]["DiskSizeMB"];
+ envdVersion?: components["schemas"]["EnvdVersion"];
+ };
+ TemplateWithBuilds: {
+ /** @description Identifier of the template */
+ templateID: string;
+ /** @description Whether the template is public or only accessible by the team */
+ public: boolean;
+ /** @description Aliases of the template */
+ aliases: 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;
+ /** @description List of builds for the template */
+ builds: components["schemas"]["TemplateBuild"][];
};
TemplateBuildRequest: {
/** @description Alias of the template */
@@ -1718,6 +2100,14 @@ export interface components {
*/
force: boolean;
};
+ TemplateBuildRequestV3: {
+ /** @description Alias of the template */
+ alias: string;
+ /** @description Identifier of the team */
+ teamID?: string;
+ cpuCount?: components["schemas"]["CPUCount"];
+ memoryMB?: components["schemas"]["MemoryMB"];
+ };
TemplateBuildRequestV2: {
/** @description Alias of the template */
alias: string;
@@ -1801,14 +2191,26 @@ export interface components {
/** @description Log message content */
message: string;
level: components["schemas"]["LogLevel"];
+ /** @description Step in the build process related to the log entry */
+ step?: string;
};
BuildStatusReason: {
/** @description Message with the status reason, currently reporting only for error status */
message: string;
/** @description Step that failed */
step?: string;
+ /**
+ * @description Log entries related to the status reason
+ * @default []
+ */
+ logEntries: components["schemas"]["BuildLogEntry"][];
};
- TemplateBuild: {
+ /**
+ * @description Status of the template build
+ * @enum {string}
+ */
+ TemplateBuildStatus: "building" | "waiting" | "ready" | "error";
+ TemplateBuildInfo: {
/**
* @description Build logs
* @default []
@@ -1823,13 +2225,26 @@ export interface components {
templateID: string;
/** @description Identifier of the build */
buildID: string;
+ status: components["schemas"]["TemplateBuildStatus"];
+ reason?: components["schemas"]["BuildStatusReason"];
+ };
+ TemplateBuildLogsResponse: {
/**
- * @description Status of the template
- * @enum {string}
+ * @description Build logs structured
+ * @default []
*/
- status: "building" | "waiting" | "ready" | "error";
- reason?: components["schemas"]["BuildStatusReason"];
+ logs: components["schemas"]["BuildLogEntry"][];
};
+ /**
+ * @description Direction of the logs that should be returned
+ * @enum {string}
+ */
+ LogsDirection: "forward" | "backward";
+ /**
+ * @description Source of the logs that should be returned
+ * @enum {string}
+ */
+ LogsSource: "temporary" | "persistent";
/**
* @description Status of the node
* @enum {string}
@@ -1896,6 +2311,16 @@ export interface components {
/** @description Detailed metrics for each disk/mount point */
disks: components["schemas"]["DiskMetrics"][];
};
+ MachineInfo: {
+ /** @description CPU family of the node */
+ cpuFamily: string;
+ /** @description CPU model of the node */
+ cpuModel: string;
+ /** @description CPU model name of the node */
+ cpuModelName: string;
+ /** @description CPU architecture of the node */
+ cpuArchitecture: string;
+ };
Node: {
/** @description Version of the orchestrator */
version: string;
@@ -1912,6 +2337,7 @@ export interface components {
serviceInstanceID: string;
/** @description Identifier of the cluster */
clusterID: string;
+ machineInfo: components["schemas"]["MachineInfo"];
status: components["schemas"]["NodeStatus"];
/**
* Format: uint32
@@ -1951,6 +2377,7 @@ export interface components {
* @description Identifier of the nomad node
*/
nodeID: string;
+ machineInfo: components["schemas"]["MachineInfo"];
status: components["schemas"]["NodeStatus"];
/** @description List of sandboxes running on the node */
sandboxes: components["schemas"]["ListedSandbox"][];
@@ -2125,6 +2552,10 @@ export interface components {
nodeID: string;
apiKeyID: string;
accessTokenID: string;
+ /** @description Maximum number of items to return per page */
+ paginationLimit: number;
+ /** @description Cursor to start the list from */
+ paginationNextToken: string;
};
requestBodies: never;
headers: never;
diff --git a/src/ui/copy-button-inline.tsx b/src/ui/copy-button-inline.tsx
new file mode 100644
index 000000000..f11499901
--- /dev/null
+++ b/src/ui/copy-button-inline.tsx
@@ -0,0 +1,43 @@
+import { useClipboard } from '@/lib/hooks/use-clipboard'
+import { cn } from '@/lib/utils/ui'
+import { useRef, useState } from 'react'
+
+export default function CopyButtonInline({
+ value,
+ children,
+ className,
+}: {
+ value: string
+ children: React.ReactNode
+ className?: string
+}) {
+ const [wasCopied, copy] = useClipboard()
+ const buttonRef = useRef(null)
+ const [capturedWidth, setCapturedWidth] = useState(null)
+
+ const handleClick = (e: React.MouseEvent) => {
+ e.stopPropagation()
+ if (buttonRef.current && !wasCopied) {
+ setCapturedWidth(buttonRef.current.offsetWidth)
+ }
+ copy(value)
+ }
+
+ return (
+
+ {wasCopied ? 'Copied!' : children}
+
+ )
+}
diff --git a/src/ui/primitives/alert.tsx b/src/ui/primitives/alert.tsx
index 4e38b3b55..9d7435e7a 100644
--- a/src/ui/primitives/alert.tsx
+++ b/src/ui/primitives/alert.tsx
@@ -49,7 +49,7 @@ const AlertTitle = React.forwardRef<
>(({ className, ...props }, ref) => (
))
@@ -61,7 +61,7 @@ const AlertDescription = React.forwardRef<
>(({ className, ...props }, ref) => (
))