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,
|
Minus,
|
||||||
MoreVertical,
|
MoreVertical,
|
||||||
Package,
|
Package,
|
||||||
|
Plus,
|
||||||
RotateCw,
|
RotateCw,
|
||||||
Router,
|
Router,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
X,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import {
|
import {
|
||||||
useDeleteDeployment,
|
useDeleteDeployment,
|
||||||
useGateway,
|
useGateway,
|
||||||
useGraph,
|
useGraph,
|
||||||
useJobs,
|
useJobs,
|
||||||
|
useMeshDeployments,
|
||||||
|
useMutateRequires,
|
||||||
usePrograms,
|
usePrograms,
|
||||||
|
useSaveReference,
|
||||||
useServiceAction,
|
useServiceAction,
|
||||||
useServices,
|
|
||||||
useSetReach,
|
useSetReach,
|
||||||
|
useSuggestions,
|
||||||
} from "@/services/api/hooks"
|
} from "@/services/api/hooks"
|
||||||
import { ConfirmModal } from "@/components/ConfirmModal"
|
import { ConfirmModal } from "@/components/ConfirmModal"
|
||||||
|
import type { MeshDeployment } from "@/types"
|
||||||
|
|
||||||
// A spatial, interactive control surface for the whole box. Lanes read left→right:
|
// 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,
|
// 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",
|
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
|
const HANDLE = { opacity: 0, width: 8, height: 8 } as const
|
||||||
|
|
||||||
// User's manual node arrangement. Positions default to the computed lane layout;
|
// User's manual node arrangement. Positions default to the computed lane layout;
|
||||||
@@ -108,14 +129,24 @@ function MapNode({ id, data }: NodeProps) {
|
|||||||
hub?: number
|
hub?: number
|
||||||
exposable?: boolean
|
exposable?: boolean
|
||||||
program?: string | null
|
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
|
onMenu?: (x: number, y: number, name: string, kind: string) => void
|
||||||
onProgram?: (program: string) => void
|
onProgram?: (program: string) => void
|
||||||
}
|
}
|
||||||
const color = KIND_COLOR[d.kind] ?? "#8b949e"
|
const color = KIND_COLOR[d.kind] ?? "#8b949e"
|
||||||
|
const ReachIcon = d.reach === "public" ? Globe : d.reach === "internal" ? Router : null
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="group relative flex items-stretch overflow-hidden rounded-md border bg-[var(--card)] text-xs shadow-sm"
|
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
|
<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"
|
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>
|
||||||
)}
|
)}
|
||||||
<div className="min-w-0 flex-1 px-2 py-1.5">
|
<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}
|
{d.label}
|
||||||
|
</span>
|
||||||
|
{ReachIcon && (
|
||||||
|
<ReachIcon
|
||||||
|
size={10}
|
||||||
|
className="shrink-0"
|
||||||
|
style={{ color: d.reach === "public" ? "#2ea043" : "#58a6ff" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{d.sub && <div className="truncate text-[10px] text-[var(--muted)]">{d.sub}</div>}
|
{d.sub && <div className="truncate text-[10px] text-[var(--muted)]">{d.sub}</div>}
|
||||||
{typeof d.hub === "number" && d.hub > 0 && (
|
{typeof d.hub === "number" && d.hub > 0 && (
|
||||||
<div className="text-[10px]" style={{ color }}>
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Handle
|
{/* Right handle: drag OUT to connect — to a hub (expose) or another node
|
||||||
id="rs"
|
(declare "requires"). Right target lands incoming dependency lines. */}
|
||||||
type="source"
|
<Handle id="rs" type="source" position={Position.Right} style={{ ...HANDLE, opacity: 0.9, background: color }} />
|
||||||
position={Position.Right}
|
<Handle id="rt" type="target" position={Position.Right} style={{ ...HANDLE, opacity: 0 }} />
|
||||||
isConnectable={!!d.exposable}
|
|
||||||
style={{ ...HANDLE, opacity: d.exposable ? 0.9 : 0, background: color }}
|
|
||||||
/>
|
|
||||||
<Handle id="lt" type="target" position={Position.Left} isConnectable={false} 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="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>
|
</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 {
|
interface Built {
|
||||||
nodes: Node[]
|
nodes: Node[]
|
||||||
@@ -246,14 +374,17 @@ interface MenuItem {
|
|||||||
|
|
||||||
export function SystemMapPage() {
|
export function SystemMapPage() {
|
||||||
const { data: graph } = useGraph()
|
const { data: graph } = useGraph()
|
||||||
const { data: services } = useServices()
|
|
||||||
const { data: jobs } = useJobs()
|
const { data: jobs } = useJobs()
|
||||||
const { data: programs } = usePrograms()
|
const { data: programs } = usePrograms()
|
||||||
const { data: gateway } = useGateway()
|
const { data: gateway } = useGateway()
|
||||||
|
const { data: suggestionsResp } = useSuggestions()
|
||||||
|
const { data: meshResp } = useMeshDeployments()
|
||||||
|
|
||||||
const setReach = useSetReach()
|
const setReach = useSetReach()
|
||||||
const deleteDeployment = useDeleteDeployment()
|
const deleteDeployment = useDeleteDeployment()
|
||||||
const serviceAction = useServiceAction()
|
const serviceAction = useServiceAction()
|
||||||
|
const mutateRequires = useMutateRequires()
|
||||||
|
const saveReference = useSaveReference()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([])
|
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
|
// 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.
|
// middle/right mouse). When off, left-drag pans and Shift-drag still lassos.
|
||||||
const [lasso, setLasso] = useState(false)
|
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(
|
const openMenu = useCallback(
|
||||||
(x: number, y: number, name: string, kind: string) => setMenu({ x, y, name, kind }),
|
(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.
|
// stable callbacks below don't need it in their dep arrays.
|
||||||
const kindRef = useRef<Record<string, string>>({})
|
const kindRef = useRef<Record<string, string>>({})
|
||||||
const reachRef = useRef<Record<string, "internal" | "public">>({})
|
const reachRef = useRef<Record<string, "internal" | "public">>({})
|
||||||
|
const focusRef = useRef<string | null>(null)
|
||||||
// Manual node positions (persisted), and the react-flow instance for fitView.
|
// Manual node positions (persisted), and the react-flow instance for fitView.
|
||||||
const posRef = useRef<PosMap | null>(null)
|
const posRef = useRef<PosMap | null>(null)
|
||||||
if (posRef.current == null) posRef.current = loadPositions()
|
if (posRef.current == null) posRef.current = loadPositions()
|
||||||
@@ -283,7 +418,6 @@ export function SystemMapPage() {
|
|||||||
const built = useMemo<Built>(() => {
|
const built = useMemo<Built>(() => {
|
||||||
if (!graph) return { nodes: [], edges: [], kindOf: {}, reachOf: {} }
|
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]))
|
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
|
// 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
|
// 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[],
|
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 => {
|
const subFor = (n: (typeof graph.nodes)[number]): string | undefined => {
|
||||||
if (n.kind === "job") return scheduleOf.get(n.name) ?? undefined
|
if (n.kind === "job") return scheduleOf.get(n.name) ?? undefined
|
||||||
const port = portOf.get(n.name)
|
const ep = n.endpoints?.[0]
|
||||||
return port ? `:${port}` : undefined
|
return ep ? `:${ep.port}` : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
const kindOf: Record<string, string> = {}
|
const kindOf: Record<string, string> = {}
|
||||||
@@ -325,6 +476,8 @@ export function SystemMapPage() {
|
|||||||
hub: n.depended_on_by,
|
hub: n.depended_on_by,
|
||||||
exposable: !!lane.exposable,
|
exposable: !!lane.exposable,
|
||||||
program: n.program && catalog.has(n.program) ? n.program : null,
|
program: n.program && catalog.has(n.program) ? n.program : null,
|
||||||
|
reach: n.reach,
|
||||||
|
externals: externalsOf.get(n.name),
|
||||||
},
|
},
|
||||||
} satisfies Node
|
} 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 maxRows = Math.max(1, ...Object.values(perLane))
|
||||||
const midY = TOP + ((maxRows - 1) * ROW_H) / 2
|
const midY = TOP + ((maxRows - 1) * ROW_H) / 2
|
||||||
// Gateway shows its host origin; Internet shows the public (tunnel) zone.
|
// Gateway shows its host origin; Internet shows the public (tunnel) zone.
|
||||||
@@ -377,6 +582,7 @@ export function SystemMapPage() {
|
|||||||
[PROG_X, "Programs"],
|
[PROG_X, "Programs"],
|
||||||
...Object.values(LANES).map((l) => [l.x, l.title] as [number, string]),
|
...Object.values(LANES).map((l) => [l.x, l.title] as [number, string]),
|
||||||
[GATEWAY_X, "Exposure"],
|
[GATEWAY_X, "Exposure"],
|
||||||
|
...(refs.length ? ([[EXTERNAL_X, "External"]] as [number, string][]) : []),
|
||||||
]
|
]
|
||||||
for (const [x, title] of headers) {
|
for (const [x, title] of headers) {
|
||||||
nodes.push({
|
nodes.push({
|
||||||
@@ -395,17 +601,23 @@ export function SystemMapPage() {
|
|||||||
|
|
||||||
for (const e of graph.edges) {
|
for (const e of graph.edges) {
|
||||||
if (e.kind !== "deployment") continue
|
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({
|
edges.push({
|
||||||
id: `dep:${e.src}->${e.dst}`,
|
id: `dep:${e.src}->${e.dst}`,
|
||||||
source: e.src,
|
source: e.src,
|
||||||
target: e.dst,
|
target: e.dst,
|
||||||
sourceHandle: "ls",
|
sourceHandle: "ls",
|
||||||
targetHandle: "rt",
|
targetHandle: "rt",
|
||||||
label: e.bind ?? undefined,
|
label: e.bind ? `${proto} · ${e.bind}` : proto,
|
||||||
deletable: false,
|
deletable: true,
|
||||||
style: { stroke: "#6e7681", strokeWidth: 1.5 },
|
data: { protocol: proto },
|
||||||
labelStyle: { fill: "#8b949e", fontSize: 10 },
|
style: { stroke: color, strokeWidth: 1.5 },
|
||||||
|
labelStyle: { fill: color, fontSize: 10 },
|
||||||
labelBgStyle: { fill: "#0d1117" },
|
labelBgStyle: { fill: "#0d1117" },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -458,31 +670,89 @@ export function SystemMapPage() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return { nodes, edges, kindOf, reachOf }
|
// Suggested (undeclared) consumption — dashed amber, click to accept. Advisory:
|
||||||
}, [graph, services, jobs, programs, gateway])
|
// 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
|
return { nodes, edges, kindOf, reachOf }
|
||||||
// deployment nodes, and override each position with the user's saved layout
|
}, [graph, jobs, programs, gateway, suggestionsResp, meshResp])
|
||||||
// (falling back to the computed lane position).
|
|
||||||
const materialize = useCallback(
|
// The lit neighborhood for the focused node: itself + everything it consumes +
|
||||||
(b: Built): Node[] =>
|
// everything that consumes it (from the authoritative graph edges). null = no focus.
|
||||||
b.nodes.map((n) => ({
|
const litOf = useCallback(
|
||||||
...n,
|
(f: string | null): Set<string> | null => {
|
||||||
position: posRef.current![n.id] ?? n.position,
|
if (!f || !graph) return null
|
||||||
data: n.type === "map" ? { ...n.data, onMenu: openMenu, onProgram: openProgram } : n.data,
|
const lit = new Set<string>([f])
|
||||||
})),
|
for (const e of graph.edges) {
|
||||||
[openMenu, openProgram],
|
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
|
// canvas (preserving the user's dragged positions via posRef). Interactions fire
|
||||||
// mutations that invalidate the queries, which flow back through here.
|
// mutations that invalidate the queries, which flow back through here.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
kindRef.current = built.kindOf
|
kindRef.current = built.kindOf
|
||||||
reachRef.current = built.reachOf
|
reachRef.current = built.reachOf
|
||||||
|
focusRef.current = focus
|
||||||
setNodes(materialize(built))
|
setNodes(materialize(built))
|
||||||
setEdges(built.edges)
|
setEdges(dimEdges(built.edges))
|
||||||
}, [built, setNodes, setEdges, materialize])
|
}, [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).
|
// Remember where the user dropped a node (persist across refetches + reloads).
|
||||||
const onNodeDragStop = useCallback((_e: MouseEvent | TouchEvent, _n: Node, dragged: Node[]) => {
|
const onNodeDragStop = useCallback((_e: MouseEvent | TouchEvent, _n: Node, dragged: Node[]) => {
|
||||||
@@ -513,7 +783,38 @@ export function SystemMapPage() {
|
|||||||
[setReach],
|
[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(
|
const onConnect = useCallback(
|
||||||
(c: Connection) => {
|
(c: Connection) => {
|
||||||
if (!c.source) return
|
if (!c.source) return
|
||||||
@@ -521,14 +822,30 @@ export function SystemMapPage() {
|
|||||||
if (!kind) return
|
if (!kind) return
|
||||||
if (c.target === "__internet__") applyReach(c.source, kind, "public")
|
if (c.target === "__internet__") applyReach(c.source, kind, "public")
|
||||||
else if (c.target === "__gateway__") applyReach(c.source, kind, "internal")
|
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 isValidConnection = useCallback((c: Connection | Edge) => {
|
||||||
const kind = c.source ? kindRef.current[c.source] : undefined
|
const kind = c.source ? kindRef.current[c.source] : undefined
|
||||||
const exposable = kind === "service" || kind === "static"
|
if (!kind) return false
|
||||||
return exposable && (c.target === "__internet__" || c.target === "__gateway__")
|
// 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
|
// Deleting a node's exposure line removes exposure: a service drops to off; a
|
||||||
@@ -537,32 +854,39 @@ export function SystemMapPage() {
|
|||||||
const onEdgesDelete = useCallback(
|
const onEdgesDelete = useCallback(
|
||||||
(deleted: Edge[]) => {
|
(deleted: Edge[]) => {
|
||||||
for (const e of deleted) {
|
for (const e of deleted) {
|
||||||
if (!e.id.startsWith("exp:")) continue
|
|
||||||
const kind = kindRef.current[e.source]
|
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 (kind === "static") {
|
||||||
if (reachRef.current[e.source] === "public") applyReach(e.source, kind, "internal")
|
if (reachRef.current[e.source] === "public") applyReach(e.source, kind, "internal")
|
||||||
} else {
|
} else {
|
||||||
applyReach(e.source, kind, "off")
|
applyReach(e.source, kind, "off")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[applyReach],
|
[applyReach, removeDep],
|
||||||
)
|
)
|
||||||
|
|
||||||
// Intercept deletion: allow exposure edges through (onEdgesDelete handles them),
|
// Intercept deletion: allow exposure edges through (onEdgesDelete handles them),
|
||||||
// route node deletion to a confirm modal and block the immediate removal.
|
// route node deletion to a confirm modal and block the immediate removal.
|
||||||
const onBeforeDelete = useCallback(
|
const onBeforeDelete = useCallback(
|
||||||
async ({ nodes: delNodes, edges: delEdges }: { nodes: Node[]; edges: Edge[] }) => {
|
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) {
|
if (target) {
|
||||||
const kind = kindRef.current[target.id]
|
const kind = kindRef.current[target.id]
|
||||||
if (kind) setConfirmDel({ name: target.id, kind })
|
if (kind) setConfirmDel({ name: target.id, kind })
|
||||||
}
|
}
|
||||||
const allowedEdges = delEdges.filter((e) => {
|
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.
|
// 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"
|
if (kindRef.current[e.source] === "static") return reachRef.current[e.source] === "public"
|
||||||
return true
|
return true
|
||||||
|
}
|
||||||
|
return false // "same program" links aren't editable here
|
||||||
})
|
})
|
||||||
return { nodes: [], edges: allowedEdges }
|
return { nodes: [], edges: allowedEdges }
|
||||||
},
|
},
|
||||||
@@ -638,7 +962,74 @@ export function SystemMapPage() {
|
|||||||
return items
|
return items
|
||||||
}, [menu, built, navigate, restart, applyReach])
|
}, [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 (
|
return (
|
||||||
<div className="h-[calc(100vh-3.5rem)] w-full">
|
<div className="h-[calc(100vh-3.5rem)] w-full">
|
||||||
@@ -648,12 +1039,14 @@ export function SystemMapPage() {
|
|||||||
onNodesChange={onNodesChange}
|
onNodesChange={onNodesChange}
|
||||||
onEdgesChange={onEdgesChange}
|
onEdgesChange={onEdgesChange}
|
||||||
onConnect={onConnect}
|
onConnect={onConnect}
|
||||||
|
onEdgeClick={onEdgeClick}
|
||||||
onNodeContextMenu={onNodeContextMenu}
|
onNodeContextMenu={onNodeContextMenu}
|
||||||
onNodeDragStop={onNodeDragStop}
|
onNodeDragStop={onNodeDragStop}
|
||||||
onEdgesDelete={onEdgesDelete}
|
onEdgesDelete={onEdgesDelete}
|
||||||
onBeforeDelete={onBeforeDelete}
|
onBeforeDelete={onBeforeDelete}
|
||||||
isValidConnection={isValidConnection}
|
isValidConnection={isValidConnection}
|
||||||
onInit={(inst) => (rfRef.current = inst)}
|
onInit={(inst) => (rfRef.current = inst)}
|
||||||
|
onSelectionChange={onSelectionChange}
|
||||||
nodeTypes={nodeTypes}
|
nodeTypes={nodeTypes}
|
||||||
fitView
|
fitView
|
||||||
fitViewOptions={{ padding: 0.1 }}
|
fitViewOptions={{ padding: 0.1 }}
|
||||||
@@ -678,6 +1071,9 @@ export function SystemMapPage() {
|
|||||||
<ControlButton onClick={resetLayout} title="Reset layout">
|
<ControlButton onClick={resetLayout} title="Reset layout">
|
||||||
<LayoutGrid size={12} />
|
<LayoutGrid size={12} />
|
||||||
</ControlButton>
|
</ControlButton>
|
||||||
|
<ControlButton onClick={() => setAddExt(true)} title="Add external resource">
|
||||||
|
<Plus size={12} />
|
||||||
|
</ControlButton>
|
||||||
</Controls>
|
</Controls>
|
||||||
<Legend />
|
<Legend />
|
||||||
{(banner || busy) && (
|
{(banner || busy) && (
|
||||||
@@ -698,6 +1094,22 @@ export function SystemMapPage() {
|
|||||||
<NodeMenu menu={menu} items={menuItems} onClose={() => setMenu(null)} />
|
<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
|
<ConfirmModal
|
||||||
open={!!confirmDel}
|
open={!!confirmDel}
|
||||||
danger
|
danger
|
||||||
@@ -763,10 +1175,13 @@ function NodeMenu({
|
|||||||
|
|
||||||
function Legend() {
|
function Legend() {
|
||||||
const items = [
|
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: "#bc8cff", label: "same program", dashed: true },
|
||||||
{ c: "#58a6ff", label: "internal — on your LAN" },
|
{ c: "#f59e0b", label: "suggested — click to accept", dashed: true },
|
||||||
{ c: "#2ea043", label: "public — on the internet" },
|
{ c: "#58a6ff", label: "internal — LAN" },
|
||||||
|
{ c: "#2ea043", label: "public — internet" },
|
||||||
]
|
]
|
||||||
return (
|
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)]">
|
<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>
|
||||||
))}
|
))}
|
||||||
<div className="mt-1 border-t border-[var(--border)] pt-1.5 leading-relaxed">
|
<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, →{" "}
|
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 />
|
<br />
|
||||||
Select a line or node + <kbd className="rounded bg-black/40 px-1">Del</kbd> to remove.
|
Select a line or node + <kbd className="rounded bg-black/40 px-1">Del</kbd> to remove.
|
||||||
<br />
|
</div>
|
||||||
<kbd className="rounded bg-black/40 px-1">Shift</kbd>-drag to box-select; drag any selected node to move them together.
|
</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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import type {
|
|||||||
ProgramDetail,
|
ProgramDetail,
|
||||||
GitStatus,
|
GitStatus,
|
||||||
GraphModel,
|
GraphModel,
|
||||||
|
GraphSuggestion,
|
||||||
|
MeshDeployment,
|
||||||
RepoSummary,
|
RepoSummary,
|
||||||
ProgramSyncResponse,
|
ProgramSyncResponse,
|
||||||
StatusResponse,
|
StatusResponse,
|
||||||
@@ -224,6 +226,31 @@ const REACH_SECTION: Record<string, string> = {
|
|||||||
job: "jobs",
|
job: "jobs",
|
||||||
tool: "tools",
|
tool: "tools",
|
||||||
static: "static",
|
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
|
// 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() {
|
export function useProgramAction() {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
return useMutation({
|
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() {
|
export function useSaveGatewayConfig() {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
|
|||||||
@@ -121,6 +121,10 @@ export interface GraphRepo {
|
|||||||
dirty: boolean
|
dirty: boolean
|
||||||
fresh: boolean | null
|
fresh: boolean | null
|
||||||
}
|
}
|
||||||
|
export interface GraphEndpoint {
|
||||||
|
protocol: string // http | tcp | pg | bolt | mqtt | redis (display heuristic)
|
||||||
|
port: number
|
||||||
|
}
|
||||||
export interface GraphNode {
|
export interface GraphNode {
|
||||||
name: string
|
name: string
|
||||||
program: string | null
|
program: string | null
|
||||||
@@ -131,6 +135,11 @@ export interface GraphNode {
|
|||||||
functional: boolean
|
functional: boolean
|
||||||
fresh: boolean | null
|
fresh: boolean | null
|
||||||
deployed: 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 {
|
export interface GraphEdge {
|
||||||
src: string
|
src: string
|
||||||
@@ -144,6 +153,27 @@ export interface GraphModel {
|
|||||||
edges: GraphEdge[]
|
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).
|
// POST /programs/{name}/sync — a fast-forward pull (no build/apply/restart).
|
||||||
export interface ProgramSyncResponse {
|
export interface ProgramSyncResponse {
|
||||||
program: string
|
program: string
|
||||||
|
|||||||
Reference in New Issue
Block a user