### SystemStatusPanel TypeError
- 증상: /monitoring 에서 Uncaught TypeError: Cannot read properties of undefined (reading 'toLocaleString')
- 원인: stats 객체는 존재하나 total 필드가 undefined 인 경우 (백엔드 응답이 기대 shape 와 다를 때) 크래시
- 수정: stats?.total != null ? ... / stats.critical ?? 0 식 null-safe 전환 (total/clusterCount/gearGroups/critical/high/medium/low 전부)
### CatalogBadges 렌더링 오류
- 증상: /design-system.html 에서
(1) Each child in a list should have a unique "key" prop
(2) Objects are not valid as a React child (found: object with keys {ko, en})
- 원인: PERFORMANCE_STATUS_META 의 meta 는 {intent, hex, label: {ko, en}} 형식. code 필드 없고 label 이 객체.
- Object.values() + <Trk key={meta.code}> 로 undefined key 중복
- getKoLabel 이 meta.label (객체) 그대로 반환해 Badge children 에 객체 주입
다른 카탈로그는 fallback: {ko, en} 패턴이라 문제 없음 (performanceStatus 만 label 객체)
- 수정:
- Object.entries() 로 순회해 Record key 를 안정적 식별자로 사용
- AnyMeta.label 타입을 string | {ko,en} 확장
- getKoLabel/getEnLabel 우선순위: fallback.ko → label.ko → label(문자열) → code → key
- PERFORMANCE_STATUS_META 자체는 변경 안 함 (admin 페이지들이 label.ko/label.en 직접 참조 중)
### 검증
- npx tsc --noEmit 통과
- pre-commit tsc+ESLint 통과
176 lines
6.3 KiB
TypeScript
176 lines
6.3 KiB
TypeScript
import { useEffect, useState, useCallback } from 'react';
|
|
import { Loader2, RefreshCw, Activity, Database, Wifi } from 'lucide-react';
|
|
import { Card, CardContent } from '@shared/components/ui/card';
|
|
import { Badge } from '@shared/components/ui/badge';
|
|
import type { BadgeIntent } from '@lib/theme/variants';
|
|
import { fetchVesselAnalysis, type VesselAnalysisStats } from '@/services/vesselAnalysisApi';
|
|
|
|
const API_BASE = import.meta.env.VITE_API_URL ?? '/api';
|
|
|
|
interface PredictionHealth {
|
|
status?: string;
|
|
message?: string;
|
|
snpdb?: boolean;
|
|
kcgdb?: boolean;
|
|
store?: { vessels?: number; points?: number; memory_mb?: number; targets?: number; permitted?: number };
|
|
}
|
|
|
|
interface AnalysisStatus {
|
|
timestamp?: string;
|
|
duration_sec?: number;
|
|
vessel_count?: number;
|
|
upserted?: number;
|
|
error?: string | null;
|
|
status?: string;
|
|
}
|
|
|
|
/**
|
|
* 시스템 상태 대시보드 (관제 모니터 카드).
|
|
*
|
|
* 표시:
|
|
* 1. 우리 백엔드 (kcg-ai-backend) 상태
|
|
* 2. 분석 엔진 (prediction) + 분석 사이클
|
|
* 3. 분석 결과 통계 (현재 시점)
|
|
*/
|
|
export function SystemStatusPanel() {
|
|
const [stats, setStats] = useState<VesselAnalysisStats | null>(null);
|
|
const [health, setHealth] = useState<PredictionHealth | null>(null);
|
|
const [analysis, setAnalysis] = useState<AnalysisStatus | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState('');
|
|
|
|
const load = useCallback(async () => {
|
|
setLoading(true); setError('');
|
|
try {
|
|
const [vaRes, healthRes, statusRes] = await Promise.all([
|
|
fetchVesselAnalysis().catch(() => null),
|
|
fetch(`${API_BASE}/prediction/health`, { credentials: 'include' }).then((r) => r.json()).catch(() => null),
|
|
fetch(`${API_BASE}/prediction/status`, { credentials: 'include' }).then((r) => r.json()).catch(() => null),
|
|
]);
|
|
if (vaRes) setStats(vaRes.stats);
|
|
if (healthRes) setHealth(healthRes);
|
|
if (statusRes) setAnalysis(statusRes);
|
|
} catch (e: unknown) {
|
|
setError(e instanceof Error ? e.message : 'unknown');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
load();
|
|
// 30초마다 자동 새로고침
|
|
const timer = setInterval(load, 30000);
|
|
return () => clearInterval(timer);
|
|
}, [load]);
|
|
|
|
return (
|
|
<Card>
|
|
<CardContent className="p-4 space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<div className="text-sm font-bold text-heading flex items-center gap-2">
|
|
<Activity className="w-4 h-4 text-cyan-400" /> 시스템 상태
|
|
<span className="text-[9px] text-hint">(30초 자동 갱신)</span>
|
|
</div>
|
|
<button type="button" onClick={load}
|
|
className="p-1.5 rounded text-hint hover:text-blue-400 hover:bg-surface-overlay" title="새로고침">
|
|
<RefreshCw className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`} />
|
|
</button>
|
|
</div>
|
|
|
|
{error && <div className="text-xs text-red-400">에러: {error}</div>}
|
|
|
|
<div className="grid grid-cols-3 gap-3">
|
|
{/* KCG 백엔드 */}
|
|
<ServiceCard
|
|
icon={<Database className="w-4 h-4" />}
|
|
title="KCG AI Backend"
|
|
status="UP"
|
|
statusIntent="success"
|
|
details={[
|
|
['포트', ':8080'],
|
|
['프로파일', 'local'],
|
|
['DB', 'kcgaidb'],
|
|
]}
|
|
/>
|
|
|
|
{/* 분석 엔진 */}
|
|
<ServiceCard
|
|
icon={<Wifi className="w-4 h-4" />}
|
|
title="AI 분석 엔진"
|
|
status={stats ? 'CONNECTED' : 'DISCONNECTED'}
|
|
statusIntent={stats ? 'success' : 'critical'}
|
|
details={[
|
|
['선박 분석', stats?.total != null ? `${stats.total.toLocaleString()}건` : '-'],
|
|
['클러스터', stats?.clusterCount != null ? `${stats.clusterCount}` : '-'],
|
|
['어구 그룹', stats?.gearGroups != null ? `${stats.gearGroups}` : '-'],
|
|
]}
|
|
/>
|
|
|
|
{/* Prediction */}
|
|
<ServiceCard
|
|
icon={<Activity className="w-4 h-4" />}
|
|
title="Prediction Service"
|
|
status={health?.status || 'UNKNOWN'}
|
|
statusIntent={health?.status === 'ok' ? 'success' : 'warning'}
|
|
details={[
|
|
['SNPDB', health?.snpdb === true ? 'OK' : '-'],
|
|
['KCGDB', health?.kcgdb === true ? 'OK' : '-'],
|
|
['최근 분석', analysis?.duration_sec ? `${analysis.duration_sec}초` : '-'],
|
|
]}
|
|
/>
|
|
</div>
|
|
|
|
{/* 위험도 분포 */}
|
|
{stats && (
|
|
<div className="grid grid-cols-4 gap-2">
|
|
<RiskBox label="CRITICAL" value={stats.critical ?? 0} color="text-red-400" />
|
|
<RiskBox label="HIGH" value={stats.high ?? 0} color="text-orange-400" />
|
|
<RiskBox label="MEDIUM" value={stats.medium ?? 0} color="text-yellow-400" />
|
|
<RiskBox label="LOW" value={stats.low ?? 0} color="text-blue-400" />
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
function ServiceCard({ icon, title, status, statusIntent, details }: {
|
|
icon: React.ReactNode;
|
|
title: string;
|
|
status: string;
|
|
statusIntent: BadgeIntent;
|
|
details: [string, string][];
|
|
}) {
|
|
return (
|
|
<div className="bg-surface-overlay border border-border rounded p-3">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<div className="flex items-center gap-1.5 text-xs text-heading font-medium">
|
|
<span className="text-cyan-400">{icon}</span>
|
|
{title}
|
|
</div>
|
|
<Badge intent={statusIntent} size="xs">
|
|
{status}
|
|
</Badge>
|
|
</div>
|
|
<div className="space-y-0.5">
|
|
{details.map(([k, v]) => (
|
|
<div key={k} className="flex justify-between text-[10px]">
|
|
<span className="text-hint">{k}</span>
|
|
<span className="text-label font-mono">{v}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function RiskBox({ label, value, color }: { label: string; value: number; color: string }) {
|
|
return (
|
|
<div className="px-3 py-2 rounded border border-border bg-surface-overlay">
|
|
<div className="text-[9px] text-hint">{label}</div>
|
|
<div className={`text-lg font-bold ${color}`}>{value.toLocaleString()}</div>
|
|
</div>
|
|
);
|
|
}
|