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 ? ( + + ) : ( +