From 9d02566cd18934569c3658c393c9ec7f97bc3950 Mon Sep 17 00:00:00 2001 From: Arjun Komath Date: Sun, 28 Jun 2026 14:20:10 +1000 Subject: [PATCH 1/7] Add project settings loading skeleton Amp-Thread-ID: https://ampcode.com/threads/T-019f0c6e-2029-759d-bc3e-754ad64c9143 Co-authored-by: Amp --- .../projects/[slug]/settings/loading.tsx | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 web/app/(dashboard)/dashboard/projects/[slug]/settings/loading.tsx diff --git a/web/app/(dashboard)/dashboard/projects/[slug]/settings/loading.tsx b/web/app/(dashboard)/dashboard/projects/[slug]/settings/loading.tsx new file mode 100644 index 00000000..99ead94a --- /dev/null +++ b/web/app/(dashboard)/dashboard/projects/[slug]/settings/loading.tsx @@ -0,0 +1,67 @@ +import { SetBreadcrumbs } from "@/components/core/breadcrumb-data"; +import { Skeleton } from "@/components/ui/skeleton"; + +export default function Loading() { + return ( + <> + + +
+ Loading project settings +
+ + ); +} From 15af7107ec8c1da24622d72f2596edd2d5d932fc Mon Sep 17 00:00:00 2001 From: Arjun Komath Date: Sun, 28 Jun 2026 14:27:45 +1000 Subject: [PATCH 2/7] Report reconcile deployment errors to rollout logs Amp-Thread-ID: https://ampcode.com/threads/T-019f0c3d-c017-70ca-b37b-766c1970c5a9 Co-authored-by: Amp --- agent/internal/agent/agent.go | 2 + agent/internal/agent/drift.go | 5 +- agent/internal/agent/reporting.go | 35 +++++++++++++ agent/internal/agent/run.go | 2 + agent/internal/http/client.go | 24 +++++---- web/lib/agent-status.ts | 77 +++++++++++++++++++++++++++++ web/lib/work-queue.ts | 81 +------------------------------ 7 files changed, 136 insertions(+), 90 deletions(-) diff --git a/agent/internal/agent/agent.go b/agent/internal/agent/agent.go index 59458e07..5461c72d 100644 --- a/agent/internal/agent/agent.go +++ b/agent/internal/agent/agent.go @@ -59,6 +59,8 @@ type Agent struct { workMutex sync.Mutex activeWorkItem *agenthttp.WorkQueueItem pendingWorkResults []agenthttp.CompletedWorkItem + deploymentErrorMutex sync.Mutex + pendingDeploymentErrors []agenthttp.DeploymentError Client *agenthttp.Client Reconciler *reconcile.Reconciler Config *Config diff --git a/agent/internal/agent/drift.go b/agent/internal/agent/drift.go index 6a192150..4ab0fc78 100644 --- a/agent/internal/agent/drift.go +++ b/agent/internal/agent/drift.go @@ -148,8 +148,11 @@ func (a *Agent) handleProcessing() { return } - if err := a.applyReconcileAction(actions[0]); err != nil { + action := actions[0] + if err := a.applyReconcileAction(action); err != nil { log.Printf("[processing] reconciliation failed: %v, transitioning to IDLE", err) + a.RecordDeploymentError(action.DeploymentID, err) + a.RequestStatusReport("reconcile failed") a.transitionToIdle() return } diff --git a/agent/internal/agent/reporting.go b/agent/internal/agent/reporting.go index 16b23a3e..e7459e0d 100644 --- a/agent/internal/agent/reporting.go +++ b/agent/internal/agent/reporting.go @@ -90,9 +90,44 @@ func (a *Agent) BuildStatusReport(includeResources bool) *agenthttp.StatusReport } } + report.DeploymentErrors = a.SnapshotDeploymentErrors() + return report } +func (a *Agent) RecordDeploymentError(deploymentID string, err error) { + if deploymentID == "" || err == nil { + return + } + + a.deploymentErrorMutex.Lock() + defer a.deploymentErrorMutex.Unlock() + + a.pendingDeploymentErrors = append(a.pendingDeploymentErrors, agenthttp.DeploymentError{ + DeploymentID: deploymentID, + Message: err.Error(), + }) +} + +func (a *Agent) SnapshotDeploymentErrors() []agenthttp.DeploymentError { + a.deploymentErrorMutex.Lock() + defer a.deploymentErrorMutex.Unlock() + + return append([]agenthttp.DeploymentError(nil), a.pendingDeploymentErrors...) +} + +func (a *Agent) ClearReportedDeploymentErrors(count int) { + a.deploymentErrorMutex.Lock() + defer a.deploymentErrorMutex.Unlock() + + if count >= len(a.pendingDeploymentErrors) { + a.pendingDeploymentErrors = nil + return + } + + a.pendingDeploymentErrors = a.pendingDeploymentErrors[count:] +} + func (a *Agent) CollectLogs() { if a.LogCollector == nil { return diff --git a/agent/internal/agent/run.go b/agent/internal/agent/run.go index 0447552b..cbeb2d62 100644 --- a/agent/internal/agent/run.go +++ b/agent/internal/agent/run.go @@ -107,12 +107,14 @@ func (a *Agent) RequestStatusReport(reason string) { func (a *Agent) reportStatus(reason string) { report := a.BuildStatusReport(true) + reportedDeploymentErrorCount := len(report.DeploymentErrors) completed, active := a.SnapshotWorkStatus() response, err := a.Client.ReportStatus(report, completed, active) if err != nil { log.Printf("[status] failed to report (%s): %v", reason, err) return } + a.ClearReportedDeploymentErrors(reportedDeploymentErrorCount) a.AcknowledgeWorkResults(response.AcceptedWorkItemResults, response.RejectedWorkItemResults) a.LogRejectedActiveWorkItems(response.RejectedActiveWorkItems) a.AcceptLeasedWorkItems(response.WorkItems) diff --git a/agent/internal/http/client.go b/agent/internal/http/client.go index 28069496..7189f791 100644 --- a/agent/internal/http/client.go +++ b/agent/internal/http/client.go @@ -233,6 +233,11 @@ type ContainerStatus struct { HealthStatus string `json:"healthStatus"` } +type DeploymentError struct { + DeploymentID string `json:"deploymentId"` + Message string `json:"message"` +} + type Resources struct { CpuCores int `json:"cpuCores"` MemoryMb int `json:"memoryMb"` @@ -245,15 +250,16 @@ type AgentHealth struct { } type StatusReport struct { - Resources *Resources `json:"resources,omitempty"` - PublicIP string `json:"publicIp,omitempty"` - PrivateIP string `json:"privateIp,omitempty"` - Meta map[string]string `json:"meta,omitempty"` - Containers []ContainerStatus `json:"containers"` - DnsInSync bool `json:"dnsInSync,omitempty"` - NetworkHealth *health.NetworkHealth `json:"networkHealth,omitempty"` - ContainerHealth *health.ContainerHealth `json:"containerHealth,omitempty"` - AgentHealth *AgentHealth `json:"agentHealth,omitempty"` + Resources *Resources `json:"resources,omitempty"` + PublicIP string `json:"publicIp,omitempty"` + PrivateIP string `json:"privateIp,omitempty"` + Meta map[string]string `json:"meta,omitempty"` + Containers []ContainerStatus `json:"containers"` + DeploymentErrors []DeploymentError `json:"deploymentErrors,omitempty"` + DnsInSync bool `json:"dnsInSync,omitempty"` + NetworkHealth *health.NetworkHealth `json:"networkHealth,omitempty"` + ContainerHealth *health.ContainerHealth `json:"containerHealth,omitempty"` + AgentHealth *AgentHealth `json:"agentHealth,omitempty"` } type CompletedWorkItem struct { diff --git a/web/lib/agent-status.ts b/web/lib/agent-status.ts index c21ca331..b1fadcf1 100644 --- a/web/lib/agent-status.ts +++ b/web/lib/agent-status.ts @@ -30,6 +30,11 @@ type ContainerStatus = { healthStatus: "none" | "starting" | "healthy" | "unhealthy"; }; +type DeploymentError = { + deploymentId: string; + message: string; +}; + function isMigrationTargetStarting(status: string | null | undefined) { return status === "deploying_target" || status === "starting"; } @@ -61,6 +66,75 @@ async function completeTargetMigration(serviceId: string) { ); } +async function applyDeploymentErrors( + serverId: string, + errors: DeploymentError[], +) { + for (const error of errors) { + if (!error.deploymentId || !error.message?.trim()) continue; + + const deployment = await db + .select({ + id: deployments.id, + serviceId: deployments.serviceId, + rolloutId: deployments.rolloutId, + rolloutStatus: rollouts.status, + serverName: servers.name, + }) + .from(deployments) + .innerJoin(servers, eq(deployments.serverId, servers.id)) + .innerJoin(rollouts, eq(deployments.rolloutId, rollouts.id)) + .where( + and( + eq(deployments.id, error.deploymentId), + eq(deployments.serverId, serverId), + ), + ) + .then((rows) => rows[0]); + + if ( + !deployment || + !deployment.rolloutId || + deployment.rolloutStatus !== "in_progress" + ) { + continue; + } + + const updated = await db + .update(deployments) + .set({ status: "failed", failedStage: "deploying" }) + .where( + and( + eq(deployments.id, deployment.id), + inArray(deployments.status, ["pending", "pulling", "starting"]), + ), + ) + .returning({ id: deployments.id }); + + if (updated.length === 0) continue; + + await ingestRolloutLog( + deployment.rolloutId, + deployment.serviceId, + "deploying", + `Deployment failed on server ${deployment.serverName}: ${formatDeploymentError(error.message)}`, + ); + + await inngest.send( + inngestEvents.resourceStatusChanged.create({ + type: "deployment", + id: deployment.id, + parentType: "rollout", + parentId: deployment.rolloutId, + }), + ); + } +} + +function formatDeploymentError(message: string): string { + return message.trim().replace(/\s+/g, " ").slice(0, 1000); +} + export type StatusReport = { resources?: { cpuCores: number; @@ -75,6 +149,7 @@ export type StatusReport = { networkHealth?: NetworkHealth; containerHealth?: ContainerHealth; agentHealth?: AgentHealth; + deploymentErrors?: DeploymentError[]; }; export async function applyStatusReport( @@ -126,6 +201,8 @@ export async function applyStatusReport( return serverLogName; }; + await applyDeploymentErrors(serverId, report.deploymentErrors || []); + const reportedDeploymentIds = report.containers .map((c) => c.deploymentId) .filter((id) => id !== ""); diff --git a/web/lib/work-queue.ts b/web/lib/work-queue.ts index 90f2cd45..c6bb674a 100644 --- a/web/lib/work-queue.ts +++ b/web/lib/work-queue.ts @@ -1,11 +1,10 @@ import { randomUUID } from "node:crypto"; import { and, eq, sql } from "drizzle-orm"; import { db } from "@/db"; -import { deployments, rollouts, servers, workQueue } from "@/db/schema"; +import { deployments, workQueue } from "@/db/schema"; import type { WorkQueue } from "@/db/types"; import { inngest } from "@/lib/inngest/client"; import { inngestEvents } from "@/lib/inngest/events"; -import { ingestRolloutLog } from "@/lib/victoria-logs"; export const WORK_QUEUE_MAX_ATTEMPTS = 3; export const WORK_QUEUE_LEASE_DURATION_MS = 2 * 60 * 1000; @@ -201,11 +200,6 @@ async function runWorkItemCompletionSideEffects( item: WorkQueue, result: WorkItemResult, ): Promise { - if ((item.type === "deploy" || item.type === "reconcile") && item.payload) { - await runDeploymentWorkCompletionSideEffects(item, result); - return; - } - if (item.type === "force_cleanup" && item.payload) { await runForceCleanupCompletionSideEffects(item, result); return; @@ -246,79 +240,6 @@ async function runWorkItemCompletionSideEffects( } } -async function runDeploymentWorkCompletionSideEffects( - item: WorkQueue, - result: WorkItemResult, -): Promise { - if (result.status !== "failed") return; - - try { - const payload = JSON.parse(item.payload) as { - deploymentId?: string; - }; - - if (!payload.deploymentId) return; - - const deployment = await db - .select({ - id: deployments.id, - serviceId: deployments.serviceId, - rolloutId: deployments.rolloutId, - rolloutStatus: rollouts.status, - serverName: servers.name, - }) - .from(deployments) - .innerJoin(servers, eq(deployments.serverId, servers.id)) - .innerJoin(rollouts, eq(deployments.rolloutId, rollouts.id)) - .where(eq(deployments.id, payload.deploymentId)) - .then((rows) => rows[0]); - - if ( - !deployment || - !deployment.rolloutId || - deployment.rolloutStatus !== "in_progress" - ) { - return; - } - - await db - .update(deployments) - .set({ - status: "failed", - failedStage: "deploying", - }) - .where(eq(deployments.id, deployment.id)); - - await ingestRolloutLog( - deployment.rolloutId, - deployment.serviceId, - "deploying", - `Deployment failed on server ${deployment.serverName}: ${formatWorkItemError(result.error)}`, - ); - - await inngest.send( - inngestEvents.resourceStatusChanged.create({ - type: "deployment", - id: deployment.id, - parentType: "rollout", - parentId: deployment.rolloutId, - }), - ); - } catch (error) { - console.error( - "[work-queue] failed to run deployment completion side effects:", - error, - ); - } -} - -function formatWorkItemError(error: string | undefined): string { - const fallback = "Agent failed to apply the deployment"; - if (!error?.trim()) return fallback; - - return error.trim().replace(/\s+/g, " ").slice(0, 1000); -} - async function runForceCleanupCompletionSideEffects( item: WorkQueue, result: WorkItemResult, From dd9deb09049470a6241e2007780da108917df254 Mon Sep 17 00:00:00 2001 From: Arjun Komath Date: Sun, 28 Jun 2026 20:11:38 +1000 Subject: [PATCH 3/7] Place new services at top center of canvas Amp-Thread-ID: https://ampcode.com/threads/T-019f0c3d-c017-70ca-b37b-766c1970c5a9 Co-authored-by: Amp --- web/actions/projects.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/web/actions/projects.ts b/web/actions/projects.ts index 00cdfd7f..cc9f0fc0 100644 --- a/web/actions/projects.ts +++ b/web/actions/projects.ts @@ -422,6 +422,14 @@ type CreateServiceInput = { }; }; +const SERVICE_CANVAS_WIDTH = 1320; +const SERVICE_CARD_WIDTH = 320; + +const NEW_SERVICE_CANVAS_POSITION = { + canvasX: (SERVICE_CANVAS_WIDTH - SERVICE_CARD_WIDTH) / 2, + canvasY: 0, +}; + export async function createService(input: CreateServiceInput) { await requireAuth(); const { projectId, environmentId, name, image, github } = input; @@ -472,6 +480,8 @@ export async function createService(input: CreateServiceInput) { stateful: false, resourceCpuLimit: resourceLimits.cpuCores, resourceMemoryLimitMb: resourceLimits.memoryMb, + canvasX: NEW_SERVICE_CANVAS_POSITION.canvasX, + canvasY: NEW_SERVICE_CANVAS_POSITION.canvasY, }); if (github?.installationId && github?.repoId) { From 89779d255643308eeafa8535dce6a4cabe6d0dbe Mon Sep 17 00:00:00 2001 From: Arjun Komath Date: Sun, 28 Jun 2026 20:16:57 +1000 Subject: [PATCH 4/7] Auto-select replica server for new services Amp-Thread-ID: https://ampcode.com/threads/T-019f0c3d-c017-70ca-b37b-766c1970c5a9 Co-authored-by: Amp --- web/actions/projects.ts | 78 ++++++++++++++++++++++++++--------------- 1 file changed, 49 insertions(+), 29 deletions(-) diff --git a/web/actions/projects.ts b/web/actions/projects.ts index 00cdfd7f..c13df264 100644 --- a/web/actions/projects.ts +++ b/web/actions/projects.ts @@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto"; import cronstrue from "cronstrue"; -import { and, desc, eq, inArray } from "drizzle-orm"; +import { and, desc, eq, inArray, isNotNull } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { ZodError, z } from "zod"; import { db } from "@/db"; @@ -457,36 +457,56 @@ export async function createService(input: CreateServiceInput) { githubRootDir = github.rootDir?.trim() || null; } - await db.insert(services).values({ - id, - projectId, - environmentId, - name, - hostname, - image: finalImage, - sourceType, - githubRepoUrl, - githubBranch, - githubRootDir, - replicas: 1, - stateful: false, - resourceCpuLimit: resourceLimits.cpuCores, - resourceMemoryLimitMb: resourceLimits.memoryMb, - }); + const availableServers = await db + .select({ id: servers.id }) + .from(servers) + .where(and(eq(servers.status, "online"), isNotNull(servers.wireguardIp))); + const selectedServer = + availableServers.length > 0 + ? availableServers[Math.floor(Math.random() * availableServers.length)] + : null; - if (github?.installationId && github?.repoId) { - const repoFullName = github.repoUrl.replace("https://github.com/", ""); - await db.insert(githubRepos).values({ - id: randomUUID(), - installationId: github.installationId, - repoId: github.repoId, - repoFullName, - defaultBranch: github.branch || "main", - serviceId: id, - deployBranch: github.branch || "main", - autoDeploy: true, + await db.transaction(async (tx) => { + await tx.insert(services).values({ + id, + projectId, + environmentId, + name, + hostname, + image: finalImage, + sourceType, + githubRepoUrl, + githubBranch, + githubRootDir, + replicas: 1, + stateful: false, + resourceCpuLimit: resourceLimits.cpuCores, + resourceMemoryLimitMb: resourceLimits.memoryMb, }); - } + + if (selectedServer) { + await tx.insert(serviceReplicas).values({ + id: randomUUID(), + serviceId: id, + serverId: selectedServer.id, + count: 1, + }); + } + + if (github?.installationId && github?.repoId) { + const repoFullName = github.repoUrl.replace("https://github.com/", ""); + await tx.insert(githubRepos).values({ + id: randomUUID(), + installationId: github.installationId, + repoId: github.repoId, + repoFullName, + defaultBranch: github.branch || "main", + serviceId: id, + deployBranch: github.branch || "main", + autoDeploy: true, + }); + } + }); return { id, name, image: finalImage, sourceType }; } From 1df7503259eb78479072b4964c7e0933c631d4ab Mon Sep 17 00:00:00 2001 From: Arjun Komath Date: Sat, 27 Jun 2026 10:18:37 +1000 Subject: [PATCH 5/7] Add control plane self update flow Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-019f0429-c6b7-711e-9d05-1ee6dce1919a --- .github/workflows/release.yml | 30 +- deployment/.env.example | 6 + deployment/compose.postgres.yml | 28 +- deployment/compose.production.yml | 28 +- deployment/install.sh | 4 + deployment/updater/Dockerfile | 16 + deployment/updater/go.mod | 3 + deployment/updater/main.go | 494 ++++++++++++++++++ docs/installation.mdx | 30 +- web/.env.example | 7 + web/actions/settings.ts | 26 + .../(dashboard)/dashboard/settings/page.tsx | 8 +- web/components/settings/global-settings.tsx | 288 +++++++++- web/db/queries.ts | 10 + web/lib/control-plane-updates.ts | 249 +++++++++ web/lib/inngest/functions/crons.ts | 17 +- web/lib/inngest/functions/index.ts | 1 + web/lib/settings-keys.ts | 3 + 18 files changed, 1234 insertions(+), 14 deletions(-) create mode 100644 deployment/updater/Dockerfile create mode 100644 deployment/updater/go.mod create mode 100644 deployment/updater/main.go create mode 100644 web/lib/control-plane-updates.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bf208de2..df91d225 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -47,7 +47,7 @@ jobs: retention-days: 1 release: - needs: agent + needs: [agent, web, registry, updater] runs-on: ubuntu-latest steps: - name: Download agent artifacts @@ -129,3 +129,31 @@ jobs: platforms: linux/amd64,linux/arm64 cache-from: type=gha,scope=registry cache-to: type=gha,mode=max,scope=registry + + updater: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Login to GHCR + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v7 + with: + context: deployment/updater + push: true + tags: | + ghcr.io/${{ github.repository }}/updater:${{ github.ref_name }} + ghcr.io/${{ github.repository }}/updater:tip + platforms: linux/amd64,linux/arm64 + cache-from: type=gha,scope=updater + cache-to: type=gha,mode=max,scope=updater diff --git a/deployment/.env.example b/deployment/.env.example index 94250249..77934985 100644 --- a/deployment/.env.example +++ b/deployment/.env.example @@ -32,6 +32,12 @@ REGISTRY_HTTP_SECRET=your-registry-http-secret INNGEST_SIGNING_KEY=signkey-xxx INNGEST_EVENT_KEY=xxx +# Control plane deployment +# Use compose.production.yml for external PostgreSQL. +COMPOSE_FILE=compose.production.yml +TECHULUS_CLOUD_VERSION=v0.0.0 +CONTROL_PLANE_UPDATER_TOKEN=generate-with-openssl-rand-hex-32 + # GitHub App Integration (optional) GITHUB_APP_ID= GITHUB_APP_PRIVATE_KEY= diff --git a/deployment/compose.postgres.yml b/deployment/compose.postgres.yml index c23e556e..c6e1d3a0 100644 --- a/deployment/compose.postgres.yml +++ b/deployment/compose.postgres.yml @@ -70,10 +70,11 @@ services: restart: unless-stopped migrate: - image: ghcr.io/techulus/cloud/web:tip + image: ghcr.io/techulus/cloud/web:${TECHULUS_CLOUD_VERSION:-tip} env_file: - ./.env environment: + - TECHULUS_CLOUD_VERSION=${TECHULUS_CLOUD_VERSION:-tip} - DATABASE_URL=${DATABASE_URL} - BETTER_AUTH_URL=https://${ROOT_DOMAIN} - APP_URL=https://${ROOT_DOMAIN} @@ -86,6 +87,8 @@ services: - INNGEST_BASE_URL=http://inngest:8288 - INNGEST_SIGNING_KEY=${INNGEST_SIGNING_KEY} - INNGEST_EVENT_KEY=${INNGEST_EVENT_KEY} + - CONTROL_PLANE_UPDATER_URL=http://control-plane-updater:8080 + - CONTROL_PLANE_UPDATER_TOKEN=${CONTROL_PLANE_UPDATER_TOKEN} - ALLOW_SIGNUP=${ALLOW_SIGNUP:-false} depends_on: postgres: @@ -94,11 +97,12 @@ services: restart: on-failure web: - image: ghcr.io/techulus/cloud/web:tip + image: ghcr.io/techulus/cloud/web:${TECHULUS_CLOUD_VERSION:-tip} scale: ${WEB_REPLICAS:-1} env_file: - ./.env environment: + - TECHULUS_CLOUD_VERSION=${TECHULUS_CLOUD_VERSION:-tip} - DATABASE_URL=${DATABASE_URL} - BETTER_AUTH_URL=https://${ROOT_DOMAIN} - APP_URL=https://${ROOT_DOMAIN} @@ -111,6 +115,8 @@ services: - INNGEST_BASE_URL=http://inngest:8288 - INNGEST_SIGNING_KEY=${INNGEST_SIGNING_KEY} - INNGEST_EVENT_KEY=${INNGEST_EVENT_KEY} + - CONTROL_PLANE_UPDATER_URL=http://control-plane-updater:8080 + - CONTROL_PLANE_UPDATER_TOKEN=${CONTROL_PLANE_UPDATER_TOKEN} - ALLOW_SIGNUP=${ALLOW_SIGNUP:-false} depends_on: migrate: @@ -141,8 +147,24 @@ services: start_period: 30s restart: unless-stopped + control-plane-updater: + image: ghcr.io/techulus/cloud/updater:${TECHULUS_CLOUD_VERSION:-tip} + environment: + - DEPLOY_DIR=/opt/techulus-cloud + - CONTROL_PLANE_UPDATER_TOKEN=${CONTROL_PLANE_UPDATER_TOKEN} + - WEB_HEALTH_URL=http://web:3000/api/health + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - /opt/techulus-cloud:/opt/techulus-cloud + healthcheck: + test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8080/health"] + interval: 30s + timeout: 5s + retries: 3 + restart: unless-stopped + registry: - image: ghcr.io/techulus/cloud/registry:tip + image: ghcr.io/techulus/cloud/registry:${TECHULUS_CLOUD_VERSION:-tip} env_file: - ./.env volumes: diff --git a/deployment/compose.production.yml b/deployment/compose.production.yml index 5ec51933..90600300 100644 --- a/deployment/compose.production.yml +++ b/deployment/compose.production.yml @@ -52,10 +52,11 @@ services: restart: unless-stopped migrate: - image: ghcr.io/techulus/cloud/web:tip + image: ghcr.io/techulus/cloud/web:${TECHULUS_CLOUD_VERSION:-tip} env_file: - ./.env environment: + - TECHULUS_CLOUD_VERSION=${TECHULUS_CLOUD_VERSION:-tip} - DATABASE_URL=${DATABASE_URL} - BETTER_AUTH_URL=https://${ROOT_DOMAIN} - APP_URL=https://${ROOT_DOMAIN} @@ -68,16 +69,19 @@ services: - INNGEST_BASE_URL=http://inngest:8288 - INNGEST_SIGNING_KEY=${INNGEST_SIGNING_KEY} - INNGEST_EVENT_KEY=${INNGEST_EVENT_KEY} + - CONTROL_PLANE_UPDATER_URL=http://control-plane-updater:8080 + - CONTROL_PLANE_UPDATER_TOKEN=${CONTROL_PLANE_UPDATER_TOKEN} - ALLOW_SIGNUP=${ALLOW_SIGNUP:-false} command: ["npx", "drizzle-kit", "push", "--force"] restart: on-failure web: - image: ghcr.io/techulus/cloud/web:tip + image: ghcr.io/techulus/cloud/web:${TECHULUS_CLOUD_VERSION:-tip} scale: ${WEB_REPLICAS:-1} env_file: - ./.env environment: + - TECHULUS_CLOUD_VERSION=${TECHULUS_CLOUD_VERSION:-tip} - DATABASE_URL=${DATABASE_URL} - BETTER_AUTH_URL=https://${ROOT_DOMAIN} - APP_URL=https://${ROOT_DOMAIN} @@ -90,6 +94,8 @@ services: - INNGEST_BASE_URL=http://inngest:8288 - INNGEST_SIGNING_KEY=${INNGEST_SIGNING_KEY} - INNGEST_EVENT_KEY=${INNGEST_EVENT_KEY} + - CONTROL_PLANE_UPDATER_URL=http://control-plane-updater:8080 + - CONTROL_PLANE_UPDATER_TOKEN=${CONTROL_PLANE_UPDATER_TOKEN} - ALLOW_SIGNUP=${ALLOW_SIGNUP:-false} depends_on: migrate: @@ -120,8 +126,24 @@ services: start_period: 30s restart: unless-stopped + control-plane-updater: + image: ghcr.io/techulus/cloud/updater:${TECHULUS_CLOUD_VERSION:-tip} + environment: + - DEPLOY_DIR=/opt/techulus-cloud + - CONTROL_PLANE_UPDATER_TOKEN=${CONTROL_PLANE_UPDATER_TOKEN} + - WEB_HEALTH_URL=http://web:3000/api/health + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - /opt/techulus-cloud:/opt/techulus-cloud + healthcheck: + test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8080/health"] + interval: 30s + timeout: 5s + retries: 3 + restart: unless-stopped + registry: - image: ghcr.io/techulus/cloud/registry:tip + image: ghcr.io/techulus/cloud/registry:${TECHULUS_CLOUD_VERSION:-tip} env_file: - ./.env volumes: diff --git a/deployment/install.sh b/deployment/install.sh index b4cbfce9..9be40423 100755 --- a/deployment/install.sh +++ b/deployment/install.sh @@ -325,6 +325,7 @@ configure_interactive() { REGISTRY_HTTP_SECRET="$(openssl rand -hex 32)" INNGEST_SIGNING_KEY="$(openssl rand -hex 32)" INNGEST_EVENT_KEY="$(openssl rand -hex 16)" + CONTROL_PLANE_UPDATER_TOKEN="$(openssl rand -hex 32)" if [[ "$USE_BUNDLED_PG" == "true" ]]; then COMPOSE_FILE="compose.postgres.yml" @@ -377,8 +378,11 @@ REGISTRY_HTTP_SECRET=${REGISTRY_HTTP_SECRET} INNGEST_SIGNING_KEY=${INNGEST_SIGNING_KEY} INNGEST_EVENT_KEY=${INNGEST_EVENT_KEY} +CONTROL_PLANE_UPDATER_TOKEN=${CONTROL_PLANE_UPDATER_TOKEN} + WEB_REPLICAS=1 ALLOW_SIGNUP=true +TECHULUS_CLOUD_VERSION=${TECHULUS_CLOUD_VERSION:-tip} COMPOSE_FILE=${COMPOSE_FILE} EOF diff --git a/deployment/updater/Dockerfile b/deployment/updater/Dockerfile new file mode 100644 index 00000000..1aefe27e --- /dev/null +++ b/deployment/updater/Dockerfile @@ -0,0 +1,16 @@ +FROM golang:1.25-alpine AS builder +WORKDIR /src +COPY go.mod main.go ./ +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/control-plane-updater . + +FROM docker:28-cli + +RUN apk add --no-cache bash curl docker-cli-compose postgresql-client + +WORKDIR /app +COPY --from=builder /out/control-plane-updater ./control-plane-updater + +ENV PORT=8080 +EXPOSE 8080 + +CMD ["/app/control-plane-updater"] diff --git a/deployment/updater/go.mod b/deployment/updater/go.mod new file mode 100644 index 00000000..68bb66cf --- /dev/null +++ b/deployment/updater/go.mod @@ -0,0 +1,3 @@ +module techulus/cloud-updater + +go 1.25.5 diff --git a/deployment/updater/main.go b/deployment/updater/main.go new file mode 100644 index 00000000..14657eb4 --- /dev/null +++ b/deployment/updater/main.go @@ -0,0 +1,494 @@ +package main + +import ( + "bufio" + "bytes" + "crypto/subtle" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net/http" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "sync" + "time" +) + +type updaterStatus struct { + Status string `json:"status"` + TargetVersion string `json:"targetVersion"` + StartedAt string `json:"startedAt"` + CompletedAt string `json:"completedAt"` + Error string `json:"error"` + Logs []string `json:"logs"` +} + +type server struct { + deployDir string + token string + rawBaseURL string + healthURL string + statusFile string + + mu sync.Mutex + status updaterStatus +} + +var versionPattern = regexp.MustCompile(`^v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$`) + +func main() { + deployDir := getenv("DEPLOY_DIR", "/opt/techulus-cloud") + s := &server{ + deployDir: deployDir, + token: os.Getenv("CONTROL_PLANE_UPDATER_TOKEN"), + rawBaseURL: getenv("RAW_BASE_URL", "https://raw.githubusercontent.com/techulus/cloud"), + healthURL: getenv("WEB_HEALTH_URL", "http://web:3000/api/health"), + statusFile: filepath.Join(deployDir, "updater-status.json"), + } + s.status = s.readStatus() + + mux := http.NewServeMux() + mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) + }) + mux.HandleFunc("/status", s.handleStatus) + mux.HandleFunc("/upgrade", s.handleUpgrade) + + addr := ":" + getenv("PORT", "8080") + log.Printf("control plane updater listening on %s", addr) + if err := http.ListenAndServe(addr, mux); err != nil { + log.Fatal(err) + } +} + +func getenv(key, fallback string) string { + if value := os.Getenv(key); value != "" { + return value + } + return fallback +} + +func (s *server) readStatus() updaterStatus { + data, err := os.ReadFile(s.statusFile) + if err != nil { + return updaterStatus{Status: "idle", Logs: []string{}} + } + + var status updaterStatus + if err := json.Unmarshal(data, &status); err != nil { + log.Printf("failed to read updater status: %v", err) + return updaterStatus{Status: "idle", Logs: []string{}} + } + if status.Status == "" { + status.Status = "idle" + } + if status.Logs == nil { + status.Logs = []string{} + } + return status +} + +func (s *server) persistStatusLocked() { + data, err := json.MarshalIndent(s.status, "", " ") + if err != nil { + log.Printf("failed to marshal updater status: %v", err) + return + } + if err := os.WriteFile(s.statusFile, data, 0o600); err != nil { + log.Printf("failed to persist updater status: %v", err) + } +} + +func (s *server) patchStatus(update func(*updaterStatus)) { + s.mu.Lock() + defer s.mu.Unlock() + update(&s.status) + s.persistStatusLocked() +} + +func (s *server) logf(format string, args ...any) { + line := fmt.Sprintf("[%s] %s", time.Now().UTC().Format(time.RFC3339), fmt.Sprintf(format, args...)) + s.mu.Lock() + s.status.Logs = append(s.status.Logs, line) + if len(s.status.Logs) > 200 { + s.status.Logs = s.status.Logs[len(s.status.Logs)-200:] + } + s.persistStatusLocked() + s.mu.Unlock() + log.Print(line) +} + +func (s *server) authorize(w http.ResponseWriter, r *http.Request) bool { + if s.token == "" { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "Updater token is not configured"}) + return false + } + + expected := "Bearer " + s.token + actual := r.Header.Get("Authorization") + if len(actual) != len(expected) || subtle.ConstantTimeCompare([]byte(actual), []byte(expected)) != 1 { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "Unauthorized"}) + return false + } + return true +} + +func (s *server) handleStatus(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "Method not allowed"}) + return + } + if !s.authorize(w, r) { + return + } + + s.mu.Lock() + status := s.status + s.mu.Unlock() + writeJSON(w, http.StatusOK, status) +} + +func (s *server) handleUpgrade(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "Method not allowed"}) + return + } + if !s.authorize(w, r) { + return + } + + var request struct { + TargetVersion string `json:"targetVersion"` + } + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + if request.TargetVersion == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "targetVersion is required"}) + return + } + + s.mu.Lock() + if s.status.Status == "running" { + status := s.status + s.mu.Unlock() + writeJSON(w, http.StatusConflict, map[string]any{"error": "Upgrade already running", "status": status}) + return + } + s.status = updaterStatus{ + Status: "running", + TargetVersion: request.TargetVersion, + StartedAt: time.Now().UTC().Format(time.RFC3339), + Logs: []string{}, + } + s.persistStatusLocked() + status := s.status + s.mu.Unlock() + + go s.upgrade(request.TargetVersion) + writeJSON(w, http.StatusAccepted, status) +} + +func writeJSON(w http.ResponseWriter, statusCode int, body any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + if err := json.NewEncoder(w).Encode(body); err != nil { + log.Printf("failed to write response: %v", err) + } +} + +func parseEnv(path string) (map[string]string, string, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, "", err + } + + env := map[string]string{} + for _, line := range strings.Split(string(data), "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") || !strings.Contains(line, "=") { + continue + } + parts := strings.SplitN(line, "=", 2) + env[parts[0]] = parts[1] + } + return env, string(data), nil +} + +func updateEnvVersion(envPath, text, targetVersion string) error { + lines := strings.Split(strings.TrimRight(text, "\r\n"), "\n") + found := false + for i, line := range lines { + if strings.HasPrefix(line, "TECHULUS_CLOUD_VERSION=") { + lines[i] = "TECHULUS_CLOUD_VERSION=" + targetVersion + found = true + break + } + } + if !found { + lines = append(lines, "TECHULUS_CLOUD_VERSION="+targetVersion) + } + return os.WriteFile(envPath, []byte(strings.Join(lines, "\n")+"\n"), 0o600) +} + +func (s *server) upgrade(targetVersion string) { + var backupDir string + composeFile := "compose.production.yml" + migrationStarted := false + + if err := s.runUpgrade(targetVersion, &backupDir, &composeFile, &migrationStarted); err != nil { + s.patchStatus(func(status *updaterStatus) { + status.Status = "failed" + status.CompletedAt = time.Now().UTC().Format(time.RFC3339) + status.Error = err.Error() + }) + s.logf("upgrade failed: %v", err) + + if backupDir != "" && !migrationStarted { + if rollbackErr := s.restoreFiles(backupDir, composeFile); rollbackErr != nil { + s.logf("rollback attempt failed: %v", rollbackErr) + } + } else if backupDir != "" { + s.logf("migration had started; automatic image rollback skipped. Restore the database dump from %s before rolling back images.", backupDir) + } + return + } + + s.patchStatus(func(status *updaterStatus) { + status.Status = "succeeded" + status.CompletedAt = time.Now().UTC().Format(time.RFC3339) + }) + s.logf("upgrade to %s completed", targetVersion) +} + +func (s *server) runUpgrade(targetVersion string, backupDir *string, composeFile *string, migrationStarted *bool) error { + // The web app validates this against the persisted latest GitHub release + // before calling the internal-only updater. Keep a local format guard here + // so the value is safe to use in URLs and compose environment updates. + if !versionPattern.MatchString(targetVersion) { + return errors.New("invalid target version") + } + + envPath := filepath.Join(s.deployDir, ".env") + env, envText, err := parseEnv(envPath) + if err != nil { + return fmt.Errorf("%s not found: %w", envPath, err) + } + + if value := env["COMPOSE_FILE"]; value != "" { + *composeFile = value + } + *backupDir = filepath.Join(s.deployDir, "backups", "update-"+strings.NewReplacer(":", "-", ".", "-").Replace(time.Now().UTC().Format(time.RFC3339Nano))) + if err := os.MkdirAll(*backupDir, 0o700); err != nil { + return err + } + + s.logf("backing up deployment files to %s", *backupDir) + if err := copyFile(envPath, filepath.Join(*backupDir, ".env")); err != nil { + return err + } + for _, file := range []string{"compose.production.yml", "compose.postgres.yml"} { + source := filepath.Join(s.deployDir, file) + if _, err := os.Stat(source); err == nil { + if err := copyFile(source, filepath.Join(*backupDir, file)); err != nil { + return err + } + } + } + if err := s.backupDatabase(env, *backupDir); err != nil { + return err + } + + s.logf("downloading compose files for %s", targetVersion) + if err := s.run("curl", []string{"-fsSL", fmt.Sprintf("%s/%s/deployment/compose.production.yml", s.rawBaseURL, targetVersion), "-o", "compose.production.yml"}, nil); err != nil { + return err + } + if err := s.run("curl", []string{"-fsSL", fmt.Sprintf("%s/%s/deployment/compose.postgres.yml", s.rawBaseURL, targetVersion), "-o", "compose.postgres.yml"}, nil); err != nil { + return err + } + if err := updateEnvVersion(envPath, envText, targetVersion); err != nil { + return err + } + + if err := s.run("docker", []string{"compose", "-f", *composeFile, "pull"}, nil); err != nil { + return err + } + *migrationStarted = true + if err := s.run("docker", []string{"compose", "-f", *composeFile, "up", "-d", "--force-recreate", "migrate"}, nil); err != nil { + return err + } + + services, err := s.composeServices(*composeFile) + if err != nil { + return err + } + if len(services) > 0 { + args := append([]string{"compose", "-f", *composeFile, "up", "-d", "--remove-orphans"}, services...) + if err := s.run("docker", args, nil); err != nil { + return err + } + } + return s.pollHealth() +} + +func copyFile(source, destination string) error { + input, err := os.Open(source) + if err != nil { + return err + } + defer input.Close() + + output, err := os.OpenFile(destination, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600) + if err != nil { + return err + } + defer output.Close() + + _, err = io.Copy(output, input) + return err +} + +func (s *server) backupDatabase(env map[string]string, backupDir string) error { + databaseURL := env["DATABASE_URL"] + if databaseURL == "" { + s.logf("DATABASE_URL not found; skipping database dump") + return nil + } + dumpPath := filepath.Join(backupDir, "database.dump") + if err := s.run("pg_dump", []string{databaseURL, "-Fc", "-f", dumpPath}, []string{"[DATABASE_URL redacted]", "-Fc", "-f", dumpPath}); err != nil { + return err + } + s.logf("database dump written to %s", dumpPath) + return nil +} + +func (s *server) composeServices(composeFile string) ([]string, error) { + output, err := s.runOutput("docker", []string{"compose", "-f", composeFile, "config", "--services"}) + if err != nil { + return nil, err + } + + var services []string + for _, line := range strings.Split(output, "\n") { + service := strings.TrimSpace(line) + if service == "" || service == "control-plane-updater" { + continue + } + services = append(services, service) + } + return services, nil +} + +func (s *server) restoreFiles(backupDir, composeFile string) error { + s.logf("attempting file/image rollback; database schema is not rolled back") + if err := copyFile(filepath.Join(backupDir, ".env"), filepath.Join(s.deployDir, ".env")); err != nil { + return err + } + for _, file := range []string{"compose.production.yml", "compose.postgres.yml"} { + backupPath := filepath.Join(backupDir, file) + if _, err := os.Stat(backupPath); err == nil { + if err := copyFile(backupPath, filepath.Join(s.deployDir, file)); err != nil { + return err + } + } + } + + services, err := s.composeServices(composeFile) + if err != nil { + return err + } + if len(services) == 0 { + return nil + } + args := append([]string{"compose", "-f", composeFile, "up", "-d", "--remove-orphans"}, services...) + return s.run("docker", args, nil) +} + +func (s *server) pollHealth() error { + client := &http.Client{Timeout: 10 * time.Second} + for attempt := 1; attempt <= 30; attempt++ { + response, err := client.Get(s.healthURL) + if err == nil { + response.Body.Close() + if response.StatusCode >= 200 && response.StatusCode < 300 { + return nil + } + s.logf("health check attempt %d returned %d", attempt, response.StatusCode) + } else { + s.logf("health check attempt %d failed: %v", attempt, err) + } + time.Sleep(5 * time.Second) + } + return errors.New("control plane did not become healthy after upgrade") +} + +func (s *server) run(name string, args []string, displayArgs []string) error { + if displayArgs == nil { + displayArgs = args + } + s.logf("$ %s %s", name, strings.Join(displayArgs, " ")) + + cmd := exec.Command(name, args...) + cmd.Dir = s.deployDir + + stdout, err := cmd.StdoutPipe() + if err != nil { + return err + } + stderr, err := cmd.StderrPipe() + if err != nil { + return err + } + + if err := cmd.Start(); err != nil { + return err + } + + var wg sync.WaitGroup + wg.Add(2) + go s.pipeLogs(&wg, stdout) + go s.pipeLogs(&wg, stderr) + wg.Wait() + + if err := cmd.Wait(); err != nil { + return err + } + return nil +} + +func (s *server) pipeLogs(wg *sync.WaitGroup, reader io.Reader) { + defer wg.Done() + scanner := bufio.NewScanner(reader) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + for scanner.Scan() { + line := strings.TrimRight(scanner.Text(), "\r\n") + if line != "" { + s.logf("%s", line) + } + } + if err := scanner.Err(); err != nil { + s.logf("failed to read process output: %v", err) + } +} + +func (s *server) runOutput(name string, args []string) (string, error) { + cmd := exec.Command(name, args...) + cmd.Dir = s.deployDir + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + if stderr.Len() > 0 { + return "", errors.New(strings.TrimSpace(stderr.String())) + } + return "", err + } + return stdout.String(), nil +} diff --git a/docs/installation.mdx b/docs/installation.mdx index f272e2b5..b62363ed 100644 --- a/docs/installation.mdx +++ b/docs/installation.mdx @@ -34,9 +34,28 @@ Clone the repository and configure your environment: ```bash cd deployment cp .env.example .env +openssl rand -hex 32 ``` -Edit `.env` with your values (see below), then start the stack: +Edit `.env` with your values. Set `CONTROL_PLANE_UPDATER_TOKEN` to +the generated token. + +For an external PostgreSQL database, keep: + +```env +COMPOSE_FILE=compose.production.yml +``` + +Set `TECHULUS_CLOUD_VERSION` to the release tag you are installing: + +```env +TECHULUS_CLOUD_VERSION=vX.Y.Z +``` + +Use `tip` only for rolling installs. Rolling installs do not show release +update prompts. + +Then start the stack: ```bash docker compose -f compose.production.yml up -d --pull always --remove-orphans @@ -45,6 +64,7 @@ docker compose -f compose.production.yml up -d --pull always --remove-orphans To use the bundled PostgreSQL instead of an external database: ```bash +sed -i.bak 's/^COMPOSE_FILE=.*/COMPOSE_FILE=compose.postgres.yml/' .env docker compose -f compose.postgres.yml up -d --pull always --remove-orphans ``` @@ -69,6 +89,14 @@ use the common commands below when investigating a self-hosted service. | `BETTER_AUTH_SECRET` | Secret key for authentication | | `ENCRYPTION_KEY` | 32 bytes as a 64-character hex string | +### Control plane deployment + +| Variable | Description | +| --- | --- | +| `COMPOSE_FILE` | Compose file used by self-updates. Use `compose.production.yml` for external PostgreSQL or `compose.postgres.yml` for bundled PostgreSQL. | +| `TECHULUS_CLOUD_VERSION` | Installed release tag. Use `tip` only for rolling installs without release prompts. | +| `CONTROL_PLANE_UPDATER_TOKEN` | Random token used by the web app to call the internal updater service. Generate with `openssl rand -hex 32`. | + ### Victoria Logs | Variable | Description | diff --git a/web/.env.example b/web/.env.example index 74dbcc30..212cc136 100644 --- a/web/.env.example +++ b/web/.env.example @@ -11,6 +11,13 @@ ENCRYPTION_KEY=your-64-character-hex-string # Public URL APP_URL=http://localhost:3000 +# Runtime release tag used for control-plane update checks (tip/dev disables prompts) +TECHULUS_CLOUD_VERSION=dev + +# Internal self-update service (production compose only) +CONTROL_PLANE_UPDATER_URL=http://control-plane-updater:8080 +CONTROL_PLANE_UPDATER_TOKEN=your-random-updater-token + # Logging (optional) VICTORIA_LOGS_URL=http://username:password@victoria-logs:9428 VICTORIA_LOGS_PRIVATE_URL=http://username:password@victoria-logs:9428 diff --git a/web/actions/settings.ts b/web/actions/settings.ts index 0f05a08e..3faae3aa 100644 --- a/web/actions/settings.ts +++ b/web/actions/settings.ts @@ -5,6 +5,11 @@ import isEmail from "validator/es/lib/isEmail"; import { ZodError } from "zod"; import { setSetting } from "@/db/queries"; import { requireAuth } from "@/lib/auth"; +import { + checkAndPersistControlPlaneUpdate, + refreshControlPlaneUpgradeState, + startControlPlaneUpgrade, +} from "@/lib/control-plane-updates"; import { buildTimeoutSchema } from "@/lib/schemas"; import { type EmailAlertsConfig, @@ -70,3 +75,24 @@ export async function updateEmailAlertsConfig(config: EmailAlertsConfig) { throw error; } } + +export async function checkControlPlaneUpdatesNow() { + await requireAuth(); + const state = await checkAndPersistControlPlaneUpdate(); + revalidatePath("/dashboard/settings"); + return state; +} + +export async function upgradeControlPlane(targetVersion: string) { + await requireAuth(); + const state = await startControlPlaneUpgrade(targetVersion); + revalidatePath("/dashboard/settings"); + return state; +} + +export async function refreshControlPlaneUpgradeStatus() { + await requireAuth(); + const state = await refreshControlPlaneUpgradeState(); + revalidatePath("/dashboard/settings"); + return state; +} diff --git a/web/app/(dashboard)/dashboard/settings/page.tsx b/web/app/(dashboard)/dashboard/settings/page.tsx index 849df559..4a3e71c7 100644 --- a/web/app/(dashboard)/dashboard/settings/page.tsx +++ b/web/app/(dashboard)/dashboard/settings/page.tsx @@ -1,6 +1,6 @@ import { SetBreadcrumbs } from "@/components/core/breadcrumb-data"; import { GlobalSettings } from "@/components/settings/global-settings"; -import { listServers, getGlobalSettings } from "@/db/queries"; +import { getGlobalSettings, listServers } from "@/db/queries"; export default async function SettingsPage() { const [servers, settings] = await Promise.all([ @@ -27,7 +27,11 @@ export default async function SettingsPage() { diff --git a/web/components/settings/global-settings.tsx b/web/components/settings/global-settings.tsx index d48fc2b4..f560eb98 100644 --- a/web/components/settings/global-settings.tsx +++ b/web/components/settings/global-settings.tsx @@ -1,10 +1,13 @@ "use client"; import { + ArrowUpCircle, Clock, + ExternalLink, Hammer, Info, Network, + RefreshCw, Server, Shield, } from "lucide-react"; @@ -13,14 +16,27 @@ import { useQueryState } from "nuqs"; import { useState } from "react"; import { toast } from "sonner"; import { + checkControlPlaneUpdatesNow, + refreshControlPlaneUpgradeStatus, updateAcmeEmail, updateBuildServers, updateBuildTimeout, updateProxyDomain, + upgradeControlPlane, } from "@/actions/settings"; import { ApiKeySettings } from "@/components/settings/api-key-settings"; import { EmailSettings } from "@/components/settings/email-settings"; +import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; import { Empty, EmptyDescription, @@ -32,6 +48,10 @@ import { Item, ItemContent, ItemMedia, ItemTitle } from "@/components/ui/item"; import { Label } from "@/components/ui/label"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import type { Server as ServerType } from "@/db/types"; +import type { + ControlPlaneUpdateState, + ControlPlaneUpgradeState, +} from "@/lib/control-plane-updates"; import type { EmailAlertsConfig } from "@/lib/settings-keys"; type Props = { @@ -42,10 +62,41 @@ type Props = { acmeEmail: string | null; proxyDomain: string | null; emailAlertsConfig: EmailAlertsConfig | null; + controlPlaneUpdateState: ControlPlaneUpdateState | null; + controlPlaneUpgradeState: ControlPlaneUpgradeState | null; }; appVersion: string | null; }; +function formatCheckedAt(value: string | null | undefined) { + if (!value) return "Never"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return "Unknown"; + return new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", + }).format(date); +} + +function buildUpgradeCommand(targetVersion: string) { + return `DEPLOY_DIR=/opt/techulus-cloud +cd "$DEPLOY_DIR" +COMPOSE_FILE=$(grep -E '^COMPOSE_FILE=' .env | cut -d= -f2- || true) +if [ -z "$COMPOSE_FILE" ]; then + COMPOSE_FILE=compose.production.yml +fi +sudo cp .env ".env.backup.$(date +%Y%m%d%H%M%S)" +sudo curl -fsSL "https://raw.githubusercontent.com/techulus/cloud/${targetVersion}/deployment/compose.production.yml" -o compose.production.yml +sudo curl -fsSL "https://raw.githubusercontent.com/techulus/cloud/${targetVersion}/deployment/compose.postgres.yml" -o compose.postgres.yml +if grep -q '^TECHULUS_CLOUD_VERSION=' .env; then + sudo sed -i.bak "s/^TECHULUS_CLOUD_VERSION=.*/TECHULUS_CLOUD_VERSION=${targetVersion}/" .env +else + echo "TECHULUS_CLOUD_VERSION=${targetVersion}" | sudo tee -a .env >/dev/null +fi +sudo docker compose -f "$COMPOSE_FILE" pull +sudo docker compose -f "$COMPOSE_FILE" up -d --remove-orphans`; +} + export function GlobalSettings({ servers, initialSettings, @@ -68,6 +119,9 @@ export function GlobalSettings({ ); const [isSavingAcmeEmail, setIsSavingAcmeEmail] = useState(false); const [isSavingProxyDomain, setIsSavingProxyDomain] = useState(false); + const [isCheckingUpdates, setIsCheckingUpdates] = useState(false); + const [isStartingUpgrade, setIsStartingUpgrade] = useState(false); + const [isRefreshingUpgrade, setIsRefreshingUpgrade] = useState(false); const toggleBuildServer = (serverId: string) => { setBuildServerIds((prev) => { @@ -143,6 +197,65 @@ export function GlobalSettings({ } }; + const handleCheckUpdates = async () => { + setIsCheckingUpdates(true); + try { + const state = await checkControlPlaneUpdatesNow(); + if (state.error) { + toast.error(state.error); + } else if (state.updateAvailable && state.latestVersion) { + toast.success(`Update available: ${state.latestVersion}`); + } else { + toast.success("Control plane is up to date"); + } + router.refresh(); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to check updates", + ); + } finally { + setIsCheckingUpdates(false); + } + }; + + const handleStartUpgrade = async (targetVersion: string) => { + setIsStartingUpgrade(true); + try { + await upgradeControlPlane(targetVersion); + toast.success("Control plane upgrade started"); + router.refresh(); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to start upgrade", + ); + } finally { + setIsStartingUpgrade(false); + } + }; + + const handleRefreshUpgradeStatus = async () => { + setIsRefreshingUpgrade(true); + try { + const state = await refreshControlPlaneUpgradeStatus(); + if (state.status === "succeeded") { + toast.success("Upgrade completed"); + } else if (state.status === "failed") { + toast.error(state.error ?? "Upgrade failed"); + } else { + toast.info(`Upgrade status: ${state.status}`); + } + router.refresh(); + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : "Failed to refresh upgrade status", + ); + } finally { + setIsRefreshingUpgrade(false); + } + }; + const buildServersChanged = buildServerIds.size !== initialSettings.buildServerIds.length || !initialSettings.buildServerIds.every((id) => buildServerIds.has(id)); @@ -153,6 +266,13 @@ export function GlobalSettings({ const acmeEmailChanged = acmeEmail !== (initialSettings.acmeEmail ?? ""); const proxyDomainChanged = proxyDomain !== (initialSettings.proxyDomain ?? ""); + const updateState = initialSettings.controlPlaneUpdateState; + const upgradeState = initialSettings.controlPlaneUpgradeState; + const displayVersion = updateState?.currentVersion ?? appVersion ?? "dev"; + const upgradeCommand = updateState?.latestVersion + ? buildUpgradeCommand(updateState.latestVersion) + : null; + const upgradeRunning = upgradeState?.status === "running"; if (servers.length === 0) { return ( @@ -389,11 +509,173 @@ export function GlobalSettings({ - Version + Control Plane Updates -
-

{appVersion ?? "dev"}

+
+
+
+

Current version

+

{displayVersion}

+
+
+

Latest version

+

+ {updateState?.latestVersion ?? "Not checked"} +

+
+
+

Last checked

+

+ {formatCheckedAt(updateState?.checkedAt)} +

+
+
+ + {updateState?.channel === "rolling" && ( +
+ This instance is running the rolling channel ({displayVersion}), + so release update prompts are disabled. +
+ )} + + {updateState?.channel === "unknown" && ( +
+ This instance version is not a release tag, so update prompts + are disabled. +
+ )} + + {updateState?.error && ( +
+ {updateState.error} +
+ )} + + {updateState?.updateAvailable && updateState.latestVersion && ( +
+
+ + Control plane update available: {displayVersion} →{" "} + {updateState.latestVersion} +
+
+ )} + + {upgradeState && upgradeState.status !== "idle" && ( +
+
+
+

+ Upgrade status: {upgradeState.status} + {upgradeState.targetVersion + ? ` (${upgradeState.targetVersion})` + : ""} +

+ {upgradeState.error && ( +

{upgradeState.error}

+ )} +
+ +
+ {upgradeState.logs.length > 0 && ( + + {upgradeState.logs.slice(-20).join("\n")} + + )} +
+ )} + +
+ + + {updateState?.releaseUrl && ( + + )} + + {updateState?.updateAvailable && + updateState.latestVersion && + upgradeCommand && ( + + + } + > + Upgrade to {updateState.latestVersion} + + + + Upgrade control plane + + Run this command on the host that runs + /opt/techulus-cloud. It updates the compose files from + the release tag, pulls images, and restarts the stack. + + + + {upgradeCommand} + +
+ The one-click upgrader runs the same flow through an + internal updater service. Back up your database before + upgrades that may include schema changes. Rollback after + migrations may require restoring the database backup. +
+ + + +
+
+ )} + + {updateState && + !updateState.updateAvailable && + !updateState.error && ( + Up to date + )} +
diff --git a/web/db/queries.ts b/web/db/queries.ts index ea2bae54..7a95274d 100644 --- a/web/db/queries.ts +++ b/web/db/queries.ts @@ -9,6 +9,10 @@ import { settings, } from "@/db/schema"; import type { HealthStats } from "@/db/types"; +import type { + ControlPlaneUpdateState, + ControlPlaneUpgradeState, +} from "@/lib/control-plane-updates"; import type { EmailAlertsConfig, SmtpConfig, @@ -272,12 +276,16 @@ export async function getGlobalSettings() { acmeEmail, proxyDomain, emailAlertsConfig, + controlPlaneUpdateState, + controlPlaneUpgradeState, ] = await Promise.all([ getSetting("servers_allowed_for_builds"), getSetting("build_timeout_minutes"), getSetting("acme_email"), getSetting("proxy_domain"), getSetting("email_alerts_config"), + getSetting("control_plane_update_state"), + getSetting("control_plane_upgrade_state"), ]); return { @@ -286,6 +294,8 @@ export async function getGlobalSettings() { acmeEmail: acmeEmail ?? null, proxyDomain: proxyDomain ?? null, emailAlertsConfig: emailAlertsConfig ?? null, + controlPlaneUpdateState: controlPlaneUpdateState ?? null, + controlPlaneUpgradeState: controlPlaneUpgradeState ?? null, }; } diff --git a/web/lib/control-plane-updates.ts b/web/lib/control-plane-updates.ts new file mode 100644 index 00000000..3aef9ab6 --- /dev/null +++ b/web/lib/control-plane-updates.ts @@ -0,0 +1,249 @@ +import { getSetting, setSetting } from "@/db/queries"; +import { SETTING_KEYS } from "@/lib/settings-keys"; + +const GITHUB_LATEST_RELEASE_URL = + "https://api.github.com/repos/techulus/cloud/releases/latest"; + +type GitHubRelease = { + tag_name?: string; + html_url?: string; + body?: string; +}; + +type ParsedVersion = { + major: number; + minor: number; + patch: number; + prerelease: string | null; +}; + +export type ControlPlaneUpdateState = { + currentVersion: string; + latestVersion: string | null; + updateAvailable: boolean; + releaseUrl: string | null; + releaseNotes: string | null; + checkedAt: string; + channel: "release" | "rolling" | "unknown"; + error?: string; +}; + +export type ControlPlaneUpgradeState = { + status: "idle" | "running" | "succeeded" | "failed"; + targetVersion: string | null; + startedAt: string | null; + completedAt: string | null; + error: string | null; + logs: string[]; +}; + +export function getCurrentControlPlaneVersion() { + return ( + process.env.TECHULUS_CLOUD_VERSION || + process.env.NEXT_PUBLIC_APP_VERSION || + "dev" + ); +} + +function parseVersion(version: string): ParsedVersion | null { + const match = version + .trim() + .match(/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/); + if (!match) return null; + + return { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + prerelease: match[4] ?? null, + }; +} + +function isGreaterVersion(a: string, b: string) { + const left = parseVersion(a); + const right = parseVersion(b); + if (!left || !right) return false; + + if (left.major !== right.major) return left.major > right.major; + if (left.minor !== right.minor) return left.minor > right.minor; + if (left.patch !== right.patch) return left.patch > right.patch; + + if (left.prerelease === right.prerelease) return false; + if (left.prerelease === null) return true; + if (right.prerelease === null) return false; + return left.prerelease > right.prerelease; +} + +function getChannel(version: string): ControlPlaneUpdateState["channel"] { + const normalized = version.trim().toLowerCase(); + if (normalized === "tip" || normalized === "dev" || normalized === "latest") { + return "rolling"; + } + return parseVersion(version) ? "release" : "unknown"; +} + +async function fetchLatestRelease(): Promise { + const headers: HeadersInit = { + Accept: "application/vnd.github.v3+json", + "User-Agent": "techulus-cloud-control-plane", + }; + + if (process.env.GITHUB_TOKEN) { + headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`; + } + + const response = await fetch(GITHUB_LATEST_RELEASE_URL, { + headers, + cache: "no-store", + }); + + if (!response.ok) { + throw new Error(`GitHub release check failed (${response.status})`); + } + + return response.json(); +} + +export async function checkControlPlaneUpdate(): Promise { + const currentVersion = getCurrentControlPlaneVersion(); + const checkedAt = new Date().toISOString(); + const channel = getChannel(currentVersion); + + if (channel !== "release") { + return { + currentVersion, + latestVersion: null, + updateAvailable: false, + releaseUrl: null, + releaseNotes: null, + checkedAt, + channel, + }; + } + + try { + const release = await fetchLatestRelease(); + const latestVersion = release.tag_name ?? null; + + return { + currentVersion, + latestVersion, + updateAvailable: latestVersion + ? isGreaterVersion(latestVersion, currentVersion) + : false, + releaseUrl: release.html_url ?? null, + releaseNotes: release.body ?? null, + checkedAt, + channel, + }; + } catch (error) { + return { + currentVersion, + latestVersion: null, + updateAvailable: false, + releaseUrl: null, + releaseNotes: null, + checkedAt, + channel, + error: error instanceof Error ? error.message : "Failed to check updates", + }; + } +} + +export async function checkAndPersistControlPlaneUpdate() { + const [previousState, nextState] = await Promise.all([ + getSetting( + SETTING_KEYS.CONTROL_PLANE_UPDATE_STATE, + ), + checkControlPlaneUpdate(), + ]); + const state = + nextState.error && previousState + ? { + ...previousState, + currentVersion: nextState.currentVersion, + updateAvailable: previousState.latestVersion + ? isGreaterVersion( + previousState.latestVersion, + nextState.currentVersion, + ) + : false, + checkedAt: nextState.checkedAt, + channel: nextState.channel, + error: nextState.error, + } + : nextState; + await Promise.all([ + setSetting(SETTING_KEYS.CONTROL_PLANE_UPDATE_STATE, state), + setSetting( + SETTING_KEYS.CONTROL_PLANE_LAST_UPDATE_CHECK_AT, + state.checkedAt, + ), + ]); + return state; +} + +function getUpdaterConfig() { + const url = process.env.CONTROL_PLANE_UPDATER_URL; + const token = process.env.CONTROL_PLANE_UPDATER_TOKEN; + if (!url || !token) { + throw new Error("Control plane updater is not configured"); + } + return { url: url.replace(/\/$/, ""), token }; +} + +async function fetchUpdaterStatus(): Promise { + const { url, token } = getUpdaterConfig(); + const response = await fetch(`${url}/status`, { + headers: { Authorization: `Bearer ${token}` }, + cache: "no-store", + }); + if (!response.ok) { + throw new Error(`Updater status request failed (${response.status})`); + } + return response.json(); +} + +async function postUpdaterUpgrade( + targetVersion: string, +): Promise { + const { url, token } = getUpdaterConfig(); + const response = await fetch(`${url}/upgrade`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ targetVersion }), + }); + + const body = await response.json().catch(() => null); + if (!response.ok) { + throw new Error( + body?.error ?? `Updater request failed (${response.status})`, + ); + } + return body; +} + +export async function startControlPlaneUpgrade(targetVersion: string) { + const updateState = await getSetting( + SETTING_KEYS.CONTROL_PLANE_UPDATE_STATE, + ); + if ( + !updateState?.updateAvailable || + updateState.latestVersion !== targetVersion + ) { + throw new Error("Target version is not the latest available release"); + } + + const upgradeState = await postUpdaterUpgrade(targetVersion); + await setSetting(SETTING_KEYS.CONTROL_PLANE_UPGRADE_STATE, upgradeState); + return upgradeState; +} + +export async function refreshControlPlaneUpgradeState() { + const upgradeState = await fetchUpdaterStatus(); + await setSetting(SETTING_KEYS.CONTROL_PLANE_UPGRADE_STATE, upgradeState); + return upgradeState; +} diff --git a/web/lib/inngest/functions/crons.ts b/web/lib/inngest/functions/crons.ts index 570835d4..85e4d7ba 100644 --- a/web/lib/inngest/functions/crons.ts +++ b/web/lib/inngest/functions/crons.ts @@ -1,14 +1,15 @@ +import { cron } from "inngest"; import { cleanupExpiredChallenges, renewExpiringCertificates, } from "@/lib/acme-manager"; import { cleanupOldBackups, runScheduledBackups } from "@/lib/backup-scheduler"; +import { checkAndPersistControlPlaneUpdate } from "@/lib/control-plane-updates"; import { checkAndRecoverStaleServers, checkAndRunScheduledDeployments, cleanupStaleItems, } from "@/lib/scheduler"; -import { cron } from "inngest"; import { inngest } from "../client"; export const staleServerCheck = inngest.createFunction( @@ -94,6 +95,20 @@ export const oldBackupsCleanup = inngest.createFunction( }, ); +export const controlPlaneUpdateCheck = inngest.createFunction( + { + id: "cron-control-plane-update-check", + triggers: [cron("0 4 * * *")], + singleton: { mode: "skip" }, + }, + async ({ step }) => { + await step.run("check-control-plane-updates", async () => { + console.log("[cron] checking control plane updates"); + await checkAndPersistControlPlaneUpdate(); + }); + }, +); + export const staleItemsCleanup = inngest.createFunction( { id: "cron-stale-items-cleanup", diff --git a/web/lib/inngest/functions/index.ts b/web/lib/inngest/functions/index.ts index 0fa57404..dd6b545e 100644 --- a/web/lib/inngest/functions/index.ts +++ b/web/lib/inngest/functions/index.ts @@ -4,6 +4,7 @@ export { buildWorkflow } from "./build-workflow"; export { certificateRenewal, challengeCleanup, + controlPlaneUpdateCheck, oldBackupsCleanup, scheduledBackupsCheck, scheduledDeploymentsCheck, diff --git a/web/lib/settings-keys.ts b/web/lib/settings-keys.ts index eccabcd4..ad8ed5b9 100644 --- a/web/lib/settings-keys.ts +++ b/web/lib/settings-keys.ts @@ -6,6 +6,9 @@ export const SETTING_KEYS = { ACME_EMAIL: "acme_email", PROXY_DOMAIN: "proxy_domain", EMAIL_ALERTS_CONFIG: "email_alerts_config", + CONTROL_PLANE_UPDATE_STATE: "control_plane_update_state", + CONTROL_PLANE_LAST_UPDATE_CHECK_AT: "control_plane_last_update_check_at", + CONTROL_PLANE_UPGRADE_STATE: "control_plane_upgrade_state", } as const; export const DEFAULT_BUILD_TIMEOUT_MINUTES = 30; From 04114796cc56b0f358dbe943efd8f882ba1e067c Mon Sep 17 00:00:00 2001 From: Arjun Komath Date: Sun, 28 Jun 2026 21:02:32 +1000 Subject: [PATCH 6/7] Reduce service card detail font size Amp-Thread-ID: https://ampcode.com/threads/T-019f0de3-436f-7628-b085-4b47801347a5 Co-authored-by: Amp --- web/components/service/service-canvas.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/web/components/service/service-canvas.tsx b/web/components/service/service-canvas.tsx index 5d1803a4..dd9f99d7 100644 --- a/web/components/service/service-canvas.tsx +++ b/web/components/service/service-canvas.tsx @@ -345,7 +345,7 @@ function ServiceCard({ className={`relative inline-flex rounded-full h-2.5 w-2.5 ${colors.dot}`} /> - + {service.deployments.length > 0 ? `${runningCount}/${service.deployments.length}` : "Not deployed"} @@ -361,7 +361,7 @@ function ServiceCard({ key={port.id} className="border-l-2 border-sky-500 pl-2.5 py-0.5" > -
+
{port.domain}
@@ -374,7 +374,7 @@ function ServiceCard({ key={port.id} className="border-l-2 border-violet-500 pl-2.5 py-0.5" > -
+
{port.protocol}://{proxyDomain}:{port.externalPort} @@ -384,7 +384,7 @@ function ServiceCard({ ))} {hasInternalDns && (
-
+
{service.hostname || service.name}.internal @@ -404,7 +404,7 @@ function ServiceCard({ className="flex items-center gap-2.5 text-muted-foreground" > - + {volume.name}
From 02213e25b3230dfacf4aca99fd62ea83c331370a Mon Sep 17 00:00:00 2001 From: Arjun Komath Date: Sun, 28 Jun 2026 21:18:40 +1000 Subject: [PATCH 7/7] Fix React Doctor cleanup issues Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-019f0de6-8af5-7490-a82d-7f382bd0b6bc --- web/actions/projects.ts | 13 ++-- .../import-compose/import-compose-form.tsx | 7 +- .../auth/device-authorization-page.tsx | 70 ++++++++++--------- web/components/core/breadcrumb-data.tsx | 25 ++++--- web/components/core/status-indicator.tsx | 40 +++++------ .../github/github-repo-selector.tsx | 20 +++--- web/components/logs/log-viewer.tsx | 69 ++++++++++-------- .../service/details/health-check-section.tsx | 51 ++++++++++---- .../details/resource-limits-section.tsx | 30 ++++++-- .../service/details/schedule-section.tsx | 13 ++-- .../service/details/source-section.tsx | 8 +-- .../service/details/start-command-section.tsx | 11 +-- web/components/service/service-canvas.tsx | 3 +- .../service/service-layout-client.tsx | 43 +++++++----- web/components/settings/api-key-settings.tsx | 22 +++--- web/components/settings/global-settings.tsx | 10 +-- 16 files changed, 255 insertions(+), 180 deletions(-) diff --git a/web/actions/projects.ts b/web/actions/projects.ts index e80c085b..dbb6f636 100644 --- a/web/actions/projects.ts +++ b/web/actions/projects.ts @@ -425,11 +425,6 @@ type CreateServiceInput = { const SERVICE_CANVAS_WIDTH = 1320; const SERVICE_CARD_WIDTH = 320; -const NEW_SERVICE_CANVAS_POSITION = { - canvasX: (SERVICE_CANVAS_WIDTH - SERVICE_CARD_WIDTH) / 2, - canvasY: 0, -}; - export async function createService(input: CreateServiceInput) { await requireAuth(); const { projectId, environmentId, name, image, github } = input; @@ -446,6 +441,10 @@ export async function createService(input: CreateServiceInput) { const id = randomUUID(); const hostname = `${project.slug}-${slugify(name)}-${env.name}`; + const newServiceCanvasPosition = { + canvasX: (SERVICE_CANVAS_WIDTH - SERVICE_CARD_WIDTH) / 2, + canvasY: 0, + }; let finalImage = image; let sourceType: "image" | "github" = "image"; @@ -490,8 +489,8 @@ export async function createService(input: CreateServiceInput) { stateful: false, resourceCpuLimit: resourceLimits.cpuCores, resourceMemoryLimitMb: resourceLimits.memoryMb, - canvasX: NEW_SERVICE_CANVAS_POSITION.canvasX, - canvasY: NEW_SERVICE_CANVAS_POSITION.canvasY, + canvasX: newServiceCanvasPosition.canvasX, + canvasY: newServiceCanvasPosition.canvasY, }); if (selectedServer) { diff --git a/web/app/(dashboard)/dashboard/projects/[slug]/[env]/import-compose/import-compose-form.tsx b/web/app/(dashboard)/dashboard/projects/[slug]/[env]/import-compose/import-compose-form.tsx index c17d2807..d3b8b5b1 100644 --- a/web/app/(dashboard)/dashboard/projects/[slug]/[env]/import-compose/import-compose-form.tsx +++ b/web/app/(dashboard)/dashboard/projects/[slug]/[env]/import-compose/import-compose-form.tsx @@ -112,10 +112,11 @@ export function ImportComposeForm({ try { const overrides: Record = {}; + const parsedServiceByName = new Map( + parsedServices.map((service) => [service.name, service]), + ); for (const [originalName, config] of Object.entries(serviceConfigs)) { - const originalService = parsedServices.find( - (s) => s.name === originalName, - ); + const originalService = parsedServiceByName.get(originalName); if (originalService) { overrides[originalName] = { name: config.name !== originalName ? config.name : undefined, diff --git a/web/components/auth/device-authorization-page.tsx b/web/components/auth/device-authorization-page.tsx index 21c4c88c..ee7f1a9b 100644 --- a/web/components/auth/device-authorization-page.tsx +++ b/web/components/auth/device-authorization-page.tsx @@ -1,7 +1,7 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { Button } from "@/components/ui/button"; import { Card, @@ -34,42 +34,48 @@ export function DeviceAuthorizationPage() { setUserCode(initialUserCode); }, [initialUserCode]); - async function verifyCode(code: string) { - const formatted = normalizeUserCode(code); - if (!formatted) { - setError("Enter the device code to continue"); - return; - } + const verifyCode = useCallback( + async (code: string) => { + const formatted = normalizeUserCode(code); + if (!formatted) { + setError("Enter the device code to continue"); + return; + } - setLoading(true); - setError(""); + setLoading(true); + setError(""); - try { - const response = await authClient.device({ - query: { user_code: formatted }, - }); + try { + const response = await authClient.device({ + query: { user_code: formatted }, + }); - if (response.error || !response.data) { - setError(response.error?.error_description || "Invalid or expired code"); - return; - } + if (response.error || !response.data) { + setError( + response.error?.error_description || "Invalid or expired code", + ); + return; + } - router.push(`/device/approve?user_code=${encodeURIComponent(formatted)}`); - } catch (err) { - setError(err instanceof Error ? err.message : "Invalid or expired code"); - } finally { - setLoading(false); - } - } + router.push( + `/device/approve?user_code=${encodeURIComponent(formatted)}`, + ); + } catch (err) { + setError( + err instanceof Error ? err.message : "Invalid or expired code", + ); + } finally { + setLoading(false); + } + }, + [router], + ); useEffect(() => { if (initialUserCode) { void verifyCode(initialUserCode); } - // initialUserCode is intentionally the only trigger here so a shared - // verification link can continue without another submit. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [initialUserCode]); + }, [initialUserCode, verifyCode]); return (
@@ -80,17 +86,13 @@ export function DeviceAuthorizationPage() { Enter the code shown in your terminal to continue signing in. -
{ - event.preventDefault(); - void verifyCode(userCode); - }} - > + void verifyCode(userCode)}>
{ setUserCode(event.target.value); diff --git a/web/components/core/breadcrumb-data.tsx b/web/components/core/breadcrumb-data.tsx index 5cb5d919..deec0bbe 100644 --- a/web/components/core/breadcrumb-data.tsx +++ b/web/components/core/breadcrumb-data.tsx @@ -3,11 +3,12 @@ import { usePathname } from "next/navigation"; import { createContext, - useContext, - useLayoutEffect, + type ReactNode, + use, useCallback, + useLayoutEffect, + useMemo, useState, - type ReactNode, } from "react"; export type Breadcrumb = { label: string; href: string }; @@ -24,6 +25,8 @@ const BreadcrumbDataContext = createContext({ setBreadcrumbs: () => {}, }); +const EMPTY_BREADCRUMBS: Breadcrumb[] = []; + export function BreadcrumbDataProvider({ children }: { children: ReactNode }) { const pathname = usePathname(); const [state, setState] = useState<{ @@ -34,7 +37,8 @@ export function BreadcrumbDataProvider({ children }: { children: ReactNode }) { pathname: "", }); - const breadcrumbs = state.pathname === pathname ? state.breadcrumbs : []; + const breadcrumbs = + state.pathname === pathname ? state.breadcrumbs : EMPTY_BREADCRUMBS; const setBreadcrumbs = useCallback( (newBreadcrumbs: Breadcrumb[], forPathname: string) => { @@ -43,21 +47,24 @@ export function BreadcrumbDataProvider({ children }: { children: ReactNode }) { [], ); + const value = useMemo( + () => ({ breadcrumbs, pathname, setBreadcrumbs }), + [breadcrumbs, pathname, setBreadcrumbs], + ); + return ( - + {children} ); } export function useBreadcrumbs() { - return useContext(BreadcrumbDataContext).breadcrumbs; + return use(BreadcrumbDataContext).breadcrumbs; } export function SetBreadcrumbs({ items }: { items: Breadcrumb[] }) { - const { setBreadcrumbs, pathname } = useContext(BreadcrumbDataContext); + const { setBreadcrumbs, pathname } = use(BreadcrumbDataContext); useLayoutEffect(() => { setBreadcrumbs(items, pathname); diff --git a/web/components/core/status-indicator.tsx b/web/components/core/status-indicator.tsx index 5ebe8075..325602ed 100644 --- a/web/components/core/status-indicator.tsx +++ b/web/components/core/status-indicator.tsx @@ -1,24 +1,24 @@ -export function StatusIndicator({ status }: { status: string }) { - const colors: Record = { - online: { - dot: "bg-emerald-500", - text: "text-emerald-600 dark:text-emerald-400", - }, - pending: { - dot: "bg-amber-500", - text: "text-amber-600 dark:text-amber-400", - }, - offline: { - dot: "bg-rose-500", - text: "text-rose-600 dark:text-rose-400", - }, - unknown: { - dot: "bg-slate-400", - text: "text-slate-500", - }, - }; +const STATUS_COLORS: Record = { + online: { + dot: "bg-emerald-500", + text: "text-emerald-600 dark:text-emerald-400", + }, + pending: { + dot: "bg-amber-500", + text: "text-amber-600 dark:text-amber-400", + }, + offline: { + dot: "bg-rose-500", + text: "text-rose-600 dark:text-rose-400", + }, + unknown: { + dot: "bg-slate-400", + text: "text-slate-500", + }, +}; - const color = colors[status] || colors.unknown; +export function StatusIndicator({ status }: { status: string }) { + const color = STATUS_COLORS[status] || STATUS_COLORS.unknown; return (
diff --git a/web/components/github/github-repo-selector.tsx b/web/components/github/github-repo-selector.tsx index 7450de9f..04179728 100644 --- a/web/components/github/github-repo-selector.tsx +++ b/web/components/github/github-repo-selector.tsx @@ -1,8 +1,8 @@ "use client"; -import { useState, useMemo } from "react"; +import { Globe, Loader2, Lock } from "lucide-react"; +import { useMemo, useState } from "react"; import useSWR from "swr"; -import { Globe, Lock, Loader2 } from "lucide-react"; import { Input } from "@/components/ui/input"; type GitHubRepo = { @@ -24,6 +24,13 @@ type SelectedRepo = { const fetcher = (url: string) => fetch(url).then((res) => res.json()); +const EMPTY_REPOS: GitHubRepo[] = []; + +function getValidGitHubRepoName(url: string) { + const match = url.match(/^https:\/\/github\.com\/([^/]+\/[^/]+)\/?$/); + return match ? match[1] : null; +} + export function GitHubRepoSelector({ value, onChange, @@ -44,7 +51,7 @@ export function GitHubRepoSelector({ }>; }>("/api/github/repos", fetcher); - const repos = data?.repos || []; + const repos = data?.repos || EMPTY_REPOS; const hasInstallations = (data?.installations?.length || 0) > 0; const filteredRepos = useMemo(() => { @@ -53,13 +60,8 @@ export function GitHubRepoSelector({ return repos.filter((r) => r.fullName.toLowerCase().includes(lower)); }, [repos, search]); - const isValidGitHubUrl = (url: string) => { - const match = url.match(/^https:\/\/github\.com\/([^/]+\/[^/]+)\/?$/); - return match ? match[1] : null; - }; - const publicRepoFromSearch = useMemo(() => { - const repoName = isValidGitHubUrl(search); + const repoName = getValidGitHubRepoName(search); if (!repoName) return null; const alreadyInList = repos.some( (r) => r.fullName.toLowerCase() === repoName.toLowerCase(), diff --git a/web/components/logs/log-viewer.tsx b/web/components/logs/log-viewer.tsx index 29cfca20..e4a881ac 100644 --- a/web/components/logs/log-viewer.tsx +++ b/web/components/logs/log-viewer.tsx @@ -25,7 +25,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { Empty, EmptyDescription, EmptyTitle } from "@/components/ui/empty"; +import { Empty, EmptyTitle } from "@/components/ui/empty"; import { Input } from "@/components/ui/input"; import { Spinner } from "@/components/ui/spinner"; import { formatDateTime, formatTime } from "@/lib/date"; @@ -66,6 +66,25 @@ interface ServerLogEntry extends BaseEntry { type Server = { id: string; name: string }; +const STATUS_OPTIONS: { + value: StatusCategory; + label: string; + color: string; +}[] = [ + { value: "2xx", label: "2xx Success", color: "bg-green-500" }, + { value: "3xx", label: "3xx Redirect", color: "bg-blue-500" }, + { value: "4xx", label: "4xx Client Error", color: "bg-yellow-500" }, + { value: "5xx", label: "5xx Server Error", color: "bg-red-500" }, +]; + +const SERVER_LOG_LEVEL_COLORS: Record = { + error: "text-red-500 bg-red-500/10", + warn: "text-yellow-500 bg-yellow-500/10", + info: "text-blue-500 bg-blue-500/10", +}; + +const EMPTY_LOGS: unknown[] = []; + type LogViewerProps = | { variant: "service-logs"; serviceId: string; servers?: Server[] } | { variant: "requests"; serviceId: string } @@ -112,19 +131,23 @@ function highlightMatches(text: string, search: string): React.ReactNode { "gi", ); const parts = text.split(regex); + let offset = 0; - return parts.map((part, i) => - regex.test(part) ? ( + return parts.map((part) => { + const start = offset; + offset += part.length; + + return regex.test(part) ? ( {part} ) : ( part - ), - ); + ); + }); } function useLogData(props: LogViewerProps, filterServerId?: string) { @@ -358,17 +381,6 @@ function RequestsFilters({ return Array.from(statusFilter).join(", "); }, [statusFilter]); - const statusOptions: { - value: StatusCategory; - label: string; - color: string; - }[] = [ - { value: "2xx", label: "2xx Success", color: "bg-green-500" }, - { value: "3xx", label: "3xx Redirect", color: "bg-blue-500" }, - { value: "4xx", label: "4xx Client Error", color: "bg-yellow-500" }, - { value: "5xx", label: "5xx Server Error", color: "bg-red-500" }, - ]; - return ( @@ -379,7 +391,7 @@ function RequestsFilters({ Status Codes - {statusOptions.map((opt) => ( + {STATUS_OPTIONS.map((opt) => ( = { - error: "text-red-500 bg-red-500/10", - warn: "text-yellow-500 bg-yellow-500/10", - info: "text-blue-500 bg-blue-500/10", - }; - return (
{entry.level} @@ -648,11 +654,10 @@ export function LogViewer(props: LogViewerProps) { const [olderLogs, setOlderLogs] = useState([]); const [isLoadingOlder, setIsLoadingOlder] = useState(false); const [olderHasMore, setOlderHasMore] = useState(true); - const [prevSelectedServerId, setPrevSelectedServerId] = - useState(selectedServerId); + const prevSelectedServerIdRef = useRef(selectedServerId); - if (selectedServerId !== prevSelectedServerId) { - setPrevSelectedServerId(selectedServerId); + if (selectedServerId !== prevSelectedServerIdRef.current) { + prevSelectedServerIdRef.current = selectedServerId; setOlderLogs([]); setOlderHasMore(true); } @@ -660,7 +665,7 @@ export function LogViewer(props: LogViewerProps) { const servers = props.variant === "service-logs" ? props.servers : undefined; const { data, isLoading } = useLogData(props, selectedServerId ?? undefined); - const recentLogs = (data?.logs || []) as unknown[]; + const recentLogs = (data?.logs as unknown[] | undefined) || EMPTY_LOGS; const logs = useMemo(() => { const seenIds = new Set(); const combined: unknown[] = []; @@ -818,8 +823,10 @@ export function LogViewer(props: LogViewerProps) { showStderr, statusFilter, ]); + const filteredLogCount = filteredLogs.length; useLayoutEffect(() => { + void filteredLogCount; const container = containerRef.current; const restoration = scrollRestorationRef.current; @@ -831,7 +838,7 @@ export function LogViewer(props: LogViewerProps) { } else if (autoScroll && container) { container.scrollTop = container.scrollHeight; } - }, [filteredLogs.length, autoScroll]); + }, [filteredLogCount, autoScroll]); const config = useMemo(() => { switch (props.variant) { diff --git a/web/components/service/details/health-check-section.tsx b/web/components/service/details/health-check-section.tsx index 38604475..88c483d6 100644 --- a/web/components/service/details/health-check-section.tsx +++ b/web/components/service/details/health-check-section.tsx @@ -1,11 +1,11 @@ "use client"; -import { useState, useReducer, useMemo, memo } from "react"; +import { ChevronDown, HeartPulse } from "lucide-react"; +import { memo, useMemo, useReducer, useState } from "react"; +import { updateServiceHealthCheck } from "@/actions/projects"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Item, ItemContent, ItemMedia, ItemTitle } from "@/components/ui/item"; -import { HeartPulse, ChevronDown } from "lucide-react"; -import { updateServiceHealthCheck } from "@/actions/projects"; import type { ServiceWithDetails as Service } from "@/db/types"; type HealthCheckState = { @@ -139,8 +139,11 @@ export const HealthCheckSection = memo(function HealthCheckSection({
- + @@ -166,56 +169,80 @@ export const HealthCheckSection = memo(function HealthCheckSection({ {showAdvanced && (
- + dispatch({ type: "SET_INTERVAL", - payload: parseInt(e.target.value) || 10, + payload: parseInt(e.target.value, 10) || 10, }) } min={1} />
- + dispatch({ type: "SET_TIMEOUT", - payload: parseInt(e.target.value) || 5, + payload: parseInt(e.target.value, 10) || 5, }) } min={1} />
- + dispatch({ type: "SET_RETRIES", - payload: parseInt(e.target.value) || 3, + payload: parseInt(e.target.value, 10) || 3, }) } min={1} />
- + dispatch({ type: "SET_START_PERIOD", - payload: parseInt(e.target.value) || 30, + payload: parseInt(e.target.value, 10) || 30, }) } min={0} diff --git a/web/components/service/details/resource-limits-section.tsx b/web/components/service/details/resource-limits-section.tsx index bd48366e..bd1a52c2 100644 --- a/web/components/service/details/resource-limits-section.tsx +++ b/web/components/service/details/resource-limits-section.tsx @@ -1,6 +1,8 @@ "use client"; -import { useState, useMemo, memo } from "react"; +import { Gauge } from "lucide-react"; +import { memo, useMemo, useState } from "react"; +import { updateServiceResourceLimits } from "@/actions/projects"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Item, ItemContent, ItemMedia, ItemTitle } from "@/components/ui/item"; @@ -8,8 +10,6 @@ import { NativeSelect, NativeSelectOption, } from "@/components/ui/native-select"; -import { Gauge } from "lucide-react"; -import { updateServiceResourceLimits } from "@/actions/projects"; import type { ServiceWithDetails as Service } from "@/db/types"; import { DEFAULT_RESOURCE_LIMITS } from "@/lib/constants"; @@ -162,8 +162,14 @@ export const ResourceLimitsSection = memo(function ResourceLimitsSection({
- +
- +
- +
- + setSchedule(e.target.value)} diff --git a/web/components/service/details/source-section.tsx b/web/components/service/details/source-section.tsx index bf8058d7..377084f1 100644 --- a/web/components/service/details/source-section.tsx +++ b/web/components/service/details/source-section.tsx @@ -58,10 +58,10 @@ export const SourceSection = memo(function SourceSection({ const [isEditing, setIsEditing] = useState(false); const [editMode, setEditMode] = useState<"github" | "image">("image"); const [isSaving, setIsSaving] = useState(false); - const [repoUrl, setRepoUrl] = useState(service.githubRepoUrl || ""); - const [branch, setBranch] = useState(service.githubBranch || "main"); - const [rootDir, setRootDir] = useState(service.githubRootDir || ""); - const [image, setImage] = useState(service.image); + const [repoUrl, setRepoUrl] = useState(""); + const [branch, setBranch] = useState("main"); + const [rootDir, setRootDir] = useState(""); + const [image, setImage] = useState(""); const [imageError, setImageError] = useState(null); const { registry, repository, tag } = parseImageInfo(service.image); diff --git a/web/components/service/details/start-command-section.tsx b/web/components/service/details/start-command-section.tsx index c09862ed..321b5ba6 100644 --- a/web/components/service/details/start-command-section.tsx +++ b/web/components/service/details/start-command-section.tsx @@ -1,11 +1,11 @@ "use client"; -import { useState, memo } from "react"; +import { Terminal } from "lucide-react"; +import { memo, useState } from "react"; +import { updateServiceStartCommand } from "@/actions/projects"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Item, ItemContent, ItemMedia, ItemTitle } from "@/components/ui/item"; -import { Terminal } from "lucide-react"; -import { updateServiceStartCommand } from "@/actions/projects"; import type { ServiceWithDetails as Service } from "@/db/types"; export const StartCommandSection = memo(function StartCommandSection({ @@ -60,8 +60,11 @@ export const StartCommandSection = memo(function StartCommandSection({
- + setCommand(e.target.value)} diff --git a/web/components/service/service-canvas.tsx b/web/components/service/service-canvas.tsx index dd9f99d7..63221d28 100644 --- a/web/components/service/service-canvas.tsx +++ b/web/components/service/service-canvas.tsx @@ -586,7 +586,7 @@ export function ServiceCanvas({ const [dockerDialogOpen, setDockerDialogOpen] = useState(false); const [githubDialogOpen, setGithubDialogOpen] = useState(false); - const [canvasScale, setCanvasScale] = useState(1); + const [canvasScale, setCanvasScale] = useState(getCanvasScale); const { data: services, @@ -604,7 +604,6 @@ export function ServiceCanvas({ useEffect(() => { const updateCanvasScale = () => setCanvasScale(getCanvasScale()); - updateCanvasScale(); window.addEventListener("resize", updateCanvasScale); return () => window.removeEventListener("resize", updateCanvasScale); diff --git a/web/components/service/service-layout-client.tsx b/web/components/service/service-layout-client.tsx index 2e38b894..04e07f9e 100644 --- a/web/components/service/service-layout-client.tsx +++ b/web/components/service/service-layout-client.tsx @@ -2,13 +2,7 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; -import { - createContext, - useCallback, - useContext, - useMemo, - useState, -} from "react"; +import { createContext, use, useCallback, useMemo, useState } from "react"; import useSWR from "swr"; import type { ServiceWithDetails as Service } from "@/db/types"; import { fetcher } from "@/lib/fetcher"; @@ -109,6 +103,28 @@ export function ServiceLayoutClient({ mutate(); }, [mutate]); + const serviceContextValue = useMemo( + () => + service + ? { + service, + pendingChanges, + projectSlug, + envName, + proxyDomain, + onUpdate: handleActionComplete, + } + : null, + [ + service, + pendingChanges, + projectSlug, + envName, + proxyDomain, + handleActionComplete, + ], + ); + const basePath = `/dashboard/projects/${projectSlug}/${envName}/services/${service?.id}`; const isConstrainedTab = @@ -193,16 +209,7 @@ export function ServiceLayoutClient({ isConstrainedTab && "container max-w-7xl mx-auto", )} > - + {children}
@@ -222,7 +229,7 @@ interface ServiceContextType { const ServiceContext = createContext(null); export function useService() { - const context = useContext(ServiceContext); + const context = use(ServiceContext); if (!context) { throw new Error("useService must be used within ServiceLayoutClient"); } diff --git a/web/components/settings/api-key-settings.tsx b/web/components/settings/api-key-settings.tsx index bcec0d21..36206cd2 100644 --- a/web/components/settings/api-key-settings.tsx +++ b/web/components/settings/api-key-settings.tsx @@ -1,10 +1,6 @@ "use client"; -import { - KeyRound, - RefreshCw, - Trash2, -} from "lucide-react"; +import { KeyRound, RefreshCw, Trash2 } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import { @@ -70,6 +66,11 @@ type ApiKeyClient = { const apiKeysClient = authClient as unknown as ApiKeyClient; +const dateTimeFormatter = new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", +}); + function getErrorMessage( error: { message?: string; error_description?: string } | null | undefined, fallback: string, @@ -83,10 +84,7 @@ function formatDate(value: string | Date | null) { const date = value instanceof Date ? value : new Date(value); if (Number.isNaN(date.getTime())) return "Unknown"; - return new Intl.DateTimeFormat(undefined, { - dateStyle: "medium", - timeStyle: "short", - }).format(date); + return dateTimeFormatter.format(date); } function describeSource(apiKey: ApiKeyRecord) { @@ -110,7 +108,7 @@ export function ApiKeySettings() { const sortedApiKeys = useMemo( () => - [...apiKeys].sort( + apiKeys.toSorted( (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), ), @@ -182,8 +180,8 @@ export function ApiKeySettings() { API Keys

- Manage keys created by CLI logins. Run tc auth login to - create a new CLI session. + Manage keys created by CLI logins. Run tc auth login{" "} + to create a new CLI session.

diff --git a/web/components/settings/global-settings.tsx b/web/components/settings/global-settings.tsx index f560eb98..746fbbb3 100644 --- a/web/components/settings/global-settings.tsx +++ b/web/components/settings/global-settings.tsx @@ -68,14 +68,16 @@ type Props = { appVersion: string | null; }; +const dateTimeFormatter = new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", +}); + function formatCheckedAt(value: string | null | undefined) { if (!value) return "Never"; const date = new Date(value); if (Number.isNaN(date.getTime())) return "Unknown"; - return new Intl.DateTimeFormat(undefined, { - dateStyle: "medium", - timeStyle: "short", - }).format(date); + return dateTimeFormatter.format(date); } function buildUpgradeCommand(targetVersion: string) {