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://<subdomain>.<domain>` for remote http-exposed apps (references use their base_url), making primer's apps launchable from civil.
This commit is contained in:
@@ -78,12 +78,20 @@ function PaletteBody({ onClose }: { onClose: () => void }) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
for (const md of mesh?.deployments ?? []) {
|
for (const md of mesh?.deployments ?? []) {
|
||||||
|
// A remote app is launchable at <subdomain>.<node-domain> 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({
|
out.push({
|
||||||
id: `${md.node}/${md.name}`,
|
id: `${md.node}/${md.name}`,
|
||||||
name: md.name,
|
name: md.name,
|
||||||
kind: md.kind,
|
kind: md.kind,
|
||||||
machine: md.node,
|
machine: md.node,
|
||||||
launchUrl: undefined, // remote launch URL isn't in the mesh payload yet
|
launchUrl: remoteLaunch,
|
||||||
detailPath: `/node/${md.node}`,
|
detailPath: `/node/${md.node}`,
|
||||||
mapNodeId: `__remote_${md.node}_${md.name}__`,
|
mapNodeId: `__remote_${md.node}_${md.name}__`,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -324,11 +324,18 @@ function ExternalNode({ data }: NodeProps) {
|
|||||||
// A deployment on another castle node (mesh-discovered). Read-only — you manage
|
// A deployment on another castle node (mesh-discovered). Read-only — you manage
|
||||||
// remote deployments from that node.
|
// remote deployments from that node.
|
||||||
function RemoteNode({ data }: NodeProps) {
|
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"
|
const color = KIND_COLOR[d.kind] ?? "#8b949e"
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex w-[132px] items-stretch overflow-hidden rounded-md border border-dashed bg-[var(--card)] text-xs"
|
className="group relative flex w-[132px] items-stretch overflow-hidden rounded-md border border-dashed bg-[var(--card)] text-xs"
|
||||||
style={{
|
style={{
|
||||||
borderColor: color,
|
borderColor: color,
|
||||||
opacity: d.focusDim ? 0.12 : 0.9,
|
opacity: d.focusDim ? 0.12 : 0.9,
|
||||||
@@ -336,6 +343,18 @@ function RemoteNode({ data }: NodeProps) {
|
|||||||
}}
|
}}
|
||||||
title="remote (on another node)"
|
title="remote (on another node)"
|
||||||
>
|
>
|
||||||
|
{d.launchUrl && (
|
||||||
|
<a
|
||||||
|
href={d.launchUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="absolute right-0.5 top-0.5 z-10 rounded bg-[var(--card)]/80 p-0.5 text-[var(--muted)] opacity-0 hover:text-[var(--primary)] group-hover:opacity-100"
|
||||||
|
title={`Launch ${d.launchUrl}`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<ExternalLink size={12} />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
<div className="w-1 shrink-0" style={{ background: color }} />
|
<div className="w-1 shrink-0" style={{ background: color }} />
|
||||||
<div className="min-w-0 flex-1 px-2 py-1">
|
<div className="min-w-0 flex-1 px-2 py-1">
|
||||||
<div className="truncate text-[var(--card-foreground)]" title={d.label}>
|
<div className="truncate text-[var(--card-foreground)]" title={d.label}>
|
||||||
@@ -476,6 +495,14 @@ export function SystemMapPage() {
|
|||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remote launch: <subdomain>.<node-domain>, 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
|
// 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 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
|
// A job shows its cron; everything else prefers its declared socket, else its
|
||||||
@@ -658,6 +685,7 @@ export function SystemMapPage() {
|
|||||||
label: md.name,
|
label: md.name,
|
||||||
kind: md.kind,
|
kind: md.kind,
|
||||||
sub: md.endpoints[0] ? `:${md.endpoints[0].port}` : undefined,
|
sub: md.endpoints[0] ? `:${md.endpoints[0].port}` : undefined,
|
||||||
|
launchUrl: remoteLaunchOf(md),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -870,7 +898,7 @@ export function SystemMapPage() {
|
|||||||
capsConsumes: [],
|
capsConsumes: [],
|
||||||
reach: null,
|
reach: null,
|
||||||
exposable: false,
|
exposable: false,
|
||||||
launchUrl: ri.md.base_url ?? undefined,
|
launchUrl: remoteLaunchOf(ri.md),
|
||||||
})
|
})
|
||||||
|
|
||||||
return { nodes, edges, kindOf, reachOf, consumes, consumedBy, meta }
|
return { nodes, edges, kindOf, reachOf, consumes, consumedBy, meta }
|
||||||
|
|||||||
@@ -168,6 +168,7 @@ export interface MeshDeployment {
|
|||||||
name: string
|
name: string
|
||||||
kind: string
|
kind: string
|
||||||
node: string // the remote hostname
|
node: string // the remote hostname
|
||||||
|
domain: string | null // the node's gateway acme domain — for <subdomain>.<domain> launch URLs
|
||||||
port: number | null
|
port: number | null
|
||||||
base_url: string | null
|
base_url: string | null
|
||||||
subdomain: string | null
|
subdomain: string | null
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ def _registry_to_json(registry: NodeRegistry) -> str:
|
|||||||
"node": {
|
"node": {
|
||||||
"hostname": registry.node.hostname,
|
"hostname": registry.node.hostname,
|
||||||
"gateway_port": registry.node.gateway_port,
|
"gateway_port": registry.node.gateway_port,
|
||||||
|
# acme domain — lets peers build launch URLs (<subdomain>.<gateway_domain>)
|
||||||
|
# for this node's exposed apps. Omitted when the node has no domain.
|
||||||
|
"gateway_domain": registry.node.gateway_domain,
|
||||||
},
|
},
|
||||||
"deployed": {},
|
"deployed": {},
|
||||||
}
|
}
|
||||||
@@ -83,6 +86,7 @@ def _json_to_registry(payload: str) -> NodeRegistry:
|
|||||||
hostname=node_data.get("hostname", ""),
|
hostname=node_data.get("hostname", ""),
|
||||||
castle_root=node_data.get("castle_root"),
|
castle_root=node_data.get("castle_root"),
|
||||||
gateway_port=node_data.get("gateway_port", 9000),
|
gateway_port=node_data.get("gateway_port", 9000),
|
||||||
|
gateway_domain=node_data.get("gateway_domain"),
|
||||||
)
|
)
|
||||||
deployed: dict[str, Deployment] = {}
|
deployed: dict[str, Deployment] = {}
|
||||||
for key, comp_data in data.get("deployed", {}).items():
|
for key, comp_data in data.get("deployed", {}).items():
|
||||||
|
|||||||
@@ -115,16 +115,18 @@ def _endpoints_of_registry(d: object) -> list[dict]:
|
|||||||
def mesh_deployments() -> dict:
|
def mesh_deployments() -> dict:
|
||||||
"""Flattened remote (mesh-discovered) deployments with derived endpoints — the
|
"""Flattened remote (mesh-discovered) deployments with derived endpoints — the
|
||||||
data the System Map needs to render other machines. Local node excluded (it's
|
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
|
already in /graph). Each entry carries its node's `domain` (gateway acme domain)
|
||||||
payload, which the registry doesn't carry yet."""
|
so peers can build launch URLs `<subdomain>.<domain>` for exposed apps."""
|
||||||
out: list[dict] = []
|
out: list[dict] = []
|
||||||
for hostname, remote in mesh_state.all_nodes(include_stale=True).items():
|
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():
|
for _kind, name, d in remote.registry.all():
|
||||||
out.append(
|
out.append(
|
||||||
{
|
{
|
||||||
"name": name,
|
"name": name,
|
||||||
"kind": d.kind,
|
"kind": d.kind,
|
||||||
"node": hostname,
|
"node": hostname,
|
||||||
|
"domain": domain,
|
||||||
"port": d.port,
|
"port": d.port,
|
||||||
"base_url": getattr(d, "base_url", None),
|
"base_url": getattr(d, "base_url", None),
|
||||||
"subdomain": d.subdomain,
|
"subdomain": d.subdomain,
|
||||||
|
|||||||
Reference in New Issue
Block a user