kcg-ai-monitoring/frontend/src/features/monitoring/SystemStatusPanel.tsx
htlee 2483174081 refactor(frontend): Badge className 위반 37건 전수 제거
- 4개 catalog(eventStatuses/enforcementResults/enforcementActions/patrolStatuses)에
  intent 필드 추가 + getXxxIntent() 헬퍼 신규
- statusIntent.ts 공통 유틸: 한글/영문 상태 문자열 → BadgeIntent 매핑
  + getRiskIntent(0-100) 점수 기반 매핑
- 모든 Badge className="..." 패턴을 intent prop으로 치환:
  - admin (AuditLogs/AccessControl/SystemConfig/NoticeManagement/DataHub)
  - ai-operations (AIModelManagement/MLOpsPage)
  - enforcement (EventList/EnforcementHistory)
  - field-ops (AIAlert)
  - detection (GearIdentification)
  - patrol (PatrolRoute/FleetOptimization)
  - parent-inference (ParentExclusion)
  - statistics (ExternalService/ReportManagement)
  - surveillance (MapControl)
  - risk-assessment (EnforcementPlan)
  - monitoring (SystemStatusPanel — ServiceCard statusColor → statusIntent 리팩토)
  - dashboard (Dashboard PatrolStatusBadge)

이제 Badge의 테마별 팔레트(라이트 파스텔 + 다크 translucent)가 자동 적용되며,
쇼케이스에서 palette 조정 시 모든 Badge 사용처에 일관되게 반영됨.
2026-04-08 12:28:23 +09:00

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. iran 백엔드 + 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'],
]}
/>
{/* iran 백엔드 */}
<ServiceCard
icon={<Wifi className="w-4 h-4" />}
title="iran 백엔드 (분석)"
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>
);
}