feat: Phase 4 — 비정상 항적 + 시스템 메트릭 페이지 (7/7 완성)
- AbnormalTracks: 유형별 통계, 일별 추이 차트, 검출 목록 테이블 - abnormalApi 클라이언트 (recent, summary, types) - ApiMetrics: 시스템 메트릭, 캐시 상세(L1/L2/L3/AIS), 처리 지연, 히트율 - 10초 폴링으로 실시간 갱신 - i18n: abnormal.* 17키 + metrics.* 21키 한/영 추가 - 전체 7개 페이지 라우팅 완성 (Navbar 메뉴 전부 활성) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
부모
8fafaad6c0
커밋
7a17d8e1d8
@ -10,6 +10,8 @@ const JobMonitor = lazy(() => import('./pages/JobMonitor.tsx'))
|
|||||||
const DataPipeline = lazy(() => import('./pages/DataPipeline.tsx'))
|
const DataPipeline = lazy(() => import('./pages/DataPipeline.tsx'))
|
||||||
const AreaStats = lazy(() => import('./pages/AreaStats.tsx'))
|
const AreaStats = lazy(() => import('./pages/AreaStats.tsx'))
|
||||||
const ApiExplorer = lazy(() => import('./pages/ApiExplorer.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'
|
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="pipeline" element={<Suspense fallback={<LoadingSpinner />}><DataPipeline /></Suspense>} />
|
||||||
<Route path="area-stats" element={<Suspense fallback={<LoadingSpinner />}><AreaStats /></Suspense>} />
|
<Route path="area-stats" element={<Suspense fallback={<LoadingSpinner />}><AreaStats /></Suspense>} />
|
||||||
<Route path="api-explorer" element={<Suspense fallback={<LoadingSpinner />}><ApiExplorer /></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>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
|
|||||||
52
frontend/src/api/abnormalApi.ts
Normal file
52
frontend/src/api/abnormalApi.ts
Normal file
@ -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.comingSoon': 'Detailed API Demo (Coming Soon)',
|
||||||
'explorer.comingSoonDesc': 'Request/Response panels, track layers, replay',
|
'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
|
// Time Range
|
||||||
'range.1d': '1D',
|
'range.1d': '1D',
|
||||||
'range.3d': '3D',
|
'range.3d': '3D',
|
||||||
|
|||||||
@ -131,6 +131,48 @@ const ko = {
|
|||||||
'explorer.comingSoon': '상세 API 시연 (향후 구현)',
|
'explorer.comingSoon': '상세 API 시연 (향후 구현)',
|
||||||
'explorer.comingSoonDesc': 'Request/Response 패널, 항적 레이어, 리플레이',
|
'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
|
// Time Range
|
||||||
'range.1d': '1일',
|
'range.1d': '1일',
|
||||||
'range.3d': '3일',
|
'range.3d': '3일',
|
||||||
|
|||||||
191
frontend/src/pages/AbnormalTracks.tsx
Normal file
191
frontend/src/pages/AbnormalTracks.tsx
Normal file
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
196
frontend/src/pages/ApiMetrics.tsx
Normal file
196
frontend/src/pages/ApiMetrics.tsx
Normal file
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
불러오는 중...
Reference in New Issue
Block a user