From 004df3ba4f5b1effd38afc7cef889b0ed180331f Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Mon, 6 Jul 2026 20:36:52 -0700 Subject: [PATCH] fix: remote endpoint reach-gating + drag-to-connect resync race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Mesh endpoints now gate the http port on `subdomain` (the registry's "exposed" signal), matching the local reach-gated derivation — so a remote reach:off service (e.g. primer's castle-gateway) no longer shows a phantom :9000. - The focus/resync effect no longer rebuilds the node set while a connection is being dragged; a background refetch mid-drag was silently cancelling the connection, so drag-to-expose/-require intermittently "did nothing". --- app/src/pages/SystemMap.tsx | 18 ++++++++++++++++++ castle-api/src/castle_api/nodes.py | 7 +++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/app/src/pages/SystemMap.tsx b/app/src/pages/SystemMap.tsx index 9ebb0c5..5a880a4 100644 --- a/app/src/pages/SystemMap.tsx +++ b/app/src/pages/SystemMap.tsx @@ -436,6 +436,9 @@ export function SystemMapPage() { const kindRef = useRef>({}) const reachRef = useRef>({}) const focusRef = useRef(null) + // True while a connection is being dragged — the resync must not replace nodes + // mid-drag (a background refetch would otherwise silently abort the connection). + const connectingRef = useRef(false) // Manual node positions (persisted), and the react-flow instance for fitView. const posRef = useRef(null) if (posRef.current == null) posRef.current = loadPositions() @@ -923,6 +926,10 @@ export function SystemMapPage() { kindRef.current = built.kindOf reachRef.current = built.reachOf focusRef.current = focus + // Don't rebuild the node/edge set while a connection is being dragged — replacing + // the source node mid-drag silently cancels the connection (drag-to-expose "does + // nothing"). The mutation that follows the drop re-syncs us anyway. + if (connectingRef.current) return setNodes(materialize(built)) // External consumption is drawn only while the consumer is selected — a dashed // line to each of its reference nodes (kept off-canvas otherwise to avoid a @@ -1041,6 +1048,15 @@ export function SystemMapPage() { [applyReach, addDep], ) + const onConnectStart = useCallback(() => { + connectingRef.current = true + }, []) + // Re-sync on connect end (a refetch may have been skipped mid-drag). + const onConnectEnd = useCallback(() => { + connectingRef.current = false + setNodes((ns) => [...ns]) + }, [setNodes]) + // Click a dashed amber suggestion to accept it → declares the requires. const onEdgeClick = useCallback( (_e: React.MouseEvent, edge: Edge) => { @@ -1211,6 +1227,8 @@ export function SystemMapPage() { onNodesChange={onNodesChange} onEdgesChange={onEdgesChange} onConnect={onConnect} + onConnectStart={onConnectStart} + onConnectEnd={onConnectEnd} onEdgeClick={onEdgeClick} onNodeDragStop={onNodeDragStop} onEdgesDelete={onEdgesDelete} diff --git a/castle-api/src/castle_api/nodes.py b/castle-api/src/castle_api/nodes.py index 4b8b76f..a365668 100644 --- a/castle-api/src/castle_api/nodes.py +++ b/castle-api/src/castle_api/nodes.py @@ -97,10 +97,13 @@ _TCP_PROTOCOL = {5432: "pg", 7687: "bolt", 1883: "mqtt", 6379: "redis"} def _endpoints_of_registry(d: object) -> list[dict]: - """Derive display endpoints from a registry deployment (mirrors relations).""" + """Derive display endpoints from a registry deployment. Mirrors the local + relations derivation, which gates the http endpoint on being exposed — here the + registry's `subdomain` is that signal (a reach:off service has none). Without + this, remote reach:off services show a phantom port (e.g. castle-gateway :9000).""" eps: list[dict] = [] port = getattr(d, "port", None) - if port is not None: + if port is not None and getattr(d, "subdomain", None): eps.append({"protocol": "http", "port": port}) tcp = getattr(d, "tcp_port", None) if tcp is not None: