iran 백엔드 프록시 잔재 제거: - IranBackendClient dead class 삭제, AppProperties/application.yml iran-backend 블록 제거 - Frontend UI 라벨/주석/system-flow manifest deprecated 마킹 - CLAUDE.md 시스템 구성 다이어그램 최신화 백엔드 계층 분리: - AlertController/MasterDataController/AdminStatsController 에서 repository/JdbcTemplate 직접 주입 제거 - AlertService/MasterDataService/AdminStatsService 신규 계층 도입 + @Transactional(readOnly=true) - Proxy controller 의 @PostConstruct RestClient 생성 → RestClientConfig @Bean 으로 통합 감사 로그 보강: - EnforcementService createRecord/updateRecord/createPlan 에 @Auditable 추가 - VesselAnalysisGroupService.resolveParent 에 PARENT_RESOLVE 액션 기록 카탈로그 정합성: - performanceStatus 를 catalogRegistry 에 등록 (쇼케이스 자동 노출) - alertLevels 확장: isValidAlertLevel / isHighSeverity / getAlertLevelOrder - LiveMapView/DarkVesselDetection 시각 매핑(opacity/radius/tier score) 상수로 추출 - GearIdentification/vesselAnomaly 직접 분기를 타입 가드/헬퍼로 치환
176 lines
6.2 KiB
TypeScript
176 lines
6.2 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 ? `${stats.total.toLocaleString()}건` : '-'],
|
|
['클러스터', stats ? `${stats.clusterCount}` : '-'],
|
|
['어구 그룹', stats ? `${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} color="text-red-400" />
|
|
<RiskBox label="HIGH" value={stats.high} color="text-orange-400" />
|
|
<RiskBox label="MEDIUM" value={stats.medium} color="text-yellow-400" />
|
|
<RiskBox label="LOW" value={stats.low} 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>
|
|
);
|
|
}
|