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,
|
DashboardHeaderTitle,
|
||||||
} from "~/modules/common/layout/dashboard/header";
|
} from "~/modules/common/layout/dashboard/header";
|
||||||
import { LiveStreamPanel } from "~/modules/mesh/live-stream-panel";
|
import { LiveStreamPanel } from "~/modules/mesh/live-stream-panel";
|
||||||
|
import { PeerGraphPanel } from "~/modules/mesh/peer-graph-panel";
|
||||||
|
|
||||||
export const generateMetadata = getMetadata({
|
export const generateMetadata = getMetadata({
|
||||||
title: "Live mesh",
|
title: "Live mesh",
|
||||||
@@ -63,7 +64,10 @@ export default async function LiveMeshPage({
|
|||||||
</div>
|
</div>
|
||||||
</DashboardHeader>
|
</DashboardHeader>
|
||||||
|
|
||||||
<LiveStreamPanel meshId={id} />
|
<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