From 312bde4cc980325bcd77a6e1ef715189224cdc75 Mon Sep 17 00:00:00 2001 From: Arjun Komath Date: Fri, 26 Jun 2026 23:22:05 +1000 Subject: [PATCH] Make services canvas interactive Add shared persisted service positions and desktop drag handling for the services canvas. Update the desktop service cards with the deployment-card visual style, responsive scaling, centered defaults, snap-to-grid placement, and a position PATCH endpoint. Amp-Thread-ID: https://ampcode.com/threads/T-019f0199-9409-72c5-94c8-731ef7c3d856 Co-authored-by: Amp --- .../services/[serviceId]/position/route.ts | 53 ++ web/components/service/service-canvas.tsx | 486 +++++++++++++++--- web/db/schema.ts | 2 + 3 files changed, 457 insertions(+), 84 deletions(-) create mode 100644 web/app/api/projects/[id]/services/[serviceId]/position/route.ts diff --git a/web/app/api/projects/[id]/services/[serviceId]/position/route.ts b/web/app/api/projects/[id]/services/[serviceId]/position/route.ts new file mode 100644 index 00000000..771441d5 --- /dev/null +++ b/web/app/api/projects/[id]/services/[serviceId]/position/route.ts @@ -0,0 +1,53 @@ +import { and, eq, isNull } from "drizzle-orm"; +import { headers } from "next/headers"; +import { z } from "zod"; +import { db } from "@/db"; +import { services } from "@/db/schema"; +import { auth } from "@/lib/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 session = await auth.api.getSession({ + headers: await headers(), + }); + + if (!session) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + 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/components/service/service-canvas.tsx b/web/components/service/service-canvas.tsx index 429b9061..bb8fffdf 100644 --- a/web/components/service/service-canvas.tsx +++ b/web/components/service/service-canvas.tsx @@ -14,7 +14,8 @@ import { } from "lucide-react"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import { useMemo, useState } from "react"; +import type { AnchorHTMLAttributes, MouseEvent, PointerEvent } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import useSWR from "swr"; import { buttonVariants } from "@/components/ui/button"; import { getStatusColorFromDeployments } from "@/components/ui/canvas-wrapper"; @@ -48,10 +49,93 @@ import { CreateGitHubServiceDialog, } from "./create-service-dialog"; +type CanvasPosition = { + canvasX: number; + canvasY: number; +}; + +const SERVICE_CARD_WIDTH = 320; +const SERVICE_CARD_HEIGHT = 150; +const SERVICE_CARD_GAP_X = 56; +const SERVICE_CARD_GAP_Y = 48; +const DEFAULT_GRID_COLUMNS = 3; +const DEFAULT_GRID_ROWS = 3; +const CANVAS_WIDTH = 1320; +const CANVAS_HEIGHT = 900; +const MIN_CANVAS_SCALE = 0.5; +const SNAP_GRID_SIZE = 24; + +function getCanvasScale() { + if (typeof window === "undefined") { + return 1; + } + + const availableWidth = window.innerWidth - 96; + const availableHeight = window.innerHeight - 112; + + return Math.max( + MIN_CANVAS_SCALE, + Math.min(1, availableWidth / CANVAS_WIDTH, availableHeight / CANVAS_HEIGHT), + ); +} + +function getDefaultServicePosition(index: number): CanvasPosition { + const gridWidth = + DEFAULT_GRID_COLUMNS * SERVICE_CARD_WIDTH + + (DEFAULT_GRID_COLUMNS - 1) * SERVICE_CARD_GAP_X; + const gridHeight = + DEFAULT_GRID_ROWS * SERVICE_CARD_HEIGHT + + (DEFAULT_GRID_ROWS - 1) * SERVICE_CARD_GAP_Y; + const gridStartX = (CANVAS_WIDTH - gridWidth) / 2; + const gridStartY = (CANVAS_HEIGHT - gridHeight) / 2; + const column = index % DEFAULT_GRID_COLUMNS; + const row = Math.floor(index / DEFAULT_GRID_COLUMNS); + + return { + canvasX: gridStartX + column * (SERVICE_CARD_WIDTH + SERVICE_CARD_GAP_X), + canvasY: gridStartY + row * (SERVICE_CARD_HEIGHT + SERVICE_CARD_GAP_Y), + }; +} + +function clampPosition(position: CanvasPosition): CanvasPosition { + return { + canvasX: Math.max( + 0, + Math.min(CANVAS_WIDTH - SERVICE_CARD_WIDTH, Math.round(position.canvasX)), + ), + canvasY: Math.max( + 0, + Math.min( + CANVAS_HEIGHT - 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 getServicePosition( + service: ServiceWithDetails, + index: number, +): CanvasPosition { + const fallback = getDefaultServicePosition(index); + + return { + canvasX: service.canvasX ?? fallback.canvasX, + canvasY: service.canvasY ?? fallback.canvasY, + }; +} + function ServiceCardSkeleton() { return ( -
-
+
+
@@ -202,13 +286,17 @@ function ServiceCard({ projectSlug, envName, proxyDomain, + dragHandleProps, }: { service: ServiceWithDetails; projectSlug: string; envName: string; proxyDomain: string | null; + dragHandleProps?: AnchorHTMLAttributes; }) { const colors = getStatusColorFromDeployments(service.deployments); + const { className: dragHandleClassName, ...linkProps } = + dragHandleProps ?? {}; const publicPorts = service.ports.filter((p) => p.isPublic && p.domain); const tcpUdpPorts = service.ports.filter( (p) => @@ -229,99 +317,249 @@ function ServiceCard({ hasInternalDns; return ( -
+
-
-
-
-

+
+
+
+

{service.name}

- - - - +
+ + {runningCount > 0 && ( + + )} + + + + {service.deployments.length > 0 + ? `${runningCount}/${service.deployments.length}` + : "Not deployed"} + +
-
- {hasEndpoints && ( -
- {publicPorts.map((port) => ( -
- - - {port.domain} - -
- ))} - {tcpUdpPorts.length > 0 && - proxyDomain && - tcpUdpPorts.map((port) => ( + {hasEndpoints && ( +
+ {publicPorts.map((port) => (
- - - {port.protocol}://{proxyDomain}:{port.externalPort} - +
+ + {port.domain} +
))} - {hasInternalDns && ( -
- - - {service.hostname || service.name}.internal - -
- )} -
- )} + {tcpUdpPorts.length > 0 && + proxyDomain && + tcpUdpPorts.map((port) => ( +
+
+ + + {port.protocol}://{proxyDomain}:{port.externalPort} + +
+
+ ))} + {hasInternalDns && ( +
+
+ + + {service.hostname || service.name}.internal + +
+
+ )} +
+ )} +

{service.volumes && service.volumes.length > 0 && ( -
+
{service.volumes.map((volume) => (
- - {volume.name} + + + {volume.name} +
))}
)} + +
+ ); +} - {service.deployments.length > 0 && ( -
-
- Replicas - - {runningCount}/{service.deployments.length} - -
-
- )} +function DraggableServiceCard({ + service, + index, + projectSlug, + envName, + proxyDomain, + canvasScale, + onPositionChange, +}: { + service: ServiceWithDetails; + index: number; + projectSlug: string; + envName: string; + proxyDomain: string | null; + canvasScale: number; + onPositionChange: (serviceId: string, position: CanvasPosition) => void; +}) { + const [dragPosition, setDragPosition] = useState(null); + const dragRef = useRef<{ + pointerId: number; + startX: number; + startY: number; + origin: CanvasPosition; + moved: boolean; + } | null>(null); + const suppressClickRef = useRef(false); + const position = dragPosition ?? getServicePosition(service, index); - {service.deployments.length === 0 && ( -
- Not deployed -
- )} - + const handlePointerDown = useCallback( + (event: PointerEvent) => { + if (event.button !== 0) { + return; + } + + event.currentTarget.setPointerCapture(event.pointerId); + dragRef.current = { + pointerId: event.pointerId, + startX: event.clientX, + startY: event.clientY, + origin: position, + moved: false, + }; + }, + [position], + ); + + const handlePointerMove = useCallback( + (event: PointerEvent) => { + const drag = dragRef.current; + if (!drag || drag.pointerId !== event.pointerId) { + return; + } + + 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, + }); + + if (Math.abs(deltaX) > 3 || Math.abs(deltaY) > 3) { + drag.moved = true; + event.preventDefault(); + } + + setDragPosition(nextPosition); + }, + [canvasScale], + ); + + const handlePointerUp = useCallback( + (event: PointerEvent) => { + const drag = dragRef.current; + if (!drag || drag.pointerId !== event.pointerId) { + return; + } + + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + dragRef.current = null; + setDragPosition(null); + + if (drag.moved) { + suppressClickRef.current = true; + onPositionChange(service.id, snapPosition(position)); + } + }, + [onPositionChange, position, service.id], + ); + + const handlePointerCancel = useCallback( + (event: PointerEvent) => { + const drag = dragRef.current; + if (!drag || drag.pointerId !== event.pointerId) { + return; + } + + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + + dragRef.current = null; + setDragPosition(null); + suppressClickRef.current = false; + }, + [], + ); + + const handleClickCapture = useCallback( + (event: MouseEvent) => { + if (!suppressClickRef.current) { + return; + } + + suppressClickRef.current = false; + event.preventDefault(); + event.stopPropagation(); + }, + [], + ); + + return ( +
+ event.preventDefault(), + }} + />
); } @@ -346,6 +584,7 @@ export function ServiceCanvas({ const [dockerDialogOpen, setDockerDialogOpen] = useState(false); const [githubDialogOpen, setGithubDialogOpen] = useState(false); + const [canvasScale, setCanvasScale] = useState(1); const { data: services, @@ -360,6 +599,15 @@ export function ServiceCanvas({ }, ); + useEffect(() => { + const updateCanvasScale = () => setCanvasScale(getCanvasScale()); + + updateCanvasScale(); + window.addEventListener("resize", updateCanvasScale); + + return () => window.removeEventListener("resize", updateCanvasScale); + }, []); + const composeHref = `/dashboard/projects/${projectSlug}/${envName}/import-compose`; const menuCallbacks = useMemo( @@ -390,6 +638,57 @@ 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, + ); + + 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(); + }); + }, + [mutate, projectId], + ); + if (!environments || isLoading) { return ( <> @@ -535,11 +834,11 @@ export function ServiceCanvas({
-
- {services.map((service) => ( - - ))} +
+
+ {services.map((service, index) => ( + + ))} +