| 1 | import React, {useMemo} from 'react'; |
| 2 | import { |
| 3 | AbsoluteFill, |
| 4 | interpolate, |
| 5 | random, |
| 6 | spring, |
| 7 | useCurrentFrame, |
| 8 | useVideoConfig, |
| 9 | } from 'remotion'; |
| 10 | import { |
| 11 | Database, |
| 12 | Gauge, |
| 13 | GitBranch, |
| 14 | LayoutDashboard, |
| 15 | Logs, |
| 16 | Server, |
| 17 | ShieldCheck, |
| 18 | } from 'lucide-react'; |
| 19 | |
| 20 | type NodeSpec = { |
| 21 | id: string; |
| 22 | label: string; |
| 23 | Icon: React.FC<{size?: number; color?: string}>; |
| 24 | angleDeg: number; |
| 25 | }; |
| 26 | |
| 27 | const BG = '#0b0912'; |
| 28 | const PRIMARY = '#f85d3b'; |
| 29 | const ACCENT = '#884fff'; |
| 30 | |
| 31 | const NodeCard: React.FC<{ |
| 32 | cx: number; |
| 33 | cy: number; |
| 34 | label: string; |
| 35 | Icon: NodeSpec['Icon']; |
| 36 | accent: string; |
| 37 | appear: number; |
| 38 | }> = ({cx, cy, label, Icon, accent, appear}) => { |
| 39 | const frame = useCurrentFrame(); |
| 40 | const {fps} = useVideoConfig(); |
| 41 | const s = spring({frame, fps, config: {damping: 14, mass: 0.8, stiffness: 120}}); |
| 42 | const scale = interpolate(s, [0, 1], [0.92, 1]) * appear; |
| 43 | const opacity = interpolate(s, [0, 1], [0, 1]) * appear; |
| 44 | |
| 45 | return ( |
| 46 | <div |
| 47 | style={{ |
| 48 | position: 'absolute', |
| 49 | left: cx, |
| 50 | top: cy, |
| 51 | transform: `translate(-50%, -50%) scale(${scale})`, |
| 52 | opacity, |
| 53 | width: 180, |
| 54 | padding: '14px 14px 12px', |
| 55 | borderRadius: 16, |
| 56 | background: 'rgba(255,255,255,0.03)', |
| 57 | border: '1px solid rgba(255,255,255,0.08)', |
| 58 | backdropFilter: 'blur(8px)', |
| 59 | boxShadow: `0 0 24px rgba(136,79,255,0.08)`, |
| 60 | textAlign: 'center', |
| 61 | fontFamily: |
| 62 | 'IBM Plex Sans, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial', |
| 63 | }} |
| 64 | > |
| 65 | <div |
| 66 | style={{ |
| 67 | display: 'inline-flex', |
| 68 | alignItems: 'center', |
| 69 | justifyContent: 'center', |
| 70 | width: 58, |
| 71 | height: 58, |
| 72 | borderRadius: 18, |
| 73 | background: 'rgba(255,255,255,0.02)', |
| 74 | border: `1px solid rgba(255,255,255,0.10)`, |
| 75 | boxShadow: `0 0 18px ${accent}33`, |
| 76 | }} |
| 77 | > |
| 78 | <Icon size={30} color={accent} /> |
| 79 | </div> |
| 80 | <div style={{marginTop: 10, fontSize: 16, fontWeight: 600, color: 'rgba(255,255,255,0.92)'}}> |
| 81 | {label} |
| 82 | </div> |
| 83 | </div> |
| 84 | ); |
| 85 | }; |
| 86 | |
| 87 | const Stream: React.FC<{ |
| 88 | x1: number; |
| 89 | y1: number; |
| 90 | x2: number; |
| 91 | y2: number; |
| 92 | color: string; |
| 93 | seed: number; |
| 94 | }> = ({x1, y1, x2, y2, color, seed}) => { |
| 95 | const frame = useCurrentFrame(); |
| 96 | const {durationInFrames} = useVideoConfig(); |
| 97 | |
| 98 | // Dotted stream |
| 99 | const dash = 10; |
| 100 | const gap = 8; |
| 101 | const speed = 2.0 + random(seed) * 1.4; |
| 102 | const offset = (frame * speed) % (dash + gap); |
| 103 | |
| 104 | // Occasional packet pulse (repeatable) |
| 105 | const t = (frame + Math.floor(random(seed + 1) * 60)) % durationInFrames; |
| 106 | const pulseEvery = 45 + Math.floor(random(seed + 2) * 35); |
| 107 | const pulsePhase = t % pulseEvery; |
| 108 | const pulseT = pulsePhase / pulseEvery; |
| 109 | const packetVisible = pulsePhase < 18; |
| 110 | |
| 111 | // Quadratic curve control point (bend outward) |
| 112 | const mx = (x1 + x2) / 2; |
| 113 | const my = (y1 + y2) / 2; |
| 114 | const bend = 80; |
| 115 | const cx = mx + (my - 300) * 0.15; |
| 116 | const cy = my - bend; |
| 117 | |
| 118 | const path = `M ${x1} ${y1} Q ${cx} ${cy} ${x2} ${y2}`; |
| 119 | |
| 120 | // Packet position along curve (approx via linear interpolation on t; fine for small effect) |
| 121 | const px = x1 + (x2 - x1) * pulseT; |
| 122 | const py = y1 + (y2 - y1) * pulseT; |
| 123 | |
| 124 | return ( |
| 125 | <svg |
| 126 | style={{ |
| 127 | position: 'absolute', |
| 128 | left: 0, |
| 129 | top: 0, |
| 130 | width: '100%', |
| 131 | height: '100%', |
| 132 | overflow: 'visible', |
| 133 | }} |
| 134 | > |
| 135 | <path |
| 136 | d={path} |
| 137 | fill="none" |
| 138 | stroke={color} |
| 139 | strokeWidth={2.2} |
| 140 | strokeDasharray={`${dash} ${gap}`} |
| 141 | strokeDashoffset={-offset} |
| 142 | opacity={0.45} |
| 143 | style={{ |
| 144 | filter: `drop-shadow(0 0 10px ${color}55) drop-shadow(0 0 18px ${color}33)`, |
| 145 | }} |
| 146 | /> |
| 147 | {packetVisible ? ( |
| 148 | <circle |
| 149 | cx={px} |
| 150 | cy={py} |
| 151 | r={4.2} |
| 152 | fill={color} |
| 153 | opacity={0.9} |
| 154 | style={{filter: `drop-shadow(0 0 14px ${color})`}} |
| 155 | /> |
| 156 | ) : null} |
| 157 | </svg> |
| 158 | ); |
| 159 | }; |
| 160 | |
| 161 | export const HubSpoke: React.FC = () => { |
| 162 | const frame = useCurrentFrame(); |
| 163 | const {fps, durationInFrames, width, height} = useVideoConfig(); |
| 164 | |
| 165 | const center = {x: width / 2, y: height / 2}; |
| 166 | const radius = 210; |
| 167 | |
| 168 | const nodes: NodeSpec[] = useMemo( |
| 169 | () => [ |
| 170 | { id: 'wazuh-manager', label: 'Wazuh Manager', Icon: Server, angleDeg: -90 }, |
| 171 | { id: 'wazuh-indexer', label: 'Wazuh Indexer', Icon: Database, angleDeg: -30 }, |
| 172 | { id: 'graylog', label: 'Graylog', Icon: Logs, angleDeg: 30 }, |
| 173 | { id: 'grafana', label: 'Grafana', Icon: Gauge, angleDeg: 90 }, |
| 174 | { id: 'shuffle', label: 'Shuffle', Icon: GitBranch, angleDeg: 150 }, |
| 175 | { id: 'velociraptor', label: 'Velociraptor', Icon: LayoutDashboard, angleDeg: 210 }, |
| 176 | ], |
| 177 | [] |
| 178 | ); |
| 179 | |
| 180 | const appear = spring({frame, fps, config: {damping: 16, stiffness: 120, mass: 0.9}}); |
| 181 | |
| 182 | const hubPulse = 0.5 + 0.5 * Math.sin((frame / durationInFrames) * Math.PI * 2); |
| 183 | const hubGlow = interpolate(hubPulse, [0, 1], [0.25, 0.6]); |
| 184 | |
| 185 | return ( |
| 186 | <AbsoluteFill |
| 187 | style={{ |
| 188 | backgroundColor: BG, |
| 189 | overflow: 'hidden', |
| 190 | }} |
| 191 | > |
| 192 | {/* Background: subtle grid + gradient */} |
| 193 | <AbsoluteFill |
| 194 | style={{ |
| 195 | background: |
| 196 | `radial-gradient(900px 380px at 50% 30%, rgba(136,79,255,0.18), transparent 60%),` + |
| 197 | `radial-gradient(700px 420px at 70% 70%, rgba(248,93,59,0.12), transparent 55%),` + |
| 198 | `linear-gradient(180deg, rgba(255,255,255,0.02), transparent 40%)`, |
| 199 | }} |
| 200 | /> |
| 201 | <AbsoluteFill |
| 202 | style={{ |
| 203 | backgroundImage: |
| 204 | 'linear-gradient(rgba(255,255,255,0.04) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,0.04) 1px, transparent 1px)', |
| 205 | backgroundSize: '56px 56px', |
| 206 | opacity: 0.10, |
| 207 | transform: `translateY(${interpolate( |
| 208 | (frame % durationInFrames) / durationInFrames, |
| 209 | [0, 1], |
| 210 | [0, -56] |
| 211 | )}px)`, |
| 212 | }} |
| 213 | /> |
| 214 | |
| 215 | {/* Streams */} |
| 216 | {nodes.map((n, idx) => { |
| 217 | const a = (n.angleDeg * Math.PI) / 180; |
| 218 | const x = center.x + Math.cos(a) * radius; |
| 219 | const y = center.y + Math.sin(a) * radius; |
| 220 | const color = idx % 2 === 0 ? PRIMARY : ACCENT; |
| 221 | return ( |
| 222 | <Stream |
| 223 | key={n.id} |
| 224 | x1={x} |
| 225 | y1={y} |
| 226 | x2={center.x} |
| 227 | y2={center.y} |
| 228 | color={color} |
| 229 | seed={idx + 10} |
| 230 | /> |
| 231 | ); |
| 232 | })} |
| 233 | |
| 234 | {/* Hub */} |
| 235 | <div |
| 236 | style={{ |
| 237 | position: 'absolute', |
| 238 | left: center.x, |
| 239 | top: center.y, |
| 240 | transform: `translate(-50%, -50%) scale(${0.98 + appear * 0.02})`, |
| 241 | width: 320, |
| 242 | padding: '22px 18px 18px', |
| 243 | borderRadius: 22, |
| 244 | background: 'rgba(255,255,255,0.04)', |
| 245 | border: '1px solid rgba(255,255,255,0.10)', |
| 246 | textAlign: 'center', |
| 247 | boxShadow: `0 0 35px rgba(248,93,59,${hubGlow}), 0 0 55px rgba(136,79,255,0.18)`, |
| 248 | backdropFilter: 'blur(10px)', |
| 249 | fontFamily: |
| 250 | 'IBM Plex Sans, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial', |
| 251 | }} |
| 252 | > |
| 253 | <div |
| 254 | style={{ |
| 255 | display: 'inline-flex', |
| 256 | alignItems: 'center', |
| 257 | justifyContent: 'center', |
| 258 | width: 76, |
| 259 | height: 76, |
| 260 | borderRadius: 22, |
| 261 | background: 'rgba(255,255,255,0.02)', |
| 262 | border: '1px solid rgba(255,255,255,0.12)', |
| 263 | boxShadow: `0 0 22px ${PRIMARY}55`, |
| 264 | }} |
| 265 | > |
| 266 | <ShieldCheck size={38} color={PRIMARY} /> |
| 267 | </div> |
| 268 | <div |
| 269 | style={{ |
| 270 | marginTop: 12, |
| 271 | fontSize: 22, |
| 272 | fontWeight: 700, |
| 273 | color: 'rgba(255,255,255,0.94)', |
| 274 | letterSpacing: 0.2, |
| 275 | }} |
| 276 | > |
| 277 | SOCFortress CoPilot |
| 278 | </div> |
| 279 | <div |
| 280 | style={{ |
| 281 | marginTop: 6, |
| 282 | fontSize: 15, |
| 283 | color: 'rgba(255,255,255,0.70)', |
| 284 | }} |
| 285 | > |
| 286 | Connect • Enrich • Automate |
| 287 | </div> |
| 288 | </div> |
| 289 | |
| 290 | {/* Outer nodes */} |
| 291 | {nodes.map((n, idx) => { |
| 292 | const a = (n.angleDeg * Math.PI) / 180; |
| 293 | const x = center.x + Math.cos(a) * radius; |
| 294 | const y = center.y + Math.sin(a) * radius; |
| 295 | const accent = idx % 2 === 0 ? PRIMARY : ACCENT; |
| 296 | return ( |
| 297 | <NodeCard |
| 298 | key={n.id} |
| 299 | cx={x} |
| 300 | cy={y} |
| 301 | label={n.label} |
| 302 | Icon={n.Icon} |
| 303 | accent={accent} |
| 304 | appear={appear} |
| 305 | /> |
| 306 | ); |
| 307 | })} |
| 308 | |
| 309 | {/* Subtle vignette */} |
| 310 | <AbsoluteFill |
| 311 | style={{ |
| 312 | background: |
| 313 | 'radial-gradient(circle at 50% 50%, transparent 40%, rgba(0,0,0,0.55) 85%)', |
| 314 | opacity: 0.55, |
| 315 | }} |
| 316 | /> |
| 317 | </AbsoluteFill> |
| 318 | ); |
| 319 | }; |