kcg-ai-monitoring/frontend/src/features/monitoring/MonitoringDashboard.tsx
htlee 9251d7593c refactor: 프로젝트 뼈대 정리 — iran 잔재 제거 + 백엔드 계층 분리 + 카탈로그 등록
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 직접 분기를 타입 가드/헬퍼로 치환
2026-04-16 16:18:18 +09:00

134 lines
6.2 KiB
TypeScript

import { useEffect, useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { PageContainer, PageHeader } from '@shared/components/layout';
import { Activity, AlertTriangle, Ship, Eye, Anchor, Radar, Shield, Bell, Clock, Target, ChevronRight } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { AreaChart, PieChart } from '@lib/charts';
import { useKpiStore } from '@stores/kpiStore';
import { useEventStore } from '@stores/eventStore';
import { getHourlyStats, type PredictionStatsHourly } from '@/services/kpi';
import { getKpiUi } from '@shared/constants/kpiUiMap';
import { formatDateTime } from '@shared/utils/dateFormat';
import { getViolationColor, getViolationLabel } from '@shared/constants/violationTypes';
import { type AlertLevel, getAlertLevelLabel, getAlertLevelIntent } from '@shared/constants/alertLevels';
import { useSettingsStore } from '@stores/settingsStore';
import { SystemStatusPanel } from './SystemStatusPanel';
/* SFR-12: 모니터링 및 경보 현황판(대시보드) */
// KPI UI 매핑 (icon, color는 store에 없으므로 라벨 기반 매핑)
// KPI_UI_MAP은 shared/constants/kpiUiMap 공통 모듈 사용
export function MonitoringDashboard() {
const { t } = useTranslation('dashboard');
const { t: tc } = useTranslation('common');
const lang = useSettingsStore((s) => s.language);
const kpiStore = useKpiStore();
const eventStore = useEventStore();
const [hourlyStats, setHourlyStats] = useState<PredictionStatsHourly[]>([]);
useEffect(() => { if (!kpiStore.loaded) kpiStore.load(); }, [kpiStore.loaded, kpiStore.load]);
useEffect(() => { if (!eventStore.loaded) eventStore.load(); }, [eventStore.loaded, eventStore.load]);
useEffect(() => {
getHourlyStats(24).then(setHourlyStats).catch(() => setHourlyStats([]));
}, []);
// 24시간 위험도/경보 추이: hourly stats → 차트 데이터
const TREND = useMemo(() => hourlyStats.map((h) => {
const hourLabel = h.statHour ? `${new Date(h.statHour).getHours()}` : '';
// 위험도 점수: byRiskLevel 가중합 (CRITICAL=100, HIGH=70, MEDIUM=40, LOW=10) 정규화
let riskScore = 0;
let total = 0;
if (h.byRiskLevel) {
const weights: Record<string, number> = { CRITICAL: 100, HIGH: 70, MEDIUM: 40, LOW: 10 };
Object.entries(h.byRiskLevel).forEach(([k, v]) => {
const cnt = Number(v) || 0;
riskScore += (weights[k.toUpperCase()] ?? 0) * cnt;
total += cnt;
});
}
const risk = total > 0 ? Math.round(riskScore / total) : 0;
return {
h: hourLabel,
risk,
alarms: (h.eventCount ?? 0) + (h.criticalCount ?? 0),
};
}), [hourlyStats]);
// KPI: store metrics + UI 매핑
const KPI = kpiStore.metrics.map((m) => ({
label: m.label,
value: m.value,
icon: getKpiUi(m.label).icon,
color: getKpiUi(m.label).color,
}));
// PIE: store violationTypes → 공통 카탈로그 기반 라벨/색상
const PIE = kpiStore.violationTypes.map((v) => ({
name: getViolationLabel(v.type, tc, lang),
value: v.pct,
color: getViolationColor(v.type),
}));
// 이벤트: store events → 첫 6개, time은 KST로 포맷
const EVENTS = eventStore.events.slice(0, 6).map((e) => ({
time: formatDateTime(e.time),
level: e.level,
title: e.title,
detail: e.detail,
}));
return (
<PageContainer>
<PageHeader
icon={Activity}
iconColor="text-green-400"
title={t('monitoring.title')}
description={t('monitoring.desc')}
/>
{/* 백엔드 + prediction 분석 엔진 시스템 상태 (실시간) */}
<SystemStatusPanel />
<div className="flex gap-2">
{KPI.map(k => (
<div key={k.label} className="flex-1 flex items-center gap-2 px-3 py-2 rounded-xl border border-border bg-card">
<div className="p-1.5 rounded-lg" style={{ backgroundColor: `${k.color}15` }}><k.icon className="w-4 h-4" style={{ color: k.color }} /></div>
<span className="text-lg font-bold text-heading">{k.value}</span>
<span className="text-[9px] text-hint">{k.label}</span>
</div>
))}
</div>
<div className="grid grid-cols-3 gap-3">
<Card className="col-span-2 bg-surface-raised border-border"><CardContent className="p-4">
<div className="text-[12px] font-bold text-label mb-3">24 · </div>
<AreaChart data={TREND} xKey="h" height={200} series={[{ key: 'risk', name: '위험도', color: '#ef4444' }, { key: 'alarms', name: '경보', color: '#3b82f6' }]} />
</CardContent></Card>
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-label mb-3"> </div>
<PieChart data={PIE} height={140} innerRadius={30} outerRadius={55} />
<div className="space-y-1 mt-2">{PIE.map(d => (
<div key={d.name} className="flex justify-between text-[10px]"><div className="flex items-center gap-1.5"><div className="w-2 h-2 rounded-full" style={{ backgroundColor: d.color }} /><span className="text-muted-foreground">{d.name}</span></div><span className="text-heading font-bold">{d.value}%</span></div>
))}</div>
</CardContent></Card>
</div>
<Card><CardContent className="p-4">
<div className="text-[12px] font-bold text-label mb-3 flex items-center gap-1.5"><Bell className="w-4 h-4 text-red-400" /> </div>
<div className="space-y-2">
{EVENTS.map((e, i) => (
<div key={i} className="flex items-center gap-3 px-3 py-2 bg-surface-overlay rounded-lg">
<span className="text-[10px] text-hint font-mono whitespace-nowrap shrink-0">{e.time}</span>
<Badge intent={getAlertLevelIntent(e.level)} size="sm" className="min-w-[52px]">
{getAlertLevelLabel(e.level, tc, lang)}
</Badge>
<span className="text-[11px] text-heading font-medium flex-1">{e.title}</span>
<span className="text-[10px] text-hint">{e.detail}</span>
</div>
))}
</div>
</CardContent></Card>
</PageContainer>
);
}