From b28645f2f4f365d26569e863f79ad999fddde103 Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Mon, 6 Jul 2026 21:07:10 -0700 Subject: [PATCH] mesh: carry gateway_domain so peers can launch remote apps The mesh payload didn't include a node's acme domain, so a peer had no way to build launch URLs for another machine's exposed apps. Publish gateway_domain in the MQTT registry payload and surface it per-deployment on /mesh/deployments as `domain`. The dashboard palette and System Map now build `https://.` for remote http-exposed apps (references use their base_url), making primer's apps launchable from civil. --- app/src/components/CommandPalette.tsx | 10 ++++++- app/src/pages/SystemMap.tsx | 34 +++++++++++++++++++++--- app/src/types/index.ts | 1 + castle-api/src/castle_api/mqtt_client.py | 4 +++ castle-api/src/castle_api/nodes.py | 6 +++-- 5 files changed, 49 insertions(+), 6 deletions(-) diff --git a/app/src/components/CommandPalette.tsx b/app/src/components/CommandPalette.tsx index 2fe67c4..496ee25 100644 --- a/app/src/components/CommandPalette.tsx +++ b/app/src/components/CommandPalette.tsx @@ -78,12 +78,20 @@ function PaletteBody({ onClose }: { onClose: () => void }) { }) } for (const md of mesh?.deployments ?? []) { + // A remote app is launchable at . when the node has an + // acme domain and the app is http-exposed (subdomain set); references carry base_url. + const remoteLaunch = + md.kind === "reference" + ? (md.base_url ?? undefined) + : md.subdomain && md.domain + ? `https://${md.subdomain}.${md.domain}` + : undefined out.push({ id: `${md.node}/${md.name}`, name: md.name, kind: md.kind, machine: md.node, - launchUrl: undefined, // remote launch URL isn't in the mesh payload yet + launchUrl: remoteLaunch, detailPath: `/node/${md.node}`, mapNodeId: `__remote_${md.node}_${md.name}__`, }) diff --git a/app/src/pages/SystemMap.tsx b/app/src/pages/SystemMap.tsx index 2ae0bd4..af38d50 100644 --- a/app/src/pages/SystemMap.tsx +++ b/app/src/pages/SystemMap.tsx @@ -324,11 +324,18 @@ function ExternalNode({ data }: NodeProps) { // A deployment on another castle node (mesh-discovered). Read-only — you manage // remote deployments from that node. function RemoteNode({ data }: NodeProps) { - const d = data as { label: string; kind: string; sub?: string; focusDim?: boolean; focused?: boolean } + const d = data as { + label: string + kind: string + sub?: string + launchUrl?: string + focusDim?: boolean + focused?: boolean + } const color = KIND_COLOR[d.kind] ?? "#8b949e" return (
+ {d.launchUrl && ( + e.stopPropagation()} + > + + + )}
@@ -476,6 +495,14 @@ export function SystemMapPage() { return undefined } + // Remote launch: ., using the node's own acme domain + // carried in the mesh payload. References launch at their base_url. + const remoteLaunchOf = (md: MeshDeployment): string | undefined => { + if (md.kind === "reference") return md.base_url ?? undefined + if (md.subdomain && md.domain) return `https://${md.subdomain}.${md.domain}` + return undefined + } + // Consumption of an external `reference` renders as a chip on the consumer (not // a long cross-map edge). Collect them here, keyed by the consuming deployment. // A job shows its cron; everything else prefers its declared socket, else its @@ -658,6 +685,7 @@ export function SystemMapPage() { label: md.name, kind: md.kind, sub: md.endpoints[0] ? `:${md.endpoints[0].port}` : undefined, + launchUrl: remoteLaunchOf(md), }, }) } @@ -870,7 +898,7 @@ export function SystemMapPage() { capsConsumes: [], reach: null, exposable: false, - launchUrl: ri.md.base_url ?? undefined, + launchUrl: remoteLaunchOf(ri.md), }) return { nodes, edges, kindOf, reachOf, consumes, consumedBy, meta } diff --git a/app/src/types/index.ts b/app/src/types/index.ts index 4f53186..8b6fc4a 100644 --- a/app/src/types/index.ts +++ b/app/src/types/index.ts @@ -168,6 +168,7 @@ export interface MeshDeployment { name: string kind: string node: string // the remote hostname + domain: string | null // the node's gateway acme domain — for . launch URLs port: number | null base_url: string | null subdomain: string | null diff --git a/castle-api/src/castle_api/mqtt_client.py b/castle-api/src/castle_api/mqtt_client.py index c212b62..7a107e5 100644 --- a/castle-api/src/castle_api/mqtt_client.py +++ b/castle-api/src/castle_api/mqtt_client.py @@ -37,6 +37,9 @@ def _registry_to_json(registry: NodeRegistry) -> str: "node": { "hostname": registry.node.hostname, "gateway_port": registry.node.gateway_port, + # acme domain — lets peers build launch URLs (.) + # for this node's exposed apps. Omitted when the node has no domain. + "gateway_domain": registry.node.gateway_domain, }, "deployed": {}, } @@ -83,6 +86,7 @@ def _json_to_registry(payload: str) -> NodeRegistry: hostname=node_data.get("hostname", ""), castle_root=node_data.get("castle_root"), gateway_port=node_data.get("gateway_port", 9000), + gateway_domain=node_data.get("gateway_domain"), ) deployed: dict[str, Deployment] = {} for key, comp_data in data.get("deployed", {}).items(): diff --git a/castle-api/src/castle_api/nodes.py b/castle-api/src/castle_api/nodes.py index a365668..cd5851c 100644 --- a/castle-api/src/castle_api/nodes.py +++ b/castle-api/src/castle_api/nodes.py @@ -115,16 +115,18 @@ def _endpoints_of_registry(d: object) -> list[dict]: def mesh_deployments() -> dict: """Flattened remote (mesh-discovered) deployments with derived endpoints — the data the System Map needs to render other machines. Local node excluded (it's - already in /graph). Cross-node dependency *edges* need `requires` in the mesh - payload, which the registry doesn't carry yet.""" + already in /graph). Each entry carries its node's `domain` (gateway acme domain) + so peers can build launch URLs `.` for exposed apps.""" out: list[dict] = [] for hostname, remote in mesh_state.all_nodes(include_stale=True).items(): + domain = getattr(remote.registry.node, "gateway_domain", None) for _kind, name, d in remote.registry.all(): out.append( { "name": name, "kind": d.kind, "node": hostname, + "domain": domain, "port": d.port, "base_url": getattr(d, "base_url", None), "subdomain": d.subdomain,