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 ? (
<>
-
+
{breadcrumbs.map((crumb, index) => (
))}
-
+
{showEllipsis && (
<>
...
@@ -105,43 +106,46 @@ function DashboardHeader({ email, name }: { email: string; name: string }) {
techulus.cloud
)}
-
- }
- >
-
-
-
-
-
-
- {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 (
+ <>
+ setOpen(true)}
+ >
+
+
+ Search…
+
+
+ ⌘K
+
+
+
+
+
+
+
+ {isLoading ? (
+
+
+ Loading navigation…
+
+ ) : error ? (
+
+
+ Could not load navigation. Try again.
+
+
mutate()}>
+ Retry
+
+
+ ) : (
+ <>
+ 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"
/>
-
+
+
void handleAutoLayout()}
+ aria-label="Auto layout"
+ title="Auto layout"
+ >
+ {positionWritePending ? (
+
+ ) : (
+
+ )}
+
@@ -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",
+ ]);
+ });
+});