diff --git a/apps/web/src/app/[locale]/dashboard/(user)/meshes/[id]/live/page.tsx b/apps/web/src/app/[locale]/dashboard/(user)/meshes/[id]/live/page.tsx index 0b1c889..5c84bda 100644 --- a/apps/web/src/app/[locale]/dashboard/(user)/meshes/[id]/live/page.tsx +++ b/apps/web/src/app/[locale]/dashboard/(user)/meshes/[id]/live/page.tsx @@ -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({ - +
+ + +
); } diff --git a/apps/web/src/modules/mesh/peer-graph-panel.tsx b/apps/web/src/modules/mesh/peer-graph-panel.tsx new file mode 100644 index 0000000..0e22149 --- /dev/null +++ b/apps/web/src/modules/mesh/peer-graph-panel.tsx @@ -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(); + 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 ( +
+ {/* Header */} +
+
+ + + peer graph + +
+ + {peers.length} peers ·{" "} + {isFetching ? "polling\u2026" : `${secondsAgo ?? "\u2014"}s ago`} + +
+ + {/* Graph area */} +
+ +
+ + {/* Legend */} +
+ + + idle + + + + working + + + + dnd + + | + + + low + + + + next + + + + now + +
+
+ ); +}; diff --git a/apps/web/src/modules/mesh/peer-graph.tsx b/apps/web/src/modules/mesh/peer-graph.tsx new file mode 100644 index 0000000..d918b92 --- /dev/null +++ b/apps/web/src/modules/mesh/peer-graph.tsx @@ -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 = { + idle: "#22c55e", // emerald-500 + working: "#d97757", // --cm-clay + dnd: "#c46686", // --cm-fig + offline: "#87867f", // --cm-fg-tertiary +}; + +const PRIORITY_COLOR: Record = { + 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(); + for (const p of peers) { + for (const g of p.groups ?? []) seen.add(g); + } + const map = new Map(); + 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(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(); + 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 ( + + {/* Subtle radial grid */} + + + + {/* Center mesh label */} + {meshName && ( + + {meshName} + + )} + + {/* Edges */} + + {edgePaths.map((ep) => ( + + ))} + + + {/* Animated pulse dots traveling along edges */} + {edgePaths + .filter((ep) => ep.opacity > 0.3) + .map((ep) => ( + + + + ))} + + {/* 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 ( + + {/* 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 ( + + ); + })} + + {/* Outer glow for active status */} + {peer.status === "working" && ( + + + + + )} + + {/* Node circle */} + + + {/* Status indicator dot */} + + + {/* Initials inside node */} + + {peer.name.slice(0, 2).toUpperCase()} + + + {/* Name label below */} + + {peer.name.length > 12 + ? peer.name.slice(0, 11) + "\u2026" + : peer.name} + + + {/* Truncated summary below name */} + {peer.summary && ( + + {peer.summary.length > 24 + ? peer.summary.slice(0, 23) + "\u2026" + : peer.summary} + + )} + + ); + })} + + {/* Empty state */} + {peers.length === 0 && ( + + No peers connected + + )} + + ); +};