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: ,