Spaces:
Configuration error
Configuration error
| "use client"; | |
| import { memo, useEffect, useMemo, useState } from "react"; | |
| import { motion } from "framer-motion"; | |
| type NodeRole = "core" | "gateway" | "worker" | "cache"; | |
| type NodeStatus = "ok" | "warm" | "hot" | "offline"; | |
| type NodeDatum = { | |
| id: number; | |
| label: string; | |
| role: NodeRole; | |
| cpu: number; | |
| mem: number; | |
| queue: number; | |
| status: NodeStatus; | |
| }; | |
| type Snapshot = { | |
| step: number; | |
| nodes: NodeDatum[]; | |
| latencyMs: number; | |
| requestRate: number; | |
| action: string; | |
| stability: number; | |
| }; | |
| type Point = { | |
| x: number; | |
| y: number; | |
| }; | |
| const POSITIONS: Point[] = [ | |
| { x: 480, y: 280 }, | |
| { x: 480, y: 92 }, | |
| { x: 650, y: 150 }, | |
| { x: 722, y: 318 }, | |
| { x: 610, y: 466 }, | |
| { x: 350, y: 466 }, | |
| { x: 238, y: 318 }, | |
| { x: 310, y: 150 }, | |
| { x: 480, y: 176 }, | |
| ]; | |
| const NODE_META: Array<Pick<NodeDatum, "id" | "label" | "role">> = [ | |
| { id: 0, label: "control-plane", role: "core" }, | |
| { id: 1, label: "ingress-01", role: "gateway" }, | |
| { id: 2, label: "worker-02", role: "worker" }, | |
| { id: 3, label: "worker-03", role: "worker" }, | |
| { id: 4, label: "cache-04", role: "cache" }, | |
| { id: 5, label: "worker-05", role: "worker" }, | |
| { id: 6, label: "worker-06", role: "worker" }, | |
| { id: 7, label: "ingress-07", role: "gateway" }, | |
| { id: 8, label: "policy-agent", role: "core" }, | |
| ]; | |
| const STATUS_STYLE: Record<NodeStatus, { accent: string; text: string; fill: string; border: string }> = { | |
| ok: { | |
| accent: "#2dd4bf", | |
| text: "#ccfbf1", | |
| fill: "rgba(8, 20, 24, 0.92)", | |
| border: "rgba(45, 212, 191, 0.28)", | |
| }, | |
| warm: { | |
| accent: "#fbbf24", | |
| text: "#fde68a", | |
| fill: "rgba(29, 22, 10, 0.94)", | |
| border: "rgba(251, 191, 36, 0.32)", | |
| }, | |
| hot: { | |
| accent: "#fb7185", | |
| text: "#fecdd3", | |
| fill: "rgba(36, 15, 20, 0.94)", | |
| border: "rgba(251, 113, 133, 0.36)", | |
| }, | |
| offline: { | |
| accent: "#71717a", | |
| text: "#d4d4d8", | |
| fill: "rgba(16, 16, 20, 0.96)", | |
| border: "rgba(113, 113, 122, 0.26)", | |
| }, | |
| }; | |
| function clamp(value: number, min: number, max: number) { | |
| return Math.max(min, Math.min(max, value)); | |
| } | |
| function smoothNoise(seed: number) { | |
| return Math.sin(seed * 12.9898) * 0.5 + Math.sin(seed * 4.1414) * 0.5; | |
| } | |
| function getStatus(cpu: number, offline: boolean): NodeStatus { | |
| if (offline) return "offline"; | |
| if (cpu >= 0.82) return "hot"; | |
| if (cpu >= 0.62) return "warm"; | |
| return "ok"; | |
| } | |
| function generateSnapshot(step: number): Snapshot { | |
| const t = step * 0.16; | |
| const phase = step % 96; | |
| const surge = phase >= 48 && phase <= 74; | |
| const recovery = phase > 74; | |
| const nodes = NODE_META.map((node) => { | |
| const wave = Math.sin(t + node.id * 0.72); | |
| const pulse = Math.max(0, Math.sin((phase - 42) / 12)); | |
| const offline = surge && (node.id === 3 || node.id === 6) && phase > 62; | |
| const base = node.role === "core" ? 0.38 : node.role === "gateway" ? 0.42 : node.role === "cache" ? 0.33 : 0.31; | |
| const stress = surge ? 0.24 + pulse * 0.24 : recovery ? 0.16 : 0.08; | |
| const cpu = offline ? 0 : clamp(base + wave * 0.1 + stress + smoothNoise(step + node.id) * 0.018, 0.07, 0.96); | |
| const mem = offline ? 0 : clamp(0.34 + Math.sin(t * 0.65 + node.id) * 0.08 + (surge ? 0.12 : 0), 0.16, 0.9); | |
| const queue = offline ? 0 : Math.round(clamp(8 + cpu * 28 + (surge ? pulse * 18 : 0), 2, 58)); | |
| return { | |
| ...node, | |
| cpu, | |
| mem, | |
| queue, | |
| status: getStatus(cpu, offline), | |
| }; | |
| }); | |
| const activeNodes = nodes.filter((node) => node.status !== "offline").length; | |
| const avgCpu = nodes.reduce((sum, node) => sum + node.cpu, 0) / Math.max(activeNodes, 1); | |
| return { | |
| step, | |
| nodes, | |
| latencyMs: clamp(32 + avgCpu * 96 + (surge ? Math.max(0, Math.sin((phase - 48) / 10)) * 64 : 0), 28, 240), | |
| requestRate: clamp(148 + Math.sin(t * 0.4) * 32 + (surge ? 170 : recovery ? 82 : 0), 80, 430), | |
| action: surge ? "traffic_shift + scale_out" : recovery ? "drain_and_recover" : "steady_state", | |
| stability: clamp((activeNodes / nodes.length) * (1 - Math.max(0, avgCpu - 0.58) * 0.75), 0.42, 0.99), | |
| }; | |
| } | |
| function edgePath(from: Point, to: Point, bend = 0) { | |
| const midX = (from.x + to.x) / 2; | |
| const midY = (from.y + to.y) / 2; | |
| const dx = to.x - from.x; | |
| const dy = to.y - from.y; | |
| const length = Math.max(1, Math.hypot(dx, dy)); | |
| const cx = midX + (-dy / length) * bend; | |
| const cy = midY + (dx / length) * bend; | |
| return `M ${from.x} ${from.y} Q ${cx} ${cy} ${to.x} ${to.y}`; | |
| } | |
| function Metric({ label, value, tone = "text-zinc-200" }: { label: string; value: string; tone?: string }) { | |
| return ( | |
| <div className="min-w-0 rounded-lg border border-zinc-800/80 bg-black/25 px-3 py-2"> | |
| <p className="font-mono text-[10px] uppercase tracking-[0.12em] text-zinc-500">{label}</p> | |
| <p className={`mt-1 truncate font-mono text-sm ${tone}`}>{value}</p> | |
| </div> | |
| ); | |
| } | |
| function Edge({ from, to, traffic, muted, index }: { from: Point; to: Point; traffic: number; muted: boolean; index: number }) { | |
| const path = edgePath(from, to, index % 2 === 0 ? 18 : -18); | |
| const hot = traffic > 0.78; | |
| const color = muted ? "rgba(113, 113, 122, 0.28)" : hot ? "rgba(251, 191, 36, 0.58)" : "rgba(45, 212, 191, 0.42)"; | |
| const particleColor = hot ? "#fbbf24" : "#67e8f9"; | |
| const duration = clamp(4.1 - traffic * 2.2, 1.45, 3.8); | |
| return ( | |
| <g> | |
| <motion.path | |
| d={path} | |
| fill="none" | |
| stroke={color} | |
| strokeWidth={muted ? 0.8 : 1 + traffic * 1.1} | |
| strokeLinecap="round" | |
| initial={false} | |
| animate={{ opacity: muted ? 0.18 : 0.34 + traffic * 0.34 }} | |
| transition={{ duration: 0.5 }} | |
| /> | |
| {!muted ? ( | |
| <> | |
| <circle r={2.4} fill={particleColor} opacity="0.76"> | |
| <animateMotion dur={`${duration}s`} repeatCount="indefinite" path={path} /> | |
| </circle> | |
| <circle r={1.45} fill={particleColor} opacity="0.42"> | |
| <animateMotion dur={`${duration * 1.25}s`} begin={`${duration * 0.45}s`} repeatCount="indefinite" path={path} /> | |
| </circle> | |
| </> | |
| ) : null} | |
| </g> | |
| ); | |
| } | |
| const NodeCard = memo(function NodeCard({ node, position }: { node: NodeDatum; position: Point }) { | |
| const style = STATUS_STYLE[node.status]; | |
| const core = node.role === "core"; | |
| const width = core ? 132 : 116; | |
| const height = core ? 74 : 66; | |
| const radius = core ? 18 : 15; | |
| const circumference = 2 * Math.PI * radius; | |
| const dashOffset = circumference * (1 - node.cpu); | |
| const opacity = node.status === "offline" ? 0.52 : 1; | |
| return ( | |
| <motion.g | |
| initial={false} | |
| animate={{ x: position.x, y: position.y, opacity }} | |
| transition={{ type: "spring", stiffness: 80, damping: 18 }} | |
| > | |
| {node.status !== "offline" ? ( | |
| <motion.circle | |
| r={width / 2} | |
| fill="none" | |
| stroke={style.accent} | |
| strokeWidth="0.7" | |
| animate={{ opacity: [0.1, 0.2, 0.1], scale: [0.96, 1.08, 0.96] }} | |
| transition={{ duration: node.status === "hot" ? 1.5 : 3.8, repeat: Infinity, ease: "easeInOut" }} | |
| /> | |
| ) : null} | |
| <motion.rect | |
| x={-width / 2} | |
| y={-height / 2} | |
| width={width} | |
| height={height} | |
| rx={14} | |
| fill={style.fill} | |
| stroke={style.border} | |
| strokeWidth="1" | |
| initial={false} | |
| animate={{ | |
| filter: node.status === "hot" ? "drop-shadow(0 0 16px rgba(251, 113, 133, 0.22))" : "drop-shadow(0 12px 30px rgba(0, 0, 0, 0.24))", | |
| }} | |
| transition={{ duration: 0.45 }} | |
| /> | |
| <circle cx={-width / 2 + 29} cy={-7} r={radius} fill="rgba(255,255,255,0.025)" stroke="rgba(255,255,255,0.08)" strokeWidth="3" /> | |
| <motion.circle | |
| cx={-width / 2 + 29} | |
| cy={-7} | |
| r={radius} | |
| fill="none" | |
| stroke={style.accent} | |
| strokeWidth="3" | |
| strokeLinecap="round" | |
| strokeDasharray={circumference} | |
| initial={false} | |
| animate={{ strokeDashoffset: dashOffset }} | |
| transition={{ duration: 0.65, ease: "easeOut" }} | |
| transform={`rotate(-90 ${-width / 2 + 29} -7)`} | |
| /> | |
| <text | |
| x={-width / 2 + 29} | |
| y={-3} | |
| textAnchor="middle" | |
| fill={style.text} | |
| fontFamily="var(--font-geist-mono), monospace" | |
| fontSize="10" | |
| fontWeight="700" | |
| > | |
| {node.status === "offline" ? "off" : `${Math.round(node.cpu * 100)}`} | |
| </text> | |
| <text x={-width / 2 + 58} y={-14} fill="#f4f4f5" fontFamily="var(--font-geist-mono), monospace" fontSize="10" fontWeight="700"> | |
| {node.label} | |
| </text> | |
| <text x={-width / 2 + 58} y={5} fill="#a1a1aa" fontFamily="var(--font-geist-mono), monospace" fontSize="8.5"> | |
| {node.role.toUpperCase()} / {node.status.toUpperCase()} | |
| </text> | |
| <text x={-width / 2 + 58} y={23} fill={style.accent} fontFamily="var(--font-geist-mono), monospace" fontSize="8.5"> | |
| MEM {Math.round(node.mem * 100)}% Q {node.queue} | |
| </text> | |
| </motion.g> | |
| ); | |
| }); | |
| export function ClusterSimulation() { | |
| const [snapshot, setSnapshot] = useState(() => generateSnapshot(0)); | |
| useEffect(() => { | |
| let step = 0; | |
| const timer = window.setInterval(() => { | |
| step += 1; | |
| setSnapshot(generateSnapshot(step)); | |
| }, 700); | |
| return () => window.clearInterval(timer); | |
| }, []); | |
| const summary = useMemo(() => { | |
| const online = snapshot.nodes.filter((node) => node.status !== "offline").length; | |
| const avgCpu = snapshot.nodes.reduce((sum, node) => sum + node.cpu, 0) / Math.max(online, 1); | |
| return { | |
| online, | |
| avgCpu, | |
| hotNodes: snapshot.nodes.filter((node) => node.status === "hot").length, | |
| }; | |
| }, [snapshot.nodes]); | |
| return ( | |
| <div className="overflow-hidden rounded-2xl border border-zinc-800/80 bg-[#080b0f] shadow-[0_24px_80px_rgba(0,0,0,0.38)]"> | |
| <div className="flex flex-col gap-3 border-b border-zinc-800/70 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:px-5"> | |
| <div className="flex items-center gap-2"> | |
| <span className="h-1.5 w-1.5 rounded-full bg-emerald-300 shadow-[0_0_12px_rgba(110,231,183,0.7)]" /> | |
| <p className="font-mono text-[10px] uppercase tracking-[0.16em] text-emerald-200/80">Simulation</p> | |
| </div> | |
| <p className="font-mono text-[10px] uppercase tracking-[0.12em] text-zinc-500"> | |
| step {snapshot.step} / {summary.online} of {snapshot.nodes.length} nodes online | |
| </p> | |
| </div> | |
| <div className="relative min-h-[25rem] overflow-hidden bg-[radial-gradient(circle_at_50%_40%,rgba(45,212,191,0.12),transparent_38%),linear-gradient(180deg,rgba(24,24,27,0.28),rgba(0,0,0,0.08))]"> | |
| <div className="pointer-events-none absolute inset-0 bg-[linear-gradient(rgba(255,255,255,0.035)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.035)_1px,transparent_1px)] bg-[size:40px_40px] opacity-35" /> | |
| <svg viewBox="0 0 960 560" className="relative h-[25rem] w-full sm:h-[31rem]" preserveAspectRatio="xMidYMid meet" aria-label="Animated distributed cluster simulation"> | |
| <defs> | |
| <radialGradient id="cluster-core-glow" cx="50%" cy="50%" r="50%"> | |
| <stop offset="0%" stopColor="rgba(45, 212, 191, 0.22)" /> | |
| <stop offset="65%" stopColor="rgba(45, 212, 191, 0.04)" /> | |
| <stop offset="100%" stopColor="rgba(45, 212, 191, 0)" /> | |
| </radialGradient> | |
| </defs> | |
| <motion.circle | |
| cx="480" | |
| cy="280" | |
| r="220" | |
| fill="url(#cluster-core-glow)" | |
| animate={{ opacity: [0.45, 0.65, 0.45], scale: [0.98, 1.02, 0.98] }} | |
| transition={{ duration: 6, repeat: Infinity, ease: "easeInOut" }} | |
| style={{ transformOrigin: "480px 280px" }} | |
| /> | |
| <g opacity="0.38"> | |
| <ellipse cx="480" cy="280" rx="310" ry="192" fill="none" stroke="rgba(161,161,170,0.22)" strokeDasharray="6 16" /> | |
| <ellipse cx="480" cy="280" rx="206" ry="128" fill="none" stroke="rgba(45,212,191,0.16)" strokeDasharray="4 14" /> | |
| </g> | |
| {snapshot.nodes.slice(1, 8).map((node, index) => ( | |
| <Edge | |
| key={`edge-core-${node.id}`} | |
| from={POSITIONS[node.id]} | |
| to={POSITIONS[0]} | |
| traffic={node.cpu} | |
| muted={node.status === "offline"} | |
| index={index} | |
| /> | |
| ))} | |
| {[1, 2, 3, 4, 5, 6, 7].map((id, index, ids) => { | |
| const nextId = ids[(index + 1) % ids.length]; | |
| const node = snapshot.nodes[id]; | |
| const nextNode = snapshot.nodes[nextId]; | |
| return ( | |
| <Edge | |
| key={`edge-ring-${id}-${nextId}`} | |
| from={POSITIONS[id]} | |
| to={POSITIONS[nextId]} | |
| traffic={(node.cpu + nextNode.cpu) / 2} | |
| muted={node.status === "offline" || nextNode.status === "offline"} | |
| index={index + 7} | |
| /> | |
| ); | |
| })} | |
| <Edge from={POSITIONS[8]} to={POSITIONS[0]} traffic={snapshot.stability} muted={false} index={18} /> | |
| {snapshot.nodes.map((node) => ( | |
| <NodeCard key={node.id} node={node} position={POSITIONS[node.id]} /> | |
| ))} | |
| </svg> | |
| </div> | |
| <div className="grid grid-cols-2 gap-2 border-t border-zinc-800/70 p-3 sm:grid-cols-5 sm:p-4"> | |
| <Metric label="latency" value={`${snapshot.latencyMs.toFixed(0)}ms`} tone={snapshot.latencyMs > 150 ? "text-amber-200" : "text-sky-200"} /> | |
| <Metric label="request rate" value={`${snapshot.requestRate.toFixed(0)} rps`} tone="text-violet-200" /> | |
| <Metric label="avg cpu" value={`${Math.round(summary.avgCpu * 100)}%`} tone={summary.avgCpu > 0.72 ? "text-rose-200" : "text-emerald-200"} /> | |
| <Metric label="stability" value={`${Math.round(snapshot.stability * 100)}%`} tone="text-emerald-200" /> | |
| <Metric label="action" value={snapshot.action} tone={summary.hotNodes > 0 ? "text-amber-200" : "text-zinc-300"} /> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| export default ClusterSimulation; | |