diff --git a/AGENT.md b/AGENT.md index e70f8f06..a7030e83 100644 --- a/AGENT.md +++ b/AGENT.md @@ -37,12 +37,77 @@ An open container deployment platform. See README.md for architecture. high-value critical behavior, serious regression risk, or contracts that would be costly to break. Keep tests focused; avoid low-signal harnesses. +## Spec-driven development workflow + +For any requested code or configuration change, work through these phases in +order. Do not collapse requirements, specification, and implementation +planning into a single step. + +### 1. Research and refine requirements + +Understand the problem before designing a solution. + +- Inspect the relevant code, documentation, existing behavior, and project + constraints. +- Clarify the desired outcome, scope, non-goals, edge cases, and acceptance + criteria. +- Identify assumptions and ask focused questions when ambiguity would + materially affect the solution. +- Present the refined requirements for confirmation. + +Deliverable: agreed requirements, constraints, assumptions, and acceptance +criteria. + +### 2. Build the specification + +Describe what will be built and how it should work. + +- Define user-visible and system behavior. +- Describe the technical approach, architecture, interfaces, data flow, and + error handling. +- Address important edge cases and consequential tradeoffs. +- Keep the specification solution-level rather than file-by-file. + +Deliverable: a reviewable specification of the intended behavior and technical +design. + +### 3. Create the development plan + +Translate the specification into concrete implementation work. + +- List the files and modules that will be added, changed, renamed, or removed. +- Describe the specific changes required in each location. +- Include API, schema, type, dependency, and configuration changes where + applicable. +- Define the tests and verification commands that will be run. +- Order the work into small, reviewable steps and identify remaining risks. + +Deliverable: an actionable, file-level development plan. + +### 4. Implement after approval + +Do not modify the codebase until the user approves the development plan. + +- Implement the approved plan using the smallest correct changes and existing + project patterns. +- Run the planned verification and report the results honestly. +- If new information requires a material change to the requirements, + specification, scope, or architecture, pause implementation and return to + the appropriate phase for approval. +- Resolve minor implementation details autonomously when they do not alter the + approved behavior or scope. + +Deliverable: implemented changes, verification results, and a concise summary +of any deviations or limitations. + ## Communication -- Keep responses concise and to the point. Avoid verbose responses unless - explicitly asked. +- Keep all written content as short as possible without omitting necessary + detail. Expand only when explicitly asked. ## ⚠️ Critical restrictions +- **NEVER EVER merge a pull request.** This prohibition is absolute, even if + the pull request is approved, checks pass, or the user asks you to ship it. - **NEVER run the Node application** (`next dev`, `next start`, `pnpm dev`), Go Agent or Go CLI without explicit permission. Tests, typechecks, and `go build` are fine. diff --git a/agent/internal/agent/backup.go b/agent/internal/agent/backup.go index e5272563..454686fd 100644 --- a/agent/internal/agent/backup.go +++ b/agent/internal/agent/backup.go @@ -154,56 +154,49 @@ func (a *Agent) processVolumeRestore(backupID, serviceID, containerID, volumeNam volumePath := filepath.Join(a.DataDir, "volumes", serviceID, volumeName) log.Printf("[restore_volume] restoring volume %s to %s", volumeName, volumePath) - reportFailure := func(err error) error { - if reportErr := a.Client.ReportRestoreComplete(backupID, false, err.Error()); reportErr != nil { - log.Printf("[restore_volume] warning: failed to report restore failure: %v", reportErr) - } - return err - } - tarPath, err := tempArtifactPath(a.DataDir, fmt.Sprintf("restore-%s.tar.gz", backupID)) if err != nil { - return reportFailure(fmt.Errorf("failed to create temp archive path: %w", err)) + return fmt.Errorf("failed to create temp archive path: %w", err) } defer os.Remove(tarPath) if !strings.HasSuffix(storagePath, ".tar.gz") { - return reportFailure(fmt.Errorf("unsupported backup archive path: %s", storagePath)) + return fmt.Errorf("unsupported backup archive path: %s", storagePath) } s3Client, err := createS3Client(storageConfig) if err != nil { - return reportFailure(fmt.Errorf("failed to create S3 client: %w", err)) + return fmt.Errorf("failed to create S3 client: %w", err) } if err := downloadFromS3(s3Client, storageConfig.Bucket, storagePath, tarPath); err != nil { - return reportFailure(fmt.Errorf("failed to download from S3: %w", err)) + return fmt.Errorf("failed to download from S3: %w", err) } log.Printf("[restore_volume] downloaded from S3: %s/%s", storageConfig.Bucket, storagePath) checksum, err := calculateChecksum(tarPath) if err != nil { - return reportFailure(fmt.Errorf("failed to calculate checksum: %w", err)) + return fmt.Errorf("failed to calculate checksum: %w", err) } if checksum != expectedChecksum { - return reportFailure(fmt.Errorf("checksum mismatch: expected %s, got %s", expectedChecksum, checksum)) + return fmt.Errorf("checksum mismatch: expected %s, got %s", expectedChecksum, checksum) } tempExtractPath, err := tempArtifactPath(a.DataDir, fmt.Sprintf("restore-extract-%s", backupID)) if err != nil { - return reportFailure(fmt.Errorf("failed to create temp extract path: %w", err)) + return fmt.Errorf("failed to create temp extract path: %w", err) } defer os.RemoveAll(tempExtractPath) if err := os.MkdirAll(tempExtractPath, 0755); err != nil { - return reportFailure(fmt.Errorf("failed to create temp extract directory: %w", err)) + return fmt.Errorf("failed to create temp extract directory: %w", err) } log.Printf("[restore_volume] extracting archive to temp location for validation") if err := extractTarGz(tarPath, tempExtractPath); err != nil { - return reportFailure(fmt.Errorf("failed to extract archive: %w", err)) + return fmt.Errorf("failed to extract archive: %w", err) } var shouldStartContainer bool @@ -214,7 +207,7 @@ func (a *Agent) processVolumeRestore(backupID, serviceID, containerID, volumeNam } else if running { log.Printf("[restore_volume] stopping container %s before restore", Truncate(containerID, 12)) if err := container.Stop(containerID); err != nil { - return reportFailure(fmt.Errorf("failed to stop container: %w", err)) + return fmt.Errorf("failed to stop container: %w", err) } shouldStartContainer = true } else { @@ -243,27 +236,23 @@ func (a *Agent) processVolumeRestore(backupID, serviceID, containerID, volumeNam if err := os.RemoveAll(volumePath); err != nil && !os.IsNotExist(err) { startContainerWithRetry() - return reportFailure(fmt.Errorf("failed to remove existing volume: %w", err)) + return fmt.Errorf("failed to remove existing volume: %w", err) } if err := os.MkdirAll(filepath.Dir(volumePath), 0755); err != nil { startContainerWithRetry() - return reportFailure(fmt.Errorf("failed to create volume parent directory: %w", err)) + return fmt.Errorf("failed to create volume parent directory: %w", err) } if err := moveDir(tempExtractPath, volumePath); err != nil { startContainerWithRetry() - return reportFailure(fmt.Errorf("failed to move restored data to volume path: %w", err)) + return fmt.Errorf("failed to move restored data to volume path: %w", err) } startContainerWithRetry() log.Printf("[restore_volume] restored volume %s successfully", volumeName) - if err := a.Client.ReportRestoreComplete(backupID, true, ""); err != nil { - log.Printf("[restore_volume] warning: failed to report restore complete: %v", err) - } - return nil } diff --git a/agent/internal/http/client.go b/agent/internal/http/client.go index 2dd8e55f..ee2d3c7b 100644 --- a/agent/internal/http/client.go +++ b/agent/internal/http/client.go @@ -599,26 +599,3 @@ func (c *Client) ReportBackupFailed(backupID string, errorMsg string) error { return nil } - -func (c *Client) ReportRestoreComplete(backupID string, success bool, errorMsg string) error { - payload := map[string]interface{}{ - "backupId": backupID, - "success": success, - } - if errorMsg != "" { - payload["error"] = errorMsg - } - - body, err := json.Marshal(payload) - if err != nil { - return fmt.Errorf("failed to marshal restore complete: %w", err) - } - - resp, err := c.doSignedJSONRequest(c.baseURL+"/api/v1/agent/restore/complete", body, []int{http.StatusOK}, "failed to report restore complete", "restore complete report failed") - if err != nil { - return err - } - defer resp.Body.Close() - - return nil -} diff --git a/agent/internal/traefik/l4.go b/agent/internal/traefik/l4.go index 5e94d5c8..20c513b1 100644 --- a/agent/internal/traefik/l4.go +++ b/agent/internal/traefik/l4.go @@ -49,7 +49,10 @@ func CompileRoutes(httpRoutes []TraefikRoute, tcpRoutes []TraefikTCPRoute, udpRo } var middlewareNames []string if serverName != "" { - config.HTTP.Middlewares["forwarded_server"] = middleware{Headers: &headersMiddleware{CustomRequestHeaders: map[string]string{"X-Forwarded-Server": serverName}}} + config.HTTP.Middlewares["forwarded_server"] = middleware{Headers: &headersMiddleware{ + CustomRequestHeaders: map[string]string{"X-Forwarded-Server": serverName}, + CustomResponseHeaders: map[string]string{"X-Techulus-Host": serverName}, + }} middlewareNames = []string{"forwarded_server@file"} } for _, route := range httpRoutes { diff --git a/agent/internal/traefik/routes_test.go b/agent/internal/traefik/routes_test.go index 911dc6ea..ff9a9734 100644 --- a/agent/internal/traefik/routes_test.go +++ b/agent/internal/traefik/routes_test.go @@ -84,6 +84,9 @@ func TestRoutesConfigRoundTripProducesConvergentHash(t *testing.T) { if forwardedServer.Headers == nil || forwardedServer.Headers.CustomRequestHeaders["X-Forwarded-Server"] != "proxy-1" { t.Fatalf("forwarded server middleware was not preserved: %#v", forwardedServer) } + if forwardedServer.Headers.CustomResponseHeaders["X-Techulus-Host"] != "proxy-1" { + t.Fatalf("Techulus host response header was not preserved: %#v", forwardedServer) + } tcpRouter := config.TCP.Routers[resourceName("tcp", "service-tcp", "tcp-route")] if tcpRouter.TLS == nil || !tcpRouter.TLS.Passthrough { t.Fatalf("TCP TLS passthrough was not preserved: %#v", tcpRouter) diff --git a/agent/internal/traefik/types.go b/agent/internal/traefik/types.go index 6fabff48..0ed5237a 100644 --- a/agent/internal/traefik/types.go +++ b/agent/internal/traefik/types.go @@ -69,7 +69,8 @@ type middleware struct { } type headersMiddleware struct { - CustomRequestHeaders map[string]string `yaml:"customRequestHeaders,omitempty"` + CustomRequestHeaders map[string]string `yaml:"customRequestHeaders,omitempty"` + CustomResponseHeaders map[string]string `yaml:"customResponseHeaders,omitempty"` } type replacePathRegex struct { diff --git a/deployment/.env.example b/deployment/.env.example index 40fb9e72..bba5d325 100644 --- a/deployment/.env.example +++ b/deployment/.env.example @@ -30,6 +30,7 @@ VM_PASSWORD=your-secure-metrics-password VM_RETENTION=30d # Registry +REGISTRY_URL=registry:5000 REGISTRY_USERNAME=admin REGISTRY_PASSWORD=your-registry-password REGISTRY_HTTP_SECRET=your-registry-http-secret diff --git a/deployment/compose.postgres.yml b/deployment/compose.postgres.yml index 1c1ae7b9..705ee6f1 100644 --- a/deployment/compose.postgres.yml +++ b/deployment/compose.postgres.yml @@ -82,7 +82,6 @@ services: - VICTORIA_LOGS_PRIVATE_URL=http://${VL_USERNAME}:${VL_PASSWORD}@victoria-logs:9428 - VICTORIA_METRICS_URL=https://${VM_USERNAME}:${VM_PASSWORD}@metrics.${ROOT_DOMAIN} - VICTORIA_METRICS_PRIVATE_URL=http://${VM_USERNAME}:${VM_PASSWORD}@victoria-metrics:8428 - - REGISTRY_URL=registry:5000 - REGISTRY_HOST=registry.${ROOT_DOMAIN} - INNGEST_BASE_URL=http://inngest:8288 - INNGEST_SIGNING_KEY=${INNGEST_SIGNING_KEY} @@ -110,7 +109,7 @@ services: - VICTORIA_LOGS_PRIVATE_URL=http://${VL_USERNAME}:${VL_PASSWORD}@victoria-logs:9428 - VICTORIA_METRICS_URL=https://${VM_USERNAME}:${VM_PASSWORD}@metrics.${ROOT_DOMAIN} - VICTORIA_METRICS_PRIVATE_URL=http://${VM_USERNAME}:${VM_PASSWORD}@victoria-metrics:8428 - - REGISTRY_URL=registry:5000 + - REGISTRY_URL=${REGISTRY_URL:-registry:5000} - REGISTRY_HOST=registry.${ROOT_DOMAIN} - INNGEST_BASE_URL=http://inngest:8288 - INNGEST_SIGNING_KEY=${INNGEST_SIGNING_KEY} diff --git a/deployment/compose.production.yml b/deployment/compose.production.yml index bea012d3..097eb523 100644 --- a/deployment/compose.production.yml +++ b/deployment/compose.production.yml @@ -64,7 +64,6 @@ services: - VICTORIA_LOGS_PRIVATE_URL=http://${VL_USERNAME}:${VL_PASSWORD}@victoria-logs:9428 - VICTORIA_METRICS_URL=https://${VM_USERNAME}:${VM_PASSWORD}@metrics.${ROOT_DOMAIN} - VICTORIA_METRICS_PRIVATE_URL=http://${VM_USERNAME}:${VM_PASSWORD}@victoria-metrics:8428 - - REGISTRY_URL=registry:5000 - REGISTRY_HOST=registry.${ROOT_DOMAIN} - INNGEST_BASE_URL=http://inngest:8288 - INNGEST_SIGNING_KEY=${INNGEST_SIGNING_KEY} @@ -89,7 +88,7 @@ services: - VICTORIA_LOGS_PRIVATE_URL=http://${VL_USERNAME}:${VL_PASSWORD}@victoria-logs:9428 - VICTORIA_METRICS_URL=https://${VM_USERNAME}:${VM_PASSWORD}@metrics.${ROOT_DOMAIN} - VICTORIA_METRICS_PRIVATE_URL=http://${VM_USERNAME}:${VM_PASSWORD}@victoria-metrics:8428 - - REGISTRY_URL=registry:5000 + - REGISTRY_URL=${REGISTRY_URL:-registry:5000} - REGISTRY_HOST=registry.${ROOT_DOMAIN} - INNGEST_BASE_URL=http://inngest:8288 - INNGEST_SIGNING_KEY=${INNGEST_SIGNING_KEY} diff --git a/deployment/install.sh b/deployment/install.sh index 6be3364c..403e727c 100755 --- a/deployment/install.sh +++ b/deployment/install.sh @@ -406,6 +406,7 @@ VM_USERNAME=${VM_USERNAME} VM_PASSWORD=${VM_PASSWORD} VM_RETENTION=30d +REGISTRY_URL=registry:5000 REGISTRY_USERNAME=${REGISTRY_USERNAME} REGISTRY_PASSWORD=${REGISTRY_PASSWORD} REGISTRY_HTTP_SECRET=${REGISTRY_HTTP_SECRET} diff --git a/docs/api/public-api.mdx b/docs/api/public-api.mdx index dfbfb153..745d70cd 100644 --- a/docs/api/public-api.mdx +++ b/docs/api/public-api.mdx @@ -143,6 +143,8 @@ Configuration and revision responses never include secret names, values, or ciph `PUT /configuration` is atomic and replaces the complete managed configuration. Send the `currentVersion` returned by the plan as one quoted strong ETag (for example, `If-Match: "sha256:…"`). Missing, unquoted, multiple, weak, or otherwise invalid headers are rejected; a service change after planning returns `409 CONFIGURATION_PLAN_STALE`. A successful response returns the authoritative target and the structured change set that was applied. The request must contain exactly `name`, `source`, `hostname`, `ports`, `placement`, `healthCheck`, `startCommand`, and `resources`. Omitted or unknown fields are rejected. `hostname` must be concrete and non-null. Use `null` to clear nullable fields, including `resources`. +Replacing the final public HTTP port with a domain automatically disables serverless in the mutable service configuration. The plan reports this side effect as a `serverless.enabled` change before apply. You do not send serverless settings in the replacement request. + ```json { "name": "web", @@ -215,7 +217,7 @@ Use manual placement to choose exact servers: } ``` -Manual placement requires online servers with WireGuard configured. Serverless services require proxy servers, including when automatic placement is used. Automatic placement is not available for stateful or volume-backed services. Submit replica changes through `placement`; the API rejects a top-level `replicas` field. +Manual placement requires online servers with WireGuard configured. Serverless services require proxy servers, including when automatic placement is used. A replacement that removes the final public HTTP domain can move the service to worker placement because the same update disables serverless. Automatic placement is not available for stateful or volume-backed services. Submit replica changes through `placement`; the API rejects a top-level `replicas` field. The API only manages stateless services with HTTP ports. Existing volumes, stateful mode, TCP or UDP ports, TLS passthrough, or invalid resource limits return a conflict with an actionable code. diff --git a/docs/services/scaling.mdx b/docs/services/scaling.mdx index 1ed8c62b..416898e1 100644 --- a/docs/services/scaling.mdx +++ b/docs/services/scaling.mdx @@ -30,6 +30,10 @@ Serverless settings are configured per service: | Sleep after | `300s` | Idle period before running containers are stopped | | Wake timeout | `300s` | Maximum time the wake gateway waits for ready upstreams | +The serverless settings appear only when the service has a public HTTP port with +a domain. Removing the final qualifying endpoint disables serverless in the +pending service configuration. + A cold wake starts the sleeping local proxy replicas for that host. Held requests resume when one upstream is ready. diff --git a/web/actions/projects.ts b/web/actions/projects.ts index 204f73df..cdb8d75c 100644 --- a/web/actions/projects.ts +++ b/web/actions/projects.ts @@ -1225,6 +1225,17 @@ export async function updateServiceConfig( })), ); if (issue) throw new Error(issue.message); + if ( + !finalPorts.some( + (port) => + port.isPublic && port.protocol === "http" && port.domain !== null, + ) + ) { + await tx + .update(services) + .set({ serverlessEnabled: false }) + .where(eq(services.id, serviceId)); + } for (const port of additions) { if (!port.domain) continue; diff --git a/web/app/(dashboard)/dashboard/page.tsx b/web/app/(dashboard)/dashboard/page.tsx index 6e716e16..6b657644 100644 --- a/web/app/(dashboard)/dashboard/page.tsx +++ b/web/app/(dashboard)/dashboard/page.tsx @@ -3,7 +3,10 @@ import Link from "next/link"; import { ClusterHealthSummary } from "@/components/cluster/cluster-health-summary"; import { SUMMARY_CARD_CLASSNAME, - SUMMARY_CARD_MIN_HEIGHT, + SUMMARY_CARD_COMPACT_CLASSNAME, + SUMMARY_CARD_COMPACT_TEXT_CLASSNAME, + SUMMARY_CARD_COMPACT_TITLE_CLASSNAME, + SUMMARY_CARD_GRID_CLASSNAME, SummaryCardStat, SummaryCardTitle, SummaryCardValue, @@ -19,6 +22,7 @@ import { EmptyTitle, } from "@/components/ui/empty"; import { getClusterHealth, listProjects, listServers } from "@/db/queries"; +import { cn } from "@/lib/utils"; export default async function DashboardPage() { const [servers, projects, clusterHealth] = await Promise.all([ @@ -28,12 +32,12 @@ export default async function DashboardPage() { ]); return ( -
-
+
+

Projects

-

+

Deploy and manage services

@@ -54,18 +58,29 @@ export default async function DashboardPage() { ) : ( -
+
{projects.map((project) => ( - {project.name} -
- - + + {project.name} + +
+ + {project.serviceCount === 0 ? ( none @@ -73,15 +88,20 @@ export default async function DashboardPage() { ) : ( <> {project.onlineServiceCount}/{project.serviceCount}{" "} - + online )} - - + + {project.environmentCount} @@ -92,7 +112,7 @@ export default async function DashboardPage() { )}
-
+

Servers

diff --git a/web/app/(dashboard)/layout-client.tsx b/web/app/(dashboard)/layout-client.tsx index 9de03ef2..4e2501ee 100644 --- a/web/app/(dashboard)/layout-client.tsx +++ b/web/app/(dashboard)/layout-client.tsx @@ -9,6 +9,7 @@ import { BreadcrumbDataProvider, useBreadcrumbs, } from "@/components/core/breadcrumb-data"; +import { DashboardCommandMenu } from "@/components/dashboard/dashboard-command-menu"; import { DashboardPageSkeleton } from "@/components/dashboard/dashboard-page-skeleton"; import { OfflineServersBanner } from "@/components/server/offline-servers-banner"; import { @@ -37,8 +38,8 @@ function DashboardHeader({ email, name }: { email: string; name: string }) { return (
-
-
+
+
{breadcrumbs.length > 0 ? ( <> -
- - } - > - - - - - - - {name} - - - {email} - - - - - } - className="cursor-pointer" +
+ + + } > - - Settings - - - signOut().then(() => router.push("/"))} - className="cursor-pointer" - > - - Sign Out - - - + + + + + + + {name} + + + {email} + + + + + } + className="cursor-pointer" + > + + Settings + + + signOut().then(() => router.push("/"))} + className="cursor-pointer" + > + + Sign Out + + + +
); diff --git a/web/app/api/navigation/route.ts b/web/app/api/navigation/route.ts new file mode 100644 index 00000000..325eab69 --- /dev/null +++ b/web/app/api/navigation/route.ts @@ -0,0 +1,47 @@ +import { and, eq, isNull } from "drizzle-orm"; +import { headers } from "next/headers"; +import { db } from "@/db"; +import { environments, projects, servers, services } from "@/db/schema"; +import { auth } from "@/lib/auth"; +import { buildNavigationItems } from "@/lib/navigation"; + +export async function GET() { + const session = await auth.api.getSession({ + headers: await headers(), + }); + + if (!session) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + const [entityRows, serverRows] = await Promise.all([ + db + .select({ + projectId: projects.id, + projectName: projects.name, + projectSlug: projects.slug, + environmentId: environments.id, + environmentName: environments.name, + serviceId: services.id, + serviceName: services.name, + serviceHostname: services.hostname, + }) + .from(projects) + .leftJoin(environments, eq(environments.projectId, projects.id)) + .leftJoin( + services, + and( + eq(services.projectId, projects.id), + eq(services.environmentId, environments.id), + isNull(services.deletedAt), + ), + ) + .orderBy(projects.name, environments.name, services.name), + db + .select({ id: servers.id, name: servers.name }) + .from(servers) + .orderBy(servers.name), + ]); + + return Response.json({ items: buildNavigationItems(entityRows, serverRows) }); +} diff --git a/web/app/api/projects/[id]/services/[serviceId]/position/route.ts b/web/app/api/projects/[id]/services/[serviceId]/position/route.ts deleted file mode 100644 index e6e140ff..00000000 --- a/web/app/api/projects/[id]/services/[serviceId]/position/route.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { and, eq, isNull } from "drizzle-orm"; -import { z } from "zod"; -import { db } from "@/db"; -import { services } from "@/db/schema"; -import { requireRequestDeveloperRole } from "@/lib/api-auth"; - -const positionSchema = z.object({ - canvasX: z.number().int().min(0).max(10000), - canvasY: z.number().int().min(0).max(10000), -}); - -export async function PATCH( - request: Request, - { params }: { params: Promise<{ id: string; serviceId: string }> }, -) { - const sessionResult = await requireRequestDeveloperRole(request); - if (!sessionResult.ok) { - return sessionResult.response; - } - - const { id: projectId, serviceId } = await params; - const parsed = positionSchema.safeParse(await request.json()); - - if (!parsed.success) { - return Response.json({ error: "Invalid position" }, { status: 400 }); - } - - const [service] = await db - .update(services) - .set(parsed.data) - .where( - and( - eq(services.id, serviceId), - eq(services.projectId, projectId), - isNull(services.deletedAt), - ), - ) - .returning({ - id: services.id, - canvasX: services.canvasX, - canvasY: services.canvasY, - }); - - if (!service) { - return Response.json({ error: "Service not found" }, { status: 404 }); - } - - return Response.json(service); -} diff --git a/web/app/api/projects/[id]/services/route.ts b/web/app/api/projects/[id]/services/route.ts index e695a620..a9941f38 100644 --- a/web/app/api/projects/[id]/services/route.ts +++ b/web/app/api/projects/[id]/services/route.ts @@ -1,5 +1,6 @@ import { and, desc, eq, inArray, isNull } from "drizzle-orm"; import { headers } from "next/headers"; +import { z } from "zod"; import { db } from "@/db"; import { builds, @@ -16,6 +17,7 @@ import { serviceVolumes, volumeBackups, } from "@/db/schema"; +import { requireRequestDeveloperRole } from "@/lib/api-auth"; import { auth } from "@/lib/auth"; import { getTimestamp } from "@/lib/date"; import { resolvePersistedSourceFromRows } from "@/lib/public-api"; @@ -25,6 +27,111 @@ import { } from "@/lib/service-config"; import { parseServiceRevisionSpec } from "@/lib/service-revision-changes"; +const MAX_CANVAS_COORDINATE = 2_147_483_647; + +class CanvasServiceNotFoundError extends Error {} + +const canvasPositionsSchema = z.object({ + positions: z + .array( + z.object({ + serviceId: z.string().min(1), + canvasX: z.number().int().min(0).max(MAX_CANVAS_COORDINATE), + canvasY: z.number().int().min(0).max(MAX_CANVAS_COORDINATE), + }), + ) + .min(1) + .max(10_000) + .refine( + (positions) => + new Set(positions.map((position) => position.serviceId)).size === + positions.length, + ), +}); + +export async function PATCH( + request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const sessionResult = await requireRequestDeveloperRole(request); + if (!sessionResult.ok) { + return sessionResult.response; + } + + const { id: projectId } = await params; + const body = await request.json().catch(() => null); + const parsed = canvasPositionsSchema.safeParse(body); + + if (!parsed.success) { + return Response.json({ error: "Invalid positions" }, { status: 400 }); + } + + try { + const savedPositions = await db.transaction(async (tx) => { + const serviceIds = parsed.data.positions.map( + (position) => position.serviceId, + ); + const activeServices = await tx + .select({ id: services.id }) + .from(services) + .where( + and( + eq(services.projectId, projectId), + inArray(services.id, serviceIds), + isNull(services.deletedAt), + ), + ); + + if (activeServices.length !== parsed.data.positions.length) { + throw new CanvasServiceNotFoundError(); + } + + const positions: Array<{ + id: string; + canvasX: number | null; + canvasY: number | null; + }> = []; + + for (const position of parsed.data.positions) { + const [savedPosition] = await tx + .update(services) + .set({ + canvasX: position.canvasX, + canvasY: position.canvasY, + }) + .where( + and( + eq(services.id, position.serviceId), + eq(services.projectId, projectId), + isNull(services.deletedAt), + ), + ) + .returning({ + id: services.id, + canvasX: services.canvasX, + canvasY: services.canvasY, + }); + + if (!savedPosition) { + throw new CanvasServiceNotFoundError(); + } + + positions.push(savedPosition); + } + + return positions; + }); + + return Response.json(savedPositions); + } catch (error) { + if (error instanceof CanvasServiceNotFoundError) { + return Response.json({ error: "Service not found" }, { status: 404 }); + } + + throw error; + } +} + export async function GET( request: Request, { params }: { params: Promise<{ id: string }> }, diff --git a/web/app/api/v1/agent/restore/complete/route.ts b/web/app/api/v1/agent/restore/complete/route.ts deleted file mode 100644 index 229e7544..00000000 --- a/web/app/api/v1/agent/restore/complete/route.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { eq } from "drizzle-orm"; -import { revalidatePath } from "next/cache"; -import { type NextRequest, NextResponse } from "next/server"; -import { db } from "@/db"; -import { volumeBackups } from "@/db/schema"; -import { verifyAgentRequest } from "@/lib/agent-auth"; -import { inngest } from "@/lib/inngest/client"; -import { inngestEvents } from "@/lib/inngest/events"; - -export async function POST(request: NextRequest) { - const body = await request.text(); - const auth = await verifyAgentRequest(request, body); - if (!auth.success) { - return NextResponse.json({ error: auth.error }, { status: auth.status }); - } - - let data: { - backupId: string; - success: boolean; - error?: string; - isMigrationRestore?: boolean; - }; - try { - data = JSON.parse(body); - } catch { - return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); - } - - const { backupId, success, error, isMigrationRestore } = data; - - if (!backupId) { - return NextResponse.json({ error: "Missing backupId" }, { status: 400 }); - } - - const backup = await db - .select() - .from(volumeBackups) - .where(eq(volumeBackups.id, backupId)) - .then((r) => r[0]); - - if (!backup) { - return NextResponse.json({ ok: true }); - } - - const isMigration = isMigrationRestore ?? backup.isMigrationBackup ?? false; - - revalidatePath("/dashboard/projects"); - - if (success) { - await inngest.send( - inngestEvents.restoreCompleted.create({ - backupId, - volumeId: backup.volumeId, - serviceId: backup.serviceId, - isMigrationRestore: isMigration, - }), - ); - - if (isMigration) { - await inngest.send( - inngestEvents.migrationRestoreCompleted.create({ - backupId, - serviceId: backup.serviceId, - }), - ); - await inngest.send( - inngestEvents.migrationRestoreFinished.create({ - backupId, - serviceId: backup.serviceId, - status: "completed", - }), - ); - } - } else { - await inngest.send( - inngestEvents.restoreFailed.create({ - backupId, - volumeId: backup.volumeId, - serviceId: backup.serviceId, - error: error || "Restore failed", - isMigrationRestore: isMigration, - }), - ); - - if (isMigration) { - const message = error || "Restore failed"; - await inngest.send( - inngestEvents.migrationRestoreFailed.create({ - backupId, - serviceId: backup.serviceId, - error: message, - }), - ); - await inngest.send( - inngestEvents.migrationRestoreFinished.create({ - backupId, - serviceId: backup.serviceId, - status: "failed", - error: message, - }), - ); - } - } - - return NextResponse.json({ ok: true }); -} diff --git a/web/components/core/status-indicator.tsx b/web/components/core/status-indicator.tsx index cf581ab6..cfa2bc28 100644 --- a/web/components/core/status-indicator.tsx +++ b/web/components/core/status-indicator.tsx @@ -1,3 +1,5 @@ +import { cn } from "@/lib/utils"; + const STATUS_COLORS: Record = { online: { dot: "bg-emerald-500", @@ -20,9 +22,11 @@ const STATUS_COLORS: Record = { export function StatusIndicator({ status, showLabel = false, + labelClassName, }: { status: string; showLabel?: boolean; + labelClassName?: string; }) { const color = STATUS_COLORS[status] || STATUS_COLORS.unknown; @@ -40,7 +44,11 @@ export function StatusIndicator({ {showLabel && ( {status} diff --git a/web/components/core/summary-card.tsx b/web/components/core/summary-card.tsx index 104063cd..e8eacf91 100644 --- a/web/components/core/summary-card.tsx +++ b/web/components/core/summary-card.tsx @@ -4,6 +4,17 @@ import { cn } from "@/lib/utils"; export const SUMMARY_CARD_MIN_HEIGHT = 148; +// Dashboard lists lay cards out two per row on mobile, so below `sm` they shed +// height, padding and a step of font size. The `sm` min-height must stay in +// sync with SUMMARY_CARD_MIN_HEIGHT. +export const SUMMARY_CARD_GRID_CLASSNAME = + "grid grid-cols-2 gap-2.5 sm:gap-4 lg:grid-cols-3"; +export const SUMMARY_CARD_COMPACT_CLASSNAME = + "min-h-[96px] px-3 py-2.5 sm:min-h-[148px] sm:px-3.5 sm:py-3"; +export const SUMMARY_CARD_COMPACT_TITLE_CLASSNAME = + "text-[13px] sm:text-[15px]"; +export const SUMMARY_CARD_COMPACT_TEXT_CLASSNAME = "text-[11px] sm:text-xs"; + export const SUMMARY_CARD_CLASSNAME = "group flex w-full flex-col rounded-xl border border-slate-200 dark:border-slate-700 bg-white/50 dark:bg-slate-900/50 px-3.5 py-3 transition-all duration-200 hover:ring hover:ring-primary/25 dark:hover:ring-primary/55"; @@ -46,12 +57,14 @@ export function SummaryCardLine({ export function SummaryCardStat({ label, children, + className, }: { label: string; children: ReactNode; + className?: string; }) { return ( -
+
{label} @@ -61,9 +74,20 @@ export function SummaryCardStat({ ); } -export function SummaryCardValue({ children }: { children: ReactNode }) { +export function SummaryCardValue({ + children, + className, +}: { + children: ReactNode; + className?: string; +}) { return ( - + {children} ); diff --git a/web/components/dashboard/dashboard-command-menu.tsx b/web/components/dashboard/dashboard-command-menu.tsx new file mode 100644 index 00000000..4e609dcd --- /dev/null +++ b/web/components/dashboard/dashboard-command-menu.tsx @@ -0,0 +1,187 @@ +"use client"; + +import { + BoxIcon, + FolderIcon, + LayoutDashboardIcon, + SearchIcon, + ServerIcon, +} from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; +import useSWR from "swr"; +import { Button } from "@/components/ui/button"; +import { + Command, + CommandDialog, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { Spinner } from "@/components/ui/spinner"; +import { fetcher } from "@/lib/fetcher"; +import type { + NavigationGroup, + NavigationItem, + NavigationResponse, +} from "@/lib/navigation"; + +const groups: NavigationGroup[] = ["Pages", "Projects", "Services", "Servers"]; + +function ResultIcon({ item }: { item: NavigationItem }) { + const className = "size-4 text-muted-foreground"; + if (item.kind === "page") + return ; + if (item.kind === "service") return ; + if (item.kind === "server") return ; + return ; +} + +function NavigationResult({ + item, + onSelect, +}: { + item: NavigationItem; + onSelect: (href: string) => void; +}) { + return ( + onSelect(item.href)} + > + + + {item.label} + {item.description && ( + + {item.description} + + )} + + + ); +} + +export function DashboardCommandMenu() { + const router = useRouter(); + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(""); + const { data, error, isLoading, mutate } = useSWR( + open ? "/api/navigation" : null, + fetcher, + { + dedupingInterval: 60_000, + revalidateOnFocus: false, + revalidateOnReconnect: false, + }, + ); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key.toLowerCase() === "k" && (event.metaKey || event.ctrlKey)) { + event.preventDefault(); + setSearch(""); + setOpen((current) => !current); + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, []); + + const handleOpenChange = (nextOpen: boolean) => { + setOpen(nextOpen); + if (!nextOpen) setSearch(""); + }; + + const handleSelect = (href: string) => { + setOpen(false); + setSearch(""); + router.push(href); + }; + + return ( + <> + + + + + + + {isLoading ? ( +
+ + Loading navigation… +
+ ) : error ? ( +
+

+ Could not load navigation. Try again. +

+ +
+ ) : ( + <> + No pages found. + {search.trim() + ? data?.items.map((item) => ( + + )) + : groups.map((group) => { + const items = data?.items.filter( + (item) => item.group === group, + ); + if (!items?.length) return null; + + return ( + + {items.map((item) => ( + + ))} + + ); + })} + + )} +
+
+
+ + ); +} diff --git a/web/components/server/server-list.tsx b/web/components/server/server-list.tsx index 8cf6abf9..d9948b14 100644 --- a/web/components/server/server-list.tsx +++ b/web/components/server/server-list.tsx @@ -6,7 +6,10 @@ import useSWR from "swr"; import { StatusIndicator } from "@/components/core/status-indicator"; import { SUMMARY_CARD_CLASSNAME, - SUMMARY_CARD_MIN_HEIGHT, + SUMMARY_CARD_COMPACT_CLASSNAME, + SUMMARY_CARD_COMPACT_TEXT_CLASSNAME, + SUMMARY_CARD_COMPACT_TITLE_CLASSNAME, + SUMMARY_CARD_GRID_CLASSNAME, SummaryCardLine, SummaryCardStat, SummaryCardTitle, @@ -22,6 +25,7 @@ import { } from "@/components/ui/empty"; import type { Server } from "@/db/types"; import { fetcher } from "@/lib/fetcher"; +import { cn } from "@/lib/utils"; type ServerWithResources = Pick< Server, @@ -103,38 +107,60 @@ export function ServerList({ ) : ( -
+
{servers.map((server) => (
- + {server.name} {server.isProxy && ( - + proxy )}
-
+
-
- - +
+ + {formatOsArch(server) || "—"} - - + +
diff --git a/web/components/service/details/serverless-section.tsx b/web/components/service/details/serverless-section.tsx index 6ede8640..9557d698 100644 --- a/web/components/service/details/serverless-section.tsx +++ b/web/components/service/details/serverless-section.tsx @@ -19,6 +19,11 @@ export const ServerlessSection = memo(function ServerlessSection( props: ServerlessSectionProps, ) { const { service } = props; + const hasPublicHttpEndpoint = service.ports.some( + (port) => port.isPublic && port.protocol === "http" && !!port.domain, + ); + if (!hasPublicHttpEndpoint) return null; + // Persisted changes are authoritative and intentionally discard any stale local draft. const settingsKey = `${service.id}:${service.serverlessEnabled}:${service.serverlessSleepAfterSeconds}:${service.serverlessWakeTimeoutSeconds}`; @@ -47,21 +52,12 @@ function ServerlessSectionEditor({ const [wakeTimeoutSeconds, setWakeTimeoutSeconds] = useState( String(service.serverlessWakeTimeoutSeconds ?? 300), ); - const hasPublicHttpEndpoint = useMemo( - () => - service.ports.some( - (port) => port.isPublic && port.protocol === "http" && !!port.domain, - ), - [service.ports], - ); const hasWorkerReplica = service.configuredReplicas.some( (replica) => replica.count > 0 && !replica.serverIsProxy, ); - const unavailableReason = !hasPublicHttpEndpoint - ? "Add a public HTTP port with a domain to enable serverless" - : hasWorkerReplica - ? "Serverless services can only be deployed to proxy nodes" - : null; + const unavailableReason = hasWorkerReplica + ? "Serverless services can only be deployed to proxy nodes" + : null; const optionsDisabled = !!unavailableReason || isSaving; const parsed = useMemo( diff --git a/web/components/service/details/service-details-overview.tsx b/web/components/service/details/service-details-overview.tsx index 06ef8bc1..44d24917 100644 --- a/web/components/service/details/service-details-overview.tsx +++ b/web/components/service/details/service-details-overview.tsx @@ -327,7 +327,7 @@ export function ServiceMetricsPanel({ ? formatCompactDate(value) : formatCompactDateTime(value) } - className="text-xs" + className="text-[10px]" /> formatAxisTick(Number(value), chartMode) } - className="text-xs" + className="text-[10px]" /> + service.canvasY === null + ? 0 + : service.canvasY + SERVICE_CARD_HEIGHT + CANVAS_VERTICAL_PADDING, + ), + ); + + return Math.max(gridCanvasHeight, persistedCanvasHeight); +} + +function getAutoLayoutPosition( + index: number, + serviceCount: number, + canvasHeight: number, +): CanvasPosition { const row = Math.floor(index / DEFAULT_GRID_COLUMNS); + const rowStartIndex = row * DEFAULT_GRID_COLUMNS; + const cardsInRow = Math.min( + DEFAULT_GRID_COLUMNS, + serviceCount - rowStartIndex, + ); + const rowWidth = + cardsInRow * SERVICE_CARD_WIDTH + + Math.max(0, cardsInRow - 1) * SERVICE_CARD_GAP_X; + const rowStartX = (CANVAS_WIDTH - rowWidth) / 2; + const rowCount = Math.ceil(serviceCount / DEFAULT_GRID_COLUMNS); + const gridHeight = + rowCount * SERVICE_CARD_HEIGHT + + Math.max(0, rowCount - 1) * SERVICE_CARD_GAP_Y; + const gridStartY = (canvasHeight - gridHeight) / 2; + const column = index - rowStartIndex; return { - canvasX: gridStartX + column * (SERVICE_CARD_WIDTH + SERVICE_CARD_GAP_X), + canvasX: rowStartX + column * (SERVICE_CARD_WIDTH + SERVICE_CARD_GAP_X), canvasY: gridStartY + row * (SERVICE_CARD_HEIGHT + SERVICE_CARD_GAP_Y), }; } -function clampPosition(position: CanvasPosition): CanvasPosition { +function clampPosition( + position: CanvasPosition, + canvasHeight: number, +): CanvasPosition { return { canvasX: Math.max( 0, @@ -147,25 +190,33 @@ function clampPosition(position: CanvasPosition): CanvasPosition { canvasY: Math.max( 0, Math.min( - CANVAS_HEIGHT - SERVICE_CARD_HEIGHT, + canvasHeight - SERVICE_CARD_HEIGHT, Math.round(position.canvasY), ), ), }; } -function snapPosition(position: CanvasPosition): CanvasPosition { - return clampPosition({ - canvasX: Math.round(position.canvasX / SNAP_GRID_SIZE) * SNAP_GRID_SIZE, - canvasY: Math.round(position.canvasY / SNAP_GRID_SIZE) * SNAP_GRID_SIZE, - }); +function snapPosition( + position: CanvasPosition, + canvasHeight: number, +): CanvasPosition { + return clampPosition( + { + canvasX: Math.round(position.canvasX / SNAP_GRID_SIZE) * SNAP_GRID_SIZE, + canvasY: Math.round(position.canvasY / SNAP_GRID_SIZE) * SNAP_GRID_SIZE, + }, + canvasHeight, + ); } function getServicePosition( service: ServiceWithDetails, index: number, + serviceCount: number, + canvasHeight: number, ): CanvasPosition { - const fallback = getDefaultServicePosition(index); + const fallback = getAutoLayoutPosition(index, serviceCount, canvasHeight); return { canvasX: service.canvasX ?? fallback.canvasX, @@ -399,16 +450,24 @@ function ServiceCard({ function DraggableServiceCard({ service, index, + serviceCount, projectSlug, envName, canvasScale, + canvasHeight, + positionWritePending, + positionWritePendingRef, onPositionChange, }: { service: ServiceWithDetails; index: number; + serviceCount: number; projectSlug: string; envName: string; canvasScale: number; + canvasHeight: number; + positionWritePending: boolean; + positionWritePendingRef: { current: boolean }; onPositionChange: (serviceId: string, position: CanvasPosition) => void; }) { const [dragPosition, setDragPosition] = useState(null); @@ -420,11 +479,13 @@ function DraggableServiceCard({ moved: boolean; } | null>(null); const suppressClickRef = useRef(false); - const position = dragPosition ?? getServicePosition(service, index); + const position = + dragPosition ?? + getServicePosition(service, index, serviceCount, canvasHeight); const handlePointerDown = useCallback( (event: PointerEvent) => { - if (event.button !== 0) { + if (event.button !== 0 || positionWritePendingRef.current) { return; } @@ -437,7 +498,7 @@ function DraggableServiceCard({ moved: false, }; }, - [position], + [position, positionWritePendingRef], ); const handlePointerMove = useCallback( @@ -449,10 +510,13 @@ function DraggableServiceCard({ const deltaX = (event.clientX - drag.startX) / canvasScale; const deltaY = (event.clientY - drag.startY) / canvasScale; - const nextPosition = clampPosition({ - canvasX: drag.origin.canvasX + deltaX, - canvasY: drag.origin.canvasY + deltaY, - }); + const nextPosition = clampPosition( + { + canvasX: drag.origin.canvasX + deltaX, + canvasY: drag.origin.canvasY + deltaY, + }, + canvasHeight, + ); if (Math.abs(deltaX) > 3 || Math.abs(deltaY) > 3) { drag.moved = true; @@ -461,7 +525,7 @@ function DraggableServiceCard({ setDragPosition(nextPosition); }, - [canvasScale], + [canvasHeight, canvasScale], ); const handlePointerUp = useCallback( @@ -479,10 +543,10 @@ function DraggableServiceCard({ if (drag.moved) { suppressClickRef.current = true; - onPositionChange(service.id, snapPosition(position)); + onPositionChange(service.id, snapPosition(position, canvasHeight)); } }, - [onPositionChange, position, service.id], + [canvasHeight, onPositionChange, position, service.id], ); const handlePointerCancel = useCallback( @@ -529,8 +593,12 @@ function DraggableServiceCard({ projectSlug={projectSlug} envName={envName} dragHandleProps={{ - className: - "touch-none cursor-grab select-none active:cursor-grabbing", + className: cn( + "touch-none select-none", + positionWritePending + ? "cursor-wait" + : "cursor-grab active:cursor-grabbing", + ), onPointerDown: handlePointerDown, onPointerMove: handlePointerMove, onPointerUp: handlePointerUp, @@ -561,6 +629,8 @@ export function ServiceCanvas({ const [dockerDialogOpen, setDockerDialogOpen] = useState(false); const [githubDialogOpen, setGithubDialogOpen] = useState(false); + const [positionWritePending, setPositionWritePending] = useState(false); + const positionWritePendingRef = useRef(false); const [canvasScale, setCanvasScale] = useState(getCanvasScale); const { @@ -575,14 +645,18 @@ export function ServiceCanvas({ revalidateOnFocus: true, }, ); + const canvasHeight = getCanvasHeight(services); useEffect(() => { - const updateCanvasScale = () => setCanvasScale(getCanvasScale()); + const updateCanvasScale = () => + setCanvasScale(getCanvasScale(canvasHeight)); + + updateCanvasScale(); window.addEventListener("resize", updateCanvasScale); return () => window.removeEventListener("resize", updateCanvasScale); - }, []); + }, [canvasHeight]); const composeHref = `/dashboard/projects/${projectSlug}/${envName}/import-compose`; @@ -614,57 +688,104 @@ export function ServiceCanvas({ [projectId, envId, projectSlug, envName, mutate], ); - const handlePositionChange = useCallback( - (serviceId: string, position: CanvasPosition) => { - const nextPosition = clampPosition(position); - - void mutate( - (current) => - current?.map((service) => - service.id === serviceId - ? { - ...service, - ...nextPosition, - } - : service, - ), - false, + const savePositions = useCallback( + async (positions: CanvasPositionUpdate[]) => { + if (positionWritePendingRef.current) { + return; + } + + const optimisticPositions = new Map( + positions.map(({ serviceId, canvasX, canvasY }) => [ + serviceId, + { canvasX, canvasY }, + ]), ); - void fetch(`/api/projects/${projectId}/services/${serviceId}/position`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(nextPosition), - }) - .then(async (response) => { - if (!response.ok) { - void mutate(); - return; - } - - const savedPosition = (await response.json()) as CanvasPosition; - - void mutate( - (current) => - current?.map((service) => - service.id === serviceId - ? { - ...service, - canvasX: savedPosition.canvasX, - canvasY: savedPosition.canvasY, - } - : service, - ), - false, - ); - }) - .catch(() => { - void mutate(); - }); + positionWritePendingRef.current = true; + setPositionWritePending(true); + + try { + await mutate( + async (current) => { + const response = await fetch( + `/api/projects/${projectId}/services`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ positions }), + }, + ); + + if (!response.ok) { + throw new Error("Failed to save canvas positions"); + } + + const savedPositions = (await response.json()) as Array< + CanvasPosition & { id: string } + >; + const savedPositionsById = new Map( + savedPositions.map(({ id, canvasX, canvasY }) => [ + id, + { canvasX, canvasY }, + ]), + ); + + return current?.map((service) => ({ + ...service, + ...savedPositionsById.get(service.id), + })); + }, + { + optimisticData: (current) => + (current ?? []).map((service) => ({ + ...service, + ...optimisticPositions.get(service.id), + })), + rollbackOnError: true, + revalidate: true, + }, + ); + } catch { + toast.error("Could not save the canvas layout"); + } finally { + positionWritePendingRef.current = false; + setPositionWritePending(false); + } }, [mutate, projectId], ); + const handlePositionChange = useCallback( + (serviceId: string, position: CanvasPosition) => { + void savePositions([ + { + serviceId, + ...clampPosition(position, canvasHeight), + }, + ]); + }, + [canvasHeight, savePositions], + ); + + const handleAutoLayout = useCallback(() => { + if (!services || positionWritePendingRef.current) { + return; + } + + const gridCanvasHeight = getGridCanvasHeight(services.length); + const positions: CanvasPositionUpdate[] = services.map( + (service, index) => ({ + serviceId: service.id, + ...clampPosition( + getAutoLayoutPosition(index, services.length, gridCanvasHeight), + gridCanvasHeight, + ), + }), + ); + + void savePositions(positions); + }, [savePositions, services]); + if (!environments || isLoading) { return ( <> @@ -827,7 +948,21 @@ export function ServiceCanvas({ projectSlug={projectSlug} className="absolute top-4 left-4 z-10" /> -
+
+
@@ -835,14 +970,14 @@ export function ServiceCanvas({ className="relative" style={{ width: CANVAS_WIDTH * canvasScale, - height: CANVAS_HEIGHT * canvasScale, + height: canvasHeight * canvasScale, }} >
))} diff --git a/web/components/ui/command.tsx b/web/components/ui/command.tsx new file mode 100644 index 00000000..7848260a --- /dev/null +++ b/web/components/ui/command.tsx @@ -0,0 +1,172 @@ +"use client"; + +import { Command as CommandPrimitive } from "cmdk"; +import { SearchIcon } from "lucide-react"; +import type * as React from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { cn } from "@/lib/utils"; + +function Command({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function CommandDialog({ + title = "Command Palette", + description = "Search for a page to open.", + children, + className, + showCloseButton = false, + ...props +}: Omit, "children"> & { + title?: string; + description?: string; + className?: string; + showCloseButton?: boolean; + children: React.ReactNode; +}) { + return ( + + + {title} + {description} + + + {children} + + + ); +} + +function CommandInput({ + className, + ...props +}: React.ComponentProps) { + return ( +
+ + +
+ ); +} + +function CommandList({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function CommandEmpty({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function CommandGroup({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function CommandItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function CommandSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + Command, + CommandDialog, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +}; diff --git a/web/lib/inngest/events/index.ts b/web/lib/inngest/events/index.ts index b6a35f31..c378e51d 100644 --- a/web/lib/inngest/events/index.ts +++ b/web/lib/inngest/events/index.ts @@ -39,8 +39,6 @@ export const inngestEvents = { migrationStarted: defineEvent("migration/started"), migrationCancelled: defineEvent("migration/cancelled"), - migrationRestoreCompleted: defineEvent("migration/restore-completed"), - migrationRestoreFailed: defineEvent("migration/restore-failed"), migrationRestoreFinished: defineEvent("migration/restore-finished"), backupStarted: defineEvent("backup/started"), diff --git a/web/lib/inngest/events/migration.ts b/web/lib/inngest/events/migration.ts index 79e29485..07ed24c7 100644 --- a/web/lib/inngest/events/migration.ts +++ b/web/lib/inngest/events/migration.ts @@ -18,19 +18,6 @@ export type MigrationEvents = { serviceId: string; }; }; - "migration/restore-completed": { - data: { - backupId: string; - serviceId: string; - }; - }; - "migration/restore-failed": { - data: { - backupId: string; - serviceId: string; - error: string; - }; - }; "migration/restore-finished": { data: { backupId: string; diff --git a/web/lib/navigation.ts b/web/lib/navigation.ts new file mode 100644 index 00000000..62b63b26 --- /dev/null +++ b/web/lib/navigation.ts @@ -0,0 +1,231 @@ +export type NavigationItemKind = + | "page" + | "project" + | "environment" + | "service" + | "server"; + +export type NavigationGroup = "Pages" | "Projects" | "Services" | "Servers"; + +export interface NavigationItem { + id: string; + kind: NavigationItemKind; + group: NavigationGroup; + label: string; + description?: string; + href: string; + keywords: string[]; +} + +export interface NavigationResponse { + items: NavigationItem[]; +} + +export interface NavigationEntityRow { + projectId: string; + projectName: string; + projectSlug: string; + environmentId: string | null; + environmentName: string | null; + serviceId: string | null; + serviceName: string | null; + serviceHostname: string | null; +} + +export interface NavigationServerRow { + id: string; + name: string; +} + +const pageItems: NavigationItem[] = [ + { + id: "page:dashboard", + kind: "page", + group: "Pages", + label: "Dashboard", + href: "/dashboard", + keywords: ["home", "projects", "servers"], + }, + { + id: "page:settings", + kind: "page", + group: "Pages", + label: "Settings", + href: "/dashboard/settings", + keywords: ["global", "configuration", "members"], + }, +]; + +const environmentPages = [ + { key: "overview", label: "Overview", suffix: "", aliases: ["canvas"] }, + { + key: "deleted", + label: "Deleted services", + suffix: "/deleted", + aliases: ["trash", "restore"], + }, + { + key: "import-compose", + label: "Import Compose", + suffix: "/import-compose", + aliases: ["docker compose", "create services"], + }, +] as const; + +const servicePages = [ + { + key: "deployments", + label: "Deployments", + suffix: "", + aliases: ["deploy", "rollouts"], + }, + { + key: "configuration", + label: "Configuration", + suffix: "/configuration", + aliases: ["config", "settings"], + }, + { key: "metrics", label: "Metrics", suffix: "/metrics", aliases: ["usage"] }, + { key: "logs", label: "Logs", suffix: "/logs", aliases: ["output"] }, + { + key: "requests", + label: "Requests", + suffix: "/requests", + aliases: ["http", "traffic"], + }, + { key: "builds", label: "Builds", suffix: "/builds", aliases: ["ci"] }, + { + key: "backups", + label: "Backups", + suffix: "/backups", + aliases: ["restore"], + }, + { + key: "changes", + label: "Changes", + suffix: "/changelog", + aliases: ["changelog", "history"], + }, +] as const; + +const serverPages = [ + { key: "overview", label: "Overview", suffix: "", aliases: ["details"] }, + { key: "metrics", label: "Metrics", suffix: "/metrics", aliases: ["usage"] }, + { key: "logs", label: "Logs", suffix: "/logs", aliases: ["output"] }, + { + key: "settings", + label: "Settings", + suffix: "/settings", + aliases: ["configuration", "config"], + }, +] as const; + +export function buildNavigationItems( + entityRows: NavigationEntityRow[], + serverRows: NavigationServerRow[], +): NavigationItem[] { + const items = [...pageItems]; + const projects = new Map(); + const environments = new Map(); + const services = new Map(); + + for (const row of entityRows) { + projects.set(row.projectId, row); + if (row.environmentId && row.environmentName) { + environments.set(row.environmentId, row); + } + if (row.serviceId && row.serviceName) { + services.set(row.serviceId, row); + } + } + + for (const project of [...projects.values()].sort((a, b) => + a.projectName.localeCompare(b.projectName), + )) { + items.push({ + id: `project:${project.projectId}:settings`, + kind: "project", + group: "Projects", + label: `${project.projectName} — Settings`, + description: "Project", + href: `/dashboard/projects/${project.projectSlug}/settings`, + keywords: [ + project.projectName, + project.projectSlug, + "project", + "configuration", + ], + }); + } + + for (const environment of [...environments.values()].sort((a, b) => + `${a.projectName}/${a.environmentName}`.localeCompare( + `${b.projectName}/${b.environmentName}`, + ), + )) { + const basePath = `/dashboard/projects/${environment.projectSlug}/${environment.environmentName}`; + for (const page of environmentPages) { + items.push({ + id: `environment:${environment.environmentId}:${page.key}`, + kind: "environment", + group: "Projects", + label: `${environment.projectName} / ${environment.environmentName} — ${page.label}`, + description: "Environment", + href: `${basePath}${page.suffix}`, + keywords: [ + environment.projectName, + environment.projectSlug, + environment.environmentName as string, + "environment", + ...page.aliases, + ], + }); + } + } + + for (const service of [...services.values()].sort((a, b) => + `${a.projectName}/${a.environmentName}/${a.serviceName}`.localeCompare( + `${b.projectName}/${b.environmentName}/${b.serviceName}`, + ), + )) { + const basePath = `/dashboard/projects/${service.projectSlug}/${service.environmentName}/services/${service.serviceId}`; + for (const page of servicePages) { + items.push({ + id: `service:${service.serviceId}:${page.key}`, + kind: "service", + group: "Services", + label: `${service.serviceName} — ${page.label}`, + description: `${service.projectName} / ${service.environmentName}`, + href: `${basePath}${page.suffix}`, + keywords: [ + service.serviceName as string, + service.serviceHostname ?? "", + service.projectName, + service.projectSlug, + service.environmentName as string, + "service", + ...page.aliases, + ], + }); + } + } + + for (const server of [...serverRows].sort((a, b) => + a.name.localeCompare(b.name), + )) { + const basePath = `/dashboard/servers/${server.id}`; + for (const page of serverPages) { + items.push({ + id: `server:${server.id}:${page.key}`, + kind: "server", + group: "Servers", + label: `${server.name} — ${page.label}`, + description: "Server", + href: `${basePath}${page.suffix}`, + keywords: [server.name, "server", ...page.aliases], + }); + } + } + + return items; +} diff --git a/web/lib/public-api.ts b/web/lib/public-api.ts index 75fbfae6..cc0f51e7 100644 --- a/web/lib/public-api.ts +++ b/web/lib/public-api.ts @@ -694,6 +694,7 @@ function canonicalReplacementState( healthCheck: healthCheckFromService(service), startCommand: service.startCommand?.trim() || null, resources, + serverless: { enabled: service.serverlessEnabled }, }; } @@ -773,7 +774,15 @@ export function planCanonicalConfiguration( ...current, source: canonicalPlanSource(current.source), }; - const desired = canonicalDesired(desiredInput); + const desiredConfiguration = canonicalDesired(desiredInput); + const desired = { + ...desiredConfiguration, + serverless: { + enabled: + canonicalCurrent.serverless.enabled && + desiredConfiguration.ports.some((port) => port.public && port.domain), + }, + }; const changes = configurationChanges(canonicalCurrent, desired); return { action: changes.length ? ("updated" as const) : ("noop" as const), @@ -869,6 +878,9 @@ async function replaceConfigurationInternal( ); } const plan = planCanonicalConfiguration(currentState, input); + const effectiveServerlessEnabled = + persisted.serverlessEnabled && + input.ports.some((port) => port.public && port.domain); if (expectedVersion !== null && plan.currentVersion !== expectedVersion) { domainError( "Service configuration changed after the plan was created", @@ -946,7 +958,7 @@ async function replaceConfigurationInternal( 400, ); if ( - persisted.serverlessEnabled && + effectiveServerlessEnabled && selected.some((server) => !server.isProxy) ) domainError( @@ -967,16 +979,6 @@ async function replaceConfigurationInternal( if (duplicateHostname) { domainError("Hostname is already in use", "HOSTNAME_CONFLICT"); } - if ( - persisted.serverlessEnabled && - !input.ports.some((port) => port.public && port.domain) - ) { - domainError( - "Serverless services require a public HTTP port with a domain", - "SERVERLESS_PORT_REQUIRED", - 400, - ); - } const portIssue = findServicePortValidationIssue( input.ports.map((port) => ({ containerPort: port.containerPort, @@ -1013,6 +1015,9 @@ async function replaceConfigurationInternal( const changes: string[] = []; const set: Partial = {}; + if (persisted.serverlessEnabled !== effectiveServerlessEnabled) { + set.serverlessEnabled = effectiveServerlessEnabled; + } const changed = (label: string, from: unknown, to: unknown) => { if (JSON.stringify(from) === JSON.stringify(to)) return false; changes.push(label); diff --git a/web/lib/work-queue.ts b/web/lib/work-queue.ts index 988568ba..5048d65f 100644 --- a/web/lib/work-queue.ts +++ b/web/lib/work-queue.ts @@ -1,7 +1,7 @@ import { randomUUID } from "node:crypto"; import { and, eq, inArray, sql } from "drizzle-orm"; import { db } from "@/db"; -import { deployments, servers, workQueue } from "@/db/schema"; +import { deployments, servers, volumeBackups, workQueue } from "@/db/schema"; import type { WorkQueue } from "@/db/types"; import { MINUTE_IN_MILLISECONDS, subtractMilliseconds } from "@/lib/date"; import { inngest } from "@/lib/inngest/client"; @@ -152,20 +152,31 @@ export async function completeWorkItemResults( const rejected: RejectedWorkItemResult[] = []; for (const result of results) { - const updated = await db - .update(workQueue) - .set({ status: result.status }) - .where( - and( - eq(workQueue.id, result.id), - eq(workQueue.serverId, serverId), - eq(workQueue.status, "processing"), - eq(workQueue.attempts, result.attempt), - ), - ) - .returning(); + const item = await db.transaction(async (tx) => { + const updated = await tx + .update(workQueue) + .set({ status: result.status }) + .where( + and( + eq(workQueue.id, result.id), + eq(workQueue.serverId, serverId), + eq(workQueue.status, "processing"), + eq(workQueue.attempts, result.attempt), + ), + ) + .returning(); - if (updated.length === 0) { + const item = updated[0]; + if (!item) return null; + + if (item.type === "restore_volume") { + await publishRestoreWorkResult(tx, item, result); + } + + return item; + }); + + if (!item) { rejected.push({ id: result.id, reason: await getRejectionReason(serverId, result.id, result.attempt), @@ -174,7 +185,9 @@ export async function completeWorkItemResults( } accepted.push(result.id); - await runWorkItemCompletionSideEffects(updated[0], result); + if (item.type !== "restore_volume") { + await runWorkItemCompletionSideEffects(item, result); + } } return { accepted, rejected }; @@ -337,6 +350,99 @@ async function getRejectionReason( return "unknown"; } +async function publishRestoreWorkResult( + tx: WorkQueueTransaction, + item: WorkQueue, + result: WorkItemResult, +): Promise { + let value: unknown; + try { + value = JSON.parse(item.payload); + } catch { + throw new Error(`Restore work item ${item.id} has invalid JSON payload`); + } + + if (!value || typeof value !== "object") { + throw new Error(`Restore work item ${item.id} has invalid payload`); + } + + const payload = value as Partial; + if ( + typeof payload.backupId !== "string" || + payload.backupId.length === 0 || + typeof payload.serviceId !== "string" || + payload.serviceId.length === 0 || + typeof payload.isMigrationRestore !== "boolean" + ) { + throw new Error(`Restore work item ${item.id} has invalid restore context`); + } + + const backup = await tx + .select({ + volumeId: volumeBackups.volumeId, + serviceId: volumeBackups.serviceId, + }) + .from(volumeBackups) + .where(eq(volumeBackups.id, payload.backupId)) + .then((rows) => rows[0]); + + if (!backup) { + throw new Error(`Restore work item ${item.id} references a missing backup`); + } + if (backup.serviceId !== payload.serviceId) { + throw new Error( + `Restore work item ${item.id} has mismatched service context`, + ); + } + + if (payload.isMigrationRestore) { + await inngest.send( + inngestEvents.migrationRestoreFinished.create( + { + backupId: payload.backupId, + serviceId: payload.serviceId, + status: result.status, + ...(result.status === "failed" + ? { error: result.error || "Restore failed" } + : {}), + }, + { + id: `migration-restore-${result.status}-${item.id}`, + }, + ), + ); + return; + } + + if (result.status === "completed") { + await inngest.send( + inngestEvents.restoreCompleted.create( + { + backupId: payload.backupId, + volumeId: backup.volumeId, + serviceId: payload.serviceId, + isMigrationRestore: false, + }, + { id: `restore-completed-${item.id}` }, + ), + ); + return; + } + + await inngest.send( + inngestEvents.restoreFailed.create( + { + backupId: payload.backupId, + volumeId: backup.volumeId, + serviceId: payload.serviceId, + error: result.error || "Restore failed", + isMigrationRestore: false, + }, + { id: `restore-failed-${item.id}` }, + ), + ); +} + async function runWorkItemCompletionSideEffects( item: WorkQueue, result: WorkItemResult, diff --git a/web/package.json b/web/package.json index b86ac9b2..04e69908 100644 --- a/web/package.json +++ b/web/package.json @@ -26,6 +26,7 @@ "better-auth": "1.6.23", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", "cron-parser": "^5.4.0", "cronstrue": "^3.9.0", "drizzle-orm": "^0.45.2", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 022ae4e6..87932cea 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -50,6 +50,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + cmdk: + specifier: ^1.1.1 + version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) cron-parser: specifier: ^5.4.0 version: 5.5.0 @@ -2134,6 +2137,168 @@ packages: '@protobufjs/utf8@1.1.1': resolution: {integrity: sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==} + '@radix-ui/primitive@1.1.7': + resolution: {integrity: sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==} + + '@radix-ui/react-compose-refs@1.1.4': + resolution: {integrity: sha512-pWJo6lQAfR6uy1n7ii7PaCc9dLPwTXDYbQpORZU5B548Aqvl2pP1SM1vJGKyxIFqZMHRopRO4CQYX2iXAIB5jA==} + peerDependencies: + '@types/react': 19.2.17 + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context@1.2.1': + resolution: {integrity: sha512-EraVbFjiIjibpLr6EjvEDmSCYJU2SlKDMiO+qEK/D9GOWnQoAQlpQo2occGYC1UM9MBeEx5Bek3UtW/Qi57vAg==} + peerDependencies: + '@types/react': 19.2.17 + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dialog@1.1.21': + resolution: {integrity: sha512-h+7qMDDmZJ8qTSPrwNyKb/PACY0ehtN8QOBlCz+C2C1jgehKekdhmHddG9YQk8BF/sHJqglPjte+jA1Jrp9HcA==} + peerDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3 + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-dismissable-layer@1.1.17': + resolution: {integrity: sha512-QAXwa38pG0xNAYh1pjdSaf86NrkqsMoDNmget/Y7X8O8E/C3Iqlj9GAPE4DfX9BPLXc7WH2TWSzMRnIoCdcjzQ==} + peerDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3 + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-focus-guards@1.1.5': + resolution: {integrity: sha512-UQvlB7L/BYh3P8MLvwZnQkH521EDos40Rwnbt5+Qpg4Vbk0z3xJjRUmR6+aka4aT1IQQXFdO5bNPoE7cvFl5xQ==} + peerDependencies: + '@types/react': 19.2.17 + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-focus-scope@1.1.14': + resolution: {integrity: sha512-/x4htnJfmW53MplkrePaDpf1o/rN1C++g88WpVobULXbSyC19NtLkXmewuJ/HCaceSmfKDNL5gOXcBGnuAvnvQ==} + peerDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3 + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-id@1.1.3': + resolution: {integrity: sha512-f/Wxm0ctyMymUJK0fqTSQlm85rbzdAkoNbPXJQ5+6caowVO8Yx+NWGjGz/oGhs/D+WIbbQpOrU0hU2Li2/42xQ==} + peerDependencies: + '@types/react': 19.2.17 + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-portal@1.1.15': + resolution: {integrity: sha512-kAfBVJUKNNKZuyGQXXG6rKolAV2KAmxxVkPXJgoq9dEFTl39286RufHQFNTL8rzha4vP8159BJ6hMGpB+bqv7A==} + peerDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3 + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-presence@1.1.9': + resolution: {integrity: sha512-LTi1v05bprIb8/GSY/GWusI0jfsYjQ3CD3Nin8o7jVxnpHzVQfzjOQJoJTQkE9bdmOnsS7SFdhkXiBv8PrYnxw==} + peerDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3 + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.1.8': + resolution: {integrity: sha512-DOlK1BdcIeYYUcFkSYFka4v1h95XTov93b0jCgW1EEiZuIhdwHY2NlE1teLIh+p0uBsuZI5A+voay+iVWpprfA==} + peerDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3 + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.3.1': + resolution: {integrity: sha512-Bu/aAQHFFh6/QAvXAeUMurJ9fbW0JUIqlojU/yBXZ7cAVqy75Y7JYYyuCr9zLNF0p4WWoJYV54CTUIf4l7FzTw==} + peerDependencies: + '@types/react': 19.2.17 + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-callback-ref@1.1.3': + resolution: {integrity: sha512-AUS7HoBBAncIsGMLNG+CcpLuJ+JIBbZzmyM8Qdb1eIThX0AlhSSC6wn40xfBlPE+ypx/vSSiRWnklUAjy3U3UA==} + peerDependencies: + '@types/react': 19.2.17 + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-controllable-state@1.2.5': + resolution: {integrity: sha512-UB1dXpxvHjR48poyKdKdTm7jT0kp3elkUKdKQiOkirlbYumqXinSJtrjDsr9maXNPvL12bKI4CDSmydms/9Aeg==} + peerDependencies: + '@types/react': 19.2.17 + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.4': + resolution: {integrity: sha512-XYcfa6wlXDCwQtePuEiPmXLSAhGL4DWtedSyRgGbG3y10mw+OnrLp6SyeY1gJFMiYF0Dx0nMAX9InylKbLEFQQ==} + peerDependencies: + '@types/react': 19.2.17 + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.3': + resolution: {integrity: sha512-rDiah9wvtqihWtWz02XreeRKIxt2EJF8y5D9rtY9l5A2zxePAtcPiOMpDugNRw5bFHz+1/8viVoc7ZVKiJknCw==} + peerDependencies: + '@types/react': 19.2.17 + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@react-email/body@0.3.0': resolution: {integrity: sha512-uGo0BOOzjbMUo3lu+BIDWayvn5o6Xyfmnlla5VGf05n8gHMvO1ll7U4FtzWe3hxMLwt53pmc4iE0M+B5slG+Ug==} engines: {node: '>=20.0.0'} @@ -3024,6 +3189,10 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + aria-query@5.3.2: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} @@ -3296,6 +3465,12 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + cmdk@1.1.1: + resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==} + peerDependencies: + react: ^18 || ^19 || ^19.0.0-rc + react-dom: ^18 || ^19 || ^19.0.0-rc + code-block-writer@13.0.3: resolution: {integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==} @@ -3537,6 +3712,9 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + diff@8.0.4: resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} engines: {node: '>=0.3.1'} @@ -4126,6 +4304,10 @@ packages: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + get-own-enumerable-keys@1.0.0: resolution: {integrity: sha512-PKsK2FSrQCyxcGHsGrLDcK0lx+0Ke+6e8KFFozA9/fIQLhQzPaRvJFdcz7+Axg3jUH/Mq+NI4xa5u/UT2tQskA==} engines: {node: '>=14.16'} @@ -5329,6 +5511,36 @@ packages: redux: optional: true + react-remove-scroll-bar@2.3.8: + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': 19.2.17 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.7.2: + resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': 19.2.17 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-style-singleton@2.2.3: + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': 19.2.17 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + react@19.2.7: resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} engines: {node: '>=0.10.0'} @@ -5870,6 +6082,26 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + use-callback-ref@1.3.3: + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': 19.2.17 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sidecar@1.1.3: + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': 19.2.17 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + use-sync-external-store@1.6.0: resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} peerDependencies: @@ -8291,6 +8523,144 @@ snapshots: '@protobufjs/utf8@1.1.1': {} + '@radix-ui/primitive@1.1.7': {} + + '@radix-ui/react-compose-refs@1.1.4(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-context@1.2.1(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-dialog@1.1.21(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.1(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-focus-guards': 1.1.5(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-focus-scope': 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-id': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-portal': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.3.1(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.5(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.17)(react@19.2.7) + aria-hidden: 1.2.6 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-dismissable-layer@1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-effect-event': 0.0.4(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-focus-guards@1.1.5(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-focus-scope@1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.3(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-id@1.1.3(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-portal@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-presence@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-primitive@2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-slot': 1.3.1(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-slot@1.3.1(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-callback-ref@1.1.3(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-controllable-state@1.2.5(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-use-effect-event': 0.0.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-effect-event@0.0.4(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-layout-effect@1.1.3(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + '@react-email/body@0.3.0(react@19.2.7)': dependencies: react: 19.2.7 @@ -9079,6 +9449,10 @@ snapshots: argparse@2.0.1: {} + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + aria-query@5.3.2: {} array-buffer-byte-length@1.0.2: @@ -9349,6 +9723,18 @@ snapshots: clsx@2.1.1: {} + cmdk@1.1.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-dialog': 1.1.21(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-id': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + code-block-writer@13.0.3: {} color-convert@2.0.1: @@ -9545,6 +9931,8 @@ snapshots: detect-libc@2.1.2: {} + detect-node-es@1.1.0: {} + diff@8.0.4: {} dijkstrajs@1.0.3: {} @@ -10330,6 +10718,8 @@ snapshots: hasown: 2.0.4 math-intrinsics: 1.1.0 + get-nonce@1.0.1: {} + get-own-enumerable-keys@1.0.0: {} get-proto@1.0.1: @@ -11419,6 +11809,33 @@ snapshots: '@types/react': 19.2.17 redux: 5.0.1 + react-remove-scroll-bar@2.3.8(@types/react@19.2.17)(react@19.2.7): + dependencies: + react: 19.2.7 + react-style-singleton: 2.2.3(@types/react@19.2.17)(react@19.2.7) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.17 + + react-remove-scroll@2.7.2(@types/react@19.2.17)(react@19.2.7): + dependencies: + react: 19.2.7 + react-remove-scroll-bar: 2.3.8(@types/react@19.2.17)(react@19.2.7) + react-style-singleton: 2.2.3(@types/react@19.2.17)(react@19.2.7) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@19.2.17)(react@19.2.7) + use-sidecar: 1.1.3(@types/react@19.2.17)(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + + react-style-singleton@2.2.3(@types/react@19.2.17)(react@19.2.7): + dependencies: + get-nonce: 1.0.1 + react: 19.2.7 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.17 + react@19.2.7: {} recast@0.23.11: @@ -12130,6 +12547,21 @@ snapshots: dependencies: punycode: 2.3.1 + use-callback-ref@1.3.3(@types/react@19.2.17)(react@19.2.7): + dependencies: + react: 19.2.7 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.17 + + use-sidecar@1.1.3(@types/react@19.2.17)(react@19.2.7): + dependencies: + detect-node-es: 1.1.0 + react: 19.2.7 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.17 + use-sync-external-store@1.6.0(react@19.2.7): dependencies: react: 19.2.7 diff --git a/web/tests/navigation-route.test.ts b/web/tests/navigation-route.test.ts new file mode 100644 index 00000000..c03bd8be --- /dev/null +++ b/web/tests/navigation-route.test.ts @@ -0,0 +1,89 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => { + const selectResults: unknown[][] = []; + function selectQuery(result: unknown[]) { + const query = { + from: vi.fn(() => query), + leftJoin: vi.fn(() => query), + orderBy: vi.fn(() => query), + // biome-ignore lint/suspicious/noThenProperty: Drizzle query builders are awaitable. + then: ( + resolve: (value: unknown[]) => unknown, + reject?: (reason: unknown) => unknown, + ) => Promise.resolve(result).then(resolve, reject), + }; + return query; + } + + return { + selectResults, + getSession: vi.fn(), + db: { + select: vi.fn(() => selectQuery(selectResults.shift() ?? [])), + }, + }; +}); + +vi.mock("next/headers", () => ({ + headers: async () => new Headers(), +})); +vi.mock("@/db", () => ({ db: mocks.db })); +vi.mock("@/lib/auth", () => ({ + auth: { api: { getSession: mocks.getSession } }, +})); + +import { GET } from "@/app/api/navigation/route"; + +describe("navigation API", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.selectResults.length = 0; + }); + + it("rejects unauthenticated requests without querying navigation data", async () => { + mocks.getSession.mockResolvedValue(null); + + const response = await GET(); + + expect(response.status).toBe(401); + expect(await response.json()).toEqual({ error: "Unauthorized" }); + expect(mocks.db.select).not.toHaveBeenCalled(); + }); + + it("returns dashboard and dynamic child pages for an authenticated session", async () => { + mocks.getSession.mockResolvedValue({ user: { id: "user-1" } }); + mocks.selectResults.push( + [ + { + projectId: "project-1", + projectName: "Acme", + projectSlug: "acme", + environmentId: "environment-1", + environmentName: "production", + serviceId: "service-1", + serviceName: "API", + serviceHostname: "api-production", + }, + ], + [{ id: "server-1", name: "edge-01" }], + ); + + const response = await GET(); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(mocks.db.select).toHaveBeenCalledTimes(2); + expect(body.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ href: "/dashboard" }), + expect.objectContaining({ + href: "/dashboard/projects/acme/production/services/service-1/logs", + }), + expect.objectContaining({ + href: "/dashboard/servers/server-1/settings", + }), + ]), + ); + }); +}); diff --git a/web/tests/navigation.test.ts b/web/tests/navigation.test.ts new file mode 100644 index 00000000..5ed1b77c --- /dev/null +++ b/web/tests/navigation.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import { buildNavigationItems } from "@/lib/navigation"; + +describe("dashboard navigation catalog", () => { + it("builds every stable child page with searchable context", () => { + const items = buildNavigationItems( + [ + { + projectId: "project-1", + projectName: "Acme", + projectSlug: "acme", + environmentId: "environment-1", + environmentName: "production", + serviceId: "service-1", + serviceName: "API", + serviceHostname: "api-production", + }, + ], + [{ id: "server-1", name: "edge-01" }], + ); + + expect(items).toHaveLength(18); + expect(items.map((item) => item.href)).toEqual( + expect.arrayContaining([ + "/dashboard", + "/dashboard/settings", + "/dashboard/projects/acme/settings", + "/dashboard/projects/acme/production", + "/dashboard/projects/acme/production/deleted", + "/dashboard/projects/acme/production/import-compose", + "/dashboard/projects/acme/production/services/service-1", + "/dashboard/projects/acme/production/services/service-1/configuration", + "/dashboard/projects/acme/production/services/service-1/metrics", + "/dashboard/projects/acme/production/services/service-1/logs", + "/dashboard/projects/acme/production/services/service-1/requests", + "/dashboard/projects/acme/production/services/service-1/builds", + "/dashboard/projects/acme/production/services/service-1/backups", + "/dashboard/projects/acme/production/services/service-1/changelog", + "/dashboard/servers/server-1", + "/dashboard/servers/server-1/metrics", + "/dashboard/servers/server-1/logs", + "/dashboard/servers/server-1/settings", + ]), + ); + + const serviceLogs = items.find( + (item) => item.id === "service:service-1:logs", + ); + expect(serviceLogs).toMatchObject({ + label: "API — Logs", + description: "Acme / production", + keywords: expect.arrayContaining([ + "API", + "api-production", + "Acme", + "production", + ]), + }); + }); + + it("deduplicates entities repeated by joined query rows", () => { + const row = { + projectId: "project-1", + projectName: "Acme", + projectSlug: "acme", + environmentId: "environment-1", + environmentName: "production", + serviceId: "service-1", + serviceName: "API", + serviceHostname: null, + }; + + const items = buildNavigationItems([row, row], []); + expect(new Set(items.map((item) => item.id)).size).toBe(items.length); + }); +}); diff --git a/web/tests/public-api-plan.test.ts b/web/tests/public-api-plan.test.ts index afe86240..5a44f29d 100644 --- a/web/tests/public-api-plan.test.ts +++ b/web/tests/public-api-plan.test.ts @@ -47,13 +47,20 @@ describe("configuration plan protocol", () => { healthCheck: null, startCommand: null, resources: null, + serverless: { enabled: false }, }; const result = planCanonicalConfiguration(current, { - ...current, + name: current.name, source: { ...current.source, repository: "https://github.com/techulus/cloud", }, + hostname: current.hostname, + ports: current.ports, + placement: current.placement, + healthCheck: current.healthCheck, + startCommand: current.startCommand, + resources: current.resources, }); expect(result.action).toBe("noop"); @@ -113,6 +120,7 @@ describe("configuration plan protocol", () => { }, startCommand: "npm start", resources: { cpuCores: 2, memoryMb: 512 }, + serverless: { enabled: false }, }; const result = planCanonicalConfiguration(current, { name: "web", @@ -174,6 +182,7 @@ describe("configuration plan protocol", () => { healthCheck: null, startCommand: null, resources: null, + serverless: { enabled: false }, }; const desired = { name: "web", @@ -208,4 +217,80 @@ describe("configuration plan protocol", () => { expect(first.changes).toEqual([]); expect(second.desiredVersion).toBe(first.desiredVersion); }); + + it("plans disabling serverless when the final public HTTP domain is removed", () => { + const current = { + name: "web", + source: { type: "image" as const, image: "nginx" }, + hostname: "web", + ports: [{ containerPort: 8080, public: true, domain: "web.example.com" }], + placement: { mode: "automatic" as const, replicas: 1 }, + healthCheck: null, + startCommand: null, + resources: null, + serverless: { enabled: true }, + }; + const desired = { + name: "web", + source: { type: "image" as const, image: "nginx" }, + hostname: "web", + ports: [{ containerPort: 8080, public: false }], + placement: { mode: "automatic" as const, replicas: 1 }, + healthCheck: null, + startCommand: null, + resources: null, + }; + + const result = planCanonicalConfiguration(current, desired); + + expect(result.changes).toContainEqual({ + field: "serverless.enabled", + from: true, + to: false, + }); + expect( + planCanonicalConfiguration( + { ...current, serverless: { enabled: false } }, + desired, + ).changes, + ).not.toContainEqual( + expect.objectContaining({ field: "serverless.enabled" }), + ); + }); + + it("preserves serverless and fingerprints its state when a public domain remains", () => { + const current = { + name: "web", + source: { type: "image" as const, image: "nginx" }, + hostname: "web", + ports: [{ containerPort: 8080, public: true, domain: "web.example.com" }], + placement: { mode: "automatic" as const, replicas: 1 }, + healthCheck: null, + startCommand: null, + resources: null, + serverless: { enabled: true }, + }; + const desired = { + name: current.name, + source: current.source, + hostname: current.hostname, + ports: current.ports, + placement: current.placement, + healthCheck: current.healthCheck, + startCommand: current.startCommand, + resources: current.resources, + }; + + const enabled = planCanonicalConfiguration(current, desired); + const disabled = planCanonicalConfiguration( + { ...current, serverless: { enabled: false } }, + desired, + ); + + expect(enabled.action).toBe("noop"); + expect(enabled.changes).toEqual([]); + expect(disabled.action).toBe("noop"); + expect(disabled.currentVersion).not.toBe(enabled.currentVersion); + expect(disabled.desiredVersion).not.toBe(enabled.desiredVersion); + }); }); diff --git a/web/tests/work-queue.test.ts b/web/tests/work-queue.test.ts new file mode 100644 index 00000000..3d12b516 --- /dev/null +++ b/web/tests/work-queue.test.ts @@ -0,0 +1,323 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => { + const state = { + updatedRows: [] as unknown[], + backupRows: [] as unknown[], + rejectionRows: [] as unknown[], + persistedStatus: "processing", + pendingStatus: null as string | null, + updateMatched: false, + }; + + function createQuery(result: unknown[]) { + const query = { + from: vi.fn(() => query), + set: vi.fn(() => query), + where: vi.fn(() => query), + returning: vi.fn(() => query), + // biome-ignore lint/suspicious/noThenProperty: Drizzle query builders are awaitable. + then: ( + resolve: (value: unknown[]) => unknown, + reject?: (reason: unknown) => unknown, + ) => Promise.resolve(result).then(resolve, reject), + }; + return query; + } + + function createUpdateQuery() { + const query = { + set: vi.fn((values: { status?: string }) => { + state.pendingStatus = values.status ?? null; + return query; + }), + where: vi.fn(() => query), + returning: vi.fn(() => query), + // biome-ignore lint/suspicious/noThenProperty: Drizzle query builders are awaitable. + then: ( + resolve: (value: unknown[]) => unknown, + reject?: (reason: unknown) => unknown, + ) => { + const row = state.updatedRows[0]; + const result = + row && state.persistedStatus === "processing" + ? [{ ...(row as object), status: state.pendingStatus }] + : []; + state.updateMatched = result.length > 0; + return Promise.resolve(result).then(resolve, reject); + }, + }; + return query; + } + + const tx = { + update: vi.fn(() => createUpdateQuery()), + select: vi.fn(() => createQuery(state.backupRows)), + }; + + return { + state, + tx, + db: { + transaction: vi.fn( + async (callback: (transaction: typeof tx) => Promise) => { + state.pendingStatus = null; + state.updateMatched = false; + const result = await callback(tx); + if (state.updateMatched && state.pendingStatus) { + state.persistedStatus = state.pendingStatus; + } + return result; + }, + ), + select: vi.fn(() => createQuery(state.rejectionRows)), + }, + send: vi.fn(), + }; +}); + +vi.mock("@/db", () => ({ db: mocks.db })); +vi.mock("@/lib/inngest/client", () => ({ + inngest: { send: mocks.send }, +})); +vi.mock("@/lib/inngest/events", () => ({ + inngestEvents: { + restoreCompleted: { + create: vi.fn((data, options) => ({ + name: "restore/completed", + data, + ...options, + })), + }, + restoreFailed: { + create: vi.fn((data, options) => ({ + name: "restore/failed", + data, + ...options, + })), + }, + migrationRestoreFinished: { + create: vi.fn((data, options) => ({ + name: "migration/restore-finished", + data, + ...options, + })), + }, + }, +})); +vi.mock("@/lib/work-queue-notifications", () => ({ + notifyWorkAvailable: vi.fn(), +})); + +import { completeWorkItemResults } from "@/lib/work-queue"; + +function restoreWorkItem(id: string, overrides: Record = {}) { + return { + id, + serverId: "server-1", + type: "restore_volume", + payload: JSON.stringify({ + backupId: "backup-1", + serviceId: "service-1", + isMigrationRestore: false, + }), + status: "completed", + attempts: 1, + createdAt: new Date(), + startedAt: new Date(), + ...overrides, + }; +} + +beforeEach(() => { + mocks.state.updatedRows = [restoreWorkItem("work-1")]; + mocks.state.backupRows = [{ volumeId: "volume-1", serviceId: "service-1" }]; + mocks.state.rejectionRows = []; + mocks.state.persistedStatus = "processing"; + mocks.state.pendingStatus = null; + mocks.state.updateMatched = false; + mocks.tx.update.mockClear(); + mocks.tx.select.mockClear(); + mocks.db.transaction.mockClear(); + mocks.db.select.mockClear(); + mocks.send.mockReset(); + mocks.send.mockResolvedValue(undefined); +}); + +describe("restore work completion", () => { + it("publishes an authorized normal restore success", async () => { + const result = await completeWorkItemResults("server-1", [ + { id: "work-1", attempt: 1, status: "completed" }, + ]); + + expect(result).toEqual({ accepted: ["work-1"], rejected: [] }); + expect(mocks.send).toHaveBeenCalledWith({ + name: "restore/completed", + id: "restore-completed-work-1", + data: { + backupId: "backup-1", + volumeId: "volume-1", + serviceId: "service-1", + isMigrationRestore: false, + }, + }); + }); + + it("publishes an authorized normal restore failure", async () => { + await completeWorkItemResults("server-1", [ + { + id: "work-1", + attempt: 1, + status: "failed", + error: "checksum mismatch", + }, + ]); + + expect(mocks.send).toHaveBeenCalledWith({ + name: "restore/failed", + id: "restore-failed-work-1", + data: { + backupId: "backup-1", + volumeId: "volume-1", + serviceId: "service-1", + isMigrationRestore: false, + error: "checksum mismatch", + }, + }); + }); + + it("publishes the terminal migration event from persisted context", async () => { + mocks.state.updatedRows = [ + restoreWorkItem("work-1", { + payload: JSON.stringify({ + backupId: "backup-1", + serviceId: "service-1", + isMigrationRestore: true, + }), + }), + ]; + + await completeWorkItemResults("server-1", [ + { id: "work-1", attempt: 1, status: "failed" }, + ]); + + expect(mocks.send).toHaveBeenCalledWith({ + name: "migration/restore-finished", + id: "migration-restore-failed-work-1", + data: { + backupId: "backup-1", + serviceId: "service-1", + status: "failed", + error: "Restore failed", + }, + }); + }); + + it("publishes a successful terminal migration event", async () => { + mocks.state.updatedRows = [ + restoreWorkItem("work-1", { + payload: JSON.stringify({ + backupId: "backup-1", + serviceId: "service-1", + isMigrationRestore: true, + }), + }), + ]; + + await completeWorkItemResults("server-1", [ + { id: "work-1", attempt: 1, status: "completed" }, + ]); + + expect(mocks.send).toHaveBeenCalledWith({ + name: "migration/restore-finished", + id: "migration-restore-completed-work-1", + data: { + backupId: "backup-1", + serviceId: "service-1", + status: "completed", + }, + }); + }); + + it("does not publish an event for a rejected result", async () => { + mocks.state.updatedRows = []; + mocks.state.rejectionRows = [ + { serverId: "server-2", status: "processing", attempts: 1 }, + ]; + + const result = await completeWorkItemResults("server-1", [ + { id: "work-1", attempt: 1, status: "completed" }, + ]); + + expect(result).toEqual({ + accepted: [], + rejected: [{ id: "work-1", reason: "server_mismatch" }], + }); + expect(mocks.send).not.toHaveBeenCalled(); + }); + + it.each([ + ["invalid JSON", "{"], + [ + "missing migration context", + JSON.stringify({ backupId: "backup-1", serviceId: "service-1" }), + ], + ])("rejects %s in the persisted payload", async (_label, payload) => { + mocks.state.updatedRows = [restoreWorkItem("work-1", { payload })]; + + await expect( + completeWorkItemResults("server-1", [ + { id: "work-1", attempt: 1, status: "completed" }, + ]), + ).rejects.toThrow("Restore work item work-1"); + expect(mocks.send).not.toHaveBeenCalled(); + }); + + it("rejects a backup from a different service", async () => { + mocks.state.backupRows = [{ volumeId: "volume-1", serviceId: "service-2" }]; + + await expect( + completeWorkItemResults("server-1", [ + { id: "work-1", attempt: 1, status: "completed" }, + ]), + ).rejects.toThrow("mismatched service context"); + expect(mocks.send).not.toHaveBeenCalled(); + }); + + it("leaves publication failures unacknowledged so they can be retried", async () => { + mocks.send.mockRejectedValueOnce(new Error("Inngest unavailable")); + + await expect( + completeWorkItemResults("server-1", [ + { id: "work-1", attempt: 1, status: "completed" }, + ]), + ).rejects.toThrow("Inngest unavailable"); + expect(mocks.state.persistedStatus).toBe("processing"); + + const retried = await completeWorkItemResults("server-1", [ + { id: "work-1", attempt: 1, status: "completed" }, + ]); + expect(retried.accepted).toEqual(["work-1"]); + expect(mocks.state.persistedStatus).toBe("completed"); + expect(mocks.send.mock.calls.map(([event]) => event.id)).toEqual([ + "restore-completed-work-1", + "restore-completed-work-1", + ]); + }); + + it("uses work item IDs to distinguish repeated restores of one backup", async () => { + await completeWorkItemResults("server-1", [ + { id: "work-1", attempt: 1, status: "completed" }, + ]); + mocks.state.updatedRows = [restoreWorkItem("work-2")]; + mocks.state.persistedStatus = "processing"; + await completeWorkItemResults("server-1", [ + { id: "work-2", attempt: 1, status: "completed" }, + ]); + + expect(mocks.send.mock.calls.map(([event]) => event.id)).toEqual([ + "restore-completed-work-1", + "restore-completed-work-2", + ]); + }); +});