A service dependency map with health-colored nodes and SVG edges that highlight the connected path on hover or focus.
npx shadcn@latest add @paragon/resource-topology"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
export type TopologyHealth = "healthy" | "degraded" | "down";
export interface TopologyNode {
id: string;
label: string;
/** Sublabel, e.g. the service kind or region. */
kind?: string;
health?: TopologyHealth;
/** Grid column, 0-based. */
col: number;
/** Grid row, 0-based. */
row: number;
}
export interface TopologyEdge {
from: string;
to: string;
}
export interface ResourceTopologyProps
extends Omit<React.ComponentProps<"div">, "onSelect"> {
nodes?: TopologyNode[];
edges?: TopologyEdge[];
/** px between grid columns / rows, node box is sized off this. */
colGap?: number;
rowGap?: number;
}
const HEALTH_VAR: Record<TopologyHealth, string> = {
healthy: "var(--color-success)",
degraded: "var(--color-warning)",
down: "var(--color-destructive)",
};
const DEFAULT_NODES: TopologyNode[] = [
{ id: "lb", label: "edge-lb", kind: "Load balancer", health: "healthy", col: 0, row: 1 },
{ id: "api", label: "api-gateway", kind: "Gateway", health: "healthy", col: 1, row: 1 },
{ id: "auth", label: "auth-svc", kind: "Service", health: "healthy", col: 2, row: 0 },
{ id: "orders", label: "orders-svc", kind: "Service", health: "degraded", col: 2, row: 1 },
{ id: "billing", label: "billing-svc", kind: "Service", health: "healthy", col: 2, row: 2 },
{ id: "pg", label: "postgres", kind: "Primary DB", health: "healthy", col: 3, row: 1 },
{ id: "redis", label: "redis", kind: "Cache", health: "down", col: 3, row: 2 },
];
const DEFAULT_EDGES: TopologyEdge[] = [
{ from: "lb", to: "api" },
{ from: "api", to: "auth" },
{ from: "api", to: "orders" },
{ from: "api", to: "billing" },
{ from: "orders", to: "pg" },
{ from: "billing", to: "pg" },
{ from: "orders", to: "redis" },
];
const BOX_W = 132;
const BOX_H = 52;
export function ResourceTopology({
nodes = DEFAULT_NODES,
edges = DEFAULT_EDGES,
colGap = 176,
rowGap = 84,
className,
...props
}: ResourceTopologyProps) {
const [active, setActive] = React.useState<string | null>(null);
const cols = Math.max(...nodes.map((n) => n.col)) + 1;
const rows = Math.max(...nodes.map((n) => n.row)) + 1;
const width = (cols - 1) * colGap + BOX_W;
const height = (rows - 1) * rowGap + BOX_H;
const pos = React.useMemo(() => {
const m = new Map<string, { x: number; y: number }>();
for (const n of nodes) {
m.set(n.id, { x: n.col * colGap + BOX_W / 2, y: n.row * rowGap + BOX_H / 2 });
}
return m;
}, [nodes, colGap, rowGap]);
// Which nodes/edges are on the active node's connected path (1 hop each way).
const { litNodes, litEdges } = React.useMemo(() => {
if (!active) return { litNodes: new Set<string>(), litEdges: new Set<number>() };
const ln = new Set<string>([active]);
const le = new Set<number>();
edges.forEach((e, i) => {
if (e.from === active || e.to === active) {
le.add(i);
ln.add(e.from);
ln.add(e.to);
}
});
return { litNodes: ln, litEdges: le };
}, [active, edges]);
return (
<div
className={cn(
"w-full overflow-x-auto rounded-xl bg-card p-4 text-card-foreground shadow-border",
className,
)}
{...props}
>
<div className="relative" style={{ width, height, minWidth: width }}>
<svg
width={width}
height={height}
className="absolute inset-0"
aria-hidden
>
{edges.map((e, i) => {
const a = pos.get(e.from);
const b = pos.get(e.to);
if (!a || !b) return null;
const mx = (a.x + b.x) / 2;
const lit = litEdges.has(i);
const dim = active && !lit;
return (
<path
key={i}
d={`M ${a.x} ${a.y} C ${mx} ${a.y}, ${mx} ${b.y}, ${b.x} ${b.y}`}
fill="none"
stroke="var(--color-border)"
strokeWidth={lit ? 2 : 1.5}
className="transition-[stroke,stroke-width,opacity] duration-200 ease-out"
style={{
stroke: lit ? "var(--color-foreground)" : undefined,
opacity: dim ? 0.3 : 1,
}}
/>
);
})}
</svg>
{nodes.map((n) => {
const p = pos.get(n.id)!;
const health = n.health ?? "healthy";
const lit = litNodes.has(n.id);
const dim = active && !lit;
return (
<button
type="button"
key={n.id}
onMouseEnter={() => setActive(n.id)}
onMouseLeave={() => setActive(null)}
onFocus={() => setActive(n.id)}
onBlur={() => setActive(null)}
aria-label={`${n.label} — ${health}`}
className="absolute flex flex-col justify-center rounded-lg bg-background px-3 text-left shadow-border outline-none transition-[opacity,box-shadow,scale] duration-200 ease-out focus-visible:ring-2 focus-visible:ring-ring active:not-disabled:scale-[0.98]"
style={{
left: p.x - BOX_W / 2,
top: p.y - BOX_H / 2,
width: BOX_W,
height: BOX_H,
opacity: dim ? 0.4 : 1,
boxShadow: lit ? "var(--shadow-border-hover)" : undefined,
}}
>
<span className="flex items-center gap-1.5">
<span
aria-hidden
className="size-2 shrink-0 rounded-full"
style={{ background: HEALTH_VAR[health] }}
/>
<span className="truncate font-mono text-[13px] font-medium">
{n.label}
</span>
</span>
{n.kind && (
<span className="truncate pl-3.5 text-[11px] text-muted-foreground">
{n.kind}
</span>
)}
</button>
);
})}
</div>
</div>
);
}