Infrastructure & DevOps

Resource Topology

A service dependency map with health-colored nodes and SVG edges that highlight the connected path on hover or focus.

Install

npx shadcn@latest add @paragon/resource-topology

resource-topology.tsx

"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>
  );
}