분산 워커 관측 Part C — useNodeStatus 3초 폴링 훅 + statusVisual 색/라벨 매핑 + 2D 워커 카드 그리드 + raw three.js 파이프라인 시각화(정상=시안 파티클 흐름 / busy=가속 / paused=앰버 정지 / degraded=주황 / down=빨강 끊김, Redis 끊김=버스 빨강). GET /api/agent-office/nodes(Part B) 소비. r3f 대신 기설치 three 직접 사용. WebGL 미지원 시 카드 폴백 + 3D/그리드 토글. vitest 10 passed, build OK. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019LV86jBozkNhSFXJA412fq
341 lines
14 KiB
JavaScript
341 lines
14 KiB
JavaScript
// 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 = `<span class="pl-dot"></span><span class="pl-name">${title}</span><span class="pl-state">${sub}</span>`;
|
|
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 <div ref={mountRef} className="pipeline-canvas" />;
|
|
}
|