diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 06ad9d5..414f610 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,6 +10,8 @@ const JobMonitor = lazy(() => import('./pages/JobMonitor.tsx')) const DataPipeline = lazy(() => import('./pages/DataPipeline.tsx')) const AreaStats = lazy(() => import('./pages/AreaStats.tsx')) const ApiExplorer = lazy(() => import('./pages/ApiExplorer.tsx')) +const AbnormalTracks = lazy(() => import('./pages/AbnormalTracks.tsx')) +const ApiMetrics = lazy(() => import('./pages/ApiMetrics.tsx')) const BASE_URL = import.meta.env.VITE_BASE_URL || '/signal-batch' @@ -53,6 +55,8 @@ export default function App() { }>} /> }>} /> }>} /> + }>} /> + }>} /> diff --git a/frontend/src/api/abnormalApi.ts b/frontend/src/api/abnormalApi.ts new file mode 100644 index 0000000..95c8502 --- /dev/null +++ b/frontend/src/api/abnormalApi.ts @@ -0,0 +1,52 @@ +import { fetchJson } from './httpClient.ts' + +export interface AbnormalTrack { + id: number + mmsi: string + timeBucket: string + abnormalType: string + typeDescription: string + abnormalDescription: string + distanceNm: number + avgSpeed: number + maxSpeed: number + pointCount: number + sourceTable: string + detectedAt: string + details: Record + trackGeoJson: { type: string; coordinates: number[][] } | null +} + +export interface AbnormalStats { + statDate: string + abnormalType: string + vesselCount: number + trackCount: number + totalPoints: number + avgDeviation: number + maxDeviation: number +} + +export interface AbnormalSummary { + type_count: number + total_tracks: number + vessel_count: number + avg_distance: number + max_speed_detected: number + typeStatistics: { abnormal_type: string; count: number; vessel_count: number }[] + dailyTrend: { date: string; count: number }[] +} + +export const abnormalApi = { + getRecent(hours = 24): Promise { + return fetchJson(`/api/v1/abnormal-tracks/recent?hours=${hours}`) + }, + + getStatisticsSummary(days = 7): Promise { + return fetchJson(`/api/v1/abnormal-tracks/statistics/summary?days=${days}`) + }, + + getTypes(): Promise> { + return fetchJson('/api/v1/abnormal-tracks/types') + }, +} diff --git a/frontend/src/i18n/en.ts b/frontend/src/i18n/en.ts index 01bc665..5fee23c 100644 --- a/frontend/src/i18n/en.ts +++ b/frontend/src/i18n/en.ts @@ -131,6 +131,48 @@ const en = { 'explorer.comingSoon': 'Detailed API Demo (Coming Soon)', 'explorer.comingSoonDesc': 'Request/Response panels, track layers, replay', + // Abnormal Tracks + 'abnormal.title': 'Abnormal Tracks', + 'abnormal.totalDetected': 'Total Detected', + 'abnormal.last7days': 'Last 7 days', + 'abnormal.affectedVessels': 'Affected Vessels', + 'abnormal.typeCount': 'Type Count', + 'abnormal.maxSpeed': 'Max Speed', + 'abnormal.byType': 'By Type', + 'abnormal.allTypes': 'All', + 'abnormal.vessels': ' vessels', + 'abnormal.dailyTrend': 'Daily Trend', + 'abnormal.recentList': 'Recent Detections', + 'abnormal.type': 'Type', + 'abnormal.time': 'Time', + 'abnormal.distance': 'Distance', + 'abnormal.avgSpeedCol': 'Avg Speed', + 'abnormal.maxSpeedCol': 'Max Speed', + 'abnormal.points': 'Points', + + // Metrics + 'metrics.title': 'System Metrics', + 'metrics.heapMemory': 'Heap Memory', + 'metrics.threads': 'Threads', + 'metrics.dbActive': 'DB Active Conn.', + 'metrics.recordsPerSec': 'Records/sec', + 'metrics.cacheDetail': 'Cache Details', + 'metrics.cacheLayer': 'Cache Layer', + 'metrics.size': 'Size', + 'metrics.maxSize': 'Max', + 'metrics.utilization': 'Utilization', + 'metrics.hitRate': 'Hit Rate', + 'metrics.processingDelay': 'Processing Delay', + 'metrics.delayMinutes': 'Delay', + 'metrics.aisCount': 'AIS Received', + 'metrics.processedVessels': 'Processed', + 'metrics.status': 'Status', + 'metrics.cacheHitSummary': 'Cache Hit Summary', + 'metrics.hits': 'Hits', + 'metrics.misses': 'Misses', + 'metrics.dbMetricsPlaceholder': 'API/WS History Metrics (Coming Soon)', + 'metrics.dbMetricsDesc': 'REST/WebSocket request history, response sizes, latency DB storage + query', + // Time Range 'range.1d': '1D', 'range.3d': '3D', diff --git a/frontend/src/i18n/ko.ts b/frontend/src/i18n/ko.ts index d9f79e1..7576d4f 100644 --- a/frontend/src/i18n/ko.ts +++ b/frontend/src/i18n/ko.ts @@ -131,6 +131,48 @@ const ko = { 'explorer.comingSoon': '상세 API 시연 (향후 구현)', 'explorer.comingSoonDesc': 'Request/Response 패널, 항적 레이어, 리플레이', + // Abnormal Tracks + 'abnormal.title': '비정상 항적', + 'abnormal.totalDetected': '총 검출', + 'abnormal.last7days': '최근 7일', + 'abnormal.affectedVessels': '영향 선박', + 'abnormal.typeCount': '유형 수', + 'abnormal.maxSpeed': '최대 속도', + 'abnormal.byType': '유형별 통계', + 'abnormal.allTypes': '전체', + 'abnormal.vessels': '척', + 'abnormal.dailyTrend': '일별 추이', + 'abnormal.recentList': '최근 검출 목록', + 'abnormal.type': '유형', + 'abnormal.time': '시간', + 'abnormal.distance': '거리', + 'abnormal.avgSpeedCol': '평균 속도', + 'abnormal.maxSpeedCol': '최대 속도', + 'abnormal.points': '포인트', + + // Metrics + 'metrics.title': '시스템 메트릭', + 'metrics.heapMemory': '힙 메모리', + 'metrics.threads': '스레드', + 'metrics.dbActive': 'DB 활성 연결', + 'metrics.recordsPerSec': '초당 처리', + 'metrics.cacheDetail': '캐시 상세', + 'metrics.cacheLayer': '캐시 계층', + 'metrics.size': '크기', + 'metrics.maxSize': '최대', + 'metrics.utilization': '사용률', + 'metrics.hitRate': '히트율', + 'metrics.processingDelay': '처리 지연', + 'metrics.delayMinutes': '지연 시간', + 'metrics.aisCount': 'AIS 수신', + 'metrics.processedVessels': '처리 선박', + 'metrics.status': '상태', + 'metrics.cacheHitSummary': '캐시 히트 요약', + 'metrics.hits': '히트', + 'metrics.misses': '미스', + 'metrics.dbMetricsPlaceholder': 'API/WS 이력 메트릭 (향후 구현)', + 'metrics.dbMetricsDesc': 'REST/WebSocket 요청 이력, 응답 크기, 소요시간 DB 저장 + 조회', + // Time Range 'range.1d': '1일', 'range.3d': '3일', diff --git a/frontend/src/pages/AbnormalTracks.tsx b/frontend/src/pages/AbnormalTracks.tsx new file mode 100644 index 0000000..f78f5f1 --- /dev/null +++ b/frontend/src/pages/AbnormalTracks.tsx @@ -0,0 +1,191 @@ +import { useState } from 'react' +import { usePoller } from '../hooks/usePoller.ts' +import { useI18n } from '../hooks/useI18n.ts' +import { abnormalApi } from '../api/abnormalApi.ts' +import type { AbnormalTrack, AbnormalSummary } from '../api/abnormalApi.ts' +import StatusBadge from '../components/common/StatusBadge.tsx' +import MetricCard from '../components/charts/MetricCard.tsx' +import BarChart from '../components/charts/BarChart.tsx' +import { formatNumber, formatDateTime } from '../utils/formatters.ts' + +const POLL_INTERVAL = 60_000 + +const TYPE_COLORS: Record = { + excessive_speed: 'danger', + teleport: 'warning', + gap_jump: 'info', + excessive_acceleration: 'secondary', + extreme_avg_speed_5min: 'danger', + user_detected: 'primary', +} + +const HOURS_OPTIONS = [6, 12, 24, 48, 72] + +export default function AbnormalTracks() { + const { t } = useI18n() + const [hours, setHours] = useState(24) + const [tracks, setTracks] = useState([]) + const [summary, setSummary] = useState(null) + const [typeFilter, setTypeFilter] = useState('all') + + usePoller(async () => { + const [tr, sm] = await Promise.allSettled([ + abnormalApi.getRecent(hours), + abnormalApi.getStatisticsSummary(7), + ]) + if (tr.status === 'fulfilled') setTracks(tr.value) + if (sm.status === 'fulfilled') setSummary(sm.value) + }, POLL_INTERVAL, [hours]) + + const filteredTracks = typeFilter === 'all' + ? tracks + : tracks.filter(t => t.abnormalType === typeFilter) + + const typeStats = summary?.typeStatistics ?? [] + + return ( +
+ {/* Header */} +
+

{t('abnormal.title')}

+
+ {HOURS_OPTIONS.map(h => ( + + ))} +
+
+ + {/* Summary Cards */} +
+ + + + +
+ + {/* Type Filter + Daily Trend */} +
+ {/* Type Statistics */} +
+
{t('abnormal.byType')}
+
+ + {typeStats.map(ts => ( + + ))} +
+
+ + {/* Daily Trend */} +
+
{t('abnormal.dailyTrend')}
+ {summary && summary.dailyTrend?.length > 0 ? ( + ({ + date: d.date.slice(5), + count: d.count, + }))} + dataKey="count" + xKey="date" + height={220} + /> + ) : ( +
{t('common.noData')}
+ )} +
+
+ + {/* Track List */} +
+
+ {t('abnormal.recentList')} ({filteredTracks.length}) +
+ {filteredTracks.length === 0 ? ( +
{t('common.noData')}
+ ) : ( +
+ + + + + + + + + + + + + + {filteredTracks.slice(0, 50).map(track => ( + + + + + + + + + + ))} + +
MMSI{t('abnormal.type')}{t('abnormal.time')}{t('abnormal.distance')}{t('abnormal.avgSpeedCol')}{t('abnormal.maxSpeedCol')}{t('abnormal.points')}
{track.mmsi} + + {formatDateTime(track.timeBucket)}{track.distanceNm?.toFixed(1) ?? '-'} nm{track.avgSpeed?.toFixed(1) ?? '-'} kn{track.maxSpeed?.toFixed(1) ?? '-'} kn{track.pointCount ?? '-'}
+
+ )} +
+
+ ) +} diff --git a/frontend/src/pages/ApiMetrics.tsx b/frontend/src/pages/ApiMetrics.tsx new file mode 100644 index 0000000..61d5e69 --- /dev/null +++ b/frontend/src/pages/ApiMetrics.tsx @@ -0,0 +1,196 @@ +import { useState } from 'react' +import { usePoller } from '../hooks/usePoller.ts' +import { useI18n } from '../hooks/useI18n.ts' +import { monitorApi } from '../api/monitorApi.ts' +import type { MetricsSummary, CacheStats, ProcessingDelay, CacheDetails } from '../api/types.ts' +import MetricCard from '../components/charts/MetricCard.tsx' +import { formatNumber } from '../utils/formatters.ts' + +const POLL_INTERVAL = 10_000 + +export default function ApiMetrics() { + const { t } = useI18n() + const [metrics, setMetrics] = useState(null) + const [cache, setCache] = useState(null) + const [cacheDetails, setCacheDetails] = useState(null) + const [delay, setDelay] = useState(null) + + usePoller(async () => { + const [m, c, cd, d] = await Promise.allSettled([ + monitorApi.getMetricsSummary(), + monitorApi.getCacheStats(), + monitorApi.getCacheDetails(), + monitorApi.getDelay(), + ]) + if (m.status === 'fulfilled') setMetrics(m.value) + if (c.status === 'fulfilled') setCache(c.value) + if (cd.status === 'fulfilled') setCacheDetails(cd.value) + if (d.status === 'fulfilled') setDelay(d.value) + }, POLL_INTERVAL) + + const memUsed = metrics?.memory.used ?? 0 + const memMax = metrics?.memory.max ?? 1 + const memPct = Math.round((memUsed / memMax) * 100) + + return ( +
+

{t('metrics.title')}

+ + {/* System Metrics */} +
+ 85 ? 'down' : memPct > 70 ? 'neutral' : 'up'} + /> + + + +
+ + {/* Cache Detail Grid */} +
+
{t('metrics.cacheDetail')}
+ {cacheDetails ? ( +
+ + + + + + + + + + + + {cacheDetails.l1_fiveMin && ( + + + + + + + + )} + {cacheDetails.l2_hourly && ( + + + + + + + + )} + {cacheDetails.l3_daily && ( + + + + + + + + )} + {cacheDetails.aisTarget && ( + + + + + + + + )} + +
{t('metrics.cacheLayer')}{t('metrics.size')}{t('metrics.maxSize')}{t('metrics.utilization')}{t('metrics.hitRate')}
L1 (5min){formatNumber(cacheDetails.l1_fiveMin.size)}{formatNumber(cacheDetails.l1_fiveMin.maxSize)} + {((cacheDetails.l1_fiveMin.size / Math.max(cacheDetails.l1_fiveMin.maxSize, 1)) * 100).toFixed(1)}% + {cacheDetails.l1_fiveMin.hitRate?.toFixed(1) ?? '-'}%
L2 (Hourly){formatNumber(cacheDetails.l2_hourly.size)}{formatNumber(cacheDetails.l2_hourly.maxSize)} + {((cacheDetails.l2_hourly.size / Math.max(cacheDetails.l2_hourly.maxSize, 1)) * 100).toFixed(1)}% + {cacheDetails.l2_hourly.hitRate?.toFixed(1) ?? '-'}%
L3 (Daily){cacheDetails.l3_daily.cachedDays ?? 0} days{cacheDetails.l3_daily.retentionDays ?? '-'} days + {cacheDetails.l3_daily.totalMemoryMb?.toFixed(0) ?? 0} MB + {cacheDetails.l3_daily.totalVessels ?? 0} vessels
AIS Target{formatNumber(cacheDetails.aisTarget.estimatedSize)}{formatNumber(cacheDetails.aisTarget.maxSize)} + {((cacheDetails.aisTarget.estimatedSize / Math.max(cacheDetails.aisTarget.maxSize, 1)) * 100).toFixed(1)}% + {cacheDetails.aisTarget.hitRate?.toFixed(1) ?? '-'}%
+
+ ) : ( +
{t('common.loading')}
+ )} +
+ + {/* Processing & Cache Summary */} +
+
+
{t('metrics.processingDelay')}
+ {delay ? ( +
+
+
+ {t('metrics.delayMinutes')} +
{delay.delayMinutes ?? 0} min
+
+
+ {t('metrics.aisCount')} +
{formatNumber(delay.recentAisCount)}
+
+
+ {t('metrics.processedVessels')} +
{formatNumber(delay.processedVessels)}
+
+
+ {t('metrics.status')} +
{delay.status}
+
+
+
+ ) : ( +
{t('common.loading')}
+ )} +
+ +
+
{t('metrics.cacheHitSummary')}
+ {cache ? ( +
+
+
+
{cache.hitRate}
+
{t('metrics.hitRate')}
+
+
+
{formatNumber(cache.hitCount)}
+
{t('metrics.hits')}
+
+
+
{formatNumber(cache.missCount)}
+
{t('metrics.misses')}
+
+
+
+ ) : ( +
{t('common.loading')}
+ )} +
+
+ + {/* Placeholder for future DB-based metrics */} +
+
+

{t('metrics.dbMetricsPlaceholder')}

+

{t('metrics.dbMetricsDesc')}

+
+
+
+ ) +}