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:
@@ -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 node→node 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'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>
|
||||
)
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user