feat(infra): NAS↔Windows 워커 파이프라인 관측 페이지 /infra (Three.js)
분산 워커 관측 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
This commit is contained in:
@@ -14,6 +14,11 @@ export async function apiGet(path) {
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 분산 워커 관측 — agent-office 집계 상태 (Part B 백엔드)
|
||||||
|
export async function getNodeStatus() {
|
||||||
|
return apiGet("/api/agent-office/nodes");
|
||||||
|
}
|
||||||
|
|
||||||
export async function apiDelete(path) {
|
export async function apiDelete(path) {
|
||||||
const res = await fetch(toApiUrl(path), { method: "DELETE" });
|
const res = await fetch(toApiUrl(path), { method: "DELETE" });
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|||||||
359
src/pages/infra/InfraMonitor.css
Normal file
359
src/pages/infra/InfraMonitor.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
141
src/pages/infra/InfraMonitor.jsx
Normal file
141
src/pages/infra/InfraMonitor.jsx
Normal file
@@ -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 (
|
||||||
|
<div className={`infra-metric${tone ? ` infra-metric--${tone}` : ''}`}>
|
||||||
|
<span className="infra-metric__v">{value ?? 0}</span>
|
||||||
|
<span className="infra-metric__l">{label}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WorkerCard({ w }) {
|
||||||
|
const color = workerColor(w);
|
||||||
|
return (
|
||||||
|
<div className={`infra-card${w.alive ? '' : ' infra-card--down'}`} style={{ '--c': color }}>
|
||||||
|
<div className="infra-card__head">
|
||||||
|
<span className="infra-card__dot" />
|
||||||
|
<div className="infra-card__id">
|
||||||
|
<div className="infra-card__title">{workerTitle(w.name)}</div>
|
||||||
|
<div className="infra-card__kind">{kindLabel(w.kind)}</div>
|
||||||
|
</div>
|
||||||
|
<span className="infra-card__state">{workerStateLabel(w)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="infra-card__metrics">
|
||||||
|
<Metric label="큐" value={w.queue_depth} tone={w.queue_depth > 0 ? 'warn' : null} />
|
||||||
|
<Metric label="실패" value={w.dead_letter} tone={w.dead_letter > 0 ? 'danger' : null} />
|
||||||
|
<Metric label="처리중" value={w.processing} />
|
||||||
|
<Metric label="완료" value={w.jobs_done} />
|
||||||
|
</div>
|
||||||
|
<div className="infra-card__foot">
|
||||||
|
{w.alive
|
||||||
|
? `last beat ${w.last_beat_age_s ?? '?'}s 전`
|
||||||
|
: '비콘 없음 (오프라인)'}
|
||||||
|
{w.jobs_failed > 0 ? ` · 누적 실패 ${w.jobs_failed}` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="infra">
|
||||||
|
<div className="infra-bar">
|
||||||
|
<div className="infra-bar__stats">
|
||||||
|
<span className={`infra-chip ${online === total && total > 0 ? 'is-ok' : online > 0 ? 'is-warn' : 'is-down'}`}>
|
||||||
|
<b>{online}</b>/{total || '–'} 온라인
|
||||||
|
</span>
|
||||||
|
<span className={`infra-chip ${redisOk === false ? 'is-down' : redisOk ? 'is-ok' : ''}`}>
|
||||||
|
Redis {redisOk === false ? '끊김' : redisOk ? '정상' : '…'}
|
||||||
|
</span>
|
||||||
|
{data?.paused && (
|
||||||
|
<span className="infra-chip is-warn">
|
||||||
|
⏸ 일시정지{data.paused_reason ? ` (${data.paused_reason})` : ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{deadLetters > 0 && <span className="infra-chip is-danger">❌ 실패 {deadLetters}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="infra-bar__actions">
|
||||||
|
{updatedAt && (
|
||||||
|
<span className="infra-updated">
|
||||||
|
{new Date(updatedAt).toLocaleTimeString('ko-KR')} 갱신
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{webgl && (
|
||||||
|
<div className="infra-toggle">
|
||||||
|
<button className={view === '3d' ? 'is-active' : ''} onClick={() => setView('3d')}>
|
||||||
|
3D
|
||||||
|
</button>
|
||||||
|
<button className={view === 'grid' ? 'is-active' : ''} onClick={() => setView('grid')}>
|
||||||
|
그리드
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button className="infra-refresh" onClick={refresh} title="새로고침">
|
||||||
|
↻
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && !data && (
|
||||||
|
<div className="infra-error">
|
||||||
|
<b>집계 서버 연결 끊김</b>
|
||||||
|
<span>{String(error.message || error)}</span>
|
||||||
|
<button onClick={refresh}>다시 시도</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{redisOk === false && (
|
||||||
|
<div className="infra-warn-banner">
|
||||||
|
⚠ Redis 버스 연결이 끊겨 모든 워커 상태를 읽을 수 없습니다. 파이프라인이 전면 중단 상태입니다.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading && !data && <div className="infra-loading">노드 상태 수집 중…</div>}
|
||||||
|
|
||||||
|
{view === '3d' && webgl && (
|
||||||
|
<div className="infra-stage">
|
||||||
|
<PipelineScene status={data} />
|
||||||
|
<div className="infra-legend">
|
||||||
|
<span><i style={{ background: '#00d4ff' }} /> 정상·흐름</span>
|
||||||
|
<span><i style={{ background: '#fbbf24' }} /> 일시정지</span>
|
||||||
|
<span><i style={{ background: '#fb923c' }} /> 실패누적</span>
|
||||||
|
<span><i style={{ background: '#f43f5e' }} /> 다운·끊김</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={`infra-grid${view === '3d' ? ' infra-grid--compact' : ''}`}>
|
||||||
|
{workers.map((w) => (
|
||||||
|
<WorkerCard key={w.name} w={w} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
src/pages/infra/InfraMonitor.test.jsx
Normal file
38
src/pages/infra/InfraMonitor.test.jsx
Normal file
@@ -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(<InfraMonitor />);
|
||||||
|
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(<InfraMonitor />);
|
||||||
|
await waitFor(() => expect(screen.getByText('집계 서버 연결 끊김')).toBeInTheDocument());
|
||||||
|
});
|
||||||
|
});
|
||||||
340
src/pages/infra/PipelineScene.jsx
Normal file
340
src/pages/infra/PipelineScene.jsx
Normal file
@@ -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 = `<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" />;
|
||||||
|
}
|
||||||
69
src/pages/infra/statusVisual.js
Normal file
69
src/pages/infra/statusVisual.js
Normal file
@@ -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 || '';
|
||||||
|
}
|
||||||
42
src/pages/infra/statusVisual.test.js
Normal file
42
src/pages/infra/statusVisual.test.js
Normal file
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
39
src/pages/infra/useNodeStatus.js
Normal file
39
src/pages/infra/useNodeStatus.js
Normal file
@@ -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 };
|
||||||
|
}
|
||||||
26
src/pages/infra/useNodeStatus.test.js
Normal file
26
src/pages/infra/useNodeStatus.test.js
Normal file
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -41,6 +41,7 @@ const SajuToday = lazy(() => import('./pages/saju/Today'));
|
|||||||
const Compatibility = lazy(() => import('./pages/saju/Compatibility'));
|
const Compatibility = lazy(() => import('./pages/saju/Compatibility'));
|
||||||
const CompatibilityResult = lazy(() => import('./pages/saju/CompatibilityResult'));
|
const CompatibilityResult = lazy(() => import('./pages/saju/CompatibilityResult'));
|
||||||
const SajuMe = lazy(() => import('./pages/saju/Me'));
|
const SajuMe = lazy(() => import('./pages/saju/Me'));
|
||||||
|
const InfraMonitor = lazy(() => import('./pages/infra/InfraMonitor'));
|
||||||
|
|
||||||
export const navLinks = [
|
export const navLinks = [
|
||||||
{
|
{
|
||||||
@@ -142,6 +143,15 @@ export const navLinks = [
|
|||||||
icon: <span style={{fontSize:'1.2em'}}>🏢</span>,
|
icon: <span style={{fontSize:'1.2em'}}>🏢</span>,
|
||||||
accent: '#8b5cf6',
|
accent: '#8b5cf6',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'infra',
|
||||||
|
label: 'Infra',
|
||||||
|
path: '/infra',
|
||||||
|
subtitle: 'NODE PIPELINE',
|
||||||
|
description: 'NAS↔Windows 워커 파이프라인 실시간 관측',
|
||||||
|
icon: <span style={{fontSize:'1.2em'}}>🛰️</span>,
|
||||||
|
accent: '#22d3ee',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'lab',
|
id: 'lab',
|
||||||
label: 'Lab',
|
label: 'Lab',
|
||||||
@@ -240,6 +250,10 @@ export const appRoutes = [
|
|||||||
path: 'agent-office',
|
path: 'agent-office',
|
||||||
lazy: () => import('./pages/agent-office/AgentOffice'),
|
lazy: () => import('./pages/agent-office/AgentOffice'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'infra',
|
||||||
|
element: <InfraMonitor />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'tarot',
|
path: 'tarot',
|
||||||
element: <Tarot />,
|
element: <Tarot />,
|
||||||
|
|||||||
Reference in New Issue
Block a user