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:
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
138
apps/web/src/modules/mesh/peer-graph-panel.tsx
Normal file
138
apps/web/src/modules/mesh/peer-graph-panel.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
462
apps/web/src/modules/mesh/peer-graph.tsx
Normal file
462
apps/web/src/modules/mesh/peer-graph.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user