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 직접 분기를 타입 가드/헬퍼로 치환
134 lines
6.2 KiB
TypeScript
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>
|
|
);
|
|
}
|