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

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

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

View File

@@ -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({