feat(web): add peer graph visualization to live mesh dashboard

Renders peers as SVG nodes in a radial layout with animated edges
showing real-time message traffic. Shares the same TanStack Query
cache as LiveStreamPanel (same queryKey). Side-by-side on desktop,
stacked on mobile.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-04-07 23:32:41 +01:00
parent f34b8fbc6b
commit 59332dc47d
3 changed files with 605 additions and 1 deletions

View File

@@ -15,6 +15,7 @@ import {
DashboardHeaderTitle,
} from "~/modules/common/layout/dashboard/header";
import { LiveStreamPanel } from "~/modules/mesh/live-stream-panel";
import { PeerGraphPanel } from "~/modules/mesh/peer-graph-panel";
export const generateMetadata = getMetadata({
title: "Live mesh",
@@ -63,7 +64,10 @@ export default async function LiveMeshPage({
</div>
</DashboardHeader>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<PeerGraphPanel meshId={id} />
<LiveStreamPanel meshId={id} />
</div>
</>
);
}

View File

@@ -0,0 +1,138 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useMemo } from "react";
import {
getMyMeshStreamResponseSchema,
type GetMyMeshStreamResponse,
} from "@turbostarter/api/schema";
import { handle } from "@turbostarter/api/utils";
import { api } from "~/lib/api/client";
import {
PeerGraph,
type GraphPeer,
type GraphEdge,
} from "~/modules/mesh/peer-graph";
const POLL_INTERVAL_MS = 4000;
/* ------------------------------------------------------------------ */
/* Transform broker response into graph-friendly structures */
/* ------------------------------------------------------------------ */
const buildGraphData = (data: GetMyMeshStreamResponse) => {
// Count messages per sender
const countMap = new Map<string, number>();
for (const e of data.envelopes) {
countMap.set(e.senderMemberId, (countMap.get(e.senderMemberId) ?? 0) + 1);
}
const peers: GraphPeer[] = data.presences.map((p) => ({
id: p.memberId,
name: p.displayName ?? p.memberId.slice(0, 8),
status: p.status === "dnd" ? "dnd" : p.status,
messageCount: countMap.get(p.memberId) ?? 0,
}));
const edges: GraphEdge[] = data.envelopes.map((e) => ({
key: e.id,
from: e.senderMemberId,
to: e.targetSpec === "*" ? null : e.targetSpec,
priority: e.priority,
createdAt: new Date(e.createdAt),
}));
return { peers, edges };
};
/* ------------------------------------------------------------------ */
/* Panel component */
/* ------------------------------------------------------------------ */
export const PeerGraphPanel = ({ meshId }: { meshId: string }) => {
const { data, isFetching, dataUpdatedAt } = useQuery({
queryKey: ["mesh", "stream", meshId],
queryFn: () =>
handle(api.my.meshes[":id"].stream.$get, {
schema: getMyMeshStreamResponseSchema,
})({ param: { id: meshId } }),
refetchInterval: POLL_INTERVAL_MS,
refetchIntervalInBackground: false,
});
const { peers, edges } = useMemo(
() => (data ? buildGraphData(data) : { peers: [], edges: [] }),
[data],
);
const secondsAgo = dataUpdatedAt
? Math.max(0, Math.floor((Date.now() - dataUpdatedAt) / 1000))
: null;
return (
<div className="flex flex-col overflow-hidden rounded-[var(--cm-radius-lg)] border border-[var(--cm-border)] bg-[var(--cm-bg)]">
{/* Header */}
<div
className="flex items-center justify-between border-b border-[var(--cm-border)] bg-[var(--cm-bg-elevated)]/60 px-4 py-3"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
<div className="flex items-center gap-3">
<span
className={
"inline-block h-2 w-2 rounded-full " +
(isFetching
? "bg-[var(--cm-clay)] animate-pulse"
: "bg-emerald-500")
}
/>
<span className="text-[11px] text-[var(--cm-fg-secondary)]">
peer graph
</span>
</div>
<span className="text-[10px] text-[var(--cm-fg-tertiary)]">
{peers.length} peers ·{" "}
{isFetching ? "polling\u2026" : `${secondsAgo ?? "\u2014"}s ago`}
</span>
</div>
{/* Graph area */}
<div className="relative aspect-square w-full min-h-[320px]">
<PeerGraph peers={peers} edges={edges} />
</div>
{/* Legend */}
<div
className="flex flex-wrap items-center gap-x-5 gap-y-1 border-t border-[var(--cm-border)] bg-[var(--cm-bg-elevated)]/30 px-4 py-2 text-[9px] text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
<span className="flex items-center gap-1.5">
<span className="inline-block h-1.5 w-1.5 rounded-full bg-emerald-500" />
idle
</span>
<span className="flex items-center gap-1.5">
<span className="inline-block h-1.5 w-1.5 rounded-full bg-[var(--cm-clay)]" />
working
</span>
<span className="flex items-center gap-1.5">
<span className="inline-block h-1.5 w-1.5 rounded-full bg-[#c46686]" />
dnd
</span>
<span className="mx-1 text-[var(--cm-border)]">|</span>
<span className="flex items-center gap-1.5">
<span className="inline-block h-px w-3 bg-emerald-500" />
low
</span>
<span className="flex items-center gap-1.5">
<span className="inline-block h-px w-3 bg-[var(--cm-fg-secondary)]" />
next
</span>
<span className="flex items-center gap-1.5">
<span className="inline-block h-px w-3 bg-red-500" />
now
</span>
</div>
</div>
);
};

View File

@@ -0,0 +1,462 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import type { PeerStatus } from "~/modules/marketing/home/mesh-stream";
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
export interface GraphPeer {
id: string;
name: string;
status: PeerStatus;
summary?: string;
/** Number of messages sent by this peer — drives node sizing */
messageCount: number;
/** Group names this peer belongs to */
groups?: string[];
}
export interface GraphEdge {
key: string;
from: string;
to: string | null; // null = broadcast (draw to all)
priority: "now" | "next" | "low";
createdAt: Date;
}
export interface PeerGraphProps {
peers: GraphPeer[];
edges: GraphEdge[];
meshName?: string;
}
/* ------------------------------------------------------------------ */
/* Constants */
/* ------------------------------------------------------------------ */
const STATUS_COLOR: Record<PeerStatus, string> = {
idle: "#22c55e", // emerald-500
working: "#d97757", // --cm-clay
dnd: "#c46686", // --cm-fig
offline: "#87867f", // --cm-fg-tertiary
};
const PRIORITY_COLOR: Record<string, string> = {
low: "#22c55e",
next: "#c2c0b6",
now: "#ef4444",
};
/** How long edges remain visible (ms) */
const EDGE_TTL_MS = 8_000;
/** Ring colors for groups — up to 8 distinct groups */
const GROUP_RING_COLORS = [
"#d97757", // clay
"#c46686", // fig
"#bcd1ca", // cactus
"#e3dacc", // oat
"#6ea8fe", // blue
"#fbbf24", // amber
"#a78bfa", // violet
"#f472b6", // pink
];
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
/** Radial layout: peers on a circle, center reserved for mesh label. */
const computeLayout = (
peerCount: number,
width: number,
height: number,
) => {
const cx = width / 2;
const cy = height / 2;
const radius = Math.min(cx, cy) * 0.68;
return { cx, cy, radius };
};
const peerPosition = (
index: number,
total: number,
cx: number,
cy: number,
radius: number,
) => {
const angle = (index / total) * 2 * Math.PI - Math.PI / 2; // start at top
return {
x: cx + radius * Math.cos(angle),
y: cy + radius * Math.sin(angle),
};
};
/** Scale node radius based on message volume relative to peers. */
const nodeRadius = (count: number, maxCount: number) => {
const base = 22;
const extra = 12;
if (maxCount === 0) return base;
return base + (count / maxCount) * extra;
};
/** Build a group-color map from all peers. */
const buildGroupColorMap = (peers: GraphPeer[]) => {
const seen = new Set<string>();
for (const p of peers) {
for (const g of p.groups ?? []) seen.add(g);
}
const map = new Map<string, string>();
let i = 0;
for (const g of seen) {
map.set(g, GROUP_RING_COLORS[i % GROUP_RING_COLORS.length]!);
i++;
}
return map;
};
/** Quadratic bezier control point offset for curved edges */
const curveOffset = (
x1: number,
y1: number,
x2: number,
y2: number,
cx: number,
cy: number,
) => {
// Push the control point toward center for a slight curve
const mx = (x1 + x2) / 2;
const my = (y1 + y2) / 2;
const factor = 0.15;
return {
qx: mx + (cx - mx) * factor,
qy: my + (cy - my) * factor,
};
};
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
export const PeerGraph = ({ peers, edges, meshName }: PeerGraphProps) => {
const svgRef = useRef<SVGSVGElement>(null);
const [dimensions, setDimensions] = useState({ width: 520, height: 520 });
const [now, setNow] = useState(Date.now());
// Tick every second to fade edges
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(id);
}, []);
// Responsive resize
useEffect(() => {
const svg = svgRef.current;
if (!svg) return;
const ro = new ResizeObserver((entries) => {
const entry = entries[0];
if (!entry) return;
const { width, height } = entry.contentRect;
if (width > 0 && height > 0) setDimensions({ width, height });
});
ro.observe(svg);
return () => ro.disconnect();
}, []);
const { width, height } = dimensions;
const { cx, cy, radius } = computeLayout(peers.length, width, height);
const maxCount = useMemo(
() => Math.max(1, ...peers.map((p) => p.messageCount)),
[peers],
);
const groupColorMap = useMemo(() => buildGroupColorMap(peers), [peers]);
// Map peer id -> position
const posMap = useMemo(() => {
const m = new Map<string, { x: number; y: number }>();
peers.forEach((p, i) => {
m.set(p.id, peerPosition(i, peers.length, cx, cy, radius));
});
return m;
}, [peers, cx, cy, radius]);
// Filter edges to those still visible
const visibleEdges = useMemo(
() => edges.filter((e) => now - e.createdAt.getTime() < EDGE_TTL_MS),
[edges, now],
);
// Build edge paths: direct -> single path, broadcast -> one path per peer
const edgePaths = useMemo(() => {
const paths: {
key: string;
d: string;
color: string;
opacity: number;
}[] = [];
for (const e of visibleEdges) {
const fromPos = posMap.get(e.from);
if (!fromPos) continue;
const age = now - e.createdAt.getTime();
const opacity = Math.max(0, 1 - age / EDGE_TTL_MS);
const color = PRIORITY_COLOR[e.priority] ?? PRIORITY_COLOR.next!;
if (e.to === null || e.to === "*") {
// Broadcast: lines to all other peers
for (const [pid, pos] of posMap) {
if (pid === e.from) continue;
const { qx, qy } = curveOffset(
fromPos.x,
fromPos.y,
pos.x,
pos.y,
cx,
cy,
);
paths.push({
key: `${e.key}-${pid}`,
d: `M${fromPos.x},${fromPos.y} Q${qx},${qy} ${pos.x},${pos.y}`,
color,
opacity: opacity * 0.6,
});
}
} else {
const toPos = posMap.get(e.to);
if (!toPos) continue;
const { qx, qy } = curveOffset(
fromPos.x,
fromPos.y,
toPos.x,
toPos.y,
cx,
cy,
);
paths.push({
key: e.key,
d: `M${fromPos.x},${fromPos.y} Q${qx},${qy} ${toPos.x},${toPos.y}`,
color,
opacity,
});
}
}
return paths;
}, [visibleEdges, posMap, cx, cy, now]);
return (
<svg
ref={svgRef}
className="h-full w-full"
viewBox={`0 0 ${width} ${height}`}
role="img"
aria-label={`Peer graph for mesh${meshName ? ` "${meshName}"` : ""} showing ${peers.length} peers and recent message traffic`}
style={{ fontFamily: "var(--cm-font-mono)" }}
>
{/* Subtle radial grid */}
<circle
cx={cx}
cy={cy}
r={radius}
fill="none"
stroke="var(--cm-border)"
strokeWidth="1"
strokeDasharray="4 6"
opacity="0.4"
/>
<circle
cx={cx}
cy={cy}
r={radius * 0.5}
fill="none"
stroke="var(--cm-border)"
strokeWidth="0.5"
strokeDasharray="2 4"
opacity="0.2"
/>
{/* Center mesh label */}
{meshName && (
<text
x={cx}
y={cy}
textAnchor="middle"
dominantBaseline="central"
fill="var(--cm-fg-tertiary)"
fontSize="11"
opacity="0.5"
>
{meshName}
</text>
)}
{/* Edges */}
<g>
{edgePaths.map((ep) => (
<path
key={ep.key}
d={ep.d}
fill="none"
stroke={ep.color}
strokeWidth="1.5"
opacity={ep.opacity}
style={{
transition: "opacity 1s ease-out",
}}
/>
))}
</g>
{/* Animated pulse dots traveling along edges */}
{edgePaths
.filter((ep) => ep.opacity > 0.3)
.map((ep) => (
<circle key={`dot-${ep.key}`} r="2.5" fill={ep.color} opacity={ep.opacity}>
<animateMotion
dur="1.2s"
repeatCount="1"
path={ep.d}
fill="freeze"
/>
</circle>
))}
{/* Peer nodes */}
{peers.map((peer, i) => {
const pos = posMap.get(peer.id);
if (!pos) return null;
const r = nodeRadius(peer.messageCount, maxCount);
const groups = peer.groups ?? [];
return (
<g key={peer.id}>
{/* Group rings (concentric, outermost first) */}
{groups.map((g, gi) => {
const ringR = r + 5 + gi * 4;
const ringColor = groupColorMap.get(g) ?? GROUP_RING_COLORS[0]!;
return (
<circle
key={g}
cx={pos.x}
cy={pos.y}
r={ringR}
fill="none"
stroke={ringColor}
strokeWidth="2"
strokeDasharray="6 3"
opacity="0.55"
/>
);
})}
{/* Outer glow for active status */}
{peer.status === "working" && (
<circle
cx={pos.x}
cy={pos.y}
r={r + 2}
fill="none"
stroke={STATUS_COLOR.working}
strokeWidth="1"
opacity="0.3"
>
<animate
attributeName="r"
values={`${r + 2};${r + 6};${r + 2}`}
dur="2s"
repeatCount="indefinite"
/>
<animate
attributeName="opacity"
values="0.3;0.08;0.3"
dur="2s"
repeatCount="indefinite"
/>
</circle>
)}
{/* Node circle */}
<circle
cx={pos.x}
cy={pos.y}
r={r}
fill="var(--cm-bg-elevated)"
stroke={STATUS_COLOR[peer.status]}
strokeWidth="2"
style={{ transition: "all 0.6s var(--cm-ease)" }}
/>
{/* Status indicator dot */}
<circle
cx={pos.x + r * 0.6}
cy={pos.y - r * 0.6}
r="4"
fill={STATUS_COLOR[peer.status]}
stroke="var(--cm-bg)"
strokeWidth="1.5"
/>
{/* Initials inside node */}
<text
x={pos.x}
y={pos.y + 1}
textAnchor="middle"
dominantBaseline="central"
fill="var(--cm-fg)"
fontSize="11"
fontWeight="600"
>
{peer.name.slice(0, 2).toUpperCase()}
</text>
{/* Name label below */}
<text
x={pos.x}
y={pos.y + r + 14}
textAnchor="middle"
dominantBaseline="central"
fill="var(--cm-fg-secondary)"
fontSize="10"
>
{peer.name.length > 12
? peer.name.slice(0, 11) + "\u2026"
: peer.name}
</text>
{/* Truncated summary below name */}
{peer.summary && (
<text
x={pos.x}
y={pos.y + r + 26}
textAnchor="middle"
dominantBaseline="central"
fill="var(--cm-fg-tertiary)"
fontSize="8"
>
{peer.summary.length > 24
? peer.summary.slice(0, 23) + "\u2026"
: peer.summary}
</text>
)}
</g>
);
})}
{/* Empty state */}
{peers.length === 0 && (
<text
x={cx}
y={cy}
textAnchor="middle"
dominantBaseline="central"
fill="var(--cm-fg-tertiary)"
fontSize="12"
>
No peers connected
</text>
)}
</svg>
);
};