From 6e415b3e4565a015e524b0b315fb55c139ba0136 Mon Sep 17 00:00:00 2001 From: gahusb Date: Tue, 30 Jun 2026 10:39:08 +0900 Subject: [PATCH] =?UTF-8?q?feat(infra):=20NAS=E2=86=94Windows=20=EC=9B=8C?= =?UTF-8?q?=EC=BB=A4=20=ED=8C=8C=EC=9D=B4=ED=94=84=EB=9D=BC=EC=9D=B8=20?= =?UTF-8?q?=EA=B4=80=EC=B8=A1=20=ED=8E=98=EC=9D=B4=EC=A7=80=20/infra=20(Th?= =?UTF-8?q?ree.js)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 분산 워커 관측 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) Claude-Session: https://claude.ai/code/session_019LV86jBozkNhSFXJA412fq --- src/api.js | 5 + src/pages/infra/InfraMonitor.css | 359 ++++++++++++++++++++++++++ src/pages/infra/InfraMonitor.jsx | 141 ++++++++++ src/pages/infra/InfraMonitor.test.jsx | 38 +++ src/pages/infra/PipelineScene.jsx | 340 ++++++++++++++++++++++++ src/pages/infra/statusVisual.js | 69 +++++ src/pages/infra/statusVisual.test.js | 42 +++ src/pages/infra/useNodeStatus.js | 39 +++ src/pages/infra/useNodeStatus.test.js | 26 ++ src/routes.jsx | 14 + 10 files changed, 1073 insertions(+) create mode 100644 src/pages/infra/InfraMonitor.css create mode 100644 src/pages/infra/InfraMonitor.jsx create mode 100644 src/pages/infra/InfraMonitor.test.jsx create mode 100644 src/pages/infra/PipelineScene.jsx create mode 100644 src/pages/infra/statusVisual.js create mode 100644 src/pages/infra/statusVisual.test.js create mode 100644 src/pages/infra/useNodeStatus.js create mode 100644 src/pages/infra/useNodeStatus.test.js diff --git a/src/api.js b/src/api.js index 75b5c7c..daca5d8 100644 --- a/src/api.js +++ b/src/api.js @@ -14,6 +14,11 @@ export async function apiGet(path) { return res.json(); } +// 분산 워커 관측 — agent-office 집계 상태 (Part B 백엔드) +export async function getNodeStatus() { + return apiGet("/api/agent-office/nodes"); +} + export async function apiDelete(path) { const res = await fetch(toApiUrl(path), { method: "DELETE" }); if (!res.ok) { diff --git a/src/pages/infra/InfraMonitor.css b/src/pages/infra/InfraMonitor.css new file mode 100644 index 0000000..6eceae4 --- /dev/null +++ b/src/pages/infra/InfraMonitor.css @@ -0,0 +1,359 @@ +/* ═══════════════════════════════════════════════════════════════════ + InfraMonitor — NAS↔Windows 워커 파이프라인 관측 콘솔 + 다크 미션컨트롤 / 텔레메트리 미학 (index.css 토큰 재사용) + ═══════════════════════════════════════════════════════════════════ */ + +.infra { + display: flex; + flex-direction: column; + gap: 16px; +} + +/* ── 상태 바 ───────────────────────────────────────────────────────── */ +.infra-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; +} +.infra-bar__stats { + display: flex; + gap: 8px; + flex-wrap: wrap; +} +.infra-chip { + font-family: var(--font-body); + font-size: 12.5px; + letter-spacing: 0.02em; + color: var(--text-dim); + background: var(--surface-card); + border: 1px solid var(--line); + border-radius: 999px; + padding: 6px 12px; + white-space: nowrap; +} +.infra-chip b { + color: var(--text-bright); + font-weight: 700; +} +.infra-chip.is-ok { + color: #00d4ff; + border-color: rgba(0, 212, 255, 0.35); + box-shadow: 0 0 16px rgba(0, 212, 255, 0.12) inset; +} +.infra-chip.is-warn { + color: #fbbf24; + border-color: rgba(251, 191, 36, 0.35); +} +.infra-chip.is-danger { + color: #fb923c; + border-color: rgba(251, 146, 60, 0.4); +} +.infra-chip.is-down { + color: #f43f5e; + border-color: rgba(244, 63, 94, 0.4); + box-shadow: 0 0 16px rgba(244, 63, 94, 0.1) inset; +} + +.infra-bar__actions { + display: flex; + align-items: center; + gap: 10px; +} +.infra-updated { + font-size: 11.5px; + color: var(--text-muted); + font-variant-numeric: tabular-nums; +} +.infra-toggle { + display: inline-flex; + border: 1px solid var(--line); + border-radius: var(--radius-sm); + overflow: hidden; +} +.infra-toggle button { + background: transparent; + border: 0; + color: var(--text-dim); + font-size: 12px; + padding: 6px 12px; + cursor: pointer; + transition: all 0.2s var(--ease-out); +} +.infra-toggle button.is-active { + background: var(--neon-cyan-muted); + color: var(--neon-cyan); +} +.infra-refresh { + background: var(--surface-card); + border: 1px solid var(--line); + color: var(--text-dim); + border-radius: var(--radius-sm); + width: 34px; + height: 32px; + font-size: 16px; + cursor: pointer; + transition: all 0.2s var(--ease-out); +} +.infra-refresh:hover { + color: var(--neon-cyan); + border-color: var(--line-bright); + transform: rotate(90deg); +} + +/* ── 에러 / 경고 / 로딩 ────────────────────────────────────────────── */ +.infra-error { + display: flex; + align-items: center; + gap: 14px; + padding: 16px 20px; + background: rgba(244, 63, 94, 0.08); + border: 1px solid rgba(244, 63, 94, 0.3); + border-radius: var(--radius-md); + color: var(--text); +} +.infra-error b { + color: #f43f5e; +} +.infra-error span { + color: var(--text-dim); + font-size: 13px; + flex: 1; +} +.infra-error button { + background: rgba(244, 63, 94, 0.18); + border: 1px solid rgba(244, 63, 94, 0.4); + color: #ffd2da; + border-radius: var(--radius-sm); + padding: 6px 14px; + cursor: pointer; +} +.infra-warn-banner { + padding: 12px 18px; + background: rgba(244, 63, 94, 0.1); + border: 1px solid rgba(244, 63, 94, 0.28); + border-radius: var(--radius-md); + color: #ffb3bf; + font-size: 13.5px; +} +.infra-loading { + padding: 40px; + text-align: center; + color: var(--text-dim); + font-family: var(--font-display); + letter-spacing: 0.04em; +} + +/* ── 3D 스테이지 ───────────────────────────────────────────────────── */ +.infra-stage { + position: relative; + border: 1px solid var(--line); + border-radius: var(--radius-lg); + overflow: hidden; + background: + radial-gradient(ellipse 90% 60% at 20% 0%, rgba(0, 212, 255, 0.08) 0%, transparent 60%), + radial-gradient(ellipse 80% 60% at 85% 100%, rgba(139, 92, 246, 0.07) 0%, transparent 60%), + linear-gradient(180deg, #060a16 0%, #04060f 100%); + box-shadow: var(--shadow-card); +} +.infra-stage::before { + /* 미세 그리드 텍스처 */ + content: ''; + position: absolute; + inset: 0; + background-image: linear-gradient(rgba(0, 212, 255, 0.04) 1px, transparent 1px), + linear-gradient(90deg, rgba(0, 212, 255, 0.04) 1px, transparent 1px); + background-size: 40px 40px; + mask-image: radial-gradient(ellipse 100% 80% at 50% 50%, #000 40%, transparent 90%); + pointer-events: none; +} +.pipeline-canvas { + position: relative; + width: 100%; + height: 58vh; + min-height: 440px; +} +.pipeline-labels { + position: absolute; + inset: 0; + pointer-events: none; + overflow: hidden; +} +.pipeline-label { + position: absolute; + top: 0; + left: 0; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 3px 9px; + background: rgba(6, 10, 22, 0.78); + border: 1px solid color-mix(in srgb, var(--pl-color, #00d4ff) 45%, transparent); + border-radius: 999px; + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); + white-space: nowrap; + will-change: transform; + transition: opacity 0.3s; +} +.pipeline-label .pl-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--pl-color, #00d4ff); + box-shadow: 0 0 8px var(--pl-color, #00d4ff); +} +.pipeline-label .pl-name { + font-family: var(--font-display); + font-size: 11.5px; + font-weight: 600; + color: var(--text-bright); + letter-spacing: 0.01em; +} +.pipeline-label .pl-state { + font-size: 10.5px; + color: var(--pl-color, #8892b0); + font-variant-numeric: tabular-nums; +} +.pipeline-label--anchor .pl-name { + color: var(--pl-color, #e8f0fe); +} + +.infra-legend { + position: absolute; + bottom: 12px; + left: 14px; + display: flex; + gap: 14px; + flex-wrap: wrap; + padding: 6px 12px; + background: rgba(6, 10, 22, 0.6); + border: 1px solid var(--line); + border-radius: 999px; + font-size: 11px; + color: var(--text-dim); +} +.infra-legend span { + display: inline-flex; + align-items: center; + gap: 6px; +} +.infra-legend i { + width: 9px; + height: 9px; + border-radius: 2px; + display: inline-block; +} + +/* ── 워커 카드 그리드 ──────────────────────────────────────────────── */ +.infra-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); + gap: 12px; +} +.infra-grid--compact { + grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); +} +.infra-card { + position: relative; + background: var(--surface-card); + border: 1px solid var(--line); + border-left: 3px solid var(--c, #4a5572); + border-radius: var(--radius-md); + padding: 14px 16px; + box-shadow: var(--shadow-sm); + transition: transform 0.2s var(--ease-out), border-color 0.2s; +} +.infra-card:hover { + transform: translateY(-2px); + border-color: color-mix(in srgb, var(--c) 40%, var(--line)); +} +.infra-card--down { + opacity: 0.72; +} +.infra-card__head { + display: flex; + align-items: center; + gap: 9px; + margin-bottom: 12px; +} +.infra-card__dot { + width: 9px; + height: 9px; + border-radius: 50%; + background: var(--c); + box-shadow: 0 0 10px var(--c); + flex-shrink: 0; +} +.infra-card__id { + flex: 1; + min-width: 0; +} +.infra-card__title { + font-family: var(--font-display); + font-size: 14px; + font-weight: 600; + color: var(--text-bright); +} +.infra-card__kind { + font-size: 10.5px; + color: var(--text-muted); + letter-spacing: 0.03em; +} +.infra-card__state { + font-size: 11px; + font-weight: 600; + color: var(--c); + padding: 3px 9px; + border: 1px solid color-mix(in srgb, var(--c) 35%, transparent); + border-radius: 999px; + white-space: nowrap; +} +.infra-card__metrics { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 6px; + margin-bottom: 10px; +} +.infra-metric { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + padding: 7px 4px; + background: rgba(255, 255, 255, 0.02); + border-radius: var(--radius-xs); +} +.infra-metric__v { + font-family: var(--font-display); + font-size: 16px; + font-weight: 700; + color: var(--text-bright); + font-variant-numeric: tabular-nums; +} +.infra-metric__l { + font-size: 10px; + color: var(--text-muted); +} +.infra-metric--warn .infra-metric__v { + color: #fbbf24; +} +.infra-metric--danger .infra-metric__v { + color: #f43f5e; +} +.infra-card__foot { + font-size: 11px; + color: var(--text-dim); + font-variant-numeric: tabular-nums; +} + +@media (max-width: 768px) { + .pipeline-canvas { + height: 46vh; + min-height: 340px; + } + .infra-bar { + gap: 10px; + } +} diff --git a/src/pages/infra/InfraMonitor.jsx b/src/pages/infra/InfraMonitor.jsx new file mode 100644 index 0000000..45358b2 --- /dev/null +++ b/src/pages/infra/InfraMonitor.jsx @@ -0,0 +1,141 @@ +// src/pages/infra/InfraMonitor.jsx +// /infra — NAS↔Windows 분산 워커 파이프라인 실시간 관측. +// 3D 파이프라인(Three.js) + 2D 워커 카드. WebGL 미지원 시 카드만. +import { useMemo, useState } from 'react'; +import { useNodeStatus } from './useNodeStatus'; +import PipelineScene from './PipelineScene'; +import { workerStateLabel, workerColor, workerTitle, kindLabel } from './statusVisual'; +import './InfraMonitor.css'; + +function hasWebGL() { + try { + const c = document.createElement('canvas'); + return !!(window.WebGLRenderingContext && (c.getContext('webgl') || c.getContext('experimental-webgl'))); + } catch { + return false; + } +} + +function Metric({ label, value, tone }) { + return ( +
+ {value ?? 0} + {label} +
+ ); +} + +function WorkerCard({ w }) { + const color = workerColor(w); + return ( +
+
+ +
+
{workerTitle(w.name)}
+
{kindLabel(w.kind)}
+
+ {workerStateLabel(w)} +
+
+ 0 ? 'warn' : null} /> + 0 ? 'danger' : null} /> + + +
+
+ {w.alive + ? `last beat ${w.last_beat_age_s ?? '?'}s 전` + : '비콘 없음 (오프라인)'} + {w.jobs_failed > 0 ? ` · 누적 실패 ${w.jobs_failed}` : ''} +
+
+ ); +} + +export default function InfraMonitor() { + const { data, error, loading, updatedAt, refresh } = useNodeStatus(3000); + const webgl = useMemo(() => hasWebGL(), []); + const [view, setView] = useState(webgl ? '3d' : 'grid'); + + const workers = data?.workers || []; + const online = workers.filter((w) => w.alive).length; + const total = workers.length; + const deadLetters = workers.reduce((a, w) => a + (w.dead_letter || 0), 0); + const redisOk = data ? data.redis_ok : null; + + return ( +
+
+
+ 0 ? 'is-ok' : online > 0 ? 'is-warn' : 'is-down'}`}> + {online}/{total || '–'} 온라인 + + + Redis {redisOk === false ? '끊김' : redisOk ? '정상' : '…'} + + {data?.paused && ( + + ⏸ 일시정지{data.paused_reason ? ` (${data.paused_reason})` : ''} + + )} + {deadLetters > 0 && ❌ 실패 {deadLetters}} +
+
+ {updatedAt && ( + + {new Date(updatedAt).toLocaleTimeString('ko-KR')} 갱신 + + )} + {webgl && ( +
+ + +
+ )} + +
+
+ + {error && !data && ( +
+ 집계 서버 연결 끊김 + {String(error.message || error)} + +
+ )} + + {redisOk === false && ( +
+ ⚠ Redis 버스 연결이 끊겨 모든 워커 상태를 읽을 수 없습니다. 파이프라인이 전면 중단 상태입니다. +
+ )} + + {loading && !data &&
노드 상태 수집 중…
} + + {view === '3d' && webgl && ( +
+ +
+ 정상·흐름 + 일시정지 + 실패누적 + 다운·끊김 +
+
+ )} + +
+ {workers.map((w) => ( + + ))} +
+
+ ); +} diff --git a/src/pages/infra/InfraMonitor.test.jsx b/src/pages/infra/InfraMonitor.test.jsx new file mode 100644 index 0000000..79a050f --- /dev/null +++ b/src/pages/infra/InfraMonitor.test.jsx @@ -0,0 +1,38 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { getNodeStatus } from '../../api'; +import InfraMonitor from './InfraMonitor'; + +vi.mock('../../api', () => ({ getNodeStatus: vi.fn() })); + +const sample = { + redis_ok: true, + paused: false, + paused_reason: null, + workers: [ + { name: 'image-render', kind: 'render', alive: true, state: 'idle', queue_depth: 0, dead_letter: 0, processing: 0, jobs_done: 5, jobs_failed: 0, last_beat_age_s: 3 }, + { name: 'insta-render', kind: 'render', alive: false, state: null, queue_depth: 3, dead_letter: 0, processing: 0, jobs_done: 0, jobs_failed: 0, last_beat_age_s: null }, + ], + links: [], +}; + +describe('InfraMonitor', () => { + beforeEach(() => vi.clearAllMocks()); + + it('renders worker cards from /nodes (grid mode in jsdom — no WebGL)', async () => { + getNodeStatus.mockResolvedValue(sample); + render(); + await waitFor(() => expect(screen.getByText('Image Render')).toBeInTheDocument()); + expect(screen.getByText('Insta Render')).toBeInTheDocument(); + // alive 워커(image-render, idle)는 '대기' 상태 라벨 + expect(screen.getByText('대기')).toBeInTheDocument(); + // 오프라인 워커(insta-render)는 '오프라인' 라벨 + expect(screen.getByText('오프라인')).toBeInTheDocument(); + }); + + it('shows error state when /nodes fails', async () => { + getNodeStatus.mockRejectedValue(new Error('down')); + render(); + await waitFor(() => expect(screen.getByText('집계 서버 연결 끊김')).toBeInTheDocument()); + }); +}); diff --git a/src/pages/infra/PipelineScene.jsx b/src/pages/infra/PipelineScene.jsx new file mode 100644 index 0000000..b748523 --- /dev/null +++ b/src/pages/infra/PipelineScene.jsx @@ -0,0 +1,340 @@ +// 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
; +} diff --git a/src/pages/infra/statusVisual.js b/src/pages/infra/statusVisual.js new file mode 100644 index 0000000..324281b --- /dev/null +++ b/src/pages/infra/statusVisual.js @@ -0,0 +1,69 @@ +// src/pages/infra/statusVisual.js +// 상태 → 색/라벨 매핑. 2D 패널과 Three.js 파이프라인이 공유하는 단일 진실원천. +// 색은 index.css 테마 팔레트와 일치(neon-cyan healthy, amber paused, orange degraded, red down). + +export const LINK_COLORS = { + healthy: '#00d4ff', // neon-cyan — 통신이 흐름 + paused: '#fbbf24', // amber — 작업중(트레이딩) 일시정지 + degraded: '#fb923c', // orange — dead-letter 누적 + down: '#f43f5e', // red — 워커 다운/링크 끊김 +}; + +const NEUTRAL = '#4a5572'; + +export function linkColor(status) { + return LINK_COLORS[status] || NEUTRAL; +} + +// 워커 객체 → 사람이 읽는 상태 라벨 +export function workerStateLabel(w) { + if (!w || !w.alive) return '오프라인'; + switch (w.state) { + case 'paused': + return '일시정지'; + case 'busy': + return '처리 중'; + case 'idle': + return '대기'; + case 'market_open': + return '장중'; + case 'market_closed': + return '휴장'; + default: + return '온라인'; + } +} + +// 워커 객체 → 링크 status 도출(2D/3D 공통). collect_status의 link 산정과 동일 규칙. +export function workerStatus(w) { + if (!w || !w.alive) return 'down'; + if (w.state === 'paused') return 'paused'; + if ((w.dead_letter || 0) > 0) return 'degraded'; + return 'healthy'; +} + +export function workerColor(w) { + return linkColor(workerStatus(w)); +} + +// 워커 내부명 → 표시 타이틀 +export const WORKER_TITLES = { + 'music-render': 'Music Render', + 'video-render': 'Video Render', + 'image-render': 'Image Render', + 'insta-render': 'Insta Render', + 'task-watcher': 'Task Watcher', + ai_trade: 'AI Trade', +}; + +export function workerTitle(name) { + return WORKER_TITLES[name] || name; +} + +// kind → 한 줄 역할 +export function kindLabel(kind) { + if (kind === 'render') return '렌더 워커'; + if (kind === 'watcher') return '작업 감시'; + if (kind === 'trader') return '트레이딩'; + return kind || ''; +} diff --git a/src/pages/infra/statusVisual.test.js b/src/pages/infra/statusVisual.test.js new file mode 100644 index 0000000..33ea250 --- /dev/null +++ b/src/pages/infra/statusVisual.test.js @@ -0,0 +1,42 @@ +import { describe, it, expect } from 'vitest'; +import { linkColor, workerStateLabel, workerStatus, workerColor, workerTitle } from './statusVisual'; + +describe('statusVisual', () => { + it('maps link status to theme colors', () => { + expect(linkColor('healthy')).toBe('#00d4ff'); + expect(linkColor('paused')).toBe('#fbbf24'); + expect(linkColor('degraded')).toBe('#fb923c'); + expect(linkColor('down')).toBe('#f43f5e'); + expect(linkColor('???')).toBe('#4a5572'); + }); + + it('labels a dead worker offline', () => { + expect(workerStateLabel({ alive: false })).toBe('오프라인'); + expect(workerStateLabel(null)).toBe('오프라인'); + }); + + it('labels alive workers by state', () => { + expect(workerStateLabel({ alive: true, state: 'idle' })).toBe('대기'); + expect(workerStateLabel({ alive: true, state: 'busy' })).toBe('처리 중'); + expect(workerStateLabel({ alive: true, state: 'paused' })).toBe('일시정지'); + expect(workerStateLabel({ alive: true, state: 'market_open' })).toBe('장중'); + }); + + it('derives worker status with dead-letter and paused precedence', () => { + expect(workerStatus({ alive: false })).toBe('down'); + expect(workerStatus({ alive: true, state: 'paused' })).toBe('paused'); + expect(workerStatus({ alive: true, state: 'idle', dead_letter: 3 })).toBe('degraded'); + expect(workerStatus({ alive: true, state: 'idle', dead_letter: 0 })).toBe('healthy'); + }); + + it('workerColor follows workerStatus', () => { + expect(workerColor({ alive: false })).toBe('#f43f5e'); + expect(workerColor({ alive: true, state: 'idle' })).toBe('#00d4ff'); + }); + + it('humanizes worker names', () => { + expect(workerTitle('insta-render')).toBe('Insta Render'); + expect(workerTitle('ai_trade')).toBe('AI Trade'); + expect(workerTitle('unknown-x')).toBe('unknown-x'); + }); +}); diff --git a/src/pages/infra/useNodeStatus.js b/src/pages/infra/useNodeStatus.js new file mode 100644 index 0000000..02388f1 --- /dev/null +++ b/src/pages/infra/useNodeStatus.js @@ -0,0 +1,39 @@ +// src/pages/infra/useNodeStatus.js +// /api/agent-office/nodes 를 주기 폴링하는 훅. 3초 권장(Three.js 흐름과 동기). +import { useEffect, useState, useRef, useCallback } from 'react'; +import { getNodeStatus } from '../../api'; + +export function useNodeStatus(intervalMs = 4000) { + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + const [updatedAt, setUpdatedAt] = useState(null); + const aliveRef = useRef(true); + + const tick = useCallback(async () => { + try { + const d = await getNodeStatus(); + if (!aliveRef.current) return; + setData(d); + setError(null); + setUpdatedAt(Date.now()); + } catch (e) { + if (!aliveRef.current) return; + setError(e); + } finally { + if (aliveRef.current) setLoading(false); + } + }, []); + + useEffect(() => { + aliveRef.current = true; + tick(); + const id = setInterval(tick, intervalMs); + return () => { + aliveRef.current = false; + clearInterval(id); + }; + }, [tick, intervalMs]); + + return { data, error, loading, updatedAt, refresh: tick }; +} diff --git a/src/pages/infra/useNodeStatus.test.js b/src/pages/infra/useNodeStatus.test.js new file mode 100644 index 0000000..86ddc10 --- /dev/null +++ b/src/pages/infra/useNodeStatus.test.js @@ -0,0 +1,26 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { useNodeStatus } from './useNodeStatus'; +import { getNodeStatus } from '../../api'; + +vi.mock('../../api', () => ({ getNodeStatus: vi.fn() })); + +describe('useNodeStatus', () => { + beforeEach(() => vi.clearAllMocks()); + + it('fetches node status on mount', async () => { + getNodeStatus.mockResolvedValue({ redis_ok: true, workers: [], links: [] }); + const { result } = renderHook(() => useNodeStatus(100000)); + await waitFor(() => expect(result.current.data).toBeTruthy()); + expect(result.current.data.redis_ok).toBe(true); + expect(result.current.loading).toBe(false); + expect(result.current.error).toBeNull(); + }); + + it('captures fetch error', async () => { + getNodeStatus.mockRejectedValue(new Error('boom')); + const { result } = renderHook(() => useNodeStatus(100000)); + await waitFor(() => expect(result.current.error).toBeTruthy()); + expect(result.current.error.message).toBe('boom'); + }); +}); diff --git a/src/routes.jsx b/src/routes.jsx index 58957cb..9532f3d 100644 --- a/src/routes.jsx +++ b/src/routes.jsx @@ -41,6 +41,7 @@ const SajuToday = lazy(() => import('./pages/saju/Today')); const Compatibility = lazy(() => import('./pages/saju/Compatibility')); const CompatibilityResult = lazy(() => import('./pages/saju/CompatibilityResult')); const SajuMe = lazy(() => import('./pages/saju/Me')); +const InfraMonitor = lazy(() => import('./pages/infra/InfraMonitor')); export const navLinks = [ { @@ -142,6 +143,15 @@ export const navLinks = [ icon: 🏢, accent: '#8b5cf6', }, + { + id: 'infra', + label: 'Infra', + path: '/infra', + subtitle: 'NODE PIPELINE', + description: 'NAS↔Windows 워커 파이프라인 실시간 관측', + icon: 🛰️, + accent: '#22d3ee', + }, { id: 'lab', label: 'Lab', @@ -240,6 +250,10 @@ export const appRoutes = [ path: 'agent-office', lazy: () => import('./pages/agent-office/AgentOffice'), }, + { + path: 'infra', + element: , + }, { path: 'tarot', element: ,