feat(app): system map — consumption model, inspect mode, externals, mesh

Reframes the map around consumes / consumed_by and adds:
- Inspect on select: dim the rest, light a node's consumes/consumed-by, and a
  side panel listing both (removable/navigable) with protocol chips.
- Protocol-typed dependency edges (http/pg/bolt/mqtt) + reach badges; TCP infra
  now shows its port.
- External resources: a registry lane of reference nodes with an "add external"
  form; consumption of one renders as a chip on the consumer (no cross-map line).
- Suggested (undeclared) consumption as dashed amber edges — click to accept.
- Capability/candidates in the panel (alternative providers of the same protocol).
- A "Machines" band rendering other mesh nodes' deployments (read-only).

Adds useSuggestions / useMeshDeployments / useSaveReference hooks and the
GraphEndpoint / GraphSuggestion / MeshDeployment types.
This commit is contained in:
2026-07-06 16:09:12 -07:00
parent 800f539ef6
commit 46778b7f2e
3 changed files with 785 additions and 62 deletions

View File

@@ -27,21 +27,27 @@ import {
Minus,
MoreVertical,
Package,
Plus,
RotateCw,
Router,
Trash2,
X,
} from "lucide-react"
import {
useDeleteDeployment,
useGateway,
useGraph,
useJobs,
useMeshDeployments,
useMutateRequires,
usePrograms,
useSaveReference,
useServiceAction,
useServices,
useSetReach,
useSuggestions,
} from "@/services/api/hooks"
import { ConfirmModal } from "@/components/ConfirmModal"
import type { MeshDeployment } from "@/types"
// A spatial, interactive control surface for the whole box. Lanes read left→right:
// leaf tools/jobs, then the service+static spine where every dependency line lives,
@@ -74,6 +80,21 @@ const KIND_COLOR: Record<string, string> = {
static: "#39c5cf",
}
// Dependency edges are colored by the protocol of the thing being consumed (the
// target's endpoint). "system" = a socket-less dep (a tool/package) or a target
// castle doesn't model an endpoint for.
const PROTO_COLOR: Record<string, string> = {
http: "#58a6ff",
pg: "#3b82f6",
bolt: "#22d3ee",
mqtt: "#a855f7",
redis: "#ef4444",
tcp: "#f59e0b",
system: "#6e7681",
}
const EXTERNAL_X = 1450 // external resources (references) — beyond Internet
const MESH_Y = -150 // band above the local lanes for other machines' deployments
const HANDLE = { opacity: 0, width: 8, height: 8 } as const
// User's manual node arrangement. Positions default to the computed lane layout;
@@ -108,14 +129,24 @@ function MapNode({ id, data }: NodeProps) {
hub?: number
exposable?: boolean
program?: string | null
reach?: string | null
externals?: { name: string; host: string; protocol: string }[]
focusDim?: boolean
focused?: boolean
onMenu?: (x: number, y: number, name: string, kind: string) => void
onProgram?: (program: string) => void
}
const color = KIND_COLOR[d.kind] ?? "#8b949e"
const ReachIcon = d.reach === "public" ? Globe : d.reach === "internal" ? Router : null
return (
<div
className="group relative flex items-stretch overflow-hidden rounded-md border bg-[var(--card)] text-xs shadow-sm"
style={{ borderColor: color, width: 156, opacity: d.dim ? 0.55 : 1 }}
style={{
borderColor: color,
width: 156,
opacity: d.focusDim ? 0.12 : d.dim ? 0.55 : 1,
boxShadow: d.focused ? `0 0 0 2px ${color}` : undefined,
}}
>
<button
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(--card-foreground)] group-hover:opacity-100"
@@ -153,26 +184,45 @@ function MapNode({ id, data }: NodeProps) {
</div>
)}
<div className="min-w-0 flex-1 px-2 py-1.5">
<div className="truncate font-medium text-[var(--card-foreground)]" title={d.label}>
<div className="flex items-center gap-1">
<span className="min-w-0 flex-1 truncate font-medium text-[var(--card-foreground)]" title={d.label}>
{d.label}
</span>
{ReachIcon && (
<ReachIcon
size={10}
className="shrink-0"
style={{ color: d.reach === "public" ? "#2ea043" : "#58a6ff" }}
/>
)}
</div>
{d.sub && <div className="truncate text-[10px] text-[var(--muted)]">{d.sub}</div>}
{typeof d.hub === "number" && d.hub > 0 && (
<div className="text-[10px]" style={{ color }}>
{d.hub} depend on this
consumed by {d.hub}
</div>
)}
{d.externals && d.externals.length > 0 && (
<div className="mt-0.5 flex flex-wrap gap-0.5">
{d.externals.map((x) => (
<span
key={x.name}
className="inline-flex items-center gap-0.5 rounded border border-[var(--border)] bg-black/20 px-1 text-[9px] text-[var(--muted)]"
title={`consumes external: ${x.name} (${x.protocol})`}
>
<ExternalLink size={8} />
{x.host}
</span>
))}
</div>
)}
</div>
<Handle
id="rs"
type="source"
position={Position.Right}
isConnectable={!!d.exposable}
style={{ ...HANDLE, opacity: d.exposable ? 0.9 : 0, background: color }}
/>
{/* Right handle: drag OUT to connect — to a hub (expose) or another node
(declare "requires"). Right target lands incoming dependency lines. */}
<Handle id="rs" type="source" position={Position.Right} style={{ ...HANDLE, opacity: 0.9, background: color }} />
<Handle id="rt" type="target" position={Position.Right} style={{ ...HANDLE, opacity: 0 }} />
<Handle id="lt" type="target" position={Position.Left} isConnectable={false} style={{ ...HANDLE, opacity: 0 }} />
<Handle id="ls" type="source" position={Position.Left} isConnectable={false} style={{ ...HANDLE, opacity: 0 }} />
<Handle id="rt" type="target" position={Position.Right} isConnectable={false} style={{ ...HANDLE, opacity: 0 }} />
</div>
)
}
@@ -228,7 +278,85 @@ function LaneNode({ data }: NodeProps) {
)
}
const nodeTypes = { map: MapNode, hub: HubNode, lane: LaneNode, program: ProgramNode }
// The host + a coarse protocol from a reference's base_url (for chips/labels).
const SCHEME_PROTO: Record<string, string> = { https: "http", http: "http", postgres: "pg", postgresql: "pg", bolt: "bolt", mqtt: "mqtt", redis: "redis" }
function hostOf(url: string | null): string {
if (!url) return ""
try {
return new URL(url).host || url
} catch {
return url
}
}
function protoOf(url: string | null): string {
if (!url) return "system"
try {
return SCHEME_PROTO[new URL(url).protocol.replace(":", "")] ?? "system"
} catch {
return "system"
}
}
// An external resource — a `reference` (manager: none) castle doesn't run. Sits in
// the External zone as a registry entry + a drop target for consumes edges.
function ExternalNode({ data }: NodeProps) {
const d = data as { label: string; host: string; focusDim?: boolean; focused?: boolean }
return (
<div
className="flex w-[150px] flex-col gap-0.5 rounded-md border border-dashed px-2 py-1.5 text-xs"
style={{
borderColor: "#8b949e",
background: "var(--card)",
opacity: d.focusDim ? 0.12 : 1,
boxShadow: d.focused ? "0 0 0 2px #8b949e" : undefined,
}}
>
<div className="flex items-center gap-1 text-[var(--card-foreground)]">
<ExternalLink size={11} className="shrink-0 text-[var(--muted)]" />
<span className="min-w-0 flex-1 truncate font-medium" title={d.label}>
{d.label}
</span>
</div>
{d.host && (
<span className="truncate font-mono text-[9px] text-[var(--muted)]" title={d.host}>
{d.host}
</span>
)}
<Handle id="lt" type="target" position={Position.Left} style={{ ...HANDLE, opacity: 0.9, background: "#8b949e" }} />
</div>
)
}
// 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 }
const color = KIND_COLOR[d.kind] ?? "#8b949e"
return (
<div
className="flex w-[132px] items-stretch overflow-hidden rounded-md border border-dashed bg-[var(--card)] text-xs opacity-90"
style={{ borderColor: color }}
title="remote (on another node)"
>
<div className="w-1 shrink-0" style={{ background: color }} />
<div className="min-w-0 flex-1 px-2 py-1">
<div className="truncate text-[var(--card-foreground)]" title={d.label}>
{d.label}
</div>
{d.sub && <div className="truncate text-[9px] text-[var(--muted)]">{d.sub}</div>}
</div>
</div>
)
}
const nodeTypes = {
map: MapNode,
hub: HubNode,
lane: LaneNode,
program: ProgramNode,
external: ExternalNode,
remote: RemoteNode,
}
interface Built {
nodes: Node[]
@@ -246,14 +374,17 @@ interface MenuItem {
export function SystemMapPage() {
const { data: graph } = useGraph()
const { data: services } = useServices()
const { data: jobs } = useJobs()
const { data: programs } = usePrograms()
const { data: gateway } = useGateway()
const { data: suggestionsResp } = useSuggestions()
const { data: meshResp } = useMeshDeployments()
const setReach = useSetReach()
const deleteDeployment = useDeleteDeployment()
const serviceAction = useServiceAction()
const mutateRequires = useMutateRequires()
const saveReference = useSaveReference()
const navigate = useNavigate()
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([])
@@ -264,6 +395,9 @@ export function SystemMapPage() {
// Lasso mode: when on, a plain left-drag draws a selection box (pan moves to
// middle/right mouse). When off, left-drag pans and Shift-drag still lassos.
const [lasso, setLasso] = useState(false)
// Inspect/focus: the single selected node whose consumes/consumed_by light up.
const [focus, setFocus] = useState<string | null>(null)
const [addExt, setAddExt] = useState(false)
const openMenu = useCallback(
(x: number, y: number, name: string, kind: string) => setMenu({ x, y, name, kind }),
@@ -275,6 +409,7 @@ export function SystemMapPage() {
// stable callbacks below don't need it in their dep arrays.
const kindRef = useRef<Record<string, string>>({})
const reachRef = useRef<Record<string, "internal" | "public">>({})
const focusRef = useRef<string | null>(null)
// Manual node positions (persisted), and the react-flow instance for fitView.
const posRef = useRef<PosMap | null>(null)
if (posRef.current == null) posRef.current = loadPositions()
@@ -283,7 +418,6 @@ export function SystemMapPage() {
const built = useMemo<Built>(() => {
if (!graph) return { nodes: [], edges: [], kindOf: {}, reachOf: {} }
const portOf = new Map((services ?? []).map((s) => [s.id, s.port]))
const scheduleOf = new Map((jobs ?? []).map((j) => [j.id, j.schedule]))
// A deployment only links to a program if that program actually exists in the
// catalog (has source). Infra like mqtt/postgres are inline container/compose
@@ -296,11 +430,28 @@ export function SystemMapPage() {
routes.filter((r) => r.public_url).map((r) => r.name).filter(Boolean) as string[],
)
// A job shows its cron; a service shows its port.
// Per-node lookups from the graph itself (authoritative for sockets/reach now).
const byName = new Map(graph.nodes.map((n) => [n.name, n]))
const epOf = new Map(graph.nodes.map((n) => [n.name, n.endpoints]))
// 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.
const externalsOf = new Map<string, { name: string; host: string; protocol: string }[]>()
for (const e of graph.edges) {
if (e.kind !== "deployment") continue
const dst = byName.get(e.dst)
if (dst?.kind !== "reference") continue
const arr = externalsOf.get(e.src) ?? []
arr.push({ name: e.dst, host: hostOf(dst.base_url) || e.dst, protocol: protoOf(dst.base_url) })
externalsOf.set(e.src, arr)
}
// A job shows its cron; everything else prefers its declared socket, else its
// gateway http port. This is what makes raw-TCP infra (postgres :5432) visible.
const subFor = (n: (typeof graph.nodes)[number]): string | undefined => {
if (n.kind === "job") return scheduleOf.get(n.name) ?? undefined
const port = portOf.get(n.name)
return port ? `:${port}` : undefined
const ep = n.endpoints?.[0]
return ep ? `:${ep.port}` : undefined
}
const kindOf: Record<string, string> = {}
@@ -325,6 +476,8 @@ export function SystemMapPage() {
hub: n.depended_on_by,
exposable: !!lane.exposable,
program: n.program && catalog.has(n.program) ? n.program : null,
reach: n.reach,
externals: externalsOf.get(n.name),
},
} satisfies Node
})
@@ -355,6 +508,58 @@ export function SystemMapPage() {
})
}
// External resources (references) — the registry + drop targets in the External
// zone. Consumption of one shows as a chip on the consumer (above), not a line.
const refs = graph.nodes.filter((n) => n.kind === "reference")
refs.forEach((n, i) => {
kindOf[n.name] = "reference"
nodes.push({
id: n.name,
type: "external",
position: { x: EXTERNAL_X, y: TOP + i * ROW_H },
deletable: true,
data: { label: n.name, host: hostOf(n.base_url) },
})
})
// Other machines (mesh-discovered) — a read-only band above the local lanes,
// one row per node, its deployments laid out horizontally.
const byMachine = new Map<string, MeshDeployment[]>()
for (const md of meshResp?.deployments ?? []) {
const arr = byMachine.get(md.node) ?? []
arr.push(md)
byMachine.set(md.node, arr)
}
let mrow = 0
for (const [machine, deps] of byMachine) {
const y = MESH_Y - mrow * (ROW_H + 8)
nodes.push({
id: `__machine_${machine}__`,
type: "lane",
position: { x: PROG_X, y: y - 4 },
selectable: false,
draggable: false,
deletable: false,
data: { label: `${machine}` },
})
deps.forEach((md, i) => {
nodes.push({
id: `__remote_${machine}_${md.name}__`,
type: "remote",
position: { x: i * 150, y },
selectable: false,
draggable: false,
deletable: false,
data: {
label: md.name,
kind: md.kind,
sub: md.endpoints[0] ? `:${md.endpoints[0].port}` : undefined,
},
})
})
mrow++
}
const maxRows = Math.max(1, ...Object.values(perLane))
const midY = TOP + ((maxRows - 1) * ROW_H) / 2
// Gateway shows its host origin; Internet shows the public (tunnel) zone.
@@ -377,6 +582,7 @@ export function SystemMapPage() {
[PROG_X, "Programs"],
...Object.values(LANES).map((l) => [l.x, l.title] as [number, string]),
[GATEWAY_X, "Exposure"],
...(refs.length ? ([[EXTERNAL_X, "External"]] as [number, string][]) : []),
]
for (const [x, title] of headers) {
nodes.push({
@@ -395,17 +601,23 @@ export function SystemMapPage() {
for (const e of graph.edges) {
if (e.kind !== "deployment") continue
if (!present.has(e.src) || !present.has(e.dst)) continue
if (!present.has(e.src)) continue
if (byName.get(e.dst)?.kind === "reference") continue // shown as a chip, not a line
if (!present.has(e.dst)) continue
// The edge inherits the protocol of the thing being consumed (target socket).
const proto = epOf.get(e.dst)?.[0]?.protocol ?? "system"
const color = PROTO_COLOR[proto] ?? PROTO_COLOR.system
edges.push({
id: `dep:${e.src}->${e.dst}`,
source: e.src,
target: e.dst,
sourceHandle: "ls",
targetHandle: "rt",
label: e.bind ?? undefined,
deletable: false,
style: { stroke: "#6e7681", strokeWidth: 1.5 },
labelStyle: { fill: "#8b949e", fontSize: 10 },
label: e.bind ? `${proto} · ${e.bind}` : proto,
deletable: true,
data: { protocol: proto },
style: { stroke: color, strokeWidth: 1.5 },
labelStyle: { fill: color, fontSize: 10 },
labelBgStyle: { fill: "#0d1117" },
})
}
@@ -458,31 +670,89 @@ export function SystemMapPage() {
})
}
return { nodes, edges, kindOf, reachOf }
}, [graph, services, jobs, programs, gateway])
// Suggested (undeclared) consumption — dashed amber, click to accept. Advisory:
// derived from env, never written until you accept (which declares a requires).
for (const s of suggestionsResp?.suggestions ?? []) {
if (!present.has(s.consumer) || !present.has(s.provider)) continue
edges.push({
id: `sug:${s.consumer}->${s.provider}`,
source: s.consumer,
target: s.provider,
sourceHandle: "ls",
targetHandle: "rt",
label: `suggest: ${s.env_var}`,
deletable: false,
style: { stroke: "#f59e0b", strokeWidth: 1.5, strokeDasharray: "5 4" },
labelStyle: { fill: "#f59e0b", fontSize: 9 },
labelBgStyle: { fill: "#0d1117" },
})
}
// Turn a Built into live nodes: inject the (stable) menu/program openers into
// deployment nodes, and override each position with the user's saved layout
// (falling back to the computed lane position).
const materialize = useCallback(
(b: Built): Node[] =>
b.nodes.map((n) => ({
...n,
position: posRef.current![n.id] ?? n.position,
data: n.type === "map" ? { ...n.data, onMenu: openMenu, onProgram: openProgram } : n.data,
})),
[openMenu, openProgram],
return { nodes, edges, kindOf, reachOf }
}, [graph, jobs, programs, gateway, suggestionsResp, meshResp])
// The lit neighborhood for the focused node: itself + everything it consumes +
// everything that consumes it (from the authoritative graph edges). null = no focus.
const litOf = useCallback(
(f: string | null): Set<string> | null => {
if (!f || !graph) return null
const lit = new Set<string>([f])
for (const e of graph.edges) {
if (e.kind !== "deployment") continue
if (e.src === f) lit.add(e.dst)
if (e.dst === f) lit.add(e.src)
}
return lit
},
[graph],
)
// Server is the source of truth: whenever derived data changes, resync the
// Turn a Built into live nodes: inject the (stable) menu/program openers, override
// positions with the saved layout, and apply focus dimming/highlight (via focusRef,
// so the server-resync path re-reads current focus).
const materialize = useCallback(
(b: Built): Node[] => {
const lit = litOf(focusRef.current)
return b.nodes.map((n) => {
const focusDim = lit ? !lit.has(n.id) : false
const focused = n.id === focusRef.current
const inspectable = n.type === "map" || n.type === "external"
let data = n.data
if (n.type === "map") data = { ...data, onMenu: openMenu, onProgram: openProgram }
if (inspectable) data = { ...data, focusDim, focused }
// Keep the focused node selected across resyncs so inspect mode survives a
// background refetch (otherwise react-flow clears selection → focus clears).
return { ...n, position: posRef.current![n.id] ?? n.position, selected: focused, data }
})
},
[openMenu, openProgram, litOf],
)
// Dim edges not touching the focused node.
const dimEdges = useCallback((es: Edge[]): Edge[] => {
const f = focusRef.current
return es.map((e) => ({
...e,
style: { ...e.style, opacity: !f || e.source === f || e.target === f ? 1 : 0.07 },
}))
}, [])
// Server is the source of truth: whenever derived data or focus changes, resync the
// canvas (preserving the user's dragged positions via posRef). Interactions fire
// mutations that invalidate the queries, which flow back through here.
useEffect(() => {
kindRef.current = built.kindOf
reachRef.current = built.reachOf
focusRef.current = focus
setNodes(materialize(built))
setEdges(built.edges)
}, [built, setNodes, setEdges, materialize])
setEdges(dimEdges(built.edges))
}, [built, focus, setNodes, setEdges, materialize, dimEdges])
// Single-node selection = inspect (dim + panel); 0 or many = no focus (drag mode).
const onSelectionChange = useCallback((p: { nodes: Node[] }) => {
const picks = p.nodes.filter((n) => n.type === "map" || n.type === "external")
setFocus(picks.length === 1 ? picks[0].id : null)
}, [])
// Remember where the user dropped a node (persist across refetches + reloads).
const onNodeDragStop = useCallback((_e: MouseEvent | TouchEvent, _n: Node, dragged: Node[]) => {
@@ -513,7 +783,38 @@ export function SystemMapPage() {
[setReach],
)
// Drop a connection on a hub → set exposure. Anything else is ignored.
// Declare / drop a `requires` edge (source requires target), then apply.
const addDep = useCallback(
(src: string, srcKind: string, dst: string) => {
setBanner({ type: "info", text: `${src} → requires ${dst} — applying…` })
mutateRequires.mutate(
{ name: src, kind: srcKind, add: dst },
{
onSuccess: () => setBanner({ type: "info", text: `${src} now requires ${dst}.` }),
onError: (e) =>
setBanner({ type: "error", text: `${src}: ${e instanceof Error ? e.message : String(e)}` }),
},
)
},
[mutateRequires],
)
const removeDep = useCallback(
(src: string, srcKind: string, dst: string) => {
setBanner({ type: "info", text: `${src} → drop requires ${dst} — applying…` })
mutateRequires.mutate(
{ name: src, kind: srcKind, remove: dst },
{
onSuccess: () => setBanner({ type: "info", text: `${src} no longer requires ${dst}.` }),
onError: (e) =>
setBanner({ type: "error", text: `${src}: ${e instanceof Error ? e.message : String(e)}` }),
},
)
},
[mutateRequires],
)
// A dropped connection: onto a hub → set exposure; onto another deployment →
// declare "source requires target".
const onConnect = useCallback(
(c: Connection) => {
if (!c.source) return
@@ -521,14 +822,30 @@ export function SystemMapPage() {
if (!kind) return
if (c.target === "__internet__") applyReach(c.source, kind, "public")
else if (c.target === "__gateway__") applyReach(c.source, kind, "internal")
else if (c.target && kindRef.current[c.target] && c.target !== c.source)
addDep(c.source, kind, c.target)
},
[applyReach],
[applyReach, addDep],
)
// Click a dashed amber suggestion to accept it → declares the requires.
const onEdgeClick = useCallback(
(_e: React.MouseEvent, edge: Edge) => {
if (!edge.id.startsWith("sug:")) return
const kind = kindRef.current[edge.source]
if (kind) addDep(edge.source, kind, edge.target)
},
[addDep],
)
const isValidConnection = useCallback((c: Connection | Edge) => {
const kind = c.source ? kindRef.current[c.source] : undefined
const exposable = kind === "service" || kind === "static"
return exposable && (c.target === "__internet__" || c.target === "__gateway__")
if (!kind) return false
// Exposure: only services/frontends, only onto a hub.
if (c.target === "__internet__" || c.target === "__gateway__")
return kind === "service" || kind === "static"
// Dependency: onto any other deployment.
return !!(c.target && kindRef.current[c.target]) && c.target !== c.source
}, [])
// Deleting a node's exposure line removes exposure: a service drops to off; a
@@ -537,32 +854,39 @@ export function SystemMapPage() {
const onEdgesDelete = useCallback(
(deleted: Edge[]) => {
for (const e of deleted) {
if (!e.id.startsWith("exp:")) continue
const kind = kindRef.current[e.source]
if (!kind) continue
if (e.id.startsWith("dep:")) {
removeDep(e.source, kind, e.target)
} else if (e.id.startsWith("exp:")) {
if (kind === "static") {
if (reachRef.current[e.source] === "public") applyReach(e.source, kind, "internal")
} else {
applyReach(e.source, kind, "off")
}
}
}
},
[applyReach],
[applyReach, removeDep],
)
// Intercept deletion: allow exposure edges through (onEdgesDelete handles them),
// route node deletion to a confirm modal and block the immediate removal.
const onBeforeDelete = useCallback(
async ({ nodes: delNodes, edges: delEdges }: { nodes: Node[]; edges: Edge[] }) => {
const target = delNodes.find((n) => n.type === "map")
const target = delNodes.find((n) => n.type === "map" || n.type === "external")
if (target) {
const kind = kindRef.current[target.id]
if (kind) setConfirmDel({ name: target.id, kind })
}
const allowedEdges = delEdges.filter((e) => {
if (!e.id.startsWith("exp:")) return false
if (e.id.startsWith("dep:")) return true // remove a requires
if (e.id.startsWith("exp:")) {
// A static's internal line can't be removed — it's always served.
if (kindRef.current[e.source] === "static") return reachRef.current[e.source] === "public"
return true
}
return false // "same program" links aren't editable here
})
return { nodes: [], edges: allowedEdges }
},
@@ -638,7 +962,74 @@ export function SystemMapPage() {
return items
}, [menu, built, navigate, restart, applyReach])
const busy = setReach.isPending || deleteDeployment.isPending || serviceAction.isPending
// The inspected node's consumes / consumed_by, resolved from the graph edges.
const focusInfo = useMemo(() => {
if (!focus || !graph) return null
const node = graph.nodes.find((n) => n.name === focus)
if (!node) return null
const kindByName = new Map(graph.nodes.map((n) => [n.name, n.kind]))
const refByName = new Map(graph.nodes.filter((n) => n.kind === "reference").map((n) => [n.name, n]))
const epByName = new Map(graph.nodes.map((n) => [n.name, n.endpoints]))
// Candidates for an interface: other providers exposing the same protocol. Only
// meaningful for distinctive protocols — "http" isn't an interface, it's a transport.
const providersOf = (proto: string, exclude: string): string[] =>
proto === "http" || proto === "system"
? []
: graph.nodes
.filter((n) => n.name !== exclude && n.name !== focus && n.endpoints.some((e) => e.protocol === proto))
.map((n) => n.name)
const consumes = graph.edges
.filter((e) => e.kind === "deployment" && e.src === focus)
.map((e) => {
const ref = refByName.get(e.dst)
const protocol = ref ? protoOf(ref.base_url) : (epByName.get(e.dst)?.[0]?.protocol ?? "system")
return {
name: e.dst,
kind: kindByName.get(e.dst) ?? "",
external: !!ref,
host: ref ? hostOf(ref.base_url) : undefined,
protocol,
alternatives: ref ? [] : providersOf(protocol, e.dst),
}
})
const consumedBy = graph.edges
.filter((e) => e.kind === "deployment" && e.dst === focus)
.map((e) => ({ name: e.src, kind: kindByName.get(e.src) ?? "" }))
return {
name: node.name,
kind: node.kind,
provides: node.provides ?? [],
capsConsumes: node.consumes ?? [],
consumes,
consumedBy,
}
}, [focus, graph])
const addExternal = useCallback(
(name: string, base_url: string) => {
setAddExt(false)
setBanner({ type: "info", text: `Adding external ${name}` })
saveReference.mutate(
{ name, base_url },
{
onSuccess: () => setBanner({ type: "info", text: `External ${name} added.` }),
onError: (e) =>
setBanner({ type: "error", text: `${name}: ${e instanceof Error ? e.message : String(e)}` }),
},
)
},
[saveReference],
)
const detailPath = (name: string, kind: string) =>
kind === "job" ? `/jobs/${name}` : kind === "tool" ? `/tools/${name}` : `/services/${name}`
const busy =
setReach.isPending ||
deleteDeployment.isPending ||
serviceAction.isPending ||
mutateRequires.isPending ||
saveReference.isPending
return (
<div className="h-[calc(100vh-3.5rem)] w-full">
@@ -648,12 +1039,14 @@ export function SystemMapPage() {
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onEdgeClick={onEdgeClick}
onNodeContextMenu={onNodeContextMenu}
onNodeDragStop={onNodeDragStop}
onEdgesDelete={onEdgesDelete}
onBeforeDelete={onBeforeDelete}
isValidConnection={isValidConnection}
onInit={(inst) => (rfRef.current = inst)}
onSelectionChange={onSelectionChange}
nodeTypes={nodeTypes}
fitView
fitViewOptions={{ padding: 0.1 }}
@@ -678,6 +1071,9 @@ export function SystemMapPage() {
<ControlButton onClick={resetLayout} title="Reset layout">
<LayoutGrid size={12} />
</ControlButton>
<ControlButton onClick={() => setAddExt(true)} title="Add external resource">
<Plus size={12} />
</ControlButton>
</Controls>
<Legend />
{(banner || busy) && (
@@ -698,6 +1094,22 @@ export function SystemMapPage() {
<NodeMenu menu={menu} items={menuItems} onClose={() => setMenu(null)} />
)}
{focusInfo && (
<InspectPanel
info={focusInfo}
onClose={() => setFocus(null)}
onOpen={(name, kind) => navigate(detailPath(name, kind))}
onUnlink={(target, targetKind) => {
const del = target
// A reference target's chip has no line; drop the requires from the focus.
void targetKind
removeDep(focusInfo.name, focusInfo.kind, del)
}}
/>
)}
{addExt && <AddExternalModal onSave={addExternal} onCancel={() => setAddExt(false)} />}
<ConfirmModal
open={!!confirmDel}
danger
@@ -763,10 +1175,13 @@ function NodeMenu({
function Legend() {
const items = [
{ c: "#6e7681", label: "requires (dependency)" },
{ c: PROTO_COLOR.http, label: "consumes — http" },
{ c: PROTO_COLOR.pg, label: "consumes — pg / db" },
{ c: PROTO_COLOR.system, label: "consumes — other" },
{ c: "#bc8cff", label: "same program", dashed: true },
{ c: "#58a6ff", label: "internal — on your LAN" },
{ c: "#2ea043", label: "public — on the internet" },
{ c: "#f59e0b", label: "suggested — click to accept", dashed: true },
{ c: "#58a6ff", label: "internal — LAN" },
{ c: "#2ea043", label: "public — internet" },
]
return (
<div className="absolute bottom-3 right-3 z-10 flex flex-col gap-1.5 rounded-md border border-[var(--border)] bg-[var(--card)]/90 px-3 py-2 text-[11px] text-[var(--muted)]">
@@ -784,12 +1199,197 @@ function Legend() {
</div>
))}
<div className="mt-1 border-t border-[var(--border)] pt-1.5 leading-relaxed">
Click a node to inspect its consumes / consumed-by.
<br />
Drag a node <span className="text-[#2ea043]">Internet</span> to publish, {" "}
<span className="text-[#58a6ff]">LAN</span> for internal.
<span className="text-[#58a6ff]">LAN</span> for internal; drag nodenode to add a consumes.
<br />
Select a line or node + <kbd className="rounded bg-black/40 px-1">Del</kbd> to remove.
<br />
<kbd className="rounded bg-black/40 px-1">Shift</kbd>-drag to box-select; drag any selected node to move them together.
</div>
</div>
)
}
// The inspect panel — a fixed right-side card listing the focused node's consumes
// (with protocol/external chips) and consumed-by, each removable/navigable.
function InspectPanel({
info,
onClose,
onOpen,
onUnlink,
}: {
info: {
name: string
kind: string
provides: string[]
capsConsumes: string[]
consumes: {
name: string
kind: string
external: boolean
host?: string
protocol: string
alternatives: string[]
}[]
consumedBy: { name: string; kind: string }[]
}
onClose: () => void
onOpen: (name: string, kind: string) => void
onUnlink: (target: string, targetKind: string) => void
}) {
return (
<div className="absolute right-3 top-3 z-20 flex max-h-[calc(100%-1.5rem)] w-64 flex-col overflow-hidden rounded-lg border border-[var(--border)] bg-[var(--card)] text-xs shadow-xl">
<div className="flex items-center gap-2 border-b border-[var(--border)] px-3 py-2">
<span className="min-w-0 flex-1 truncate font-semibold text-[var(--card-foreground)]" title={info.name}>
{info.name}
</span>
<span className="shrink-0 rounded bg-black/30 px-1.5 py-0.5 text-[9px] uppercase text-[var(--muted)]">
{info.kind}
</span>
<button onClick={() => onOpen(info.name, info.kind)} title="Open" className="text-[var(--muted)] hover:text-[var(--card-foreground)]">
<ExternalLink size={13} />
</button>
<button onClick={onClose} title="Close" className="text-[var(--muted)] hover:text-[var(--card-foreground)]">
<X size={14} />
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-3 py-2">
{(info.provides.length > 0 || info.capsConsumes.length > 0) && (
<PanelSection title="Capabilities">
{info.provides.map((t) => (
<span key={`p-${t}`} className="mr-1 inline-block rounded bg-green-900/40 px-1 text-[9px] text-green-300" title="provides">
provides {t}
</span>
))}
{info.capsConsumes.map((t) => (
<span key={`c-${t}`} className="mr-1 inline-block rounded bg-blue-900/40 px-1 text-[9px] text-blue-300" title="needs">
needs {t}
</span>
))}
</PanelSection>
)}
<PanelSection title={`Consumes (${info.consumes.length})`}>
{info.consumes.length === 0 && <Empty />}
{info.consumes.map((c) => (
<div key={c.name} className="group py-0.5">
<div className="flex items-center gap-1.5">
<span
className="shrink-0 rounded px-1 text-[9px] font-medium"
style={{ background: `${PROTO_COLOR[c.protocol] ?? PROTO_COLOR.system}33`, color: PROTO_COLOR[c.protocol] ?? PROTO_COLOR.system }}
>
{c.protocol}
</span>
{c.external ? (
<span className="inline-flex min-w-0 flex-1 items-center gap-1 truncate text-[var(--muted)]" title={c.host}>
<ExternalLink size={10} className="shrink-0" />
{c.name}
</span>
) : (
<button
className="min-w-0 flex-1 truncate text-left text-[var(--card-foreground)] hover:underline"
onClick={() => onOpen(c.name, c.kind)}
>
{c.name}
</button>
)}
<button
onClick={() => onUnlink(c.name, c.kind)}
title="Remove this dependency"
className="shrink-0 text-[var(--muted)] opacity-0 hover:text-red-400 group-hover:opacity-100"
>
<X size={11} />
</button>
</div>
{c.alternatives.length > 0 && (
<div className="pl-6 text-[9px] text-[var(--muted)]" title={`other ${c.protocol} providers`}>
alt: {c.alternatives.join(", ")}
</div>
)}
</div>
))}
</PanelSection>
<PanelSection title={`Consumed by (${info.consumedBy.length})`}>
{info.consumedBy.length === 0 && <Empty />}
{info.consumedBy.map((c) => (
<button
key={c.name}
className="block w-full truncate py-0.5 text-left text-[var(--card-foreground)] hover:underline"
onClick={() => onOpen(c.name, c.kind)}
>
{c.name}
</button>
))}
</PanelSection>
</div>
</div>
)
}
function PanelSection({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="mb-2">
<div className="mb-1 text-[10px] font-semibold uppercase tracking-wide text-[var(--muted)]">{title}</div>
{children}
</div>
)
}
function Empty() {
return <div className="text-[10px] italic text-[var(--muted)]">none</div>
}
// A small form to declare an external resource (a `reference` — name + base_url).
function AddExternalModal({
onSave,
onCancel,
}: {
onSave: (name: string, base_url: string) => void
onCancel: () => void
}) {
const [name, setName] = useState("")
const [url, setUrl] = useState("")
const ok = /^[a-z0-9][a-z0-9-]*$/.test(name) && /^\w+:\/\//.test(url)
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={onCancel}>
<div
className="w-full max-w-sm rounded-lg border border-[var(--border)] bg-[var(--card)] p-5 text-sm"
onClick={(e) => e.stopPropagation()}
>
<h3 className="mb-1 font-semibold">Add external resource</h3>
<p className="mb-3 text-xs text-[var(--muted)]">
An endpoint castle doesn&apos;t run (a SaaS API, a remote service). Draw a consumes edge to
it from any deployment.
</p>
<label className="mb-1 block text-xs text-[var(--muted)]">Name</label>
<input
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="claude-api"
className="mb-3 w-full rounded border border-[var(--border)] bg-black/30 px-2 py-1 font-mono text-xs focus:border-[var(--primary)] focus:outline-none"
/>
<label className="mb-1 block text-xs text-[var(--muted)]">Base URL</label>
<input
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://api.anthropic.com"
className="mb-4 w-full rounded border border-[var(--border)] bg-black/30 px-2 py-1 font-mono text-xs focus:border-[var(--primary)] focus:outline-none"
/>
<div className="flex justify-end gap-2">
<button
onClick={onCancel}
className="rounded border border-[var(--border)] px-3 py-1.5 text-xs text-[var(--muted)] hover:border-[var(--primary)]"
>
Cancel
</button>
<button
onClick={() => ok && onSave(name, url)}
disabled={!ok}
className="rounded bg-blue-700 px-3 py-1.5 text-xs text-white hover:bg-blue-600 disabled:opacity-40"
>
Add
</button>
</div>
</div>
</div>
)

View File

@@ -11,6 +11,8 @@ import type {
ProgramDetail,
GitStatus,
GraphModel,
GraphSuggestion,
MeshDeployment,
RepoSummary,
ProgramSyncResponse,
StatusResponse,
@@ -224,6 +226,31 @@ const REACH_SECTION: Record<string, string> = {
job: "jobs",
tool: "tools",
static: "static",
reference: "references",
}
// Create/update an external resource — a `reference` deployment (manager: none)
// that points at an endpoint castle doesn't run (a SaaS API, a remote service).
// Behind the System Map's "add external resource" authoring. No apply needed —
// a reference has no runtime unit; it just declares the endpoint.
export function useSaveReference() {
const qc = useQueryClient()
return useMutation({
mutationFn: async ({
name,
base_url,
description,
}: {
name: string
base_url: string
description?: string
}) => {
await apiClient.put(`/config/references/${name}`, {
config: { manager: "none", base_url, description: description || null },
})
},
onSuccess: () => qc.invalidateQueries(),
})
}
// Set a deployment's exposure (off | internal | public), then converge it. This
@@ -270,6 +297,54 @@ export function useDeleteDeployment() {
})
}
interface Requirement {
kind: string // system | deployment
ref: string
bind?: string | null
}
// Add or remove a `requires` edge on a deployment, then converge. Reads the
// deployment's authoritative current requires (so system deps and binds are
// preserved), applies the single add/remove, writes it back, and applies. Behind
// the System Map's draw-a-line / delete-a-line dependency editing.
export function useMutateRequires() {
const qc = useQueryClient()
return useMutation({
mutationFn: async ({
name,
kind,
add,
remove,
}: {
name: string
kind: string
add?: string
remove?: string
}) => {
const section = REACH_SECTION[kind] ?? "services"
const detail = await apiClient.get<DeploymentDetail>(`/deployments/${name}`)
const cur = (detail.manifest?.requires as Requirement[] | undefined) ?? []
let next = cur
if (remove) next = next.filter((r) => !(r.kind === "deployment" && r.ref === remove))
if (add && !next.some((r) => r.kind === "deployment" && r.ref === add))
next = [...next, { kind: "deployment", ref: add }]
await apiClient.put(`/config/${section}/${name}`, {
config: { requires: next.length ? next : null },
})
try {
return await apiClient.post<ApplyResult>("/apply", { name })
} catch (err) {
if (err instanceof TypeError) {
await waitForApi()
return null
}
throw err
}
},
onSuccess: () => qc.invalidateQueries(),
})
}
export function useProgramAction() {
const qc = useQueryClient()
return useMutation({
@@ -344,6 +419,24 @@ export function useGraph() {
})
}
// Undeclared-consumption suggestions (env → provider socket matches). Advisory only.
export function useSuggestions() {
return useQuery({
queryKey: ["graph", "suggestions"],
queryFn: () => apiClient.get<{ suggestions: GraphSuggestion[] }>("/graph/suggestions"),
staleTime: 30_000,
})
}
// Deployments on other (mesh-discovered) castle nodes, for the multi-node map.
export function useMeshDeployments() {
return useQuery({
queryKey: ["mesh", "deployments"],
queryFn: () => apiClient.get<{ deployments: MeshDeployment[] }>("/mesh/deployments"),
refetchInterval: 30_000,
})
}
export function useSaveGatewayConfig() {
const qc = useQueryClient()
return useMutation({

View File

@@ -121,6 +121,10 @@ export interface GraphRepo {
dirty: boolean
fresh: boolean | null
}
export interface GraphEndpoint {
protocol: string // http | tcp | pg | bolt | mqtt | redis (display heuristic)
port: number
}
export interface GraphNode {
name: string
program: string | null
@@ -131,6 +135,11 @@ export interface GraphNode {
functional: boolean
fresh: boolean | null
deployed: boolean | null
reach: "off" | "internal" | "public" | null
endpoints: GraphEndpoint[]
base_url: string | null // set for kind === "reference" (external resource)
provides: string[] // capability types this offers (from its program)
consumes: string[] // capability types it needs (from its program)
}
export interface GraphEdge {
src: string
@@ -144,6 +153,27 @@ export interface GraphModel {
edges: GraphEdge[]
}
// GET /graph/suggestions — undeclared consumption inferred from env endpoint values
// (an advisory; accepting one declares a real `requires`).
export interface GraphSuggestion {
consumer: string
provider: string
env_var: string
endpoint: string
protocol: string
}
// GET /mesh/deployments — deployments on other (mesh-discovered) castle nodes.
export interface MeshDeployment {
name: string
kind: string
node: string // the remote hostname
port: number | null
base_url: string | null
subdomain: string | null
endpoints: GraphEndpoint[]
}
// POST /programs/{name}/sync — a fast-forward pull (no build/apply/restart).
export interface ProgramSyncResponse {
program: string