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 (
+
+ );
+};