Phase 1: 모노레포 디렉토리 구조 구축 - 기존 React 프로젝트를 frontend/ 디렉토리로 이동 (git mv) - backend/ 디렉토리 생성 (Phase 2에서 Spring Boot 초기화) - database/migration/ 디렉토리 생성 (Phase 2에서 Flyway 마이그레이션) - 루트 .gitignore에 frontend/, backend/ 경로 반영 - 루트 CLAUDE.md를 모노레포 가이드로 갱신 - Makefile 추가 (dev/build/lint 통합 명령) - frontend/vite.config.ts에 /api → :8080 백엔드 proxy 설정 - .githooks/pre-commit을 모노레포 구조에 맞게 갱신 (frontend/ 변경 시 frontend/ 내부에서 검증) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
105 lines
5.4 KiB
TypeScript
105 lines
5.4 KiB
TypeScript
import { useEffect } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { Card, CardContent } from '@shared/components/ui/card';
|
|
import { Badge } from '@shared/components/ui/badge';
|
|
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';
|
|
|
|
/* SFR-12: 모니터링 및 경보 현황판(대시보드) */
|
|
|
|
// KPI UI 매핑 (icon, color는 store에 없으므로 라벨 기반 매핑)
|
|
const KPI_UI_MAP: Record<string, { icon: LucideIcon; color: string }> = {
|
|
'실시간 탐지': { icon: Radar, color: '#3b82f6' },
|
|
'EEZ 침범': { icon: AlertTriangle, color: '#ef4444' },
|
|
'다크베셀': { icon: Eye, color: '#f97316' },
|
|
'불법환적 의심': { icon: Anchor, color: '#a855f7' },
|
|
'추적 중': { icon: Target, color: '#06b6d4' },
|
|
'나포/검문': { icon: Shield, color: '#10b981' },
|
|
};
|
|
const TREND = Array.from({ length: 24 }, (_, i) => ({ h: `${i}시`, risk: 30 + Math.floor(Math.random() * 50), alarms: Math.floor(Math.random() * 8) }));
|
|
// 위반 유형 → 차트 색상 매핑
|
|
const PIE_COLOR_MAP: Record<string, string> = {
|
|
'EEZ 침범': '#ef4444', '다크베셀': '#f97316', 'MMSI 변조': '#eab308',
|
|
'불법환적': '#a855f7', '어구 불법': '#6b7280',
|
|
};
|
|
const LV: Record<string, string> = { CRITICAL: 'text-red-400 bg-red-500/15', HIGH: 'text-orange-400 bg-orange-500/15', MEDIUM: 'text-yellow-400 bg-yellow-500/15', LOW: 'text-blue-400 bg-blue-500/15' };
|
|
|
|
export function MonitoringDashboard() {
|
|
const { t } = useTranslation('dashboard');
|
|
const kpiStore = useKpiStore();
|
|
const eventStore = useEventStore();
|
|
|
|
useEffect(() => { if (!kpiStore.loaded) kpiStore.load(); }, [kpiStore.loaded, kpiStore.load]);
|
|
useEffect(() => { if (!eventStore.loaded) eventStore.load(); }, [eventStore.loaded, eventStore.load]);
|
|
|
|
// KPI: store metrics + UI 매핑
|
|
const KPI = kpiStore.metrics.map((m) => ({
|
|
label: m.label,
|
|
value: m.value,
|
|
icon: KPI_UI_MAP[m.label]?.icon ?? Radar,
|
|
color: KPI_UI_MAP[m.label]?.color ?? '#3b82f6',
|
|
}));
|
|
|
|
// PIE: store violationTypes → 차트 데이터 변환
|
|
const PIE = kpiStore.violationTypes.map((v) => ({
|
|
name: v.type,
|
|
value: v.pct,
|
|
color: PIE_COLOR_MAP[v.type] ?? '#6b7280',
|
|
}));
|
|
|
|
// 이벤트: store events → 첫 6개, time 포맷 변환
|
|
const EVENTS = eventStore.events.slice(0, 6).map((e) => ({
|
|
time: e.time.includes(' ') ? e.time.split(' ')[1].slice(0, 5) : e.time,
|
|
level: e.level,
|
|
title: e.title,
|
|
detail: e.detail,
|
|
}));
|
|
|
|
return (
|
|
<div className="p-5 space-y-4">
|
|
<div>
|
|
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2"><Activity className="w-5 h-5 text-green-400" />{t('monitoring.title')}</h2>
|
|
<p className="text-[10px] text-hint mt-0.5">{t('monitoring.desc')}</p>
|
|
</div>
|
|
<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 w-10">{e.time}</span>
|
|
<Badge className={`border-0 text-[9px] w-16 text-center ${LV[e.level]}`}>{e.level}</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>
|
|
</div>
|
|
);
|
|
}
|