diff --git a/app/src/pages/SystemMap.tsx b/app/src/pages/SystemMap.tsx index 09dd68a..da2d237 100644 --- a/app/src/pages/SystemMap.tsx +++ b/app/src/pages/SystemMap.tsx @@ -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 = { 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 = { + 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 (
+ +
+
+ {(info.provides.length > 0 || info.capsConsumes.length > 0) && ( + + {info.provides.map((t) => ( + + provides {t} + + ))} + {info.capsConsumes.map((t) => ( + + needs {t} + + ))} + + )} + + {info.consumes.length === 0 && } + {info.consumes.map((c) => ( +
+
+ + {c.protocol} + + {c.external ? ( + + + {c.name} + + ) : ( + + )} + +
+ {c.alternatives.length > 0 && ( +
+ alt: {c.alternatives.join(", ")} +
+ )} +
+ ))} +
+ + {info.consumedBy.length === 0 && } + {info.consumedBy.map((c) => ( + + ))} + +
+ + ) +} + +function PanelSection({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+
{title}
+ {children} +
+ ) +} + +function Empty() { + return
none
+} + +// 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 ( +
+
e.stopPropagation()} + > +

Add external resource

+

+ An endpoint castle doesn't run (a SaaS API, a remote service). Draw a consumes edge to + it from any deployment. +

+ + 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" + /> + + 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" + /> +
+ + +
) diff --git a/app/src/services/api/hooks.ts b/app/src/services/api/hooks.ts index e7bcf47..1575676 100644 --- a/app/src/services/api/hooks.ts +++ b/app/src/services/api/hooks.ts @@ -11,6 +11,8 @@ import type { ProgramDetail, GitStatus, GraphModel, + GraphSuggestion, + MeshDeployment, RepoSummary, ProgramSyncResponse, StatusResponse, @@ -224,6 +226,31 @@ const REACH_SECTION: Record = { 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(`/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("/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({ diff --git a/app/src/types/index.ts b/app/src/types/index.ts index 26e8e5d..e431bc5 100644 --- a/app/src/types/index.ts +++ b/app/src/types/index.ts @@ -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