// src/pages/infra/PipelineScene.jsx // NAS ↔ Redis 큐 버스 ↔ Windows 워커 6종을 raw three.js로 그린 실시간 파이프라인. // 정상: 시안 파티클이 흐름 / busy: 빠르게 / paused: 앰버 정지 / degraded: 주황 흐름 / down: 빨강·흐름 멈춤. // status(/nodes)는 statusRef로 RAF 루프에 최신값 주입. 라벨은 3D→화면 투영 HTML 오버레이. import { useEffect, useRef } from 'react'; import * as THREE from 'three'; import { linkColor, workerStatus, workerStateLabel, workerTitle } from './statusVisual'; const NODES = [ { name: 'music-render', kind: 'render' }, { name: 'video-render', kind: 'render' }, { name: 'image-render', kind: 'render' }, { name: 'insta-render', kind: 'render' }, { name: 'task-watcher', kind: 'watcher' }, { name: 'ai_trade', kind: 'trader' }, ]; const hexToColor = (hex) => new THREE.Color(hex); function workerByName(status, name) { if (!status || !Array.isArray(status.workers)) return null; return status.workers.find((w) => w.name === name) || null; } // 링크의 현재 상태 문자열 → 'healthy'|'paused'|'degraded'|'down'|null function linkStatusOf(status, link) { if (!status) return null; if (link.kind === 'trunk') return status.redis_ok ? 'healthy' : 'down'; const w = workerByName(status, link.worker); if (link.kind === 'branch' && !status.redis_ok) return 'down'; if (!w) return 'down'; return workerStatus(w); } export default function PipelineScene({ status }) { const mountRef = useRef(null); const statusRef = useRef(status); statusRef.current = status; useEffect(() => { const mount = mountRef.current; if (!mount) return undefined; let width = mount.clientWidth || 900; let height = mount.clientHeight || 520; const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(52, width / height, 0.1, 200); camera.position.set(0, 1.4, 20.5); camera.lookAt(0, -0.3, 0); const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); renderer.setSize(width, height); renderer.domElement.style.display = 'block'; mount.appendChild(renderer.domElement); // ── lights ── scene.add(new THREE.AmbientLight(0x5577aa, 0.65)); const l1 = new THREE.PointLight(0x00d4ff, 1.3, 80); l1.position.set(-10, 7, 14); scene.add(l1); const l2 = new THREE.PointLight(0x8b5cf6, 1.1, 80); l2.position.set(10, -7, 12); scene.add(l2); // ── positions ── const nasPos = new THREE.Vector3(-9, 0, 0); const redisPos = new THREE.Vector3(-1.5, 0, 0); const colX = 8; const ys = [6.25, 3.75, 1.25, -1.25, -3.75, -6.25]; const nodePositions = NODES.map((n, i) => new THREE.Vector3(colX, ys[i], 0)); const disposables = []; const track = (obj) => { if (obj.geometry) disposables.push(obj.geometry); if (obj.material) disposables.push(obj.material); return obj; }; // ── NAS node (left monolith) ── const nasMesh = track( new THREE.Mesh( new THREE.BoxGeometry(2.2, 3.2, 1.4), new THREE.MeshStandardMaterial({ color: 0x0d1530, emissive: 0x0a2a44, emissiveIntensity: 0.9, metalness: 0.5, roughness: 0.35, }) ) ); nasMesh.position.copy(nasPos); scene.add(nasMesh); // ── Redis bus (vertical glowing spine) ── const busMesh = track( new THREE.Mesh( new THREE.CylinderGeometry(0.55, 0.55, 13.2, 24, 1, true), new THREE.MeshBasicMaterial({ color: 0x00d4ff, transparent: true, opacity: 0.85, side: THREE.DoubleSide, }) ) ); busMesh.position.copy(redisPos); scene.add(busMesh); const busCore = track( new THREE.Mesh( new THREE.CylinderGeometry(0.18, 0.18, 13.2, 16), new THREE.MeshBasicMaterial({ color: 0xe8f0fe, transparent: true, opacity: 0.9 }) ) ); busCore.position.copy(redisPos); scene.add(busCore); // ── worker nodes ── const nodeMeshes = NODES.map((n, i) => { const geo = n.kind === 'trader' ? new THREE.IcosahedronGeometry(0.95, 0) : n.kind === 'watcher' ? new THREE.OctahedronGeometry(1.0, 0) : new THREE.BoxGeometry(1.7, 1.4, 1.4); const mat = new THREE.MeshStandardMaterial({ color: 0x0d1530, emissive: 0x111a3a, emissiveIntensity: 1.0, metalness: 0.45, roughness: 0.4, }); const mesh = new THREE.Mesh(geo, mat); mesh.position.copy(nodePositions[i]); scene.add(mesh); disposables.push(geo, mat); return mesh; }); // ── links (curves) ── const particleGeo = new THREE.SphereGeometry(0.13, 8, 8); disposables.push(particleGeo); const PARTICLES_PER_LINK = 6; function makeLink(curve, kind, worker) { const pts = curve.getPoints(60); const lineGeo = new THREE.BufferGeometry().setFromPoints(pts); const lineMat = new THREE.LineBasicMaterial({ color: 0x2a3a66, transparent: true, opacity: 0.55, }); const line = new THREE.Line(lineGeo, lineMat); scene.add(line); disposables.push(lineGeo, lineMat); const pMat = new THREE.MeshBasicMaterial({ color: 0x00d4ff, transparent: true, opacity: 0.95, blending: THREE.AdditiveBlending, depthWrite: false, }); disposables.push(pMat); const particles = []; for (let k = 0; k < PARTICLES_PER_LINK; k += 1) { const pm = new THREE.Mesh(particleGeo, pMat); scene.add(pm); particles.push({ mesh: pm, t: k / PARTICLES_PER_LINK }); } return { curve, kind, worker, line, lineMat, pMat, particles }; } const links = []; // trunk: NAS → Redis links.push( makeLink( new THREE.QuadraticBezierCurve3( nasPos.clone().add(new THREE.Vector3(1.2, 0, 0)), new THREE.Vector3((nasPos.x + redisPos.x) / 2, 0.6, 1.2), redisPos.clone() ), 'trunk' ) ); // branches: Redis → render/watcher (indices 0..4) for (let i = 0; i < 5; i += 1) { const start = new THREE.Vector3(redisPos.x, ys[i] * 0.45, 0); const end = nodePositions[i].clone().add(new THREE.Vector3(-1.0, 0, 0)); const ctrl = new THREE.Vector3((start.x + end.x) / 2, (start.y + end.y) / 2, 1.6); links.push(makeLink(new THREE.QuadraticBezierCurve3(start, ctrl, end), 'branch', NODES[i].name)); } // ai_trade: node → NAS directly (http-pull, bypasses Redis bus) links.push( makeLink( new THREE.QuadraticBezierCurve3( nodePositions[5].clone().add(new THREE.Vector3(-0.9, -0.2, 0)), new THREE.Vector3(0, -9.5, 4.5), nasPos.clone().add(new THREE.Vector3(0.4, -1.4, 0)) ), 'pull', 'ai_trade' ) ); // ── HTML label overlay ── const overlay = document.createElement('div'); overlay.className = 'pipeline-labels'; mount.appendChild(overlay); const makeLabel = (title, sub) => { const el = document.createElement('div'); el.className = 'pipeline-label'; el.innerHTML = `${title}${sub}`; overlay.appendChild(el); return el; }; const nasLabel = makeLabel('NAS', '게이트웨이'); nasLabel.classList.add('pipeline-label--anchor'); const busLabel = makeLabel('Redis Bus', '큐'); busLabel.classList.add('pipeline-label--anchor'); const nodeLabels = NODES.map((n) => makeLabel(workerTitle(n.name), '—')); const projectTo = (pos, el, dx = 0, dy = 0) => { const v = pos.clone().project(camera); const x = (v.x * 0.5 + 0.5) * width + dx; const y = (-v.y * 0.5 + 0.5) * height + dy; el.style.transform = `translate(-50%,-50%) translate(${x}px,${y}px)`; el.style.opacity = v.z < 1 ? '1' : '0'; }; // ── animation ── let raf = 0; let last = performance.now(); const clock = { t: 0 }; const speedFor = (st) => { if (st === 'down' || st === 'paused' || st == null) return 0; return 0.16; // healthy/degraded base }; function frame(now) { const dt = Math.min((now - last) / 1000, 0.05); last = now; clock.t += dt; const status = statusRef.current; // Redis bus color/pulse const redisOk = !status || status.redis_ok; const busColor = redisOk ? 0x00d4ff : 0xf43f5e; const pulse = 0.7 + Math.sin(clock.t * 2.2) * 0.18; busMesh.material.color.setHex(busColor); busMesh.material.opacity = 0.45 + pulse * 0.3; busCore.material.opacity = redisOk ? 0.55 + pulse * 0.35 : 0.5; // per-link links.forEach((lk) => { const st = linkStatusOf(status, lk); const col = hexToColor(st ? linkColor(st) : '#2a3a66'); lk.lineMat.color.copy(col); lk.lineMat.opacity = st === 'down' ? 0.5 : 0.55; lk.pMat.color.copy(col); let speed = speedFor(st); // busy 워커는 빠르게 if (lk.worker && status) { const w = workerByName(status, lk.worker); if (w && w.state === 'busy') speed = 0.42; } const showParticles = st !== 'down'; lk.pMat.opacity = showParticles ? 0.95 : 0.0; lk.particles.forEach((p) => { p.t = (p.t + speed * dt) % 1; const pos = lk.curve.getPoint(p.t); p.mesh.position.copy(pos); p.mesh.visible = showParticles; const s = st === 'paused' ? 0.8 : 1 + Math.sin((p.t + clock.t) * 6) * 0.25; p.mesh.scale.setScalar(s); }); }); // worker node color/pulse + labels NODES.forEach((n, i) => { const w = workerByName(status, n.name); const stt = workerStatus(w); const c = hexToColor(linkColor(stt)); const mesh = nodeMeshes[i]; mesh.material.emissive.copy(c); const alive = w && w.alive; const beat = alive ? 1.05 + Math.sin(clock.t * 3 + i) * 0.06 : 0.92; mesh.material.emissiveIntensity = alive ? 0.9 + Math.sin(clock.t * 3 + i) * 0.25 : 0.35; mesh.scale.setScalar(beat); mesh.rotation.y += dt * (n.kind === 'render' ? 0.15 : 0.4); // label const el = nodeLabels[i]; el.style.setProperty('--pl-color', linkColor(stt)); const sub = el.querySelector('.pl-state'); if (sub) sub.textContent = workerStateLabel(w); projectTo(nodePositions[i].clone().add(new THREE.Vector3(0, 1.5, 0)), el); }); // NAS / bus labels nasLabel.style.setProperty('--pl-color', redisOk ? '#00d4ff' : '#f43f5e'); projectTo(nasPos.clone().add(new THREE.Vector3(0, 2.2, 0)), nasLabel); busLabel.style.setProperty('--pl-color', redisOk ? '#00d4ff' : '#f43f5e'); const busSub = busLabel.querySelector('.pl-state'); if (busSub) busSub.textContent = redisOk ? '정상' : '연결 끊김'; projectTo(redisPos.clone().add(new THREE.Vector3(0, 7.3, 0)), busLabel); renderer.render(scene, camera); raf = requestAnimationFrame(frame); } raf = requestAnimationFrame(frame); // ── resize ── const onResize = () => { width = mount.clientWidth || width; height = mount.clientHeight || height; camera.aspect = width / height; camera.updateProjectionMatrix(); renderer.setSize(width, height); }; const ro = new ResizeObserver(onResize); ro.observe(mount); // ── cleanup ── return () => { cancelAnimationFrame(raf); ro.disconnect(); disposables.forEach((d) => d.dispose && d.dispose()); renderer.dispose(); if (renderer.domElement.parentNode) renderer.domElement.parentNode.removeChild(renderer.domElement); if (overlay.parentNode) overlay.parentNode.removeChild(overlay); }; }, []); return
; }