Merge pull request 'feat: Phase 4 — 비정상 항적 + 시스템 메트릭 (7/7 완성)' (#36) from feature/dashboard-phase-1 into develop

This commit is contained in:
htlee 2026-02-19 19:20:21 +09:00
커밋 97b71b16e1
6개의 변경된 파일527개의 추가작업 그리고 0개의 파일을 삭제

파일 보기

@ -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() {
<Route path="pipeline" element={<Suspense fallback={<LoadingSpinner />}><DataPipeline /></Suspense>} />
<Route path="area-stats" element={<Suspense fallback={<LoadingSpinner />}><AreaStats /></Suspense>} />
<Route path="api-explorer" element={<Suspense fallback={<LoadingSpinner />}><ApiExplorer /></Suspense>} />
<Route path="abnormal" element={<Suspense fallback={<LoadingSpinner />}><AbnormalTracks /></Suspense>} />
<Route path="metrics" element={<Suspense fallback={<LoadingSpinner />}><ApiMetrics /></Suspense>} />
</Route>
</Routes>
</ErrorBoundary>

파일 보기

@ -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<string, unknown>
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<AbnormalTrack[]> {
return fetchJson(`/api/v1/abnormal-tracks/recent?hours=${hours}`)
},
getStatisticsSummary(days = 7): Promise<AbnormalSummary> {
return fetchJson(`/api/v1/abnormal-tracks/statistics/summary?days=${days}`)
},
getTypes(): Promise<Record<string, string>> {
return fetchJson('/api/v1/abnormal-tracks/types')
},
}

파일 보기

@ -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',

파일 보기

@ -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일',

파일 보기

@ -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<string, string> = {
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<AbnormalTrack[]>([])
const [summary, setSummary] = useState<AbnormalSummary | null>(null)
const [typeFilter, setTypeFilter] = useState<string>('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 (
<div className="space-y-6 fade-in">
{/* Header */}
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">{t('abnormal.title')}</h1>
<div className="flex gap-1">
{HOURS_OPTIONS.map(h => (
<button
key={h}
onClick={() => setHours(h)}
className={`rounded-md px-3 py-1 text-xs font-medium transition ${
hours === h
? 'bg-primary text-white'
: 'bg-surface-hover text-muted hover:text-foreground'
}`}
>
{h}h
</button>
))}
</div>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
<MetricCard
title={t('abnormal.totalDetected')}
value={summary ? formatNumber(summary.total_tracks) : '-'}
subtitle={t('abnormal.last7days')}
/>
<MetricCard
title={t('abnormal.affectedVessels')}
value={summary ? formatNumber(summary.vessel_count) : '-'}
/>
<MetricCard
title={t('abnormal.typeCount')}
value={summary ? String(summary.type_count) : '-'}
/>
<MetricCard
title={t('abnormal.maxSpeed')}
value={summary ? `${summary.max_speed_detected?.toFixed(1) ?? '0'} kn` : '-'}
trend="down"
/>
</div>
{/* Type Filter + Daily Trend */}
<div className="grid gap-4 lg:grid-cols-2">
{/* Type Statistics */}
<div className="sb-card">
<div className="sb-card-header">{t('abnormal.byType')}</div>
<div className="space-y-2">
<button
onClick={() => setTypeFilter('all')}
className={`w-full rounded-md px-3 py-2 text-left text-sm transition ${
typeFilter === 'all'
? 'bg-primary/10 font-medium text-primary'
: 'hover:bg-surface-hover'
}`}
>
{t('abnormal.allTypes')} ({tracks.length})
</button>
{typeStats.map(ts => (
<button
key={ts.abnormal_type}
onClick={() => setTypeFilter(ts.abnormal_type)}
className={`flex w-full items-center justify-between rounded-md px-3 py-2 text-left text-sm transition ${
typeFilter === ts.abnormal_type
? 'bg-primary/10 font-medium text-primary'
: 'hover:bg-surface-hover'
}`}
>
<span className="flex items-center gap-2">
<span
className={`inline-block h-2 w-2 rounded-full bg-${TYPE_COLORS[ts.abnormal_type] ?? 'secondary'}`}
/>
{ts.abnormal_type}
</span>
<span className="text-xs text-muted">
{ts.count}{t('common.items')} / {ts.vessel_count}{t('abnormal.vessels')}
</span>
</button>
))}
</div>
</div>
{/* Daily Trend */}
<div className="sb-card">
<div className="sb-card-header">{t('abnormal.dailyTrend')}</div>
{summary && summary.dailyTrend?.length > 0 ? (
<BarChart
data={summary.dailyTrend.map(d => ({
date: d.date.slice(5),
count: d.count,
}))}
dataKey="count"
xKey="date"
height={220}
/>
) : (
<div className="py-8 text-center text-sm text-muted">{t('common.noData')}</div>
)}
</div>
</div>
{/* Track List */}
<div className="sb-card">
<div className="sb-card-header">
{t('abnormal.recentList')} ({filteredTracks.length})
</div>
{filteredTracks.length === 0 ? (
<div className="py-8 text-center text-sm text-muted">{t('common.noData')}</div>
) : (
<div className="overflow-x-auto">
<table className="sb-table">
<thead>
<tr>
<th>MMSI</th>
<th>{t('abnormal.type')}</th>
<th>{t('abnormal.time')}</th>
<th>{t('abnormal.distance')}</th>
<th>{t('abnormal.avgSpeedCol')}</th>
<th>{t('abnormal.maxSpeedCol')}</th>
<th>{t('abnormal.points')}</th>
</tr>
</thead>
<tbody>
{filteredTracks.slice(0, 50).map(track => (
<tr key={track.id}>
<td className="font-mono text-xs">{track.mmsi}</td>
<td>
<StatusBadge status={track.abnormalType} />
</td>
<td className="text-xs">{formatDateTime(track.timeBucket)}</td>
<td className="text-right">{track.distanceNm?.toFixed(1) ?? '-'} nm</td>
<td className="text-right">{track.avgSpeed?.toFixed(1) ?? '-'} kn</td>
<td className="text-right">{track.maxSpeed?.toFixed(1) ?? '-'} kn</td>
<td className="text-center">{track.pointCount ?? '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
)
}

파일 보기

@ -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<MetricsSummary | null>(null)
const [cache, setCache] = useState<CacheStats | null>(null)
const [cacheDetails, setCacheDetails] = useState<CacheDetails | null>(null)
const [delay, setDelay] = useState<ProcessingDelay | null>(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 (
<div className="space-y-6 fade-in">
<h1 className="text-2xl font-bold">{t('metrics.title')}</h1>
{/* System Metrics */}
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
<MetricCard
title={t('metrics.heapMemory')}
value={metrics ? `${memUsed}MB / ${memMax}MB` : '-'}
subtitle={metrics ? `${memPct}%` : undefined}
trend={memPct > 85 ? 'down' : memPct > 70 ? 'neutral' : 'up'}
/>
<MetricCard
title={t('metrics.threads')}
value={metrics ? String(metrics.threads) : '-'}
/>
<MetricCard
title={t('metrics.dbActive')}
value={metrics ? String(metrics.database.activeConnections) : '-'}
/>
<MetricCard
title={t('metrics.recordsPerSec')}
value={metrics ? `${(metrics.processing?.recordsPerSecond ?? 0).toFixed(0)}/s` : '-'}
/>
</div>
{/* Cache Detail Grid */}
<div className="sb-card">
<div className="sb-card-header">{t('metrics.cacheDetail')}</div>
{cacheDetails ? (
<div className="overflow-x-auto">
<table className="sb-table">
<thead>
<tr>
<th>{t('metrics.cacheLayer')}</th>
<th className="text-right">{t('metrics.size')}</th>
<th className="text-right">{t('metrics.maxSize')}</th>
<th className="text-right">{t('metrics.utilization')}</th>
<th className="text-right">{t('metrics.hitRate')}</th>
</tr>
</thead>
<tbody>
{cacheDetails.l1_fiveMin && (
<tr>
<td>L1 (5min)</td>
<td className="text-right">{formatNumber(cacheDetails.l1_fiveMin.size)}</td>
<td className="text-right">{formatNumber(cacheDetails.l1_fiveMin.maxSize)}</td>
<td className="text-right">
{((cacheDetails.l1_fiveMin.size / Math.max(cacheDetails.l1_fiveMin.maxSize, 1)) * 100).toFixed(1)}%
</td>
<td className="text-right">{cacheDetails.l1_fiveMin.hitRate?.toFixed(1) ?? '-'}%</td>
</tr>
)}
{cacheDetails.l2_hourly && (
<tr>
<td>L2 (Hourly)</td>
<td className="text-right">{formatNumber(cacheDetails.l2_hourly.size)}</td>
<td className="text-right">{formatNumber(cacheDetails.l2_hourly.maxSize)}</td>
<td className="text-right">
{((cacheDetails.l2_hourly.size / Math.max(cacheDetails.l2_hourly.maxSize, 1)) * 100).toFixed(1)}%
</td>
<td className="text-right">{cacheDetails.l2_hourly.hitRate?.toFixed(1) ?? '-'}%</td>
</tr>
)}
{cacheDetails.l3_daily && (
<tr>
<td>L3 (Daily)</td>
<td className="text-right">{cacheDetails.l3_daily.cachedDays ?? 0} days</td>
<td className="text-right">{cacheDetails.l3_daily.retentionDays ?? '-'} days</td>
<td className="text-right">
{cacheDetails.l3_daily.totalMemoryMb?.toFixed(0) ?? 0} MB
</td>
<td className="text-right">{cacheDetails.l3_daily.totalVessels ?? 0} vessels</td>
</tr>
)}
{cacheDetails.aisTarget && (
<tr>
<td>AIS Target</td>
<td className="text-right">{formatNumber(cacheDetails.aisTarget.estimatedSize)}</td>
<td className="text-right">{formatNumber(cacheDetails.aisTarget.maxSize)}</td>
<td className="text-right">
{((cacheDetails.aisTarget.estimatedSize / Math.max(cacheDetails.aisTarget.maxSize, 1)) * 100).toFixed(1)}%
</td>
<td className="text-right">{cacheDetails.aisTarget.hitRate?.toFixed(1) ?? '-'}%</td>
</tr>
)}
</tbody>
</table>
</div>
) : (
<div className="py-4 text-center text-sm text-muted">{t('common.loading')}</div>
)}
</div>
{/* Processing & Cache Summary */}
<div className="grid gap-4 lg:grid-cols-2">
<div className="sb-card">
<div className="sb-card-header">{t('metrics.processingDelay')}</div>
{delay ? (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<span className="text-muted">{t('metrics.delayMinutes')}</span>
<div className="text-2xl font-bold">{delay.delayMinutes ?? 0}<span className="text-sm text-muted"> min</span></div>
</div>
<div>
<span className="text-muted">{t('metrics.aisCount')}</span>
<div className="text-2xl font-bold">{formatNumber(delay.recentAisCount)}</div>
</div>
<div>
<span className="text-muted">{t('metrics.processedVessels')}</span>
<div className="font-bold">{formatNumber(delay.processedVessels)}</div>
</div>
<div>
<span className="text-muted">{t('metrics.status')}</span>
<div className={`font-bold ${
delay.status === 'NORMAL' ? 'text-success' :
delay.status === 'WARNING' ? 'text-warning' : 'text-danger'
}`}>{delay.status}</div>
</div>
</div>
</div>
) : (
<div className="py-4 text-center text-sm text-muted">{t('common.loading')}</div>
)}
</div>
<div className="sb-card">
<div className="sb-card-header">{t('metrics.cacheHitSummary')}</div>
{cache ? (
<div className="space-y-3">
<div className="grid grid-cols-3 gap-3 text-center text-sm">
<div>
<div className="text-2xl font-bold">{cache.hitRate}</div>
<div className="text-xs text-muted">{t('metrics.hitRate')}</div>
</div>
<div>
<div className="text-2xl font-bold">{formatNumber(cache.hitCount)}</div>
<div className="text-xs text-muted">{t('metrics.hits')}</div>
</div>
<div>
<div className="text-2xl font-bold">{formatNumber(cache.missCount)}</div>
<div className="text-xs text-muted">{t('metrics.misses')}</div>
</div>
</div>
</div>
) : (
<div className="py-4 text-center text-sm text-muted">{t('common.loading')}</div>
)}
</div>
</div>
{/* Placeholder for future DB-based metrics */}
<div className="sb-card border-dashed">
<div className="py-6 text-center text-sm text-muted">
<p>{t('metrics.dbMetricsPlaceholder')}</p>
<p className="mt-1 text-xs opacity-60">{t('metrics.dbMetricsDesc')}</p>
</div>
</div>
</div>
)
}