feat(app): map polish — loading spinner, cleaner cards, reveal-on-select

- Loading spinner while the graph query is in flight.
- Deployment cards drop the "consumed by N" line and external-ref chips — both
  now live in the inspect panel, so cards are just name/port/reach.
- A node's external (reference) edges are drawn only while it's selected, then
  removed — no permanent web of long cross-map lines.
- LAN exposure edges animate like the Internet ones (animation = live traffic).
This commit is contained in:
2026-07-06 16:44:10 -07:00
parent 1dace6c6a0
commit 4f5f569da4

View File

@@ -126,11 +126,9 @@ function MapNode({ id, data }: NodeProps) {
kind: string
sub?: string
dim?: boolean
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
@@ -197,25 +195,6 @@ function MapNode({ id, data }: NodeProps) {
)}
</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 }}>
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>
{/* Right handle: drag OUT to connect — to a hub (expose) or another node
(declare "requires"). Right target lands incoming dependency lines. */}
@@ -373,7 +352,7 @@ interface MenuItem {
}
export function SystemMapPage() {
const { data: graph } = useGraph()
const { data: graph, isLoading: graphLoading } = useGraph()
const { data: jobs } = useJobs()
const { data: programs } = usePrograms()
const { data: gateway } = useGateway()
@@ -436,16 +415,6 @@ export function SystemMapPage() {
// 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 => {
@@ -473,11 +442,9 @@ export function SystemMapPage() {
kind: n.kind,
sub: subFor(n),
dim: lane.dim,
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
})
@@ -664,7 +631,7 @@ export function SystemMapPage() {
target: isPub ? "__internet__" : "__gateway__",
sourceHandle: "rs",
targetHandle: "lt",
animated: isPub,
animated: true,
deletable: true,
style: { stroke: isPub ? "#2ea043" : "#58a6ff", strokeWidth: 1.75 },
})
@@ -745,8 +712,31 @@ export function SystemMapPage() {
reachRef.current = built.reachOf
focusRef.current = focus
setNodes(materialize(built))
setEdges(dimEdges(built.edges))
}, [built, focus, setNodes, setEdges, materialize, dimEdges])
// External consumption is drawn only while the consumer is selected — a dashed
// line to each of its reference nodes (kept off-canvas otherwise to avoid a
// permanent web of long cross-map lines).
const extEdges: Edge[] = []
if (focus && graph) {
const refNames = new Set(
graph.nodes.filter((n) => n.kind === "reference").map((n) => n.name),
)
for (const e of graph.edges) {
if (e.kind === "deployment" && e.src === focus && refNames.has(e.dst)) {
extEdges.push({
id: `ext:${focus}->${e.dst}`,
source: focus,
target: e.dst,
sourceHandle: "rs",
targetHandle: "lt",
animated: true,
deletable: false,
style: { stroke: "#8b949e", strokeWidth: 1.5, strokeDasharray: "4 3" },
})
}
}
}
setEdges([...dimEdges(built.edges), ...extEdges])
}, [built, focus, graph, setNodes, setEdges, materialize, dimEdges])
// Single-node selection = inspect (dim + panel); 0 or many = no focus (drag mode).
const onSelectionChange = useCallback((p: { nodes: Node[] }) => {
@@ -1060,6 +1050,12 @@ export function SystemMapPage() {
colorMode="dark"
>
<Background color="#30363d" gap={20} />
{(graphLoading || !graph) && (
<div className="absolute inset-0 z-20 flex items-center justify-center gap-2 text-sm text-[var(--muted)]">
<Loader2 size={18} className="animate-spin" />
Loading map
</div>
)}
<Controls showInteractive={false}>
<ControlButton
onClick={() => setLasso((v) => !v)}