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",