diff --git a/app/src/pages/SystemMap.tsx b/app/src/pages/SystemMap.tsx index af5d020..f5cec6f 100644 --- a/app/src/pages/SystemMap.tsx +++ b/app/src/pages/SystemMap.tsx @@ -198,10 +198,13 @@ function MapNode({ data }: NodeProps) { {d.sub &&
{d.sub}
} - {/* Right handle: drag OUT to connect — to a hub (expose) or another node - (declare "requires"). Right target lands incoming dependency lines. */} + {/* Right handles: `rs` (source) is what you drag OUT to connect — to a hub + (expose) or another node (declare "requires"). `rt` (target) only anchors + incoming dependency lines, so it's nudged below `rs`: stacked on top it + would swallow the pointer-down (you can't START a drag from a target + handle in strict mode) and dragging a fresh line would do nothing. */} - + @@ -474,9 +477,13 @@ export function SystemMapPage() { // fallback, but there's no ProgramSpec, so no grip/link. const catalog = new Set((programs ?? []).map((p) => p.id)) const routes = gateway?.routes ?? [] - const exposed = new Set(routes.map((r) => r.name).filter(Boolean) as string[]) - const publicSet = new Set( - routes.filter((r) => r.public_url).map((r) => r.name).filter(Boolean) as string[], + // The gateway's authoritative public (tunnel) URL per exposed name, so a public + // service's launch link reads as its internet address, not the internal one. + // (Only the *address* comes from the gateway; exposure state is `n.reach`.) + const publicUrlOf = new Map( + routes + .filter((r) => r.public_url && r.name) + .map((r) => [r.name as string, r.public_url as string]), ) // Per-node lookups from the graph itself (authoritative for sockets/reach now). @@ -487,11 +494,15 @@ export function SystemMapPage() { // http-exposed service served at .. TCP/tools/jobs aren't. const domain = gateway?.domain ?? null const launchOf = (n: (typeof graph.nodes)[number]): string | undefined => { - if (!domain || !n.reach || n.reach === "off") return undefined - if (n.kind === "static") return `https://${n.name}.${domain}` - if (n.kind === "service" && n.endpoints.some((e) => e.protocol === "http")) - return `https://${n.name}.${domain}` - return undefined + if (!n.reach || n.reach === "off") return undefined + const isHttp = + n.kind === "static" || + (n.kind === "service" && n.endpoints.some((e) => e.protocol === "http")) + if (!isHttp) return undefined + // Public services live at both URLs; prefer the internet one so the link + // matches the green Internet edge instead of reading as internal-only. + if (n.reach === "public" && publicUrlOf.has(n.name)) return publicUrlOf.get(n.name) + return domain ? `https://${n.name}.${domain}` : undefined } // Remote launch: ., using the node's own acme domain @@ -745,17 +756,19 @@ export function SystemMapPage() { } } - // Reach is modal: one line per exposed node. public → a single green line to - // Internet; internal → a single blue line to Gateway. Dragging a new line to - // the other target switches the mode; deleting the line removes exposure. + // Reach is modal: one line per exposed node, driven by the node's own `reach` + // (the single source of truth). public → a single green line to Internet; + // internal → a single blue line to Gateway. Dragging a new line to the other + // target switches the mode; deleting the line removes exposure. const reachOf: Record = {} - for (const name of exposed) { - if (!present.has(name)) continue - const isPub = publicSet.has(name) - reachOf[name] = isPub ? "public" : "internal" + for (const n of graph.nodes) { + if (!present.has(n.name)) continue + if (n.reach !== "internal" && n.reach !== "public") continue + const isPub = n.reach === "public" + reachOf[n.name] = isPub ? "public" : "internal" edges.push({ - id: `exp:${name}`, - source: name, + id: `exp:${n.name}`, + source: n.name, target: isPub ? "__internet__" : "__gateway__", sourceHandle: "rs", targetHandle: "lt",